checker-kerberos/checker/collect.go

537 lines
15 KiB
Go

package checker
import (
"context"
"crypto/rand"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"strconv"
"strings"
"sync"
"time"
asn1 "github.com/jcmturner/gofork/encoding/asn1"
"github.com/jcmturner/gokrb5/v8/config"
"github.com/jcmturner/gokrb5/v8/iana/errorcode"
"github.com/jcmturner/gokrb5/v8/iana/etypeID"
"github.com/jcmturner/gokrb5/v8/iana/nametype"
"github.com/jcmturner/gokrb5/v8/iana/patype"
"github.com/jcmturner/gokrb5/v8/messages"
"github.com/jcmturner/gokrb5/v8/types"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// srvPrefixes lists every SRV label we probe, in order. The first two are
// considered mandatory (no realm is usable without one of them).
var srvPrefixes = []struct {
prefix string
role string
proto string
}{
{"_kerberos._tcp.", "kdc", "tcp"},
{"_kerberos._udp.", "kdc", "udp"},
{"_kerberos-master._tcp.", "master", "tcp"},
{"_kerberos-adm._tcp.", "kadmin", "tcp"},
{"_kpasswd._tcp.", "kpasswd", "tcp"},
{"_kpasswd._udp.", "kpasswd", "udp"},
}
// weakEnctypes lists the IDs considered weak per RFC 8429 + common guidance.
var weakEnctypes = map[int32]bool{
etypeID.DES_CBC_CRC: true,
etypeID.DES_CBC_MD4: true,
etypeID.DES_CBC_MD5: true,
etypeID.DES_CBC_RAW: true,
etypeID.DES3_CBC_MD5: true,
etypeID.DES3_CBC_RAW: true,
etypeID.DES3_CBC_SHA1: true,
etypeID.DES_HMAC_SHA1: true,
etypeID.RC4_HMAC: true,
etypeID.RC4_HMAC_EXP: true,
}
// preferredEnctypes is the enctype preference list used in anonymous probes
// and authenticated requests. RC4 is included last so the KDC's ETYPE-INFO2
// reveals whether it still supports it.
var preferredEnctypes = []int32{
etypeID.AES256_CTS_HMAC_SHA1_96,
etypeID.AES128_CTS_HMAC_SHA1_96,
etypeID.AES256_CTS_HMAC_SHA384_192,
etypeID.AES128_CTS_HMAC_SHA256_128,
etypeID.RC4_HMAC,
}
// etypeNameByID is a deterministic id->name lookup built once from
// etypeID.ETypesByName, ignoring CMS/Env OID aliases.
var etypeNameByID = func() map[int32]string {
m := make(map[int32]string, len(etypeID.ETypesByName))
for name, id := range etypeID.ETypesByName {
if strings.Contains(name, "-CmsOID") || strings.HasSuffix(name, "-EnvOID") {
continue
}
if existing, ok := m[id]; ok && existing < name {
continue // keep lexicographically smallest for stability
}
m[id] = name
}
return m
}()
// etypeName returns a human-friendly name for an enctype ID, falling back
// to its numeric value when unknown.
func etypeName(id int32) string {
if name, ok := etypeNameByID[id]; ok {
return name
}
return fmt.Sprintf("etype-%d", id)
}
func (p *kerberosProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
realmRaw, _ := opts["realm"].(string)
realmRaw = strings.TrimSuffix(realmRaw, ".")
if realmRaw == "" {
return nil, fmt.Errorf("realm is required")
}
realm := strings.ToUpper(realmRaw)
domain := strings.ToLower(realmRaw)
timeoutSec := optFloat(opts, "timeout", 5)
if timeoutSec <= 0 {
timeoutSec = 5
}
timeout := time.Duration(timeoutSec * float64(time.Second))
data := &KerberosData{
Realm: realm,
CollectedAt: time.Now().UTC(),
Resolution: map[string]HostResolution{},
}
// 1. SRV discovery ---------------------------------------------------------
resolver := &net.Resolver{}
data.SRV = make([]SRVBucket, len(srvPrefixes))
var wg sync.WaitGroup
for i, sp := range srvPrefixes {
wg.Go(func() {
bucket := SRVBucket{Prefix: sp.prefix, LookupName: sp.prefix + domain + "."}
lctx, cancel := context.WithTimeout(ctx, timeout)
_, addrs, err := resolver.LookupSRV(lctx, "", "", sp.prefix+domain)
cancel()
if err != nil {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
bucket.NXDomain = true
} else {
bucket.Error = err.Error()
}
}
for _, srv := range addrs {
target := strings.TrimSuffix(srv.Target, ".")
bucket.Records = append(bucket.Records, SRVRecord{
Target: target,
Port: srv.Port,
Priority: srv.Priority,
Weight: srv.Weight,
})
}
data.SRV[i] = bucket
})
}
wg.Wait()
// 2. Forward resolution ----------------------------------------------------
var targets []string
seen := map[string]bool{}
for _, b := range data.SRV {
for _, r := range b.Records {
if !seen[r.Target] {
seen[r.Target] = true
targets = append(targets, r.Target)
}
}
}
resolutions := make([]HostResolution, len(targets))
for i, target := range targets {
wg.Go(func() {
hr := HostResolution{Target: target}
lctx, cancel := context.WithTimeout(ctx, timeout)
ips, err := resolver.LookupIPAddr(lctx, target)
cancel()
if err != nil {
hr.Error = err.Error()
}
for _, ip := range ips {
if ip.IP.To4() != nil {
hr.IPv4 = append(hr.IPv4, ip.IP.String())
} else {
hr.IPv6 = append(hr.IPv6, ip.IP.String())
}
}
resolutions[i] = hr
})
}
wg.Wait()
for _, hr := range resolutions {
data.Resolution[hr.Target] = hr
}
// 3. L4 probes -------------------------------------------------------------
kdcHosts := map[string]uint16{} // target -> tcp port, used to pick the AS-REQ destination
for _, b := range data.SRV {
role := roleForPrefix(b.Prefix)
if strings.HasSuffix(b.Prefix, "._tcp.") {
for _, r := range b.Records {
probe := dialTCP(ctx, r.Target, r.Port, role, timeout)
data.Probes = append(data.Probes, probe)
if role == "kdc" && probe.OK {
if _, ok := kdcHosts[r.Target]; !ok {
kdcHosts[r.Target] = r.Port
}
}
}
}
}
// 4. AS-REQ probe (tries each reachable KDC TCP first, then UDP fallbacks).
asProbe := ASProbeResult{Attempted: true}
asReq, err := buildProbeASReq(realm)
if err != nil {
asProbe.Error = fmt.Sprintf("build AS-REQ: %v", err)
} else {
req, _ := asReq.Marshal()
// Target list: reachable TCP KDCs, then UDP KDCs if nothing else.
var tcpEps, udpEps []endpoint
for _, b := range data.SRV {
if !strings.HasPrefix(b.Prefix, "_kerberos.") {
continue
}
proto := "tcp"
if strings.HasSuffix(b.Prefix, "._udp.") {
proto = "udp"
}
for _, r := range b.Records {
e := endpoint{r.Target, r.Port, proto}
if proto == "tcp" {
tcpEps = append(tcpEps, e)
} else {
udpEps = append(udpEps, e)
}
}
}
// TCP first, stop after the first parsed reply. UDP endpoints are
// always probed (they are the only place we record UDP KDC
// reachability), even when a TCP target already answered.
eps := append(tcpEps[:len(tcpEps):len(tcpEps)], udpEps...)
for _, e := range eps {
if e.proto == "tcp" && asProbe.Target != "" {
continue
}
start := time.Now()
var reply []byte
var perr error
if e.proto == "tcp" {
reply, perr = sendASReqTCP(ctx, e.target, e.port, req, timeout)
} else {
reply, perr = sendASReqUDP(ctx, e.target, e.port, req, timeout)
probe := KDCProbe{
Target: e.target, Port: e.port, Proto: "udp", Role: "kdc",
RTT: time.Since(start),
}
if perr == nil && len(reply) > 0 {
probe.OK = true
probe.KrbSeen = true
} else if perr != nil {
probe.Error = perr.Error()
}
data.Probes = append(data.Probes, probe)
}
if perr != nil || len(reply) == 0 {
continue
}
if asProbe.Target == "" {
asProbe.Target = e.target
asProbe.Proto = e.proto
parseASResponse(reply, &asProbe)
}
}
if asProbe.Target == "" && asProbe.Error == "" {
asProbe.Error = "no KDC answered our AS-REQ probe"
}
if !asProbe.ServerTime.IsZero() {
asProbe.ClockSkew = time.Since(asProbe.ServerTime)
}
}
data.AS = asProbe
// 5. Roll up enctypes ------------------------------------------------------
for _, e := range asProbe.Enctypes {
if e.Weak {
data.WeakEnctypes = append(data.WeakEnctypes, e)
}
}
data.Enctypes = asProbe.Enctypes
// 6. Optional authenticated round-trip ------------------------------------
principal, _ := opts["principal"].(string)
password, _ := opts["password"].(string)
if principal != "" && password == "" {
data.Auth = &AuthProbeResult{
Attempted: true,
Principal: principal,
Error: "password is required when a principal is supplied",
}
} else if principal != "" {
data.Auth = runAuthProbe(ctx, realm, principal, password,
stringOpt(opts, "targetService"), kdcHosts, timeout)
}
return data, nil
}
func roleForPrefix(prefix string) string {
switch {
case strings.HasPrefix(prefix, "_kerberos-master."):
return "master"
case strings.HasPrefix(prefix, "_kerberos-adm."):
return "kadmin"
case strings.HasPrefix(prefix, "_kpasswd."):
return "kpasswd"
default:
return "kdc"
}
}
type endpoint struct {
target string
port uint16
proto string
}
func dialTCP(ctx context.Context, target string, port uint16, role string, timeout time.Duration) KDCProbe {
probe := KDCProbe{Target: target, Port: port, Proto: "tcp", Role: role}
addr := net.JoinHostPort(target, strconv.Itoa(int(port)))
start := time.Now()
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", addr)
probe.RTT = time.Since(start)
if err != nil {
probe.Error = err.Error()
return probe
}
_ = conn.Close()
probe.OK = true
return probe
}
// sendASReqTCP frames the AS-REQ with its 4-byte length prefix (RFC 4120 §7.2.2)
// and reads the response the same way.
func sendASReqTCP(ctx context.Context, target string, port uint16, body []byte, timeout time.Duration) ([]byte, error) {
addr := net.JoinHostPort(target, strconv.Itoa(int(port)))
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return nil, err
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
hdr := make([]byte, 4)
binary.BigEndian.PutUint32(hdr, uint32(len(body)))
if _, err := conn.Write(append(hdr, body...)); err != nil {
return nil, err
}
lenBuf := make([]byte, 4)
if _, err := io.ReadFull(conn, lenBuf); err != nil {
return nil, err
}
n := binary.BigEndian.Uint32(lenBuf)
if n == 0 || n > 1<<20 {
return nil, fmt.Errorf("suspicious reply length %d", n)
}
buf := make([]byte, n)
if _, err := io.ReadFull(conn, buf); err != nil {
return nil, err
}
return buf, nil
}
func sendASReqUDP(ctx context.Context, target string, port uint16, body []byte, timeout time.Duration) ([]byte, error) {
addr := net.JoinHostPort(target, strconv.Itoa(int(port)))
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "udp", addr)
if err != nil {
return nil, err
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
if _, err := conn.Write(body); err != nil {
return nil, err
}
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
return nil, err
}
return buf[:n], nil
}
// buildProbeASReq builds an AS-REQ for krbtgt/REALM@REALM with a throwaway
// cname. We don't want to depend on reading a local krb5.conf, so we craft
// the request fields by hand from a default config.
func buildProbeASReq(realm string) (messages.ASReq, error) {
cfg := config.New()
cfg.LibDefaults.DefaultRealm = realm
cfg.LibDefaults.NoAddresses = true
cfg.LibDefaults.TicketLifetime = 10 * time.Minute
cfg.LibDefaults.DefaultTktEnctypeIDs = preferredEnctypes
cname := types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, randomProbeCName())
return messages.NewASReqForTGT(realm, cfg, cname)
}
// randomProbeCName returns a probe-only principal name. The random suffix
// avoids creating a recognizable, repeating audit-log entry on the KDC and
// keeps two concurrent probes from colliding on the same cname.
func randomProbeCName() string {
var b [6]byte
if _, err := rand.Read(b[:]); err != nil {
return "probe-happydomain"
}
return "probe-happydomain-" + hex.EncodeToString(b[:])
}
// parseASResponse inspects the raw KDC reply and fills the ASProbeResult.
// Expected replies: KRB-ERROR (PREAUTH_REQUIRED / C_PRINCIPAL_UNKNOWN) or,
// less commonly, an AS-REP (principal exists and doesn't require preauth .
// AS-REP roasting territory).
func parseASResponse(raw []byte, out *ASProbeResult) {
// Try KRB-ERROR first.
var krbErr messages.KRBError
if err := krbErr.Unmarshal(raw); err == nil {
out.ErrorCode = krbErr.ErrorCode
out.ErrorName = errorcodeName(krbErr.ErrorCode)
out.ServerRealm = krbErr.Realm
if !krbErr.STime.IsZero() {
out.ServerTime = krbErr.STime.UTC()
}
out.PreauthReq = krbErr.ErrorCode == errorcode.KDC_ERR_PREAUTH_REQUIRED
out.Raw = fmt.Sprintf("KRB-ERROR %s", out.ErrorName)
if len(krbErr.EData) > 0 {
enctypes, pkinit := extractEData(krbErr.EData)
out.Enctypes = enctypes
out.PKINITOffered = pkinit
}
return
}
// Try AS-REP. If this succeeds, preauth wasn't required, surface it.
var asRep messages.ASRep
if err := asRep.Unmarshal(raw); err == nil {
out.PrincipalFound = true
out.ServerRealm = asRep.CRealm
out.Raw = "AS-REP received without preauth (AS-REP roasting exposure)"
return
}
out.Error = "unable to parse KDC reply (" + hex.EncodeToString(first(raw, 16)) + ")"
}
func first(b []byte, n int) []byte {
if len(b) < n {
return b
}
return b[:n]
}
// extractEData parses the METHOD-DATA / PADataSequence that KDCs attach to
// PREAUTH_REQUIRED errors and returns the advertised enctypes + pkinit hint.
func extractEData(edata []byte) ([]EnctypeEntry, bool) {
var pas types.PADataSequence
if _, err := asn1.Unmarshal(edata, &pas); err != nil {
return nil, false
}
var out []EnctypeEntry
pkinit := false
for _, pa := range pas {
switch pa.PADataType {
case patype.PA_ETYPE_INFO2:
var info types.ETypeInfo2
if _, err := asn1.Unmarshal(pa.PADataValue, &info); err == nil {
for _, e := range info {
out = append(out, EnctypeEntry{
ID: e.EType,
Name: etypeName(e.EType),
Weak: weakEnctypes[e.EType],
Salt: e.Salt,
Source: "etype-info2",
})
}
}
case patype.PA_ETYPE_INFO:
var info types.ETypeInfo
if _, err := asn1.Unmarshal(pa.PADataValue, &info); err == nil {
for _, e := range info {
if hasEnctype(out, e.EType) {
continue
}
out = append(out, EnctypeEntry{
ID: e.EType,
Name: etypeName(e.EType),
Weak: weakEnctypes[e.EType],
Salt: string(e.Salt),
Source: "etype-info",
})
}
}
case patype.PA_PK_AS_REQ, patype.PA_PK_AS_REQ_OLD:
pkinit = true
}
}
return out, pkinit
}
func hasEnctype(list []EnctypeEntry, id int32) bool {
for _, e := range list {
if e.ID == id {
return true
}
}
return false
}
// ---- helpers ----------------------------------------------------------------
func optFloat(opts sdk.CheckerOptions, key string, def float64) float64 {
v, ok := opts[key]
if !ok {
return def
}
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case string:
if f, err := strconv.ParseFloat(x, 64); err == nil {
return f
}
}
return def
}
func stringOpt(opts sdk.CheckerOptions, key string) string {
s, _ := opts[key].(string)
return s
}