docs(grpc-events): trace the ExchangeKey token crypto — KDF=SHA256(secret); token construction localized

Frida-hooked Windows CNG (scripts/frida/aahclientmanaged-cng-exchangekey.js) during
a real native ExchangeKey to recover the token derivation:

- The ECDH + KDF are standard CNG driven by managed System.Security.Cryptography
  .ECDiffieHellmanCng: NCryptSecretAgreement (P-256) -> NCryptDeriveKey(KDF=HASH,
  SHA256, 32 bytes). So the derived key = SHA256(ECDH shared secret).
- "ECK1" is the standard CNG BCRYPT_ECCPUBLIC_BLOB magic (P-256), confirming our
  BuildExchangeKeyClientHello wire format.
- The 26-byte token (constant 0x8e marker) is a custom construction over the
  derived key: a 528-candidate offline cracker (HMAC/SHA/AES-GCM/CBC/CTR over the
  derived key x request slices x creds) found no match, and it matches none of the
  traced hash digests. It is built in aahClientManaged's C++/CLI <Module> code
  between the DeriveKeyMaterial call and the openParameters assembly.

Next: ILSpy cannot decompile the mixed-mode assembly (crashes, exit 70); use dnlib
(IL-level) to dump the <Module> method referencing DeriveKeyMaterial and read the
post-derive token construction. 2 of 3 layers cleared (key exchange + client key);
the 3rd (token) is localized, pending dnlib extraction. Orchestrator stays on v6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
Joseph Doherty
2026-06-23 11:11:21 -04:00
parent 3fd522fa10
commit b2ac35b98e
2 changed files with 208 additions and 9 deletions
@@ -245,12 +245,38 @@ Path B (ExchangeKey ECDH): 132/171 AuthenticationFailed "EstablishConnection
```
So the ECDH cleared the client-key check; the remaining blocker is **authentication**: the 26-byte
v8 credential token must be a *valid* value derived from the ECDH shared secret (not zeros). This is
the token KDF/cipher — the part that is not yet reverse-engineered and that would require analyzing
AVEVA's native ExchangeKey/credential crypto to recover the derivation (the .NET-shipped result stays
pure managed either way). The "Path B-lite" hypothesis (zeroed token rides through after key
agreement) is therefore disproven at the auth layer — 2 of 3 layers are cleared, the 3rd is the
credential-token derivation. ExchangeKey + the v8 serializer are committed and ready; the orchestrator
stays on v6 (set `eventConnection: true` to re-arm once the token KDF lands). The token-loop routing
guardrail (`HistorianGrpcHandshakeRoutingTests`) was scoped to the closure so the legitimate
ExchangeKey call is allowed while still pinning that the Negotiate token loop never routes there.
v8 credential token must be a *valid* value derived from the ECDH shared secret (not zeros).
### Token crypto traced 2026-06-23 (Frida → Windows CNG) — KDF found, token construction still open
Hooked Windows CNG (`bcrypt.dll`/`ncrypt.dll`) while the native harness ran a real ExchangeKey
(`scripts/frida/aahclientmanaged-cng-exchangekey.js` + `artifacts/.../cng-trace.py`). Findings:
- **The ECDH + KDF are standard CNG, driven by managed `System.Security.Cryptography.ECDiffieHellmanCng`**
(backtrace top frame = `System.Core.ni.dll`; the caller is aahClientManaged's C++/CLI `<Module>`):
`NCryptSecretAgreement` (P-256) → `NCryptDeriveKey(KDF=HASH, HASH_ALGORITHM=SHA256, 32 bytes)`. So the
derived key = **SHA256(ECDH shared secret)** — exactly `ECDiffieHellmanCng{ KeyDerivationFunction=Hash,
HashAlgorithm=SHA256 }.DeriveKeyMaterial(...)`. Our managed `DeriveExchangeKeySecret` should switch to
this (SHA256 of the raw agreement) to match.
- **`"ECK1"` is NOT AVEVA-custom** — it is the standard Windows CNG `BCRYPT_ECCPUBLIC_BLOB` magic for
P-256 (`NCryptExportKey`/`ImportKey` emit exactly `ECK1 + len(32) + X(32) + Y(32)`), confirming our
`BuildExchangeKeyClientHello` wire format is correct.
- **The 26-byte token is a custom construction that is not yet reproduced.** Correlated one run's
derived key (`SHA256(secret)`) with that run's token (from the IL openParameters capture): a
528-candidate offline cracker (HMAC/SHA/AES-GCM/CBC/CTR over the derived key × request slices ×
creds) found **no match**, and the token matches **none** of the traced hash digests. The token
starts with a constant `0x8e` marker in both captured runs (so it is structured, not raw cipher
output). It is built in managed code between the `DeriveKeyMaterial` call and the openParameters
assembly.
**Next step:** ILSpy cannot decompile the mixed-mode assembly (full-assembly and `<Module>` both crash,
exit 70). Use **dnlib** (IL-level, won't choke on the native parts) to dump the `<Module>` method that
references `ECDiffieHellmanCng.DeriveKeyMaterial` and read the post-derive token construction, then
implement it managed-side and re-test (non-destructive).
**2 of 3 layers cleared** (key exchange + client key); the 3rd (token construction) is localized to a
specific managed method, pending dnlib extraction. ExchangeKey + the v8 serializer are committed; the
orchestrator stays on v6 (set `eventConnection: true` to re-arm once the token construction lands). The
token-loop routing guardrail (`HistorianGrpcHandshakeRoutingTests`) was scoped to the closure so the
legitimate ExchangeKey call is allowed while still pinning that the Negotiate token loop never routes
there.
@@ -0,0 +1,173 @@
// Frida hook for the native ExchangeKey credential-token crypto (Windows CNG / bcrypt.dll).
// Traces the ECDH secret agreement, the KDF (with its parameter list), symmetric-key import, and
// encrypt/hash so the 26-byte v8 credential-token derivation can be reconstructed in managed code.
// Reverse-engineering aid only — observes the native client; nothing is shipped from here.
'use strict';
function resolve(modName, fnName) {
let m = null;
try { m = Process.getModuleByName(modName); } catch (e) {
try { m = Module.load(modName); } catch (e2) { return null; }
}
try { return m.findExportByName(fnName); } catch (e) { return null; }
}
function dump(label, ptr, len) {
if (ptr.isNull() || len <= 0) { console.log(label + ' <empty>'); return; }
const n = Math.min(len, 256);
console.log(label + ' (' + len + ' bytes)\n' + hexdump(ptr, { length: n, header: false, ansi: false }));
}
function hook(modName, fnName, onEnter, onLeave) {
const addr = resolve(modName, fnName);
if (!addr) { console.log('[skip] ' + modName + '!' + fnName + ' not found'); return; }
Interceptor.attach(addr, { onEnter: onEnter, onLeave: onLeave });
console.log('[hooked] ' + modName + '!' + fnName);
}
// BCryptOpenAlgorithmProvider(phAlgorithm, pszAlgId, pszImplementation, dwFlags) — names every algo used.
hook('bcrypt.dll', 'BCryptOpenAlgorithmProvider', function (a) {
console.log('[OpenAlgorithmProvider] algId=' + (a[1].isNull() ? '?' : a[1].readUtf16String()));
});
// BCryptSecretAgreement(hPrivKey, hPubKey, *phAgreedSecret, flags)
hook('bcrypt.dll', 'BCryptSecretAgreement', function (a) {
console.log('[SecretAgreement] hPriv=' + a[0] + ' hPub=' + a[1]);
});
// Decode a BCryptBufferDesc parameter list (used by BCryptDeriveKey) into (type -> bytes).
function dumpParamList(pParamList) {
if (pParamList.isNull()) { console.log(' paramList <null>'); return; }
const cBuffers = pParamList.add(4).readU32(); // ULONG ulVersion; ULONG cBuffers;
const pBuffers = pParamList.add(8).readPointer(); // BCryptBuffer* pBuffers;
const names = { 0: 'HASH_ALGORITHM', 1: 'SECRET_PREPEND', 2: 'SECRET_APPEND', 3: 'HMAC_KEY',
4: 'TLS_PRF_LABEL', 5: 'TLS_PRF_SEED', 6: 'SECRET_HANDLE', 8: 'SP80056A_CONCAT',
0xD: 'LABEL', 0xE: 'CONTEXT', 0xF: 'SALT', 0x10: 'ITERATION_COUNT' };
console.log(' paramList cBuffers=' + cBuffers);
for (let i = 0; i < cBuffers; i++) {
const b = pBuffers.add(i * 16); // { ULONG cbBuffer; ULONG BufferType; PVOID pvBuffer; }
const cb = b.readU32();
const type = b.add(4).readU32();
const pv = b.add(8).readPointer();
const tn = names[type] || ('0x' + type.toString(16));
if (type === 0 || type === 4 || type === 0xD) { // string-ish (hash alg name / label)
console.log(' [' + tn + '] ' + (pv.isNull() ? '?' : pv.readUtf16String()));
} else {
dump(' [' + tn + ']', pv, cb);
}
}
}
// BCryptDeriveKey(hSecret, pwszKDF, *pParamList, pbDerivedKey, cbDerivedKey, *pcbResult, flags)
hook('bcrypt.dll', 'BCryptDeriveKey', function (a) {
this.kdf = a[1].isNull() ? '?' : a[1].readUtf16String();
this.outKey = a[3]; this.pcb = a[5];
console.log('[DeriveKey] KDF=' + this.kdf + ' cbDerivedKey=' + a[4].toInt32());
dumpParamList(a[2]);
}, function () {
const n = this.pcb.isNull() ? 0 : this.pcb.readU32();
dump('[DeriveKey] derived', this.outKey, n);
});
hook('bcrypt.dll', 'BCryptDeriveKeyPBKDF2', function (a) {
console.log('[PBKDF2] cbPassword=' + a[2].toInt32() + ' cbSalt=' + a[4].toInt32() + ' iter=' + a[5]);
dump(' password', a[1], a[2].toInt32());
dump(' salt', a[3], a[4].toInt32());
});
// BCryptGenerateSymmetricKey(hAlg, *phKey, pbKeyObject, cbKeyObject, pbSecret, cbSecret, flags) — the actual key bytes.
hook('bcrypt.dll', 'BCryptGenerateSymmetricKey', function (a) {
dump('[GenerateSymmetricKey] keyBytes', a[4], a[5].toInt32());
});
// BCryptEncrypt(hKey, pbIn, cbIn, *pPad, pbIV, cbIV, pbOut, cbOut, *pcbResult, flags)
hook('bcrypt.dll', 'BCryptEncrypt', function (a) {
this.out = a[6]; this.pcb = a[8];
dump('[Encrypt] plaintext', a[1], a[2].toInt32());
dump('[Encrypt] IV', a[4], a[5].toInt32());
}, function () {
const n = this.pcb.isNull() ? 0 : this.pcb.readU32();
dump('[Encrypt] ciphertext', this.out, n);
});
// Hash path (in case the token is a keyed hash rather than a cipher).
hook('bcrypt.dll', 'BCryptHashData', function (a) {
dump('[HashData] input', a[1], a[2].toInt32());
});
hook('bcrypt.dll', 'BCryptFinishHash', function (a) {
this.out = a[1]; this.cb = a[2].toInt32();
}, function () {
dump('[FinishHash] digest', this.out, this.cb);
});
// ---- NCrypt (CNG key-storage layer) — the likely home of the ECDH ExchangeKey + token crypto ----
// NCryptSecretAgreement(hPrivKey, hPubKey, *phAgreedSecret, dwFlags)
hook('ncrypt.dll', 'NCryptSecretAgreement', function (a) {
console.log('[NCryptSecretAgreement] hPriv=' + a[0] + ' hPub=' + a[1]);
console.log(' backtrace (addr -> module+offset):');
Thread.backtrace(this.context, Backtracer.ACCURATE).slice(0, 14).forEach(function (addr) {
const m = Process.findModuleByAddress(addr);
if (m) {
console.log(' ' + addr + ' ' + m.name + '+0x' + addr.sub(m.base).toString(16));
} else {
console.log(' ' + addr + ' <JIT/unknown>');
}
});
});
// NCryptDeriveKey(hSharedSecret, pwszKDF, *pParameterList, pbDerivedKey, cbDerivedKey, *pcbResult, dwFlags)
hook('ncrypt.dll', 'NCryptDeriveKey', function (a) {
this.kdf = a[1].isNull() ? '?' : a[1].readUtf16String();
this.outKey = a[3]; this.pcb = a[5];
console.log('[NCryptDeriveKey] KDF=' + this.kdf + ' cbDerivedKey=' + a[4].toInt32());
dumpParamList(a[2]);
}, function () {
const n = this.pcb.isNull() ? 0 : this.pcb.readU32();
dump('[NCryptDeriveKey] derived', this.outKey, n);
});
// NCryptEncrypt(hKey, pbInput, cbInput, *pPaddingInfo, pbOutput, cbOutput, *pcbResult, dwFlags)
hook('ncrypt.dll', 'NCryptEncrypt', function (a) {
this.out = a[4]; this.pcb = a[6];
dump('[NCryptEncrypt] plaintext', a[1], a[2].toInt32());
}, function () {
const n = this.pcb.isNull() ? 0 : this.pcb.readU32();
dump('[NCryptEncrypt] ciphertext', this.out, n);
});
// NCryptImportKey(hProvider, hImportKey, pszBlobType, *pParameterList, *phKey, pbData, cbData, dwFlags)
hook('ncrypt.dll', 'NCryptImportKey', function (a) {
console.log('[NCryptImportKey] blobType=' + (a[2].isNull() ? '?' : a[2].readUtf16String()));
dump(' blob', a[5], a[6].toInt32());
});
// NCryptExportKey(hKey, hExportKey, pszBlobType, *pParameterList, pbOutput, cbOutput, *pcbResult, dwFlags)
hook('ncrypt.dll', 'NCryptExportKey', function (a) {
this.blobType = a[2].isNull() ? '?' : a[2].readUtf16String();
this.out = a[4]; this.pcb = a[6];
}, function () {
const n = this.pcb.isNull() ? 0 : this.pcb.readU32();
console.log('[NCryptExportKey] blobType=' + this.blobType);
dump(' blob', this.out, n);
});
hook('ncrypt.dll', 'NCryptOpenStorageProvider', function (a) {
console.log('[NCryptOpenStorageProvider] ' + (a[1].isNull() ? '?' : a[1].readUtf16String()));
});
// BCrypt EC key operations (in case the ECDH is bcrypt but uses import/export rather than DeriveKey).
hook('bcrypt.dll', 'BCryptImportKeyPair', function (a) {
console.log('[BCryptImportKeyPair] blobType=' + (a[2].isNull() ? '?' : a[2].readUtf16String()) + ' cb=' + a[5].toInt32());
dump(' blob', a[4], a[5].toInt32());
});
hook('bcrypt.dll', 'BCryptExportKey', function (a) {
this.blobType = a[2].isNull() ? '?' : a[2].readUtf16String();
this.out = a[3]; this.pcb = a[5];
}, function () {
const n = this.pcb.isNull() ? 0 : this.pcb.readU32();
console.log('[BCryptExportKey] blobType=' + this.blobType);
dump(' blob', this.out, n);
});
console.log('=== CNG ExchangeKey crypto hooks installed ===');