Files
Erik Geiser cd52919c0c Small fixes
2026-04-23 09:57:36 +02:00

602 lines
17 KiB
Go

package ldapauth
import (
"context"
"crypto"
"crypto/ecdh"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/md5"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/binary"
"encoding/hex"
"encoding/pem"
"fmt"
"net"
"os"
"strings"
"time"
"github.com/RedTeamPentesting/adauth"
"github.com/RedTeamPentesting/adauth/compat"
"github.com/RedTeamPentesting/adauth/pkinit"
"github.com/RedTeamPentesting/adauth/x509ext"
"github.com/go-ldap/ldap/v3"
"github.com/oiweiwei/gokrb5.fork/v9/client"
"github.com/oiweiwei/gokrb5.fork/v9/iana/etypeID"
"github.com/oiweiwei/gokrb5.fork/v9/iana/flags"
"github.com/oiweiwei/gokrb5.fork/v9/types"
"github.com/spf13/pflag"
)
// Options holds LDAP specific options.
type Options struct {
// LDAP scheme (ldap or ldaps).
Scheme string
// Verify indicates whether TLS verification should be performed.
Verify bool
// Timeout sets the request timeout for the LDAP connection.
Timeout time.Duration
// Debug can be set to enable debug output, for example with
// adauth.NewDebugFunc(...).
Debug func(string, ...any)
// SimpleBind indicates that SimpleBind authentication should be used
// instead of NTLM, Kerberos or mTLS. For this, a cleartext password is
// required.
SimpleBind bool
// TLSConfig for LDAPS or LDAP+StartTLS. InsecureSkipVerify is ignored and
// set according to Options.Verify. MaxVersion will be changed to 1.2 unless
// Options.DisableChannelBinding is set.
TLSConfig *tls.Config
// DisableChannelBinding omits the TLS certificate hash in Kerberos and NTLM
// authentication.
DisableChannelBinding bool
// StartTLS indicates that a TLS connection should be established even for
// non-LDAPS connections before authenticating. For client-certificate
// authentication on regular LDAP connections, StartTLS will be used even if
// this option is disabled.
StartTLS bool
// DialOptions can be used to customize the connection. DialOptions is
// ignored when a custom LDAPDialer is set.
DialOptions []ldap.DialOpt
// KerberosDialer is a custom dialer that is used to request Kerberos
// tickets. DialContext is used if implemented.
KerberosDialer adauth.Dialer
// LDAPDialer is a custom dialer that is used to establish LDAP connections.
// DialContext is used if implemented.
LDAPDialer adauth.Dialer
}
// RegisterFlags registers LDAP specific flags to a pflag.FlagSet such as the
// default flagset pflag.CommandLine.
func (opts *Options) RegisterFlags(flagset *pflag.FlagSet) {
flagset.StringVar(&opts.Scheme, "scheme", "ldaps", "Scheme (ldap or ldaps)")
flagset.DurationVar(&opts.Timeout, "timeout", 5*time.Second, "LDAP connection timeout")
flagset.BoolVar(&opts.SimpleBind, "simple-bind", false, "Authenticate with simple bind")
flagset.BoolVar(&opts.Verify, "verify", false, "Verify LDAP TLS certificate")
flagset.BoolVar(&opts.StartTLS, "start-tls", false,
"Negotiate StartTLS before authenticating on regular LDAP connection")
}
// SetDialer configures a dialer for LDAP and Kerberos.
func (opts *Options) SetDialer(dialer adauth.Dialer) {
opts.KerberosDialer = dialer
opts.LDAPDialer = dialer
}
// Connect returns an authenticated LDAP connection to the domain controller's
// LDAP server.
func Connect(ctx context.Context, authOpts *adauth.Options, ldapOpts *Options) (conn *ldap.Conn, err error) {
creds, target, err := authOpts.WithDCTarget(ctx, ldapOpts.Scheme)
if err != nil {
return nil, err
}
return ConnectTo(ctx, creds, target, ldapOpts)
}
// Connect returns an authenticated LDAP connection to the specified target.
func ConnectTo(
ctx context.Context, creds *adauth.Credential, target *adauth.Target, ldapOpts *Options,
) (conn *ldap.Conn, err error) {
opts := ldapOpts
if opts.Debug == nil {
opts.Debug = func(s string, a ...any) {}
}
opts.TLSConfig, err = TLSConfig(ldapOpts, creds.ClientCert, creds.ClientCertKey, creds.CACerts)
if err != nil {
return nil, fmt.Errorf("configure TLS: %w", err)
}
if !ldapOpts.TLSConfig.InsecureSkipVerify && net.ParseIP(target.AddressWithoutPort()) != nil {
hostname, err := target.Hostname(ctx)
if err != nil {
return nil, fmt.Errorf("determine target hostname for TLS verification: %w", err)
}
opts.TLSConfig.ServerName = hostname
}
conn, err = connect(ctx, target, opts)
if err != nil {
return nil, err
}
if opts.Timeout == 0 {
conn.SetTimeout(3 * time.Second)
} else {
conn.SetTimeout(opts.Timeout)
}
err = bind(ctx, conn, creds, target, opts)
if err != nil {
return nil, err
}
return conn, nil
}
func connect(ctx context.Context, target *adauth.Target, opts *Options) (conn *ldap.Conn, err error) {
switch {
case strings.EqualFold(opts.Scheme, "ldaps"):
if target.Port == "" {
target.Port = ldap.DefaultLdapsPort
}
if opts.LDAPDialer == nil {
conn, err = ldap.DialURL("ldaps://"+target.Address(),
append(opts.DialOptions, ldap.DialWithTLSConfig(opts.TLSConfig))...)
if err != nil {
return nil, fmt.Errorf("LDAPS dial: %w", err)
}
} else {
tcpConn, err := adauth.AsContextDialer(opts.LDAPDialer).DialContext(ctx, "tcp", target.Address())
if err != nil {
return nil, fmt.Errorf("dial with custom dialer: %w", err)
}
tlsConn := tls.Client(tcpConn, opts.TLSConfig)
err = tlsConn.HandshakeContext(ctx)
if err != nil {
_ = tcpConn.Close()
return nil, err
}
conn = ldap.NewConn(tlsConn, true)
conn.Start()
}
opts.Debug("connected to LDAPS server %s", target.Address())
return conn, nil
case strings.EqualFold(opts.Scheme, "ldap"):
if target.Port == "" {
target.Port = ldap.DefaultLdapPort
}
if opts.LDAPDialer == nil {
conn, err = ldap.DialURL("ldap://"+target.Address(), opts.DialOptions...)
if err != nil {
return nil, fmt.Errorf("LDAP dial: %w", err)
}
} else {
tcpConn, err := adauth.AsContextDialer(opts.LDAPDialer).DialContext(ctx, "tcp", target.Address())
if err != nil {
return nil, fmt.Errorf("dial with custom dialer: %w", err)
}
conn = ldap.NewConn(tcpConn, false)
conn.Start()
}
opts.Debug("connected to LDAP server %s", target.Address())
if opts.StartTLS {
opts.Debug("negotiating StartTLS")
err = conn.StartTLS(opts.TLSConfig)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("StartTLS: %w", err)
}
}
return conn, nil
default:
return nil, fmt.Errorf("invalid scheme: %q", opts.Scheme)
}
}
func bind(
ctx context.Context, conn *ldap.Conn, creds *adauth.Credential, target *adauth.Target, opts *Options,
) (err error) {
switch {
case opts.SimpleBind:
switch {
case creds.Password == "" && !creds.PasswordIsEmptyString:
return fmt.Errorf("specify a password for simple bind or -p '' for an unauthenticated simple bind")
case creds.Password == "" && creds.PasswordIsEmptyString:
opts.Debug("using unauthenticated simple bind")
default:
opts.Debug("authenticating with simple bind")
}
_, err = conn.SimpleBind(&ldap.SimpleBindRequest{
Username: creds.UPN(),
Password: creds.Password,
AllowEmptyPassword: creds.PasswordIsEmptyString,
})
if err != nil {
return fmt.Errorf("simple bind: %w", err)
}
case !target.UseKerberos && creds.ClientCert == nil:
opts.Debug("authenticating using NTLM bind")
if !creds.PasswordIsEmptyString && (creds.Password == "" && creds.NTHash == "") {
return fmt.Errorf("no credentials available for NTLM")
}
bindRequest := &ldap.NTLMBindRequest{
Domain: creds.Domain,
Username: creds.Username,
Password: creds.Password,
Hash: creds.NTHash,
AllowEmptyPassword: creds.PasswordIsEmptyString,
}
tlsState, ok := conn.TLSConnectionState()
if ok && !opts.DisableChannelBinding {
bindRequest.Negotiator = ntlmNegotiatorWithChannelBinding(tlsState.PeerCertificates[0], creds.Domain)
} else {
bindRequest.Negotiator = ntlmNegotiatorForDomain(creds.Domain)
}
_, err = conn.NTLMChallengeBind(bindRequest)
if err != nil {
return fmt.Errorf("NTLM bind: %w", err)
}
case target.UseKerberos:
authClient, err := kerberosClient(ctx, conn, creds, opts)
if err != nil {
return err
}
spn, err := target.SPN(ctx)
if err != nil {
return fmt.Errorf("build SPN: %w", err)
}
err = conn.GSSAPIBindRequestWithAPOptions(authClient, &ldap.GSSAPIBindRequest{
ServicePrincipalName: spn,
AuthZID: creds.NTHash,
}, []int{flags.APOptionMutualRequired})
if err != nil {
return fmt.Errorf("GSSAPI bind: %w", err)
}
case creds.ClientCert != nil && strings.EqualFold(opts.Scheme, "ldap"):
opts.Debug("authenticating with client certificate via StartTLS")
_, ok := conn.TLSConnectionState()
if !ok {
opts.Debug("negotiating StartTLS")
err = conn.StartTLS(opts.TLSConfig)
if err != nil {
return fmt.Errorf("StartTLS: %w", err)
}
}
err = conn.ExternalBind()
if err != nil {
if creds.ClientCert.Issuer.CommonName == "" ||
strings.EqualFold(creds.ClientCert.Subject.CommonName, creds.ClientCert.Issuer.CommonName) {
return fmt.Errorf("external bind: %w "+
"(certificate likely belongs to a KeyCredentialLink, try PKINIT with -k instead)", err)
}
return fmt.Errorf("external bind: %w", err)
}
case creds.ClientCert != nil:
opts.Debug("authenticating with client certificate")
res, err := conn.WhoAmI(nil)
if err != nil {
return fmt.Errorf("send whoami to verify certificate authentication: %w", err)
}
if res.AuthzID == "" {
if creds.ClientCert.Issuer.CommonName == "" ||
strings.EqualFold(creds.ClientCert.Subject.CommonName, creds.ClientCert.Issuer.CommonName) {
return fmt.Errorf("client certificate authentication failed " +
"(certificate likely belongs to a KeyCredentialLink, try PKINIT with -k instead)")
}
return fmt.Errorf("client certificate authentication failed")
}
default:
return fmt.Errorf("no credentials available")
}
return nil
}
func kerberosClient(
ctx context.Context, conn *ldap.Conn, creds *adauth.Credential, opts *Options,
) (*gssapiClient, error) {
krbConf, err := creds.KerberosConfig(ctx)
if err != nil {
return nil, fmt.Errorf("configure Kerberos: %w", err)
}
if opts.KerberosDialer == nil {
opts.KerberosDialer = &net.Dialer{Timeout: pkinit.DefaultKerberosRoundtripDeadline}
}
var (
authClient *gssapiClient
cert *x509.Certificate
)
tlsState, ok := conn.TLSConnectionState()
if ok && !opts.DisableChannelBinding {
cert = tlsState.PeerCertificates[0]
}
switch {
case creds.Password != "" || creds.PasswordIsEmptyString:
opts.Debug("authenticating using GSSAPI bind (password)")
authClient = &gssapiClient{
Client: client.NewWithPassword(
creds.Username,
strings.ToUpper(creds.Domain),
creds.Password,
compat.Gokrb5ForkV9KerberosConfig(krbConf),
client.DisablePAFXFAST(true),
client.Dialer(opts.KerberosDialer),
),
}
authClient.BindCertificate = cert
case creds.NTHash != "":
opts.Debug("authenticating using GSSAPI bind (NT hash)")
ntHash, err := hex.DecodeString(creds.NTHash)
if err != nil {
return nil, fmt.Errorf("decode NT hash: %w", err)
}
authClient = &gssapiClient{
Client: client.NewWithEncryptionKey(
creds.Username,
strings.ToUpper(creds.Domain),
types.EncryptionKey{
KeyType: etypeID.RC4_HMAC,
KeyValue: ntHash,
},
compat.Gokrb5ForkV9KerberosConfig(krbConf),
client.DisablePAFXFAST(true),
client.Dialer(opts.KerberosDialer),
),
BindCertificate: cert,
}
authClient.BindCertificate = cert
case creds.AESKey != "":
opts.Debug("authenticating using GSSAPI bind (AES key)")
aesKey, err := hex.DecodeString(creds.AESKey)
if err != nil {
return nil, fmt.Errorf("decode AES key: %w", err)
}
var keyType int32
switch len(aesKey) {
case 32:
keyType = etypeID.AES256_CTS_HMAC_SHA1_96
case 16:
keyType = etypeID.AES128_CTS_HMAC_SHA1_96
default:
return nil, fmt.Errorf("invalid AES128/AES256 key: key size is %d bytes", len(aesKey))
}
authClient = &gssapiClient{
Client: client.NewWithEncryptionKey(
creds.Username,
strings.ToUpper(creds.Domain),
types.EncryptionKey{
KeyType: keyType,
KeyValue: aesKey,
},
compat.Gokrb5ForkV9KerberosConfig(krbConf),
client.DisablePAFXFAST(true),
client.Dialer(opts.KerberosDialer),
),
BindCertificate: cert,
}
authClient.BindCertificate = cert
case creds.ClientCert != nil:
opts.Debug("authenticating using GSSAPI bind (PKINIT)")
rsaKey, ok := creds.ClientCertKey.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("cannot use %T because PKINIT requires an RSA key", creds.ClientCertKey)
}
return newPKINITClient(ctx, creds.Username, strings.ToUpper(creds.Domain),
creds.ClientCert, rsaKey, krbConf, opts.KerberosDialer)
case creds.CCache != "":
opts.Debug("authenticating using GSSAPI bind (ccache)")
authClient, err = newClientFromCCache(
creds.Username, strings.ToUpper(creds.Domain), creds.CCache, krbConf, opts.KerberosDialer)
if err != nil {
return nil, fmt.Errorf("create GSSAPI client from CCACHE: %w", err)
}
authClient.BindCertificate = cert
default:
return nil, fmt.Errorf("no credentials available for Kerberos")
}
return authClient, nil
}
// TLSConfig returns a TLS config based on the default config in the provided
// LDAP options as well as PFX files.
func TLSConfig(
opts *Options, clientCert *x509.Certificate, clientCertKey crypto.PrivateKey, caCerts []*x509.Certificate,
) (*tls.Config, error) {
tlsConfig := opts.TLSConfig
if tlsConfig == nil {
tlsConfig = &tls.Config{}
}
tlsConfig.InsecureSkipVerify = !opts.Verify
if tlsConfig.MaxVersion == 0 && !opts.DisableChannelBinding {
tlsConfig.MaxVersion = tls.VersionTLS12 // channel binding is not supported for TLS1.3
}
if clientCert == nil {
return tlsConfig, nil
}
var (
keyBytes []byte
err error
)
switch v := clientCertKey.(type) {
case *rsa.PrivateKey, *ecdsa.PrivateKey, ed25519.PrivateKey, *ecdh.PrivateKey:
keyBytes, err = x509.MarshalPKCS8PrivateKey(v)
if err != nil {
return nil, fmt.Errorf("marshal private key: %w", err)
}
default:
return nil, fmt.Errorf("unsupported client certificate key type: %T", clientCertKey)
}
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: clientCert.Raw,
})
keyPEM := pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: keyBytes,
})
clientCertificate, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, fmt.Errorf("load client certificate: %w", err)
}
tlsConfig.Certificates = append(tlsConfig.Certificates, clientCertificate)
if len(caCerts) == 0 {
return tlsConfig, nil
}
if tlsConfig.RootCAs == nil {
tlsConfig.RootCAs = x509.NewCertPool()
}
for _, cert := range caCerts {
tlsConfig.RootCAs.AppendCertsFromPEM(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}))
}
return tlsConfig, nil
}
// UserAndDomainFromPFX extracts the username and domain from UPNs in the
// certificate's otherName SAN extension.
func UserAndDomainFromPFX(pfxFile string, password string) (user string, domain string, err error) {
pfxData, err := os.ReadFile(pfxFile)
if err != nil {
return "", "", fmt.Errorf("read PFX: %w", err)
}
_, cert, _, err := adauth.DecodePFX(pfxData, password)
if err != nil {
return "", "", fmt.Errorf("decode PFX: %w", err)
}
user, domain = userAndDomainFromCert(cert)
return user, domain, nil
}
func userAndDomainFromCert(cert *x509.Certificate) (user string, domain string) {
upns, err := x509ext.UPNsFromOtherNames(cert)
if err != nil {
return "", ""
}
for _, upn := range upns {
if !strings.Contains(upn, "@") {
continue
}
parts := strings.Split(upn, "@")
if len(parts) != 2 {
continue
}
return parts[0], parts[1]
}
return "", ""
}
// ChannelBinding hash computes the channel binding token that can be included
// in the authentication handshake to make sure that the client has established
// a TLS connection to the correct server.
func ChannelBindingHash(cert *x509.Certificate) []byte {
hashType := crypto.SHA256
switch cert.SignatureAlgorithm {
case x509.SHA384WithRSA, x509.ECDSAWithSHA384, x509.SHA384WithRSAPSS:
hashType = crypto.SHA384
case x509.SHA512WithRSA, x509.ECDSAWithSHA512, x509.SHA512WithRSAPSS:
hashType = crypto.SHA512
}
certHasher := hashType.New()
_, _ = certHasher.Write(cert.Raw)
certHash := certHasher.Sum(nil)
prefix := "tls-server-end-point:"
// https://learn.microsoft.com/en-us/windows/win32/api/sspi/ns-sspi-sec_channel_bindings
// https://github.com/jborean93/Mailozaurr/blob
// /6b565c4a1debdf301a95b93674ff12acdf8c762c/Classes/Class.SaslMechanismWindowsAuth.ps1#L67C19-L68C1
channelBindingStructure := []byte{
0, 0, 0, 0, // InitiatorAddrType
0, 0, 0, 0, // InitiatorLength
0, 0, 0, 0, // AcceptorAddrType,
0, 0, 0, 0, // AcceptorLength,
}
channelBindingStructure = binary.LittleEndian.AppendUint32(channelBindingStructure,
uint32(len(prefix)+len(certHash))) // ApplicationDataLength
channelBindingStructure = append(channelBindingStructure, []byte(prefix)...)
channelBindingStructure = append(channelBindingStructure, certHash...)
channelBindingHasher := md5.New()
channelBindingHasher.Write(channelBindingStructure)
hash := channelBindingHasher.Sum(nil)
return hash
}