Hardening Negotiate/Kerberos authentication (#3404)

This commit is contained in:
Miroslav Štampar 2026-07-25 00:24:52 +02:00
parent 1bd1b457d7
commit ab881132b8
5 changed files with 227 additions and 40 deletions

View file

@ -10,9 +10,11 @@ See the file 'LICENSE' for copying permission
# the TGS exchange and AP-REQ follow. Talks to the KDC over TCP (4-byte length framing).
# Python 2.7 / 3.x.
import calendar
import os
import socket
import struct
import threading
import time
from extra.kerberos import der
@ -47,6 +49,14 @@ PVNO = 5
DEFAULT_ETYPES = (18, 17, 23) # aes256-cts, aes128-cts, rc4-hmac (best first)
KDC_TIMEOUT = 10 # seconds for the KDC TCP exchange
MAX_KDC_RESPONSE = 8 * 1024 * 1024 # cap on a KDC reply (guards a hostile length prefix)
KERBEROS_TIME_FORMAT = "%Y%m%d%H%M%SZ" # RFC 4120 KerberosTime (always UTC)
# Bounds on the string-to-key work factor a KDC may ask for. The PA-ETYPE-INFO2 hint carrying it
# arrives on an *unauthenticated* KRB-ERROR, and the field is a full 32 bits, so an absurd value would
# either weaken the derived key against offline guessing or burn hours of CPU (RFC 3962 warns about
# both and recommends configurable bounds). A count of 0 nominally means 2**32, which we cannot honour.
MIN_PBKDF2_ITERATIONS = 4096 # the RFC 3962 default; nothing legitimate is lower
MAX_PBKDF2_ITERATIONS = 1000000
def _enctype(etype):
if etype not in ENCTYPES:
@ -87,7 +97,34 @@ def _nonce():
return struct.unpack(">I", os.urandom(4))[0] & 0x7fffffff
def _kerberosTime(offsetSeconds=0):
return time.strftime("%Y%m%d%H%M%SZ", time.gmtime(time.time() + offsetSeconds))
return time.strftime(KERBEROS_TIME_FORMAT, time.gmtime(time.time() + offsetSeconds))
_timestampLock = threading.Lock()
_lastMicros = -1
def _timestamp():
"""(KerberosTime, microseconds) taken from a single clock reading and unique within the process.
An acceptor's replay cache rejects a repeated (ctime, cusec) for the same principal and service,
and a threaded scan mints an authenticator per request, so the pair must never repeat; a strictly
increasing microsecond counter also keeps cusec inside its INTEGER (0..999999) range by construction.
"""
global _lastMicros
with _timestampLock:
micros = max(int(time.time() * 1000000), _lastMicros + 1)
_lastMicros = micros
return time.strftime(KERBEROS_TIME_FORMAT, time.gmtime(micros // 1000000)), micros % 1000000
def _expTime(field):
"""An [n]-wrapped KerberosTime as epoch seconds (None when absent or unparsable, so an unusual
time format degrades ticket-expiry tracking rather than failing the exchange)."""
try:
return calendar.timegm(time.strptime(der.decodeGeneralString(der.peel(field)[1]), KERBEROS_TIME_FORMAT))
except ValueError:
return None
def _principalName(nameType, components):
return der.sequence(
@ -131,31 +168,56 @@ def _raiseIfError(message):
_expString(fields[11]) if 11 in fields else None)
return tag, content
def _parseEtypeInfo2(errorFields):
"""Extract [(etype, salt, iterations), ...] from a KDC_ERR_PREAUTH_REQUIRED error's PA-ETYPE-INFO2,
telling us which etype/salt/s2kparams the KDC expects for the long-term key."""
def _etypeHints(methodData):
"""Parse a METHOD-DATA TLV (SEQUENCE OF PA-DATA) into PA-ETYPE-INFO2 hints as
{etype: (salt, iterations)}, telling us which etype/salt/s2kparams the KDC expects for the
long-term key. The first entry for an etype wins; a malformed hint yields none (so the caller
falls back to its defaults) rather than raising."""
out = []
if 12 not in errorFields: # no e-data
return out
hints = {}
try:
methodData = der.peel(errorFields[12])[1] # e-data OCTET STRING -> METHOD-DATA (SEQ OF PA-DATA)
for _, paData in der.children(der.peel(methodData)[1]):
pa = _fields(paData)
if 1 in pa and 2 in pa and _expInteger(pa[1]) == PA_ETYPE_INFO2:
info = der.peel(pa[2])[1] # padata-value OCTET STRING -> ETYPE-INFO2 (SEQ OF entry)
for _, entry in der.children(der.peel(info)[1]):
fields = _fields(entry)
etype = _expInteger(fields[0])
salt = _expOctet(fields[1]) if 1 in fields else None # opaque octets for string2key (RFC 3961), not UTF-8
iterations = None
if 2 in fields:
raw = bytes(der.peel(fields[2])[1]) # s2kparams: 4-byte BE iteration count for AES
iterations = struct.unpack(">I", raw)[0] if len(raw) == 4 else None
out.append((etype, salt, iterations))
hints.setdefault(_expInteger(fields[0]), (salt, iterations))
except (KeyError, IndexError, ValueError, struct.error):
del out[:] # malformed hint -> fall back to the default etype/salt
return out
hints.clear() # malformed hint -> fall back to the default etype/salt
return hints
def _preauthHints(errorFields):
"""The etype hints carried by a KDC_ERR_PREAUTH_REQUIRED error's e-data (best effort)."""
if 12 not in errorFields: # no e-data
return {}
try:
return _etypeHints(der.peel(errorFields[12])[1]) # e-data OCTET STRING -> METHOD-DATA
except (KeyError, IndexError, ValueError, struct.error):
return {}
def _validatedIterations(iterations):
"""Refuse a string-to-key work factor outside local policy. The hint is unauthenticated, so a
spoofed count could either cheapen an offline attack on the PA-ENC-TIMESTAMP we are about to send
or stall the scan for hours; failing loudly beats doing either silently."""
if iterations is not None and not MIN_PBKDF2_ITERATIONS <= iterations <= MAX_PBKDF2_ITERATIONS:
raise KerberosError(-1, "KDC advertised an out-of-policy string-to-key iteration count (%d)" % iterations)
return iterations
def _hintFor(hints, etype, salt, chosenSalt):
"""Apply the hint for 'etype': its salt (unless the caller pinned one) and its work factor."""
advertisedSalt, iterations = hints.get(etype, (None, None))
if salt is None and advertisedSalt is not None:
chosenSalt = advertisedSalt
return chosenSalt, _validatedIterations(iterations)
def _replyEtype(response):
"""Return the etype of a KDC-REP's enc-part (which etype the KDC used for the client's key)."""
@ -196,6 +258,8 @@ def _parseRep(response, key, usage, expectedNonce, expectedType):
"sessionKeyType": _expInteger(keyFields[0]),
"etype": repEtype,
"crealm": _expString(rep[3]),
# EncKDCRepPart endtime [7]; a scan can outlive the ticket, so the caller can re-fetch
"endtime": _expTime(encKdcRep[7]) if 7 in encKdcRep else None,
}
except (KeyError, IndexError, ValueError, struct.error):
raise KerberosError(-1, "malformed KDC reply")
@ -211,7 +275,8 @@ def _reqBody(realm, snameType, snameComponents, etypes, nonce, cnameComponents=N
parts.append(der.tagged(8, der.sequenceOf([der.integer(_) for _ in etypes]))) # etype
return der.sequence(*parts)
def _authenticator(crealm, cnameComponents, cksum=None):
def _authenticator(crealm, cnameComponents, cksum=None, seqNumber=None):
ctime, cusec = _timestamp() # both from one clock reading, never repeating
parts = [
der.tagged(0, der.integer(PVNO)),
der.tagged(1, der.generalString(crealm)),
@ -220,8 +285,10 @@ def _authenticator(crealm, cnameComponents, cksum=None):
if cksum is not None:
parts.append(der.tagged(3, der.sequence(der.tagged(0, der.integer(cksum[0])),
der.tagged(1, der.octetString(cksum[1])))))
parts.append(der.tagged(4, der.integer(struct.unpack(">I", os.urandom(4))[0] % 1000000))) # cusec
parts.append(der.tagged(5, der.generalizedTime(_kerberosTime()))) # ctime
parts.append(der.tagged(4, der.integer(cusec)))
parts.append(der.tagged(5, der.generalizedTime(ctime)))
if seqNumber is not None: # [7] seq-number, expected of a GSS AP-REQ
parts.append(der.tagged(7, der.integer(seqNumber)))
return der.application(2, der.sequence(*parts))
def _apReq(ticket, encAuthenticator, etype, apOptions=b"\x00\x00\x00\x00"):
@ -248,10 +315,10 @@ def getTGT(realm, username, password, kdcHost, kdcPort=88, etypes=DEFAULT_ETYPES
Follows the standard two-step flow: an initial request without pre-auth learns the KDC's expected
etype/salt/iteration-count from PA-ETYPE-INFO2 (so non-default salts and AES-128-only principals
work), then a PA-ENC-TIMESTAMP-authenticated request obtains the ticket. Returns
{'ticket': <raw Ticket TLV>, 'sessionKey': bytes, 'sessionKeyType': int, 'crealm': str}.
{'ticket': <raw Ticket TLV>, 'sessionKey': bytes, 'sessionKeyType': int, 'crealm': str,
'endtime': epoch seconds}. 'realm' is used exactly as given (RFC 4120 realms are case-sensitive).
"""
realm = realm.upper()
chosenSalt = salt if salt is not None else realm + username
# 1) probe without pre-auth to discover the etype/salt/iterations (or get the TGT outright)
@ -261,7 +328,10 @@ def getTGT(realm, username, password, kdcHost, kdcPort=88, etypes=DEFAULT_ETYPES
if tag == der.applicationTag(AS_REP): # KDC issued the ticket without pre-auth
etype = _replyEtype(response) # derive the key for the etype the KDC actually used
clientKey = _enctype(etype).string2key(password, chosenSalt)
rep = _fields(der.peel(der.peel(response)[1])[1])
# the reply's own padata can still carry the salt/iterations of a non-default principal
chosenSalt, iterations = _hintFor(_etypeHints(rep[2]) if 2 in rep else {}, etype, salt, chosenSalt)
clientKey = _enctype(etype).string2key(password, chosenSalt, iterations)
return _parseRep(response, clientKey, USAGE_AS_REP_ENCPART, nonce, AS_REP)
etype, iterations = etypes[0], None
@ -270,19 +340,21 @@ def getTGT(realm, username, password, kdcHost, kdcPort=88, etypes=DEFAULT_ETYPES
code = _expInteger(errorFields[6]) if 6 in errorFields else -1
if code != KDC_ERR_PREAUTH_REQUIRED:
raise KerberosError(code, _expString(errorFields[11]) if 11 in errorFields else None)
for advertisedEtype, advertisedSalt, advertisedIters in _parseEtypeInfo2(errorFields):
if advertisedEtype in ENCTYPES:
etype = advertisedEtype
iterations = advertisedIters
if salt is None and advertisedSalt is not None:
chosenSalt = advertisedSalt
# the hint is unauthenticated, so it may only choose among the etypes we actually offered, and
# in *our* order of preference rather than the KDC's (otherwise it could force a downgrade)
hints = _preauthHints(errorFields)
for offered in etypes:
if offered in hints and offered in ENCTYPES:
etype = offered
break
chosenSalt, iterations = _hintFor(hints, etype, salt, chosenSalt)
enc = _enctype(etype)
clientKey = enc.string2key(password, chosenSalt, iterations)
# 2) authenticated request with PA-ENC-TIMESTAMP under the discovered etype/salt
paTsEnc = der.sequence(der.tagged(0, der.generalizedTime(_kerberosTime())), der.tagged(1, der.integer(0)))
patime, pausec = _timestamp()
paTsEnc = der.sequence(der.tagged(0, der.generalizedTime(patime)), der.tagged(1, der.integer(pausec)))
cipher = enc.encrypt(clientKey, USAGE_AS_REQ_PA_ENC_TIMESTAMP, paTsEnc)
paData = der.sequence(
der.tagged(1, der.integer(PA_ENC_TIMESTAMP)),
@ -296,10 +368,10 @@ def getTGT(realm, username, password, kdcHost, kdcPort=88, etypes=DEFAULT_ETYPES
def getServiceTicket(tgt, realm, username, serviceComponents, kdcHost, kdcPort=88, etypes=DEFAULT_ETYPES):
"""Present the TGT in a PA-TGS-REQ AP-REQ to obtain a ticket for the named service.
Returns the same shape as getTGT (the 'ticket' is now the service ticket).
Returns the same shape as getTGT (the 'ticket' is now the service ticket). Cross-realm referrals
are not followed, so 'serviceComponents' must name a service inside 'realm'.
"""
realm = realm.upper()
enc = _enctype(tgt["sessionKeyType"])
nonce = _nonce()
reqBody = _reqBody(realm, NT_SRV_INST, serviceComponents, etypes, nonce)
@ -327,6 +399,7 @@ def spnegoFromTicket(service, realm, username):
enc = _enctype(service["sessionKeyType"])
gssChecksum = (GSS_CHECKSUM_TYPE, struct.pack("<I", 16) + b"\x00" * 16 + struct.pack("<I", GSS_CHECKSUM_FLAGS))
authenticator = _authenticator(realm.upper(), [username], cksum=gssChecksum)
# seq-number is expected of the GSS mechanism's initial AP-REQ (RFC 4121), so always send one
authenticator = _authenticator(realm, [username], cksum=gssChecksum, seqNumber=_nonce())
encAuth = enc.encrypt(service["sessionKey"], USAGE_AP_REQ_AUTH, authenticator)
return spnego.negTokenInit(_apReq(service["ticket"], encAuth, service["sessionKeyType"]))

View file

@ -18,6 +18,7 @@ import os
import struct
from extra.kerberos.aes import AES, _xor # _xor reused (no second copy) from the AES core
from lib.core.decorators import cachedmethod
from lib.request.ntlm import _md4 # proven RFC 1320 MD4 (reused, not re-derived)
# RFC 3962 string-to-key default work factor when the KDC advertises no explicit count
@ -122,8 +123,13 @@ class AESEnctype(object):
out += bytearray(block)
return bytes(out[:self.keysize])
@cachedmethod
def dk(self, key, constant):
"""RFC 3961 DK = random-to-key(DR(...)); random-to-key is the identity for AES."""
"""RFC 3961 DK = random-to-key(DR(...)); random-to-key is the identity for AES.
Cached: it is a pure function of (key, constant), while a scan mints an authenticator per
request from the same handful of long-lived keys, so the pure-Python DR would otherwise be
recomputed for every single one."""
return self._dr(key, constant)
@ -222,8 +228,9 @@ class RC4Enctype(object):
@staticmethod
def _usage(usage):
# RFC 4757 section 3: a couple of Kerberos usages map to Microsoft-specific values
return struct.pack("<I", {3: 8, 9: 8}.get(usage, usage))
# RFC 4757 section 3: a couple of Kerberos usages map to Microsoft-specific values (per the
# published errata, usage 9 is NOT folded into 8 - only 3->8 and 23->13 apply)
return struct.pack("<I", {3: 8, 23: 13}.get(usage, usage))
def encrypt(self, key, usage, plaintext, confounder=None):
if confounder is None:

View file

@ -40,18 +40,27 @@ def _fromKrb5Conf(realm):
except (IOError, OSError):
return None
header = re.search(r"(?im)^\s*%s\s*=\s*\{" % re.escape(realm), content)
# scope the search to the [realms] section itself: '[capaths]' uses the identical
# 'realm = { ... }' syntax, so a same-named capath block must not shadow the real one
section = re.search(r"(?im)^[ \t]*\[realms\][ \t]*$", content)
if not section:
return None
nextSection = re.search(r"(?m)^[ \t]*\[", content[section.end():])
sectionEnd = section.end() + nextSection.start() if nextSection else len(content)
realms = content[section.end():sectionEnd]
header = re.search(r"(?im)^\s*%s\s*=\s*\{" % re.escape(realm), realms)
if not header:
return None
start = header.end() # brace-match so a nested '{ }' block cannot truncate us
depth, i = 1, start
while i < len(content) and depth > 0:
if content[i] == "{":
while i < len(realms) and depth > 0:
if realms[i] == "{":
depth += 1
elif content[i] == "}":
elif realms[i] == "}":
depth -= 1
i += 1
block = content[start:i - 1]
block = realms[start:i - 1]
kdc = re.search(r"(?im)^\s*kdc\s*=\s*(\S+)", block)
return kdc.group(1) if kdc else None
@ -135,11 +144,11 @@ def parseSrv(response):
offset = _skipName(data, offset)
if offset + 10 > len(data):
break
rtype, _cls, _ttl, rdlength = struct.unpack(">HHIH", bytes(data[offset:offset + 10]))
rtype, rclass, _ttl, rdlength = struct.unpack(">HHIH", bytes(data[offset:offset + 10]))
offset += 10
if offset + rdlength > len(data):
break
if rtype == _SRV_TYPE and rdlength >= 6:
if rtype == _SRV_TYPE and rclass == _IN_CLASS and rdlength >= 6:
priority, weight, port = struct.unpack(">HHH", bytes(data[offset:offset + 6]))
target = _readName(data, offset + 6)[0].rstrip(".")
if target:

View file

@ -14,6 +14,7 @@ See the file 'LICENSE' for copying permission
import base64
import logging
import threading
import time
from lib.core.common import getSafeExString
from lib.core.common import singleTimeLogMessage
@ -27,10 +28,20 @@ from extra.kerberos.client import KerberosError
from extra.kerberos.client import spnegoFromTicket
from extra.kerberos.discovery import discoverKdc
TICKET_REFRESH_SKEW = 300 # re-fetch a ticket this long before it expires
def _expiring(ticket):
"""True for a cached ticket close enough to its expiry to be worth replacing (a scan can easily
run longer than the ticket lifetime, and an expired AP-REQ is rejected by every acceptor)."""
return ticket is not None and ticket.get("endtime") is not None and time.time() + TICKET_REFRESH_SKEW >= ticket["endtime"]
class HTTPNegotiateAuthHandler(_urllib.request.BaseHandler):
handler_order = 480
def __init__(self, realm, username, password, kdcHost=None, kdcPort=None):
# Kerberos realms are case-sensitive, but the credentials arrive in the Windows 'DOMAIN\\user'
# form where the domain is not, so normalize here rather than inside the protocol client
self.realm = realm.upper()
self.username = username
self.password = password
@ -48,6 +59,10 @@ class HTTPNegotiateAuthHandler(_urllib.request.BaseHandler):
raise self._tgtFailure
if host in self._hostFailures: # this host already failed -> don't retry it
raise self._hostFailures[host]
if _expiring(self._tickets.get(host)): # drop a ticket that a long scan has outlived
del self._tickets[host]
if _expiring(self._tgt):
self._tgt = None
if host not in self._tickets:
if self._tgt is None:
if self.kdcHost is None: # krb5.conf / DNS SRV / realm-name discovery

View file

@ -15,6 +15,7 @@ import os
import struct
import sys
import tempfile
import time
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@ -26,6 +27,7 @@ from extra.kerberos import der
from extra.kerberos import discovery
from extra.kerberos.aes import AES
from extra.kerberos.crypto import ENCTYPES, nfold
from lib.request.kerberos import _expiring
def _dnsName(name):
@ -41,6 +43,31 @@ def _h(value):
return binascii.unhexlify(value)
def _etypeInfo2Entry(etype, salt=None, iterations=None):
parts = [der.tagged(0, der.integer(etype))]
if salt is not None:
parts.append(der.tagged(1, der.generalString(salt)))
if iterations is not None:
parts.append(der.tagged(2, der.octetString(struct.pack(">I", iterations))))
return der.sequence(*parts)
def _preauthError(entries):
"""The error-field map a KDC_ERR_PREAUTH_REQUIRED reply advertising 'entries' would produce."""
paData = der.sequence(
der.tagged(1, der.integer(19)), # PA-ETYPE-INFO2
der.tagged(2, der.octetString(der.sequenceOf(entries))),
)
return {12: der.octetString(der.sequenceOf([paData]))}
def _selectEtype(offered, hints):
"""getTGT's etype choice: the client's own preference order, restricted to what it offered."""
return next((_ for _ in offered if _ in hints and _ in ENCTYPES), None)
class TestKerberosAES(unittest.TestCase):
def test_fips197_known_answer(self):
# FIPS-197 Appendix C.1 (AES-128) and C.3 (AES-256)
@ -168,8 +195,64 @@ class TestKerberosClient(unittest.TestCase):
def test_etype_info2_best_effort(self):
# a malformed PA-ETYPE-INFO2 must yield no advertised info (fall back to defaults), not crash
self.assertEqual(client._parseEtypeInfo2({12: der.octetString(b"\xff\xff\xff")}), [])
self.assertEqual(client._parseEtypeInfo2({}), [])
self.assertEqual(client._preauthHints({12: der.octetString(b"\xff\xff\xff")}), {})
self.assertEqual(client._preauthHints({}), {})
self.assertEqual(client._etypeHints(der.octetString(b"\xff\xff\xff")), {})
def test_etype_info2_hints(self):
hints = client._preauthHints(_preauthError([_etypeInfo2Entry(18, "SALT", 4096),
_etypeInfo2Entry(23)]))
self.assertEqual(hints, {18: (b"SALT", 4096), 23: (None, None)})
def test_iteration_count_policy(self):
# the hint is unauthenticated: a count that would cheapen an offline attack or stall the scan
# for hours must be refused, and 0 (nominally 2**32) is not silently taken as the default
self.assertIsNone(client._validatedIterations(None))
self.assertEqual(client._validatedIterations(4096), 4096)
for bogus in (0, 1, 1000, client.MAX_PBKDF2_ITERATIONS + 1, 0xFFFFFFFF):
self.assertRaises(client.KerberosError, client._validatedIterations, bogus)
def test_hint_cannot_override_pinned_salt(self):
hints = {18: (b"KDCSALT", 4096)}
self.assertEqual(client._hintFor(hints, 18, "PINNED", "PINNED"), ("PINNED", 4096))
self.assertEqual(client._hintFor(hints, 18, None, "DEFAULT"), (b"KDCSALT", 4096))
self.assertEqual(client._hintFor(hints, 17, None, "DEFAULT"), ("DEFAULT", None))
def test_etype_selection_honours_client_preference(self):
# a spoofed hint must not be able to pull the client onto an etype it never offered, and the
# client's own preference order wins over the KDC's
hints = client._preauthHints(_preauthError([_etypeInfo2Entry(23), _etypeInfo2Entry(18)]))
self.assertEqual(_selectEtype((18, 17), hints), 18) # KDC listed rc4 first
self.assertEqual(_selectEtype((17, 18), hints), 18) # only 18 is hinted
self.assertIsNone(_selectEtype((18, 17), client._preauthHints(_preauthError([_etypeInfo2Entry(23)]))))
def test_authenticator_timestamps_are_unique(self):
# an acceptor's replay cache keys on (ctime, cusec), and a threaded scan mints one per request
stamps = [client._timestamp() for _ in range(2000)]
self.assertEqual(len(set(stamps)), len(stamps))
self.assertTrue(all(0 <= cusec <= 999999 for _, cusec in stamps))
def test_authenticator_carries_seq_number(self):
# RFC 4121 expects a sequence number in the GSS mechanism's initial AP-REQ authenticator
fields = client._fields(der.peel(der.peel(
client._authenticator("EXAMPLE.COM", ["user"], seqNumber=0x11223344))[1])[1])
self.assertEqual(client._expInteger(fields[7]), 0x11223344)
self.assertNotIn(7, client._fields(der.peel(der.peel(
client._authenticator("EXAMPLE.COM", ["user"]))[1])[1]))
def test_kerberos_time_round_trip(self):
self.assertEqual(client._expTime(der.generalizedTime("19700101000010Z")), 10)
self.assertIsNone(client._expTime(der.generalizedTime("not-a-time")))
class TestKerberosTicketCache(unittest.TestCase):
def test_expiring(self):
now = time.time()
self.assertFalse(_expiring(None))
self.assertFalse(_expiring({"endtime": None})) # a KDC that sent no parsable endtime
self.assertFalse(_expiring({"endtime": now + 36000}))
self.assertTrue(_expiring({"endtime": now - 1})) # already expired
self.assertTrue(_expiring({"endtime": now + 60})) # inside the refresh skew
class TestKerberosDiscovery(unittest.TestCase):