Photo by Compare Fibre / Unsplash

Writeup: Jnouned Routeur - 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{Jn0un3d_4dM1N}

Files Provided

  • bootloader.bin (26,752 bytes) - ESP32 2nd stage bootloader
  • jnouned_router.bin (873,056 bytes) - Main firmware image
  • partition-table (1).bin (3,072 bytes) - Partition table

Solution

Step 1: Identify the Firmware Format

Running file and strings on the binaries reveals this is an ESP-IDF v5.3.2 firmware for ESP32 (Xtensa LX6 architecture). The project name is jnouned_router.

strings jnouned_router.bin | grep -i "esp-idf\|project\|admin\|flag"

Key strings found:

  • admin_login <password> - UART console expects a password
  • flag - A console command to "Show hidden flag"
  • FLAG CAPTURED: %s - Flag output format
  • CTF_FLAGS - NVS namespace for encrypted flags
  • Failed to decrypt flag %d - Multiple encrypted flags exist

Step 2: Parse the ESP32 Image Segments

The ESP32 image header reveals 5 segments:

SegmentVirtual AddressSizePurpose
00x3f400020124 KBDROM (read-only data)
10x3ffb00006 KBDRAM (initialized data)
20x400d0020617 KBIROM (code, flash-mapped)
30x3ffb193010 KBDRAM (BSS-adjacent)
40x4008000098 KBIRAM (fast code)

Step 3: Disassemble the Password Construction

Using the Capstone disassembly framework with Xtensa support, I located the password construction function at 0x400daa14:

from capstone import *
md = Cs(CS_ARCH_XTENSA, CS_MODE_LITTLE_ENDIAN)

The disassembled function:

entry   a1, 0x20
mov.n   a10, a2          ; buffer pointer
mov.n   a11, a3          ; buffer size
l32r    a15, "2022"      ; literal pool
l32r    a14, "admin-"
l32r    a13, "jnoun-"
l32r    a12, "%s%s%s"
l32r    a8, snprintf     ; 0x401539cc
callx8  a8               ; snprintf(buf, size, "%s%s%s", "jnoun-", "admin-", "2022")
retw.n

Understanding Xtensa's windowed ABI: callx8 rotates the register window by 8, so the caller's a12-a15 become the callee's a4-a7. This means:

  • a4 = "%s%s%s" (format string)
  • a5 = "jnoun-"
  • a6 = "admin-"
  • a7 = "2022"

Password: jnoun-admin-2022

Step 4: Locate Encrypted Flag Data

By tracing string references in the IROM literal pools, I found the flag decryption infrastructure:

  • 8 small encrypted blocks (4 bytes each) at DROM addresses 0x3f412e38-0x3f412e54
  • 8 large encrypted blocks (alternating 16 and 32 bytes) at 0x3f412d78-0x3f412e28
  • An XOR decryption function at 0x400de16c

Step 5: Reverse the XOR Key Derivation

The function at 0x400de16c XOR-decrypts the 8 small blocks with hardcoded per-block keys:

; Block 0: data from 0x3f412e54, XOR key 0x38
l32r    a9, [0x3f412e54]
l8ui    a9, a9, 0
movi.n  a10, 0x38
xor     a9, a9, a10
s8i     a9, a2, 0          ; write to output buffer

; Block 1: data from 0x3f412e50, XOR key 0xe7
; ... (same pattern for all 8 blocks)
BlockSource AddressXOR Key
00x3f412e540x38
10x3f412e500xe7
20x3f412e4c0x91
30x3f412e480xf7
40x3f412e440x10
50x3f412e400x6b
60x3f412e3c0x74
70x3f412e380x96

XOR-decrypting these 8 blocks produces a 32-byte AES-256 key.

Step 6: AES-256-CBC Decryption

The large encrypted blocks are structured as alternating (16-byte IV, 32-byte ciphertext) pairs. Using the derived AES key with CBC mode:

from Crypto.Cipher import AES

# Derived 32-byte key from XOR step
key = bytes([...])  # 32 bytes

# For each flag: (IV_address, CT_address)
flag_pairs = [
    (0x3f412e28, 0x3f412e08),  # Flag 0
    (0x3f412df8, 0x3f412dd8),  # Flag 1
    (0x3f412dc8, 0x3f412da8),  # Flag 2
    (0x3f412d98, 0x3f412d78),  # Flag 3
]

for iv_addr, ct_addr in flag_pairs:
    iv = read_drom(iv_addr, 16)
    ct = read_drom(ct_addr, 32)
    cipher = AES.new(key, AES.MODE_CBC, iv=iv)
    flag = cipher.decrypt(ct)  # Remove PKCS7 padding
    print(flag)

Decrypted Flags

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

ESPILON{Jn0un3d_4dM1N}

Tools Used

  • Python 3 with Capstone (Xtensa disassembly)
  • PyCryptodome (AES decryption)
  • stringsxxdfile (initial recon)

Key Takeaways

  • ESP32 uses Xtensa LX6 with a windowed register ABI — call8 shifts registers by 8, so function arguments map differently than in conventional calling conventions.
  • l32r is the primary way Xtensa loads constants — it reads from a literal pool at a negative PC-relative offset. Following these pointers is essential for resolving string and data references.
  • The firmware used a two-layer encryption scheme: XOR to derive an AES key, then AES-256-CBC with per-flag IVs to protect the actual flag strings.
CTFWriteupReverse