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 +}