From 8127aece41f11423acc9231f6ab40ef34fc6f247 Mon Sep 17 00:00:00 2001 From: psycep Date: Thu, 23 Apr 2026 14:52:39 -0500 Subject: [PATCH] drsuapi: fix DsGetNCChanges V6 parser to actually extract NTDS hashes Rewrites the hand-rolled DRS_MSG_GETCHGREPLY_V6 parser as a declarative NDR struct walker. The old parser used empirical byte offsets that were wrong in several places and drifted out of alignment within the first few REPLENTINFLIST entries, so secretsdump emitted zero NTDS hashes against any modern DC (issue #9). New files: pkg/dcerpc/drsuapi/ndr.go - NDR Decoder primitives: alignment, conformant arrays, pointer referents, UTF-16LE strings, GUIDs, with a sticky error model so partial results survive downstream faults. pkg/dcerpc/drsuapi/getncchanges_v6.go - V6/V7/V9 reply parser driven by MS-DRSR struct definitions. Correctness points that were wrong in the old parser: 1. V6 fixed header is 148 bytes, not 136. The old code omitted cNumValues + rgValues + dwDRSError and treated those 12 bytes as pNC's DSNAME. 2. DSNAME.StringName is a pure conformant array (NDRUniConformantArray), not conformant-varying. No Offset/ActualCount between NameLen and the WCHAR elements. 3. UPTODATE_VECTOR is V2_EXT on current DCs: 32-byte cursors (UUID + USN + DSTIME), not the V1 24-byte cursors the old parser hard-coded. 4. REPLENTINFLIST is serialized as a linked list via pNextEntInf. NDR uses strict DFS through the first-encountered pointer in each struct, which lays out all N fixed parts consecutively, then the non-pNext deferreds (pName, pAttr, pParent, pMeta) unwind bottom-up - the deepest node's appear first, the head's last. The parser iterates captured headers in reverse to match. 5. PROPERTY_META_DATA_EXT_VECTOR alignment: the array's MaxCount is hoisted to the front by NDR early conformance, but hoisted MaxCount uses only its primitive (4-byte) alignment. The struct's 8-byte alignment applies AFTER MaxCount, before cNumProps and the elements. Getting this wrong drifted every entry whose prior pParent UUID landed on a 4-aligned (non-8-aligned) position. Verification against live GOAD sevenkingdoms.local DC: 17/17 NTDS password hashes (Administrator, krbtgt, vagrant, KINGSLANDING\$, ESSOS\$, NORTH\$, and all domain users) match impacket-secretsdump byte-for-byte. Independent Python byte-level reference walker confirms the same 17 unicodePwd-bearing entries offline. Also removes ~950 lines of the now-unreachable legacy V6 helpers (parseGetNCChangesResponseV6, skipDSNAME, skipUpToDateVector, skipPropertyMetaDataExtVector, parsePrefixTable, parseREPLENTINFLIST, parseENTINF, findValidDSNAME, parseDSNAMEIntoObject, parseATTRBLOCK) from getncchanges.go. Closes #9. --- pkg/dcerpc/drsuapi/getncchanges.go | 966 +------------------------- pkg/dcerpc/drsuapi/getncchanges_v6.go | 370 ++++++++++ pkg/dcerpc/drsuapi/ndr.go | 223 ++++++ 3 files changed, 603 insertions(+), 956 deletions(-) create mode 100644 pkg/dcerpc/drsuapi/getncchanges_v6.go create mode 100644 pkg/dcerpc/drsuapi/ndr.go diff --git a/pkg/dcerpc/drsuapi/getncchanges.go b/pkg/dcerpc/drsuapi/getncchanges.go index 018f2be..145cff4 100644 --- a/pkg/dcerpc/drsuapi/getncchanges.go +++ b/pkg/dcerpc/drsuapi/getncchanges.go @@ -387,11 +387,16 @@ func parseGetNCChangesResponse(resp []byte, sessionKey []byte) (*GetNCChangesRes case 1: // V1 is a simple/error response return parseGetNCChangesResponseV1(r) - case 6: - return parseGetNCChangesResponseV6(resp, sessionKey) - case 7, 9: - // V7 and V9 are similar to V6 with additional fields - return parseGetNCChangesResponseV6(resp, sessionKey) + case 6, 7, 9: + // V6 is the canonical shape; V7 and V9 add fields after V6 that we + // currently don't consume. Implementation lives in getncchanges_v6.go. + // On parse error we still return whatever objects were extracted + // before the decoder hit the fault. + res, err := parseGetNCChangesResponseV6NDR(resp, sessionKey) + if err != nil && build.Debug { + log.Printf("[D] V6 NDR parse stopped early: %v (returning %d objects)", err, len(res.Objects)) + } + return res, nil default: return nil, fmt.Errorf("unsupported response version: %d", outVersion) } @@ -530,957 +535,6 @@ func getExopErrName(code uint32) string { } } -func parseGetNCChangesResponseV6(resp []byte, sessionKey []byte) (*GetNCChangesResult, error) { - result := &GetNCChangesResult{} - - // Work with the full response buffer for deferred pointer parsing - r := bytes.NewReader(resp) - - if build.Debug { - log.Printf("[D] Response total length: %d bytes", len(resp)) - // Show bytes at key positions - if len(resp) > 2900 { - log.Printf("[D] Bytes at pos 2840-2872 (around Entry 48): %x", resp[2840:2872]) - log.Printf("[D] Bytes at pos 2872-2904 (Entry 49 area): %x", resp[2872:2904]) - log.Printf("[D] Bytes at pos 2904-2936: %x", resp[2904:2936]) - } - if len(resp) > 10400 { - log.Printf("[D] Bytes at pos 10264-10296 (where Entry 280 should be): %x", resp[10264:10296]) - } - } - - // Skip outVersion (4 bytes) - already read - r.Seek(4, 0) - - // Union tag - var unionTag uint32 - binary.Read(r, binary.LittleEndian, &unionTag) - - // DRS_MSG_GETCHGREPLY_V6: - // uuidDsaObjSrc (16 bytes) - var dsaObjSrc [16]byte - r.Read(dsaObjSrc[:]) - - // uuidInvocIdSrc (16 bytes) - var invocIdSrc [16]byte - r.Read(invocIdSrc[:]) - - // pNC pointer - var ptrNC uint32 - binary.Read(r, binary.LittleEndian, &ptrNC) - pos, _ := r.Seek(0, 1) - - // usnvecFrom (USN_VECTOR - 24 bytes) - need 8-byte alignment first - // USN_VECTOR contains LONGLONG fields, so it needs 8-byte alignment - if pos%8 != 0 { - r.Seek(int64(8-pos%8), 1) - } - - // usnvecFrom (USN_VECTOR - 24 bytes) - r.Seek(24, 1) // Skip usnvecFrom - - // usnvecTo (USN_VECTOR - 24 bytes) - read for pagination - binary.Read(r, binary.LittleEndian, &result.HighWaterMark.HighObjUpdate) - binary.Read(r, binary.LittleEndian, &result.HighWaterMark.Reserved) - binary.Read(r, binary.LittleEndian, &result.HighWaterMark.HighPropUpdate) - - // pUpToDateVecSrc pointer - var ptrUpToDate uint32 - binary.Read(r, binary.LittleEndian, &ptrUpToDate) - - // PrefixTableSrc - SCHEMA_PREFIX_TABLE (inline structure) - var prefixCount uint32 - binary.Read(r, binary.LittleEndian, &prefixCount) - var ptrPrefixEntry uint32 - binary.Read(r, binary.LittleEndian, &ptrPrefixEntry) - - // ulExtendedRet - var extendedRet uint32 - binary.Read(r, binary.LittleEndian, &extendedRet) - - // cNumObjects - var numObjects uint32 - binary.Read(r, binary.LittleEndian, &numObjects) - - // cNumBytes - var numBytes uint32 - binary.Read(r, binary.LittleEndian, &numBytes) - - // pObjects pointer - var ptrObjects uint32 - binary.Read(r, binary.LittleEndian, &ptrObjects) - - // fMoreData - var moreData uint32 - binary.Read(r, binary.LittleEndian, &moreData) - result.MoreData = moreData != 0 - - // cNumNcSizeObjects, cNumNcSizeValues (V6 specific) - var cNumNcSizeObjects, cNumNcSizeValues uint32 - binary.Read(r, binary.LittleEndian, &cNumNcSizeObjects) - binary.Read(r, binary.LittleEndian, &cNumNcSizeValues) - - if build.Debug { - log.Printf("[D] V6 response: ptrNC=0x%x, ptrUpToDate=0x%x, prefixCount=%d, ptrObjects=0x%x", - ptrNC, ptrUpToDate, prefixCount, ptrObjects) - log.Printf("[D] V6 response: numObjects=%d, numBytes=%d, moreData=%v", - numObjects, numBytes, result.MoreData) - log.Printf("[D] V6 response: extendedRet=%d, usnvecTo=[%d,%d,%d]", - extendedRet, result.HighWaterMark.HighObjUpdate, result.HighWaterMark.Reserved, result.HighWaterMark.HighPropUpdate) - pos, _ := r.Seek(0, 1) - log.Printf("[D] V6 response: current position after header: %d", pos) - } - - // Now parse the deferred data - // The order of deferred data follows pointer declaration order: - // 1. pNC (DSNAME) - // 2. pUpToDateVecSrc (if non-null) - // 3. pPrefixEntry (prefix table entries) - // 4. pObjects (REPLENTINFLIST) - - // There appear to be 12 extra bytes before the deferred pNC DSNAME conformance - r.Seek(12, 1) - - if build.Debug { - pos, _ := r.Seek(0, 1) - log.Printf("[D] After 12-byte skip, position: %d", pos) - } - - // Skip pNC deferred data (we don't need it) - if ptrNC != 0 { - skipDSNAME(r) - if build.Debug { - pos, _ := r.Seek(0, 1) - log.Printf("[D] After skipDSNAME, position: %d", pos) - } - } - - // Skip pUpToDateVecSrc if present - if ptrUpToDate != 0 { - skipUpToDateVector(r) - if build.Debug { - pos, _ := r.Seek(0, 1) - log.Printf("[D] After skipUpToDateVector, position: %d", pos) - } - } - - // Parse prefix table (needed to interpret attribute types) - prefixTable := make(map[uint32][]byte) - if ptrPrefixEntry != 0 && prefixCount > 0 { - if build.Debug { - pos, _ := r.Seek(0, 1) - log.Printf("[D] Before parsePrefixTable (count=%d), position: %d", prefixCount, pos) - // Show bytes at current position - peek := make([]byte, 32) - n, _ := r.Read(peek) - log.Printf("[D] Bytes at position %d: %x", pos, peek[:n]) - r.Seek(pos, 0) // Seek back - } - parsePrefixTable(r, prefixCount, prefixTable) - if build.Debug { - pos, _ := r.Seek(0, 1) - log.Printf("[D] After parsePrefixTable, position: %d", pos) - } - } - - // Parse REPLENTINFLIST (linked list of replicated entries) - if ptrObjects != 0 { - if build.Debug { - pos, _ := r.Seek(0, 1) - log.Printf("[D] About to parse REPLENTINFLIST at position %d", pos) - // Show next 64 bytes at this position - peek := make([]byte, 64) - n, _ := r.Read(peek) - log.Printf("[D] Next %d bytes at position %d: %x", n, pos, peek[:n]) - r.Seek(pos, 0) // Seek back - } - result.Objects = parseREPLENTINFLIST(r, sessionKey, prefixTable, numObjects) - if build.Debug { - log.Printf("[D] REPLENTINFLIST parsed, got %d objects", len(result.Objects)) - } - } - _ = numBytes - _ = dsaObjSrc - _ = invocIdSrc - _ = unionTag - _ = cNumNcSizeObjects - _ = cNumNcSizeValues - _ = extendedRet - - return result, nil -} - -func skipDSNAME(r *bytes.Reader) { - // DSNAME conformant structure: [MaxCount][structLen][SidLen][Guid][Sid][NameLen][StringName] - var maxCount uint32 - binary.Read(r, binary.LittleEndian, &maxCount) - - var structLen uint32 - binary.Read(r, binary.LittleEndian, &structLen) - - var sidLen uint32 - binary.Read(r, binary.LittleEndian, &sidLen) - - // Guid (16) + Sid (28) + NameLen (4) + StringName (maxCount*2) - skipBytes := 16 + 28 + 4 + int(maxCount)*2 - r.Seek(int64(skipBytes), 1) - - // Align to 4 bytes - pos, _ := r.Seek(0, 1) - if pos%4 != 0 { - r.Seek(int64(4-pos%4), 1) - } - - _ = structLen - _ = sidLen -} - -func skipUpToDateVector(r *bytes.Reader) { - // UPTODATE_VECTOR can be V1 or V2 (detected by dwVersion field) - // - // For V1_EXT (section 5.208 of MS-DRSR): - // dwVersion (4 bytes) = 1 - // dwReserved1 (4 bytes) - // cNumCursors (4 bytes) - // rgCursors conformance (4 bytes) - NDR array MaxCount - // rgCursors (cNumCursors * 24 bytes) - each V1 cursor: GUID(16) + USN(8) - // - // For V2_EXT (section 5.209): - // dwVersion (4 bytes) = 2 - // dwReserved1 (4 bytes) - // cNumCursors (4 bytes) - // dwReserved2 (4 bytes) - // rgCursors conformance (4 bytes) - NDR array MaxCount - // rgCursors (cNumCursors * 32 bytes) - each V2 cursor: GUID(16) + USN(8) + time(8) - - startPos, _ := r.Seek(0, 1) - - if build.Debug { - peek := make([]byte, 80) - n, _ := r.Read(peek) - log.Printf("[D] skipUpToDateVector: startPos=%d, first %d bytes: %x", startPos, n, peek[:n]) - r.Seek(startPos, 0) - } - - // Read structure fields - var version, reserved1, cNumCursors uint32 - binary.Read(r, binary.LittleEndian, &version) - binary.Read(r, binary.LittleEndian, &reserved1) - binary.Read(r, binary.LittleEndian, &cNumCursors) - - var cursorSize int64 - if version == 1 { - // Based on byte analysis: - // - Position 256: version=1, reserved=0, cNumCursors=2 - // - Position 268: 12 extra bytes (00000000 01000000 00000000) - // - Position 280: Cursor 1 GUID (16 bytes) - // - Position 296: Cursor 1 USN (8 bytes) - cursor 1 ends at 304 - // - Position 304: 8 bytes (3ae1741f 03000000) - maybe partial cursor 2 or something else - // - Position 312: 27000000 = 39 = prefix count - THIS should be the array conformance! - // - // So the prefix table array conformance is at position 312. - // We need to skip: 12 (extra bytes) + 24 (1 cursor) + 8 (extra) = 44 bytes - // Or: skip until we reach a position where the next 4 bytes = prefixCount - - // Skip the 12 extra bytes - r.Seek(12, 1) - // V1 cursor: GUID (16) + USN (8) = 24 bytes - cursorSize = 24 - // Only skip 1 cursor regardless of cNumCursors, then skip 8 more bytes - cNumCursors = 1 - if build.Debug { - pos, _ := r.Seek(0, 1) - log.Printf("[D] skipUpToDateVector: V1, skipped 12 extra bytes, now at pos=%d, will skip %d cursor bytes + 8 extra", pos, cNumCursors*24) - } - } else if version == 2 { - // V2 has an extra dwReserved2 field before the array conformance - var reserved2 uint32 - binary.Read(r, binary.LittleEndian, &reserved2) - // Read NDR array conformance - var arrayMaxCount uint32 - binary.Read(r, binary.LittleEndian, &arrayMaxCount) - // V2 cursor: GUID (16) + USN (8) + timeLastSync (8) = 32 bytes - cursorSize = 32 - if build.Debug { - log.Printf("[D] skipUpToDateVector: V2, reserved2=0x%x, arrayMaxCount=%d", reserved2, arrayMaxCount) - } - } else { - if build.Debug { - log.Printf("[D] WARNING: skipUpToDateVector unexpected version=%d, cNumCursors=%d", version, cNumCursors) - } - // Assume V1 structure - var arrayMaxCount uint32 - binary.Read(r, binary.LittleEndian, &arrayMaxCount) - cursorSize = 24 - } - - if build.Debug { - pos, _ := r.Seek(0, 1) - log.Printf("[D] skipUpToDateVector: version=%d, cNumCursors=%d, cursorSize=%d, will skip %d bytes, pos before skip=%d", - version, cNumCursors, cursorSize, int64(cNumCursors)*cursorSize, pos) - } - - // Skip cursor data - r.Seek(int64(cNumCursors)*cursorSize, 1) - - // For V1, skip 8 extra bytes after cursors (empirically needed to align with prefix table) - if version == 1 { - r.Seek(8, 1) - } - - if build.Debug { - endPos, _ := r.Seek(0, 1) - log.Printf("[D] skipUpToDateVector: endPos=%d, skipped %d bytes total", endPos, endPos-startPos) - } -} - -// skipPropertyMetaDataExtVector skips the PROPERTY_META_DATA_EXT_VECTOR deferred data. -// Per MS-DRSR 5.162, the structure is: -// -// typedef struct { -// DWORD dwVersion; -// DWORD dwReserved; -// DWORD cNumProps; -// [size_is(cNumProps)] PROPERTY_META_DATA_EXT rgMetaData[]; -// } PROPERTY_META_DATA_EXT_VECTOR; -// -// And per MS-DRSR 5.161, each PROPERTY_META_DATA_EXT is: -// -// typedef struct { -// DWORD dwVersion; -// DSTIME timeChanged; // 8 bytes (LONGLONG) -// UUID uuidDsaOriginating; // 16 bytes -// USN usnOriginating; // 8 bytes -// USN usnProperty; // 8 bytes -// } PROPERTY_META_DATA_EXT; -// -// Total per element: 4 + 8 + 16 + 8 + 8 = 44 bytes -func skipPropertyMetaDataExtVector(r *bytes.Reader) { - startPos, _ := r.Seek(0, 1) - - if build.Debug { - peek := make([]byte, 48) - n, _ := r.Read(peek) - log.Printf("[D] skipPropertyMetaDataExtVector: startPos=%d, first %d bytes: %x", startPos, n, peek[:n]) - r.Seek(startPos, 0) - } - - // NDR conformant structure: MaxCount for rgMetaData array comes first - var maxCount uint32 - binary.Read(r, binary.LittleEndian, &maxCount) - - // Sanity check - maxCount should be reasonable (< 1000 for most objects) - // If it looks like garbage, don't consume any bytes - let subsequent parsing handle it - if maxCount > 1000 { - if build.Debug { - log.Printf("[D] skipPropertyMetaDataExtVector: maxCount=%d looks like garbage, not consuming bytes", maxCount) - } - r.Seek(startPos, 0) - return - } - - // Read structure fields - var version, reserved, cNumProps uint32 - binary.Read(r, binary.LittleEndian, &version) - binary.Read(r, binary.LittleEndian, &reserved) - binary.Read(r, binary.LittleEndian, &cNumProps) - - if build.Debug { - log.Printf("[D] skipPropertyMetaDataExtVector: maxCount=%d, version=%d, reserved=%d, cNumProps=%d", - maxCount, version, reserved, cNumProps) - } - - // Skip the array elements: each PROPERTY_META_DATA_EXT is 44 bytes - // Use maxCount (NDR conformance) for the array size - const metaDataExtSize = 44 - r.Seek(int64(maxCount)*metaDataExtSize, 1) - - if build.Debug { - endPos, _ := r.Seek(0, 1) - log.Printf("[D] skipPropertyMetaDataExtVector: endPos=%d, skipped %d bytes total from original start", - endPos, endPos-startPos) - } -} - -func parsePrefixTable(r *bytes.Reader, count uint32, prefixTable map[uint32][]byte) { - startPos, _ := r.Seek(0, 1) - - if build.Debug { - // Show bytes at current position - peek := make([]byte, 32) - n, _ := r.Read(peek) - log.Printf("[D] parsePrefixTable: startPos=%d, first %d bytes: %x", startPos, n, peek[:n]) - r.Seek(startPos, 0) // Seek back - } - - // Conformant array: MaxCount first - var maxCount uint32 - binary.Read(r, binary.LittleEndian, &maxCount) - - if build.Debug { - pos, _ := r.Seek(0, 1) - log.Printf("[D] parsePrefixTable: maxCount=%d, count=%d, pos after maxCount=%d", maxCount, count, pos) - } - - // Sanity check - if maxCount != count { - if build.Debug { - log.Printf("[D] WARNING: parsePrefixTable maxCount=%d != count=%d", maxCount, count) - } - } - - // Parse each PREFIX_TABLE_ENTRY (fixed part) - for i := uint32(0); i < count; i++ { - var ndx uint32 - binary.Read(r, binary.LittleEndian, &ndx) - - // OID_t: length (4) + elements pointer (4) - var length uint32 - binary.Read(r, binary.LittleEndian, &length) - var ptrElements uint32 - binary.Read(r, binary.LittleEndian, &ptrElements) - - if build.Debug && i < 3 { - log.Printf("[D] PrefixEntry[%d]: ndx=%d, oid_len=%d, oid_ptr=0x%x", i, ndx, length, ptrElements) - } - _ = ndx - _ = length - _ = ptrElements - } - - if build.Debug { - pos, _ := r.Seek(0, 1) - log.Printf("[D] parsePrefixTable: after fixed part (count=%d entries), pos=%d", count, pos) - } - - // Now parse the deferred OID data - for i := uint32(0); i < count; i++ { - var maxLen uint32 - binary.Read(r, binary.LittleEndian, &maxLen) - - if build.Debug && i < 3 { - log.Printf("[D] PrefixOID[%d]: maxLen=%d", i, maxLen) - } - - // Sanity check - OID shouldn't be more than a few hundred bytes - if maxLen > 1000 { - if build.Debug { - pos, _ := r.Seek(0, 1) - log.Printf("[D] WARNING: PrefixOID[%d] maxLen=%d seems too large at pos=%d, likely parsing error", i, maxLen, pos) - // Show bytes around this position - peek := make([]byte, 32) - r.Seek(pos-4, 0) // Go back to read the maxLen bytes too - n, _ := r.Read(peek) - log.Printf("[D] Bytes at pos %d: %x", pos-4, peek[:n]) - } - break - } - - oidBytes := make([]byte, maxLen) - r.Read(oidBytes) - - prefixTable[uint32(i)] = oidBytes - - // Align to 4 bytes - if maxLen%4 != 0 { - r.Seek(int64(4-maxLen%4), 1) - } - } - - if build.Debug { - endPos, _ := r.Seek(0, 1) - log.Printf("[D] parsePrefixTable: endPos=%d, total bytes consumed=%d", endPos, endPos-startPos) - } - - _ = maxCount -} - -// entryHeader holds the fixed parts of a REPLENTINFLIST entry -type entryHeader struct { - ptrNext uint32 - ptrName uint32 - flags uint32 - attrCount uint32 - ptrAttr uint32 - isNCPrefix uint32 - ptrParentGuid uint32 - ptrMetaData uint32 -} - -func parseREPLENTINFLIST(r *bytes.Reader, sessionKey []byte, prefixTable map[uint32][]byte, numObjects uint32) []ReplicatedObject { - var objects []ReplicatedObject - - // REPLENTINFLIST is a linked list - // NDR layout: all fixed parts first, then all deferred data - // Structure per entry: - // - pNextEntInf (4 bytes) - pointer to next entry (0 if last) - // - ENTINF inline structure: - // - pName (4 bytes) - pointer to DSNAME - // - ulFlags (4 bytes) - // - AttrBlock inline: - // - attrCount (4 bytes) - // - pAttr (4 bytes) - pointer to ATTR array - // - fIsNCPrefix (4 bytes) - // - pParentGuid (4 bytes) - pointer to GUID - // - pMetaDataExt (4 bytes) - pointer to PROPERTY_META_DATA_EXT_VECTOR - - startPos, _ := r.Seek(0, 1) - - if build.Debug { - // Show first 64 bytes at start of REPLENTINFLIST - peek := make([]byte, 64) - n, _ := r.Read(peek) - log.Printf("[D] parseREPLENTINFLIST: startPos=%d, first %d bytes: %x, expecting %d objects", startPos, n, peek[:n], numObjects) - r.Seek(startPos, 0) // Seek back - } - - // First, read all fixed parts for all entries in the linked list - // Stop when ptrNext is 0 (end of list) or when we've read numObjects entries - var entries []entryHeader - - for i := uint32(0); i < numObjects; i++ { - entryStartPos, _ := r.Seek(0, 1) - - var entry entryHeader - binary.Read(r, binary.LittleEndian, &entry.ptrNext) - binary.Read(r, binary.LittleEndian, &entry.ptrName) - binary.Read(r, binary.LittleEndian, &entry.flags) - binary.Read(r, binary.LittleEndian, &entry.attrCount) - binary.Read(r, binary.LittleEndian, &entry.ptrAttr) - binary.Read(r, binary.LittleEndian, &entry.isNCPrefix) - binary.Read(r, binary.LittleEndian, &entry.ptrParentGuid) - binary.Read(r, binary.LittleEndian, &entry.ptrMetaData) - - if build.Debug && (i < 5 || i >= 45 || i == numObjects-1) { - log.Printf("[D] Entry %d at pos %d: ptrNext=0x%x, ptrName=0x%x, flags=0x%x, attrCount=%d, ptrAttr=0x%x, ptrParentGuid=0x%x, ptrMetaData=0x%x", - i, entryStartPos, entry.ptrNext, entry.ptrName, entry.flags, entry.attrCount, entry.ptrAttr, entry.ptrParentGuid, entry.ptrMetaData) - } - - // Stop at end of linked list (ptrNext == 0) - // Also stop if we see garbage values (sanity check) - if entry.ptrNext == 0 { - entries = append(entries, entry) - if build.Debug { - log.Printf("[D] Entry %d has ptrNext=0, end of linked list (expected %d entries)", i, numObjects) - } - break - } - - // Sanity check: ptrNext should be a small referent ID, not a large random value - if entry.ptrNext > 0x100000 { - if build.Debug { - log.Printf("[D] Entry %d has suspicious ptrNext=0x%x, stopping (expected %d entries)", i, entry.ptrNext, numObjects) - } - break - } - - entries = append(entries, entry) - } - - if build.Debug { - pos, _ := r.Seek(0, 1) - log.Printf("[D] Parsed %d entry headers from linked list, now at pos=%d", len(entries), pos) - // Show bytes at this position - peek := make([]byte, 64) - n, _ := r.Read(peek) - log.Printf("[D] Bytes at deferred data start (pos=%d): %x", pos, peek[:n]) - r.Seek(pos, 0) // Seek back - - // If we stopped early, show what caused it - if len(entries) < int(numObjects) { - expectedPos := startPos + int64(len(entries))*32 - log.Printf("[D] Expected entry at pos %d (should read 281 entries but got %d)", expectedPos, len(entries)) - // Show bytes at where we stopped - r.Seek(expectedPos, 0) - badBytes := make([]byte, 64) - n, _ := r.Read(badBytes) - log.Printf("[D] Bytes at stopped position (%d): %x", expectedPos, badBytes[:n]) - r.Seek(pos, 0) - } - } - - // Now parse deferred data for each entry in order - for i, entry := range entries { - obj := &ReplicatedObject{} - - if build.Debug && i < 5 { - pos, _ := r.Seek(0, 1) - log.Printf("[D] Parsing deferred data for entry %d at pos %d (ptrName=0x%x, attrCount=%d, ptrAttr=0x%x)", - i, pos, entry.ptrName, entry.attrCount, entry.ptrAttr) - } - - // 1. pName -> DSNAME - if entry.ptrName != 0 { - if build.Debug && i < 5 { - pos, _ := r.Seek(0, 1) - log.Printf("[D] Entry %d: before parseDSNAME at pos %d", i, pos) - } - parseDSNAMEIntoObject(r, obj) - if build.Debug && i < 5 { - pos, _ := r.Seek(0, 1) - log.Printf("[D] Entry %d: after parseDSNAME at pos %d, DN=%s", i, pos, obj.DN) - } - } - - // 2. pAttr -> ATTR array - if entry.ptrAttr != 0 && entry.attrCount > 0 { - if build.Debug && i < 5 { - pos, _ := r.Seek(0, 1) - log.Printf("[D] Entry %d: before parseATTRBLOCK at pos %d (attrCount=%d)", i, pos, entry.attrCount) - } - parseATTRBLOCK(r, entry.attrCount, obj, sessionKey) - if build.Debug && i < 5 { - pos, _ := r.Seek(0, 1) - log.Printf("[D] Entry %d: after parseATTRBLOCK at pos %d", i, pos) - } - } - - // 3. pParentGuid -> GUID (skip) - if entry.ptrParentGuid != 0 { - if build.Debug && i < 5 { - pos, _ := r.Seek(0, 1) - log.Printf("[D] Entry %d: skipping pParentGuid (16 bytes) at pos %d", i, pos) - } - r.Seek(16, 1) - } - - // 4. pMetaData -> PROPERTY_META_DATA_EXT_VECTOR - // Note: Metadata parsing is complex and the layout varies. Since we only need - // attributes (credentials are in ATTRBLOCK), we skip metadata entirely. - // The position will be handled by the start of the next entry's deferred data. - if entry.ptrMetaData != 0 { - if build.Debug && i < 5 { - pos, _ := r.Seek(0, 1) - log.Printf("[D] Entry %d: skipping pMetaData (position at %d)", i, pos) - } - // Don't call skipPropertyMetaDataExtVector - let the next entry's parsing handle position - } - - // If sAMAccountName is empty, extract it from DN - if obj.SAMAccountName == "" && obj.DN != "" { - if strings.HasPrefix(obj.DN, "CN=") { - parts := strings.SplitN(obj.DN[3:], ",", 2) - if len(parts) > 0 { - obj.SAMAccountName = parts[0] - } - } - } - - // Only add objects that have useful data (have a SAMAccountName or NT hash) - if obj.SAMAccountName != "" || len(obj.NTHash) > 0 { - objects = append(objects, *obj) - if build.Debug { - log.Printf("[D] Parsed object: DN=%s, SAM=%s, RID=%d", obj.DN, obj.SAMAccountName, obj.RID) - } - } - } - - return objects -} - -func parseENTINF(r *bytes.Reader, sessionKey []byte) *ReplicatedObject { - obj := &ReplicatedObject{} - - // pName (DSNAME*) pointer - var ptrName uint32 - binary.Read(r, binary.LittleEndian, &ptrName) - - // ulFlags - var flags uint32 - binary.Read(r, binary.LittleEndian, &flags) - - // AttrBlock - ATTRBLOCK inline structure - // attrCount (DWORD) - var attrCount uint32 - binary.Read(r, binary.LittleEndian, &attrCount) - - // pAttr pointer - var ptrAttr uint32 - binary.Read(r, binary.LittleEndian, &ptrAttr) - - // Parse DSNAME deferred data to get DN and GUID - if ptrName != 0 { - parseDSNAMEIntoObject(r, obj) - } - - // Parse attributes - if ptrAttr != 0 && attrCount > 0 { - parseATTRBLOCK(r, attrCount, obj, sessionKey) - } - - _ = flags - - return obj -} - -// findValidDSNAME searches forward from startPos for a valid-looking DSNAME structure. -// This is used when NDR parsing gets misaligned due to variable-length metadata. -func findValidDSNAME(r *bytes.Reader, startPos int64) (found bool, maxCount uint32) { - // Search forward in 4-byte increments for a valid-looking DSNAME - // DSNAME: [MaxCount][structLen][SidLen][Guid][Sid][NameLen][StringName] - // We validate: MaxCount < 500, SidLen <= 28, structLen reasonable - for offset := int64(0); offset < 2000; offset += 4 { - r.Seek(startPos+offset, 0) - var testMaxCount, testStructLen, testSidLen uint32 - binary.Read(r, binary.LittleEndian, &testMaxCount) - binary.Read(r, binary.LittleEndian, &testStructLen) - binary.Read(r, binary.LittleEndian, &testSidLen) - - // Validate: MaxCount between 1-500, structLen 60-2000, SidLen 0-28 - if testMaxCount > 0 && testMaxCount < 500 && - testStructLen >= 60 && testStructLen < 2000 && - testSidLen <= 28 { - r.Seek(startPos+offset, 0) - return true, testMaxCount - } - } - return false, 0 -} - -func parseDSNAMEIntoObject(r *bytes.Reader, obj *ReplicatedObject) { - startPos, _ := r.Seek(0, 1) - - var maxCount uint32 - binary.Read(r, binary.LittleEndian, &maxCount) - - // Sanity check - if maxCount looks like garbage, try to find a valid DSNAME structure - if maxCount > 10000 || maxCount == 0 { - found, foundMaxCount := findValidDSNAME(r, startPos+4) // Start at +4 since we already read 4 bytes - if !found { - r.Seek(startPos, 0) - return - } - maxCount = foundMaxCount - // Re-read maxCount at new position - binary.Read(r, binary.LittleEndian, &maxCount) - } - - var structLen uint32 - binary.Read(r, binary.LittleEndian, &structLen) - - var sidLen uint32 - binary.Read(r, binary.LittleEndian, &sidLen) - - // GUID (16 bytes) - r.Read(obj.GUID[:]) - - // Sid (28 bytes) - sid := make([]byte, 28) - r.Read(sid) - if sidLen > 0 && sidLen <= 28 { - obj.ObjectSid = sid[:sidLen] - // Extract RID (last 4 bytes of SID) - if sidLen >= 8 { - obj.RID = binary.LittleEndian.Uint32(sid[sidLen-4:]) - } - } - - // NameLen - var nameLen uint32 - binary.Read(r, binary.LittleEndian, &nameLen) - - // StringName (UTF-16LE) - if nameLen > 0 && maxCount > 0 && nameLen <= maxCount { - nameBytes := make([]byte, maxCount*2) - r.Read(nameBytes) - obj.DN = utf16le.DecodeToString(nameBytes[:nameLen*2]) - } - - // Align to 4 bytes - pos, _ := r.Seek(0, 1) - if pos%4 != 0 { - r.Seek(int64(4-pos%4), 1) - } -} - -func parseATTRBLOCK(r *bytes.Reader, attrCount uint32, obj *ReplicatedObject, sessionKey []byte) { - startPos, _ := r.Seek(0, 1) - - // Conformant array: MaxCount - var maxCount uint32 - binary.Read(r, binary.LittleEndian, &maxCount) - - // Sanity check - if maxCount > 10000 || attrCount > 10000 { - if build.Debug { - log.Printf("[D] WARNING: parseATTRBLOCK maxCount=%d, attrCount=%d too large at pos=%d, skipping", maxCount, attrCount, startPos) - } - r.Seek(startPos, 0) - return - } - - // Set a reasonable max size for ATTRBLOCK deferred data (100KB should be plenty for most objects) - const maxATTRBLOCKSize = 100000 - maxPos := startPos + maxATTRBLOCKSize - - if build.Debug { - pos, _ := r.Seek(0, 1) - log.Printf("[D] parseATTRBLOCK: maxCount=%d, attrCount=%d, pos=%d", maxCount, attrCount, pos) - } - - // Read all ATTR structures first (fixed part) - // Use maxCount (NDR conformance) not attrCount (from header) - they may differ - type attrHeader struct { - attrTyp uint32 - valCount uint32 - pAVal uint32 - } - attrs := make([]attrHeader, maxCount) - - for i := uint32(0); i < maxCount; i++ { - binary.Read(r, binary.LittleEndian, &attrs[i].attrTyp) - binary.Read(r, binary.LittleEndian, &attrs[i].valCount) - binary.Read(r, binary.LittleEndian, &attrs[i].pAVal) - if build.Debug && (i < 5 || i >= maxCount-3) { - log.Printf("[D] ATTR[%d]: attrTyp=0x%x, valCount=%d, pAVal=0x%x", i, attrs[i].attrTyp, attrs[i].valCount, attrs[i].pAVal) - } - } - - if build.Debug { - pos, _ := r.Seek(0, 1) - log.Printf("[D] parseATTRBLOCK: after ATTR headers, pos=%d", pos) - } - - // NDR serialization for nested structures: - // For each ATTRVALBLOCK (pointed to by pAVal): - // 1. Conformance (MaxCount for ATTRVAL array) - // 2. All ATTRVAL inline parts: (valLen, pVal) pairs - // 3. All ATTRVAL deferred data: actual value bytes - // Then the next ATTRVALBLOCK follows. - // - // We process each ATTRVALBLOCK completely before moving to the next. - - for i := uint32(0); i < maxCount; i++ { - if attrs[i].pAVal == 0 || attrs[i].valCount == 0 { - continue - } - - attrTyp := attrs[i].attrTyp - - if build.Debug && (i < 5 || i >= maxCount-3) { - pos, _ := r.Seek(0, 1) - log.Printf("[D] ATTRVALBLOCK[%d]: pos=%d, attrTyp=0x%x", i, pos, attrTyp) - } - - // Check position before reading more - currentPos, _ := r.Seek(0, 1) - if currentPos > maxPos { - if build.Debug { - log.Printf("[D] parseATTRBLOCK: exceeded maxPos at attr %d, bailing out", i) - } - r.Seek(startPos, 0) - return - } - - // Read conformant array MaxCount - var valMaxCount uint32 - binary.Read(r, binary.LittleEndian, &valMaxCount) - - // Sanity check valMaxCount - if valMaxCount > 10000 { - if build.Debug { - log.Printf("[D] ATTRVALBLOCK[%d]: valMaxCount=%d too large, bailing out", i, valMaxCount) - } - r.Seek(startPos, 0) - return - } - - // Use struct's valCount for inline element count - // The NDR conformance should match, but trust the struct field - actualInlineCount := attrs[i].valCount - - if build.Debug && (i < 5 || i >= maxCount-3) { - log.Printf("[D] ATTRVALBLOCK[%d]: valMaxCount=%d, valCount=%d, actualInlineCount=%d", - i, valMaxCount, attrs[i].valCount, actualInlineCount) - } - - if actualInlineCount > 10000 { - if build.Debug { - log.Printf("[D] ATTRVALBLOCK[%d]: skipping garbage actualInlineCount=%d", i, actualInlineCount) - } - r.Seek(startPos, 0) - return - } - - // Read inline (valLen, pVal) pairs based on valCount from struct - valLens := make([]uint32, actualInlineCount) - hasPVals := make([]bool, actualInlineCount) - for j := uint32(0); j < actualInlineCount; j++ { - binary.Read(r, binary.LittleEndian, &valLens[j]) - var pVal uint32 - binary.Read(r, binary.LittleEndian, &pVal) - hasPVals[j] = pVal != 0 - if build.Debug && i < 5 && j < 3 { - log.Printf("[D] ATTRVALBLOCK[%d] val[%d]: valLen=%d, pVal=0x%x", i, j, valLens[j], pVal) - } - } - - // Now read deferred value bytes for each ATTRVAL with non-null pVal - // Each pVal is [size_is(valLen)] UCHAR*, so each deferred referent has: - // - Conformance (4 bytes, should equal valLen) - // - Data bytes (valLen) - // - Alignment padding - - actualValCount := attrs[i].valCount - for j := uint32(0); j < actualValCount; j++ { - if !hasPVals[j] { - continue - } - - // Read pVal conformance (should equal valLen from inline data) - var pValConformance uint32 - binary.Read(r, binary.LittleEndian, &pValConformance) - - if build.Debug && i < 5 && j < 3 { - log.Printf("[D] ATTRVALBLOCK[%d] val[%d] deferred: pValConformance=%d, expected valLen=%d", - i, j, pValConformance, valLens[j]) - } - - // Sanity check - conformance should match valLen and be reasonable - if pValConformance > 100000 { - if build.Debug { - pos, _ := r.Seek(0, 1) - log.Printf("[D] ATTRVALBLOCK[%d] val[%d]: skipping huge pValConformance=%d at pos=%d", i, j, pValConformance, pos) - } - // Reset to start and bail out - r.Seek(startPos, 0) - return - } - - // Check if we would exceed maxPos - currentPos, _ := r.Seek(0, 1) - if currentPos+int64(pValConformance) > maxPos { - if build.Debug { - log.Printf("[D] ATTRVALBLOCK[%d]: exceeding maxPos, bailing out at pos=%d", i, currentPos) - } - r.Seek(startPos, 0) - return - } - - // Read the actual value bytes using the conformance (not valLen) - valData := make([]byte, pValConformance) - r.Read(valData) - - // Align to 4 bytes after each value - if pValConformance%4 != 0 { - r.Seek(int64(4-pValConformance%4), 1) - } - - // Process attribute based on type - processAttribute(attrTyp, valData, obj, sessionKey) - } - - if build.Debug && (i < 5 || i >= maxCount-3) { - pos, _ := r.Seek(0, 1) - log.Printf("[D] ATTRVALBLOCK[%d]: after deferred data, pos=%d", i, pos) - } - } - - if build.Debug { - pos, _ := r.Seek(0, 1) - log.Printf("[D] parseATTRBLOCK: complete, pos=%d", pos) - } -} func processAttribute(attrTyp uint32, valData []byte, obj *ReplicatedObject, sessionKey []byte) { switch attrTyp { diff --git a/pkg/dcerpc/drsuapi/getncchanges_v6.go b/pkg/dcerpc/drsuapi/getncchanges_v6.go new file mode 100644 index 0000000..4f67e09 --- /dev/null +++ b/pkg/dcerpc/drsuapi/getncchanges_v6.go @@ -0,0 +1,370 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Declarative NDR parser for DRS_MSG_GETCHGREPLY_V6 and its nested +// structures. Each helper mirrors an IDL struct from MS-DRSR and is +// written against the Decoder primitives in ndr.go, so the byte-level +// work is explicit rather than expressed via magic offsets. +// +// Structure map (MS-DRSR): +// +// DRS_MSG_GETCHGREPLY_V6 4.1.10.2.11 +// pNC -> DSNAME 4.1.4.1.1 +// pUpToDateVecSrc -> UPTODATE_VECTOR_V{1,2} 4.1.4.1.6 / 4.1.4.1.7 +// PrefixTableSrc -> SCHEMA_PREFIX_TABLE 4.1.10.1.7 +// pObjects -> REPLENTINFLIST* 4.1.10.1.9 +// Entinf -> ENTINF 5.58 +// pParentGuidm -> UUID +// pMetaDataExt -> PROPERTY_META_DATA_EXT_VECTOR 4.2.2.1.3 + +package drsuapi + +import ( + "encoding/binary" + "strings" + + "github.com/mandiant/gopacket/pkg/utf16le" +) + +// parseGetNCChangesResponseV6NDR parses the V6/V7/V9 GetNCChanges reply. +// V6 is the canonical shape; V7 and V9 add fields after the V6 body which +// this parser does not consume (same limitation as Impacket's secretsdump). +func parseGetNCChangesResponseV6NDR(resp []byte, sessionKey []byte) (*GetNCChangesResult, error) { + d := NewDecoder(resp) + result := &GetNCChangesResult{} + + // DRS_MSG_GETCHGREPLY header (inline portion). The enclosing response + // was already consumed through outVersion; our Decoder starts at the + // beginning of the payload, so we still skip 4 bytes of outVersion + + // 4 bytes of union tag to land on the V6 fields. + d.Skip(4) // outVersion, already read by caller + d.Skip(4) // union tag + + // uuidDsaObjSrc (16) + uuidInvocIdSrc (16). + d.Skip(16) + d.Skip(16) + + ptrNC := d.ReadPointer() + + // usnvecFrom is a USN_VECTOR = 3 LONGLONGs. LONGLONG alignment is 8, + // so we align before reading. + d.Align(8) + d.Skip(24) // usnvecFrom + + // usnvecTo: capture the high-water mark we return as the pagination + // cursor. Three LONGLONGs. + result.HighWaterMark.HighObjUpdate = d.ReadUint64() + result.HighWaterMark.Reserved = d.ReadUint64() + result.HighWaterMark.HighPropUpdate = d.ReadUint64() + + ptrUpToDate := d.ReadPointer() + + // PrefixTableSrc is a SCHEMA_PREFIX_TABLE inline: cNumPrefixes + + // pPrefixEntry referent. + prefixCount := d.ReadUint32() + ptrPrefixEntry := d.ReadPointer() + + // The rest of the fixed V6 header. + _ = d.ReadUint32() // ulExtendedRet + numObjects := d.ReadUint32() + _ = d.ReadUint32() // cNumBytes + ptrObjects := d.ReadPointer() + result.MoreData = d.ReadUint32() != 0 + _ = d.ReadUint32() // cNumNcSizeObjects (V6) + _ = d.ReadUint32() // cNumNcSizeValues (V6) + + // Full V6 header also has cNumValues, rgValues, dwDRSError. Impacket + // treats rgValues as an opaque DWORD (not a pointer with deferred + // REPLVALINF_V1_ARRAY) because parsing REPLVALINF is broken in their + // implementation too. + _ = d.ReadUint32() // cNumValues + _ = d.ReadUint32() // rgValues (opaque) + _ = d.ReadUint32() // dwDRSError + + // Deferred data follows in pointer declaration order: pNC, + // pUpToDateVecSrc, pPrefixEntry, pObjects. + + if ptrNC != 0 { + skipDSNAMEv2(d) + } + if ptrUpToDate != 0 { + skipUpToDateVectorV2(d) + } + + prefixTable := make(map[uint32][]byte) + if ptrPrefixEntry != 0 && prefixCount > 0 { + readPrefixTableV2(d, prefixCount, prefixTable) + } + + if ptrObjects != 0 { + result.Objects = readREPLENTINFLISTArrayV2(d, sessionKey, prefixTable, numObjects) + } + + return result, d.Err() +} + +// skipDSNAMEv2 consumes a DSNAME deferred struct without capturing values. +// DSNAME is a conformant struct (StringName is an NDRUniConformantArray, not +// conformant-varying); wire layout is MaxCount + structLen + SidLen + Guid +// + Sid + NameLen + StringName[MaxCount]. +func skipDSNAMEv2(d *Decoder) { + maxCount := d.ReadConformance() + _ = d.ReadUint32() // structLen + _ = d.ReadUint32() // SidLen + d.Skip(16) // Guid + d.Skip(28) // Sid + _ = d.ReadUint32() // NameLen + d.Skip(int(maxCount) * 2) + d.Align(4) +} + +// readDSNAMEv2 parses a DSNAME and fills the given ReplicatedObject's +// GUID, ObjectSid, RID, and DN fields. Same layout as skipDSNAMEv2. +func readDSNAMEv2(d *Decoder, obj *ReplicatedObject) { + maxCount := d.ReadConformance() + _ = d.ReadUint32() // structLen + sidLen := d.ReadUint32() + copy(obj.GUID[:], d.ReadBytes(16)) + sid := d.ReadBytes(28) + if sidLen > 0 && sidLen <= 28 && sid != nil { + obj.ObjectSid = append([]byte(nil), sid[:sidLen]...) + if sidLen >= 8 { + obj.RID = binary.LittleEndian.Uint32(sid[sidLen-4:]) + } + } + nameLen := d.ReadUint32() + if maxCount > 0 { + name := d.ReadBytes(int(maxCount) * 2) + if name != nil { + visible := name + if nameLen > 0 && int(nameLen)*2 <= len(name) { + visible = name[:nameLen*2] + } + obj.DN = utf16le.DecodeToString(visible) + for len(obj.DN) > 0 && obj.DN[len(obj.DN)-1] == 0 { + obj.DN = obj.DN[:len(obj.DN)-1] + } + } + } + d.Align(4) +} + +// skipUpToDateVectorV2 consumes the deferred UPTODATE_VECTOR_V{1,2}_EXT. +// Both V1 and V2 share the same header (dwVersion, dwReserved1, cNumCursors, +// dwReserved2); cursors are UUID + USN (24 bytes) in V1, UUID + USN + DSTIME +// (32 bytes) in V2. NDR early conformance puts MaxCount at the front. +func skipUpToDateVectorV2(d *Decoder) { + _ = d.ReadConformance() // MaxCount + version := d.ReadUint32() + _ = d.ReadUint32() // dwReserved1 + cNumCursors := d.ReadUint32() + _ = d.ReadUint32() // dwReserved2 + + cursorSize := 24 + if version == 2 { + cursorSize = 32 + } + d.Skip(int(cNumCursors) * cursorSize) +} + +// readPrefixTableV2 consumes the PrefixTableEntry_ARRAY deferred data +// (conformant array of {ndx: DWORD, prefix: OID_t}) and populates the +// prefixTable map with ndx -> OID bytes for decoding attribute type IDs. +func readPrefixTableV2(d *Decoder, count uint32, prefixTable map[uint32][]byte) { + _ = d.ReadConformance() // MaxCount of the PrefixTableEntry array + + type entry struct { + ndx uint32 + oidLen uint32 + oidPtrRef uint32 + } + entries := make([]entry, count) + for i := uint32(0); i < count; i++ { + entries[i].ndx = d.ReadUint32() + entries[i].oidLen = d.ReadUint32() + entries[i].oidPtrRef = d.ReadPointer() + } + + for i := range entries { + if entries[i].oidPtrRef == 0 || entries[i].oidLen == 0 { + continue + } + oidMaxCount := d.ReadConformance() + oidBytes := d.ReadBytes(int(oidMaxCount)) + if oidBytes != nil { + prefixTable[entries[i].ndx] = append([]byte(nil), oidBytes...) + } + d.Align(4) + } +} + +// skipPropertyMetaDataExtVectorV2 consumes a PROPERTY_META_DATA_EXT_VECTOR +// deferred struct. +// +// PROPERTY_META_DATA_EXT_VECTOR (MS-DRSR 4.2.2.1.3): +// +// DWORD cNumProps +// [size_is(cNumProps)] PROPERTY_META_DATA_EXT rgMetaData[] +// +// Each PROPERTY_META_DATA_EXT (MS-DRSR 4.2.2.1.4): +// +// DWORD dwVersion (4) +// DSTIME timeChanged (8, LONGLONG) +// UUID uuidDsaOriginating (16) +// USN usnOriginating (8, LONGLONG) +// +// DSTIME and USN force 8-byte alignment for the element, so struct alignment +// is also 8. NDR early-conformance hoists rgMetaData's MaxCount to the front, +// but the hoisted MaxCount uses only its primitive (4-byte) alignment; the +// struct's 8-byte alignment is applied AFTER MaxCount before the struct's +// other fields. Wire layout is: +// +// [pad to 4] MaxCount(4) [pad to 8] cNumProps(4) [pad to 8] elements[MaxCount] +// +// Each element is 40 bytes: dwVersion(4) + pad(4) + timeChanged(8) + +// uuidDsaOriginating(16) + usnOriginating(8). The amount of padding inserted +// depends on where the decoder landed after the preceding pParent UUID, which +// is 4-aligned rather than 8-aligned. +func skipPropertyMetaDataExtVectorV2(d *Decoder) { + maxCount := d.ReadUint32() // hoisted conformance, 4-aligned + d.Align(8) // struct alignment before cNumProps + _ = d.ReadUint32() // cNumProps + d.Align(8) // element alignment + const metaDataExtSize = 40 + d.Skip(int(maxCount) * metaDataExtSize) +} + +// readREPLENTINFLISTArrayV2 parses the linked-list of REPLENTINFLIST entries +// pointed to by pObjects. REPLENTINFLIST is a self-referential struct with +// pNextEntInf (first field) threading the list, plus Entinf, fIsNCPrefix, +// pParentGuidm, and pMetaDataExt. NDR serializes this via strict DFS through +// pNext: all N fixed parts come first (since pNext is always the first +// pointer encountered in each node), then the non-pNext deferreds unwind +// bottom-up — the deepest node's pName/pAttr/pParent/pMeta appear first, +// the head node's appear last. So to match the wire we must iterate the +// captured headers in reverse. +func readREPLENTINFLISTArrayV2(d *Decoder, sessionKey []byte, prefixTable map[uint32][]byte, numObjects uint32) []ReplicatedObject { + type header struct { + ptrNext uint32 + ptrName uint32 + flags uint32 + attrCount uint32 + ptrAttr uint32 + isNCPrefix uint32 + ptrParentGuid uint32 + ptrMetaData uint32 + } + + headers := make([]header, numObjects) + for i := uint32(0); i < numObjects; i++ { + headers[i].ptrNext = d.ReadPointer() + headers[i].ptrName = d.ReadPointer() + headers[i].flags = d.ReadUint32() + headers[i].attrCount = d.ReadUint32() + headers[i].ptrAttr = d.ReadPointer() + headers[i].isNCPrefix = d.ReadUint32() + headers[i].ptrParentGuid = d.ReadPointer() + headers[i].ptrMetaData = d.ReadPointer() + } + + objects := make([]ReplicatedObject, 0, numObjects) + for i := int(numObjects) - 1; i >= 0; i-- { + h := headers[i] + obj := ReplicatedObject{} + + if h.ptrName != 0 { + readDSNAMEv2(d, &obj) + } + if h.ptrAttr != 0 && h.attrCount > 0 { + readATTRBLOCKv2(d, &obj, sessionKey, prefixTable) + } + if h.ptrParentGuid != 0 { + d.ReadGUID() + } + if h.ptrMetaData != 0 { + skipPropertyMetaDataExtVectorV2(d) + } + + if obj.SAMAccountName == "" && strings.HasPrefix(obj.DN, "CN=") { + rdn := obj.DN[3:] + if comma := strings.IndexByte(rdn, ','); comma >= 0 { + rdn = rdn[:comma] + } + obj.SAMAccountName = rdn + } + + if obj.SAMAccountName != "" || len(obj.NTHash) > 0 { + objects = append(objects, obj) + } + } + + return objects +} + +// readATTRBLOCKv2 consumes a deferred ATTR_ARRAY. Layout is: MaxCount + N +// ATTR fixed parts (attrTyp, valCount, pAVal), then for each non-null pAVal +// the deferred ATTRVAL_ARRAY, which itself is MaxCount + N (valLen, pVal) +// fixed parts followed by each pVal's deferred byte array. +// +// prefixTable is accepted for future use (attribute-type OID expansion); +// processAttribute works on the raw numeric attrTyp today. +func readATTRBLOCKv2(d *Decoder, obj *ReplicatedObject, sessionKey []byte, prefixTable map[uint32][]byte) { + _ = prefixTable + + const maxReasonable = 10 * 1024 * 1024 + arrayMax := d.ReadConformance() + if arrayMax > maxReasonable { + return + } + + type attrFixed struct { + attrTyp uint32 + ptrAVal uint32 + } + attrs := make([]attrFixed, arrayMax) + for i := uint32(0); i < arrayMax; i++ { + attrs[i].attrTyp = d.ReadUint32() + _ = d.ReadUint32() // valCount (ignored; wire conformance is authoritative) + attrs[i].ptrAVal = d.ReadPointer() + } + + for i := uint32(0); i < arrayMax; i++ { + if attrs[i].ptrAVal == 0 { + continue + } + valMax := d.ReadConformance() + if valMax > maxReasonable { + return + } + vals := make([]uint32, valMax) + for j := uint32(0); j < valMax; j++ { + _ = d.ReadUint32() // valLen + vals[j] = d.ReadPointer() + } + for j := uint32(0); j < valMax; j++ { + if vals[j] == 0 { + continue + } + byteMax := d.ReadConformance() + if byteMax > maxReasonable { + return + } + valData := d.ReadBytes(int(byteMax)) + if valData != nil { + processAttribute(attrs[i].attrTyp, append([]byte(nil), valData...), obj, sessionKey) + } + d.Align(4) + } + } +} diff --git a/pkg/dcerpc/drsuapi/ndr.go b/pkg/dcerpc/drsuapi/ndr.go new file mode 100644 index 0000000..63e7094 --- /dev/null +++ b/pkg/dcerpc/drsuapi/ndr.go @@ -0,0 +1,223 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// NDR decoder primitives used to parse DRSUAPI responses. This is not a +// full generic NDR framework: it provides the specific primitives the +// DRSUAPI code needs (alignment, conformant and conformant-varying array +// headers, pointer referent IDs, UTF-16LE strings) so that the hand-rolled +// parsers in this package can follow NDR rules explicitly rather than +// relying on empirical byte offsets. +// +// NDR rules this decoder encodes: +// +// - Primitives have natural alignment: USHORT=2, ULONG/DWORD=4, +// LONGLONG/UHYPER=8. Before reading a primitive, the cursor is aligned +// to that primitive's size by skipping padding bytes. +// +// - A struct's alignment is the maximum alignment of its fields. Before +// reading a struct, align to that maximum. +// +// - Conformant array: MaxCount (ULONG) precedes the elements. +// +// - Conformant-varying array: MaxCount (ULONG) + Offset (ULONG) + +// ActualCount (ULONG) precedes the elements; Offset is almost always 0 +// and ActualCount is the actual element count on the wire. +// +// - Early conformance: a conformant or conformant-varying array embedded +// in a struct has its MaxCount hoisted to the front of the struct, +// before all other fields. +// +// - Pointer: ULONG referent ID. If non-zero, the pointed-to data is +// serialized in "deferred data" order AFTER the enclosing struct's +// fixed part, following the order pointers appear in the struct. + +package drsuapi + +import ( + "encoding/binary" + "fmt" + + "github.com/mandiant/gopacket/pkg/utf16le" +) + +// Decoder walks an NDR byte stream. +type Decoder struct { + data []byte + pos int + err error +} + +// NewDecoder creates a decoder positioned at offset 0. +func NewDecoder(data []byte) *Decoder { + return &Decoder{data: data} +} + +// Pos returns the current byte offset. +func (d *Decoder) Pos() int { return d.pos } + +// Err returns the first error encountered, if any. Callers that want +// per-call error handling should check Err after a block of reads. +func (d *Decoder) Err() error { return d.err } + +// Remaining is the number of bytes left to read. +func (d *Decoder) Remaining() int { return len(d.data) - d.pos } + +// SeekTo moves the cursor to an absolute offset. Fails softly if out of +// range: the error is recorded and subsequent reads return zero values. +func (d *Decoder) SeekTo(pos int) { + if pos < 0 || pos > len(d.data) { + d.err = fmt.Errorf("ndr: seek to out-of-range offset %d (len=%d)", pos, len(d.data)) + return + } + d.pos = pos +} + +// Skip advances the cursor by n bytes. A negative n seeks backward. +func (d *Decoder) Skip(n int) { d.SeekTo(d.pos + n) } + +// Align moves the cursor forward to the next multiple of n. NDR requires +// padding to each primitive's natural alignment before reading. +func (d *Decoder) Align(n int) { + if n <= 1 { + return + } + rem := d.pos % n + if rem != 0 { + d.Skip(n - rem) + } +} + +// ReadUint8 reads a single byte (no alignment). +func (d *Decoder) ReadUint8() uint8 { + if d.err != nil { + return 0 + } + if d.pos+1 > len(d.data) { + d.err = fmt.Errorf("ndr: short read at offset %d (uint8)", d.pos) + return 0 + } + v := d.data[d.pos] + d.pos++ + return v +} + +// ReadUint16 reads a USHORT with 2-byte alignment. +func (d *Decoder) ReadUint16() uint16 { + d.Align(2) + if d.err != nil { + return 0 + } + if d.pos+2 > len(d.data) { + d.err = fmt.Errorf("ndr: short read at offset %d (uint16)", d.pos) + return 0 + } + v := binary.LittleEndian.Uint16(d.data[d.pos:]) + d.pos += 2 + return v +} + +// ReadUint32 reads a ULONG/DWORD with 4-byte alignment. +func (d *Decoder) ReadUint32() uint32 { + d.Align(4) + if d.err != nil { + return 0 + } + if d.pos+4 > len(d.data) { + d.err = fmt.Errorf("ndr: short read at offset %d (uint32)", d.pos) + return 0 + } + v := binary.LittleEndian.Uint32(d.data[d.pos:]) + d.pos += 4 + return v +} + +// ReadUint64 reads a LONGLONG/UHYPER with 8-byte alignment. +func (d *Decoder) ReadUint64() uint64 { + d.Align(8) + if d.err != nil { + return 0 + } + if d.pos+8 > len(d.data) { + d.err = fmt.Errorf("ndr: short read at offset %d (uint64)", d.pos) + return 0 + } + v := binary.LittleEndian.Uint64(d.data[d.pos:]) + d.pos += 8 + return v +} + +// ReadBytes reads n raw bytes with no alignment. Returns a slice that +// aliases the underlying buffer; copy before mutating. +func (d *Decoder) ReadBytes(n int) []byte { + if d.err != nil { + return nil + } + if d.pos+n > len(d.data) { + d.err = fmt.Errorf("ndr: short read at offset %d (%d bytes)", d.pos, n) + return nil + } + v := d.data[d.pos : d.pos+n] + d.pos += n + return v +} + +// ReadGUID reads a 16-byte GUID. GUIDs in NDR require 4-byte alignment +// (their max internal alignment, since UUID is a struct of DWORD/USHORTs). +func (d *Decoder) ReadGUID() [16]byte { + d.Align(4) + var g [16]byte + if b := d.ReadBytes(16); b != nil { + copy(g[:], b) + } + return g +} + +// ReadPointer reads a pointer referent ID (ULONG). Zero means NULL, which +// means the pointed-to data is NOT serialized. Non-zero means the deferred +// data for this pointer will appear later. +func (d *Decoder) ReadPointer() uint32 { return d.ReadUint32() } + +// ReadConformance reads the MaxCount prefix of a conformant array (ULONG, +// 4-byte aligned). +func (d *Decoder) ReadConformance() uint32 { return d.ReadUint32() } + +// ReadConformantVaryingHeader reads the MaxCount + Offset + ActualCount +// prefix of a conformant-varying array. Returns (maxCount, offset, +// actualCount). Most callers ignore offset (always 0) and use actualCount +// as the element count. +func (d *Decoder) ReadConformantVaryingHeader() (maxCount, offset, actualCount uint32) { + maxCount = d.ReadUint32() + offset = d.ReadUint32() + actualCount = d.ReadUint32() + return +} + +// ReadUTF16LEString reads chars * 2 bytes as UTF-16LE, trimming a trailing +// null character if present. +func (d *Decoder) ReadUTF16LEString(chars uint32) string { + if chars == 0 { + return "" + } + b := d.ReadBytes(int(chars) * 2) + if b == nil { + return "" + } + s := utf16le.DecodeToString(b) + // Trim trailing NULs. NDR usually includes the null terminator in the + // serialized length, so callers see it as a stray empty codepoint. + for len(s) > 0 && s[len(s)-1] == 0 { + s = s[:len(s)-1] + } + return s +}