It’s been roughly ten years since I last published on this blog. Ten years. At that point, calling it a short break would be a little dishonest.
I’ve spent those years doing the things I usually do: offensive security, red teaming, security engineering, bug bounty, and building things that occasionally make other people’s security problems my problem. I also kept telling myself I’d get back to writing. Apparently, I needed a decade to choose a WordPress theme.
When I finally renovated the site, I had an idea for the first post: revisit A2Billing, one of the very first applications I wrote about here around ten years ago. I wasn’t planning a grand research project. I wanted to see how the software had aged, read some PHP, and maybe find something worth writing about.
I ended up with two findings: a pair of pre-authentication SQL injection points in the payment flow, and an authenticated remote code execution chain in the administration interface. Neither needed a fancy new exploitation technique. What made them interesting was the way ordinary coding mistakes, old security assumptions, and framework behavior worked together.
I’m attaching the proof-of-concept scripts to the post. This is the story behind them, with the relevant code and a few details that made the investigation more entertaining than expected.
What I tested
The research was conducted against the A2Billing develop branch at commit 8dd474c (dated February 5, 2019), running locally in Docker with PHP 7.4.33, Apache 2.4, MySQL 5.7, Smarty 3.1.27, and ADOdb. The findings below describe that tested snapshot and environment; I haven’t independently established which other releases or production configurations are affected.
The two main findings are:
| Finding | Entry point | Access needed | Result |
|---|---|---|---|
| Pre-auth SQL injection | customer/checkout_process.php and customer/A2B_recurring_payment.php | No A2Billing login | Blind extraction of database values in the lab |
| Authenticated RCE chain | Admin configuration and backup/restore features | Administrative access | PHP code execution as the web server user in the lab |
These are not six different headlines dressed up as six CVEs. The second finding is a chain of smaller problems, including CSRF handling, file upload validation, and path configuration. There is also a separate command-injection issue in the restore code, which I’ll mention below.
Finding 1: SQL injection before login
I started in the customer payment flow. Payment callbacks are good places to look because they often accept requests without an interactive user session, and sometimes developers confuse a callback’s expected parameters with authentication.
In customer/checkout_process.php, the code takes transactionID from POST and attempts to reject values that aren’t positive integers:
if (!intval($transactionID) > 0) { exit(); }
if ($sess_id == "") { exit(); }Code language: PHP (php)
The first line is not checking what its author probably intended. PHP evaluates the negation before the comparison, effectively interpreting it as (!intval($transactionID)) > 0. A concrete example is transactionID=1 AND SLEEP(3): intval() returns 1, the condition evaluates to false, and the request continues. Crucially, the code never replaces $transactionID with the validated integer. The original string is used later.
That matters because the payment lookup constructs SQL by concatenation:
$QUERY = "SELECT id, cardid, amount, vat, paymentmethod, cc_owner, cc_number, "
. "cc_expires, creationdate, status, cvv, credit_card_type, currency, item_id, item_type "
. "FROM cc_epayment_log WHERE id = ".$transactionID
. " AND (status = 0 OR (status = 2 AND $NOW_2MIN))";
$transaction_data = $paymentTable->SQLExec($DBHandle_max, $QUERY);Code language: PHP (php)
It also reuses the same value in an UPDATE query before payment-provider verification. In other words, a string that merely starts like an integer can end up controlling SQL in two places.
You might expect the customer authentication include to stop this, but in the tested path it doesn’t. customer.module.access.php can set a session rights value for an unauthenticated user without terminating execution, and checkout_process.php doesn’t subsequently call has_rights() before the vulnerable queries. Including an authentication file is not the same thing as enforcing authentication. An important distinction, apparently.
The blacklist that tried very hard
A2Billing does have input sanitization. Quite a lot of it, actually. Its sanitize_data() function strips terms such as SELECT, UNION, WHERE, SUBSTRING, ASCII, and CONCAT, along with operators and comment syntax. The request passes through that sanitization twice, with another protection file in between that undoes some escaping.
This took some investigation because the usual trick of nesting a blacklisted SQL keyword inside itself doesn’t survive two passes. For instance, SELSELECTECT becomes SELECT after one pass, then disappears in the second. At first, this looked like an annoying obstacle. It wasn’t a security boundary.
An extra nesting layer survives both replacements:
SELSELSELECTECTECT → SELSELECTECT → SELECT
The same construction works for other blocked keywords. Meanwhile, SUBSTR, ORD, SLEEP, > and the MySQL # comment marker aren’t blocked in the first place. The sanitizer’s special handling of IF also doesn’t match (IF.
This is the fundamental problem with filtering SQL by deleting words. The database understands an expression and its grammar; the filter understands substrings. They are playing entirely different games, and the database gets the last move.
For more detail on the specific filter bypass techniques and sanitize_data() behavior, see my earlier post: https://0x4148.com/posts/a2billing-2.1.1-sqli/
Getting data out was more interesting than getting in
Confirming SQL injection was the easy part. Extracting data reliably was where I spent the time.
The obvious paths were disappointing. The application didn’t reflect selected database values into the response. Error-based techniques produced database errors, but ADOdb suppressed them with its debugging disabled. A timing-based condition using SLEEP() did work, although it was slow: the input reached both the SELECT and UPDATE, so a true condition could incur the delay twice. In the isolated test environment, PayPal-related network calls also added noise and latency.
Then I noticed this result check:
if (!is_array($transaction_data) && count($transaction_data) == 0) {
// log and exit
}Code language: PHP (php)
When the query returns no matching row, SQLExec() can return false. On PHP 7.4, calling count(false) raises a warning. With display_errors=On in my lab, that warning appears in the HTTP response. When a row does match, the is_array() condition short-circuits and the warning doesn’t appear.
That accidental difference became a boolean oracle: warning present means no matching row; warning absent means a row matched. I could ask yes/no questions about database values without sleeping or waiting for useful SQL output to appear on the page.
After arranging a suitable test transaction, responses were around 40 milliseconds. The PoC extracted a 16-character dummy card number, 4111111111111111, in roughly 3.8 seconds in that environment. This was lab data, not a real customer’s payment card. Those timings depend on the particular transaction, payment method, status, and PHP error-output settings. They shouldn’t be mistaken for universal production numbers.
A final complication was the payment state. The handler updates matching transactions to status=2; fresh transactions can stop matching on subsequent requests. An older test transaction with status=2 and a creation time outside the two-minute retry window provided a stable oracle. This is the kind of small application detail that matters much more than whether a payload looks clever in a screenshot.
The query didn’t have to stay in the payment table
The original injection sits inside a query on cc_epayment_log, which contains payment-related data. But once I understood the double-pass keyword filtering, a reconstructed UNION SELECT made it possible to test conditions against other readable tables, including cc_ui_authen, the administrative authentication table.
The values still weren’t printed directly, so this remained blind extraction. The UNION was useful for reaching another table, not for dumping whole rows into the HTML response. In the tested schema, the relevant exposure included payment details, customer-account data, and administrative password hashes, limited by the permissions of the database account.
A second injection point
Following the same flow led to customer/A2B_recurring_payment.php. It builds a database filter directly from a request-controlled id:
$card_clause = "id = $id";
$result = $table_card->Get_list($DBHandle, $card_clause);Code language: PHP (php)
The script’s early gate checks payment_status == "Completed" and txn_type == "subscr_payment", but both values come from POST. It does not include the customer authentication module. There is also a PayPal connection step on the path, which can prevent the vulnerable SQL from being reached in environments where the connection fails. So pre-authentication does not mean unconditionally reachable in every deployment.
Same underlying mistake, another payment endpoint: a request parameter is treated like a number without being safely bound into the SQL query.
Finding 2: Making the backup feature execute PHP
The second finding is an authenticated chain in the administration panel. It is a good example of why I prefer tracing an actual feature end to end rather than collecting isolated suspicious lines of code.
The relevant feature is backup and restore. It lets an administrator upload a database backup, and the destination is controlled by the backup_path setting. Nothing particularly exotic so far. The problem appears when you combine the behavior of the form handler, the upload code, and the filesystem.
The CSRF check overlooked file-only requests
The admin form handler verifies its CSRF token only inside this condition:
if (!empty($_POST)) {
// compare submitted CSRF token to the expected token
}Code language: PHP (php)
For a multipart request containing only file fields, PHP can populate $_FILES while leaving $_POST empty. The token check is skipped even though a POST request is being processed. Other action parameters can be read from the query string. That means a file upload can reach the handler without passing through the intended token verification.
This is not an authentication bypass. The restore page still requires administrative maintenance permissions. The CSRF mistake simply removes one of the protections that should apply to an authenticated request.
Upload validation and destination control
The upload handler constructs its destination like this:
$uploadfile = BACKUP_PATH . '/' . basename($_FILES['databasebackup']['name']);
move_uploaded_file($_FILES['databasebackup']['tmp_name'], $uploadfile);Code language: PHP (php)
basename() prevents simple path-separator tricks. It does not restrict the extension or make an uploaded file safe. The tested handler accepted a PHP filename as-is.
The destination is another problem. BACKUP_PATH comes from the application’s backup_path configuration value, which an administrator can change without a restrictive path validation check. In my Docker environment, the Smarty compiled-template directory, admin/templates_c/, was both writable by the PHP process and reachable through the web server. Configuring the backup directory there and uploading a PHP file resulted in code execution as www-data.
That is the chain in one paragraph: an authorized admin can change the backup directory, the upload handler accepts an executable file type, and the destination can be placed inside a web-served PHP directory. The separate multipart CSRF flaw makes the upload request less constrained than the form design suggests.
The advisory also documents seeded default administrative credentials in the tested installation. That is a separate exposure and can make obtaining admin access easier if an operator leaves the defaults unchanged. It is not the same as claiming that this RCE is pre-authentication, and I don’t want to blur the two.
One more surprise in restore
While reviewing restore, I found another issue: for .gz backup files, the stored path is concatenated into a shell command without safely quoting it.
$run_gzip = GUNZIP_EXE . " -c " . $path . " | ";
$run_restore = $run_gzip . MYSQL . " -u " . USER . " -p" . PASS;
exec($run_restore);Code language: PHP (php)
Because the stored filename can contain shell metacharacters, the restore operation has a separate OS command injection risk when the affected restore action is triggered. That’s distinct from placing a PHP file in a web-accessible directory; it deserves its own fix even if upload-directory restrictions are added.
Not bad for a backup utility. Its job description has expanded considerably.
The bigger problem is the security model
What I find striking isn’t that an old PHP application contains SQL injection. It’s that so many parts of the code look protected when read in isolation. There is an integer check, a SQL blacklist, an authentication include, CSRF tokens, basename(), and an admin permission check. Yet none of those controls necessarily protects the exact trust boundary where it’s needed.
The fixes are less exciting than the bugs. Bind SQL parameters instead of deleting keywords. Use the validated integer rather than the original request string. Make authorization checks fail closed. Verify CSRF for state-changing requests regardless of whether $_POST is populated. Restrict upload types and keep backup directories outside the document root. Don’t let an arbitrary configuration path become an executable upload destination. Quote shell arguments, or avoid shell construction entirely. And don’t expose PHP warnings to production users.
Those are boring recommendations, I know. They are also a lot cheaper than explaining why the billing platform’s backup feature was running arbitrary code.
Proofs of concept and disclosure
I prepared the PoCs against the isolated Docker setup. The SQL injection script covers content-based boolean extraction, a timing fallback, and cross-table extraction; the other scripts cover the authenticated code-execution path and the lab chain.
PoC attachments:
poc_sqli.pyrce_poc.pychain_poc.py
Research summary:
- Tested snapshot: A2Billing
develop, commit8dd474c - Research date: October 12, 2026
- CVE identifiers: Pending
- Vendor status: The vendor confirmed A2Billing is no longer actively developed, since newer Asterisk releases are no longer compatible with it, so it now lives on primarily as a practice lab rather than a maintained production platform.
The detailed request sequences and validation output belong with the accompanying PoCs rather than occupying half the blog post. All testing described here was performed against a local environment. Only test systems you own or are authorized to assess.
Back where I started
Reopening a project I first wrote about a decade ago was a strange way to relaunch the blog. The tools have changed, the work has changed, and I’ve definitely changed. But reading a function, spotting an assumption, and asking what happens when the input doesn’t behave still feels exactly the same.
This time, I intend to publish more than once every ten years. Expect offensive security research, engineering experiments, bug bounty stories, things learned while building security products, and probably the occasional opinion I should have kept to myself.
A2Billing seemed like a good place to start again. Apparently, it remembered me too.
Until next time,
Ahmed