Initial commit: managed .NET 10 AVEVA Historian SDK + reverse-engineering toolkit
Full read-only SDK (src/AVEVA.Historian.Client) implementing the CLAUDE.md required
surface against AVEVA Historian's binary WCF protocol — no native AVEVA runtime
dependency. All operations live-verified against a local Historian:
- ProbeAsync, ReadRawAsync, ReadAggregateAsync, ReadAtTimeAsync, ReadEventsAsync
- BrowseTagNamesAsync, GetTagMetadataAsync (17 native data-type codes mapped)
- GetConnectionStatusAsync, GetStoreForwardStatusAsync, GetSystemParameterAsync
- 108/108 unit + integration tests pass
Includes the reverse-engineering toolkit (tools/AVEVA.Historian.ReverseEngineering)
used to decode the protocol: WCF probes, IL inspection via dnlib, and IL-rewrite
instrumentation (instrument-wcf-{write,read}message etc.) plus the .NET Framework
trace harness (tools/AVEVA.Historian.NativeTraceHarness) for parity testing.
Sanitized handoff evidence under docs/reverse-engineering/. Native AVEVA binaries
(current/, aveva-install-x64/, aveva-install-x86/) are gitignored — fetch separately
from the AVEVA installer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
param(
|
||||
[string]$TagName = "OtOpcUaParityTest_001.Counter",
|
||||
[string]$ServerName = "localhost",
|
||||
[ValidateSet("history", "event")]
|
||||
[string]$Scenario = "history",
|
||||
[int]$LookbackMinutes = 1440,
|
||||
[int]$MaxRows = 1,
|
||||
[int]$ConnectionWaitSeconds = 15,
|
||||
[int]$AttachWindowSeconds = 30,
|
||||
[switch]$DirectConnection,
|
||||
[string]$OutputPath = $null,
|
||||
[string]$PointerPath = $null
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$harness = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\bin\Debug\net481\AVEVA.Historian.NativeTraceHarness.exe"
|
||||
if (-not (Test-Path -LiteralPath $harness)) {
|
||||
throw "Missing harness executable. Run dotnet build .\Histsdk.slnx first."
|
||||
}
|
||||
|
||||
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||
$OutputPath = Join-Path $repoRoot "docs\reverse-engineering\runtime-pointer-frida-$Scenario-$stamp.ndjson"
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($PointerPath)) {
|
||||
$PointerPath = Join-Path $repoRoot "docs\reverse-engineering\runtime-method-pointers-before-$Scenario-start-latest.json"
|
||||
}
|
||||
|
||||
$outputDirectory = Split-Path -Parent $OutputPath
|
||||
$pointerDirectory = Split-Path -Parent $PointerPath
|
||||
New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $pointerDirectory | Out-Null
|
||||
Remove-Item -LiteralPath $PointerPath -Force -ErrorAction SilentlyContinue
|
||||
|
||||
$childOut = Join-Path $env:TEMP ("histsdk-runtime-pointer-{0}.out.log" -f ([Guid]::NewGuid().ToString("N")))
|
||||
$childErr = Join-Path $env:TEMP ("histsdk-runtime-pointer-{0}.err.log" -f ([Guid]::NewGuid().ToString("N")))
|
||||
$fridaScript = Join-Path $env:TEMP ("histsdk-runtime-pointer-{0}.js" -f ([Guid]::NewGuid().ToString("N")))
|
||||
|
||||
$childArgs = @(
|
||||
"--scenario", $Scenario,
|
||||
"--server-name", $ServerName,
|
||||
"--tag", $TagName,
|
||||
"--lookback-minutes", $LookbackMinutes.ToString(),
|
||||
"--max-rows", $MaxRows.ToString(),
|
||||
"--connection-wait-seconds", $ConnectionWaitSeconds.ToString(),
|
||||
"--runtime-method-pointer-output", $PointerPath,
|
||||
"--runtime-method-pointer-filters", "StartDataQuery;StartQuery;GetNextRow;StartEventQuery",
|
||||
"--pre-start-sleep-seconds", $AttachWindowSeconds.ToString()
|
||||
)
|
||||
|
||||
if ($DirectConnection) {
|
||||
$childArgs += "--direct-connection"
|
||||
}
|
||||
|
||||
Write-Host "Starting native trace harness. It will pause $AttachWindowSeconds second(s) before StartQuery."
|
||||
$process = Start-Process -FilePath $harness -ArgumentList $childArgs -WorkingDirectory $repoRoot -RedirectStandardOutput $childOut -RedirectStandardError $childErr -PassThru -WindowStyle Hidden
|
||||
|
||||
try {
|
||||
$deadline = (Get-Date).AddSeconds([Math]::Max($ConnectionWaitSeconds + 20, 30))
|
||||
while (-not (Test-Path -LiteralPath $PointerPath)) {
|
||||
if ($process.HasExited) {
|
||||
throw "Harness exited before writing runtime pointer snapshot."
|
||||
}
|
||||
|
||||
if ((Get-Date) -gt $deadline) {
|
||||
throw "Timed out waiting for runtime pointer snapshot: $PointerPath"
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
|
||||
$snapshot = Get-Content -LiteralPath $PointerPath -Raw | ConvertFrom-Json
|
||||
$targets = @()
|
||||
foreach ($group in $snapshot.MethodPointers) {
|
||||
foreach ($method in $group.Methods) {
|
||||
if ($null -eq $method.FunctionPointer -or $null -ne $method.PrepareError) {
|
||||
continue
|
||||
}
|
||||
|
||||
$targets += [pscustomobject]@{
|
||||
Name = "$($method.DeclaringType).$($method.Name)"
|
||||
Address = $method.FunctionPointer
|
||||
MetadataToken = $method.MetadataToken
|
||||
Filter = $group.Filter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$targets = $targets | Sort-Object Address -Unique
|
||||
if ($targets.Count -eq 0) {
|
||||
throw "Runtime pointer snapshot did not contain hookable function pointers."
|
||||
}
|
||||
|
||||
$targetJson = $targets | ConvertTo-Json -Depth 6 -Compress
|
||||
$script = @"
|
||||
'use strict';
|
||||
|
||||
const targets = $targetJson;
|
||||
const maxDumpBytes = 256;
|
||||
|
||||
function emit(kind, payload) {
|
||||
payload.kind = kind;
|
||||
payload.pid = Process.id;
|
||||
payload.tid = Process.getCurrentThreadId();
|
||||
payload.timestamp = new Date().toISOString();
|
||||
console.log('FRIDA_EVENT ' + JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function toHex(bytes) {
|
||||
const parts = [];
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const text = bytes[i].toString(16);
|
||||
parts.push(text.length === 1 ? '0' + text : text);
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function asciiPreview(bytes) {
|
||||
let text = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const b = bytes[i];
|
||||
text += b >= 32 && b <= 126 ? String.fromCharCode(b) : '.';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function utf16Preview(bytes) {
|
||||
let text = '';
|
||||
const count = Math.min(bytes.length - (bytes.length % 2), 96);
|
||||
for (let i = 0; i < count; i += 2) {
|
||||
const code = bytes[i] | (bytes[i + 1] << 8);
|
||||
text += code >= 32 && code <= 126 ? String.fromCharCode(code) : '.';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function isReadablePointer(value) {
|
||||
if (value.isNull() || value.compare(ptr('0x10000')) < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const range = Process.findRangeByAddress(value);
|
||||
return range !== null && range.protection.indexOf('r') !== -1;
|
||||
}
|
||||
|
||||
function dumpPointer(value) {
|
||||
const range = Process.findRangeByAddress(value);
|
||||
const available = range === null ? 0 : Math.min(maxDumpBytes, range.base.add(range.size).sub(value).toNumber());
|
||||
if (available <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = Memory.readByteArray(value, available);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = Array.from(new Uint8Array(raw));
|
||||
return {
|
||||
address: value.toString(),
|
||||
rangeBase: range.base.toString(),
|
||||
rangeSize: range.size,
|
||||
protection: range.protection,
|
||||
byteCount: bytes.length,
|
||||
hexPrefix: toHex(bytes.slice(0, Math.min(bytes.length, 96))),
|
||||
asciiPrefix: asciiPreview(bytes.slice(0, Math.min(bytes.length, 96))),
|
||||
utf16Prefix: utf16Preview(bytes.slice(0, Math.min(bytes.length, 192)))
|
||||
};
|
||||
}
|
||||
|
||||
function inspectArgs(args, count) {
|
||||
const inspected = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const value = args[i];
|
||||
const item = { index: i, value: value.toString() };
|
||||
if (isReadablePointer(value)) {
|
||||
try {
|
||||
item.memory = dumpPointer(value);
|
||||
} catch (e) {
|
||||
item.memoryError = String(e);
|
||||
}
|
||||
}
|
||||
inspected.push(item);
|
||||
}
|
||||
return inspected;
|
||||
}
|
||||
|
||||
emit('runtime-pointer-startup', {
|
||||
arch: Process.arch,
|
||||
platform: Process.platform,
|
||||
targetCount: targets.length,
|
||||
targets: targets
|
||||
});
|
||||
|
||||
for (const target of targets) {
|
||||
const address = ptr(target.Address);
|
||||
try {
|
||||
Interceptor.attach(address, {
|
||||
onEnter(args) {
|
||||
this.target = target;
|
||||
this.argsSnapshot = inspectArgs(args, 14);
|
||||
emit('enter', {
|
||||
function: target.Name,
|
||||
address: target.Address,
|
||||
metadataToken: target.MetadataToken,
|
||||
filter: target.Filter,
|
||||
args: this.argsSnapshot
|
||||
});
|
||||
},
|
||||
onLeave(retval) {
|
||||
emit('leave', {
|
||||
function: this.target.Name,
|
||||
address: this.target.Address,
|
||||
retval: retval.toString(),
|
||||
args: this.argsSnapshot
|
||||
});
|
||||
}
|
||||
});
|
||||
emit('hooked', target);
|
||||
} catch (e) {
|
||||
emit('hook-error', {
|
||||
function: target.Name,
|
||||
address: target.Address,
|
||||
metadataToken: target.MetadataToken,
|
||||
error: String(e)
|
||||
});
|
||||
}
|
||||
}
|
||||
"@
|
||||
|
||||
Set-Content -LiteralPath $fridaScript -Value $script -Encoding ASCII
|
||||
Write-Host "Attaching Frida to PID $($process.Id). Capture: $OutputPath"
|
||||
& frida -q -p $process.Id -l $fridaScript 2>&1 | Tee-Object -FilePath $OutputPath
|
||||
|
||||
if (-not $process.HasExited) {
|
||||
$process.WaitForExit(30000) | Out-Null
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (-not $process.HasExited) {
|
||||
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
"--- pointer snapshot ---" | Tee-Object -FilePath $OutputPath -Append
|
||||
Get-Content -LiteralPath $PointerPath -ErrorAction SilentlyContinue | Tee-Object -FilePath $OutputPath -Append
|
||||
"--- native stdout ---" | Tee-Object -FilePath $OutputPath -Append
|
||||
Get-Content -LiteralPath $childOut -ErrorAction SilentlyContinue | Tee-Object -FilePath $OutputPath -Append
|
||||
"--- native stderr ---" | Tee-Object -FilePath $OutputPath -Append
|
||||
Get-Content -LiteralPath $childErr -ErrorAction SilentlyContinue | Tee-Object -FilePath $OutputPath -Append
|
||||
Remove-Item -LiteralPath $childOut, $childErr, $fridaScript -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host "Capture complete: $OutputPath"
|
||||
Reference in New Issue
Block a user