Writeup: Admin Panel - 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{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 bootloaderjnouned_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 a10–a15 become the callee's a2–a7:
| Caller | Callee | Value |
|---|---|---|
a10 | a2 | buf (output buffer) |
a11 | a3 | size (buffer length) |
a12 | a4 | "%s%s%s" (format) |
a13 | a5 | "jnoun-" |
a14 | a6 | "admin-" |
a15 | a7 | "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 0x400d0e70–0x400d0ee4 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
strings,xxd— 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
l32rinstructions load from literal pools at negative PC-relative offsets — following these pointers maps every string and constant to the function that uses it. - The
callx8windowed call rotates registers by 8, so tracing argument flow requires mapping callera10–a15to calleea2–a7. - NVS persistence means the admin state survives reboots — a real security concern for embedded devices where physical UART access is possible.