From 8cdb67a92d8ea48039e51ebe211c708a940bc55a Mon Sep 17 00:00:00 2001 From: Jacob Paullus <93807253+psycep@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:17:28 -0500 Subject: [PATCH] smb2: support true anonymous (null session) NTLM bind Allow NTLMInitiator with empty User/Password/Hash to establish an SMB anonymous (null) session instead of refusing at dial time. - client.go: drop the dial-time guard that rejected empty-user NTLM initiators outright. - internal/ntlm/client.go: add an anonymous AUTHENTICATE branch per [MS-NLMP] 3.1.5.1.2 -- empty NtChallengeResponse, a single 0x00 byte LmChallengeResponse, NTLMSSP_ANONYMOUS set, no MIC and no session key. DomainName/UserName/Workstation are forced empty and the signing, sealing and key-exchange flags are cleared (leaving KEY_EXCH set with a zero-length EncryptedRandomSessionKey makes strict servers such as Samba reject the bind with STATUS_INVALID_PARAMETER). - initiator.go: nil-guard Sum/SessionKey/infoMap so the keyless anonymous path cannot panic. Verified against Samba: a server permitting null sessions accepts the bind (logged as ANONYMOUS LOGON, S-1-5-7) and lists shares; a server with restrict anonymous=2 fails gracefully via os.ErrPermission; authenticated binds are unchanged. --- pkg/third_party/smb2/client.go | 5 -- pkg/third_party/smb2/initiator.go | 9 +++ pkg/third_party/smb2/internal/ntlm/client.go | 40 +++++++++- .../smb2/internal/ntlm/ntlm_test.go | 73 +++++++++++++++++++ 4 files changed, 120 insertions(+), 7 deletions(-) diff --git a/pkg/third_party/smb2/client.go b/pkg/third_party/smb2/client.go index a98a176..ee41d9e 100644 --- a/pkg/third_party/smb2/client.go +++ b/pkg/third_party/smb2/client.go @@ -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 { diff --git a/pkg/third_party/smb2/initiator.go b/pkg/third_party/smb2/initiator.go index 220b296..8fb3c16 100644 --- a/pkg/third_party/smb2/initiator.go +++ b/pkg/third_party/smb2/initiator.go @@ -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() } diff --git a/pkg/third_party/smb2/internal/ntlm/client.go b/pkg/third_party/smb2/internal/ntlm/client.go index 3cb819e..42cb507 100644 --- a/pkg/third_party/smb2/internal/ntlm/client.go +++ b/pkg/third_party/smb2/internal/ntlm/client.go @@ -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 diff --git a/pkg/third_party/smb2/internal/ntlm/ntlm_test.go b/pkg/third_party/smb2/internal/ntlm/ntlm_test.go index 69a90a1..5dc2b42 100644 --- a/pkg/third_party/smb2/internal/ntlm/ntlm_test.go +++ b/pkg/third_party/smb2/internal/ntlm/ntlm_test.go @@ -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",