mirror of
https://github.com/sqlmapproject/sqlmap.git
synced 2026-10-12 02:22:55 +00:00
JWT_REGEX accepts a signature segment of any length and parseJWT only validates the header/payload, so a value that matches the JWT pattern but carries a malformed/truncated signature (e.g. 41 base64url characters, which is length % 4 == 1 and thus impossible base64) reached crackHMAC. There decodeBase64(signature) let binascii.Error propagate, aborting the whole run with an unhandled exception during checkJWT(). Guard the signature decode: a signature that is not valid base64url cannot be an HMAC we could verify, so crackHMAC now treats it as not crackable (returns None) instead of raising. The other auditJWT findings are unaffected. Added a regression doctest. Co-authored-by: chan770 <[email protected]>
163 lines
6.8 KiB
Python
163 lines
6.8 KiB
Python
#!/usr/bin/env python
|
|
|
|
"""
|
|
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
|
See the file 'LICENSE' for copying permission
|
|
"""
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import re
|
|
|
|
from lib.core.convert import decodeBase64
|
|
from lib.core.convert import encodeBase64
|
|
from lib.core.convert import getBytes
|
|
from lib.core.convert import getText
|
|
from thirdparty import six
|
|
|
|
# a compact JSON Web Token: base64url(header).base64url(payload).base64url(signature); a header always starts
|
|
# with '{"' which base64url-encodes to the literal prefix 'eyJ', so this matches JWTs embedded in a larger value
|
|
JWT_REGEX = r"eyJ[A-Za-z0-9_-]{4,}\.eyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*"
|
|
|
|
# keyed-hash algorithms sqlmap can both verify (crack) and forge offline
|
|
HMAC_ALGORITHMS = {"HS256": hashlib.sha256, "HS384": hashlib.sha384, "HS512": hashlib.sha512}
|
|
|
|
def encodeSegment(value):
|
|
return encodeBase64(json.dumps(value, separators=(',', ':')), binary=False, safe=True)
|
|
|
|
def parseJWT(token):
|
|
"""Split and decode a JWT into its header/payload/signature parts (None if it is not a well-formed JWT).
|
|
|
|
>>> data = parseJWT("eyJhbGciOiJub25lIn0.eyJ1c2VyIjoiYWRtaW4ifQ.")
|
|
>>> data["header"]["alg"] == "none" and data["payload"]["user"] == "admin"
|
|
True
|
|
>>> parseJWT("not.a.jwt") is None
|
|
True
|
|
"""
|
|
|
|
if not token or token.count('.') != 2:
|
|
return None
|
|
|
|
header, payload, signature = token.split('.')
|
|
|
|
try:
|
|
header = json.loads(decodeBase64(header, binary=False))
|
|
payload = json.loads(decodeBase64(payload, binary=False))
|
|
except Exception:
|
|
return None
|
|
|
|
if not isinstance(header, dict) or not isinstance(header.get("alg"), six.string_types):
|
|
return None
|
|
|
|
return {"header": header, "payload": payload, "signature": signature, "signingInput": token.rsplit('.', 1)[0], "raw": token}
|
|
|
|
def findJWTs(value):
|
|
"""Return every well-formed JWT found inside an arbitrary value (e.g. a Cookie/Authorization header)."""
|
|
|
|
return [match.group(0) for match in re.finditer(JWT_REGEX, value or "") if parseJWT(match.group(0))]
|
|
|
|
def forgeJWT(header, payload, key=None):
|
|
"""Re-encode a (possibly tampered) header/payload, signing with 'key' for an HMAC 'alg' or leaving the
|
|
signature empty for 'alg':'none' - the primitive behind the alg:none and weak-secret exploitation paths.
|
|
|
|
>>> forgeJWT({"alg": "none"}, {"user": "admin"}).endswith('.')
|
|
True
|
|
>>> parseJWT(forgeJWT({"alg": "HS256"}, {"user": "admin"}, key="secret"))["payload"]["user"] == "admin"
|
|
True
|
|
"""
|
|
|
|
alg = (header.get("alg") or "none")
|
|
signingInput = "%s.%s" % (encodeSegment(header), encodeSegment(payload))
|
|
|
|
if alg.lower() == "none":
|
|
signature = ""
|
|
elif alg.upper() in HMAC_ALGORITHMS and key is not None:
|
|
digest = hmac.new(getBytes(key), getBytes(signingInput), HMAC_ALGORITHMS[alg.upper()]).digest()
|
|
signature = encodeBase64(digest, binary=False, safe=True)
|
|
else:
|
|
raise ValueError("unsupported algorithm '%s' for forging" % alg)
|
|
|
|
return "%s.%s" % (signingInput, signature)
|
|
|
|
def crackHMAC(token, secrets, limit=None):
|
|
"""Try to recover the HMAC signing secret of an HS* token from an iterable of candidate secrets; returns
|
|
the secret on success (a full forgery primitive), else None. Purely offline - no requests.
|
|
|
|
>>> token = forgeJWT({"alg": "HS256"}, {"user": "admin"}, key="s3cr3t")
|
|
>>> crackHMAC(token, ["admin", "s3cr3t", "letmein"])
|
|
's3cr3t'
|
|
>>> crackHMAC(token, ["admin", "letmein"]) is None
|
|
True
|
|
>>> crackHMAC("eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWRtaW4ifQ.x", ["secret"]) is None
|
|
True
|
|
"""
|
|
|
|
data = parseJWT(token)
|
|
if not data or (data["header"].get("alg") or "").upper() not in HMAC_ALGORITHMS:
|
|
return None
|
|
|
|
fn = HMAC_ALGORITHMS[data["header"]["alg"].upper()]
|
|
signingInput = getBytes(data["signingInput"])
|
|
|
|
try:
|
|
target = decodeBase64(data["signature"], binary=True)
|
|
except Exception:
|
|
# a signature segment that is not valid base64url (e.g. a truncated/malformed token that still
|
|
# matches the JWT pattern) cannot be an HMAC we could verify, so it is simply not crackable
|
|
return None
|
|
|
|
for index, secret in enumerate(secrets):
|
|
if limit is not None and index >= limit:
|
|
break
|
|
secret = secret.strip() if hasattr(secret, "strip") else secret
|
|
if hmac.new(getBytes(secret), signingInput, fn).digest() == target:
|
|
return getText(secret)
|
|
|
|
return None
|
|
|
|
def auditJWT(token, secrets=None, crackLimit=None):
|
|
"""Offline heuristic battery over a single JWT - the 'bad JWT setup' checks that bite in the real world and
|
|
CTFs. Returns findings as (id, severity, summary, detail); online oracle checks (does the server ACCEPT an
|
|
alg:none / bit-flipped / expired forgery) are layered on top by the caller, which owns response comparison.
|
|
|
|
>>> sorted(_[0] for _ in auditJWT("eyJhbGciOiJub25lIn0.eyJ1c2VyIjoiYWRtaW4ifQ."))
|
|
['alg-none', 'no-expiry']
|
|
"""
|
|
|
|
findings = []
|
|
data = parseJWT(token)
|
|
if not data:
|
|
return findings
|
|
|
|
header, payload = data["header"], data["payload"]
|
|
alg = (header.get("alg") or "").strip()
|
|
|
|
# an unsigned token that the app already issued means forged claims need no key at all
|
|
if alg.lower() == "none" or data["signature"] == "":
|
|
findings.append(("alg-none", "critical", "token declares alg '%s' (unsigned)" % (alg or "none"), "claims can be forged with no key"))
|
|
|
|
# a guessable HMAC secret is a full forgery primitive - crack it against the provided dictionary
|
|
if alg.upper() in HMAC_ALGORITHMS and secrets is not None:
|
|
secret = crackHMAC(token, secrets, crackLimit)
|
|
if secret is not None:
|
|
findings.append(("weak-hmac-secret", "critical", "HMAC secret recovered ('%s')" % secret, "arbitrary tokens can be forged and re-signed"))
|
|
|
|
# an asymmetric token may be vulnerable to RS/HS confusion if the public key is retrievable
|
|
if alg.upper().startswith(("RS", "ES", "PS")):
|
|
findings.append(("alg-confusion", "info", "asymmetric algorithm '%s'" % alg, "test RS/HS confusion if the public key is obtainable (JWKS/TLS)"))
|
|
|
|
# header fields that pull in attacker-controllable key material (CVE-2018-0114 class)
|
|
for field in ("jku", "x5u", "jwk", "x5c"):
|
|
if field in header:
|
|
findings.append(("header-key-injection", "high", "header carries '%s'" % field, "attacker-hosted key material may be trusted"))
|
|
|
|
# 'kid' commonly feeds a key lookup (file/DB/command) - a natural injection point
|
|
if "kid" in header:
|
|
findings.append(("kid-injection", "info", "header carries 'kid'", "candidate injection point (SQLi/LFI/path/command via key lookup)"))
|
|
|
|
if isinstance(payload, dict) and "exp" not in payload:
|
|
findings.append(("no-expiry", "high", "no 'exp' claim", "token does not expire"))
|
|
|
|
return findings
|