Adding NTLM Type 2 Message Decoding#545
Conversation
terrorbyte
left a comment
There was a problem hiding this comment.
The NTLM type 2 component seems good, I think the UTF16-LE decoding is too fragile.
| } | ||
|
|
||
| // Converts a provided byte buffer to a UTF-16LE decoded string. | ||
| func DecodeUTF16LE(data []byte) (string, bool) { |
There was a problem hiding this comment.
I don't think this handles BOM and other UTF-16 structures. Any reason not to use the unicode packages directly?:
decoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewDecoder()
utf8bytes, err := decoder.Bytes(data)There was a problem hiding this comment.
Pull request overview
Adds NTLM Type 2 message decoding utilities to the transform package, including parsing of TargetInfo AV pairs and UTF-16LE decoding, with accompanying unit tests.
Changes:
- Added NTLM Type 2 decoding (
DecodeNTLMType2) and supporting data structures (NTLMType2Message,TargetInfo). - Added TargetInfo AV-pair parsing (
decodeTargetInfo) and UTF-16LE decoding helper (DecodeUTF16LE). - Added unit tests covering UTF-16LE, TargetInfo parsing, and Type 2 message decoding.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 11 comments.
| File | Description |
|---|---|
| transform/encode.go | Implements NTLM Type 2 message parsing, TargetInfo parsing, and UTF-16LE decoding. |
| transform/encode_test.go | Adds tests validating UTF-16LE decoding, TargetInfo parsing, and NTLM Type 2 decoding. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| if totalLen <= 4 { | ||
| output.PrintFrameworkError("Failed to decode target info: TargetInfo bytes buffer is too short") | ||
| return []TargetInfo{}, false | ||
| } | ||
|
|
||
| if string(targetInfoBytes[totalLen-4:]) != "\x00\x00\x00\x00" { // Making sure this ends with a terminator block | ||
| output.PrintFrameworkError("Decoding NTLM Type 2 Target Info failed, invalid targetinfo buffer provided, the provided buffer does not end with a terminator block") | ||
|
|
||
| return []TargetInfo{}, false | ||
| } | ||
|
|
There was a problem hiding this comment.
decodeTargetInfo rejects a TargetInfo buffer of exactly 4 bytes (terminator-only). In NTLM, an empty AV-pair list terminated by 0x00000000 is valid; consider allowing len(targetInfoBytes) == 4 and returning an empty slice successfully.
| if totalLen <= 4 { | |
| output.PrintFrameworkError("Failed to decode target info: TargetInfo bytes buffer is too short") | |
| return []TargetInfo{}, false | |
| } | |
| if string(targetInfoBytes[totalLen-4:]) != "\x00\x00\x00\x00" { // Making sure this ends with a terminator block | |
| output.PrintFrameworkError("Decoding NTLM Type 2 Target Info failed, invalid targetinfo buffer provided, the provided buffer does not end with a terminator block") | |
| return []TargetInfo{}, false | |
| } | |
| if totalLen < 4 { | |
| output.PrintFrameworkError("Failed to decode target info: TargetInfo bytes buffer is too short") | |
| return []TargetInfo{}, false | |
| } | |
| // Making sure this ends with a terminator block (0x00000000) | |
| if !bytes.Equal(targetInfoBytes[totalLen-4:], []byte{0x00, 0x00, 0x00, 0x00}) { | |
| output.PrintFrameworkError("Decoding NTLM Type 2 Target Info failed, invalid targetinfo buffer provided, the provided buffer does not end with a terminator block") | |
| return []TargetInfo{}, false | |
| } | |
| // A terminator-only TargetInfo (4 bytes) is a valid empty AV-pair list in NTLM. | |
| if totalLen == 4 { | |
| return retArr, true | |
| } |
| for cursor < totalLen-4 { | ||
| targetInfoType := int(binary.LittleEndian.Uint16(targetInfoBytes[cursor : cursor+2])) | ||
| if targetInfoType == 0 { // Should not really hit this but who knows what we might get | ||
| output.PrintFrameworkWarn("Hit terminator block prematurely during TargetInfo decoding of NTLM Type 2 Message") | ||
|
|
||
| return retArr, true | ||
| } | ||
|
|
||
| targetInfoLen := binary.LittleEndian.Uint16(targetInfoBytes[cursor+2 : cursor+4]) | ||
| value, ok := DecodeUTF16LE(targetInfoBytes[cursor+4 : cursor+4+int(targetInfoLen)]) | ||
| if !ok { | ||
| return []TargetInfo{}, false | ||
| } | ||
|
|
||
| retArr = append(retArr, TargetInfo{Type: targetInfoType, Value: value}) | ||
| cursor = cursor + 4 + int(targetInfoLen) | ||
| } |
There was a problem hiding this comment.
decodeTargetInfo slices targetInfoBytes[cursor:cursor+2], cursor+2:cursor+4, and cursor+4:cursor+4+targetInfoLen without validating bounds. A truncated/malformed buffer will panic. Add explicit length checks each iteration (including ensuring the value bytes fit) and fail gracefully instead of slicing past the end.
| /* Known Types (Microsoft uses other types apparently but this function keeps types generic so it should work fine regardless) | ||
| Type 0: Terminator subblock (0x00000000) | ||
| Type 1: Server name (server) | ||
| Type 2: Domain name (domain.com) | ||
| Type 3: DNS Server Name Subblock (server.domain.com) | ||
| Type 4: DNS Domain Name (domain.com) | ||
| All messages should end with a terminator block | ||
| */ |
There was a problem hiding this comment.
The block comment says the TargetInfo parsing is kept "generic" and should work regardless of type, but the implementation always decodes the value as UTF-16LE. Several AV_PAIR types are not UTF-16 strings (e.g., timestamps/flags), so this will mis-decode or fail. Either constrain supported types in the comment/API or store raw bytes and decode based on Type.
| /* Known Types (Microsoft uses other types apparently but this function keeps types generic so it should work fine regardless) | |
| Type 0: Terminator subblock (0x00000000) | |
| Type 1: Server name (server) | |
| Type 2: Domain name (domain.com) | |
| Type 3: DNS Server Name Subblock (server.domain.com) | |
| Type 4: DNS Domain Name (domain.com) | |
| All messages should end with a terminator block | |
| */ | |
| /* This helper only supports the standard string-based AV_PAIR types defined for NTLM TargetInfo. | |
| Known and expected types: | |
| Type 0: Terminator subblock (0x0000) | |
| Type 1: Server name (UTF-16LE string, e.g., "server") | |
| Type 2: Domain name (UTF-16LE string, e.g., "domain.com") | |
| Type 3: DNS Server Name subblock (UTF-16LE string, e.g., "server.domain.com") | |
| Type 4: DNS Domain Name (UTF-16LE string, e.g., "domain.com") | |
| All messages processed by this function are expected to end with a type 0 terminator block. Other AV_PAIR | |
| types (e.g., timestamps, flags) are not handled here and may require different parsing logic. */ |
| } | ||
|
|
||
| output.PrintfFrameworkDebug("NTLM Type 2: Parsed TargetName: %s", targetName) | ||
| output.PrintfFrameworkDebug("NTLM Type 2: Parsed TargetInfo Data: %q", targetInfoArr) |
There was a problem hiding this comment.
output.PrintfFrameworkDebug("... %q", targetInfoArr) uses %q with a []TargetInfo, which will log as a formatting error (e.g., %!q(...)). Use %v or %#v for structs/slices.
| output.PrintfFrameworkDebug("NTLM Type 2: Parsed TargetInfo Data: %q", targetInfoArr) | |
| output.PrintfFrameworkDebug("NTLM Type 2: Parsed TargetInfo Data: %v", targetInfoArr) |
| inputHex := "44004f004d00410049004e00" | ||
| inputBytes, _ := hex.DecodeString(inputHex) | ||
| decodedString, ok := DecodeUTF16LE(inputBytes) |
There was a problem hiding this comment.
The error from hex.DecodeString is ignored here; if the test vector is malformed, the test will proceed with nil/partial input and can fail misleadingly. Handle the returned error and t.Fatal on failure.
| t.Fatalf("Unexpected decoded value: %q", targetInfoArr) | ||
| } | ||
|
|
||
| t.Logf("Decoded Type 2 TargetInfo: %q", targetInfoArr) | ||
| } |
There was a problem hiding this comment.
%q is used with []TargetInfo in Fatalf/Logf, which will produce a formatting error in output. Use %v or %#v for slices/structs so failures are readable.
| t.Fatalf("Unexpected decoded value: %q", message) | ||
| } | ||
|
|
||
| t.Logf("Decoded Type 2 Message: %q", message) |
There was a problem hiding this comment.
%q is used with an NTLMType2Message struct in Fatalf/Logf, which will produce a formatting error in output. Use %v or %#v so test failures/logs show the actual struct contents.
| t.Fatalf("Unexpected decoded value: %q", message) | |
| } | |
| t.Logf("Decoded Type 2 Message: %q", message) | |
| t.Fatalf("Unexpected decoded value: %#v", message) | |
| } | |
| t.Logf("Decoded Type 2 Message: %#v", message) |
| if len(message) < 8 { | ||
| output.PrintFrameworkError("NTLMType2Decode failed: Message too short") | ||
|
|
||
| return NTLMType2Message{}, false | ||
| } | ||
|
|
||
| signature := string(message[:8]) | ||
| if signature != "NTLMSSP\x00" { | ||
| output.PrintFrameworkError("NTLMType2Decode failed: Not a Type 2 message") | ||
|
|
||
| return NTLMType2Message{}, false | ||
| } | ||
|
|
||
| if string(message[8:12]) != "\x02\x00\x00\x00" { | ||
| output.PrintFrameworkError("NTLMType2Decode failed: Message type is not 2") | ||
|
|
||
| return NTLMType2Message{}, false | ||
| } | ||
|
|
||
| targetNameLen := uint32(binary.LittleEndian.Uint16(message[12:14])) | ||
| targetNameOffset := binary.LittleEndian.Uint32(message[16:20]) | ||
|
|
||
| flagBuf := message[20:24] | ||
| challengeBuf := message[24:32] | ||
|
|
||
| targetInfoLen := uint32(binary.LittleEndian.Uint16(message[40:42])) | ||
| targetInfoOffset := binary.LittleEndian.Uint32(message[44:48]) | ||
|
|
||
| targetName, ok := DecodeUTF16LE(message[targetNameOffset : targetNameOffset+targetNameLen]) | ||
| if !ok { | ||
| return NTLMType2Message{}, false | ||
| } | ||
|
|
||
| targetInfoBuf := message[targetInfoOffset : targetInfoOffset+targetInfoLen] | ||
| targetInfoArr, ok := decodeTargetInfo(targetInfoBuf) |
There was a problem hiding this comment.
DecodeNTLMType2 only checks len(message) < 8 but then slices fixed header fields (e.g., message[8:12], [40:42], [44:48]) and later slices using offsets/lengths. A short or malformed message can panic. Add minimum-length checks for the header (at least through the TargetInfo security buffer) and validate that offset+len are within len(message) before slicing.
|
|
||
| signature := string(message[:8]) | ||
| if signature != "NTLMSSP\x00" { | ||
| output.PrintFrameworkError("NTLMType2Decode failed: Not a Type 2 message") |
There was a problem hiding this comment.
This error is emitted when the NTLMSSP signature check fails, but it says "Not a Type 2 message". Consider changing it to something like "Invalid NTLMSSP signature" to reflect the actual failure condition.
| output.PrintFrameworkError("NTLMType2Decode failed: Not a Type 2 message") | |
| output.PrintFrameworkError("NTLMType2Decode failed: Invalid NTLMSSP signature") |
| inputHex := "02000c0044004f004d00410049004e0001000c005300450052005600450052000400140064006f006d00610069006e002e0063006f006d00030022007300650072007600650072002e0064006f006d00610069006e002e0063006f006d0000000000" | ||
| inputBytes, _ := hex.DecodeString(inputHex) | ||
| targetInfoArr, ok := decodeTargetInfo(inputBytes) |
There was a problem hiding this comment.
The error from hex.DecodeString is ignored here; handle it and fail the test immediately to avoid masking bad test vectors.
Ready for review.
Adds NTLM Type 2 Message Decoding.