diff --git a/docs/reverse-engineering/grpc-event-query-capture.md b/docs/reverse-engineering/grpc-event-query-capture.md index a95e4a8..837f07f 100644 --- a/docs/reverse-engineering/grpc-event-query-capture.md +++ b/docs/reverse-engineering/grpc-event-query-capture.md @@ -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 ``): + `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 `` both crash, +exit 70). Use **dnlib** (IL-level, won't choke on the native parts) to dump the `` 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. diff --git a/scripts/frida/aahclientmanaged-cng-exchangekey.js b/scripts/frida/aahclientmanaged-cng-exchangekey.js new file mode 100644 index 0000000..8acb6d6 --- /dev/null +++ b/scripts/frida/aahclientmanaged-cng-exchangekey.js @@ -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 + ' '); 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 '); 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 + ' '); + } + }); +}); + +// 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 ===');