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])
};
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.
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:
add H:Hbecomes the list head,blocker_type = &ipblocker_info.dport 0packets: advanceidxto 22 without writing anything,blocker_typeintact.- 4 packets: write
next = selinux_state - 4(word by word intoports[23..26]). del H: withHas the head,ips = H->next = selinux_state - 4.reset (ip=0):reset_head()doesips->triggers = 0, i.e.*(u32*)((selinux_state-4)+4) = *(u32*)selinux_state = 0, soenforcing = 0.clean:ips = NULL, the list is stable again (no bogus head to walk).
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:
- Booby-trapped message -> UAF (part 1) ->
stage1(downloader). stage2b:ContentResolver.insert(content://.../../ipblockerd.conf, [0x01] + pre-XORed stage3), the provider’s write-side path traversal (like part 2 on read).- ~10 s later:
ipblockerdre-reads the.conf,mmapR-X,call->stage3runs as root. stage3: leak KASLR -> OOB write ->enforcing = 0->readofflag3-> TCP exfil to10.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 toselinux_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
ucharidxindexing a 16-slot array without bounds (fake ring buffer) -> OOB read and write towardblocker_type/next. - The write is network-driven (
NF_INET_LOCAL_OUThook); 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(notexit), and never restartipblockerd(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
- Klecko, SELinux bypasses (incl. the
enforcingflag) - vmlinux-to-elf (symbolize a bzImage)
- SELinux concepts (MAC vs DAC, Android)
- Netfilter hooks (NF_INET_LOCAL_OUT)
- corCTF 2025 - corphone (Android LPE + SELinux bypass)
- 8kSec, Android SELinux Internals III (kernel bypass + CVE chains)
- CloudFuzz, Android Kernel Exploitation (char driver, ioctl, LPE)
- pahole / BTF (kernel struct offsets)