Files
histsdk/scripts/Attach-NativeTraceHarnessRuntimePointerCapture.ps1
dohertj2 c95824a65d 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>
2026-05-04 06:31:48 -04:00

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"