Evidence-backed draft · source analysis only. The vulnerable source excerpts are from the public
2025.3.13tag and the cited advisory. This application has not run PHPNuxBill or sent an injection payload to any deployment.
The vulnerable component is a RADIUS-to-database bridge
PHPNuxBill is a billing application that can answer FreeRADIUS REST calls. Its radius.php file accepts authentication, authorization, and accounting attributes, looks up customer and session records, and returns RADIUS control/reply attributes as JSON.
The action comes from an HTTP header or query parameter:
$action = $_SERVER['HTTP_X_FREERADIUS_SECTION'];
if (empty($action)) {
$action = _get('action');
}
switch ($action) {
case 'authenticate':
// customer and voucher lookups
break;
case 'authorize':
// plan and credential lookups
break;
case 'accounting':
// session lookup and update
break;
}
That makes the file a trust boundary. Values such as username, macAddr, and nasid arrive as HTTP input, but are later used to identify rows in the billing database. They must remain data all the way into the SQL driver.
Read the diagram as text
FreeRADIUS sends request attributes to radius.php. Request helpers return strings. The strings are interpolated into whereRaw. The database parses the combined string as SQL syntax and then accesses customer or accounting records.
Prepared values versus SQL syntax
A SQL query has two fundamentally different parts:
- Syntax, such as column names, operators, and
AND. - Values, such as a username or MAC address.
A parameterized query sends those separately. The database parses a fixed statement and binds each value afterward. String interpolation does the opposite: it creates one SQL-language string before parsing. A quote in an input value can therefore terminate the intended literal and alter the grammar that follows.
The difference can be represented without a database:
Parameterized
SQL: BINARY username = ? AND macaddr = ? AND nasid = ?
values: [username, macAddr, nasid]
Interpolated
SQL: BINARY username = '<username>'
AND macaddr = '<macAddr>'
AND nasid = '<nasid>'
Escaping or character filtering in one code path is not equivalent to binding values at every sink. The affected file contains several raw-query constructions, and inputs flow through authentication as well as accounting branches.
The clearest sink is the accounting lookup
At lines 266–278 of the 2025.3.13 source, username, macAddr, and nasid are request-controlled. All three are concatenated into one whereRaw() expression:
case 'accounting':
$username = _req('username');
if (empty($username)) {
show_radius_result([
"control:Auth-Type" => "Reject",
"reply:Reply-Message" => "Username empty"
], 200);
die();
}
$d = ORM::for_table('rad_acct')
->whereRaw(
"BINARY username = '$username' " .
"AND macaddr = '" . _post('macAddr') . "' " .
"AND nasid = '" . _post('nasid') . "'"
)
->findOne();
The non-empty check changes neither the SQL grammar nor the trust level of username. There is also no binding placeholder between _post() and whereRaw() for the other fields.
Read the diagram as text
A RADIUS peer sends username, MAC address, and NAS identifier values to radius.php. The handler reads those untrusted strings and gives one interpolated SQL fragment to the ORM. The database parses values and syntax together, then returns a row, error, or timing difference.
The vulnerable boundary is visible directly in the source: each request value reaches a raw SQL string without an intervening parameter-binding operation.
The pattern also appears in authentication and authorization
The issue is broader than the one accounting statement. Authentication uses the same pattern when looking up usernames and PPPoE usernames:
$username = _req('username');
$c = ORM::for_table('tbl_customers')
->select('password')
->select('pppoe_password')
->whereRaw(
"BINARY username = '$username' AND status = 'Active'"
)
->find_one();
The fallback query changes the column but not the trust boundary:
$c = ORM::for_table('tbl_customers')
->select('password')
->select('pppoe_password')
->whereRaw(
"BINARY pppoe_username = '$username' AND status = 'Active'"
)
->find_one();
Authorization and subsequent recharge lookups repeat raw interpolation. This matters when fixing the bug: replacing only the accounting statement can leave equivalent sinks reachable through another action value.
A useful source-audit query is therefore not “where is line 277?” but “where can a request-derived value reach whereRaw()?” In the affected file, the answer includes customer, voucher, recharge, and accounting queries.
A local-only test should observe query behavior, not extract real data
No live system is needed to verify the core concept. A responsible lab would use a disposable PHPNuxBill instance with a synthetic database and network access restricted to the test harness. Every row, credential, and identifier should be synthetic and destroyed with the environment.
A baseline accounting request would look structurally like this:
POST /radius.php HTTP/1.1
Host: phpnuxbill.local
X-FreeRADIUS-Section: accounting
Content-Type: application/x-www-form-urlencoded
username=lab-user&macAddr=00:11:22:33:44:55&nasid=lab-nas
The test should then change one synthetic field and observe a bounded signal: the SQL statement captured by a test database logger, a controlled error, or a timing assertion against test-only rows. It should not enumerate customer records, use a third-party host, or retain credentials.
Read the diagram as text
Create a known synthetic row, send a baseline request, alter one input around a quote boundary, and observe only a captured statement, controlled error, or timing assertion. Apply parameter binding and repeat the identical request to confirm that the signal disappears.
This article does not provide a data-extraction payload because the service has not built that lab or independently verified affected/fixed behavior. The advisory reports time-based blind extraction; that remains an upstream claim in this draft.
What a complete fix needs to change
The durable fix is parameter binding at every request-derived sink. The exact Idiorm API must be checked against the version bundled by PHPNuxBill, but the intended shape is:
// Illustrative shape: confirm the actual ORM API before applying.
$d = ORM::for_table('rad_acct')
->where_raw(
'BINARY username = ? AND macaddr = ? AND nasid = ?',
[$username, _post('macAddr'), _post('nasid')]
)
->find_one();
An even safer option is to use ordinary ORM equality methods when no raw SQL feature is required. BINARY or collation requirements may justify a raw fragment, but they do not justify interpolating values into that fragment.
Input validation remains defense in depth. A MAC address can be parsed into a canonical format and a NAS identifier can have an expected length, but validation should not become the primary SQL boundary. Usernames often need punctuation and international characters; ad-hoc allowlists are easy to apply inconsistently.
Patch status and uncertainty
The public 2025.3.13 tag contains the shown sinks, and the CVE description states that releases through 2025.3.20 are affected. At research time, the repository’s default branch still showed the same raw accounting query. We did not identify a source commit that removes all affected sinks, so this draft does not claim that the default branch or any unverified tag is fixed.
That missing fixed revision is also why the article remains source analysis only. A proper fixed-version comparison needs an upstream patch or release that can be pinned and tested.
Mitigation and review checklist
Follow the project or CNA advisory for an official fixed release. Until a fix is available and verified, restrict the FreeRADIUS REST endpoint to the specific RADIUS service that needs it; do not expose it as a general public web route. Database credentials used by the billing application should have only the permissions the application requires.
During code review:
- Enumerate every
whereRaw/where_rawcall inradius.php. - Trace
_req,_get, and_postvalues into each call. - Replace interpolation with binding, including fallback branches.
- Add regression cases for quotes in
username,macAddr, andnasidusing synthetic records. - Verify errors sent to Telegram or HTTP responses do not disclose SQL text or secrets.
- Repeat tests for
authenticate,authorize, andaccountingactions.
Sources
- https://github.com/hotspotbilling/phpnuxbill/blob/2025.3.13/radius.php#L19-L45
- https://github.com/hotspotbilling/phpnuxbill/blob/2025.3.13/radius.php#L266-L312
- https://github.com/hotspotbilling/phpnuxbill/security/advisories/GHSA-q8ch-r8cv-q579
- https://www.vulncheck.com/advisories/phpnuxbill-through-2025.3.20-unauthenticated-sql-injection-via-radius-php