c95824a65d
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>
257 lines
8.3 KiB
PowerShell
257 lines
8.3 KiB
PowerShell
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"
|