TryHackMe ICS / OT SCADA Modbus Easy

Brr

A SCADA dashboard with untouched vendor credentials tells you exactly where its PLC lives, and the protocol on the other end — Modbus, designed in 1979 for a factory floor nobody could reach — never asks who you are. No exploit, no privesc chain: twelve hand-packed bytes over a raw socket and the controller reads its own memory out loud.

PlatformTryHackMe
DifficultyEasy
CategoryICS / OT / Protocol
01

Network Reconnaissance

Fast scan first, just to see what's answering before committing to a full sweep.

shell
sudo nmap -sS -sV -sC 10.48.151.107 -F -T4
22
SSH
OpenSSH 9.6p1 Ubuntu
80
WebSockify
Python 3.12.3
8080
Tomcat
title: "ScadaBR CTF"

A page title that says ScadaBR CTF isn't being coy about the theme. ScadaBR is an open-source SCADA/HMI suite — the dashboard a plant operator stares at all day to watch tank levels and valve states. Whenever one of these turns up, the interesting target usually isn't the dashboard. It's whatever industrial protocol the dashboard is talking to behind the scenes.

WebSockify on port 80 is a hint in the same direction: it's a WebSocket-to-TCP bridge, most commonly used to put a VNC session in a browser. Something graphical is running on this host that they want reachable — worth noting, though it turns out not to be needed.

02

ScadaBR — Default Credentials, and a Datasource Named "secret"

ScadaBR ships with a well-documented default login, and this deployment never changed it. admin:admin lands straight on the operator console.

Always try vendor defaults on ICS software before anything else. These systems get deployed by control engineers, not security teams, they live on networks assumed to be isolated, and they get patched on a decade-long cycle if at all. Default credentials on SCADA products are not a CTF convenience — they're a real and persistent finding.
ScadaBR — /ScadaBR/data_sources.shtm200 OK
ScadaBR data sources page showing a Modbus IP datasource named secret pointing at plc:5020
One datasource: name "secret", type Modbus IP, connection plc:5020. Beneath it a single point named "test" — binary, slave id 1, offset 0/0.

Somebody's sense of humour named the datasource "secret" and then parked it behind admin:admin. The name is a joke; the connection string is the actual finding. Two fields matter here:

01
Type: Modbus IP. ScadaBR isn't storing this data itself — it's a client, polling something else and drawing widgets from whatever comes back.
02
Connection: plc:5020. The real data lives one hop further down, on a host the box resolves as plc, listening on port 5020. ScadaBR is a middleman we don't actually need.
03
Slave id 1, offset 0/0. Free reconnaissance on the PLC's addressing — unit ID 1, and whatever's interesting starts at register address 0. Both values go straight into the request later.

The point type shown is Binary at offset 0, which in ScadaBR terms means a coil or discrete input — a single bit. That's the decoy. Nothing says the PLC only holds what the HMI was configured to display, and the register table sitting next to it is a much larger surface.

03

The Port nmap Couldn't Name

Full sweep now, all 65535 ports, no -F shortcuts.

shell
sudo nmap -p- 10.48.151.107
output — nmap -p-
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 80/tcp open http WebSockify Python/3.12.3 5020/tcp open zenginkyo-1? # ← nmap's best guess, and it's wrong 5901/tcp open vnc VNC (protocol 3.8) | vnc-info: | Security types: VeNCrypt (19), VNC Authentication (2)

There it is — 5020, exactly the port ScadaBR's datasource pointed at. nmap labels it zenginkyo-1?, which is just a lookup in nmap-services saying "historically some Japanese banking protocol registered this number." The trailing question mark means no service probe matched. That's expected and worth internalising:

nmap's fingerprint database is built for IT protocols. Modbus, DNP3, BACnet, S7comm and friends routinely come back unlabeled or misidentified, because their probes look nothing like the HTTP/TLS/SSH banner exchanges nmap is tuned for. An "unknown service" on an odd port next to a SCADA frontend deserves manual investigation, not a shrug.

We don't need nmap to name it anyway — the ScadaBR config already told us: Modbus. VNC on 5901 is sitting there too (that's what WebSockify on 80 is bridging), but the PLC gives up everything on its own, so the desktop never comes into play.

04

How Modbus Actually Works

Modbus is old. Older than most of the engineers still deploying it. Modicon designed it in 1979 so its programmable logic controllers could exchange readings over a serial line, and the industry never replaced it — it just kept bolting new transports underneath the same message format. "Modbus TCP" is that identical 1979 conversation wrapped in a TCP socket instead of RS-485 wiring, which is precisely what's listening on 5020.

The design brief back then was "let two industrial devices on an isolated factory floor talk to each other." Authentication was not part of that brief, and it still isn't. There is no login, no token, no session — nothing beyond the TCP handshake itself. Whoever can route a packet to the controller can read its memory, and with the write function codes, change it.

This is the whole point of the box. Anyone who can reach port 5020 has exactly the access ScadaBR has after authenticating. The login screen on 8080 was decorative — it protected the dashboard, never the controller.

One controller, four tables of memory

A Modbus device exposes its entire state as four flat memory tables. That's the complete data model — no schema, no field names, no types beyond "bit" and "16-bit word":

TableWidthAccessTypically holds
Coils1 bitread / writerelay and valve states — on/off outputs
Discrete Inputs1 bitread-onlylimit switches, sensor trip flags
Input Registers16 bitread-onlymeasured values — temperature, pressure
Holding Registers16 bitread / writesetpoints, configuration, general scratch memory

Holding registers are the interesting one: read and write, 16 bits wide, and completely generic. In a real plant a holding register might be a target temperature or a pump speed. The protocol attaches no meaning to the contents — it's sixteen bits, and interpretation is entirely a convention between whoever wrote the PLC program and whoever reads it. Which means nothing stops a challenge author from parking one ASCII character per register and letting the controller hand it out to anyone who asks.

Function codes: the verbs

Every request opens with a single byte naming the operation. The whole vocabulary is small enough to memorise:

CodeOperationTarget table
0x01Read Coilscoils
0x02Read Discrete Inputsdiscrete inputs
0x03Read Holding Registersholding registers
0x04Read Input Registersinput registers
0x05 / 0x06Write Single Coil / Registercoils / holding registers
0x0F / 0x10Write Multiple Coils / Registerscoils / holding registers

0x03 is what we want — read holding registers, the read/write 16-bit table. Note how casually the write codes sit in that same list. On a live plant, 0x06 against the right address is the difference between a pump running and a pump not running.

The envelope: MBAP header

Serial Modbus used a CRC checksum so a receiver could tell where one message ended and the next began. TCP already guarantees ordered, error-checked delivery, so when Modbus was adapted for IP networks the CRC was dropped and replaced with the MBAP header — Modbus Application Protocol header. Its entire job is bookkeeping: which request is this, which protocol, how long is the message, which device should answer. Everything after the header is the PDU — the Protocol Data Unit, the actual command, structurally unchanged since the RS-485 days.

Modbus TCP request — read holding registers12 bytes total
Transaction ID2 bytes
Protocol ID2 bytes
Length2 bytes
Unit ID1 byte
Function1 byte
Start Addr2 bytes
Quantity2 bytes
MBAP header — 7 bytes of routing, invisible to the PLC's logic PDU — 5 bytes, the command itself
TxID
Transaction ID — a number you choose, echoed back verbatim in the reply. Fire several requests down one socket and this is how you match each answer to its question. Nothing validates it; any value works.
Proto
Protocol ID — always 0. It exists because someone in the nineties imagined other protocols riding this same header. They never arrived. The field is a permanent zero.
Len
Length — byte count of everything after this field: the Unit ID plus the entire PDU. Not the length of the whole message. Getting this wrong is the single most common hand-rolled-Modbus bug.
UID
Unit ID — a leftover from serial days, when one TCP gateway bridged to many physical devices sharing a bus. ScadaBR already told us this PLC answers as slave id 1.

Fill that layout in with real values — read 60 holding registers starting at address 0, unit 1 — and here are the exact twelve bytes that leave the socket:

wire format — hex
00 01 00 00 00 06 01 03 00 00 00 3c └TxID─┘ └Proto┘ └─Len─┘ UID FC └Start┘ └─Qty─┘ └──────── MBAP header, 7 bytes ────┘ └─── PDU, 5 bytes ───┘ # 00 06 → six bytes follow the Length field (1 unit id + 5 pdu) # 00 3c → 60 in hex: sixty registers, sixty possible characters
05

Skipping the Middleman — Talking to the PLC Directly

ScadaBR would happily poll this datasource and render it as a widget, but there's no reason to fight a Java web app's UI when the protocol underneath has zero access control. A TCP socket and struct is the entire toolchain. No pymodbus, no library — packing the bytes by hand is the part actually worth understanding.

python — mod.py
import socket, struct TARGET_IP = "10.48.151.107" TARGET_PORT = 5020 # non-standard — Modbus normally lives on 502 # PDU: 0x03 = Read Holding Registers, start at address 0, read 60 registers pdu = struct.pack(">BHH", 0x03, 0, 60) # MBAP: Transaction ID=1, Protocol ID=0, Length=len(pdu)+1, Unit ID=1 mbap = struct.pack(">HHHB", 1, 0, len(pdu) + 1, 1) s = socket.socket() s.connect((TARGET_IP, TARGET_PORT)) s.sendall(mbap + pdu) resp = s.recv(4096) # Response = [MBAP: 7 bytes][function code: 1][byte count: 1][register data...] byte_count = resp[8] regs = struct.unpack(">" + "H" * (byte_count // 2), resp[9:9 + byte_count]) # Each register holds one ASCII character code — keep only printable ones print("".join(chr(r) for r in regs if 32 <= r < 127)) s.close()

Nothing exotic — it's the same twelve bytes from the diagram, typed into struct.pack instead of drawn as boxes. A few details are worth slowing down on, because they're where hand-rolled protocol code usually breaks.

Why every format string starts with >

The leading > forces big-endian byte order — most significant byte first. Modbus mandates this regardless of what CPU sits on either end, the same way TCP/IP does (it's literally called "network byte order"). Drop the > and struct falls back to the host's native order, which on any x86 machine is little-endian. The value 60 would go out as 3c 00 instead of 00 3c — that's 15360 to the PLC, and it would either error out or try to hand back thirty kilobytes of registers.

Packing the PDU — ">BHH"

Three values, back to back: B is one unsigned byte for the function code, and each H is an unsigned short — two bytes — for the starting address and the register count. One byte plus two plus two is five, exactly the PDU width in the diagram. The address is 0 because ScadaBR's own point was configured at offset 0, and 60 is simply a generous read window — plenty of room for a short string, cheap enough that there's no reason to be precise.

Packing the header — ">HHHB"

Three shorts and a byte: transaction ID, protocol ID, length, unit ID. The field that trips people up is len(pdu) + 1. Length is not the size of the message — it's the number of bytes that follow the Length field itself, which is the Unit ID (1 byte) plus the PDU (5 bytes). That's 6, matching the 00 06 in the hex dump. Writing len(pdu) or len(mbap + pdu) here are both extremely easy mistakes, and the PLC's response to either is usually silence rather than a helpful error.

Taking the response apart

The reply mirrors the request's shape. Seven bytes of MBAP first (same four fields, transaction ID echoed back). Then at offset 7, the function code — an echo confirming which command this answers, and the field where an error would show up: on failure the PLC sets the high bit, so 0x03 comes back as 0x83 followed by an exception code. At offset 8, a byte count — how many data bytes follow. From offset 9 onward, the register values themselves.

That's why the slice is resp[9:9 + byte_count], and why the format string is built dynamically: byte_count // 2 converts "bytes of payload" into "number of 16-bit registers", since each register is two bytes wide. Sixty registers means byte_count comes back as 120, giving ">" + "H" * 60 — a format string with sixty Hs in it, generated rather than typed.

Turning registers into text

chr(r) treats each 16-bit register as a character code. Sixty registers were requested but the stored string is short, so most come back as 0 — untouched PLC memory. The if 32 <= r < 127 filter is doing real work: 32 is the space character, 126 is ~, and that range is exactly printable ASCII. Without it you'd be printing a wall of null bytes and control characters with the actual content buried somewhere inside.

Worth knowing for other boxes: that filter is also a crude search. If you don't know where data is stored, sweep a wide address range with 0x03, then 0x04 for input registers, and let the printable-ASCII filter tell you which offsets hold text instead of sensor values. Registers full of zeros or wildly varying numbers are real process data; a contiguous run inside 32–126 is somebody's string.
06

Result

shell
python3 mod.py THM{[redacted]}
Flag redacted. TryHackMe's terms prohibit publishing room answers, so the value stays out of this writeup. The script above reproduces it in a couple of seconds against a live instance — that's the part that's actually worth having.

No exploit, no fuzzing, no privilege escalation. Twelve bytes sent to an unauthenticated protocol port, and the controller reads its own memory out loud. The ScadaBR login and the datasource cheekily named "secret" were the only things standing between an anonymous TCP connection and the PLC's register table, and neither of them was ever actually in the way.

07

Chain Summary & Takeaways

01 nmap -F ScadaBR HMI exposed on 8080 — ICS environment confirmed
02 default creds admin:admin operator console access, datasource config readable
03 datasource: Modbus IP @ plc:5020 backend protocol, host and unit id handed over
04 nmap -p- 5020 reachable directly — the PLC isn't segmented off
05 Modbus has no auth layer HMI login becomes irrelevant — talk to the controller instead
06 FC 0x03 over raw socket holding registers decode as ASCII → contents recovered
T1
In OT, network reachability is the access control. Modbus TCP has no authentication of any kind. If a packet can route to port 502 (or 5020, or wherever it's been moved), it can read controller memory and — with the write function codes — change it. Nothing else is checking.
T2
A SCADA frontend is a convenience layer, not a security boundary. The moment its configuration leaks the backend address and protocol, the frontend's own login stops mattering. Read HMI configs for what they hand you: hostnames, ports, protocols, slave ids, register offsets.
T3
Don't trust nmap to name industrial services. zenginkyo-1? is a port-number lookup, not a fingerprint. OT protocols come back unlabeled constantly. An unknown port next to a SCADA product is a lead, not noise.
T4
Hand-packing a protocol beats reaching for a library when you're learning it. Twelve bytes of struct.pack teaches you the header layout, byte order, and length semantics in a way pymodbus.read_holding_registers() never will — and when something misbehaves, you can read the wire.
T5
This gap is why the Purdue Model exists. Field-level protocols were designed for physically isolated networks and were never built to defend themselves. The real-world control is segmentation between IT and OT zones — because the protocol layer offers exactly nothing.