Skip to content

Commit edbfca0

Browse files
committed
Adding switch --jwt
1 parent b2f98b6 commit edbfca0

12 files changed

Lines changed: 862 additions & 4 deletions

File tree

extra/vulnserver/vulnserver.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from __future__ import print_function
1111

1212
import base64
13+
import hashlib
14+
import hmac
1315
import json
1416
import os
1517
import random
@@ -38,6 +40,30 @@
3840
except (IOError, OSError):
3941
pass
4042

43+
# Self-contained JSON Web Token forge/parse for the '/jwt' endpoint, so '--jwt' has a live target in the
44+
# vuln-test. The signing secret is a common one (crackable from the shipped wordlist), the endpoint accepts
45+
# unsigned 'alg':'none' tokens (VULN) and reflects 'kid' into a SQL error (injectable key lookup).
46+
JWT_SECRET = "secret"
47+
48+
def _jwt_b64url(data):
49+
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
50+
51+
def _jwt_forge(header, payload, secret):
52+
seg = lambda obj: _jwt_b64url(json.dumps(obj, separators=(',', ':')).encode(UNICODE_ENCODING))
53+
signingInput = "%s.%s" % (seg(header), seg(payload))
54+
signature = _jwt_b64url(hmac.new(secret.encode(UNICODE_ENCODING), signingInput.encode(UNICODE_ENCODING), hashlib.sha256).digest())
55+
return "%s.%s" % (signingInput, signature)
56+
57+
def _jwt_parse(token):
58+
try:
59+
header, payload, signature = token.split('.')
60+
pad = lambda value: value + '=' * (-len(value) % 4)
61+
return json.loads(base64.urlsafe_b64decode(pad(header))), json.loads(base64.urlsafe_b64decode(pad(payload))), signature
62+
except Exception:
63+
return None
64+
65+
JWT_TOKEN = _jwt_forge({"alg": "HS256", "typ": "JWT", "kid": "key1"}, {"user": "guest", "role": "user", "exp": 9999999999}, JWT_SECRET)
66+
4167
if PY3:
4268
from http.client import FORBIDDEN
4369
from http.client import INTERNAL_SERVER_ERROR
@@ -1003,6 +1029,28 @@ def do_REQUEST(self):
10031029
self.wfile.write(output.encode(UNICODE_ENCODING, "ignore"))
10041030
return
10051031

1032+
if self.url == "/jwt":
1033+
self.send_response(OK)
1034+
self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING)
1035+
self.send_header("Connection", "close")
1036+
self.end_headers()
1037+
1038+
parsed = _jwt_parse(self.params.get("session", ""))
1039+
output = "<html><body>access denied. please sign in.</body></html>"
1040+
if parsed:
1041+
header, payload, _ = parsed
1042+
kid = header.get("kid")
1043+
if hasattr(kid, "count") and kid.count("'") % 2 == 1: # str/unicode (py2/py3), not an int/dict
1044+
# VULNERABLE: 'kid' feeds a key-lookup query unsanitized -> a lone quote breaks it
1045+
output = "<html><body>You have an error in your SQL syntax near '%s'</body></html>" % kid
1046+
elif (header.get("alg") or "").lower() == "none":
1047+
output = "<html><body>welcome back, %s. secret area.</body></html>" % payload.get("user") # VULN: unsigned accepted
1048+
elif _jwt_forge(header, payload, JWT_SECRET) == self.params.get("session"):
1049+
output = "<html><body>welcome back, %s. secret area.</body></html>" % payload.get("user")
1050+
1051+
self.wfile.write(output.encode(UNICODE_ENCODING, "ignore"))
1052+
return
1053+
10061054
if self.url == "/csrf":
10071055
if self.params.get("csrf_token") == _csrf_token:
10081056
self.url = "/"

lib/controller/checks.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1276,6 +1276,41 @@ def _(page):
12761276

12771277
return kb.heuristicTest
12781278

1279+
def checkJWT():
1280+
"""
1281+
Passive, always-on heuristic: surface any JSON Web Token the request carries (cookie, header or
1282+
parameter) together with its offline weaknesses (alg:none, guessable HMAC secret, unsafe key
1283+
headers, missing expiry), hinting at '--jwt' for active confirmation and injection.
1284+
"""
1285+
1286+
if kb.jwtChecked or conf.jwt:
1287+
return
1288+
1289+
kb.jwtChecked = True
1290+
1291+
from lib.utils.jwt import auditJWT
1292+
from lib.utils.jwt import findJWTs
1293+
from lib.core.settings import JWT_COMMON_SECRETS
1294+
1295+
haystacks = list((conf.parameters or {}).values())
1296+
haystacks += [value for _, value in (conf.httpHeaders or [])]
1297+
1298+
seen = set()
1299+
for haystack in haystacks:
1300+
for token in findJWTs(haystack):
1301+
if token in seen:
1302+
continue
1303+
seen.add(token)
1304+
1305+
infoMsg = "heuristic (JWT) test shows that the request carries a JSON Web Token (rerun with switch '--jwt')"
1306+
logger.info(infoMsg)
1307+
1308+
for _, severity, summary, __ in auditJWT(token, secrets=JWT_COMMON_SECRETS):
1309+
logger.info("JWT weakness (%s): %s" % (severity, summary))
1310+
1311+
if conf.beep:
1312+
beep()
1313+
12791314
def checkDynParam(place, parameter, value):
12801315
"""
12811316
This function checks if the URL parameter is dynamic. If it is

lib/controller/controller.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from lib.controller.checks import checkConnection
1717
from lib.controller.checks import checkDynParam
1818
from lib.controller.checks import checkInternet
19+
from lib.controller.checks import checkJWT
1920
from lib.controller.checks import checkNullConnection
2021
from lib.controller.checks import checkSqlInjection
2122
from lib.controller.checks import checkStability
@@ -529,12 +530,14 @@ def start():
529530

530531
checkWaf()
531532

532-
if conf.mineParams and not any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe, conf.hql)):
533+
checkJWT()
534+
535+
if conf.mineParams and not any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe, conf.hql, conf.jwt)):
533536
from lib.utils.paraminer import mineParameters
534537
mineParameters()
535538

536-
if any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe, conf.hql)) and (conf.reportJson or conf.resultsFile):
537-
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")
539+
if any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe, conf.hql, conf.jwt)) and (conf.reportJson or conf.resultsFile):
540+
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")
538541

539542
if conf.graphql:
540543
from lib.techniques.graphql.inject import graphqlScan
@@ -571,6 +574,11 @@ def start():
571574
hqlScan()
572575
continue
573576

577+
if conf.jwt:
578+
from lib.techniques.jwt.inject import jwtScan
579+
jwtScan()
580+
continue
581+
574582
if conf.nullConnection:
575583
checkNullConnection()
576584

lib/core/option.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2244,6 +2244,7 @@ def _setKnowledgeBaseAttributes(flushAll=True):
22442244
kb.heuristicPage = False
22452245
kb.heuristicTest = None
22462246
kb.hintValue = ""
2247+
kb.jwtChecked = False
22472248
kb.htmlFp = []
22482249
kb.huffmanModel = {}
22492250
kb.respTruncated = False

lib/core/optiondict.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
"ssti": "boolean",
128128
"xxe": "boolean",
129129
"hql": "boolean",
130+
"jwt": "boolean",
130131
"oobServer": "string",
131132
"oobToken": "string",
132133
"timeSec": "integer",

lib/core/settings.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from thirdparty import six
2121

2222
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
23-
VERSION = "1.10.7.188"
23+
VERSION = "1.10.7.189"
2424
TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable"
2525
TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34}
2626
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 @@
12301230

12311231
HQL_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in HQL_ERROR_SIGNATURES)
12321232

1233+
# Small, fast dictionary the always-on JWT heuristic tries against an HS* signature (the full
1234+
# '--jwt' audit streams the shipped wordlist instead); these are the secrets seen over and over in
1235+
# tutorials, framework defaults and CTFs
1236+
JWT_COMMON_SECRETS = ("secret", "password", "changeme", "admin", "test", "jwt", "key", "private",
1237+
"your-256-bit-secret", "your_jwt_secret", "supersecret", "secretkey", "s3cr3t", "1234567890",
1238+
"qwerty", "root", "token", "default", "example", "mysecret", "jwtsecret", "signingkey")
1239+
1240+
# Upper bound on candidate secrets tried during the offline '--jwt' HMAC crack (keeps a huge custom
1241+
# wordlist from turning an audit into an unbounded brute-force)
1242+
JWT_MAX_CRACK_WORDS = 2000000
1243+
12331244
# Regexes that pull the mapped entity/root name out of a Hibernate diagnostic (the
12341245
# ORM equivalent of a leaked table name; HQL has no information_schema so error-based
12351246
# leakage is the native way to learn the entity model). First capture group = name.

lib/core/testing.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ def vulnTest(tests=None, label="vuln"):
9999
("-u \"<base>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
100100
("-u \"<base>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
101101
("-u \"<base>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
102+
("-u \"<base>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
102103
("-u <url> --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)
103104
("-u \"<base>xxe\" --data=\"<root><q>x</q></root>\" --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
104105
("-u \"<url>&query=*\" --flush-session --technique=Q --banner", ("Title: SQLite inline queries", "banner: '3.")),

lib/parse/cmdline.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,9 @@ def cmdLineParser(argv=None):
808808
nonsql.add_argument("--hql", dest="hql", action="store_true",
809809
help="Test for HQL/JPQL (Hibernate ORM) injection")
810810

811+
nonsql.add_argument("--jwt", dest="jwt", action="store_true",
812+
help="Audit JSON Web Tokens (JWT) for weaknesses")
813+
811814
nonsql.add_argument("--oob-server", dest="oobServer",
812815
help="Out-of-band server for blind '--xxe'")
813816

lib/techniques/jwt/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/usr/bin/env python
2+
3+
"""
4+
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
5+
See the file 'LICENSE' for copying permission
6+
"""
7+
8+
pass

0 commit comments

Comments
 (0)