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,215 @@
|
||||
'use strict';
|
||||
|
||||
const moduleName = 'aahClient.dll';
|
||||
const maxDumpBytes = 256;
|
||||
const interestingExportFragments = [
|
||||
'mdas_OpenConnection',
|
||||
'mdas_CloseConnection',
|
||||
'mdas_StartDataRetrievalQuery',
|
||||
'mdas_GetNextDataQueryResult',
|
||||
'mdas_StartEventDataRetrievalQuery',
|
||||
'mdas_GetNextEventDataQueryResult',
|
||||
'mdas_StartBlockRetrievalQuery',
|
||||
'mdas_GetNextBlockQueryResult',
|
||||
'mdas_EndQuery'
|
||||
];
|
||||
|
||||
let hooksInstalled = false;
|
||||
|
||||
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), 128);
|
||||
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, 128))),
|
||||
asciiPrefix: asciiPreview(bytes.slice(0, Math.min(bytes.length, 128))),
|
||||
utf16Prefix: utf16Preview(bytes.slice(0, Math.min(bytes.length, 256)))
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function isInterestingExport(name) {
|
||||
for (const fragment of interestingExportFragments) {
|
||||
if (name.indexOf(fragment) !== -1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function installHooks() {
|
||||
if (hooksInstalled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let module = null;
|
||||
try {
|
||||
module = Process.getModuleByName(moduleName);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
hooksInstalled = true;
|
||||
const exports = module.enumerateExports().filter(e => e.type === 'function' && isInterestingExport(e.name));
|
||||
emit('module-loaded', {
|
||||
module: module.name,
|
||||
base: module.base.toString(),
|
||||
size: module.size,
|
||||
path: module.path,
|
||||
exportCount: exports.length,
|
||||
exports: exports.map(e => ({ name: e.name, address: e.address.toString() }))
|
||||
});
|
||||
|
||||
for (const exported of exports) {
|
||||
try {
|
||||
Interceptor.attach(exported.address, {
|
||||
onEnter(args) {
|
||||
this.name = exported.name;
|
||||
this.address = exported.address.toString();
|
||||
this.argsSnapshot = inspectArgs(args, 18);
|
||||
emit('enter', {
|
||||
function: this.name,
|
||||
address: this.address,
|
||||
args: this.argsSnapshot
|
||||
});
|
||||
},
|
||||
onLeave(retval) {
|
||||
emit('leave', {
|
||||
function: this.name,
|
||||
address: this.address,
|
||||
retval: retval.toString(),
|
||||
args: this.argsSnapshot
|
||||
});
|
||||
}
|
||||
});
|
||||
emit('hooked', { function: exported.name, address: exported.address.toString() });
|
||||
} catch (e) {
|
||||
emit('hook-error', { function: exported.name, address: exported.address.toString(), error: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isInterestingModule(module) {
|
||||
const name = module.name.toLowerCase();
|
||||
const path = module.path.toLowerCase();
|
||||
return name.indexOf('aah') !== -1 || path.indexOf('histsdk') !== -1 || path.indexOf('aveva') !== -1;
|
||||
}
|
||||
|
||||
emit('startup', {
|
||||
arch: Process.arch,
|
||||
platform: Process.platform,
|
||||
modules: Process.enumerateModules()
|
||||
.filter(isInterestingModule)
|
||||
.map(m => ({ name: m.name, base: m.base.toString(), size: m.size, path: m.path }))
|
||||
});
|
||||
|
||||
try {
|
||||
Process.attachModuleObserver({
|
||||
onAdded(module) {
|
||||
if (isInterestingModule(module)) {
|
||||
emit('module-added', {
|
||||
name: module.name,
|
||||
base: module.base.toString(),
|
||||
size: module.size,
|
||||
path: module.path
|
||||
});
|
||||
}
|
||||
|
||||
installHooks();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
emit('module-observer-error', { error: String(e) });
|
||||
}
|
||||
|
||||
if (!installHooks()) {
|
||||
const timer = setInterval(() => {
|
||||
if (installHooks()) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
Reference in New Issue
Block a user