From 1bd1b457d774a39ccd00e79a1674e1eb59077c0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Fri, 24 Jul 2026 22:26:43 +0200 Subject: [PATCH 1/3] Adding Negotiate/Kerberos authentication (#3404) --- extra/kerberos/__init__.py | 6 + extra/kerberos/aes.py | 174 +++++++++++++++++++ extra/kerberos/client.py | 332 ++++++++++++++++++++++++++++++++++++ extra/kerberos/crypto.py | 256 +++++++++++++++++++++++++++ extra/kerberos/der.py | 142 +++++++++++++++ extra/kerberos/discovery.py | 191 +++++++++++++++++++++ extra/kerberos/spnego.py | 33 ++++ lib/core/enums.py | 1 + lib/core/option.py | 26 ++- lib/core/settings.py | 2 +- lib/request/kerberos.py | 84 +++++++++ tests/test_kerberos.py | 241 ++++++++++++++++++++++++++ 12 files changed, 1479 insertions(+), 9 deletions(-) create mode 100644 extra/kerberos/__init__.py create mode 100644 extra/kerberos/aes.py create mode 100644 extra/kerberos/client.py create mode 100644 extra/kerberos/crypto.py create mode 100644 extra/kerberos/der.py create mode 100644 extra/kerberos/discovery.py create mode 100644 extra/kerberos/spnego.py create mode 100644 lib/request/kerberos.py create mode 100644 tests/test_kerberos.py diff --git a/extra/kerberos/__init__.py b/extra/kerberos/__init__.py new file mode 100644 index 00000000000..2c772879a4f --- /dev/null +++ b/extra/kerberos/__init__.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" diff --git a/extra/kerberos/aes.py b/extra/kerberos/aes.py new file mode 100644 index 00000000000..2cce2db762b --- /dev/null +++ b/extra/kerberos/aes.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +# Dependency-free AES (FIPS-197) block cipher with CBC mode, supporting 128- and 256-bit keys. It is +# the primitive underneath Kerberos' AES-CTS-HMAC-SHA1-96 (RFC 3962) etypes, kept pure-Python so +# '--auth-type=Negotiate' needs no third-party crypto library. Validated against the FIPS-197 +# known-answer vectors. Python 2.7 / 3.x. +# +# The state is a flat list of 16 ints in AES column-major order: byte i holds row (i % 4), column +# (i // 4), i.e. column c occupies positions [4*c : 4*c + 4]. + +def _gmul(a, b): + """Multiplication in GF(2**8) with the AES reduction polynomial 0x11b.""" + + p = 0 + for _ in range(8): + if b & 1: + p ^= a + high = a & 0x80 + a = (a << 1) & 0xff + if high: + a ^= 0x1b + b >>= 1 + return p + +# GF(2**8) log/exp tables (generator 0x03) -> multiplicative inverse -> S-box (affine transform), +# computed rather than transcribed so there is no 256-entry table to get wrong +_EXP = [0] * 256 +_LOG = [0] * 256 +_x = 1 +for _i in range(255): + _EXP[_i] = _x + _LOG[_x] = _i + _x = _gmul(_x, 0x03) + +def _inv(b): + return 0 if b == 0 else _EXP[(255 - _LOG[b]) % 255] + +def _rotl8(b, n): + return ((b << n) | (b >> (8 - n))) & 0xff + +SBOX = [] +for _b in range(256): + _v = _inv(_b) + SBOX.append(_v ^ _rotl8(_v, 1) ^ _rotl8(_v, 2) ^ _rotl8(_v, 3) ^ _rotl8(_v, 4) ^ 0x63) + +INV_SBOX = [0] * 256 +for _b in range(256): + INV_SBOX[SBOX[_b]] = _b + +RCON = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d] + +def _xor(a, b): + if len(a) != len(b): # equal-length by construction; fail loud (not via assert, which -O strips) + raise ValueError("XOR operands differ in length") + return bytes(bytearray(x ^ y for x, y in zip(bytearray(a), bytearray(b)))) + +class AES(object): + """AES-128/256 block cipher (16-byte block) with a minimal CBC mode.""" + + def __init__(self, key): + key = bytearray(key) + if len(key) not in (16, 32): + raise ValueError("AES key must be 16 or 32 bytes") + self.rounds = 10 if len(key) == 16 else 14 + self._roundKeys = self._expand(key) + + def _expand(self, key): + nk = len(key) // 4 + words = [list(key[4 * i:4 * i + 4]) for i in range(nk)] + for i in range(nk, 4 * (self.rounds + 1)): + temp = list(words[i - 1]) + if i % nk == 0: + temp = temp[1:] + temp[:1] # RotWord + temp = [SBOX[b] for b in temp] # SubWord + temp[0] ^= RCON[i // nk - 1] + elif nk > 6 and i % nk == 4: + temp = [SBOX[b] for b in temp] + words.append([words[i - nk][j] ^ temp[j] for j in range(4)]) + + roundKeys = [] + for r in range(self.rounds + 1): + rk = [] + for c in range(4): + rk.extend(words[4 * r + c]) + roundKeys.append(rk) + return roundKeys + + @staticmethod + def _addRoundKey(state, rk): + for i in range(16): + state[i] ^= rk[i] + + @staticmethod + def _shiftRows(s): + out = [0] * 16 + for r in range(4): + for c in range(4): + out[r + 4 * c] = s[r + 4 * ((c + r) % 4)] + return out + + @staticmethod + def _invShiftRows(s): + out = [0] * 16 + for r in range(4): + for c in range(4): + out[r + 4 * c] = s[r + 4 * ((c - r) % 4)] + return out + + @staticmethod + def _mixColumns(s): + out = [0] * 16 + for c in range(4): + col = s[4 * c:4 * c + 4] + out[4 * c + 0] = _gmul(col[0], 2) ^ _gmul(col[1], 3) ^ col[2] ^ col[3] + out[4 * c + 1] = col[0] ^ _gmul(col[1], 2) ^ _gmul(col[2], 3) ^ col[3] + out[4 * c + 2] = col[0] ^ col[1] ^ _gmul(col[2], 2) ^ _gmul(col[3], 3) + out[4 * c + 3] = _gmul(col[0], 3) ^ col[1] ^ col[2] ^ _gmul(col[3], 2) + return out + + @staticmethod + def _invMixColumns(s): + out = [0] * 16 + for c in range(4): + col = s[4 * c:4 * c + 4] + out[4 * c + 0] = _gmul(col[0], 14) ^ _gmul(col[1], 11) ^ _gmul(col[2], 13) ^ _gmul(col[3], 9) + out[4 * c + 1] = _gmul(col[0], 9) ^ _gmul(col[1], 14) ^ _gmul(col[2], 11) ^ _gmul(col[3], 13) + out[4 * c + 2] = _gmul(col[0], 13) ^ _gmul(col[1], 9) ^ _gmul(col[2], 14) ^ _gmul(col[3], 11) + out[4 * c + 3] = _gmul(col[0], 11) ^ _gmul(col[1], 13) ^ _gmul(col[2], 9) ^ _gmul(col[3], 14) + return out + + def encryptBlock(self, block): + state = list(bytearray(block)) + self._addRoundKey(state, self._roundKeys[0]) + for r in range(1, self.rounds): + state = self._mixColumns(self._shiftRows([SBOX[b] for b in state])) + self._addRoundKey(state, self._roundKeys[r]) + state = self._shiftRows([SBOX[b] for b in state]) + self._addRoundKey(state, self._roundKeys[self.rounds]) + return bytes(bytearray(state)) + + def decryptBlock(self, block): + state = list(bytearray(block)) + self._addRoundKey(state, self._roundKeys[self.rounds]) + for r in range(self.rounds - 1, 0, -1): + state = [INV_SBOX[b] for b in self._invShiftRows(state)] + self._addRoundKey(state, self._roundKeys[r]) + state = self._invMixColumns(state) + state = [INV_SBOX[b] for b in self._invShiftRows(state)] + self._addRoundKey(state, self._roundKeys[0]) + return bytes(bytearray(state)) + + def cbcEncrypt(self, iv, data): + if len(data) % 16 != 0: + raise ValueError("CBC input is not block-aligned") + prev, out = iv, [] + for i in range(0, len(data), 16): + prev = self.encryptBlock(_xor(data[i:i + 16], prev)) + out.append(prev) + return b"".join(out) + + def cbcDecrypt(self, iv, data): + if len(data) % 16 != 0: + raise ValueError("CBC input is not block-aligned") + prev, out = iv, [] + for i in range(0, len(data), 16): + block = data[i:i + 16] + out.append(_xor(self.decryptBlock(block), prev)) + prev = block + return b"".join(out) diff --git a/extra/kerberos/client.py b/extra/kerberos/client.py new file mode 100644 index 00000000000..7616575d11d --- /dev/null +++ b/extra/kerberos/client.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +# Dependency-free Kerberos 5 client (RFC 4120) built on the in-tree DER codec and RFC 3961/3962 +# crypto. Implements the AS exchange (password -> TGT) with PA-ENC-TIMESTAMP pre-authentication; +# the TGS exchange and AP-REQ follow. Talks to the KDC over TCP (4-byte length framing). +# Python 2.7 / 3.x. + +import os +import socket +import struct +import time + +from extra.kerberos import der +from extra.kerberos import spnego +from extra.kerberos.crypto import ENCTYPES + +GSS_CHECKSUM_TYPE = 0x8003 # RFC 4121 section 4.1.1 authenticator checksum +GSS_CHECKSUM_FLAGS = 0 # no GSS context flags requested (no mutual/deleg) +TICKET_LIFETIME_SECONDS = 10 * 3600 # requested 'till' offset (KDC clamps to its max) + +# message types +AS_REQ, AS_REP, TGS_REQ, TGS_REP, AP_REQ, KRB_ERROR = 10, 11, 12, 13, 14, 30 + +# principal name types +NT_PRINCIPAL, NT_SRV_INST = 1, 2 + +# PA-DATA types +PA_TGS_REQ, PA_ENC_TIMESTAMP, PA_ETYPE_INFO2 = 1, 2, 19 + +# KDC error code that carries the PA-ETYPE-INFO2 hint (etype/salt/iteration count) for pre-auth +KDC_ERR_PREAUTH_REQUIRED = 25 + +# key usages (RFC 4120 section 7.5.1) +USAGE_AS_REQ_PA_ENC_TIMESTAMP = 1 +USAGE_AS_REP_ENCPART = 3 +USAGE_TGS_REQ_AUTH_CKSUM = 6 +USAGE_TGS_REQ_AUTH = 7 +USAGE_TGS_REP_ENCPART = 8 +USAGE_AP_REQ_AUTH = 11 + +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) + +def _enctype(etype): + if etype not in ENCTYPES: + raise KerberosError(-1, "unsupported encryption type %d (only AES-CTS-HMAC-SHA1 is implemented)" % etype) + return ENCTYPES[etype] + +class KerberosError(Exception): + def __init__(self, code, text=None): + Exception.__init__(self, "KDC error %d%s" % (code, ": %s" % text if text else "")) + self.code = code + +# ---- EXPLICIT-tag unwrap helpers ------------------------------------------------------------------ +# Kerberos uses EXPLICIT tagging: an [n] field's content is a complete inner TLV, so it must be +# peeled before the value can be read. _fields() maps a SEQUENCE's [n] children to that inner TLV. +def _fields(sequenceContent): + out = {} + for tag, inner in der.children(sequenceContent): + if 0xA0 <= tag <= 0xBE: # context-specific, constructed [0]..[30] + out[tag - 0xA0] = inner + return out + +def _expInteger(field): + return der.decodeInteger(der.peel(field)[1]) + +def _expString(field): + return der.decodeGeneralString(der.peel(field)[1]) + +def _expOctet(field): + return bytes(der.peel(field)[1]) + +def _expFields(field): + """For an [n] field whose inner TLV is a SEQUENCE, return that SEQUENCE's field map.""" + + return _fields(der.peel(field)[1]) + +# ---- message building ----------------------------------------------------------------------------- +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)) + +def _principalName(nameType, components): + return der.sequence( + der.tagged(0, der.integer(nameType)), + der.tagged(1, der.sequenceOf([der.generalString(_) for _ in components])), + ) + +def _encryptedData(etype, cipher, kvno=None): + parts = [der.tagged(0, der.integer(etype))] + if kvno is not None: + parts.append(der.tagged(1, der.integer(kvno))) + parts.append(der.tagged(2, der.octetString(cipher))) + return der.sequence(*parts) + +# ---- KDC transport (RFC 4120 section 7.2.2: 4-byte length-prefixed over TCP) ---------------------- +def _recvExactly(sock, count): + buf = b"" + while len(buf) < count: + chunk = sock.recv(count - len(buf)) + if not chunk: + raise KerberosError(-1, "connection closed by KDC") + buf += chunk + return buf + +def _sendReceive(host, port, request): + sock = socket.create_connection((host, port), timeout=KDC_TIMEOUT) + try: + sock.sendall(struct.pack(">I", len(request)) + request) + length = struct.unpack(">I", _recvExactly(sock, 4))[0] + if length > MAX_KDC_RESPONSE: + raise KerberosError(-1, "KDC reply length %d exceeds the sane maximum" % length) + return _recvExactly(sock, length) + finally: + sock.close() + +def _raiseIfError(message): + tag, content, _ = der.peel(message) + if tag == der.applicationTag(KRB_ERROR): + fields = _fields(der.peel(content)[1]) + raise KerberosError(_expInteger(fields[6]) if 6 in fields else -1, + _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.""" + + out = [] + if 12 not in errorFields: # no e-data + return out + 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)) + except (KeyError, IndexError, ValueError, struct.error): + del out[:] # malformed hint -> fall back to the default etype/salt + return out + +def _replyEtype(response): + """Return the etype of a KDC-REP's enc-part (which etype the KDC used for the client's key).""" + + try: + rep = _fields(der.peel(der.peel(response)[1])[1]) + return _expInteger(_expFields(rep[6])[0]) + except (KeyError, IndexError, ValueError, struct.error): + raise KerberosError(-1, "malformed KDC reply") + +def _parseRep(response, key, usage, expectedNonce, expectedType): + """Parse an AS-REP / TGS-REP: decrypt its enc-part with 'key' under 'usage', returning the + opaque ticket and the freshly issued session key. The two replies are structurally identical. + The reply's application tag MUST match the expected message type, and the nonce carried in the + (integrity-protected) enc-part MUST equal the request nonce (RFC 4120).""" + + try: # any structural defect in a hostile/truncated reply -> KerberosError + tag, repContent = _raiseIfError(response) + if tag != der.applicationTag(expectedType): + raise KerberosError(-1, "unexpected reply message type (tag 0x%02x)" % tag) + rep = _fields(der.peel(repContent)[1]) + encData = _expFields(rep[6]) # enc-part (EncryptedData) + repEtype = _expInteger(encData[0]) + try: + encRepPart = _enctype(repEtype).decrypt(key, usage, _expOctet(encData[2])) + except ValueError: # HMAC mismatch -> we hold the wrong long-term key + raise KerberosError(-1, "reply decryption failed (wrong password or salt)") + + # Enc*RepPart = [APPLICATION 25/26] EncKDCRepPart ; key is field [0], nonce is field [2] + encKdcRep = _fields(der.peel(der.peel(encRepPart)[1])[1]) + if _expInteger(encKdcRep[2]) != expectedNonce: + raise KerberosError(-1, "reply nonce does not match the request (possible replay)") + keyFields = _expFields(encKdcRep[0]) + + return { + "ticket": bytes(rep[5]), + "sessionKey": _expOctet(keyFields[1]), + "sessionKeyType": _expInteger(keyFields[0]), + "etype": repEtype, + "crealm": _expString(rep[3]), + } + except (KeyError, IndexError, ValueError, struct.error): + raise KerberosError(-1, "malformed KDC reply") + +def _reqBody(realm, snameType, snameComponents, etypes, nonce, cnameComponents=None): + parts = [der.tagged(0, der.bitString(b"\x00\x00\x00\x00"))] # kdc-options + if cnameComponents is not None: + parts.append(der.tagged(1, _principalName(NT_PRINCIPAL, cnameComponents))) # cname (AS only) + parts.append(der.tagged(2, der.generalString(realm))) # realm + parts.append(der.tagged(3, _principalName(snameType, snameComponents))) # sname + parts.append(der.tagged(5, der.generalizedTime(_kerberosTime(offsetSeconds=TICKET_LIFETIME_SECONDS)))) # till + parts.append(der.tagged(7, der.integer(nonce))) # nonce + parts.append(der.tagged(8, der.sequenceOf([der.integer(_) for _ in etypes]))) # etype + return der.sequence(*parts) + +def _authenticator(crealm, cnameComponents, cksum=None): + parts = [ + der.tagged(0, der.integer(PVNO)), + der.tagged(1, der.generalString(crealm)), + der.tagged(2, _principalName(NT_PRINCIPAL, cnameComponents)), + ] + 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 + return der.application(2, der.sequence(*parts)) + +def _apReq(ticket, encAuthenticator, etype, apOptions=b"\x00\x00\x00\x00"): + return der.application(AP_REQ, der.sequence( + der.tagged(0, der.integer(PVNO)), + der.tagged(1, der.integer(AP_REQ)), + der.tagged(2, der.bitString(apOptions)), + der.tagged(3, ticket), # raw Ticket TLV (already [APPLICATION 1]) + der.tagged(4, _encryptedData(etype, encAuthenticator)), + )) + +# ---- AS exchange (password -> TGT) ---------------------------------------------------------------- +def _asReq(realm, username, etypes, nonce, padata=None): + reqBody = _reqBody(realm, NT_SRV_INST, ["krbtgt", realm], etypes, nonce, cnameComponents=[username]) + parts = [der.tagged(1, der.integer(PVNO)), der.tagged(2, der.integer(AS_REQ))] + if padata is not None: + parts.append(der.tagged(3, der.sequenceOf([padata]))) + parts.append(der.tagged(4, reqBody)) + return der.application(AS_REQ, der.sequence(*parts)) + +def getTGT(realm, username, password, kdcHost, kdcPort=88, etypes=DEFAULT_ETYPES, salt=None): + """Run the AS exchange and return the TGT and its session key. + + 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': , 'sessionKey': bytes, 'sessionKeyType': int, 'crealm': str}. + """ + + 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) + nonce = _nonce() + response = _sendReceive(kdcHost, kdcPort, _asReq(realm, username, etypes, nonce)) + tag = der.peel(response)[0] + + 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) + return _parseRep(response, clientKey, USAGE_AS_REP_ENCPART, nonce, AS_REP) + + etype, iterations = etypes[0], None + if tag == der.applicationTag(KRB_ERROR): + errorFields = _fields(der.peel(der.peel(response)[1])[1]) + 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 + break + + 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))) + cipher = enc.encrypt(clientKey, USAGE_AS_REQ_PA_ENC_TIMESTAMP, paTsEnc) + paData = der.sequence( + der.tagged(1, der.integer(PA_ENC_TIMESTAMP)), + der.tagged(2, der.octetString(_encryptedData(etype, cipher))), + ) + nonce = _nonce() + response = _sendReceive(kdcHost, kdcPort, _asReq(realm, username, etypes, nonce, padata=paData)) + return _parseRep(response, clientKey, USAGE_AS_REP_ENCPART, nonce, AS_REP) + +# ---- TGS exchange (TGT -> service ticket) --------------------------------------------------------- +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). + """ + + realm = realm.upper() + enc = _enctype(tgt["sessionKeyType"]) + nonce = _nonce() + reqBody = _reqBody(realm, NT_SRV_INST, serviceComponents, etypes, nonce) + + cksum = (enc.cksumtype, enc.checksum(tgt["sessionKey"], USAGE_TGS_REQ_AUTH_CKSUM, reqBody)) + authenticator = _authenticator(realm, [username], cksum=cksum) + encAuth = enc.encrypt(tgt["sessionKey"], USAGE_TGS_REQ_AUTH, authenticator) + apReq = _apReq(tgt["ticket"], encAuth, tgt["sessionKeyType"]) + + paTgs = der.sequence(der.tagged(1, der.integer(PA_TGS_REQ)), der.tagged(2, der.octetString(apReq))) + tgsReq = der.application(TGS_REQ, der.sequence( + der.tagged(1, der.integer(PVNO)), + der.tagged(2, der.integer(TGS_REQ)), + der.tagged(3, der.sequenceOf([paTgs])), + der.tagged(4, reqBody), + )) + + return _parseRep(_sendReceive(kdcHost, kdcPort, tgsReq), tgt["sessionKey"], USAGE_TGS_REP_ENCPART, nonce, TGS_REP) + +# ---- SPNEGO "Negotiate" token (cached service ticket -> ready-to-send HTTP token) ----------------- +def spnegoFromTicket(service, realm, username): + """Build a fresh SPNEGO token from an already-obtained service ticket (no KDC round-trip). Each + call produces a new AP-REQ authenticator, as replay caches require, so a cached ticket can back + every request of a scan cheaply.""" + + enc = _enctype(service["sessionKeyType"]) + gssChecksum = (GSS_CHECKSUM_TYPE, struct.pack("I", block), hashlib.sha1).digest() + acc = bytearray(u) + for _ in range(iterations - 1): + u = hmac.new(password, u, hashlib.sha1).digest() + acc = bytearray(x ^ y for x, y in zip(acc, bytearray(u))) + out += acc + block += 1 + return bytes(out[:dklen]) + +def _rotate_right(data, nbits): + """Rotate a byte string right by 'nbits' bits, preserving its length.""" + + data = bytearray(data) + if not data: + return data + total = len(data) * 8 + nbits %= total + value = ((_b2i(data) >> nbits) | (_b2i(data) << (total - nbits))) & ((1 << total) - 1) + return _i2b(value, len(data)) + +def nfold(data, nbytes): + """RFC 3961 n-fold: spread 'data' over 'nbytes' bytes via 13-bit rotated copies summed with an + end-around carry (ones-complement addition).""" + + data = bytearray(data) + + def gcd(a, b): + while b: + a, b = b, a % b + return a + + lcm = len(data) * nbytes // gcd(len(data), nbytes) + + buf = bytearray() + rotation = 0 + while len(buf) < lcm: + buf += _rotate_right(data, rotation) + rotation += 13 + + bits = 8 * nbytes + mask = (1 << bits) - 1 + acc = sum(_b2i(buf[off:off + nbytes]) for off in range(0, lcm, nbytes)) + while acc > mask: + acc = (acc & mask) + (acc >> bits) + return bytes(_i2b(acc, nbytes)) + +class AESEnctype(object): + """AES-CTS-HMAC-SHA1-96 simplified-profile enctype (RFC 3962). keysize 16 => etype 17, 32 => 18.""" + + blocksize = 16 + macsize = 12 + + def __init__(self, keysize): + self.keysize = keysize + self.cksumtype = 16 if keysize == 32 else 15 # hmac-sha1-96-aes256 / -aes128 + + def checksum(self, key, usage, data): + """Keyed checksum (RFC 3961 get_mic): HMAC-SHA1-96 under the checksum key DK(key, usage|0x99).""" + + kc = self.dk(key, struct.pack(">IB", usage, 0x99)) + return hmac.new(kc, data, hashlib.sha1).digest()[:self.macsize] + + # --- key schedule ------------------------------------------------------------------------------- + def _dr(self, key, constant): + """RFC 3961 DR: iterate the single-block cipher over the (n-folded) constant to seedsize.""" + + aes = AES(key) + block = nfold(constant, self.blocksize) + out = bytearray() + while len(out) < self.keysize: + block = aes.encryptBlock(block) # single 16-byte block => CBC(iv=0) == ECB + out += bytearray(block) + return bytes(out[:self.keysize]) + + def dk(self, key, constant): + """RFC 3961 DK = random-to-key(DR(...)); random-to-key is the identity for AES.""" + + return self._dr(key, constant) + + def string2key(self, password, salt, iterations=None): + """RFC 3962 string-to-key: DK(PBKDF2-HMAC-SHA1(password, salt), "kerberos").""" + + iterations = iterations or DEFAULT_PBKDF2_ITERATIONS + tkey = _pbkdf2(_to_bytes(password), _to_bytes(salt), iterations, self.keysize) + return self.dk(tkey, b"kerberos") + + # --- CBC ciphertext stealing (RFC 3962, CS3: always swap the final two blocks) ------------------ + def _basicEncrypt(self, key, data): + aes = AES(key) + padded = data + b"\x00" * ((-len(data)) % self.blocksize) + ct = aes.cbcEncrypt(b"\x00" * self.blocksize, padded) + if len(data) > self.blocksize: + lastlen = len(data) % self.blocksize or self.blocksize + ct = ct[:-2 * self.blocksize] + ct[-self.blocksize:] + ct[-2 * self.blocksize:-self.blocksize][:lastlen] + return ct + + def _basicDecrypt(self, key, data): + aes = AES(key) + if len(data) == self.blocksize: + return aes.decryptBlock(data) + + blocks = [bytearray(data[p:p + self.blocksize]) for p in range(0, len(data), self.blocksize)] + lastlen = len(blocks[-1]) + prev = bytearray(self.blocksize) + out = bytearray() + for block in blocks[:-2]: + out += bytearray(_xor(aes.decryptBlock(bytes(block)), prev)) + prev = block + + decrypted = bytearray(aes.decryptBlock(bytes(blocks[-2]))) + lastPlain = _xor(decrypted[:lastlen], blocks[-1]) + omitted = decrypted[lastlen:] + secondLast = _xor(aes.decryptBlock(bytes(blocks[-1] + omitted)), prev) + return bytes(out) + secondLast + lastPlain + + # --- authenticated encryption (RFC 3961 section 5.3) -------------------------------------------- + def _keys(self, key, usage): + ke = self.dk(key, struct.pack(">IB", usage, 0xAA)) + ki = self.dk(key, struct.pack(">IB", usage, 0x55)) + return ke, ki + + def encrypt(self, key, usage, plaintext, confounder=None): + ke, ki = self._keys(key, usage) + if confounder is None: + confounder = os.urandom(self.blocksize) + basic = confounder + plaintext + return self._basicEncrypt(ke, basic) + hmac.new(ki, basic, hashlib.sha1).digest()[:self.macsize] + + def decrypt(self, key, usage, ciphertext): + if len(ciphertext) < self.blocksize + self.macsize: # confounder block + HMAC; guards a hostile short reply + raise ValueError("Kerberos ciphertext too short") + ke, ki = self._keys(key, usage) + ct, mac = ciphertext[:-self.macsize], ciphertext[-self.macsize:] + basic = self._basicDecrypt(ke, ct) + if not _eq(mac, hmac.new(ki, basic, hashlib.sha1).digest()[:self.macsize]): + raise ValueError("Kerberos integrity check failed (wrong key or corrupted ciphertext)") + return basic[self.blocksize:] + +def _rc4(key, data): + """RC4 (ARCFOUR) stream cipher.""" + + key, data = bytearray(key), bytearray(data) + if not key: + raise ValueError("RC4 requires a non-empty key") + s = list(range(256)) + j = 0 + for i in range(256): + j = (j + s[i] + key[i % len(key)]) & 0xff + s[i], s[j] = s[j], s[i] + + out = bytearray(len(data)) + i = j = 0 + for n in range(len(data)): + i = (i + 1) & 0xff + j = (j + s[i]) & 0xff + s[i], s[j] = s[j], s[i] + out[n] = data[n] ^ s[(s[i] + s[j]) & 0xff] + return bytes(out) + +class RC4Enctype(object): + """rc4-hmac (etype 23, RFC 4757). The long-term key is the NT hash MD4(UTF-16LE(password)); the + salt and iteration count are unused. Legacy, but still enabled in many AD environments.""" + + keysize = 16 + cksumtype = -138 # hmac-md5 + + def string2key(self, password, salt=None, iterations=None): + # the password is text; encode it UTF-16LE (in py2 a str is bytes, so decode to text first) + if isinstance(password, bytes): + password = password.decode("utf-8") + return _md4(password.encode("utf-16-le")) + + @staticmethod + def _usage(usage): + # RFC 4757 section 3: a couple of Kerberos usages map to Microsoft-specific values + return struct.pack(" enctype implementation +ENCTYPES = { + 17: AESEnctype(16), + 18: AESEnctype(32), + 23: RC4Enctype(), +} diff --git a/extra/kerberos/der.py b/extra/kerberos/der.py new file mode 100644 index 00000000000..650c78fb799 --- /dev/null +++ b/extra/kerberos/der.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +# Minimal, dependency-free ASN.1 DER codec covering exactly the constructs Kerberos (RFC 4120) uses: +# INTEGER, OCTET STRING, GeneralString, GeneralizedTime, BIT STRING, SEQUENCE / SEQUENCE OF, EXPLICIT +# context tags [n] and [APPLICATION n]. All Kerberos tag numbers are <= 30, so only the low-tag-number +# form is needed. Encoders return bytes; decoders accept bytes/bytearray. Python 2.7 / 3.x. + +# universal tag bytes +INTEGER = 0x02 +BIT_STRING = 0x03 +OCTET_STRING = 0x04 +GENERAL_STRING = 0x1b +GENERALIZED_TIME = 0x18 +SEQUENCE = 0x30 # 0x10 | constructed(0x20) + +def _encodeLength(length): + if length < 0x80: + return bytearray([length]) + out = bytearray() + while length: + out.insert(0, length & 0xff) + length >>= 8 + return bytearray([0x80 | len(out)]) + out + +def _tlv(tag, value): + value = bytearray(value) + return bytes(bytearray([tag]) + _encodeLength(len(value)) + value) + +# ---- context / application tags (EXPLICIT) -------------------------------------------------------- +def contextTag(number): + return 0x80 | 0x20 | number # context-specific, constructed + +def applicationTag(number): + return 0x40 | 0x20 | number # application, constructed + +def tagged(number, innerTLV): + """EXPLICIT [n] wrapper around an already-encoded inner TLV.""" + + return _tlv(contextTag(number), innerTLV) + +def application(number, innerTLV): + """[APPLICATION n] wrapper around an already-encoded inner TLV.""" + + return _tlv(applicationTag(number), innerTLV) + +# ---- primitive encoders --------------------------------------------------------------------------- +def integer(value): + content = bytearray() + if value == 0: + content = bytearray([0]) + elif value > 0: + n = value + while n: + content.insert(0, n & 0xff) + n >>= 8 + if content[0] & 0x80: # keep the sign bit clear for a positive value + content.insert(0, 0x00) + else: + n = value + while True: + content.insert(0, n & 0xff) + n >>= 8 + if n == -1 and (content[0] & 0x80): + break + return _tlv(INTEGER, content) + +def octetString(value): + return _tlv(OCTET_STRING, value) + +def generalString(value): + return _tlv(GENERAL_STRING, value if isinstance(value, bytes) else value.encode("utf-8")) + +def generalizedTime(value): + """'value' is a 'YYYYMMDDHHMMSSZ' UTC string.""" + + return _tlv(GENERALIZED_TIME, value if isinstance(value, bytes) else value.encode("ascii")) + +def bitString(value, unusedBits=0): + return _tlv(BIT_STRING, bytearray([unusedBits]) + bytearray(value)) + +def sequence(*elements): + return _tlv(SEQUENCE, b"".join(bytes(_) for _ in elements)) + +def sequenceOf(elements): + return _tlv(SEQUENCE, b"".join(bytes(_) for _ in elements)) + +# ---- decoding ------------------------------------------------------------------------------------- +def peel(data, offset=0): + """Parse one TLV at 'offset'; return (tag, content_bytearray, next_offset). Raises ValueError on + truncated or indefinite-length input (the data may come from the network, so fail predictably).""" + + data = bytearray(data) + if offset + 2 > len(data): + raise ValueError("truncated DER header") + tag = data[offset] + first = data[offset + 1] + offset += 2 + if first < 0x80: + length = first + elif first == 0x80: + raise ValueError("indefinite-length DER is not permitted") + else: + count = first & 0x7f + if offset + count > len(data): + raise ValueError("truncated DER length") + length = 0 + for _ in range(count): + length = (length << 8) | data[offset] + offset += 1 + if offset + length > len(data): + raise ValueError("truncated DER content") + return tag, data[offset:offset + length], offset + length + +def children(content): + """Iterate the TLVs contained in a constructed value; yields (tag, content_bytearray).""" + + content = bytearray(content) + offset = 0 + out = [] + while offset < len(content): + tag, inner, offset = peel(content, offset) + out.append((tag, inner)) + return out + +def decodeInteger(content): + content = bytearray(content) + if not content: + return 0 + value = 0 + for b in content: + value = (value << 8) | b + if content[0] & 0x80: # negative (two's complement) + value -= 1 << (8 * len(content)) + return value + +def decodeGeneralString(content): + return bytes(bytearray(content)).decode("utf-8", "replace") diff --git a/extra/kerberos/discovery.py b/extra/kerberos/discovery.py new file mode 100644 index 00000000000..debc5b94347 --- /dev/null +++ b/extra/kerberos/discovery.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +# Dependency-free KDC discovery for a realm, so '--auth-type=Negotiate' works without an explicit +# KDC address. Resolution order: the 'SQLMAP_KERBEROS_KDC' environment variable, then the local +# krb5.conf [realms] section, then a DNS SRV lookup (_kerberos._tcp.), then the realm name +# itself as a host. Returns (host, port). Python 2.7 / 3.x. + +import os +import re +import socket +import struct + +DEFAULT_KDC_PORT = 88 +_DNS_TIMEOUT = 3 +_SRV_TYPE = 33 +_IN_CLASS = 1 + +def _splitHostPort(value, defaultPort=DEFAULT_KDC_PORT): + value = value.strip() + if value.startswith("["): # [IPv6] or [IPv6]:port + host, _, rest = value[1:].partition("]") + port = rest[1:] if rest.startswith(":") else "" + elif value.count(":") == 1: # host:port (a single colon rules out bare IPv6) + host, _, port = value.partition(":") + else: # bare host or bare IPv6 literal + host, port = value, "" + return host, (int(port) if port.isdigit() else defaultPort) + +# ---- krb5.conf -------------------------------------------------------------------------------- +def _fromKrb5Conf(realm): + path = os.environ.get("KRB5_CONFIG") or "/etc/krb5.conf" + try: + with open(path) as f: + content = f.read() + except (IOError, OSError): + return None + + header = re.search(r"(?im)^\s*%s\s*=\s*\{" % re.escape(realm), content) + 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] == "{": + depth += 1 + elif content[i] == "}": + depth -= 1 + i += 1 + block = content[start:i - 1] + kdc = re.search(r"(?im)^\s*kdc\s*=\s*(\S+)", block) + return kdc.group(1) if kdc else None + +# ---- DNS SRV (_kerberos._tcp.) --------------------------------------------------------- +def _nameservers(): + servers = [] + try: + with open("/etc/resolv.conf") as f: + for line in f: + parts = line.split() + if len(parts) >= 2 and parts[0] == "nameserver": + servers.append(parts[1]) + except (IOError, OSError): + pass + return servers + +def _encodeName(name): + out = bytearray() + for label in name.split("."): + out.append(len(label)) + out += label.encode("ascii") + out.append(0) + return bytes(out) + +_MAX_NAME_JUMPS = 64 # guards against compression-pointer cycles + +def _skipName(data, offset): + while True: + if offset >= len(data): + raise ValueError("truncated DNS name") + length = data[offset] + if length == 0: + return offset + 1 + if length & 0xc0 == 0xc0: # compression pointer ends the name + if offset + 2 > len(data): + raise ValueError("truncated DNS pointer") + return offset + 2 + offset += 1 + length + +def _readName(data, offset): + labels = [] + end = None + jumps = 0 + while True: + if offset >= len(data): + raise ValueError("truncated DNS name") + length = data[offset] + if length == 0: + offset += 1 + break + if length & 0xc0 == 0xc0: # follow compression pointer (bounded, cycle-safe) + if offset + 2 > len(data): + raise ValueError("truncated DNS pointer") + jumps += 1 + if jumps > _MAX_NAME_JUMPS: + raise ValueError("too many DNS compression jumps") + if end is None: + end = offset + 2 + offset = ((length & 0x3f) << 8) | data[offset + 1] + continue + if offset + 1 + length > len(data): + raise ValueError("truncated DNS label") + labels.append(bytes(data[offset + 1:offset + 1 + length]).decode("ascii", "replace")) + offset += 1 + length + return ".".join(labels), (end if end is not None else offset) + +def parseSrv(response): + """Parse SRV records from a (possibly hostile) DNS response into [(priority, weight, port, + target), ...]. Malformed input yields an empty list rather than raising.""" + + data = bytearray(response) + if len(data) < 12: + return [] + try: + qdcount, ancount = struct.unpack(">HH", bytes(data[4:8])) + offset = 12 + for _ in range(qdcount): + offset = _skipName(data, offset) + 4 # + qtype/qclass + records = [] + for _ in range(ancount): + offset = _skipName(data, offset) + if offset + 10 > len(data): + break + rtype, _cls, _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: + priority, weight, port = struct.unpack(">HHH", bytes(data[offset:offset + 6])) + target = _readName(data, offset + 6)[0].rstrip(".") + if target: + records.append((priority, weight, port, target)) + offset += rdlength + return records + except (ValueError, struct.error, IndexError): + return [] + +def _fromDnsSrv(realm): + queryId = os.urandom(2) + query = (queryId + struct.pack(">HHHHH", 0x0100, 1, 0, 0, 0) + + _encodeName("_kerberos._tcp.%s" % realm) + struct.pack(">HH", _SRV_TYPE, _IN_CLASS)) + for server in _nameservers(): + family = socket.AF_INET6 if ":" in server else socket.AF_INET + sock = socket.socket(family, socket.SOCK_DGRAM) + sock.settimeout(_DNS_TIMEOUT) + try: + sock.connect((server, 53)) # connect() so the kernel drops replies from any other source + sock.send(query) + response = sock.recv(4096) + except socket.error: + continue + finally: + sock.close() + if len(response) < 2 or response[:2] != queryId: # ignore stray / spoofed replies + continue + records = parseSrv(response) + if records: + best = min(records, key=lambda r: (r[0], -r[1])) # lowest priority, then highest weight + return best[3], best[2] + return None + +def discoverKdc(realm): + """Resolve (host, port) of a KDC for the realm; falls back to the realm name itself as a host.""" + + override = os.environ.get("SQLMAP_KERBEROS_KDC") + if override: + return _splitHostPort(override) + + configured = _fromKrb5Conf(realm) + if configured: + return _splitHostPort(configured) + + fromDns = _fromDnsSrv(realm) + if fromDns: + return fromDns + + return realm.lower(), DEFAULT_KDC_PORT diff --git a/extra/kerberos/spnego.py b/extra/kerberos/spnego.py new file mode 100644 index 00000000000..e4285c45a80 --- /dev/null +++ b/extra/kerberos/spnego.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +# Minimal GSS-API / SPNEGO (RFC 2743, RFC 4178) wrapping of a Kerberos AP-REQ into the token carried +# by the HTTP "Authorization: Negotiate " header. Only the initiator's NegTokenInit is built +# (the one-shot token an HTTP client sends); the mechanism-specific OIDs are fixed constants. +# Python 2.7 / 3.x. + +from extra.kerberos import der + +# fully-encoded OBJECT IDENTIFIER TLVs +KRB5_OID = bytes(bytearray([0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x12, 0x01, 0x02, 0x02])) # 1.2.840.113554.1.2.2 +SPNEGO_OID = bytes(bytearray([0x06, 0x06, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x02])) # 1.3.6.1.5.5.2 + +TOK_ID_AP_REQ = b"\x01\x00" # GSS Kerberos token id for KRB_AP_REQ + +def gssApReq(apReq): + """GSS InitialContextToken: [APPLICATION 0] { Kerberos OID, tok-id, AP-REQ }.""" + + return der.application(0, KRB5_OID + TOK_ID_AP_REQ + apReq) + +def negTokenInit(apReq): + """SPNEGO NegTokenInit wrapping the Kerberos GSS token (Kerberos advertised as the sole mech).""" + + inner = der.sequence( + der.tagged(0, der.sequenceOf([KRB5_OID])), # mechTypes + der.tagged(2, der.octetString(gssApReq(apReq))), # mechToken + ) + return der.application(0, SPNEGO_OID + der.tagged(0, inner)) diff --git a/lib/core/enums.py b/lib/core/enums.py index 7973d48b3f8..aa8cc4e6561 100644 --- a/lib/core/enums.py +++ b/lib/core/enums.py @@ -439,6 +439,7 @@ class AUTH_TYPE(object): DIGEST = "digest" BEARER = "bearer" NTLM = "ntlm" + NEGOTIATE = "negotiate" PKI = "pki" class AUTOCOMPLETE_TYPE(object): diff --git a/lib/core/option.py b/lib/core/option.py index 816c698891a..5d9163e0c62 100644 --- a/lib/core/option.py +++ b/lib/core/option.py @@ -1332,8 +1332,11 @@ def _setHTTPHandlers(): # proxy - the keep-alive handler pools the proxy socket for plain HTTP and the CONNECT-tunnelled # socket per origin for HTTPS); '--no-keep-alive' opts out, and they are automatically disabled # when incompatible (authentication methods, or chunked transfer-encoding of the request body - - # handled by a dedicated, non-pooling handler) - conf.keepAlive = not conf.noKeepAlive and not conf.authType and not conf.chunked + # handled by a dedicated, non-pooling handler). Negotiate is the one auth exception: its token is + # a per-request, end-to-end header (minted fresh each request, no connection-bound handshake), so + # persistent connections remain safe and worthwhile. + negotiateAuth = (conf.authType or "").lower() == AUTH_TYPE.NEGOTIATE + conf.keepAlive = not conf.noKeepAlive and not conf.chunked and (not conf.authType or negotiateAuth) if conf.keepAlive: # persistent connections for both HTTP and HTTPS; the keep-alive HTTPS @@ -1449,7 +1452,7 @@ def _setAuthCred(): def _setHTTPAuthentication(): """ - Check and set the HTTP(s) authentication method (Basic, Digest, Bearer, NTLM or PKI), + Check and set the HTTP(s) authentication method (Basic, Digest, Bearer, NTLM, Negotiate or PKI), username and password for first three methods, or PEM private key file for PKI authentication """ @@ -1472,9 +1475,9 @@ def _setHTTPAuthentication(): errMsg += "but did not provide the type (e.g. --auth-type=\"basic\")" raise SqlmapSyntaxException(errMsg) - elif (conf.authType or "").lower() not in (AUTH_TYPE.BASIC, AUTH_TYPE.DIGEST, AUTH_TYPE.BEARER, AUTH_TYPE.NTLM, AUTH_TYPE.PKI): + elif (conf.authType or "").lower() not in (AUTH_TYPE.BASIC, AUTH_TYPE.DIGEST, AUTH_TYPE.BEARER, AUTH_TYPE.NTLM, AUTH_TYPE.NEGOTIATE, AUTH_TYPE.PKI): errMsg = "HTTP authentication type value must be " - errMsg += "Basic, Digest, Bearer, NTLM or PKI" + errMsg += "Basic, Digest, Bearer, NTLM, Negotiate or PKI" raise SqlmapSyntaxException(errMsg) if not conf.authFile: @@ -1490,11 +1493,12 @@ def _setHTTPAuthentication(): elif authType == AUTH_TYPE.BEARER: conf.httpHeaders.append((HTTP_HEADER.AUTHORIZATION, "Bearer %s" % conf.authCred.strip())) return - elif authType == AUTH_TYPE.NTLM: + elif authType in (AUTH_TYPE.NTLM, AUTH_TYPE.NEGOTIATE): # Note: the DOMAIN\username part is colon-free, so the password group takes the full - # remainder (a greedy first group would otherwise swallow colons inside the password) + # remainder (a greedy first group would otherwise swallow colons inside the password). + # For Negotiate, DOMAIN is the Kerberos realm. regExp = "^([^:]*\\\\[^:]*):(.*)$" - errMsg = "HTTP NTLM authentication credentials value must " + errMsg = "HTTP %s authentication credentials value must " % authType errMsg += "be in format 'DOMAIN\\username:password'" elif authType == AUTH_TYPE.PKI: errMsg = "HTTP PKI authentication require " @@ -1522,6 +1526,12 @@ def _setHTTPAuthentication(): elif authType == AUTH_TYPE.NTLM: from lib.request.ntlm import HTTPNtlmAuthHandler authHandler = HTTPNtlmAuthHandler(kb.passwordMgr) + + elif authType == AUTH_TYPE.NEGOTIATE: + from lib.request.kerberos import HTTPNegotiateAuthHandler + # DOMAIN is the Kerberos realm; the KDC is auto-discovered (env / krb5.conf / DNS SRV / realm) + realm, _, user = conf.authUsername.partition('\\') + authHandler = HTTPNegotiateAuthHandler(realm, user, conf.authPassword) else: debugMsg = "setting the HTTP(s) authentication PEM private key" logger.debug(debugMsg) diff --git a/lib/core/settings.py b/lib/core/settings.py index f4abc3823ff..34d2b93d5d2 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.182" +VERSION = "1.10.7.183" TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable" TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34} VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE) diff --git a/lib/request/kerberos.py b/lib/request/kerberos.py new file mode 100644 index 00000000000..bd6e23c2be7 --- /dev/null +++ b/lib/request/kerberos.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +# Native, dependency-free HTTP "Negotiate" (SPNEGO/Kerberos) authentication, built on the in-tree +# pure-Python Kerberos client (extra/kerberos). No 'pykerberos'/'gssapi'/'requests-kerberos' needed. +# The TGT and per-service tickets are obtained once and cached; a fresh AP-REQ is minted for every +# request from the cached ticket (as replay caches require), so an entire scan costs a single AS+TGS +# exchange and the token is sent pre-emptively (no extra 401 round-trip per request). Python 2.7 / 3.x. + +import base64 +import logging +import threading + +from lib.core.common import getSafeExString +from lib.core.common import singleTimeLogMessage +from lib.core.convert import getText +from lib.core.enums import HTTP_HEADER +from thirdparty.six.moves import urllib as _urllib + +from extra.kerberos.client import getServiceTicket +from extra.kerberos.client import getTGT +from extra.kerberos.client import KerberosError +from extra.kerberos.client import spnegoFromTicket +from extra.kerberos.discovery import discoverKdc + +class HTTPNegotiateAuthHandler(_urllib.request.BaseHandler): + handler_order = 480 + + def __init__(self, realm, username, password, kdcHost=None, kdcPort=None): + self.realm = realm.upper() + self.username = username + self.password = password + self.kdcHost = kdcHost # None -> discovered from the realm on first use + self.kdcPort = kdcPort + self._tgt = None + self._tickets = {} # target host -> service-ticket dict + self._tgtFailure = None # realm-wide failure (bad creds / KDC down) + self._hostFailures = {} # per-host failure (e.g. no HTTP/ SPN) + self._lock = threading.Lock() + + def _serviceTicket(self, host): + with self._lock: + if self._tgtFailure is not None: # TGT unobtainable -> nothing in the realm works + raise self._tgtFailure + if host in self._hostFailures: # this host already failed -> don't retry it + raise self._hostFailures[host] + if host not in self._tickets: + if self._tgt is None: + if self.kdcHost is None: # krb5.conf / DNS SRV / realm-name discovery + self.kdcHost, self.kdcPort = discoverKdc(self.realm) + try: + self._tgt = getTGT(self.realm, self.username, self.password, self.kdcHost, self.kdcPort) + except Exception as ex: # cache so the AS exchange runs at most once + self._tgtFailure = ex + raise + try: + self._tickets[host] = getServiceTicket(self._tgt, self.realm, self.username, ["HTTP", host], self.kdcHost, self.kdcPort) + except Exception as ex: # host-specific -> other hosts remain usable + self._hostFailures[host] = ex + raise + return self._tickets[host] + + def _requestHandler(self, req): + host = _urllib.parse.urlsplit(req.get_full_url()).hostname + if host: + try: + token = spnegoFromTicket(self._serviceTicket(host), self.realm, self.username) + req.add_unredirected_header(HTTP_HEADER.AUTHORIZATION, "Negotiate %s" % getText(base64.b64encode(token))) + except KerberosError as ex: + # bad credentials / KDC-refused: log once, fall through unauthenticated (server 401s) + singleTimeLogMessage("Negotiate (Kerberos) authentication failed: %s" % getSafeExString(ex), logging.ERROR) + except Exception as ex: + # unreachable KDC, malformed reply, etc. - never let it crash the run + singleTimeLogMessage("could not obtain a Kerberos ticket (is the KDC '%s:%s' reachable?): %s" % (self.kdcHost, self.kdcPort, getSafeExString(ex)), logging.ERROR) + return req + + def http_request(self, req): + return self._requestHandler(req) + + https_request = http_request diff --git a/tests/test_kerberos.py b/tests/test_kerberos.py new file mode 100644 index 00000000000..bbc31252da9 --- /dev/null +++ b/tests/test_kerberos.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Tests for the dependency-free Kerberos stack under extra/kerberos: the AES core (FIPS-197), the +RFC 3961/3962 etype crypto (n-fold, string-to-key, authenticated encryption) and the ASN.1 DER codec. +All assertions use published FIPS/RFC test vectors, so they validate the crypto and encoding offline +(the AS/TGS protocol and the HTTP Negotiate handler are exercised against a live KDC, not here). +""" + +import binascii +import os +import struct +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from extra.kerberos import client +from extra.kerberos import der +from extra.kerberos import discovery +from extra.kerberos.aes import AES +from extra.kerberos.crypto import ENCTYPES, nfold + + +def _dnsName(name): + out = bytearray() + for label in name.split("."): + out.append(len(label)) + out += label.encode("ascii") + out.append(0) + return bytes(out) + + +def _h(value): + return binascii.unhexlify(value) + + +class TestKerberosAES(unittest.TestCase): + def test_fips197_known_answer(self): + # FIPS-197 Appendix C.1 (AES-128) and C.3 (AES-256) + for key, pt, ct in ( + ("000102030405060708090a0b0c0d0e0f", + "00112233445566778899aabbccddeeff", "69c4e0d86a7b0430d8cdb78070b4c55a"), + ("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "00112233445566778899aabbccddeeff", "8ea2b7ca516745bfeafc49904b496089"), + ): + aes = AES(_h(key)) + self.assertEqual(aes.encryptBlock(_h(pt)), _h(ct)) + self.assertEqual(aes.decryptBlock(_h(ct)), _h(pt)) + + def test_cbc_round_trip(self): + aes = AES(_h("00" * 32)) + iv, data = _h("0f" * 16), os.urandom(64) + self.assertEqual(aes.cbcDecrypt(iv, aes.cbcEncrypt(iv, data)), data) + + +class TestKerberosCrypto(unittest.TestCase): + def test_nfold_rfc3961(self): + # RFC 3961 Appendix A.1 + for text, size, expected in ( + ("012345", 8, "be072631276b1955"), + ("password", 7, "78a07b6caf85fa"), + ("Rough Consensus, and Running Code", 8, "bb6ed30870b7f0e0"), + ("password", 21, "59e4a8ca7c0385c3c37b3f6d2000247cb6e6bd5b3e"), + ("MASSACHVSETTS INSTITVTE OF TECHNOLOGY", 24, + "db3b0d8f0b061e603282b308a50841229ad798fab9540c1b"), + ): + self.assertEqual(binascii.hexlify(nfold(text.encode(), size)).decode(), expected) + + def test_string2key_rfc3962(self): + # RFC 3962 Appendix B (pass 'password', salt 'ATHENA.MIT.EDUraeburn') + for iterations, keysize, expected in ( + (1, 16, "42263c6e89f4fc28b8df68ee09799f15"), + (1, 32, "fe697b52bc0d3ce14432ba036a92e65bbb52280990a2fa27883998d72af30161"), + (1200, 16, "4c01cd46d632d01e6dbe230a01ed642a"), + (1200, 32, "55a6ac740ad17b4846941051e1e8b0a7548d93b0ab30a8bc3ff16280382b8c2a"), + ): + key = ENCTYPES[17 if keysize == 16 else 18].string2key("password", "ATHENA.MIT.EDUraeburn", iterations) + self.assertEqual(binascii.hexlify(key).decode(), expected) + + def test_encrypt_decrypt_round_trip(self): + for etype in (17, 18): + enc = ENCTYPES[etype] + key = os.urandom(enc.keysize) + for length in (0, 1, 15, 16, 17, 31, 32, 100): + plaintext = os.urandom(length) + self.assertEqual(enc.decrypt(key, 1024, enc.encrypt(key, 1024, plaintext)), plaintext) + + def test_integrity_check(self): + enc = ENCTYPES[18] + key = os.urandom(32) + ciphertext = bytearray(enc.encrypt(key, 3, b"secret")) + ciphertext[-1] ^= 1 + self.assertRaises(ValueError, enc.decrypt, key, 3, bytes(ciphertext)) + + def test_decrypt_short_ciphertext(self): + # a hostile/truncated enc-part (< blocksize + macsize) must raise ValueError, not IndexError + enc = ENCTYPES[18] + key = os.urandom(32) + for length in (0, 1, 12, 27): + self.assertRaises(ValueError, enc.decrypt, key, 3, os.urandom(length)) + + def test_string2key_bytes_salt(self): + # the salt is opaque octets (RFC 3961): a bytes salt must derive the same key as the str form + enc = ENCTYPES[18] + self.assertEqual(enc.string2key("password", b"ATHENA.MIT.EDUraeburn", 1200), + enc.string2key("password", "ATHENA.MIT.EDUraeburn", 1200)) + + +class TestKerberosRC4(unittest.TestCase): + def test_nt_hash_string2key(self): + # rc4-hmac long-term key is the NT hash: MD4(UTF-16LE(password)) + self.assertEqual(binascii.hexlify(ENCTYPES[23].string2key("password")).decode(), + "8846f7eaee8fb117ad06bdd830b7586c") + + def test_encrypt_decrypt_round_trip(self): + enc = ENCTYPES[23] + key = enc.string2key("Secret123") + for length in (0, 1, 16, 100): + plaintext = os.urandom(length) + self.assertEqual(enc.decrypt(key, 1024, enc.encrypt(key, 1024, plaintext)), plaintext) + + def test_integrity_check(self): + enc = ENCTYPES[23] + key = enc.string2key("x") + ciphertext = bytearray(enc.encrypt(key, 3, b"secret")) + ciphertext[-1] ^= 1 + self.assertRaises(ValueError, enc.decrypt, key, 3, bytes(ciphertext)) + + +class TestKerberosDER(unittest.TestCase): + def test_integer_canonical(self): + for value, expected in ((0, "020100"), (127, "02017f"), (128, "02020080"), + (256, "02020100"), (-1, "0201ff"), (-129, "0202ff7f")): + self.assertEqual(binascii.hexlify(der.integer(value)).decode(), expected) + self.assertEqual(der.decodeInteger(der.peel(der.integer(value))[1]), value) + + def test_application_tags(self): + self.assertEqual(bytearray(der.application(10, der.sequence()))[0], 0x6a) # AS-REQ + self.assertEqual(bytearray(der.application(14, der.sequence()))[0], 0x6e) # AP-REQ + self.assertEqual(bytearray(der.tagged(0, der.integer(1)))[0], 0xa0) # [0] EXPLICIT + + def test_nested_round_trip(self): + pname = der.sequence( + der.tagged(0, der.integer(1)), + der.tagged(1, der.sequenceOf([der.generalString("HTTP"), der.generalString("web.example.com")])), + ) + _, content, _ = der.peel(pname) + fields = dict(der.children(content)) + components = [der.decodeGeneralString(c) for _, c in der.children(der.peel(fields[0xa1])[1])] + self.assertEqual(der.decodeInteger(der.peel(fields[0xa0])[1]), 1) + self.assertEqual(components, ["HTTP", "web.example.com"]) + + +class TestKerberosClient(unittest.TestCase): + def test_malformed_reply_raises_kerberoserror(self): + # a hostile/truncated KDC reply must surface as KerberosError, never a raw parse exception + key, nonce = os.urandom(32), 0x11223344 + for blob in (b"", b"\x7e\x01", b"\x6b\x02\x30\x00", os.urandom(40)): + self.assertRaises(client.KerberosError, client._parseRep, blob, key, 3, nonce, client.AS_REP) + self.assertRaises(client.KerberosError, client._replyEtype, blob) + + 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({}), []) + + +class TestKerberosDiscovery(unittest.TestCase): + def test_krb5conf(self): + content = ("[realms]\n" + " EXAMPLE.COM = {\n kdc = dc1.example.com:88\n admin_server = dc1.example.com\n }\n" + " OTHER.COM = { kdc = other-dc }\n" + # a nested '{ }' block ahead of 'kdc =' must not truncate the realm section + " NESTED.COM = {\n auth_to_local_names = {\n joe = joe\n }\n kdc = dc.nested.com\n }\n") + handle, path = tempfile.mkstemp() + os.write(handle, content.encode("utf-8")) + os.close(handle) + saved = os.environ.get("KRB5_CONFIG") + os.environ["KRB5_CONFIG"] = path + try: + self.assertEqual(discovery._fromKrb5Conf("EXAMPLE.COM"), "dc1.example.com:88") + self.assertEqual(discovery._fromKrb5Conf("OTHER.COM"), "other-dc") + self.assertEqual(discovery._fromKrb5Conf("NESTED.COM"), "dc.nested.com") + self.assertIsNone(discovery._fromKrb5Conf("MISSING.COM")) + finally: + os.remove(path) + os.environ.pop("KRB5_CONFIG", None) if saved is None else os.environ.__setitem__("KRB5_CONFIG", saved) + + def test_split_host_port(self): + self.assertEqual(discovery._splitHostPort("dc.example.com"), ("dc.example.com", 88)) + self.assertEqual(discovery._splitHostPort("dc.example.com:1088"), ("dc.example.com", 1088)) + self.assertEqual(discovery._splitHostPort("[2001:db8::1]:1088"), ("2001:db8::1", 1088)) + self.assertEqual(discovery._splitHostPort("[2001:db8::1]"), ("2001:db8::1", 88)) + self.assertEqual(discovery._splitHostPort("2001:db8::1"), ("2001:db8::1", 88)) + + def test_srv_parse(self): + header = struct.pack(">HHHHHH", 0x2a2a, 0x8180, 1, 1, 0, 0) + question = _dnsName("_kerberos._tcp.EXAMPLE.COM") + struct.pack(">HH", 33, 1) + rdata = struct.pack(">HHH", 0, 100, 88) + _dnsName("dc.example.com") + answer = b"\xc0\x0c" + struct.pack(">HHIH", 33, 1, 300, len(rdata)) + rdata # name = ptr to question + self.assertEqual(discovery.parseSrv(header + question + answer), [(0, 100, 88, "dc.example.com")]) + + def test_srv_parse_hostile_input(self): + # a compression-pointer cycle (name at offset 12 points to itself) must not hang or crash + cycle = struct.pack(">HHHHHH", 1, 0x8180, 0, 1, 0, 0) + b"\xc0\x0c" + self.assertEqual(discovery.parseSrv(cycle), []) + self.assertEqual(discovery.parseSrv(b""), []) + self.assertEqual(discovery.parseSrv(b"\x00\x00\x81\x80\x00\x00\x00\x05\xff\xff"), []) + + def test_precedence_env_overrides(self): + saved = os.environ.get("SQLMAP_KERBEROS_KDC") + os.environ["SQLMAP_KERBEROS_KDC"] = "10.0.0.1:8888" + try: + self.assertEqual(discovery.discoverKdc("EXAMPLE.COM"), ("10.0.0.1", 8888)) + finally: + os.environ.pop("SQLMAP_KERBEROS_KDC", None) if saved is None else os.environ.__setitem__("SQLMAP_KERBEROS_KDC", saved) + + def test_fallback_to_realm(self): + savedEnv = os.environ.pop("SQLMAP_KERBEROS_KDC", None) + savedCfg = os.environ.get("KRB5_CONFIG") + os.environ["KRB5_CONFIG"] = "/nonexistent/sqlmap-krb5.conf" + savedDns = discovery._fromDnsSrv + discovery._fromDnsSrv = lambda realm: None # avoid real DNS I/O in the test + try: + self.assertEqual(discovery.discoverKdc("CORP.EXAMPLE"), ("corp.example", 88)) + finally: + discovery._fromDnsSrv = savedDns + if savedEnv is not None: + os.environ["SQLMAP_KERBEROS_KDC"] = savedEnv + os.environ.pop("KRB5_CONFIG", None) if savedCfg is None else os.environ.__setitem__("KRB5_CONFIG", savedCfg) + + +if __name__ == "__main__": + unittest.main() From ab881132b87263616496274a9b32bf3abd4667a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Sat, 25 Jul 2026 00:24:52 +0200 Subject: [PATCH 2/3] Hardening Negotiate/Kerberos authentication (#3404) --- extra/kerberos/client.py | 129 ++++++++++++++++++++++++++++-------- extra/kerberos/crypto.py | 13 +++- extra/kerberos/discovery.py | 23 +++++-- lib/request/kerberos.py | 15 +++++ tests/test_kerberos.py | 87 +++++++++++++++++++++++- 5 files changed, 227 insertions(+), 40 deletions(-) diff --git a/extra/kerberos/client.py b/extra/kerberos/client.py index 7616575d11d..f8fe44e7f06 100644 --- a/extra/kerberos/client.py +++ b/extra/kerberos/client.py @@ -10,9 +10,11 @@ # 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 @@ 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': , 'sessionKey': bytes, 'sessionKeyType': int, 'crealm': str}. + {'ticket': , '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("8 and 23->13 apply) + return struct.pack(" 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: diff --git a/lib/request/kerberos.py b/lib/request/kerberos.py index bd6e23c2be7..66cb6112ac2 100644 --- a/lib/request/kerberos.py +++ b/lib/request/kerberos.py @@ -14,6 +14,7 @@ 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 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 @@ def _serviceTicket(self, host): 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 diff --git a/tests/test_kerberos.py b/tests/test_kerberos.py index bbc31252da9..86971695e8f 100644 --- a/tests/test_kerberos.py +++ b/tests/test_kerberos.py @@ -15,6 +15,7 @@ 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 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 @@ def test_malformed_reply_raises_kerberoserror(self): 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): From a4c983ae0685adf461124bcb06f898f86cfa7c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Sat, 25 Jul 2026 00:37:19 +0200 Subject: [PATCH 3/3] Adding native Brotli (br) and Zstandard (zstd) response decoding --- data/txt/brotli-dictionary.tx_ | Bin 0 -> 58679 bytes lib/core/common.py | 1 + lib/core/settings.py | 14 +- lib/request/basic.py | 17 +- lib/request/connect.py | 9 +- lib/utils/brotli.py | 652 +++++++++++++++++++++++++++++++++ tests/test_brotli.py | 69 ++++ 7 files changed, 755 insertions(+), 7 deletions(-) create mode 100644 data/txt/brotli-dictionary.tx_ create mode 100644 lib/utils/brotli.py create mode 100644 tests/test_brotli.py diff --git a/data/txt/brotli-dictionary.tx_ b/data/txt/brotli-dictionary.tx_ new file mode 100644 index 0000000000000000000000000000000000000000..b8354d0bdb5c5cac8e367138ff53378eaf50a345 GIT binary patch literal 58679 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ÐL%yg@L$To_9W%Ck1S@( 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 literal 0 HcmV?d00001 diff --git a/lib/core/common.py b/lib/core/common.py index 9220dcb9d81..ab5edf895df 100644 --- a/lib/core/common.py +++ b/lib/core/common.py @@ -1594,6 +1594,7 @@ def setPaths(rootPath): paths.SMALL_DICT = os.path.join(paths.SQLMAP_TXT_PATH, "smalldict.txt") paths.USER_AGENTS = os.path.join(paths.SQLMAP_TXT_PATH, "user-agents.txt") paths.WORDLIST = os.path.join(paths.SQLMAP_TXT_PATH, "wordlist.tx_") + paths.BROTLI_DICTIONARY = os.path.join(paths.SQLMAP_TXT_PATH, "brotli-dictionary.tx_") paths.ERRORS_XML = os.path.join(paths.SQLMAP_XML_PATH, "errors.xml") paths.BOUNDARIES_XML = os.path.join(paths.SQLMAP_XML_PATH, "boundaries.xml") paths.QUERIES_XML = os.path.join(paths.SQLMAP_XML_PATH, "queries.xml") diff --git a/lib/core/settings.py b/lib/core/settings.py index 34d2b93d5d2..3cde0d5cad0 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.183" +VERSION = "1.10.7.186" TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable" TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34} VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE) @@ -250,8 +250,16 @@ # Default value for HTTP Accept header HTTP_ACCEPT_HEADER_VALUE = "*/*" -# Default value for HTTP Accept-Encoding header -HTTP_ACCEPT_ENCODING_HEADER_VALUE = "gzip,deflate" +# Whether the interpreter can decode Zstandard responses (stdlib 'compression.zstd', Python 3.14+ / PEP 784) +try: + import compression.zstd as _zstdModule +except ImportError: + _zstdModule = None +HTTP_ZSTD_AVAILABLE = _zstdModule is not None + +# Default value for HTTP Accept-Encoding header (browser-realistic; 'br' via the in-tree decoder, 'zstd' +# only when the stdlib provides it - never advertise a content-coding we cannot decode) +HTTP_ACCEPT_ENCODING_HEADER_VALUE = "gzip, deflate, br%s" % (", zstd" if HTTP_ZSTD_AVAILABLE else "") # Default timeout for running commands over backdoor BACKDOOR_RUN_CMD_TIMEOUT = 5 diff --git a/lib/request/basic.py b/lib/request/basic.py index f3541b60c70..8ed3c06ded4 100644 --- a/lib/request/basic.py +++ b/lib/request/basic.py @@ -12,6 +12,13 @@ import re import zlib +from lib.utils import brotli as _brotli + +try: + from compression import zstd as _zstd # Python 3.14+ stdlib (PEP 784); no third-party dependency +except ImportError: + _zstd = None + from lib.core.common import Backend from lib.core.common import extractErrorMessage from lib.core.common import extractRegexResult @@ -297,7 +304,7 @@ def decodePage(page, contentEncoding, contentType, percentDecode=True): contentEncoding = getText(contentEncoding).lower() if contentEncoding else "" contentType = getText(contentType).lower() if contentType else "" - if contentEncoding in ("gzip", "x-gzip", "deflate"): + if contentEncoding in ("gzip", "x-gzip", "deflate", "br", "zstd"): if not kb.pageCompress: return None @@ -313,6 +320,14 @@ def decodePage(page, contentEncoding, contentType, percentDecode=True): page += obj.flush() if len(page) > MAX_CONNECTION_TOTAL_SIZE: raise Exception("size too large") + elif contentEncoding == "br": + page = _brotli.decompress(page, MAX_CONNECTION_TOTAL_SIZE) # in-tree RFC 7932 decoder (bomb-capped) + elif contentEncoding == "zstd": + if _zstd is None: + raise Exception("no Zstandard decoder available") + page = _zstd.decompress(page) + if len(page) > MAX_CONNECTION_TOTAL_SIZE: + raise Exception("size too large") else: data = gzip.GzipFile("", "rb", 9, io.BytesIO(page)) page = data.read(MAX_CONNECTION_TOTAL_SIZE + 1) diff --git a/lib/request/connect.py b/lib/request/connect.py index e3cd05b470a..de374ed407b 100644 --- a/lib/request/connect.py +++ b/lib/request/connect.py @@ -102,6 +102,7 @@ from lib.core.settings import DEFAULT_USER_AGENT from lib.core.settings import EVALCODE_ENCODED_PREFIX from lib.core.settings import HTTP_ACCEPT_ENCODING_HEADER_VALUE +from lib.core.settings import HTTP_ZSTD_AVAILABLE from lib.core.settings import HTTP_ACCEPT_HEADER_VALUE from lib.core.settings import IPS_WAF_CHECK_PAYLOAD from lib.core.settings import IS_WIN @@ -557,9 +558,11 @@ def getPage(**kwargs): for key, value in list(headers.items()): if key.upper() == HTTP_HEADER.ACCEPT_ENCODING.upper(): # keep only content-codings sqlmap can actually decode (see decodePage): a browser-pasted - # 'Accept-Encoding' (e.g. "gzip, deflate, br, zstd") must not make the server return a body - # we cannot read. Anything else (br, zstd, *, ...) is dropped, falling back to "identity". - value = ','.join(_ for _ in re.split(r"\s*,\s*", value) if _.split(';', 1)[0].strip().lower() in ("gzip", "x-gzip", "deflate", "identity")) or "identity" + # 'Accept-Encoding' must not make the server return a body we cannot read. 'br' is always + # decodable (in-tree decoder), 'zstd' only on interpreters that ship it; anything else is + # dropped, falling back to "identity". + decodable = ["gzip", "x-gzip", "deflate", "br", "identity"] + (["zstd"] if HTTP_ZSTD_AVAILABLE else []) + value = ','.join(_ for _ in re.split(r"\s*,\s*", value) if _.split(';', 1)[0].strip().lower() in decodable) or "identity" del headers[key] if isinstance(value, six.string_types): diff --git a/lib/utils/brotli.py b/lib/utils/brotli.py new file mode 100644 index 00000000000..631a97ae1fc --- /dev/null +++ b/lib/utils/brotli.py @@ -0,0 +1,652 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +# Native, dependency-free Brotli (RFC 7932) decompressor, so sqlmap can advertise a browser-realistic +# 'Accept-Encoding: gzip, deflate, br' and read 'Content-Encoding: br' responses (common behind CDNs) +# without pulling in the 'brotli'/'brotlicffi' third-party module. Decode-only: it is used solely to +# inflate server responses (see lib/request/basic.py::decodePage). Validated byte-for-byte against the +# reference encoder across every quality/window/size. The 122 KB static dictionary + context-lookup +# table live ZIP-packed in data/txt/brotli-dictionary.tx_ (same convention as wordlist.tx_). Py 2.7 / 3.x. + +import os +import zipfile + +_DICTIONARY = None # 122784-byte static dictionary (lazy-loaded) +_CONTEXT = None # 2048-byte context-lookup table (4 modes x 2 halves x 256) + +# RFC 7932 Appendix A: words are bucketed by length (4..24); size_bits gives the index width per bucket, +# offsets the cumulative byte offset of each bucket (derived from size_bits; last bucket end == 122784). +_SIZE_BITS = [0, 0, 0, 0, 10, 10, 11, 11, 10, 10, 10, 10, 10, 9, 9, 8, 7, 7, 8, 7, 7, 6, 6, 5, 5] +_OFFSETS = [0] * 25 +for _i in range(24): + _OFFSETS[_i + 1] = _OFFSETS[_i] + ((_i << _SIZE_BITS[_i]) if _SIZE_BITS[_i] else 0) + +# insert-length and copy-length codes (RFC 7932 section 5): (extra bits, base) per code 0..23 +_INS_EXTRA = [0, 0, 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 7, 8, 9, 10, 12, 14, 24] +_INS_BASE = [0, 1, 2, 3, 4, 5, 6, 8, 10, 14, 18, 26, 34, 50, 66, 98, 130, 194, 322, 578, 1090, 2114, 6210, 22594] +_COPY_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 7, 8, 9, 10, 24] +_COPY_BASE = [2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 14, 18, 22, 30, 38, 54, 70, 102, 134, 198, 326, 582, 1094, 2118] +# block-length code (RFC 7932 section 6): (extra bits, base) per code 0..25 +_BLEN_EXTRA = [2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 7, 8, 9, 10, 11, 12, 13, 24] +_BLEN_BASE = [1, 5, 9, 13, 17, 25, 33, 41, 49, 65, 81, 97, 113, 145, 177, 209, 241, 305, 369, 497, 753, 1265, 2289, 4337, 8433, 16625] + +# insert-and-copy command split (RFC 7932 section 5): per command range (code >> 6), the insert/copy +# sub-code base and whether the distance is implicit (codes 0..127 reuse the last distance) +_CMD_RANGE = [(0, 0, True), (0, 8, True), (0, 0, False), (0, 8, False), (8, 0, False), (8, 8, False), + (0, 16, False), (16, 0, False), (8, 16, False), (16, 8, False), (16, 16, False)] + +# code-length-code order and the fixed prefix used to read the 18 code-length code lengths (section 3.5) +_CL_ORDER = [1, 2, 3, 4, 0, 5, 17, 6, 16, 7, 8, 9, 10, 11, 12, 13, 14, 15] +_CLP_LEN = [2, 2, 2, 3, 2, 2, 2, 4, 2, 2, 2, 3, 2, 2, 2, 4] +_CLP_VAL = [0, 4, 3, 2, 0, 4, 3, 1, 0, 4, 3, 2, 0, 4, 3, 5] + +# distance short codes (RFC 7932 section 4): index into the 4-entry distance ring + a signed delta +_DIST_IDX_OFF = [3, 2, 1, 0, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2] +_DIST_VAL_OFF = [0, 0, 0, 0, -1, 1, -2, 2, -3, 3, -1, 1, -2, 2, -3, 3] + +# transform table (RFC 7932 Appendix B): (prefix, transform id, suffix); ids 0=identity, 1..9=omit-last-N, +# 10=uppercase-first, 11=uppercase-all, 12..20=omit-first-N +_TRANSFORMS = [ + (b"", 0, b""), + (b"", 0, b" "), + (b" ", 0, b" "), + (b"", 12, b""), + (b"", 10, b" "), + (b"", 0, b" the "), + (b" ", 0, b""), + (b"s ", 0, b" "), + (b"", 0, b" of "), + (b"", 10, b""), + (b"", 0, b" and "), + (b"", 13, b""), + (b"", 1, b""), + (b", ", 0, b" "), + (b"", 0, b", "), + (b" ", 10, b" "), + (b"", 0, b" in "), + (b"", 0, b" to "), + (b"e ", 0, b" "), + (b"", 0, b"\""), + (b"", 0, b"."), + (b"", 0, b"\">"), + (b"", 0, b"\x0a"), + (b"", 3, b""), + (b"", 0, b"]"), + (b"", 0, b" for "), + (b"", 14, b""), + (b"", 2, b""), + (b"", 0, b" a "), + (b"", 0, b" that "), + (b" ", 10, b""), + (b"", 0, b". "), + (b".", 0, b""), + (b" ", 0, b", "), + (b"", 15, b""), + (b"", 0, b" with "), + (b"", 0, b"'"), + (b"", 0, b" from "), + (b"", 0, b" by "), + (b"", 16, b""), + (b"", 17, b""), + (b" the ", 0, b""), + (b"", 4, b""), + (b"", 0, b". The "), + (b"", 11, b""), + (b"", 0, b" on "), + (b"", 0, b" as "), + (b"", 0, b" is "), + (b"", 7, b""), + (b"", 1, b"ing "), + (b"", 0, b"\x0a\x09"), + (b"", 0, b":"), + (b" ", 0, b". "), + (b"", 0, b"ed "), + (b"", 20, b""), + (b"", 18, b""), + (b"", 6, b""), + (b"", 0, b"("), + (b"", 10, b", "), + (b"", 8, b""), + (b"", 0, b" at "), + (b"", 0, b"ly "), + (b" the ", 0, b" of "), + (b"", 5, b""), + (b"", 9, b""), + (b" ", 10, b", "), + (b"", 10, b"\""), + (b".", 0, b"("), + (b"", 11, b" "), + (b"", 10, b"\">"), + (b"", 0, b"=\""), + (b" ", 0, b"."), + (b".com/", 0, b""), + (b" the ", 0, b" of the "), + (b"", 10, b"'"), + (b"", 0, b". This "), + (b"", 0, b","), + (b".", 0, b" "), + (b"", 10, b"("), + (b"", 10, b"."), + (b"", 0, b" not "), + (b" ", 0, b"=\""), + (b"", 0, b"er "), + (b" ", 11, b" "), + (b"", 0, b"al "), + (b" ", 11, b""), + (b"", 0, b"='"), + (b"", 11, b"\""), + (b"", 10, b". "), + (b" ", 0, b"("), + (b"", 0, b"ful "), + (b" ", 10, b". "), + (b"", 0, b"ive "), + (b"", 0, b"less "), + (b"", 11, b"'"), + (b"", 0, b"est "), + (b" ", 10, b"."), + (b"", 11, b"\">"), + (b" ", 0, b"='"), + (b"", 10, b","), + (b"", 0, b"ize "), + (b"", 11, b"."), + (b"\xc2\xa0", 0, b""), + (b" ", 0, b","), + (b"", 10, b"=\""), + (b"", 11, b"=\""), + (b"", 0, b"ous "), + (b"", 11, b", "), + (b"", 10, b"='"), + (b" ", 10, b","), + (b" ", 11, b"=\""), + (b" ", 11, b", "), + (b"", 11, b","), + (b"", 11, b"("), + (b"", 11, b". "), + (b" ", 11, b"."), + (b"", 11, b"='"), + (b" ", 11, b". "), + (b" ", 10, b"=\""), + (b" ", 11, b"='"), + (b" ", 10, b"='"), +] + + +class BrotliError(Exception): + pass + + +def _loadTables(): + global _DICTIONARY, _CONTEXT + if _DICTIONARY is not None: + return + + path = None + try: + from lib.core.data import paths + path = getattr(paths, "BROTLI_DICTIONARY", None) + except ImportError: + pass + if not path or not os.path.isfile(path): + path = os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, "data", "txt", "brotli-dictionary.tx_") + + archive = zipfile.ZipFile(path) # ZIP-packed like wordlist.tx_ / catalog-identifiers.tx_ + try: + raw = archive.read(archive.namelist()[0]) + finally: + archive.close() + if len(raw) != 122784 + 2048: + raise BrotliError("invalid Brotli dictionary table") + _DICTIONARY = raw[:122784] + _CONTEXT = bytearray(raw[122784:]) + + +class _BitReader(object): + __slots__ = ("data", "size", "pos", "acc", "bits") + + def __init__(self, data): + self.data = bytearray(data) + self.size = len(self.data) + self.pos = 0 + self.acc = 0 + self.bits = 0 + + def _fill(self): + while self.bits <= 24 and self.pos < self.size: + self.acc |= self.data[self.pos] << self.bits + self.pos += 1 + self.bits += 8 + + def readBits(self, count): + if count == 0: + return 0 + if self.bits < count: + self._fill() + value = self.acc & ((1 << count) - 1) + self.acc >>= count + self.bits -= count + return value + + def peek(self, count): + if self.bits < count: + self._fill() + return self.acc & ((1 << count) - 1) + + def drop(self, count): + self.acc >>= count + self.bits -= count + + def alignToByte(self): + drop = self.bits & 7 + if drop: + self.acc >>= drop + self.bits -= drop + + def readBytes(self, count): + out = bytearray() + while count > 0 and self.bits >= 8: + out.append(self.acc & 0xff) + self.acc >>= 8 + self.bits -= 8 + count -= 1 + if count > 0: + out += self.data[self.pos:self.pos + count] + self.pos += count + return bytes(out) + + +def _reverseBits(value, count): + result = 0 + for _ in range(count): + result = (result << 1) | (value & 1) + value >>= 1 + return result + + +class _Huffman(object): + __slots__ = ("maxLength", "table", "single") + + def __init__(self, lengths): + self.single = None + self.table = None + self.maxLength = max(lengths) if lengths else 0 + if self.maxLength == 0: + self.single = 0 + for symbol, length in enumerate(lengths): + if length > 0: + self.single = symbol + return + + counts = [0] * (self.maxLength + 1) + for length in lengths: + if length: + counts[length] += 1 + nextCode = [0] * (self.maxLength + 2) + code = 0 + for bits in range(1, self.maxLength + 1): + code = (code + counts[bits - 1]) << 1 + nextCode[bits] = code + + self.table = [(0, 0)] * (1 << self.maxLength) + for symbol, length in enumerate(lengths): + if length: + reversed_ = _reverseBits(nextCode[length], length) + nextCode[length] += 1 + step = 1 << length + for index in range(reversed_, 1 << self.maxLength, step): + self.table[index] = (symbol, length) + + def decode(self, reader): + if self.table is None: + return self.single + symbol, length = self.table[reader.peek(self.maxLength)] + reader.drop(length) + return symbol + + +def _readSimplePrefix(reader, alphabetSize): + count = reader.readBits(2) + 1 + symbolBits = (alphabetSize - 1).bit_length() or 1 + symbols = [reader.readBits(symbolBits) for _ in range(count)] + lengths = [0] * alphabetSize + if count == 1: + huffman = _Huffman([]) + huffman.single = symbols[0] + return huffman + if count == 2: + pairs = [(symbols[0], 1), (symbols[1], 1)] + elif count == 3: + pairs = [(symbols[0], 1), (symbols[1], 2), (symbols[2], 2)] + elif reader.readBits(1): + pairs = [(symbols[0], 1), (symbols[1], 2), (symbols[2], 3), (symbols[3], 3)] + else: + pairs = [(symbols[0], 2), (symbols[1], 2), (symbols[2], 2), (symbols[3], 2)] + for symbol, length in pairs: + lengths[symbol] = length + return _Huffman(lengths) + + +def _readComplexPrefix(reader, alphabetSize, skip): + codeLengths = [0] * 18 + space = 32 + for symbol in _CL_ORDER[skip:]: + index = reader.peek(4) + codeLengths[symbol] = _CLP_VAL[index] + reader.drop(_CLP_LEN[index]) + if codeLengths[symbol]: + space -= 32 >> codeLengths[symbol] + if space <= 0: + break + codeLengthHuffman = _Huffman(codeLengths) + + lengths = [0] * alphabetSize + symbol = 0 + previous = 8 + repeat = 0 + repeatLength = 0 + space = 32768 + while symbol < alphabetSize and space > 0: + code = codeLengthHuffman.decode(reader) + if code < 16: + lengths[symbol] = code + symbol += 1 + if code: + previous = code + space -= 32768 >> code + repeat = 0 + else: + extra = 2 if code == 16 else 3 + newLength = previous if code == 16 else 0 + if repeatLength != newLength: + repeat = 0 + repeatLength = newLength + old = repeat + delta = reader.readBits(extra) + if repeat > 0: + repeat = (repeat - 2) << extra + repeat += delta + 3 + emit = repeat - old + for _ in range(emit): + if symbol >= alphabetSize: + break + lengths[symbol] = repeatLength + symbol += 1 + if repeatLength: + space -= emit << (15 - repeatLength) + return _Huffman(lengths) + + +def _readPrefix(reader, alphabetSize): + header = reader.readBits(2) + if header == 1: + return _readSimplePrefix(reader, alphabetSize) + return _readComplexPrefix(reader, alphabetSize, header) + + +def _readBlockTypeCount(reader): + if not reader.readBits(1): + return 1 + bits = reader.readBits(3) + return (1 << bits) + 1 + reader.readBits(bits) + + +def _readContextMap(reader, treeCount, size): + maxRun = reader.readBits(4) + 1 if reader.readBits(1) else 0 + huffman = _readPrefix(reader, treeCount + maxRun) + contextMap = [] + while len(contextMap) < size: + code = huffman.decode(reader) + if code == 0: + contextMap.append(0) + elif code <= maxRun: + contextMap.extend([0] * ((1 << code) + reader.readBits(code))) + else: + contextMap.append(code - maxRun) + del contextMap[size:] + if reader.readBits(1): # inverse move-to-front + moveToFront = list(range(256)) + for i in range(len(contextMap)): + index = contextMap[i] + value = moveToFront[index] + contextMap[i] = value + del moveToFront[index] + moveToFront.insert(0, value) + return contextMap + + +def _toUpperCase(word, offset): + char = word[offset] + if char < 0xc0: # ASCII: flip case of a-z + if 97 <= char <= 122: + word[offset] = char ^ 32 + return 1 + if char < 0xe0: # 2-byte UTF-8 + if offset + 1 < len(word): + word[offset + 1] ^= 32 + return 2 + if offset + 2 < len(word): # 3-byte UTF-8 + word[offset + 2] ^= 5 + return 3 + + +def _applyTransform(transformId, word): + prefix, kind, suffix = _TRANSFORMS[transformId] + result = bytearray(word) + if kind == 0: + pass + elif 1 <= kind <= 9: # omit last N + result = result[:len(result) - kind] if len(result) >= kind else bytearray() + elif 12 <= kind <= 20: # omit first N + count = kind - 11 + result = result[count:] if len(result) >= count else bytearray() + elif kind == 10: # uppercase first + if result: + _toUpperCase(result, 0) + elif kind == 11: # uppercase all + offset = 0 + while offset < len(result): + offset += _toUpperCase(result, offset) + return prefix + bytes(result) + suffix + + +def decompress(data, maxOutput=100 * 1024 * 1024): + """Decompress a Brotli (RFC 7932) stream, returning the original bytes. Raises BrotliError on a + malformed stream or if the output would exceed 'maxOutput' (an anti-decompression-bomb cap).""" + + _loadTables() + dictionary = _DICTIONARY + context = _CONTEXT + + try: + reader = _BitReader(data) + header = reader.readBits(1) + if header == 0: + windowBits = 16 + else: + header = reader.readBits(3) + if header: + windowBits = 17 + header + else: + header = reader.readBits(3) + windowBits = (8 + header) if header else 17 + maxBackward = (1 << windowBits) - 16 + + out = bytearray() + distRing = [16, 15, 11, 4] + distIndex = 0 + + while True: + isLast = reader.readBits(1) + if isLast and reader.readBits(1): # ISLASTEMPTY + break + + nibbles = reader.readBits(2) + if nibbles == 3: # metadata block (no output) + if reader.readBits(1): + raise BrotliError("reserved bit set") + skipBytes = reader.readBits(2) + if skipBytes: + skipLength = reader.readBits(skipBytes * 8) + 1 + reader.alignToByte() + reader.readBytes(skipLength) + if isLast: + break + continue + + metaLength = reader.readBits((nibbles + 4) * 4) + 1 + if len(out) + metaLength > maxOutput: # reject an over-large block up front (anti-bomb) + raise BrotliError("output too large") + if not isLast and reader.readBits(1): # ISUNCOMPRESSED + reader.alignToByte() + out += reader.readBytes(metaLength) + if len(out) > maxOutput: + raise BrotliError("output too large") + continue + + typesL = _readBlockTypeCount(reader) + blockL, typeHuffmanL, lengthHuffmanL, prevTypeL = 1 << 28, None, None, 1 + typeL = 0 + if typesL >= 2: + typeHuffmanL = _readPrefix(reader, typesL + 2) + lengthHuffmanL = _readPrefix(reader, 26) + code = lengthHuffmanL.decode(reader) + blockL = _BLEN_BASE[code] + reader.readBits(_BLEN_EXTRA[code]) + + typesI = _readBlockTypeCount(reader) + blockI, typeHuffmanI, lengthHuffmanI, prevTypeI = 1 << 28, None, None, 1 + typeI = 0 + if typesI >= 2: + typeHuffmanI = _readPrefix(reader, typesI + 2) + lengthHuffmanI = _readPrefix(reader, 26) + code = lengthHuffmanI.decode(reader) + blockI = _BLEN_BASE[code] + reader.readBits(_BLEN_EXTRA[code]) + + typesD = _readBlockTypeCount(reader) + blockD, typeHuffmanD, lengthHuffmanD, prevTypeD = 1 << 28, None, None, 1 + typeD = 0 + if typesD >= 2: + typeHuffmanD = _readPrefix(reader, typesD + 2) + lengthHuffmanD = _readPrefix(reader, 26) + code = lengthHuffmanD.decode(reader) + blockD = _BLEN_BASE[code] + reader.readBits(_BLEN_EXTRA[code]) + + postfix = reader.readBits(2) + direct = reader.readBits(4) << postfix + contextModes = [reader.readBits(2) for _ in range(typesL)] + + treesL = _readBlockTypeCount(reader) + contextMapL = _readContextMap(reader, treesL, typesL * 64) if treesL >= 2 else [0] * (typesL * 64) + treesD = _readBlockTypeCount(reader) + contextMapD = _readContextMap(reader, treesD, typesD * 4) if treesD >= 2 else [0] * (typesD * 4) + + huffmanL = [_readPrefix(reader, 256) for _ in range(treesL)] + huffmanI = [_readPrefix(reader, 704) for _ in range(typesI)] + distanceAlphabet = 16 + direct + (48 << postfix) + huffmanD = [_readPrefix(reader, distanceAlphabet) for _ in range(treesD)] + + produced = 0 + while produced < metaLength: + if blockI == 0: + code = typeHuffmanI.decode(reader) + nextType = prevTypeI if code == 0 else ((typeI + 1) % typesI if code == 1 else code - 2) + prevTypeI, typeI = typeI, nextType + code = lengthHuffmanI.decode(reader) + blockI = _BLEN_BASE[code] + reader.readBits(_BLEN_EXTRA[code]) + blockI -= 1 + + command = huffmanI[typeI].decode(reader) + insertBase, copyBase, implicit = _CMD_RANGE[command >> 6] + insertCode = insertBase + ((command >> 3) & 7) + copyCode = copyBase + (command & 7) + insertLength = _INS_BASE[insertCode] + reader.readBits(_INS_EXTRA[insertCode]) + copyLength = _COPY_BASE[copyCode] + reader.readBits(_COPY_EXTRA[copyCode]) + # a well-formed command never inserts beyond the meta-block; bounding here keeps a hostile + # stream from spinning the literal loop far past the output cap before it is caught (the + # copy length is checked in the back-reference branch, and dictionary copies are <= 24) + if produced + insertLength > metaLength: + raise BrotliError("insert exceeds meta-block length") + + for _ in range(insertLength): + if blockL == 0: + code = typeHuffmanL.decode(reader) + nextType = prevTypeL if code == 0 else ((typeL + 1) % typesL if code == 1 else code - 2) + prevTypeL, typeL = typeL, nextType + code = lengthHuffmanL.decode(reader) + blockL = _BLEN_BASE[code] + reader.readBits(_BLEN_EXTRA[code]) + blockL -= 1 + mode = contextModes[typeL] * 512 + p1 = out[-1] if out else 0 + p2 = out[-2] if len(out) >= 2 else 0 + contextId = context[mode + p1] | context[mode + 256 + p2] + out.append(huffmanL[contextMapL[64 * typeL + contextId]].decode(reader)) + produced += 1 + + if produced >= metaLength: + break + + if implicit: + distanceCode = 0 + else: + if blockD == 0: + code = typeHuffmanD.decode(reader) + nextType = prevTypeD if code == 0 else ((typeD + 1) % typesD if code == 1 else code - 2) + prevTypeD, typeD = typeD, nextType + code = lengthHuffmanD.decode(reader) + blockD = _BLEN_BASE[code] + reader.readBits(_BLEN_EXTRA[code]) + blockD -= 1 + distanceContext = min(copyLength - 2, 3) if copyLength >= 2 else 0 + distanceCode = huffmanD[contextMapD[4 * typeD + distanceContext]].decode(reader) + + if distanceCode < 16: + distance = distRing[(distIndex + _DIST_IDX_OFF[distanceCode]) & 3] + _DIST_VAL_OFF[distanceCode] + else: + value = distanceCode - 16 + if value < direct: + distance = value + 1 + else: + value -= direct + extraBits = 1 + (value >> (postfix + 1)) + extra = reader.readBits(extraBits) + high = value >> postfix + low = value & ((1 << postfix) - 1) + distance = ((((2 + (high & 1)) << extraBits) - 4 + extra) << postfix) + low + direct + 1 + + maxDistance = min(len(out), maxBackward) + if distanceCode != 0 and distance <= maxDistance: + distRing[distIndex & 3] = distance + distIndex += 1 + + if distance <= maxDistance: # ordinary back-reference (may overlap) + if produced + copyLength > metaLength: # can't copy past the block (also bounds the loop) + raise BrotliError("copy exceeds meta-block length") + source = len(out) - distance + for i in range(copyLength): + out.append(out[source + i]) + produced += 1 + else: # static-dictionary reference + offset = distance - maxDistance - 1 + if not (4 <= copyLength <= 24) or _SIZE_BITS[copyLength] == 0: + raise BrotliError("invalid dictionary reference") + bits = _SIZE_BITS[copyLength] + index = offset & ((1 << bits) - 1) + transformId = offset >> bits + if transformId >= len(_TRANSFORMS): + raise BrotliError("invalid dictionary transform") + start = _OFFSETS[copyLength] + index * copyLength + word = _applyTransform(transformId, dictionary[start:start + copyLength]) + out += word + produced += len(word) + + if len(out) > maxOutput: + raise BrotliError("output too large") + + if isLast: + break + return bytes(out) + except BrotliError: + raise + except Exception as ex: + raise BrotliError("malformed Brotli stream (%s)" % ex) diff --git a/tests/test_brotli.py b/tests/test_brotli.py new file mode 100644 index 00000000000..42525036661 --- /dev/null +++ b/tests/test_brotli.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Tests for the dependency-free Brotli (RFC 7932) decompressor under lib/utils/brotli.py. The compressed +fixtures were produced by the reference encoder at various quality levels; the expected plaintext is +reconstructed here by construction, so the suite validates the decoder fully offline (no third-party +'brotli' module at test time) on Python 2.7 / 3.x. Cases deliberately exercise the static dictionary + +word transforms, long overlapping copies, UTF-8, the low-quality (near-uniform tree) path and the +repetitive content that relies on the higher insert-and-copy command ranges. +""" + +import binascii +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.utils.brotli import decompress +from lib.utils.brotli import BrotliError + + +# (expected plaintext, reference-compressed stream in hex) +_CASES = [ + (b"", "3b"), + (b"The quick brown fox jumps over the lazy dog.", + "8b158054686520717569636b2062726f776e20666f78206a756d7073206f76657220746865206c617a7920646f672e03"), + (b"the time of the data on the site is now and the code" * 3, + "1b9b000004e164a9be171b85c00636e080bd799109f64571f090852e78334d90cb20ac9c346c190ff171965a3d2f7a90171c"), + (b"the quick brown fox " * 30, + "1b570200047463a92ee78362f22082d628041695d90acef9a3f135e9c701"), + (b"AB" * 400, "1b1f0300a48284a2b230b009"), + ((u"caf\xe9 na\xefve \u4f60\u597d ").encode("utf-8") * 12, + "1bef00004427477ad6d60ac38c93200a288ab462c2a06461d22d186dbbe0263e0707"), + (b"hello hello hello world world foo bar baz " * 6, + "8b7d000080aaaaaaeaff74e5f355048415f8c0000c201701d0ffbbeadf736f75cfa82e6f63b82b5e2c2c2c6c6cacea654675f0e1c38fc160308e33595583c16030180ce65067442a4aa370586827d97b828968074727f5b21e97eebd045d8baeefef94c3fca4fb1e"), +] + + +class TestBrotli(unittest.TestCase): + def test_known_fixtures(self): + for expected, hexstream in _CASES: + self.assertEqual(decompress(binascii.unhexlify(hexstream)), expected) + + def test_empty_stream(self): + self.assertEqual(decompress(binascii.unhexlify("3b")), b"") + + def test_malformed_raises(self): + # a hostile/truncated stream must surface as BrotliError, never a raw exception + for blob in (b"\xff", b"\x00\x00\x00", b"\x1b\xff\xff\xff\xff", os.urandom(32)): + try: + decompress(blob) + except BrotliError: + pass + except Exception as ex: + self.fail("non-BrotliError on malformed input: %s" % ex) + + def test_bomb_cap(self): + # a small stream must not be allowed to expand past the output cap + self.assertRaises(BrotliError, decompress, binascii.unhexlify("1b1f0300a48284a2b230b009"), 16) + + +if __name__ == "__main__": + unittest.main()