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:

  1. copy_from_user(buf, userptr, 16) — reads a 16-byte token from userspace
  2. AES decrypt the stored ciphertext using the token as the key (aes_expandkey + aes_decrypt)
  3. 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{...}
The buffer needs to be at least 80 bytes — the flag is written back starting at offset 0, overwriting the token. 16 bytes is not enough.

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.