Adding switch --jwt
Some checks are pending
/ build (macos-latest, 3.8) (push) Waiting to run
/ build (ubuntu-latest, pypy-2.7) (push) Waiting to run
/ build (windows-latest, 3.14) (push) Waiting to run

This commit is contained in:
Miroslav Štampar 2026-07-25 22:40:12 +02:00
parent b2f98b61ec
commit edbfca0b8e
12 changed files with 862 additions and 4 deletions

153
lib/utils/jwt.py Normal file
View file

@ -0,0 +1,153 @@
#!/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
# 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 "alg" not in header:
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
"""
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"])
target = decodeBase64(data["signature"], binary=True)
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