Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions extra/vulnserver/vulnserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from __future__ import print_function

import base64
import hashlib
import hmac
import json
import os
import random
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = "<html><body>access denied. please sign in.</body></html>"
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 = "<html><body>You have an error in your SQL syntax near '%s'</body></html>" % kid
elif (header.get("alg") or "").lower() == "none":
output = "<html><body>welcome back, %s. secret area.</body></html>" % payload.get("user") # VULN: unsigned accepted
elif _jwt_forge(header, payload, JWT_SECRET) == self.params.get("session"):
output = "<html><body>welcome back, %s. secret area.</body></html>" % 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 = "/"
Expand Down
35 changes: 35 additions & 0 deletions lib/controller/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions lib/controller/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -571,6 +574,11 @@ def start():
hqlScan()
continue

if conf.jwt:
from lib.techniques.jwt.inject import jwtScan
jwtScan()
continue

if conf.nullConnection:
checkNullConnection()

Expand Down
1 change: 1 addition & 0 deletions lib/core/option.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/core/optiondict.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"ssti": "boolean",
"xxe": "boolean",
"hql": "boolean",
"jwt": "boolean",
"oobServer": "string",
"oobToken": "string",
"timeSec": "integer",
Expand Down
13 changes: 12 additions & 1 deletion lib/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from thirdparty import six

# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
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)
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions lib/core/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ def vulnTest(tests=None, label="vuln"):
("-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
("-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
("-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
("-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
("-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)
("-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
("-u \"<url>&query=*\" --flush-session --technique=Q --banner", ("Title: SQLite inline queries", "banner: '3.")),
Expand Down
3 changes: 3 additions & 0 deletions lib/parse/cmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'")

Expand Down
8 changes: 8 additions & 0 deletions lib/techniques/jwt/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Loading