KernelBackdoor (UNBR Finals 2026) — Android + Hidden Kernel Module
An Android app that looks completely harmless ships a Linux kernel module in its assets/ folder.
I had to notice it, load it manually, reverse the XOR token from native code, and hit a single ioctl to get the flag back.
1. Initial recon
The challenge gives you SystemInfo.apk, a bzImage, and a short README saying it runs on an Android 12 x86_64 emulator. The app UI shows mundane system info — model, battery, storage. Nothing obviously suspicious.
I unzipped the APK and the interesting stuff showed up immediately:
unzip SystemInfo.apk -d app_unpacked
ls app_unpacked/assets/
# → module.ko
ls app_unpacked/lib/x86_64/
# → libnative.so
A kernel module shipped inside an Android app — that's the central observation, and everything else follows from it.
2. Analyzing module.ko
I loaded it in Ghidra and looked for the init function. There's a char device registration for /dev/ctf and a single unlocked_ioctl handler.
The ioctl handler checks for command 0x1337 and does:
copy_from_user(buf, userptr, 16)— reads a 16-byte token from userspace- AES decrypt the stored ciphertext using the token as the key (
aes_expandkey+aes_decrypt) copy_to_user(userptr, plaintext, len)— writes the flag back into the same buffer
So the flow is: send token → get flag. No need to break AES — just recover the right 16-byte key from the native library.
3. Extracting the token from libnative.so
strings didn't give me the token directly. In Ghidra, I found the function that does the ioctl call — it's reachable from the JNI method BackdoorService_triggerBackdoor.
Inside I found two static 8-byte arrays and a single XOR key byte 0x42. The token is assembled like this:
for (int i = 0; i < 8; i++) {
token[i] = part1[i] ^ 0x42;
token[8+i] = part2[i] ^ 0x42;
}
I did the XOR manually on the byte arrays from .rodata and got the 16-byte token:
b9 27 6f 31 ce f4 47 ac 7f f3 1b e9 74 c5 93 f8
4. Loading the module and getting /dev/ctf
The app doesn't actually load the module itself in the final version — you have to do it manually. I extracted the module from the APK and pushed it to the emulator:
adb push module.ko /data/local/tmp/
adb shell "su 0 insmod /data/local/tmp/module.ko"
adb shell ls -l /dev/ctf
# crw-rw-rw- ...
# if permissions are too tight:
adb shell "su 0 chmod 666 /dev/ctf"
5. Writing the exploit
I compiled a small C program for Android x86_64 using the NDK, pushed it, and ran it:
#include <stdio.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <string.h>
#define IOCTL_CMD 0x1337
int main(void) {
unsigned char buf[80] = {
0xb9, 0x27, 0x6f, 0x31, 0xce, 0xf4, 0x47, 0xac,
0x7f, 0xf3, 0x1b, 0xe9, 0x74, 0xc5, 0x93, 0xf8
};
int fd = open("/dev/ctf", O_RDWR);
if (fd < 0) { perror("open"); return 1; }
if (ioctl(fd, IOCTL_CMD, buf) < 0) { perror("ioctl"); return 1; }
printf("%s\n", buf);
close(fd);
return 0;
}
$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android21-clang \
-O2 -o exploit exploit.c
adb push exploit /data/local/tmp/
adb shell chmod 755 /data/local/tmp/exploit
adb shell /data/local/tmp/exploit
Output:
CTF{...}
6. Alternative: Frida approach
The app has anti-Frida checks: it scans /proc/self/maps for Frida-related strings and checks port 27042. If Frida is detected, the backdoor service exits silently — no crash, no error.
Bypass options: rename the Frida server binary, use a modified "undetected" build, or patch the APK smali to remove the checks. Once past that, hooking ioctl at cmd == 0x1337 shows both the token (going in) and the flag (coming out):
const ioctlPtr = Module.findExportByName(null, "ioctl");
Interceptor.attach(ioctlPtr, {
onEnter(args) {
this.cmd = args[1].toInt32();
this.buf = args[2];
},
onLeave(retval) {
if (this.cmd === 0x1337)
console.log(this.buf.readUtf8String());
}
});
Either way, the approach is the same: recover the 16-byte key from native code and pass it to the kernel module through a standard ioctl call — no need to break AES. For me, the static path through Ghidra was faster than dealing with the Frida detection.