# CVE-2026-10835 — Root Cause Analysis

## Summary

The SALESmanago & Leadoo WordPress plugin (before 3.11.3) is vulnerable to
authenticated SQL injection via the `salesmanago_export_count_contacts` AJAX
action. The plugin registers this action on `wp_ajax_` (authenticated users
only) but the authorization helper `SecureHelper::validate_ajax_nonce()` returns
`false` early for any non-admin user **without** calling `die()`/`wp_send_json_error()`,
and `ExportController::countContacts()` ignores the return value — so an
authenticated user with the minimal **Subscriber** role reaches the vulnerable
query with **no nonce required**. `ExportModel::parseArgs()` reads the
base64-decoded JSON `data` request parameter and assigns `$data->dateFrom`
directly to `$this->dateFrom` with no sanitization; `ExportModel::getExportContactsQuery()`
then interpolates that value directly into a SQL string
(`A.post_date >= '{$this->dateFrom}'`), which is classic SQL injection. The
injection was confirmed end-to-end against a real WordPress + MySQL stack: a
time-based blind `SLEEP()` payload produced a response delay that scales
linearly with the sleep duration, and a `UNION ALL` payload extracted the
`wp_users` row count directly through the injection.

## Impact

- **Package/component affected:** `SALESmanago & Leadoo` WordPress plugin
  (slug `salesmanago`), specifically `src/Admin/Model/ExportModel.php`
  (`parseArgs()` + `getExportContactsQuery()`) and `src/Includes/SecureHelper.php`
  (`validate_ajax_nonce()`).
- **Affected versions:** all versions up to and including **3.11.2** (fixed in **3.11.3**).
- **Risk level:** High (CVSS 7.7 per the WPScan advisory). An authenticated
  Subscriber (or any role) can append arbitrary SQL to the export-contacts count
  query and exfiltrate sensitive data such as user password hashes, secret keys,
  and the contents of any database table via time-based/boolean/UNION techniques.

## Impact Parity

- **Disclosed/claimed maximum impact:** SQL injection allowing an authenticated
  attacker (Subscriber+) to extract sensitive information from the database.
- **Reproduced impact from this run:**
  1. Time-based blind SQL injection — `SLEEP(N)` executed inside the SQL engine,
     response delay scaling linearly with N (≈6 s per unit).
  2. Data extraction — a `UNION ALL` injection returned the `wp_users` row count
     (`count = 2`, matching the ground-truth value read independently via `$wpdb`).
- **Parity:** `full`. The claimed SQL-injection/data-extraction impact was
  demonstrated through the real remote `/wp-admin/admin-ajax.php` endpoint with
  an authenticated Subscriber session.
- **Not demonstrated:** This proof focused on counting/extraction primitives; a
  full dump of password hashes was not performed (out of scope for a
  reproduction). The demonstrated primitives are sufficient to mount such an
  extraction with standard SQLi tooling (e.g. sqlmap on the `data`/`dateFrom` parameter).

## Root Cause

Two defects combine to produce the vulnerability:

1. **Missing input sanitization + string interpolation in SQL.**
   `ExportModel::parseArgs()` (vulnerable 3.11.2):
   ```php
   $data = json_decode( base64_decode( $_REQUEST['data'] ) );
   $this->dateFrom = empty( $data->dateFrom ) ? '2000-01-01' : $data->dateFrom; // NO sanitization
   ```
   `ExportModel::getExportContactsQuery()` then builds the query by direct
   interpolation:
   ```php
   $query .= "WHERE A.post_type = 'shop_order' AND A.post_date >= '{$this->dateFrom}' AND A.post_date <= '{$this->dateTo}'";
   ```
   `$this->dateFrom` is attacker-controlled and unsanitized, so a single quote
   in `dateFrom` breaks out of the string literal and injects arbitrary SQL.
   Note the query is post-processed with `preg_replace('/\s\s+/', ' ', $query)`,
   which collapses newlines to single spaces; this is why a `-- ` line comment
   must not be used (it would also comment out the closing `) AS qwerty`), and
   why the working payloads balance the SQL instead.

2. **Broken authorization on the AJAX action.**
   `SecureHelper::validate_ajax_nonce()` (vulnerable 3.11.2):
   ```php
   public static function validate_ajax_nonce($action) {
       if ( function_exists('current_user_can') && ! current_user_can('manage_options') ) {
           return false;          // <-- Subscriber hits this: returns, but does NOT die()
       }
       ...                          // check_ajax_referer() / wp_send_json_error() never reached for non-admins
   }
   ```
   `ExportController::countContacts()` calls `SecureHelper::validate_ajax_nonce(...)`
   but **ignores its return value**, so execution continues to `parseArgs()` and
   the vulnerable query regardless. A Subscriber therefore reaches the SQL with
   no nonce and no capability check.

**Fix (3.11.3):**
- `validate_ajax_nonce()` now enforces both a valid nonce (`check_ajax_referer('salesmanago_admin','sm_nonce',false)`)
  **and** `current_user_can('manage_options')`, calling `wp_send_json_error(['message'=>'forbidden'], 403)`
  (which dies) on failure — so a Subscriber is blocked with HTTP 403.
- `parseArgs()` now validates input: `sanitize_text_field()`, strict base64
  decode, JSON-error checking, and a new `validate_date_format()` for `dateFrom`.
- `getExportContactsQuery()` now uses `$wpdb->prepare()` with `%s` placeholders:
  ```php
  $query .= $this->db->prepare(" AND A.post_date >= %s AND A.post_date <= %s ", $this->dateFrom, $this->dateTo);
  ```
  eliminating the string interpolation.

Fixed-version reference: WordPress.org plugin download
`https://downloads.wordpress.org/plugin/salesmanago.3.11.3.zip`; advisory
`https://a8cteam5105.wordpress.com/vulnerability/3c7b37ab-b069-4257-82b2-5b4c54f7e503/`.

## Reproduction Steps

1. **Reference script:** `bundle/repro/reproduction_steps.sh` (self-contained;
   also uses `bundle/repro/docker-compose.yml`).
2. **What the script does:**
   - Stands up a real WordPress 6.7 (PHP 8.2) + MySQL 8.0 stack via Docker
     Compose. Because the sandbox host cannot reach the Docker bridge network
     (different mount/network namespace), all HTTP interaction is executed
     **inside** the WordPress container via `docker compose exec`.
   - Runs the WordPress 5-minute install, seeds a minimal `salesmanago_configuration`
     option (via `wp eval-file`/`$wpdb`, since the `wordpress:apache` image ships
     no `mysql` CLI), copies the plugin into the container via `docker cp`
     (bind mounts do not work across the namespace boundary), activates it, and
     creates a **Subscriber** user plus two dummy posts.
   - Logs in as the Subscriber through the real `wp-login.php` cookie flow.
   - Sends SQL-injection payloads to the real `/wp-admin/admin-ajax.php` endpoint
     (`action=salesmanago_export_count_contacts`, `data` = base64 JSON with an
     injected `dateFrom`), measuring HTTP status and response time, and parses the
     `count` field from the JSON response.
   - Repeats the entire flow against the **fixed 3.11.3** plugin as a negative
     control, then compares results and writes `runtime_manifest.json`.
3. **Expected evidence of reproduction:**
   - Vulnerable 3.11.2: `SLEEP(0)≈0.07 s`, `SLEEP(1)≈6.07 s`, `SLEEP(2)≈12.01 s`
     (linear timing → blind SQLi), and `UNION ALL` extraction `count = 2`
     matching the ground-truth `wp_users` row count.
   - Fixed 3.11.3: every Subscriber request returns HTTP **403**
     `{"success":false,"data":{"message":"forbidden"}}` with no timing delay and
     no `count` field.

## Evidence

- **Manifest:** `bundle/repro/runtime_manifest.json` (valid JSON; concrete timing,
  extraction, and fixed-control values).
- **Verdict:** `bundle/repro/validation_verdict.json`.
- **Run logs:** `bundle/logs/vulnerable/run.log`, `bundle/logs/fixed/run.log`.
- **Per-request captures:** `bundle/logs/vulnerable/req_*.txt`,
  `bundle/logs/fixed/req_*.txt` (each file is the raw curl body followed by a
  final line `HTTP_CODE TIME_TOTAL`).
- **Summaries:** `bundle/logs/vulnerable/summary.csv`, `bundle/logs/fixed/summary.csv`
  (`name|http|time|count`).
- **Ground truth:** `bundle/logs/vulnerable/groundtruth_usercount.txt` (`2`).

Key excerpts (vulnerable 3.11.2):

```
# Time-based blind SQLi — linear scaling (each row sleeps N seconds)
sleep0 | 200 | 0.075820s | count=1
sleep1 | 200 | 6.071948s | count=1
sleep2 | 200 | 12.012303s | count=1

# Data extraction — UNION ALL reads wp_users row count through the injection
union_usercount | 200 | 0.009666s | count=2     # matches ground-truth (admin + subscriber = 2)
union_nouser    | 200 | 0.017291s | count=0     # WHERE ID=99999 -> 0 rows (control)
baseline        | 200 | 0.010917s | count=0     # no injection
```

Raw vulnerable `union_usercount` response body (the injected `dateFrom` is
reflected back and `count` is the extracted value):
```json
{"packageSize":400,...,"dateFrom":"2000-01-01' AND 1=0 UNION ALL SELECT 1,2,3,4,5,6,7,8,9,10,11 FROM (SELECT 1 AS post_date FROM wp_users) A WHERE '1'='1","dateTo":"2026-07-05","count":"2",...}
```

Fixed 3.11.3 negative control — Subscriber is blocked, no SQL executes:
```
sleep2          | 403 | 0.009891s | count=
union_usercount | 403 | 0.011896s | count=
```
Raw fixed `union_usercount` response: `{"success":false,"data":{"message":"forbidden"}}` (HTTP 403).

**Environment:** Docker `wordpress:6.7-php8.2-apache` + `mysql:8.0`; plugin
versions 3.11.2 (vulnerable) and 3.11.3 (fixed) downloaded from
`downloads.wordpress.org`. No sanitizer used; the oracle is wall-clock response
time and the JSON `count` field returned by the real product endpoint.

## Recommendations / Next Steps

- **Upgrade** to SALESmanago & Leadoo **3.11.3** or later immediately.
- **Fix approach (already applied in 3.11.3):** use `$wpdb->prepare()` with
  placeholders for every user-controlled value, validate/whitelist `dateFrom`
  with a date-format check, and enforce both a valid nonce and `manage_options`
  capability on every export AJAX action (die on failure).
- **Defense in depth:** register export actions only for `manage_options` users
  via `add_action('wp_ajax_...', ...)` combined with an explicit
  `current_user_can('manage_options')` gate at the top of each handler; audit all
  other `$wpdb->get_var/get_row/get_results/query` call sites for raw interpolation.
- **Testing:** add an integration test that sends a SQLi payload (single quote,
  `SLEEP`, `UNION`) to each export AJAX action as a Subscriber and asserts a 403
  and no timing differential; add a static scan forbidding `{$...}` interpolation
  inside SQL strings passed to `$wpdb`.

## Additional Notes

- **Idempotency confirmation:** `reproduction_steps.sh` was run **twice
  consecutively**; both runs exited 0 and produced identical confirming results
  (timing scaling `yes`, `union_usercount == groundtruth == 2`, fixed control
  `403`). The stack is fully torn down (`docker compose down -v`) at the end of
  each instance, so each run starts from a clean MySQL volume.
- **Sandbox constraints handled:** (a) the Docker daemon runs in a separate
  mount namespace from the shell, so bind mounts see an empty directory — the
  plugin is injected with `docker cp` instead; (b) the `wordpress:apache` image
  has no `mysql` client, so DB seeding/reads use `wp eval-file` (pure `$wpdb`);
  (c) the host cannot reach the container network, so all HTTP requests
  (`wp-login.php`, `admin-ajax.php`) run inside the container via
  `docker compose exec curl http://127.0.0.1/...`.
- **Payload note:** the `SLEEP` payload is balanced as
  `2000-01-01' OR SLEEP(N) OR '1'='1` rather than using a `-- ` comment, because
  the plugin's `preg_replace('/\s\s+/', ' ', $query)` whitespace collapse turns
  newlines into spaces and would make `-- ` comment out the closing `) AS qwerty`,
  breaking the query (observed as `count:null` during development).
- **Limitations:** WooCommerce is not installed, so the export query's
  `shop_order` filter matches zero rows by design; the time-based proof relies on
  the `OR SLEEP(N)` term being evaluated for the default `post`/`page` rows that
  the `WHERE` scans. The data-extraction proof uses a derived table
  `(SELECT 1 AS post_date FROM wp_users) A` so the trailing
  `AND A.post_date <= '<dateTo>'` clause stays valid inside the UNION second
  SELECT. No destructive SQL was executed; only `SLEEP`, `UNION ALL`, and
  `COUNT` were used.
