HTB: SEPC Challenge
SEPC - HackTheBox Challenge Writeup
Challenge Information
| Field | Value |
|---|---|
| Name | SEPC |
| Category | Reversing |
| Difficulty | Medium |
| Author | d3vn0mi |
Description
An intercepted deep-space satellite from Arodor is running an embedded operating system. The goal is to breach its “secure enclave” — a piece of firmware responsible for validating a security key — and recover the key that unlocks Arodor’s encrypted communications.
Solution Overview
The challenge ships a minimal embedded Linux system built to run under QEMU: a kernel image, an initramfs, and a launcher script. Inside the initramfs, an init script loads a custom kernel module (checker.ko) that creates a character device, /dev/checker, and hands control to a small userland binary (checker) that talks to it.
The kernel module is the “secure enclave.” Reverse engineering its write and read handlers shows it implements a byte-at-a-time verification oracle: each byte written to the device is compared against a value derived from two tables baked into the module’s .rodata section (an XOR of a “ciphertext” table against a “key” table). Because both tables are static and fully present in the compiled module, the entire check can be replicated offline in Python without ever booting the emulator — recovering the flag directly from the binary.
Key Steps
Step 1: Get the real challenge artifact
The extracted challenge directory only contained a 0-byte bzImage, so the actual challenge files had to be pulled fresh via the HTB challenge API.
# Match the local challenge slug against the HTB challenge catalog# to find the correct challenge ID and confirm it's downloadable.import sys, jsonsys.path.insert(0, "lib")from htb_api import HTBClient
c = HTBClient()challenges = c.list_challenges()match = [ch for ch in challenges if "sepc" in ch["name"].lower()]print(json.dumps(match, indent=2))# -> SEPC, id 528, author clubby789, download: true# Download the genuine ~9.6 MB archive (bzImage + initramfs + run.sh)python3 -c "import syssys.path.insert(0, '/app/lib')from htb_api import HTBClientc = HTBClient()name, blob = c.download_challenge(528)open(name, 'wb').write(blob)"Step 2: Unpack the embedded system
run.sh was a plain QEMU launcher (qemu-system-x86_64 -kernel bzImage -initrd initramfs.cpio.gz ...). With no cpio binary available in the working container, the newc cpio archive was parsed manually.
# Minimal newc cpio parser — enough to walk headers and dump file contentsimport gzip, os, struct
data = gzip.decompress(open("initramfs.cpio.gz", "rb").read())off = 0while True: magic = data[off:off+6] if magic != b"070701": break hdr = data[off:off+110] namesize = int(hdr[94:102], 16) filesize = int(hdr[54:62], 16) name_off = off + 110 name = data[name_off:name_off+namesize-1].decode() file_off = name_off + namesize file_off = (file_off + 3) & ~3 # 4-byte alignment content = data[file_off:file_off+filesize] if name == "TRAILER!!!": break if filesize: os.makedirs(os.path.dirname(name) or ".", exist_ok=True) open(name, "wb").write(content) off = file_off + filesize off = (off + 3) & ~3Inspecting the extracted init script revealed the design:
# init (extracted from initramfs)insmod checker.komknod /dev/checker c 137 0exec /checkerStep 3: Reverse the kernel module
checker.ko was small and unstripped, so its symbol table and section layout were readable directly.
readelf -sW checker.ko # confirm write/read fops symbolsreadelf -SW checker.ko # locate .text, .rodata, .bss offsetsobjdump -d -j .text -M intel checker.ko # disassemble handlersobjdump -d -j .text -M intel -r checker.ko # with relocations, to resolve .rodata refsDisassembly of the character device’s file operations resolved two handlers:
checker_write(.text+0x90) — accepts exactly 1 byte per call via_copy_from_user, storing it into a.bssslot indexed by a running counter.checker_read(.text+0xe0) — computes.rodata[0x60 + i] ^ .rodata[0x20 + i], compares the result against the stored byte, then_copy_to_users a status code:0= wrong byte,1= correct so far (counter increments),2= sequence complete. Acmp rax, 0x21bounds the counter, capping the key at 34 bytes.
This is a byte-at-a-time XOR oracle: the intended path is to drive /dev/checker one byte at a time under QEMU until each byte returns status 1.
Step 4: Skip the emulator — recover the key statically
Since both the “ciphertext” table (.rodata+0x60) and “key” table (.rodata+0x20) are static, compiled-in data, there was no need to actually boot QEMU and brute-force the device byte by byte.
# Recover the flag directly from checker.ko's .rodata sectionimport subprocess, re
out = subprocess.run( ["objdump", "-s", "-j", ".rodata", "checker.ko"], capture_output=True, text=True).stdout
# Parse hex bytes out of the objdump -s dump into a flat bytearrayrodata = bytearray()for line in out.splitlines(): m = re.match(r"\s*[0-9a-f]+\s+((?:[0-9a-f]{2,8}\s+){1,4})", line) if m: hexpart = m.group(1).replace(" ", "") rodata += bytes.fromhex(hexpart)
table_a = rodata[0x60:0x60+34] # "ciphertext"table_b = rodata[0x20:0x20+34] # "key"
flag = bytes(a ^ b for a, b in zip(table_a, table_b))print(flag.decode())This produced a 34-byte ASCII string in HTB{REDACTED} form — exactly the byte count the module’s bound (cmp rax, 0x21) verifies.
Step 5: Confirm and submit
The recovered value was double-checked against the userland checker binary’s expected format before submission.
strings -n 5 checker | grep -i "flag\|correct"echo -n 'HTB{REDACTED}' > flagSubmitted to HTB and verified: “Congratulations!”
Tools Used
| Tool | Purpose |
|---|---|
readelf | Inspect ELF sections and symbols of checker.ko |
objdump | Disassemble .text (with relocations) and dump raw .rodata bytes |
Python (custom newc parser) | Manually unpack initramfs.cpio.gz without a cpio binary |
strings | Sanity-check the userland checker binary for hints |
HTB Challenge API (htb_api.HTBClient) | Fetch the genuine challenge archive when the local copy was corrupted |
Key Learnings
- Verify artifacts before reversing them. The initially available
bzImagewas a 0-byte stub; matching the challenge slug against the HTB API and re-downloading the real archive was a necessary first step, not an assumption to skip. - Character-device kernel modules make convenient CTF oracles. A
write/readpair on/dev/checkeris a clean way to gate a flag behind a stateful, byte-at-a-time check enforced in kernel space. - Static data defeats “intended” dynamic paths. The challenge was designed to be solved by driving the oracle live under QEMU, but because the comparison tables were compiled directly into
.rodata, the entire XOR check could be replicated offline — no emulator boot required. newccpio format is simple enough to hand-parse when standard tooling (cpio) isn’t available in the working environment — useful fallback knowledge for embedded/firmware challenges.
Flag
HTB{REDACTED}