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.
This commit is contained in:
psycep
2026-04-23 14:52:39 -05:00
parent 5c03c57191
commit 8127aece41
3 changed files with 603 additions and 956 deletions
+10 -956
View File
@@ -387,11 +387,16 @@ func parseGetNCChangesResponse(resp []byte, sessionKey []byte) (*GetNCChangesRes
case 1: case 1:
// V1 is a simple/error response // V1 is a simple/error response
return parseGetNCChangesResponseV1(r) return parseGetNCChangesResponseV1(r)
case 6: case 6, 7, 9:
return parseGetNCChangesResponseV6(resp, sessionKey) // V6 is the canonical shape; V7 and V9 add fields after V6 that we
case 7, 9: // currently don't consume. Implementation lives in getncchanges_v6.go.
// V7 and V9 are similar to V6 with additional fields // On parse error we still return whatever objects were extracted
return parseGetNCChangesResponseV6(resp, sessionKey) // 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: default:
return nil, fmt.Errorf("unsupported response version: %d", outVersion) 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) { func processAttribute(attrTyp uint32, valData []byte, obj *ReplicatedObject, sessionKey []byte) {
switch attrTyp { switch attrTyp {
+370
View File
@@ -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)
}
}
}
+223
View File
@@ -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
}