Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/Infisical/infisical-merge
go 1.25.9

require (
github.com/Azure/go-ntlmssp v0.1.1
github.com/BobuSumisu/aho-corasick v1.0.3
github.com/Masterminds/sprig/v3 v3.3.0
github.com/awnumar/memguard v0.23.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BobuSumisu/aho-corasick v1.0.3 h1:uuf+JHwU9CHP2Vx+wAy6jcksJThhJS9ehR8a+4nPE9g=
github.com/BobuSumisu/aho-corasick v1.0.3/go.mod h1:hm4jLcvZKI2vRF2WDU1N4p/jpWtpOzp3nLmi9AzX/XE=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
Expand Down
116 changes: 109 additions & 7 deletions packages/pam/handlers/mssql/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"sync"
"time"

"github.com/Azure/go-ntlmssp"
"github.com/Infisical/infisical-merge/packages/pam/session"
"github.com/rs/zerolog/log"
)
Expand All @@ -18,6 +19,8 @@
InjectUsername string
InjectPassword string
InjectDatabase string
InjectDomain string
AuthMethod string // "sql-login" or "ntlm"
Comment thread
saifsmailbox98 marked this conversation as resolved.
Outdated
EnableTLS bool
TLSConfig *tls.Config
SessionID string
Expand Down Expand Up @@ -231,7 +234,19 @@
log.Info().Str("sessionID", p.config.SessionID).Msg("TLS established with server")
}

// 4. Send LOGIN7 with injected credentials
if p.config.AuthMethod == "ntlm" {
return p.authenticateNTLM(serverConn)
}
return p.authenticateSQL(serverConn)
}

func (p *MssqlProxy) authenticateSQL(serverConn net.Conn) (_ net.Conn, _ []*TDSPacket, retErr error) {
defer func() {
if retErr != nil {
serverConn.Close()
}
}()

loginMsg := &Login7Message{
Username: p.config.InjectUsername,
Password: p.config.InjectPassword,
Expand All @@ -247,7 +262,6 @@
Payload: loginMsg.Encode(),
}
if err := loginPkt.Write(serverConn); err != nil {
serverConn.Close()
return nil, nil, fmt.Errorf("send login to server: %w", err)
}

Expand All @@ -257,11 +271,8 @@
Int("loginPktLen", len(loginPkt.Payload)+TDSHeaderSize).
Msg("Sent LOGIN7 to server")

// 5. Read login response - forward to client
log.Info().Str("sessionID", p.config.SessionID).Msg("Waiting for login response...")
response, err := ReadAllPackets(serverConn)
if err != nil {
serverConn.Close()
return nil, nil, fmt.Errorf("read login response: %w", err)
}
log.Info().
Expand All @@ -271,18 +282,109 @@

respPayload := CombinePayloads(response)
if ContainsToken(respPayload, TokenError) {
serverConn.Close()
return nil, nil, fmt.Errorf("server authentication failed")
}
if !ContainsToken(respPayload, TokenLoginAck) {
serverConn.Close()
return nil, nil, fmt.Errorf("no login ack from server")
}

log.Info().Str("sessionID", p.config.SessionID).Msg("MSSQL server authentication successful")
return serverConn, response, nil
}

func (p *MssqlProxy) authenticateNTLM(serverConn net.Conn) (_ net.Conn, _ []*TDSPacket, retErr error) {
defer func() {
if retErr != nil {
serverConn.Close()
}
}()

negotiate, err := ntlmssp.NewNegotiateMessage(p.config.InjectDomain, "infisical-proxy")
if err != nil {
return nil, nil, fmt.Errorf("create NTLM negotiate message: %w", err)
}

loginMsg := &Login7Message{
Database: p.config.InjectDatabase,
AppName: "Infisical PAM Proxy",
Hostname: "infisical-proxy",
SSPIData: negotiate,
}

loginPkt := &TDSPacket{
Type: PacketTypeLogin7,
Status: StatusEOM,
PacketID: 1,
Payload: loginMsg.Encode(),
}
if err := loginPkt.Write(serverConn); err != nil {
return nil, nil, fmt.Errorf("send NTLM login to server: %w", err)
}

log.Info().
Str("sessionID", p.config.SessionID).
Str("domain", p.config.InjectDomain).
Str("user", p.config.InjectUsername).
Msg("Sent LOGIN7 with NTLM negotiate to server")

challengeResponse, err := ReadAllPackets(serverConn)
if err != nil {
return nil, nil, fmt.Errorf("read NTLM challenge: %w", err)
}

challengePayload := CombinePayloads(challengeResponse)

if ContainsToken(challengePayload, TokenError) {
return nil, nil, fmt.Errorf("server rejected NTLM negotiate")

Check failure on line 338 in packages/pam/handlers/mssql/proxy.go

View check run for this annotation

Claude / Claude Code Review

NTLM challenge ContainsToken byte-scan triggers spurious auth failures

The new `authenticateNTLM` path checks for server errors with `ContainsToken(challengePayload, TokenError)` (proxy.go:337), but `ContainsToken` is a naive byte scan and the challenge payload is mostly opaque binary (random 8-byte `ServerChallenge`, FILETIME timestamps, `NegotiateFlags`, etc.). Whenever any of those bytes happens to equal `0xAA`, the proxy returns "server rejected NTLM negotiate" even though the server sent a valid challenge — yielding intermittent NTLM auth failures (roughly 3%+
Comment thread
saifsmailbox98 marked this conversation as resolved.
Outdated
}

challengeToken, err := ExtractSSPIToken(challengePayload)
if err != nil {
return nil, nil, fmt.Errorf("extract NTLM challenge: %w", err)
}

log.Info().
Str("sessionID", p.config.SessionID).
Int("challengeLen", len(challengeToken)).
Msg("Received NTLM challenge from server")

// domainNeeded=true: include domain in the NTLM authenticate response
authenticate, err := ntlmssp.ProcessChallenge(challengeToken, p.config.InjectUsername, p.config.InjectPassword, true)
if err != nil {
return nil, nil, fmt.Errorf("process NTLM challenge: %w", err)
}
Comment thread
saifsmailbox98 marked this conversation as resolved.

sspiPkt := &TDSPacket{
Type: PacketTypeSSPI,
Status: StatusEOM,
PacketID: 1,
Payload: authenticate,
}
if err := sspiPkt.Write(serverConn); err != nil {
return nil, nil, fmt.Errorf("send NTLM authenticate: %w", err)
}

log.Info().
Str("sessionID", p.config.SessionID).
Msg("Sent NTLM authenticate to server")

response, err := ReadAllPackets(serverConn)
if err != nil {
return nil, nil, fmt.Errorf("read NTLM login response: %w", err)
}

respPayload := CombinePayloads(response)
if ContainsToken(respPayload, TokenError) {
return nil, nil, fmt.Errorf("NTLM authentication failed")
}
if !ContainsToken(respPayload, TokenLoginAck) {
return nil, nil, fmt.Errorf("no login ack after NTLM authentication")
}

log.Info().Str("sessionID", p.config.SessionID).Msg("MSSQL NTLM authentication successful")
return serverConn, response, nil
}

func (p *MssqlProxy) proxyToServer(client, server net.Conn, errCh chan error) {
defer func() {
if r := recover(); r != nil {
Expand Down
48 changes: 43 additions & 5 deletions packages/pam/handlers/mssql/tds.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@
MaxPackets = 100
)

var ntlmsspSignature = []byte("NTLMSSP\x00")

// ExtractSSPIToken finds the NTLM token in a TDS server response by scanning for the NTLMSSP signature.
func ExtractSSPIToken(payload []byte) ([]byte, error) {
idx := bytes.Index(payload, ntlmsspSignature)
if idx < 0 {
return nil, fmt.Errorf("no NTLMSSP token found in server response")
}
return payload[idx:], nil
}

// TDSPacket represents a TDS packet
type TDSPacket struct {
Type uint8
Expand Down Expand Up @@ -294,6 +305,7 @@
Password string
AppName string
Database string
SSPIData []byte
}

// ParseLogin7 parses a LOGIN7 message (extracts only what we need)
Expand Down Expand Up @@ -324,7 +336,8 @@
fSetLang = 0x80

// OptionFlags2
fODBC = 0x02
fODBC = 0x02
fIntSecurity = 0x80 // Integrated Security (SSPI/NTLM)
)

// Encode serializes the LOGIN7 message
Expand All @@ -341,9 +354,19 @@
m.Header.OptionFlags1 = fUseDB | fSetLang
m.Header.OptionFlags2 = fODBC

useSSPI := len(m.SSPIData) > 0

hostname := encodeUTF16(m.Hostname)
username := encodeUTF16(m.Username)
password := manglePassword(m.Password)
var username, password []byte
if useSSPI {
// NTLM: username and password are empty in LOGIN7; auth is via SSPI blob
username = nil
password = nil
m.Header.OptionFlags2 |= fIntSecurity
} else {
username = encodeUTF16(m.Username)
password = manglePassword(m.Password)
}
appname := encodeUTF16(m.AppName)
database := encodeUTF16(m.Database)
cltIntName := encodeUTF16("ODBC") // Client interface name
Expand Down Expand Up @@ -385,12 +408,24 @@
offset += uint16(len(database))

m.Header.SSPIOffset = offset
m.Header.SSPILength = 0
if useSSPI {
sspiLen := len(m.SSPIData)
if sspiLen <= 65535 {
m.Header.SSPILength = uint16(sspiLen)
m.Header.SSPILongLength = 0
} else {
m.Header.SSPILength = 0
m.Header.SSPILongLength = uint32(sspiLen)
}
offset += uint16(sspiLen)
} else {
m.Header.SSPILength = 0

Check warning on line 422 in packages/pam/handlers/mssql/tds.go

View check run for this annotation

Claude / Claude Code Review

SSPILength boundary: 65535 uses sentinel value, server reads zero bytes

**nit**: In `Login7Message.Encode` (tds.go:411-422), the SSPI length boundary uses `if sspiLen <= 65535`, which sets `SSPILength = 0xFFFF` and `SSPILongLength = 0` when `sspiLen` is exactly 65535. Per MS-TDS 2.2.6.4, `cbSSPI = 0xFFFF` is a sentinel directing the server to read `cbSSPILong` for the real length — so a 65535-byte payload would be interpreted as a 0-byte SSPI blob. In practice this boundary is unreachable (go-ntlmssp negotiate/authenticate messages are well under 2KB), but the fix i
Comment thread
saifsmailbox98 marked this conversation as resolved.
m.Header.SSPILongLength = 0
}
m.Header.AtchDBFileOffset = offset
m.Header.AtchDBFileLength = 0
m.Header.ChangePasswordOff = offset
m.Header.ChangePasswordLen = 0
m.Header.SSPILongLength = 0

m.Header.Length = uint32(offset)

Expand All @@ -404,6 +439,9 @@
buf.Write(appname)
buf.Write(cltIntName)
buf.Write(database)
if useSSPI {
buf.Write(m.SSPIData)
}

return buf.Bytes()
}
Expand Down
2 changes: 2 additions & 0 deletions packages/pam/pam-proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,8 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo
InjectUsername: credentials.Username,
InjectPassword: credentials.Password,
InjectDatabase: credentials.Database,
InjectDomain: credentials.Domain,
AuthMethod: credentials.AuthMethod,
EnableTLS: credentials.SSLEnabled,
TLSConfig: tlsConfig,
SessionID: pamConfig.SessionId,
Expand Down
Loading