537 lines
15 KiB
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
|
|
}
|