Photo by Compare Fibre / Unsplash

Writeup: Admin Panel - 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{Adm1n_4r3_jn0uned}

Solution

Step 1: Firmware Reconnaissance

The challenge provides three ESP32 firmware files targeting the Xtensa LX6 architecture, built with ESP-IDF v5.3.2:

  • bootloader.bin — 2nd stage bootloader
  • jnouned_router.bin — Main application (873 KB)
  • partition-table (1).bin — NVS / PHY / factory layout

String analysis immediately reveals an admin authentication system:

admin_login <password>
Admin login successful (persisted).
ADMIN STATE RESTORED FROM NVS
UART console ready. Try: admin_login <password>
Show WiFi credentials (admin only)
=== ROUTER SETTINGS (admin privileged) ===
admin logged in

The firmware has a UART console with an admin_login command, persistent admin state via NVS, and privileged commands gated behind authentication — including a flag command.

Step 2: Reverse Engineering the Password

The firmware contains string fragments that hint at the password composition:

admin-
jnoun-
2022
%s%s%s

Using Capstone to disassemble the Xtensa code, I located the password construction function at 0x400daa14:

0x400daa14: entry   a1, 0x20
0x400daa17: mov.n   a10, a2          ; buf (passed through)
0x400daa19: mov.n   a11, a3          ; size (passed through)
0x400daa1b: l32r    a15, [0x400d0a40] ; -> "2022"
0x400daa1e: l32r    a14, [0x400d0a44] ; -> "admin-"
0x400daa21: l32r    a13, [0x400d0a48] ; -> "jnoun-"
0x400daa24: l32r    a12, [0x400d0a4c] ; -> "%s%s%s"
0x400daa27: l32r    a8,  [0x401539cc] ; -> snprintf
0x400daa2a: callx8  a8
0x400daa2d: retw.n

In Xtensa's windowed register ABI, callx8 rotates the register window by 8. The caller's registers a10a15 become the callee's a2a7:

CallerCalleeValue
a10a2buf (output buffer)
a11a3size (buffer length)
a12a4"%s%s%s" (format)
a13a5"jnoun-"
a14a6"admin-"
a15a7"2022"

This calls: snprintf(buf, size, "%s%s%s", "jnoun-", "admin-", "2022")

Password: jnoun-admin-2022

Step 3: Trace the Admin Login Flow

The admin_login command handler validates the password and persists the admin state to NVS. Related strings show the full flow:

usage: admin_login <password>              — input validation
Admin login successful (persisted).        — on correct password
Admin state saved to NVS                   — persistence
Failed to open NVS for admin save: %s      — error path
ADMIN STATE RESTORED FROM NVS             — on boot, if previously authed

After successful login, the console unlocks privileged commands including settings (WiFi credentials) and flag (encrypted flag reveal).

Step 4: Locate the Encrypted Flags

The flag command triggers a decryption routine. Tracing IROM literal pool references around 0x400d0e700x400d0ee4 reveals the encryption layout:

AES key material — 8 XOR-encrypted 4-byte blocks in DROM:

0x400d0e94: -> 0x3f412e54    0x400d0ea4: -> 0x3f412e44
0x400d0e98: -> 0x3f412e50    0x400d0ea8: -> 0x3f412e40
0x400d0e9c: -> 0x3f412e4c    0x400d0eac: -> 0x3f412e3c
0x400d0ea0: -> 0x3f412e48    0x400d0eb0: -> 0x3f412e38

Flag ciphertexts — 4 pairs of (16-byte IV, 32-byte ciphertext):

0x400d0ec0: -> 0x3f412e28 (IV 0)     0x400d0ec4: -> 0x3f412e08 (CT 0)
0x400d0ec8: -> 0x3f412df8 (IV 1)     0x400d0ecc: -> 0x3f412dd8 (CT 1)
0x400d0ed0: -> 0x3f412dc8 (IV 2)     0x400d0ed4: -> 0x3f412da8 (CT 2)
0x400d0ed8: -> 0x3f412d98 (IV 3)     0x400d0edc: -> 0x3f412d78 (CT 3)

Step 5: Derive the AES-256 Key via XOR

The function at 0x400de16c XOR-decrypts each 4-byte block with a hardcoded single-byte 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  # DROM vaddr -> file offset
    block = data[foff:foff+4]
    aes_key.extend(bytes([b ^ k for b in block]))

# Result: 32-byte AES-256 key
# 79d8ba7a 61728ced 3f45ea9d 7c534c3f b364bdef 2f16995b 051f36c9 85a7577a

Step 6: Decrypt Flag 2 (Index 2)

Flag 2 uses the third IV/ciphertext pair:

  • IV: 16 bytes at 0x3f412dc8
  • Ciphertext: 32 bytes at 0x3f412da8
from Crypto.Cipher import AES

iv_off = 0x3f412dc8 - 0x3f400020 + 0x20
ct_off = 0x3f412da8 - 0x3f400020 + 0x20

iv = data[iv_off:iv_off+16]   # bf2fe61d 6f26d4a5 ecb2685f aaccca07
ct = data[ct_off:ct_off+32]   # b8eb0e5d 3c22e018 9f43d566 c30ecc8d
                               # 2211cfb5 a5e88113 a5290bc7 07c0c897

cipher = AES.new(bytes(aes_key), AES.MODE_CBC, iv=iv)
plaintext = cipher.decrypt(ct)

# Strip PKCS7 padding (last byte = 0x06, six padding bytes)
pad = plaintext[-1]
plaintext = plaintext[:-pad]

print(plaintext.decode())  # ESPILON{Adm1n_4r3_jn0uned}

Flag 2 Context

ESPILON{Adm1n_4r3_jn0uned} — "Admins are jnouned" — refers to the admin authentication bypass at the heart of this challenge. The firmware protects its UART console with an admin_login command, but the password jnoun-admin-2022 is trivially reconstructed from three plaintext string fragments concatenated via snprintf. Once authenticated, the admin gains access to privileged router settings and the encrypted flag store.

The full admin attack chain:

UART Console
    │
    ├── admin_login jnoun-admin-2022
    │       │
    │       ├── Password validated against snprintf("%s%s%s", "jnoun-", "admin-", "2022")
    │       └── Admin state persisted to NVS
    │
    ├── settings   → WiFi credentials (admin only)
    ├── flag       → Triggers AES-256-CBC decryption of stored flags
    └── start_session → Exfiltration session

All Decrypted Flags

FLAG 0: ESPILON{Jn0un3d_4dM1N}
FLAG 1: ESPILON{802_11_tx_jnned}
FLAG 2: ESPILON{Adm1n_4r3_jn0uned}    <-- this flag
FLAG 3: ESPILON{Jn0un3d_UDP_Pr0t0c0l}

Flag

ESPILON{Adm1n_4r3_jn0uned}

Tools Used

  • Python 3 — binary parsing, scripting
  • Capstone — Xtensa LX6 disassembly
  • PyCryptodome — AES-256-CBC decryption
  • stringsxxd — initial recon

Key Takeaways

  • ESP32 firmware stores string constants in flash-mapped DROM. Password fragments spread across the read-only data segment are easily recovered with strings, but understanding how they're assembled requires disassembling the Xtensa code.
  • Xtensa l32r instructions load from literal pools at negative PC-relative offsets — following these pointers maps every string and constant to the function that uses it.
  • The callx8 windowed call rotates registers by 8, so tracing argument flow requires mapping caller a10a15 to callee a2a7.
  • NVS persistence means the admin state survives reboots — a real security concern for embedded devices where physical UART access is possible.
CTFReverseWriteup