SEPC
SEPC
Platform: HackTheBox | Category: Reverse | Difficulty: Medium Date: 2026-02-19 | Status: Solved
Description
We've extracted an embedded operating system running on an intercepted deep-space satellite launched by Arodor. If we can breach the secure enclave and extract their security mechanisms, we can crack their encrypted communications.
The challenge provides a bootable Linux system: bzImage (kernel), initramfs.cpio.gz (filesystem), run.sh (QEMU launch script). The goal is to reverse a kernel module that implements password verification through a character device and extract the flag from the .rodata section.
Solution Approach
The challenge provides a bootable Linux system: bzImage (kernel), initramfs.cpio.gz (filesystem), run.sh (QEMU launch script).
Techniques: xor key recovery, kernel module analysis, rodata extraction, char device protocol reversing, initramfs extraction.
Steps
Analysis
Step 1: Extracting the Filesystem
Unpacking initramfs.cpio.gz reveals the structure:
/init — boot script
/checker — ELF 64-bit statically linked stripped (userspace)
/checker.ko — Linux kernel module (not stripped)
/initramfs.cpio.gz — internal initramfs (empty/incomplete)
The /init script shows the interaction architecture:
insmod checker.ko mount -t proc none /proc mount -t sysfs none /sys mknod /dev/checker c 137 0 chmod 0666 /dev/checker exec /checker
Key point: the kernel module checker.ko is loaded, a character device /dev/checker is created with major number 137, then the userspace binary /checker is launched which communicates with the kernel through this device.
Step 2: Analyzing the Kernel Module (checker.ko)
The module is not stripped — all symbols are available for analysis. It creates a character device with 4 handlers:
| Handler | Behavior |
|---|---|
| open | Single-open guard; initializes counter = 0, clears state |
| release | Resets the open flag |
| write | Copies exactly 1 byte from userspace to BSS buffer |
| read | Main verification logic — byte-by-byte XOR comparison |
The read handler implements byte-by-byte verification:
// Pseudocode of read handler
if (count != 1) return -EINVAL;
byte expected = rodata[0x60 + counter] ^ rodata[0x20 + counter];
if (user_byte != expected) {
result = 0; // FAIL — byte mismatch
} else {
counter++;
if (counter > 33) {
result = 2; // SUCCESS — all 34 bytes matched
counter = 0;
} else {
result = 1; // CONTINUE — need more bytes
}
}
copy_to_user(buf, &result, 1);
Critical comparison in assembly: cmp rax, 0x21 (33) with ja (jump if above) — 34 bytes are verified (indices 0–33).
Step 3: Analyzing the Userspace Binary (checker)
The /checker binary is a statically linked, stripped ELF. Logic:
- Prints
"Please enter your security key for offline verification" - Reads user input
- Opens
/dev/checker - For each input byte:
write(fd, &byte, 1)→read(fd, &result, 1) result == 0→"[X] PASSWORD REJECTED [X]"result == 2→"] PASSWORD VERIFIED ["
Protocol: userspace sends one byte at a time via write, the kernel compares and returns status via read. This is a classic userspace↔kernel communication pattern through a char device.
Step 4: Extracting XOR Tables from .rodata
Two 34-byte tables in the .rodata section of the kernel module:
Table 1 (offset 0x20):
b4 e4 e9 ab 09 36 4a c2 a5 14 e5 35 66 c3 99 14
5a 34 f1 18 91 7d 23 70 fa b5 3d fa 3d e5 00 d1
69 15
Table 2 (offset 0x60):
fc b0 ab d0 6e 44 2b a0 c7 7d 8b 52 39 a7 ad 60
6e 6b 97 6a a1 10 7c 1b c9 c7 53 c9 51 d0 70 e5
0a 26
Solution
Flag Extraction Script
#!/usr/bin/env python3
"""
SEPC (Secure Enclave) — HackTheBox
Extracting the flag by XORing two tables from .rodata of the kernel module checker.ko
"""
t1 = bytes([
0xb4, 0xe4, 0xe9, 0xab, 0x09, 0x36, 0x4a, 0xc2,
0xa5, 0x14, 0xe5, 0x35, 0x66, 0xc3, 0x99, 0x14,
0x5a, 0x34, 0xf1, 0x18, 0x91, 0x7d, 0x23, 0x70,
0xfa, 0xb5, 0x3d, 0xfa, 0x3d, 0xe5, 0x00, 0xd1,
0x69, 0x15
])
t2 = bytes([
0xfc, 0xb0, 0xab, 0xd0, 0x6e, 0x44, 0x2b, 0xa0,
0xc7, 0x7d, 0x8b, 0x52, 0x39, 0xa7, 0xad, 0x60,
0x6e, 0x6b, 0x97, 0x6a, 0xa1, 0x10, 0x7c, 0x1b,
0xc9, 0xc7, 0x53, 0xc9, 0x51, 0xd0, 0x70, 0xe5,
0x0a, 0x26
])
flag_bytes = bytes([a ^ b for a, b in zip(t1, t2)])
flag = flag_bytes.decode('ascii')
print(f"Flag: {flag}}}")
Explanation
The kernel module verifies 34 bytes (the flag content without the closing curly brace }). Each byte is checked as input_byte == table1[i] ^ table2[i]. A simple XOR of the two tables from .rodata yields:
HTB{REDACTED}` is added by the userspace binary context (the 35th character is not verified by the module).
### Alternative Approaches
- **Dynamic analysis** — run the system in QEMU, attach GDB to the kernel (`-s -S`), set a breakpoint on the read handler and observe the XOR operation
- **Module patching** — modify `checker.ko` to output the expected bytes to `dmesg` instead of comparing
- **Bruteforce via QEMU** — send all 256 variants byte by byte, determine the correct byte by the response (0 vs 1). Slow, but doesn't require reversing the module
## Flag
HTB{REDACTED}
## Lessons Learned
1. **Start with the init script** — it shows the entire architecture: which modules are loaded, which devices are created, what runs in userspace
2. **Kernel module > userspace binary** — if the `.ko` is not stripped but userspace is stripped, reverse the module first: symbols and handler structure will give you the complete picture
3. **Char device protocol** — `write` sends data to the kernel, `read` receives the result. Understanding this protocol is key to understanding the verification logic
4. **XOR in .rodata** — if you see two arrays of equal length and an XOR operation between them, it's almost certainly a key/flag. Extract the tables and XOR them offline
5. **Count the bytes** — `cmp rax, 0x21` + `ja` means 34 bytes (0–33), not 33. An off-by-one error can cost you the flag