Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ require (
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c
github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb
github.com/projectdiscovery/goflags v0.1.74
github.com/projectdiscovery/gologger v1.1.69
github.com/projectdiscovery/gologger v1.1.68
github.com/projectdiscovery/gostruct v0.0.2
github.com/projectdiscovery/govaluate v0.0.0-20260504230327-80320480bb6e
github.com/projectdiscovery/gozero v0.1.1-0.20251027191944-a4ea43320b81
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -879,8 +879,8 @@ github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb h1:rutG90
github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb/go.mod h1:FLjF1DmZ+POoGEiIQdWuYVwS++C/GwpX8YaCsTSm1RY=
github.com/projectdiscovery/goflags v0.1.74 h1:n85uTRj5qMosm0PFBfsvOL24I7TdWRcWq/1GynhXS7c=
github.com/projectdiscovery/goflags v0.1.74/go.mod h1:UMc9/7dFz2oln+10tv6cy+7WZKTHf9UGhaNkF95emh4=
github.com/projectdiscovery/gologger v1.1.69 h1:lj839gk8x0RhS5tOi9aqYQ+LGU0v7JwEL1Kitrqzt/k=
github.com/projectdiscovery/gologger v1.1.69/go.mod h1:kpLKNafZWRN9P7WpJYtIOY/XvY/v41GDdU9NzICdKmo=
github.com/projectdiscovery/gologger v1.1.68 h1:KfdIO/3X7BtHssWZuqhxPZ+A946epCCx2cz+3NnRAnU=
github.com/projectdiscovery/gologger v1.1.68/go.mod h1:Xae0t4SeqJVa0RQGK9iECx/+HfXhvq70nqOQp2BuW+o=
github.com/projectdiscovery/gostruct v0.0.2 h1:s8gP8ApugGM4go1pA+sVlPDXaWqNP5BBDDSv7VEdG1M=
github.com/projectdiscovery/gostruct v0.0.2/go.mod h1:H86peL4HKwMXcQQtEa6lmC8FuD9XFt6gkNR0B/Mu5PE=
github.com/projectdiscovery/govaluate v0.0.0-20260504230327-80320480bb6e h1:o+ulEIaC2+9V2Ezr6mI5xEhKWsf0V/+FUQIS723Aj6U=
Expand Down
8 changes: 5 additions & 3 deletions pkg/utils/telnetmini/ntlm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
134 changes: 134 additions & 0 deletions pkg/utils/telnetmini/ntlm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package telnetmini

import (
"encoding/binary"
"strings"
"testing"
)

// buildChallenge constructs a minimal 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)

copy(ntlm[0:], "NTLMSSP\x00")

if headerLen >= 12 {
binary.LittleEndian.PutUint32(ntlm[8:12], 2)
}

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)

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)

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")
}
}

// TestParseNTLMResponse_Minimal48Bytes verifies that a challenge with exactly
// 48 bytes (the minimum valid fixed-header size, no target name or info) is
// accepted — confirming the boundary condition of the len<48 guard.
func TestParseNTLMResponse_Minimal48Bytes(t *testing.T) {
data := buildChallenge(48)
resp, err := ParseNTLMResponse(data)
if err != nil {
t.Fatalf("48-byte challenge should be valid, got error: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response for 48-byte challenge")
}
}

func TestParseNTLMResponse_ErrorCases(t *testing.T) {
wrongTypeChallenge := buildChallenge(48)
binary.LittleEndian.PutUint32(wrongTypeChallenge[8:12], 1)

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: wrongTypeChallenge,
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)
}
}
}
Loading