diff --git a/e2e/go.mod b/e2e/go.mod index 44d14afd..328ed660 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -34,6 +34,7 @@ require ( dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Azure/go-ntlmssp v0.1.1 // indirect github.com/BobuSumisu/aho-corasick v1.0.3 // indirect github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e // indirect github.com/Masterminds/goutils v1.1.1 // indirect diff --git a/e2e/go.sum b/e2e/go.sum index 04236c5f..fef3f46e 100644 --- a/e2e/go.sum +++ b/e2e/go.sum @@ -53,6 +53,8 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +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= diff --git a/go.mod b/go.mod index 2ccba870..07d8dde1 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 8ca629d6..2b2740c3 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/packages/pam/handlers/mssql/proxy.go b/packages/pam/handlers/mssql/proxy.go index 5073ba99..514657ee 100644 --- a/packages/pam/handlers/mssql/proxy.go +++ b/packages/pam/handlers/mssql/proxy.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/Azure/go-ntlmssp" "github.com/Infisical/infisical-merge/packages/pam/session" "github.com/rs/zerolog/log" ) @@ -18,6 +19,8 @@ type MssqlProxyConfig struct { InjectUsername string InjectPassword string InjectDatabase string + InjectDomain string + AuthMethod string // "sql-login" or "ntlm" EnableTLS bool TLSConfig *tls.Config SessionID string @@ -231,7 +234,19 @@ func (p *MssqlProxy) connectAndAuthenticateToServer() (net.Conn, []*TDSPacket, e 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, @@ -247,7 +262,6 @@ func (p *MssqlProxy) connectAndAuthenticateToServer() (net.Conn, []*TDSPacket, e Payload: loginMsg.Encode(), } if err := loginPkt.Write(serverConn); err != nil { - serverConn.Close() return nil, nil, fmt.Errorf("send login to server: %w", err) } @@ -257,11 +271,8 @@ func (p *MssqlProxy) connectAndAuthenticateToServer() (net.Conn, []*TDSPacket, e 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(). @@ -271,11 +282,9 @@ func (p *MssqlProxy) connectAndAuthenticateToServer() (net.Conn, []*TDSPacket, e 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") } @@ -283,6 +292,98 @@ func (p *MssqlProxy) connectAndAuthenticateToServer() (net.Conn, []*TDSPacket, e 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) + + challengeToken, err := ExtractSSPIToken(challengePayload) + if err != nil { + if ContainsToken(challengePayload, TokenError) { + return nil, nil, fmt.Errorf("server rejected NTLM negotiate") + } + 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") + + ntlmUsername := p.config.InjectDomain + "\\" + p.config.InjectUsername + authenticate, err := ntlmssp.ProcessChallenge(challengeToken, ntlmUsername, p.config.InjectPassword, true) + if err != nil { + return nil, nil, fmt.Errorf("process NTLM challenge: %w", err) + } + + 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 { diff --git a/packages/pam/handlers/mssql/tds.go b/packages/pam/handlers/mssql/tds.go index fe40845e..5e6b4dbe 100644 --- a/packages/pam/handlers/mssql/tds.go +++ b/packages/pam/handlers/mssql/tds.go @@ -53,6 +53,17 @@ const ( 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 @@ -294,6 +305,7 @@ type Login7Message struct { Password string AppName string Database string + SSPIData []byte } // ParseLogin7 parses a LOGIN7 message (extracts only what we need) @@ -324,7 +336,8 @@ const ( fSetLang = 0x80 // OptionFlags2 - fODBC = 0x02 + fODBC = 0x02 + fIntSecurity = 0x80 // Integrated Security (SSPI/NTLM) ) // Encode serializes the LOGIN7 message @@ -341,9 +354,19 @@ func (m *Login7Message) Encode() []byte { 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 @@ -385,12 +408,24 @@ func (m *Login7Message) Encode() []byte { 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 + 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) @@ -404,6 +439,9 @@ func (m *Login7Message) Encode() []byte { buf.Write(appname) buf.Write(cltIntName) buf.Write(database) + if useSSPI { + buf.Write(m.SSPIData) + } return buf.Bytes() } diff --git a/packages/pam/pam-proxy.go b/packages/pam/pam-proxy.go index edf273af..f57077a0 100644 --- a/packages/pam/pam-proxy.go +++ b/packages/pam/pam-proxy.go @@ -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,