HTB: SEPC Challenge

SEPC - HackTheBox Challenge Writeup

Challenge Information

FieldValue
NameSEPC
CategoryReversing
DifficultyMedium
Authord3vn0mi

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, json
sys.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
Terminal window
# Download the genuine ~9.6 MB archive (bzImage + initramfs + run.sh)
python3 -c "
import sys
sys.path.insert(0, '/app/lib')
from htb_api import HTBClient
c = 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 contents
import gzip, os, struct
data = gzip.decompress(open("initramfs.cpio.gz", "rb").read())
off = 0
while 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) & ~3

Inspecting the extracted init script revealed the design:

Terminal window
# init (extracted from initramfs)
insmod checker.ko
mknod /dev/checker c 137 0
exec /checker

Step 3: Reverse the kernel module

checker.ko was small and unstripped, so its symbol table and section layout were readable directly.

Terminal window
readelf -sW checker.ko # confirm write/read fops symbols
readelf -SW checker.ko # locate .text, .rodata, .bss offsets
objdump -d -j .text -M intel checker.ko # disassemble handlers
objdump -d -j .text -M intel -r checker.ko # with relocations, to resolve .rodata refs

Disassembly 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 .bss slot 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. A cmp rax, 0x21 bounds 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 section
import 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 bytearray
rodata = 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.

Terminal window
strings -n 5 checker | grep -i "flag\|correct"
echo -n 'HTB{REDACTED}' > flag

Submitted to HTB and verified: “Congratulations!”

Tools Used

ToolPurpose
readelfInspect ELF sections and symbols of checker.ko
objdumpDisassemble .text (with relocations) and dump raw .rodata bytes
Python (custom newc parser)Manually unpack initramfs.cpio.gz without a cpio binary
stringsSanity-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 bzImage was 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/read pair on /dev/checker is 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.
  • newc cpio 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}