This is Part 3 of a 4-part series on vulnerabilities in FrontAccounting 2.4.19.
After finding the UNION injection in rep601.php (Part 2), I wanted to know how deep the reporting module's injection problem went. I started reading more report files, looking for the same pattern: $_POST['PARAM_N'] concatenated into SQL without parameterization or integer casting.
rep710.php — the Audit Trail report — looked injectable. I sent a test payload with SLEEP(2). I expected a 2-second delay. The request hung for 70 seconds.
That's when the math got interesting.
File: reporting/rep710.php
// Lines 62-63
$systype = $_POST['PARAM_2'];
$user = $_POST['PARAM_3'];
// Line 45 — the primary injection point
$sql .= "AND a.type=$systype ";
The $systype variable (sourced from PARAM_2) is an integer used in a WHERE clause, unquoted and unsanitized. Same pattern as Part 2. But the query structure is different — this one joins across the audit_trail table and related GL tables in a multi-table query.
There's a second injection point at line 47:
$sql .= "AND a.user='$user' ";
PARAM_3 goes into a string-quoted context. Different injection mechanism, but also exploitable. I'll come back to that.
Here's what makes rep710.php distinct from rep601.php. When MySQL evaluates a query like:
SELECT ... FROM audit_trail a JOIN ... WHERE a.type = 1 OR SLEEP(2)-- -
The SLEEP(2) function is evaluated for every row that participates in the JOIN result set. If the JOIN produces 35 rows, SLEEP(2) executes 35 times. 2 seconds times 35 rows equals 70 seconds.
This isn't a MySQL bug — it's how predicate evaluation works in a row-by-row scan. The OR SLEEP(2) becomes part of the WHERE clause filter, and the database engine evaluates it for each candidate row. With an OR, if the left side is true, the right side is short-circuited for that row. But for rows where a.type = 1 is false, SLEEP(2) executes.
The audit trail table in a populated FrontAccounting instance accumulates rows with every transaction. The more activity, the more rows, the worse the amplification. On a production system with years of audit data, a SLEEP(1) could lock a database connection for minutes or hours.
Baseline (normal request):
time curl -sk -b "$COOKIE" -X POST "${TARGET}/reporting/prn_redirect.php" \
-d 'REP_ID=710&PARAM_0=01/01/2020&PARAM_1=01/01/2030&PARAM_2=1&PARAM_3=-1&PARAM_4=&PARAM_5=0&PARAM_6=0' \
-o /dev/null
Result: 0.090 seconds
Injection with SLEEP(2):
time curl -sk -b "$COOKIE" -X POST "${TARGET}/reporting/prn_redirect.php" \
--data-urlencode "REP_ID=710" \
--data-urlencode "PARAM_0=01/01/2020" \
--data-urlencode "PARAM_1=01/01/2030" \
--data-urlencode "PARAM_2=1 OR SLEEP(2)-- -" \
--data-urlencode "PARAM_3=-1" \
--data-urlencode "PARAM_4=" \
--data-urlencode "PARAM_5=0" \
--data-urlencode "PARAM_6=0" \
-o /dev/null
Result: 70.159 seconds
70,159 milliseconds versus 90 milliseconds. A 780x amplification factor. The SLEEP(2) per row, multiplied across 35 rows in the JOIN, produces a 70-second server-side delay from a single HTTP request.


This amplification isn't just an exploitation curiosity — it's a practical denial-of-service vector. Consider:
SLEEP(30) on a table with 35 rows = 1,050 seconds (17.5 minutes) per requestSLEEP(1) = 10,000 seconds (2.8 hours) per requestmax_connections is 151A handful of concurrent requests with high SLEEP values could exhaust the database connection pool and lock out every other user of the application. This is a single-request DoS that scales with data volume.
I didn't test this at scale (out of scope for the engagement), but the math is straightforward.
PARAM_3 flows into a string-quoted context: AND a.user='$user'. The quotes are there, but as covered in Part 1, html_cleanup() encodes single quotes to '. So on the surface, you'd think the string injection is blocked.
It gets more nuanced. The payload:
PARAM_2=0&PARAM_3=-1' UNION SELECT 1,2,3,4,'2025-01-01',6,7,'2025-01-01',8,9,'admin',11-- -
This produced a response of 2,399 bytes versus a 2,401 byte baseline — different enough to suggest the UNION data was rendered into the PDF. Two independent injection points in the same query, through two different mechanisms (unquoted integer and string context).
For practical exploitation, the PARAM_2 integer injection is easier and more reliable. But the existence of the second vector in PARAM_3 broadens the attack surface and provides an alternative path if the integer parameter were to be fixed independently.
I tried reducing the SLEEP duration to minimize amplification. SLEEP(0.1) still produced a multi-second delay due to multiplication across rows. There's no way to inject a SLEEP that's both detectable and fast when the amplification factor is unknown — you have to accept the multiplication.
I also tried BENCHMARK() as an alternative to SLEEP() for time-based confirmation. It works, but SLEEP() is simpler and the amplification makes even small values trivially detectable.
For data extraction via time-based blind technique: it works, but it's painfully slow given the amplification. Each boolean test (one bit of information) takes 70+ seconds. Extracting a 32-character MD5 hash bit by bit would take hours. With UNION injection already available in rep601.php (Part 2), there's no practical reason to extract data through this slower channel. I confirmed the injection is real and moved on.
After finding rep601.php and rep710.php, the pattern was clear: report files read POST parameters and concatenate them into SQL. I searched for all instances of this pattern across the reporting module's 52+ files. The same root cause — unparameterized integer values in WHERE clauses — appears in multiple reports. Parts 2, 3, and 4 of this series each demonstrate a different exploitation technique (UNION, time-based blind, boolean-based blind), but they share the same underlying vulnerability class.
The required permission for rep710.php is SA_GLANALYTIC, assigned to standard accounting roles. No admin privileges needed.

Immediate fix for rep710.php:
// Replace:
$systype = $_POST['PARAM_2'];
// With:
$systype = (int)$_POST['PARAM_2'];
And for the string parameter:
// Replace:
$user = $_POST['PARAM_3'];
// With:
$user = db_escape($_POST['PARAM_3']);
// And ensure the query uses: AND a.user=$user (db_escape adds quotes)
Systemic recommendation: Same as Part 2 — the reporting module needs a centralized parameter validation layer. Integer parameters should be cast. String parameters should go through prepared statements or the database driver's escape function. The report dispatcher (prn_redirect.php) is the right place to enforce this before delegating to individual report files.
Rate limiting note: Even after fixing the injection, the amplification behavior is worth understanding. Any query that evaluates expressions per-row across large tables is a potential resource consumption vector. Consider query timeouts (MAX_EXECUTION_TIME hint or wait_timeout configuration) as a defense-in-depth measure.
| Date | Event |
|---|---|
| 2026-04-11 | Vulnerability discovered during independent security research |
| 2026-04-16 | Vendor notified with full technical details |
| 2026-04-17 | Vendor acknowledgment |
| 2026-04-17 | CVE-2026-40523 assigned |
| 2026-04-26 | Version 2.4.20 released with the fix |
| 2026-06-27 | Public disclosure |
Next: Part 4 covers a boolean-based SQL injection in the Journal Entries report, where the IN() clause creates a response-size oracle with a 132x differential.
Jiva Security offers web application penetration testing, source-assisted assessments, and dedicated vulnerability research. Every engagement is performed directly by me — no subcontractors, no account managers, no junior staff. Senior offensive expertise from first contact to final report.