Photo by Compare Fibre / Unsplash

Writeup: 802.11 is jnouned - EspilonCTF (ESP)

Feri Harjulianto

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:

FileSizeRole
bootloader.bin26,752 B2nd stage bootloader
jnouned_router.bin873,056 BMain application firmware
partition-table (1).bin3,072 BNVS + 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
SegmentVirtual AddressSizeContains
DROM0x3f400020124 KBStrings, encrypted flag data, constants
DRAM0x3ffb00006 KBInitialized variables
IROM0x400d0020617 KBApplication code + literal pools
DRAM20x3ffb193010 KBMore initialized data
IRAM0x4008000098 KBInterrupt 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_FLAGSFLAG %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:

  1. Layer 1 (XOR): 8 small blocks derive a 32-byte AES key
  2. 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:

BlockDROM AddressXOR KeyMnemonic
00x3f412e540x38movi.n a10, 0x38
10x3f412e500xe7movi.n a10, -0x19
20x3f412e4c0x91movi a10, -0x6f
30x3f412e480xf7movi.n a10, -9
40x3f412e440x10movi.n a10, 0x10
50x3f412e400x6bmovi a10, 0x6b
60x3f412e3c0x74movi a10, 0x74
70x3f412e380x96movi 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:

FlagIV AddressCiphertext Address
00x3f412e28 (16 B)0x3f412e08 (32 B)
10x3f412df8 (16 B)0x3f412dd8 (32 B)
20x3f412dc8 (16 B)0x3f412da8 (32 B)
30x3f412d98 (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
  • stringsxxd — initial reconnaissance

Key Takeaways

  • Xtensa's windowed register ABI shifts registers on function calls (call8 rotates 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.
CTFReverseWriteup