← Week of October 5, 2026
THE DEEP DIVE / CVE-2026-108107 9.8 CRITICAL

PHPNuxBill.

PHPNuxBill through 2025.3.20 contains an unauthenticated SQL injection vulnerability in the radius.php FreeRADIUS REST endpoint that interpolates request parameters into whereRaw() queries. Attackers can send crafted username, macAddr or nasid parameters to the accounting or authenticate actions to extract customer records and credentials via time-based blind SQL injection.

READING MODE

Concepts, source walkthroughs, diagrams, evidence, and limitations.

Full analysis selected.

Evidence-backed draft · source analysis only. The vulnerable source excerpts are from the public 2025.3.13 tag 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;
}
PHP codeAction selection makes radius.php the entry point for three RADIUS workflows.

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.

Rendering diagram…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.
Processing stepData storeLabelled direction
Data-flow diagramRequest values remain untrusted until interpolation turns them into SQL syntax.
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>'
Text outputParameterized queries keep statement grammar separate from request values.

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();
PHP codeThe accounting branch interpolates three request-controlled values into one raw predicate.

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.

Rendering diagram…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.
Actor or componentNumbered message directionLifetime during sequence
Sequence diagramThe accounting request reaches the SQL parser without a parameter-binding step.
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();
PHP codeThe authentication lookup places username directly inside a raw SQL string.

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();
PHP codeThe PPPoE fallback changes the column but preserves the same unsafe boundary.

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
HTTP exchangeA baseline request for a disposable lab uses only synthetic identifiers.

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.

Rendering diagram…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.
Processing stepLabelled direction
Data-flow diagramA bounded differential test changes one input and then repeats it after parameter binding.
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();
PHP codeAn illustrative remediation passes values separately from the fixed SQL grammar.

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:

  1. Enumerate every whereRaw/where_raw call in radius.php.
  2. Trace _req, _get, and _post values into each call.
  3. Replace interpolation with binding, including fallback branches.
  4. Add regression cases for quotes in username, macAddr, and nasid using synthetic records.
  5. Verify errors sent to Telegram or HTTP responses do not disclose SQL text or secrets.
  6. Repeat tests for authenticate, authorize, and accounting actions.

Sources

FOLLOW THE EVIDENCE

Sources & further reading

  1. github.com ↗
  2. github.com ↗
  3. github.com ↗
  4. www.vulncheck.com ↗

Technical details can change. Consult upstream advisories before making remediation decisions.