Files
mandiant-gopacket/pkg/ldap/gssapi.go
T
Jacob Paullus 8eea029431 kerberos, dcerpc: tunnel KDC traffic through pkg/transport
The embedded gokrb5/v8 library hard-coded net.DialTimeout for AS/TGS
exchanges, bypassing -proxy and leaking the operator's source IP to the
KDC (UDP/88 first, TCP/88 fallback). The DCERPC Kerberos auth path used
a separate library (oiweiwei/gokrb5.fork/v9 via go-msrpc) that leaked the
same way.

Vendor jcmturner/gokrb5/v8 in-tree at pkg/third_party/gokrb5 with a
required KDCDialer first argument on every client constructor, so
proxy-bypass becomes a compile error. Wire kerberos.TransportKDCDialer
everywhere a gokrb5 client is built. Stamp udp_preference_limit=1 and
dns_lookup_kdc/realm=false unconditionally so KRB5 is TCP-only and the
OS resolver is never consulted; /etc/krb5.conf and $KRB5_CONFIG are
deliberately not read.

For DCERPC: set krbConfig.KDCDialer on every krb5.Config, pass
dcerpc.WithDialer(transport.ContextDialer{}) on every dcerpc.Dial, and
use the "ncacn_ip_tcp:" StringBinding prefix on the OXID-pivot dial so
go-msrpc's hard-coded pre-dial net.LookupIP is skipped (defers FQDN
resolution to the SOCKS5 proxy).

Verified against a live GOAD lab: 8 Kerberos-touching tools plus 5
NTLM/password/PtH regressions all operate through SOCKS5 with zero
direct packets to the AD subnet. Negative control (no -proxy)
immediately emits direct SYNs to the KDC, confirming both the leak
class and the fix.
2026-05-11 23:23:21 -05:00

335 lines
10 KiB
Go

// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ldap
import (
"encoding/asn1"
"encoding/hex"
"fmt"
"log"
"os"
"github.com/mandiant/gopacket/pkg/third_party/gokrb5/gssapi"
"github.com/mandiant/gopacket/pkg/third_party/gokrb5/iana/keyusage"
"github.com/mandiant/gopacket/pkg/third_party/gokrb5/types"
"github.com/mandiant/gopacket/pkg/kerberos"
)
var debugGSSAPI = os.Getenv("DEBUG_GSSAPI") != ""
// OID for Kerberos V5 (GSSAPI)
var oidKerberos = asn1.ObjectIdentifier{1, 2, 840, 113554, 1, 2, 2}
// KerberosGSSAPIClient implements go-ldap's GSSAPIClient interface
// using our kerberos.Client for Kerberos SASL authentication.
type KerberosGSSAPIClient struct {
krbClient *kerberos.Client
sessionKey types.EncryptionKey
seqNum uint64
}
// NewKerberosGSSAPIClient creates a new GSSAPIClient for LDAP Kerberos auth.
func NewKerberosGSSAPIClient(krbClient *kerberos.Client) *KerberosGSSAPIClient {
return &KerberosGSSAPIClient{
krbClient: krbClient,
}
}
// InitSecContext generates the initial GSSAPI token for Kerberos authentication.
// Implements GSSAPIClient.InitSecContext.
func (g *KerberosGSSAPIClient) InitSecContext(target string, token []byte) ([]byte, bool, error) {
if debugGSSAPI {
log.Printf("[GSSAPI] InitSecContext called: target=%s, token=%d bytes", target, len(token))
}
if token != nil {
// Server sent a response token (AP-REP or SASL challenge)
if debugGSSAPI {
log.Printf("[GSSAPI] InitSecContext continuation: server sent %d bytes", len(token))
log.Printf("[GSSAPI] Server token hex: %s", hex.EncodeToString(token))
}
// Signal context is established, go-ldap will call NegotiateSaslAuth next
// Return empty token, needContinue=false
return nil, false, nil
}
// Generate AP-REQ for the target SPN and get full encryption key
apReq, key, err := g.krbClient.GenerateAPReqFull(target)
if err != nil {
return nil, false, fmt.Errorf("failed to generate AP-REQ: %v", err)
}
g.sessionKey = key
if debugGSSAPI {
log.Printf("[GSSAPI] Generated AP-REQ: %d bytes, session key type: %d", len(apReq), key.KeyType)
}
// Wrap AP-REQ in GSSAPI token format
gssToken, err := wrapGSSAPIToken(apReq)
if err != nil {
return nil, false, fmt.Errorf("failed to wrap GSSAPI token: %v", err)
}
if debugGSSAPI {
log.Printf("[GSSAPI] Wrapped token: %d bytes", len(gssToken))
}
// Return needContinue=true to have go-ldap call us again with server's response
// This ensures we get the SASL challenge for NegotiateSaslAuth
return gssToken, true, nil
}
// InitSecContextWithOptions is the same as InitSecContext but with additional options.
// Implements GSSAPIClient.InitSecContextWithOptions.
func (g *KerberosGSSAPIClient) InitSecContextWithOptions(target string, token []byte, options []int) ([]byte, bool, error) {
if debugGSSAPI {
log.Printf("[GSSAPI] InitSecContextWithOptions: options=%v", options)
}
// Options are not used in our implementation
return g.InitSecContext(target, token)
}
// NegotiateSaslAuth completes the SASL authentication handshake.
// It receives the server's security layer token and returns our response.
// Implements GSSAPIClient.NegotiateSaslAuth.
func (g *KerberosGSSAPIClient) NegotiateSaslAuth(token []byte, authzid string) ([]byte, error) {
// The server sends a wrapped token describing supported security layers:
// Byte 0: Supported security layers bitmask
// bit 0 = no security layer
// bit 1 = integrity only
// bit 2 = integrity and confidentiality
// Bytes 1-3: Maximum receive buffer size (big-endian)
if debugGSSAPI {
log.Printf("[GSSAPI] NegotiateSaslAuth: received %d bytes from server", len(token))
log.Printf("[GSSAPI] Server token hex: %s", hex.EncodeToString(token))
log.Printf("[GSSAPI] Session key type: %d, len: %d", g.sessionKey.KeyType, len(g.sessionKey.KeyValue))
}
if len(token) < 4 {
return nil, fmt.Errorf("invalid server security token: too short")
}
// Unwrap the server's token using gokrb5's WrapToken
var wt gssapi.WrapToken
err := wt.Unmarshal(token, true) // true = expect from acceptor
if err != nil {
if debugGSSAPI {
log.Printf("[GSSAPI] WrapToken unmarshal failed: %v, trying MIC token", err)
}
// Try as MIC token instead
return g.handleMICToken(token, authzid)
}
if debugGSSAPI {
log.Printf("[GSSAPI] WrapToken unmarshaled: Flags=%02x, EC=%d, RRC=%d, SeqNum=%d, Payload=%d bytes",
wt.Flags, wt.EC, wt.RRC, wt.SndSeqNum, len(wt.Payload))
}
// Verify the token
valid, err := wt.Verify(g.sessionKey, keyusage.GSSAPI_ACCEPTOR_SEAL)
if err != nil || !valid {
if debugGSSAPI {
log.Printf("[GSSAPI] WrapToken verify failed: %v, valid=%v", err, valid)
}
// Try without verification as fallback
return g.handleMICToken(token, authzid)
}
// Parse server's security layers from unwrapped payload
if len(wt.Payload) < 4 {
return nil, fmt.Errorf("invalid unwrapped token: too short")
}
serverLayers := wt.Payload[0]
serverMaxBuf := uint32(wt.Payload[1])<<16 | uint32(wt.Payload[2])<<8 | uint32(wt.Payload[3])
if debugGSSAPI {
log.Printf("[GSSAPI] Server offers: layers=0x%02x, maxbuf=%d", serverLayers, serverMaxBuf)
}
// Build response: select no security layer (0x01) since we don't want to wrap all LDAP traffic
// This should work if server offers it (bit 0 set)
response := make([]byte, 4+len(authzid))
if serverLayers&0x01 != 0 {
response[0] = 0x01 // No security layer
response[1] = 0x00
response[2] = 0x00
response[3] = 0x00
} else {
// Server requires security layer, use integrity
response[0] = 0x04 // Integrity protection
response[1] = 0x00
response[2] = 0xFF
response[3] = 0xFF
}
if authzid != "" {
copy(response[4:], []byte(authzid))
}
if debugGSSAPI {
log.Printf("[GSSAPI] Sending response: layer=0x%02x", response[0])
}
// Wrap response using gokrb5's WrapToken
g.seqNum++
wrapped, err := gssapi.NewInitiatorWrapToken(response, g.sessionKey)
if err != nil {
return nil, fmt.Errorf("failed to wrap response: %v", err)
}
wrappedBytes, err := wrapped.Marshal()
if err != nil {
return nil, fmt.Errorf("failed to marshal wrapped response: %v", err)
}
if debugGSSAPI {
log.Printf("[GSSAPI] Wrapped response: %d bytes", len(wrappedBytes))
}
return wrappedBytes, nil
}
// handleMICToken handles the case where the server sends a MIC token (older format)
// or when WrapToken parsing fails.
func (g *KerberosGSSAPIClient) handleMICToken(token []byte, authzid string) ([]byte, error) {
// Try to parse as MIC token (RFC 1964)
var mt gssapi.MICToken
err := mt.Unmarshal(token, true)
var serverLayers byte = 0x07 // Assume all layers supported if we can't parse
var serverMaxBuf uint32 = 65535
if err == nil {
if debugGSSAPI {
log.Printf("[GSSAPI] MICToken unmarshaled: Flags=%02x, SeqNum=%d, Payload=%d bytes",
mt.Flags, mt.SndSeqNum, len(mt.Payload))
}
// Verify the MIC token
valid, verr := mt.Verify(g.sessionKey, keyusage.GSSAPI_ACCEPTOR_SIGN)
if verr == nil && valid && len(mt.Payload) >= 4 {
serverLayers = mt.Payload[0]
serverMaxBuf = uint32(mt.Payload[1])<<16 | uint32(mt.Payload[2])<<8 | uint32(mt.Payload[3])
}
} else if debugGSSAPI {
log.Printf("[GSSAPI] MICToken unmarshal also failed: %v", err)
// Try to parse raw token - maybe it's just the 4-byte security layer data
if len(token) >= 4 {
serverLayers = token[0]
serverMaxBuf = uint32(token[1])<<16 | uint32(token[2])<<8 | uint32(token[3])
log.Printf("[GSSAPI] Raw token parse: layers=0x%02x, maxbuf=%d", serverLayers, serverMaxBuf)
}
}
if debugGSSAPI {
log.Printf("[GSSAPI] Using: layers=0x%02x, maxbuf=%d", serverLayers, serverMaxBuf)
}
// Build response - prefer no security layer if available
response := make([]byte, 4+len(authzid))
if serverLayers&0x01 != 0 {
response[0] = 0x01 // No security layer
response[1] = 0x00
response[2] = 0x00
response[3] = 0x00
} else if serverLayers&0x02 != 0 {
response[0] = 0x02 // Integrity only
response[1] = 0x00
response[2] = 0xFF
response[3] = 0xFF
} else {
response[0] = 0x04 // Confidentiality
response[1] = 0x00
response[2] = 0xFF
response[3] = 0xFF
}
if authzid != "" {
copy(response[4:], []byte(authzid))
}
if debugGSSAPI {
log.Printf("[GSSAPI] MIC path: Sending response with layer=0x%02x", response[0])
}
// Wrap response using gokrb5's WrapToken
g.seqNum++
wrapped, err := gssapi.NewInitiatorWrapToken(response, g.sessionKey)
if err != nil {
return nil, fmt.Errorf("failed to wrap response: %v", err)
}
wrappedBytes, err := wrapped.Marshal()
if err != nil {
return nil, fmt.Errorf("failed to marshal wrapped response: %v", err)
}
return wrappedBytes, nil
}
// DeleteSecContext cleans up the security context.
// Implements GSSAPIClient.DeleteSecContext.
func (g *KerberosGSSAPIClient) DeleteSecContext() error {
g.sessionKey = types.EncryptionKey{}
return nil
}
// wrapGSSAPIToken wraps an AP-REQ in GSSAPI token format.
// Format: [OID length][OID][AP-REQ]
func wrapGSSAPIToken(apReq []byte) ([]byte, error) {
// GSSAPI token format (RFC 2743):
// 0x60 [length] [OID tag 0x06] [OID length] [OID] [token]
// Encode the Kerberos OID
oidBytes, err := asn1.Marshal(oidKerberos)
if err != nil {
return nil, err
}
// Build inner token: OID + AP-REQ (with krb5 token tag 0x01 0x00)
innerToken := make([]byte, 0, len(oidBytes)+2+len(apReq))
innerToken = append(innerToken, oidBytes...)
innerToken = append(innerToken, 0x01, 0x00) // Kerberos AP-REQ token ID
innerToken = append(innerToken, apReq...)
// Wrap in APPLICATION tag (0x60)
return wrapASN1Application(innerToken), nil
}
// wrapASN1Application wraps data in ASN.1 APPLICATION tag (0x60)
func wrapASN1Application(data []byte) []byte {
length := len(data)
var result []byte
if length < 128 {
result = make([]byte, 2+length)
result[0] = 0x60
result[1] = byte(length)
copy(result[2:], data)
} else if length < 256 {
result = make([]byte, 3+length)
result[0] = 0x60
result[1] = 0x81
result[2] = byte(length)
copy(result[3:], data)
} else {
result = make([]byte, 4+length)
result[0] = 0x60
result[1] = 0x82
result[2] = byte(length >> 8)
result[3] = byte(length)
copy(result[4:], data)
}
return result
}