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:

  • NotificationService exported + 10s poll = 0-click surface. debuggable=true (handy for attaching gdb or using run-as during analysis), extractNativeLibs=false (the .so is mmap’d straight from the APK, absent from the FS), cleartext HTTP.
  • We control every byte of the message: content is 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:

  • erase does sfree(this->val) then sfree(this): that second free writes next at the struct’s offset 0, so set_val points exactly at a buffer we just freed and control. No ASLR to break.
  • the name survives: sfree only writes offset 0, name stays intact, so we can re-trigger the variable by its original name.

set_val, at the struct head, is overwritten by the free-list link on free: offset 0 becomes a pointer we control, aimed at the shellcode, on the RWX heap

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):

  1. {{U=val}} creates U and allocates its buffer B_U.
  2. {{T=val}} creates T and allocates B_T.
  3. {{T=}} erases T: sfree(B_T) then sfree(S_T). The struct S_T stays dangling, S_T.set_val = &B_T, the name "T" intact.
  4. {{U=val}} reassigns U: we reclaim B_T (exact size) and fill it with val = shellcode.
  5. {{T=z}} fires: get_variable("T") finds S_T, var->set_val = &B_T, call, shellcode.

Groom sequence: reassigning U reclaims T’s freed buffer; T’s struct stays dangling and its set_val points at the shellcode we just wrote


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