fix(telnetmini): replace len<12 guard with len<48 to prevent slice-bounds panic on truncated NTLM challenge

ParseNTLMResponse read fixed-header fields at offsets 12-14, 16-20, 40-42,
and 44-48 after only checking len>=12, causing a slice-bounds-out-of-range
panic for any NTLM section between 12 and 47 bytes. Because this input comes
from untrusted network data, a malicious or malformed telnet server could crash
the scanner (or the embedding host process when used via the SDK).

Raise the guard to len<48 so the full fixed header is present before any field
is touched. Add unit tests covering the happy path, existing error cases, and a
no-panic regression table for every truncated length in [12,47].

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
tejgokaniandClaude Sonnet 4.6 committed 2026-05-25 14:47:57 +05:30
1 parent 3684849ac9
commit 1aff867491
2 files changed
+131 -3

No files matched your search

+5 -3
View File
@@ -40,9 +40,11 @@ func ParseNTLMResponse(data []byte) (*NTLMInfoResponse, error) {
// Extract NTLM data (NTLMSSP.*\xff\xf0)
ntlmData := data[ntlmStart : ntlmStart+ntlmEnd]
// Check message type (should be 2 for Challenge)
if len(ntlmData) < 12 {
return nil, fmt.Errorf("NTLM response too short")
// Check message type (should be 2 for Challenge).
// The fixed header runs to offset 48 (target-info offset field ends at byte 48),
// so reject anything shorter before touching any field offsets.
if len(ntlmData) < 48 {
return nil, fmt.Errorf("NTLM response too short: need at least 48 bytes, got %d", len(ntlmData))
}
messageType := binary.LittleEndian.Uint32(ntlmData[8:12])
+126
View File
@@ -0,0 +1,126 @@
package telnetmini
import (
"encoding/binary"
"strings"
"testing"
)
// buildChallenge constructs a minimal well-formed NTLM type-2 challenge message
// of exactly headerLen bytes, wrapped in the telnet framing expected by ParseNTLMResponse.
func buildChallenge(headerLen int) []byte {
ntlm := make([]byte, headerLen)
// NTLMSSP\0 signature (8 bytes)
copy(ntlm[0:], "NTLMSSP\x00")
// Message type 2 (Challenge) at offset 8
if headerLen >= 12 {
binary.LittleEndian.PutUint32(ntlm[8:12], 2)
}
// Wrap with telnet framing: prefix + Sub-option End terminator
var out []byte
out = append(out, ntlm...)
out = append(out, 0xFF, 0xF0)
return out
}
// buildValidChallenge returns a fully-formed 48-byte NTLM type-2 challenge with
// a small UTF-16LE target name appended after the fixed header.
func buildValidChallenge() []byte {
targetName := []byte("W\x00I\x00N\x00") // "WIN" in UTF-16LE
ntlm := make([]byte, 48+len(targetName))
copy(ntlm[0:], "NTLMSSP\x00")
binary.LittleEndian.PutUint32(ntlm[8:12], 2)
// Target name: len=6, max len=6, offset=48
binary.LittleEndian.PutUint16(ntlm[12:14], uint16(len(targetName)))
binary.LittleEndian.PutUint16(ntlm[14:16], uint16(len(targetName)))
binary.LittleEndian.PutUint32(ntlm[16:20], 48)
// Negotiate flags at 20 (4 bytes) – zero is fine for this test
// Server challenge at 24 (8 bytes) – zero
// Reserved at 32 (8 bytes) – zero
// Target info: len=0, offset=48 (no target info block)
binary.LittleEndian.PutUint16(ntlm[40:42], 0)
binary.LittleEndian.PutUint32(ntlm[44:48], 48)
copy(ntlm[48:], targetName)
var out []byte
out = append(out, ntlm...)
out = append(out, 0xFF, 0xF0)
return out
}
func TestParseNTLMResponse_Valid(t *testing.T) {
data := buildValidChallenge()
resp, err := ParseNTLMResponse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response")
}
}
func TestParseNTLMResponse_ErrorCases(t *testing.T) {
tests := []struct {
name string
input []byte
wantErr string
}{
{
name: "nil input",
input: nil,
wantErr: "NTLMSSP signature not found",
},
{
name: "empty input",
input: []byte{},
wantErr: "NTLMSSP signature not found",
},
{
name: "missing NTLMSSP signature",
input: []byte("hello world\xFF\xF0"),
wantErr: "NTLMSSP signature not found",
},
{
name: "missing Sub-option End terminator",
input: []byte("NTLMSSP\x00" + strings.Repeat("\x00", 40)),
wantErr: "not properly terminated",
},
{
name: "wrong message type",
input: append(func() []byte { b := make([]byte, 48); copy(b, "NTLMSSP\x00"); binary.LittleEndian.PutUint32(b[8:12], 1); return b }(), 0xFF, 0xF0),
wantErr: "expected NTLM challenge message",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := ParseNTLMResponse(tc.input)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Errorf("error %q does not contain %q", err.Error(), tc.wantErr)
}
})
}
}
// TestParseNTLMResponse_TruncatedNoPanic verifies that every NTLM section length
// in [12, 47] returns an error instead of panicking (regression for slice-bounds bug).
func TestParseNTLMResponse_TruncatedNoPanic(t *testing.T) {
for length := 12; length < 48; length++ {
data := buildChallenge(length)
_, err := ParseNTLMResponse(data)
if err == nil {
t.Errorf("length %d: expected error for truncated challenge, got nil", length)
}
}
}