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"