mirror of
https://github.com/mandiant/gopacket
synced 2026-06-21 13:57:02 +00:00
8eea029431
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.
360 lines
10 KiB
Go
360 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 kerberos
|
|
|
|
import (
|
|
"encoding/asn1"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mandiant/gopacket/pkg/transport"
|
|
"unicode/utf16"
|
|
|
|
"github.com/mandiant/gopacket/pkg/third_party/gokrb5/config"
|
|
"github.com/mandiant/gopacket/pkg/third_party/gokrb5/crypto"
|
|
"github.com/mandiant/gopacket/pkg/third_party/gokrb5/iana/nametype"
|
|
"github.com/mandiant/gopacket/pkg/third_party/gokrb5/messages"
|
|
"github.com/mandiant/gopacket/pkg/third_party/gokrb5/types"
|
|
"golang.org/x/crypto/md4"
|
|
)
|
|
|
|
// TGTRequest holds configuration for a TGT request.
|
|
type TGTRequest struct {
|
|
Username string
|
|
Password string
|
|
Domain string
|
|
NTHash string
|
|
AESKey string
|
|
DCIP string
|
|
DCHost string
|
|
Service string // SPN for requesting service ticket via AS-REQ (optional)
|
|
PrincipalType int32 // Principal name type (default: KRB_NT_PRINCIPAL)
|
|
}
|
|
|
|
// TGTResult holds the result of a successful TGT request.
|
|
type TGTResult struct {
|
|
Ticket []byte // Marshaled ticket bytes
|
|
SessionKey types.EncryptionKey
|
|
CName types.PrincipalName
|
|
SName types.PrincipalName
|
|
Realm string
|
|
AuthTime time.Time
|
|
EndTime time.Time
|
|
RenewTill time.Time
|
|
Flags uint32
|
|
}
|
|
|
|
// GetTGT requests a TGT from the KDC and returns the result.
|
|
func GetTGT(req *TGTRequest) (*TGTResult, error) {
|
|
realm := strings.ToUpper(req.Domain)
|
|
|
|
// Determine KDC host
|
|
kdcHost := req.DCIP
|
|
if kdcHost == "" {
|
|
kdcHost = req.DCHost
|
|
}
|
|
if kdcHost == "" {
|
|
kdcHost = req.Domain
|
|
}
|
|
|
|
// Determine encryption type and derive key
|
|
var encType int32
|
|
var key []byte
|
|
var err error
|
|
|
|
if req.AESKey != "" {
|
|
keyBytes, err := hex.DecodeString(req.AESKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid AES key: %v", err)
|
|
}
|
|
switch len(keyBytes) {
|
|
case 16:
|
|
encType = 17 // AES128
|
|
case 32:
|
|
encType = 18 // AES256
|
|
default:
|
|
return nil, fmt.Errorf("AES key must be 16 or 32 bytes (got %d)", len(keyBytes))
|
|
}
|
|
key = keyBytes
|
|
} else if req.NTHash != "" {
|
|
hashStr := req.NTHash
|
|
if strings.Contains(hashStr, ":") {
|
|
parts := strings.Split(hashStr, ":")
|
|
hashStr = parts[len(parts)-1]
|
|
}
|
|
key, err = hex.DecodeString(hashStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid NT hash: %v", err)
|
|
}
|
|
encType = 23 // RC4-HMAC
|
|
} else if req.Password != "" {
|
|
// Derive RC4-HMAC key from password (MD4 of UTF-16LE password)
|
|
key = ntHash(req.Password)
|
|
encType = 23 // RC4-HMAC
|
|
} else {
|
|
return nil, fmt.Errorf("no authentication method specified (need password, hash, or aesKey)")
|
|
}
|
|
|
|
// Build AS-REQ. Defense-in-depth: networking below uses transport.Dial
|
|
// directly (gokrb5's sendToKDC is never invoked), so the opsec keys are
|
|
// no-ops on this path. Stamping them now means a future refactor that
|
|
// introduces a gokrb5 client.Client inherits the proxy/DNS guarantees.
|
|
cfg := config.New()
|
|
cfg.LibDefaults.DefaultRealm = realm
|
|
ApplyKrb5OpsecDefaults(cfg)
|
|
cfg.LibDefaults.DefaultTktEnctypes = []string{etypeName(encType)}
|
|
cfg.LibDefaults.DefaultTktEnctypeIDs = []int32{encType}
|
|
cfg.LibDefaults.Forwardable = true
|
|
|
|
// Determine principal type
|
|
principalType := int32(nametype.KRB_NT_PRINCIPAL)
|
|
if req.PrincipalType != 0 {
|
|
principalType = req.PrincipalType
|
|
}
|
|
cName := types.NewPrincipalName(principalType, req.Username)
|
|
|
|
// Determine service name (default: krbtgt/REALM, or custom SPN via -service)
|
|
var sName types.PrincipalName
|
|
if req.Service != "" {
|
|
sName = types.NewPrincipalName(nametype.KRB_NT_SRV_INST, req.Service)
|
|
} else {
|
|
sName = types.NewPrincipalName(nametype.KRB_NT_SRV_INST, "krbtgt/"+realm)
|
|
}
|
|
|
|
asReq, err := messages.NewASReq(realm, cfg, cName, sName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create AS-REQ: %v", err)
|
|
}
|
|
|
|
// Add PA-ENC-TIMESTAMP pre-authentication
|
|
paTimestamp, err := buildPAEncTimestamp(key, encType)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to build PA-ENC-TIMESTAMP: %v", err)
|
|
}
|
|
asReq.PAData = append(asReq.PAData, paTimestamp)
|
|
|
|
// Marshal and send
|
|
b, err := asReq.Marshal()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal AS-REQ: %v", err)
|
|
}
|
|
|
|
respBuf, err := sendKDCRequest(kdcHost, b)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Parse response
|
|
var asRep messages.ASRep
|
|
if err := asRep.Unmarshal(respBuf); err != nil {
|
|
var krbErr messages.KRBError
|
|
if errErr := krbErr.Unmarshal(respBuf); errErr == nil {
|
|
return nil, translateKRBError(krbErr)
|
|
}
|
|
return nil, fmt.Errorf("failed to unmarshal AS-REP: %v", err)
|
|
}
|
|
|
|
// Decrypt the encrypted part to get session key (usage 3 = AS-REP enc part)
|
|
et, err := crypto.GetEtype(encType)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unsupported encryption type %d: %v", encType, err)
|
|
}
|
|
|
|
decrypted, err := et.DecryptMessage(key, asRep.EncPart.Cipher, 3)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decrypt AS-REP: %v (wrong password/key?)", err)
|
|
}
|
|
|
|
// Parse EncASRepPart
|
|
var encPart messages.EncKDCRepPart
|
|
if err := encPart.Unmarshal(decrypted); err != nil {
|
|
return nil, fmt.Errorf("failed to parse encrypted AS-REP part: %v", err)
|
|
}
|
|
|
|
// Marshal the ticket for ccache storage
|
|
ticketBytes, err := asRep.Ticket.Marshal()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal ticket: %v", err)
|
|
}
|
|
|
|
// Determine flags from the response
|
|
flags := uint32(0x50e10000) // Default: forwardable, proxiable, renewable, initial, pre_authent, enc_pa_rep
|
|
|
|
result := &TGTResult{
|
|
Ticket: ticketBytes,
|
|
SessionKey: encPart.Key,
|
|
CName: asRep.CName,
|
|
SName: sName,
|
|
Realm: realm,
|
|
AuthTime: encPart.AuthTime,
|
|
EndTime: encPart.EndTime,
|
|
RenewTill: encPart.RenewTill,
|
|
Flags: flags,
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// SaveTGT saves a TGT result to a ccache file.
|
|
func SaveTGT(filename string, result *TGTResult) error {
|
|
return saveToCCache(filename, result.Ticket, result.SessionKey,
|
|
result.CName, result.Realm, result.SName,
|
|
result.AuthTime, result.EndTime, result.RenewTill, result.Flags)
|
|
}
|
|
|
|
// buildPAEncTimestamp creates a PA-ENC-TIMESTAMP pre-authentication data.
|
|
func buildPAEncTimestamp(key []byte, encType int32) (types.PAData, error) {
|
|
now := time.Now().UTC()
|
|
|
|
// PA-ENC-TS-ENC ::= SEQUENCE {
|
|
// patimestamp [0] KerberosTime,
|
|
// pausec [1] Microseconds OPTIONAL
|
|
// }
|
|
timestamp := types.PAEncTSEnc{
|
|
PATimestamp: now,
|
|
PAUSec: int(now.Nanosecond() / 1000),
|
|
}
|
|
|
|
tsBytes, err := asn1.Marshal(timestamp)
|
|
if err != nil {
|
|
return types.PAData{}, fmt.Errorf("failed to marshal timestamp: %v", err)
|
|
}
|
|
|
|
// Encrypt with the user's key (usage 1 = AS-REQ PA-ENC-TIMESTAMP)
|
|
et, err := crypto.GetEtype(encType)
|
|
if err != nil {
|
|
return types.PAData{}, fmt.Errorf("unsupported etype: %v", err)
|
|
}
|
|
|
|
_, encrypted, err := et.EncryptMessage(key, tsBytes, 1)
|
|
if err != nil {
|
|
return types.PAData{}, fmt.Errorf("failed to encrypt timestamp: %v", err)
|
|
}
|
|
|
|
// Build EncryptedData structure
|
|
encData := types.EncryptedData{
|
|
EType: encType,
|
|
KVNO: 0,
|
|
Cipher: encrypted,
|
|
}
|
|
|
|
encDataBytes, err := encData.Marshal()
|
|
if err != nil {
|
|
return types.PAData{}, fmt.Errorf("failed to marshal encrypted data: %v", err)
|
|
}
|
|
|
|
return types.PAData{
|
|
PADataType: 2, // PA-ENC-TIMESTAMP
|
|
PADataValue: encDataBytes,
|
|
}, nil
|
|
}
|
|
|
|
// sendKDCRequest sends a Kerberos message to the KDC and returns the response.
|
|
func sendKDCRequest(kdcHost string, data []byte) ([]byte, error) {
|
|
addr := FormatKDC(kdcHost, "88")
|
|
conn, err := transport.Dial("tcp", addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to connect to KDC %s: %v", addr, err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
conn.SetDeadline(time.Now().Add(10 * time.Second))
|
|
|
|
// Send: 4-byte big-endian length + data
|
|
length := uint32(len(data))
|
|
header := make([]byte, 4)
|
|
binary.BigEndian.PutUint32(header, length)
|
|
|
|
if _, err := conn.Write(append(header, data...)); err != nil {
|
|
return nil, fmt.Errorf("failed to send request: %v", err)
|
|
}
|
|
|
|
// Read response length
|
|
respHeader := make([]byte, 4)
|
|
if _, err := io.ReadFull(conn, respHeader); err != nil {
|
|
return nil, fmt.Errorf("failed to read response header: %v", err)
|
|
}
|
|
respLen := binary.BigEndian.Uint32(respHeader)
|
|
|
|
if respLen > 1024*1024 {
|
|
return nil, fmt.Errorf("response too large: %d bytes", respLen)
|
|
}
|
|
|
|
// Read response body
|
|
respBuf := make([]byte, respLen)
|
|
if _, err := io.ReadFull(conn, respBuf); err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %v", err)
|
|
}
|
|
|
|
return respBuf, nil
|
|
}
|
|
|
|
// ntHash computes the MD4 hash of the UTF-16LE encoded password.
|
|
func ntHash(password string) []byte {
|
|
utf16Chars := utf16.Encode([]rune(password))
|
|
b := make([]byte, len(utf16Chars)*2)
|
|
for i, c := range utf16Chars {
|
|
binary.LittleEndian.PutUint16(b[i*2:], c)
|
|
}
|
|
h := md4.New()
|
|
h.Write(b)
|
|
return h.Sum(nil)
|
|
}
|
|
|
|
// etypeName returns the string name for an encryption type ID.
|
|
func etypeName(etype int32) string {
|
|
switch etype {
|
|
case 17:
|
|
return "aes128-cts-hmac-sha1-96"
|
|
case 18:
|
|
return "aes256-cts-hmac-sha1-96"
|
|
case 23:
|
|
return "rc4-hmac"
|
|
default:
|
|
return "rc4-hmac"
|
|
}
|
|
}
|
|
|
|
// translateKRBError provides a human-readable error from a KRB-ERROR.
|
|
func translateKRBError(e messages.KRBError) error {
|
|
code := e.ErrorCode
|
|
switch code {
|
|
case 6:
|
|
return fmt.Errorf("client not found in Kerberos database (KDC_ERR_C_PRINCIPAL_UNKNOWN)")
|
|
case 7:
|
|
return fmt.Errorf("server not found in Kerberos database (KDC_ERR_S_PRINCIPAL_UNKNOWN)")
|
|
case 14:
|
|
return fmt.Errorf("KDC has no support for PADATA type (pre-authentication type not supported)")
|
|
case 18:
|
|
return fmt.Errorf("client's credentials have been revoked (KDC_ERR_CLIENT_REVOKED)")
|
|
case 23:
|
|
return fmt.Errorf("ticket has expired (KDC_ERR_TKT_EXPIRED)")
|
|
case 24:
|
|
return fmt.Errorf("pre-authentication failed (KDC_ERR_PREAUTH_FAILED) - invalid password/key")
|
|
case 25:
|
|
return fmt.Errorf("additional pre-authentication required (KDC_ERR_PREAUTH_REQUIRED)")
|
|
case 37:
|
|
return fmt.Errorf("clock skew too great (KDC_ERR_SKEW) - sync time with DC")
|
|
case 68:
|
|
return fmt.Errorf("principal valid for pre-authentication only")
|
|
default:
|
|
return fmt.Errorf("KDC error code %d: %s", code, e.EText)
|
|
}
|
|
}
|