Skip to content

drsuapi: fix DsGetNCChanges V6 parser to actually extract NTDS hashes#16

Merged
psycep merged 1 commit intomainfrom
drsuapi-ndr-rewrite
Apr 23, 2026
Merged

drsuapi: fix DsGetNCChanges V6 parser to actually extract NTDS hashes#16
psycep merged 1 commit intomainfrom
drsuapi-ndr-rewrite

Conversation

@psycep
Copy link
Copy Markdown
Collaborator

@psycep psycep commented Apr 23, 2026

Summary

  • Rewrites the hand-rolled DRS_MSG_GETCHGREPLY_V6 parser as a declarative NDR struct walker. The old parser drifted out of alignment within the first few REPLENTINFLIST entries, so secretsdump emitted zero NTDS hashes against any modern DC.
  • Adds pkg/dcerpc/drsuapi/ndr.go with NDR Decoder primitives (alignment, conformant arrays, pointer referents, UTF-16LE strings, GUIDs, sticky error model).
  • Adds pkg/dcerpc/drsuapi/getncchanges_v6.go with a parser built from MS-DRSR struct definitions.
  • Removes ~950 lines of now-unreachable legacy V6 helpers from getncchanges.go.

Net: +603 / -956 lines.

Closes #9.

Correctness points the old parser got wrong

  1. V6 fixed header is 148 bytes, not 136. The old code omitted cNumValues + rgValues + dwDRSError and treated those 12 bytes as the start of pNC's DSNAME.
  2. DSNAME.StringName is a pure conformant array (NDRUniConformantArray), not conformant-varying. No Offset/ActualCount header 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 serialization order: it's 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 deferreds 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. This was the final bug blocking full-response parsing.

Verification

Live DC, compared to impacket-secretsdump:

$ diff impacket.txt gopacket.txt
< Guest:501:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::

17/17 real password hashes match byte-for-byte (Administrator, krbtgt, vagrant, KINGSLANDING$, ESSOS$, NORTH$, and all 11 domain users). The single line Impacket emits that we don't is Guest:501 with the empty-password placeholder hash. Guest exists in the replication stream but has no unicodePwd attribute; Impacket emits a placeholder, gopacket filters to accounts where a real hash was extracted. Cosmetic output difference, no credential recovery gap.

Independent spec-driven Python reference walker (not Impacket; Impacket's own fromString on DRS_MSG_GETCHGREPLY_V6 doesn't deeply traverse the linked list): agrees with the Go parser on exactly the 17 unicodePwd-bearing DNs.

Offline parse of a 1MB captured response: 254 objects extracted from 307 replicated entries, end-to-end without drift.

Test plan

  • go build ./...
  • go vet ./...
  • go test ./... (all existing tests pass)
  • Live gopacket secretsdump -just-dc-ntlm against GOAD sevenkingdoms.local DC, all 17 hashes recovered
  • Byte-for-byte diff vs impacket-secretsdump, identical on every real hash
  • Regression for large multi-page dumps (fMoreData=1). Our test blob has fMoreData=0; this code path is unchanged from before but worth exercising against a bigger domain before closing any follow-on issues.

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.
@psycep psycep merged commit 4b90cde into main Apr 23, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Secretsdump doesn't dump NTDS.DIT hashes

1 participant