CTF Shutlock 2026 · pwn Android

Parts 1 and 2 gave us 0-click native code execution, first under the app’s uid (flag1), then the ability to make a broken provider read a system file (flag2). flag3 climbs one more notch: it belongs to root, and, surprise, root isn’t even enough. The file is also SELinux-protected: under enforcing, even uid 0 takes a denial. So we need kernel code: enough to become root and neutralize the SELinux policy.

Goal: read /data/vendor/flag3.txt (-r-------- root root, SELinux type unreadable even by root) remotely.

TL;DR

A root service, ipblockerd, re-reads a config file every 10 s and executes its content as native code: that’s our root exec point, which we write through the same provider as in part 2 (this time via insert(), same path traversal on the write side). From there, a character driver shipped with the challenge (ipblocker, compiled built-in) carries two OOBs: a read (IOCTL_GET) that reports a kernel pointer -> breaks KASLR, and a write (add_port, driven by network packets) that corrupts a linked list to plant a 0 in selinux_state.enforcing -> SELinux goes permissive -> we read flag3. All tuned in a QEMU debug environment that boots the target’s real kernel.

Reconnaissance

The root exec point

flag2 left us in the snotes_provider domain (uid system); still not root, and SELinux guards flag3 anyway. We look for a root component we can drive. That’s ipblockerd: a /vendor service, launched on boot as user root, seclabel u:r:ipblockerd:s0, capabilities NET_RAW NET_ADMIN, oneshot. Its binary is stripped (pulled off the emulator via adb pull, reversed); its loop re-reads /data/vendor/ipblockerd.conf every ~10 s and, if the first byte is 0x01, runs a “feature” left active:

mmap(0, size, PROT_READ|WRITE, MAP_PRIVATE|ANON)   // page
memcpy(page, conf+1, size)                          // rest of file
mprotect(page, size, PROT_READ|EXEC)                // R-X
call page                                           // call @0
munmap(page)                                         // then loop

Arbitrary root native code execution, without the slightest check on the content. Two constraints follow, which become subtleties (cf. debug): the blob must ret (not exit: the oneshot process would die) and preserve rbx (reused right after for the munmap).

The SELinux decoy

Writing that file isn’t trivial: ipblockerd.conf is 0666 (DAC open), but its SELinux type vendor_data_file is writable only by the snotes_provider domain. The “world-writable” is a decoy: our app takes a MAC denial. So we reuse part 2’s pivot: SNotesProvider has the exact same path traversal on the write side (insert()) as on the read side (query()). Part 2’s JNI capability serves this time to write, through the provider, the /data/vendor/ipblockerd.conf that ipblockerd will execute for us as root.

The driver

Part 3 hands us the vulnerable code directly: driver/ipblocker.{c,h}. One detail that matters: it’s compiled obj-y, i.e. linked into the kernel image (not a .ko). Its globals (ipblocker_info, …) are therefore ordinary vmlinux symbols: leaking one directly yields the KASLR slide, applicable to any other symbol, selinux_state included.


The driver’s two bugs

Each watched IP is a 64-byte object. The layout (confirmed via BTF) is the origin of everything:

struct ipblocked {
    u32 ip;                    // +0
    u32 triggers;              // +4
    struct ports {             // +8
        unsigned char idx;       // +8
        u16 ports[16];           // +10  -> last valid slot @ +40
    };
    struct ipblocker_type *blocker_type;  // +48   (= ports[19..22])
    struct ipblocked      *next;          // +56   (= ports[23..26])
};

struct ipblocked (64 B): idx indexes ports[16] without bounds; right behind, blocker_type (offset 48 = ports[19]) and next (offset 56 = ports[23]) are within reach of the overflowing index

Common root cause: ports[16] is a fake ring buffer. idx (an unsigned char, 0..255) indexes into ports[16] without ever being brought back into [0,15]. And ports[k] in C = “access base + 2*k”, unchecked: an out-of-range idx lands beside the array, and right after ports[] sit two kernel pointers, blocker_type and next.

Bug A, OOB read (IOCTL_GET):

int idx = ip->last_triggered_ports.idx - 1;              // no clamp
for (unsigned char i = 0; i < MAX_PORTS; i++)
    req->res.ports[i] = ip->last_triggered_ports.ports[idx - i];

A fresh link has idx = 255 (reset_ip does idx = -1) -> a read ~500 B past the object. By bringing idx to 23 (via packets, cf. below), idx_local = 22 and res.ports[0..3] = ports[22..19] = blocker_type. blocker_type points at the global ipblocker_info -> leak of a vmlinux symbol, returned to userspace.

OOB read: with idx=23, IOCTL_GET reads ports[22..19] backward, i.e. the 8 bytes of blocker_type, which points at ipblocker_info and yields the KASLR slide

Bug B, OOB write (add_port, netfilter hook):

if (++ports->idx == MAX_PORTS || port == 0)
    return;
ports->ports[ports->idx] = port;

++idx always happens; the guard only tests equality == 16, never a wraparound. As soon as idx >= 17, we write ports[idx] = port out of bounds, the value being the outgoing packet’s destination port (u16, which we control). The driver records this port from an NF_INET_LOCAL_OUT hook: the write is driven by sending packets. Handy detail: port == 0 exits after the increment but before the write -> advance idx without writing anything, to aim exactly at the field we want (and notably to skip blocker_type without corrupting it). Emitting a packet to dport 0 requires a raw socket with IP_HDRINCL (the normal socket API refuses it); ipblockerd has precisely the NET_RAW capability.

Disabling SELinux

The leak (bug A) gives &ipblocker_info at runtime; compared to the same symbol’s static address (via vmlinux-to-elf), it yields the KASLR slide, so selinux_state = &ipblocker_info + 0x483cf0 (fixed offset for this build). And a gift: selinux_state.enforcing is the very first byte of the structure -> it’s enough to write 0 there (without ever touching policy, at offset 56, which would avoid an invalid dereference). The sequence, with bug B:

  1. add H: H becomes the list head, blocker_type = &ipblocker_info.
  2. dport 0 packets: advance idx to 22 without writing anything, blocker_type intact.
  3. 4 packets: write next = selinux_state - 4 (word by word into ports[23..26]).
  4. del H: with H as the head, ips = H->next = selinux_state - 4.
  5. reset (ip=0): reset_head() does ips->triggers = 0, i.e. *(u32*)((selinux_state-4)+4) = *(u32*)selinux_state = 0, so enforcing = 0.
  6. clean: ips = NULL, the list is stable again (no bogus head to walk).

List corruption: we write next = selinux_state - 4, then reset writes triggers (offset +4) right onto enforcing (offset +0) of selinux_state, planting a 0 that flips SELinux to permissive

SELinux permissive -> every denial becomes logged but allowed -> the root ipblockerd finally reads /data/vendor/flag3.txt.

Debug environment

Debugging an LPE inside the AVD is a nightmare (no simple gdb stub, a panic reboots the emulator, slow iteration). So we set up a local debug environment that boots the real kernel-ranchu (Linux 6.12 android16 GKI, x86_64, KASLR on) under QEMU with a minimal busybox initramfs: spec-identical by construction (same offsets, same slab, same hardening), gdb + KVM + instant reboot. No KASAN: its redzones around slab objects would shift what the OOB touches.

Everything comes from the target: the kernel via docker cp csms-target:/emu64x/kernel-ranchu; the config and BTF via adb pull /proc/config.gz and /sys/kernel/btf/vmlinux; the addresses via /proc/kallsyms (kptr_restrict=0 on this userdebug build): that’s where we read the delta selinux_state - ipblocker_info = 0x483cf0 (a KASLR invariant) and confirm enforcing @ 0. vmlinux-to-elf rebuilds a symbolized vmlinux.elf for gdb.

A harness plays the role of ipblockerd: it exposes the primitives (open /dev/ipblocker, the ioctls, and sending forged packets) to compose the exploit by hand in the debug environment, before porting it to stage3. The hardening config dictates the strategy: STATIC_USERMODEHELPER_PATH="" (with STATIC_USERMODEHELPER=y) kills the classic privesc via modprobe_path -> the goal must be “SELinux off”, not “root” (we already have root via ipblockerd).

Two dev subtleties

The blob must end with return, never exit(): ipblockerd expects control back to munmap and loop again; an exit() kills the oneshot process outright and the service vanishes from ps after a single run. Second subtlety: restarting ipblockerd between two tests breaks everything, before its loop it rewrites the .conf’s first byte with a disabled value (anti-residual-file defense). The right method, which is also the real chain’s: write the file while the service is running, without ever restarting it.


Delivery and porting

stage3 reuses the logic validated in the debug environment, but freestanding (raw syscalls, like stage2), with the leak computed at runtime (real KASLR), the structs and packets built by hand, then open/read of the flag and TCP exfil:

clang --target=x86_64-linux-gnu -ffreestanding -nostdlib -fPIC -Os -c stage3.c
ld -T flat.ld stage3.o               # _start @0, RET + preserve rbx
objcopy -O binary a.out stage3.bin

The full 0-click chain, a single HTTP message:

  1. Booby-trapped message -> UAF (part 1) -> stage1 (downloader).
  2. stage2b: ContentResolver.insert(content://.../../ipblockerd.conf, [0x01] + pre-XORed stage3), the provider’s write-side path traversal (like part 2 on read).
  3. ~10 s later: ipblockerd re-reads the .conf, mmap R-X, call -> stage3 runs as root.
  4. stage3: leak KASLR -> OOB write -> enforcing = 0 -> read of flag3 -> TCP exfil to 10.0.2.2:4444.

Flag & wrap-up

[stage2b status]: OK:inserted
[+] FLAG3 = SHLK{...}

SHLK{...} exfiltrated 0-click.

  • Root isn’t enough: under SELinux enforcing, even uid 0 takes a denial. The kernel goal is therefore not privesc (we already have root via ipblockerd) but disabling SELinux, a single one-byte write to selinux_state.enforcing.
  • A built-in driver (obj-y) makes the leak devastating: its globals are vmlinux symbols, so one leak = the whole KASLR slide.
  • Both bugs’ cause fits in one line: a uchar idx indexing a 16-slot array without bounds (fake ring buffer) -> OOB read and write toward blocker_type/next.
  • The write is network-driven (NF_INET_LOCAL_OUT hook); the dport 0 (raw socket, NET_RAW) = advancing the index without writing, to aim at the exact word.
  • Debugging an LPE = booting the real kernel under QEMU (spec-identical), not the AVD.
  • Two subtleties: return (not exit), and never restart ipblockerd (it rewrites the .conf), write while it’s running.

Three pivots chained from a single HTTP message, zero-click: the template engine’s UAF (initial exec), the vendor ContentProvider’s path traversal (writing a privileged file), then the driver’s pair of OOBs (privesc + SELinux neutralization). The “3, 2, 1… 0 click” series is complete.

References