OSCE3 study materials
OSCE3 study resources

Boolean-oracle SpEL injection behind a substring blacklist

OSCE3 · Put your knowledge to work in context.

All case studies

Boolean-oracle SpEL injection behind a substring blacklist

Contoso's internal metric builder is a Spring Boot 3.2 service at metrics.corp.internal. Analysts define calculated fields as SpEL expressions in JSON; the server evaluates them with SpelExpressionParser against a map of numbers and returns only a boolean - the expression result coerced through Boolean.TRUE.equals(...) - plus HTTP 200. A servlet filter rejects any request body containing Runtime, ProcessBuilder, exec, forName, getClass, Class., T( or ScriptEngine, case-insensitively, before the expression is ever parsed. The container runs as uid 1001, has no outbound network access, and holds the flag in the environment variable REPORTING_SECRET. The /app directory is readable by the service account but not by your web-tier foothold. Your authenticated session holds the ANALYST role and can POST /api/fields/validate. Responses come back in under 80 ms, and a deployment rollback is scheduled in 30 minutes, after which the substring filter is replaced by a strict allowlist.

1. The substring filter appears to close every obvious SpEL gadget. Describe the bypass you would try first, give the exact request-body syntax, and explain why the filter cannot see it.

Read the worked answer

The filter and the parser do not read the same string, and that gap is the whole bug.

  • The servlet filter regexes the raw JSON body before the value is stored or handed to the parser.
  • SpEL's lexer decodes \uXXXX escapes inside string literals during tokenization, which happens after the filter has already run.
  • So "java.lang.Run\u0074ime" is the string java.lang.Runtime to SpEL, while the body contains no Runtime substring for the filter.

First request on the wire (the doubled backslash is deliberate: JSON decoding must leave a single backslash in the SpEL source, otherwise the filter sees the decoded t and matches):

{"expr":"\"\".class.forName(\"java.lang.Run\\u0074ime\")"}

"".class sidesteps the blocked getClass, and forName is itself blocked as a literal but can be written forN\u0061me. From Runtime.getRuntime() you can reach new java.lang.ProcessBuilder(...), or more usefully java.lang.System.getenv('REPORTING_SECRET').

Two caveats worth stating before you invest: this dies the moment the filter becomes an AST walk (the escape is already resolved by then), and any SpEL exception returns 500 rather than false, which you must treat as a distinct oracle value rather than noise. Test the bypass in the first two minutes and only build the extraction pipeline if it holds.

2. The endpoint returns only true/false. Design the extraction of the 41-character secret within your 30-minute window and explain why an RCE detour is the wrong investment here.

Read the worked answer

Turn the boolean into a per-character oracle and never look at the response body again.

Oracle shape. java.nio.file.Files.readString(java.nio.file.Path.of("/app/.env")) returns the whole file as a Go... as a Java String, and Boolean.TRUE.equals() of a String is false, so wrap the predicate explicitly: ...readString(...).contains("REPORTING_SECRET=abc") or ...substring(0,N).equals("prefix"+"c"). Any exception surfaces as 500, which you can also use, but keep the happy path exception-free.

Budget. A 64-symbol charset via binary search on charAt(n) is 6 requests per character; 41 characters plus a length probe is roughly 250 requests. At 80 ms that is ~20 seconds of wall clock, leaving ample room for retries and for the 500s you will hit while calibrating.

Use order. Extract length, then characters left to right, then re-read the secret twice to confirm it is stable.

Why not RCE. The container has no egress, so exec buys you nothing you can see; the only channel back is the same boolean, which you would have to build anyway; and a spawned process is the single most likely thing to trip endpoint monitoring and end the window early. The file read stays entirely inside the JVM's existing permissions and looks like normal expression evaluation.