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

422 lines
11 KiB
Go

package adauth
import (
"context"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"github.com/RedTeamPentesting/adauth/x509ext"
"github.com/spf13/pflag"
)
// Options holds command line options that are used to determine authentication
// credentials and target.
type Options struct {
// Username (with domain) in one of the following formats:
// `UPN`, `domain\user`, `domain/user` or `user`
User string
Password string
NTHash string
AESKey string
CCache string
DomainController string
ForceKerberos bool
// It is possible to specify a cert/key pair directly, as PEM files or as a
// single PFX file.
Certificate *x509.Certificate
CertificateKey any
PFXFileName string
PFXPassword string
PEMCertFileName string
PEMKeyFileName string
credential *Credential
flagset *pflag.FlagSet
Debug func(fmt string, a ...any)
Resolver Resolver
}
// RegisterFlags registers authentication flags to a pflag.FlagSet such as the
// default flagset `pflag.CommandLine`.
func (opts *Options) RegisterFlags(flagset *pflag.FlagSet) {
defaultCCACHEFile := os.Getenv("KRB5CCNAME")
ccacheHint := ""
if defaultCCACHEFile == "" {
ccacheHint = " (defaults to $KRB5CCNAME, currently unset)"
}
flagset.StringVarP(&opts.User, "user", "u", "",
"Username ('`user@domain`', 'domain\\user', 'domain/user' or 'user')")
flagset.StringVarP(&opts.Password, "password", "p", "", "Password")
flagset.StringVarP(&opts.NTHash, "nt-hash", "H", "", "NT `hash` ('NT', ':NT' or 'LM:NT')")
flagset.StringVar(&opts.AESKey, "aes-key", "", "Kerberos AES `hex key`")
flagset.StringVar(&opts.PFXFileName, "pfx", "", "Client certificate and private key as PFX `file`")
flagset.StringVar(&opts.PFXPassword, "pfx-password", "", "Password for PFX file")
flagset.StringVar(&opts.CCache, "ccache", defaultCCACHEFile, "Kerberos CCache `file` name"+ccacheHint)
flagset.StringVar(&opts.DomainController, "dc", "", "Domain controller")
flagset.BoolVarP(&opts.ForceKerberos, "kerberos", "k", false, "Use Kerberos authentication")
opts.flagset = flagset
}
func (opts *Options) debug(format string, a ...any) {
if opts.Debug != nil {
opts.Debug(format, a...)
}
}
func portForProtocol(protocol string) string {
switch strings.ToLower(protocol) {
case "ldap":
return "389"
case "ldaps":
return "636"
case "http":
return "80"
case "https":
return "443"
case "smb":
return "445"
case "rdp":
return "3389"
case "kerberos":
return "88"
default:
return ""
}
}
func addPortForProtocolIfMissing(protocol string, addr string) string {
host, port, err := net.SplitHostPort(addr)
if err != nil || port != "" {
return addr
}
port = portForProtocol(protocol)
if port == "" {
return addr
}
return net.JoinHostPort(host, port)
}
// WithDCTarget returns credentials and the domain controller for the
// corresponding domain as the target.
func (opts *Options) WithDCTarget(ctx context.Context, protocol string) (*Credential, *Target, error) {
if opts.DomainController != "" {
return opts.WithTarget(ctx, protocol, addPortForProtocolIfMissing(protocol, opts.DomainController))
}
cred, err := opts.preliminaryCredential()
if err != nil {
return nil, nil, err
}
if cred.Domain == "" {
return nil, nil, fmt.Errorf("domain unknown")
}
resolver := ensureResolver(opts.Resolver, opts.debug)
var dcAddr string
host, port, err := resolver.LookupFirstService(ctx, protocol, cred.Domain)
if err != nil {
lookupSRVErr := fmt.Errorf("could not lookup %q service of domain %q: %w", protocol, cred.Domain, err)
dcAddr, err = resolver.LookupDCByDomain(ctx, cred.Domain)
if err != nil {
return nil, nil, fmt.Errorf("could not find DC: %w and %w", lookupSRVErr, err)
}
port := portForProtocol(protocol)
if port != "" {
dcAddr = net.JoinHostPort(dcAddr, port)
}
opts.debug("using DC %s based on domain lookup for %s", dcAddr, cred.Domain)
} else {
dcAddr = net.JoinHostPort(host, strconv.Itoa(port))
opts.debug("using DC %s based on SRV lookup for domain %s", dcAddr, cred.Domain)
}
return cred, newTarget(
protocol, dcAddr, opts.ForceKerberos || cred.mustUseKerberos(), opts.CCache, opts.Resolver), nil
}
// WithTarget returns credentials and the specified target.
func (opts *Options) WithTarget(ctx context.Context, protocol string, target string) (*Credential, *Target, error) {
if protocol == "" {
protocol = "host"
}
cred, err := opts.preliminaryCredential()
if err != nil {
return nil, nil, err
}
t := newTarget(protocol, target, opts.ForceKerberos || cred.mustUseKerberos(), opts.CCache, opts.Resolver)
if cred.Domain == "" {
hostname, err := t.Hostname(ctx)
if err != nil {
return nil, nil, fmt.Errorf("lookup target hostname to determine domain: %w", err)
}
parts := strings.SplitN(hostname, ".", 2)
if len(parts) == 2 {
switch {
case strings.Contains(parts[1], "."):
cred.Domain = parts[1]
default:
cred.Domain = hostname
}
}
}
return cred, t, nil
}
// Username returns the user's name. Username may return an empty string.
func (opts *Options) Username() string {
cred, err := opts.preliminaryCredential()
if err != nil {
return ""
}
return cred.Username
}
// UPN returns the user's domain. Domain may return an empty string.
func (opts *Options) Domain() string {
cred, err := opts.preliminaryCredential()
if err != nil {
return ""
}
return cred.Domain
}
// UPN returns the user's universal principal name. UPN may return an empty
// string.
func (opts *Options) UPN() string {
cred, err := opts.preliminaryCredential()
if err != nil {
return ""
}
return cred.UPN()
}
// NoTarget returns the user credentials without supplementing it with
// information from a target.
func (opts *Options) NoTarget() (*Credential, error) {
return opts.preliminaryCredential()
}
func (opts *Options) preliminaryCredential() (*Credential, error) {
if opts.credential != nil {
return opts.credential, nil
}
domain, username := splitUserIntoDomainAndUsername(opts.User)
cleanedNTHash := cleanNTHash(opts.NTHash)
var ntHash string
if cleanedNTHash != "" {
ntHashBytes, err := hex.DecodeString(cleanedNTHash)
if err != nil {
return nil, fmt.Errorf("invalid NT hash: parse hex: %w", err)
} else if len(ntHashBytes) != 16 {
return nil, fmt.Errorf("invalid NT hash: %d bytes instead of 16", len(ntHashBytes))
}
ntHash = cleanedNTHash
}
var aesKey string
if opts.AESKey != "" {
aesKeyBytes, err := hex.DecodeString(opts.AESKey)
if err != nil {
return nil, fmt.Errorf("invalid AES key: parse hex: %w", err)
} else if len(aesKeyBytes) != 16 && len(aesKeyBytes) != 32 {
return nil, fmt.Errorf("invalid AES key: %d bytes instead of 16 or 32", len(aesKeyBytes))
}
aesKey = opts.AESKey
}
var ccache string
if opts.CCache != "" {
s, err := os.Stat(opts.CCache)
if err != nil {
return nil, fmt.Errorf("stat CCache path: %w", err)
} else if s.IsDir() {
return nil, fmt.Errorf("CCache path is a directory: %s", opts.CCache)
}
ccache = opts.CCache
}
cred := &Credential{
Username: username,
Password: opts.Password,
Domain: domain,
NTHash: cleanNTHash(ntHash),
AESKey: aesKey,
CCache: ccache,
dc: opts.DomainController,
PasswordIsEmptyString: opts.Password == "" && (opts.flagset != nil && opts.flagset.Changed("password")),
CCacheIsFromEnv: opts.CCache != "" && (opts.flagset != nil && !opts.flagset.Changed("ccache")),
Resolver: opts.Resolver,
}
switch {
case opts.Certificate != nil && opts.CertificateKey == nil:
return nil, fmt.Errorf("specify a key file for the client certificate")
case opts.Certificate != nil && opts.CertificateKey != nil:
cred.ClientCert = opts.Certificate
cred.ClientCertKey = opts.CertificateKey
case opts.PFXFileName != "":
cert, key, caCerts, err := readPFX(opts.PFXFileName, opts.PFXPassword)
if err != nil {
return nil, err
}
cred.ClientCert = cert
cred.ClientCertKey = key
cred.CACerts = caCerts
case opts.PEMCertFileName != "" && opts.PEMKeyFileName == "":
return nil, fmt.Errorf("specify a key file for the client certificate")
case opts.PEMCertFileName != "" && opts.PEMKeyFileName != "":
cert, key, err := readPEMCertAndKey(opts.PEMCertFileName, opts.PEMKeyFileName)
if err != nil {
return nil, err
}
cred.ClientCert = cert
cred.ClientCertKey = key
}
//nolint:nestif
if cred.ClientCert != nil {
user, domain, err := x509ext.UserAndDomainFromOtherNames(cred.ClientCert)
if err == nil {
if cred.Username == "" {
cred.Username = user
}
if cred.Domain == "" {
cred.Domain = domain
}
}
}
opts.credential = cred
return cred, nil
}
func readPFX(fileName string, password string) (*x509.Certificate, any, []*x509.Certificate, error) {
pfxData, err := os.ReadFile(fileName)
if err != nil {
return nil, nil, nil, fmt.Errorf("read PFX: %w", err)
}
key, cert, caCerts, err := DecodePFX(pfxData, password)
if err != nil {
return nil, nil, nil, fmt.Errorf("decode PFX: %w", err)
}
return cert, key, caCerts, nil
}
func readPEMCertAndKey(certFileName string, certKeyFileName string) (*x509.Certificate, any, error) {
certData, err := os.ReadFile(certFileName)
if err != nil {
return nil, nil, fmt.Errorf("read cert file: %w", err)
}
block, _ := pem.Decode(certData)
if block == nil {
return nil, nil, fmt.Errorf("could not PEM-decode certificate")
}
if block.Type != "" && !strings.Contains(strings.ToLower(block.Type), "certificate") {
return nil, nil, fmt.Errorf("unexpected block type for certificate: %q", block.Type)
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, nil, fmt.Errorf("parse certificate: %w", err)
}
certKeyData, err := os.ReadFile(certKeyFileName)
if err != nil {
return nil, nil, fmt.Errorf("read cert key file: %w", err)
}
block, _ = pem.Decode(certKeyData)
if block == nil {
return nil, nil, fmt.Errorf("could not PEM-decode certificate key")
}
if block.Type != "" && !strings.Contains(strings.ToLower(block.Type), "key") {
return nil, nil, fmt.Errorf("unexpected block type for key: %q", block.Type)
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
key, pkcs1Err := x509.ParsePKCS1PrivateKey(block.Bytes)
if pkcs1Err == nil {
return cert, key, nil
}
return nil, nil, fmt.Errorf("parse private key: %w", err)
}
return cert, key, nil
}
// NewDebugFunc creates a debug output handler.
func NewDebugFunc(enabled *bool, writer io.Writer, colored bool) func(string, ...any) {
return func(format string, a ...any) {
if enabled == nil || !*enabled {
return
}
format = strings.TrimRight(format, "\n")
if colored {
format = "\033[2m" + format + "\033[0m"
}
_, _ = fmt.Fprintf(writer, format+"\n", a...)
}
}
func cleanNTHash(h string) string {
if !strings.Contains(h, ":") {
return h
}
parts := strings.Split(h, ":")
if len(parts) != 2 {
return h
}
return parts[1]
}