Merge pull request #36 from mandiant/anonymous-null-session

smb2: support true anonymous (null session) NTLM bind
This commit is contained in:
Jacob Paullus
2026-06-08 18:06:14 -05:00
committed by GitHub
4 changed files with 120 additions and 7 deletions
-5
View File
@@ -46,11 +46,6 @@ func (d *Dialer) DialContext(ctx context.Context, tcpConn net.Conn) (*Session, e
if d.Initiator == nil {
return nil, &InternalError{"Initiator is empty"}
}
if i, ok := d.Initiator.(*NTLMInitiator); ok {
if i.User == "" {
return nil, &InternalError{"Anonymous account is not supported yet. Use guest account instead"}
}
}
maxCreditBalance := d.MaxCreditBalance
if maxCreditBalance == 0 {
+9
View File
@@ -58,15 +58,24 @@ func (i *NTLMInitiator) AcceptSecContext(sc []byte) ([]byte, error) {
}
func (i *NTLMInitiator) Sum(bs []byte) []byte {
if i.ntlm == nil || i.ntlm.Session() == nil {
return nil
}
mic, _ := i.ntlm.Session().Sum(bs, i.seqNum)
return mic
}
func (i *NTLMInitiator) SessionKey() []byte {
if i.ntlm == nil || i.ntlm.Session() == nil {
return nil
}
return i.ntlm.Session().SessionKey()
}
func (i *NTLMInitiator) infoMap() *ntlm.InfoMap {
if i.ntlm == nil || i.ntlm.Session() == nil {
return nil
}
return i.ntlm.Session().InfoMap()
}
+38 -2
View File
@@ -134,7 +134,17 @@ func (c *Client) Authenticate(cmsg []byte) (amsg []byte, err error) {
user := utf16le.EncodeStringToBytes(c.User)
workstation := utf16le.EncodeStringToBytes(c.Workstation)
if domain == nil {
// Anonymous (null session) bind: no username, no password, no hash.
// Per [MS-NLMP] 3.1.5.1.2 DomainName, UserName and Workstation must all
// be empty, so force them empty here (UserName already is) and suppress
// the targetName fallback below regardless of any value the caller set.
anonymous := c.User == "" && c.Password == "" && c.Hash == nil
if anonymous {
domain = nil
workstation = nil
}
if domain == nil && !anonymous {
domain = targetName
}
@@ -179,7 +189,7 @@ func (c *Client) Authenticate(cmsg []byte) (amsg []byte, err error) {
off += len
}
if c.User != "" || c.Password != "" || c.Hash != nil {
if !anonymous {
var err error
var h hash.Hash
@@ -308,6 +318,32 @@ func (c *Client) Authenticate(cmsg []byte) (amsg []byte, err error) {
}
c.session = session
} else {
// Anonymous authentication [MS-NLMP] 3.1.5.1.2:
// NtChallengeResponse: empty
// LmChallengeResponse: Z(1) — a single 0x00 byte
// NTLMSSP_ANONYMOUS set; no MIC; no session key (null sessions aren't signed).
// Clear the signing/sealing/key-exchange flags: we establish no session
// key, so leaving KEY_EXCH set with a zero-length EncryptedRandomSessionKey
// makes strict servers (e.g. Samba) reject the bind with INVALID_PARAMETER.
flags |= NTLMSSP_ANONYMOUS
flags &^= NTLMSSP_NEGOTIATE_KEY_EXCH | NTLMSSP_NEGOTIATE_SIGN | NTLMSSP_NEGOTIATE_SEAL | NTLMSSP_NEGOTIATE_ALWAYS_SIGN
// LmChallengeResponse = single 0x00 byte at the current payload offset.
le.PutUint16(amsg[12:14], 1) // LmChallengeResponseLen
le.PutUint16(amsg[14:16], 1) // LmChallengeResponseMaxLen
le.PutUint32(amsg[16:20], uint32(off)) // LmChallengeResponseBufferOffset
amsg[off] = 0
off++
// NtChallengeResponse + EncryptedRandomSessionKey fields stay zero (empty).
le.PutUint32(amsg[60:64], flags) // NegotiateFlags (with ANONYMOUS)
copy(amsg[64:], version) // Version
// No MIC: leave amsg[72:88] zero. c.session stays nil.
// Trim the buffer (it was sized for a full NTLMv2 response) so no
// stray trailing zero bytes ride along in the token.
amsg = amsg[:off]
}
return amsg, nil
+73
View File
@@ -211,6 +211,79 @@ func TestSeal(t *testing.T) {
}
}
func TestAnonymousAuthenticate(t *testing.T) {
// Empty User/Password/Hash => true anonymous (null session) bind.
// Set Domain/Workstation to confirm they're stripped from the token
// rather than leaked into the payload.
c := &Client{
Domain: "SHOULD_NOT_APPEAR",
Workstation: "SHOULD_NOT_APPEAR",
}
s := NewServer("server")
nmsg, err := c.Negotiate()
if err != nil {
t.Fatal(err)
}
cmsg, err := s.Challenge(nmsg)
if err != nil {
t.Fatal(err)
}
amsg, err := c.Authenticate(cmsg)
if err != nil {
t.Fatalf("anonymous Authenticate returned error: %v", err)
}
if amsg == nil {
t.Fatal("anonymous Authenticate returned nil message")
}
if le.Uint32(amsg[60:64])&NTLMSSP_ANONYMOUS == 0 {
t.Errorf("NTLMSSP_ANONYMOUS flag not set in NegotiateFlags %#x", le.Uint32(amsg[60:64]))
}
if got := le.Uint16(amsg[12:14]); got != 1 {
t.Errorf("LmChallengeResponseLen = %d, want 1", got)
}
if got := le.Uint16(amsg[20:22]); got != 0 {
t.Errorf("NtChallengeResponseLen = %d, want 0", got)
}
if got := le.Uint16(amsg[28:30]); got != 0 {
t.Errorf("DomainNameLen = %d, want 0 (must not leak)", got)
}
if got := le.Uint16(amsg[36:38]); got != 0 {
t.Errorf("UserNameLen = %d, want 0", got)
}
if got := le.Uint16(amsg[44:46]); got != 0 {
t.Errorf("WorkstationLen = %d, want 0 (must not leak)", got)
}
// LmChallengeResponse must be a single 0x00 byte at its declared offset.
lmOff := le.Uint32(amsg[16:20])
if int(lmOff) >= len(amsg) {
t.Fatalf("LmChallengeResponseBufferOffset %d out of range (len %d)", lmOff, len(amsg))
}
if amsg[lmOff] != 0 {
t.Errorf("LmChallengeResponse byte = %#x, want 0x00", amsg[lmOff])
}
// No session key for a null session.
if c.Session() != nil {
t.Error("expected nil session for anonymous bind")
}
// No MIC on the anonymous path.
if !bytes.Equal(amsg[72:88], make([]byte, 16)) {
t.Errorf("expected zero MIC, got %x", amsg[72:88])
}
}
func TestClientServer(t *testing.T) {
c := &Client{
User: "user",