Writeup: 802.11 is jnouned - EspilonCTF (ESP)
Challenge Description
The CERT-CORP intercepted a strange firmware from an unknown router model built by the shady Jnouned Company. Analysts found it targets an ESP32-based prototype, but the system is protected by a locked UART console. Your mission: flash the firmware, break into the system, and unlock admin access.
Category: Reverse Engineering Flag: ESPILON{802_11_tx_jnned}
Solution
Step 1: Firmware Reconnaissance
The challenge provides three files for an ESP32 (Xtensa LX6) device running ESP-IDF v5.3.2:
| File | Size | Role |
|---|---|---|
bootloader.bin | 26,752 B | 2nd stage bootloader |
jnouned_router.bin | 873,056 B | Main application firmware |
partition-table (1).bin | 3,072 B | NVS + PHY + factory layout |
String analysis reveals WiFi-related functionality:
WIFI CORE
Init WiFi Access Point
Decoded WiFi config (hidden in firmware)
802_11_tx
start_session
Exfiltration session started. Protocol: ...
JNOUNER_SECRET_EXFILTRATION
The firmware contains a WiFi access point, an exfiltration session system, and encrypted flags stored in NVS under the namespace CTF_FLAGS.
Step 2: Parse ESP32 Image Segments
The firmware header contains 5 memory segments:
import struct
with open("jnouned_router.bin", "rb") as f:
data = f.read()
offset = 24 # skip image header
for i in range(5):
vaddr, seg_len = struct.unpack_from('<II', data, offset)
print(f"Segment {i}: vaddr=0x{vaddr:08x} len=0x{seg_len:x}")
offset += 8 + seg_len
| Segment | Virtual Address | Size | Contains |
|---|---|---|---|
| DROM | 0x3f400020 | 124 KB | Strings, encrypted flag data, constants |
| DRAM | 0x3ffb0000 | 6 KB | Initialized variables |
| IROM | 0x400d0020 | 617 KB | Application code + literal pools |
| DRAM2 | 0x3ffb1930 | 10 KB | More initialized data |
| IRAM | 0x40080000 | 98 KB | Interrupt handlers, fast code |
Address conversion formulas:
- DROM:
file_offset = vaddr - 0x3f400020 + 0x20 - IROM:
file_offset = vaddr - 0x400d0020 + 0x20020
Step 3: Locate the Flag Encryption Infrastructure
By searching for 4-byte address references to known strings (CTF_FLAGS, FLAG %d: %s), I mapped the IROM literal pool around 0x400d0e70-0x400d0ee4:
0x400d0e94: -> 0x3f412e54 (encrypted block 0, 4 bytes)
0x400d0e98: -> 0x3f412e50 (encrypted block 1, 4 bytes)
0x400d0e9c: -> 0x3f412e4c (encrypted block 2, 4 bytes)
0x400d0ea0: -> 0x3f412e48 (encrypted block 3, 4 bytes)
0x400d0ea4: -> 0x3f412e44 (encrypted block 4, 4 bytes)
0x400d0ea8: -> 0x3f412e40 (encrypted block 5, 4 bytes)
0x400d0eac: -> 0x3f412e3c (encrypted block 6, 4 bytes)
0x400d0eb0: -> 0x3f412e38 (encrypted block 7, 4 bytes)
0x400d0eb4: -> "CTF_FLAGS"
0x400d0ec0: -> 0x3f412e28 (flag ciphertext/IV)
0x400d0ec4: -> 0x3f412e08 (flag ciphertext/IV)
...
0x400d0edc: -> 0x3f412d78 (flag ciphertext/IV)
0x400d0ee0: -> "FLAG %d: %s"
0x400d0ee4: -> "Failed to decrypt flag %d"
This reveals a two-layer encryption scheme:
- Layer 1 (XOR): 8 small blocks derive a 32-byte AES key
- Layer 2 (AES-256-CBC): Large blocks contain IV+ciphertext pairs for each flag
Step 4: Reverse the XOR Key Derivation Function
The function at 0x400de16c processes 8 blocks of 4 bytes each. Disassembling with Capstone:
from capstone import *
md = Cs(CS_ARCH_XTENSA, CS_MODE_LITTLE_ENDIAN)
md.detail = True
md.skipdata = True
Each block follows the same pattern:
; Block 0
l32r a9, [0x3f412e54] ; load encrypted data pointer
add.n a9, a9, a8 ; index into block
l8ui a9, a9, 0 ; load byte
movi.n a10, 0x38 ; XOR key for this block
xor a9, a9, a10 ; decrypt
s8i a9, a2, 0 ; store to output
addi.n a8, a8, 1 ; next byte
addi.n a2, a2, 1 ; advance output pointer
bltui a8, 4, loop ; repeat for 4 bytes
Extracted XOR keys for all 8 blocks:
| Block | DROM Address | XOR Key | Mnemonic |
|---|---|---|---|
| 0 | 0x3f412e54 | 0x38 | movi.n a10, 0x38 |
| 1 | 0x3f412e50 | 0xe7 | movi.n a10, -0x19 |
| 2 | 0x3f412e4c | 0x91 | movi a10, -0x6f |
| 3 | 0x3f412e48 | 0xf7 | movi.n a10, -9 |
| 4 | 0x3f412e44 | 0x10 | movi.n a10, 0x10 |
| 5 | 0x3f412e40 | 0x6b | movi a10, 0x6b |
| 6 | 0x3f412e3c | 0x74 | movi a10, 0x74 |
| 7 | 0x3f412e38 | 0x96 | movi a10, -0x6a |
Note: Xtensa movi.n uses signed immediates, so -0x19 = 0xe7, -0x6f = 0x91, etc.
Decrypting yields a 32-byte AES-256 key:
small_blocks = [0x3f412e54, 0x3f412e50, 0x3f412e4c, 0x3f412e48,
0x3f412e44, 0x3f412e40, 0x3f412e3c, 0x3f412e38]
xor_keys = [0x38, 0xe7, 0x91, 0xf7, 0x10, 0x6b, 0x74, 0x96]
aes_key = bytearray()
for addr, k in zip(small_blocks, xor_keys):
foff = addr - 0x3f400020 + 0x20
block = data[foff:foff+4]
aes_key.extend(bytes([b ^ k for b in block]))
# aes_key = 79d8ba7a61728ced3f45ea9d7c534c3fb364bdef2f16995b051f36c985a7577a
Step 5: AES-256-CBC Flag Decryption
The 8 large block pointers form 4 pairs of (16-byte IV, 32-byte ciphertext), one per flag:
| Flag | IV Address | Ciphertext Address |
|---|---|---|
| 0 | 0x3f412e28 (16 B) | 0x3f412e08 (32 B) |
| 1 | 0x3f412df8 (16 B) | 0x3f412dd8 (32 B) |
| 2 | 0x3f412dc8 (16 B) | 0x3f412da8 (32 B) |
| 3 | 0x3f412d98 (16 B) | 0x3f412d78 (32 B) |
from Crypto.Cipher import AES
flag_pairs = [
(0x3f412e28, 0x3f412e08),
(0x3f412df8, 0x3f412dd8),
(0x3f412dc8, 0x3f412da8),
(0x3f412d98, 0x3f412d78),
]
for i, (iv_addr, ct_addr) in enumerate(flag_pairs):
iv = read_drom(iv_addr, 16)
ct = read_drom(ct_addr, 32)
cipher = AES.new(bytes(aes_key), AES.MODE_CBC, iv=iv)
plaintext = cipher.decrypt(ct)
# Strip PKCS7 padding
pad = plaintext[-1]
plaintext = plaintext[:-pad]
print(f"FLAG {i}: {plaintext.decode()}")
Output:
FLAG 0: ESPILON{Jn0un3d_4dM1N}
FLAG 1: ESPILON{802_11_tx_jnned}
FLAG 2: ESPILON{Adm1n_4r3_jn0uned}
FLAG 3: ESPILON{Jn0un3d_UDP_Pr0t0c0l}
Flag 2 Context
ESPILON{802_11_tx_jnned} relates to the WiFi 802.11 transmission capabilities embedded in the router firmware. The firmware initializes an ESP32 WiFi Access Point (Init WiFi Access Point) and contains an exfiltration session system (JNOUNER_SECRET_EXFILTRATION) that transmits data over 802.11 frames. The flag name references this covert WiFi TX channel — the "jnouned" company's backdoor for wireless data exfiltration.
Relevant strings in the firmware supporting this:
Exfiltration session started. Protocol: ...
JNOUNER_SECRET_EXFILTRATION
Hint: Secret pattern is JNOUNER_SECRET_XXXX
PROTOCOL INITIALIZATION LEAK
start_session
Flag
ESPILON{802_11_tx_jnned}
Tools Used
- Python 3 — scripting and binary parsing
- Capstone — Xtensa disassembly engine
- PyCryptodome — AES-256-CBC decryption
strings,xxd— initial reconnaissance
Key Takeaways
- Xtensa's windowed register ABI shifts registers on function calls (
call8rotates by 8), requiring careful tracking of argument mapping. l32r(literal load) is the primary mechanism for loading constants in Xtensa — literal pools sit before functions at negative PC-relative offsets.- The firmware used a two-layer encryption scheme: a simple XOR layer to derive an AES-256 key from scattered 4-byte blocks, then AES-256-CBC with unique per-flag IVs to protect each flag string. This is a common CTF pattern where the "key" is obfuscated to slow static analysis.