# CVE-2026-10835 — Variant Root Cause Analysis

## Summary

A distinct **alternate-trigger variant** of CVE-2026-10835 was found and
confirmed end-to-end. The parent reproduction exploited the
`salesmanago_export_count_contacts` AJAX action
(`ExportController::countContacts()` → `ExportModel::getExportContactsQuery(true)`
→ `$wpdb->get_var()`). This variant reaches the **same** SQL-injection sink —
`ExportModel::getExportContactsQuery()` interpolating the unsanitized
`$this->dateFrom` directly into `A.post_date >= '{$this->dateFrom}'` — via the
sibling AJAX action **`salesmanago_export_contacts`**
(`ExportController::exportContacts()` → `getExportContactsQuery(false)` →
`$wpdb->get_results()`). It relies on the **same** two defects as the parent:
(a) `SecureHelper::validate_ajax_nonce()` returns `false` early for non-admins
**without dying**, and the controller ignores the return value, so an
authenticated Subscriber reaches the query with no nonce; and (b) `parseArgs()`
assigns `$data->dateFrom` to `$this->dateFrom` with no sanitization. The variant
was confirmed on vulnerable **3.11.2** (time-based blind `SLEEP()` delivered
through the `export_contacts` action produced response delays scaling linearly
with N: 0.073s → 6.068s → 12.012s) and was **ruled out as a bypass** on fixed
**3.11.3** (the patched `validate_ajax_nonce()` dies with HTTP 403 for the
Subscriber, the handler adds a `manage_options` gate, `parseArgs()` validates
`dateFrom`, and `getExportContactsQuery()` uses `$wpdb->prepare()`). Outcome:
**alternate-trigger variant, not a bypass.**

## Fix Coverage / Assumptions

**Invariant the 3.11.3 fix relies on:** every code path that reaches the
`getExportContactsQuery()` SQL sink (or any `$wpdb` call with user input) is
gated by `SecureHelper::validate_ajax_nonce()` **and** a per-handler
`current_user_can('manage_options')` check, and the only attacker-controlled
field interpolated into SQL (`dateFrom`) is both validated (strict date format)
and parameterized (`%s`).

**Code paths the fix explicitly covers:** all five export AJAX actions
registered in `ExportController::registerActions()` —
`salesmanago_export_count_contacts`, `salesmanago_export_contacts`,
`salesmanago_export_count_events`, `salesmanago_export_events`,
`salesmanago_export_products`. Each now dies 403 for non-admins, validates
`dateFrom`/`dateTo`, and the contacts query is `$wpdb->prepare()`-d. The events
path (`getEventsData`) already routed through WooCommerce's parameterized
`wc_get_orders`; the products path only interpolated int-cast `LIMIT/OFFSET`,
now also `prepare()`-d.

**What the fix does NOT cover (evaluated, no gap found):** I inspected every
other entry point that could reach a SQL sink with attacker-controlled input —
the `Admin.php` AJAX actions (`refresh_owners`, `refresh_catalogs`,
`generate_swjs`, `wp_ajax_trash_post`), the REST endpoints
(`salesmanago/v2/callbackApiV3` token-gated; `salesmanago/v1/cart` int-cast
cart recovery), the `CronController` (token compared with `hash_equals`), and
all remaining `$wpdb` call sites (`AdminModel`, `ProductCatalogModel`). None
interpolate attacker-controlled strings into SQL. The `dateTo` field was never
injectable (`strtotime()`+`date()` collapse it to a clean date). The fix is
**complete** for the SQL-injection class. See `bundle/vuln_variant/patch_analysis.md`.

## Variant / Alternate Trigger

**Entry point:** `POST /wp-admin/admin-ajax.php` with
`action=salesmanago_export_contacts`, sent by an authenticated **Subscriber**
(no nonce required in 3.11.2). The request body parameter `data` is a base64
JSON object containing the injected `dateFrom` and `packageCount=1` (the latter
is required to pass `exportContacts()`'s `if ( $this->ExportModel->getPackageCount() )`
guard so the vulnerable query actually executes).

**Code path (vulnerable 3.11.2):**
- `src/Admin/Controller/ExportController.php` line 64 — registration
  `wp_ajax_salesmanago_export_contacts → exportContacts`.
- `src/Admin/Controller/ExportController.php` lines 122–168 — `exportContacts()`:
  - line 123: `SecureHelper::validate_ajax_nonce( 'salesmanago_count_contacts' )`
    (note the pre-existing typo'd action name; for a Subscriber the early-return
    `false` happens regardless).
  - line 141: `$this->ExportModel->parseArgs()` (sets unsanitized `dateFrom`).
  - line 130: `$query = $this->ExportModel->getExportContactsQuery( false )` (the
    sink; `$count=false` adds `LIMIT/OFFSET` instead of the `COUNT(*)` wrapper
    used by `countContacts`).
  - line 131: `$results = $this->db->get_results( $query, ARRAY_A )` (the SQL
    executes here, with the injected `SLEEP()`/`UNION`).
- `src/Admin/Model/ExportModel.php` lines 138–148 — `parseArgs()`: unsanitized
  `$this->dateFrom = $data->dateFrom`.
- `src/Admin/Model/ExportModel.php` lines 341–406 — `getExportContactsQuery()`:
  `A.post_date >= '{$this->dateFrom}'` (string interpolation).
- `src/Includes/SecureHelper.php` lines 81–104 — `validate_ajax_nonce()`:
  returns `false` for non-admins without dying.

**Why this is materially distinct from the parent (not a relabeling):**
different AJAX action/endpoint, different controller method, different `$wpdb`
primitive (`get_results` row-set vs `get_var` scalar count), an extra
`packageCount` guard the attacker must satisfy, and a different exploitation
primitive (the `count` scalar is **not** echoed by `exportContacts` — its rows
flow to `prepareContactsToExport` + the SALESmanago API export — so direct
UNION extraction via the HTTP response is blind here, whereas `countContacts`
echoes the count; time-based blind `SLEEP` works on both).

## Impact

- **Package/component affected:** `SALESmanago & Leadoo` WordPress plugin
  (slug `salesmanago`), `src/Admin/Controller/ExportController.php`
  (`exportContacts()`) sharing `src/Admin/Model/ExportModel.php`
  (`parseArgs()` + `getExportContactsQuery()`) and `src/Includes/SecureHelper.php`
  (`validate_ajax_nonce()`).
- **Affected versions (as tested):** confirmed on **3.11.2**; blocked on
  **3.11.3**. Same affected range as the parent CVE (≤ 3.11.2).
- **Risk level:** High. An authenticated Subscriber (lowest privileged role)
  can append arbitrary SQL to the export-contacts query via the
  `salesmanago_export_contacts` action and exfiltrate arbitrary database
  contents (user hashes, secret keys, any table) using time-based/boolean blind
  techniques. CVSS parity with the parent (7.7).

## Impact Parity

- **Disclosed/claimed maximum impact (parent/variant):** authenticated SQL
  injection allowing a Subscriber to read sensitive data from the database.
- **Reproduced impact from this variant run:** time-based blind SQL injection
  via the `salesmanago_export_contacts` action — `SLEEP(N)` executed inside the
  SQL engine with response delay scaling linearly with N (0.073s → 6.068s →
  12.012s), from an authenticated Subscriber with no nonce. This is the same
  read primitive as the parent, demonstrated through the alternate endpoint.
- **Parity:** `full` for the SQL-injection read primitive (same sink, same
  auth level, same trust boundary). The parent additionally demonstrated a
  direct UNION-based count extraction via the echoed `count` field; that
  specific *direct-echo* extraction is **not** available on `exportContacts`
  (rows are not echoed), but the same data is extractable via time-based/boolean
  blind SQLi (the demonstrated `SLEEP` primitive is the foundation for both).
- **Not demonstrated:** a full dump of password hashes via this variant (out of
  scope for a variant reproduction; the demonstrated blind-SLEEP primitive is
  sufficient to mount such extraction with standard SQLi tooling).

## Root Cause

The same underlying bug is reachable because `ExportController` registers
**two** authenticated AJAX actions (`salesmanago_export_count_contacts` and
`salesmanago_export_contacts`) that both call the **same**
`ExportModel::getExportContactsQuery()` sink, and both rely on the **same**
`SecureHelper::validate_ajax_nonce()` authorization helper. In 3.11.2 that
helper returns `false` for non-admins without dying, and both controllers
ignore the return value, so a Subscriber reaches the identical unsanitized
`dateFrom`→SQL interpolation from either endpoint. The root cause is therefore
identical (shared sink + shared input source + shared auth-bypass defect); see
`bundle/vuln_variant/root_cause_equivalence.json`.

**Fix reference:** WordPress.org plugin release `salesmanago.3.11.3.zip`
(`https://downloads.wordpress.org/plugin/salesmanago.3.11.3.zip`); the 3.11.3
diff is analyzed in `bundle/vuln_variant/patch_analysis.md`. No public git
commit is available (the plugin is distributed as a WordPress.org zip); the
exact tested source is pinned by release version + zip SHA-256 in
`bundle/vuln_variant/source_identity.json`.

## Reproduction Steps

1. **Reference script:** `bundle/vuln_variant/reproduction_steps.sh`
   (self-contained; reuses the plugin zips under `bundle/artifacts/` and
   `bundle/repro/wp-cli.phar`, and writes its own `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. All HTTP interaction is executed **inside** the WordPress
     container via `docker compose exec` (the sandbox host cannot reach the
     Docker bridge network).
   - Runs the WP 5-minute install, seeds a minimal `salesmanago_configuration`
     option (so `ExportController`'s constructor completes and the `wp_ajax_`
     handlers register), copies the plugin into the container, activates it,
     creates a **Subscriber** user + two dummy posts, and logs in as the
     Subscriber via the real `wp-login.php` cookie flow.
   - Sends time-based blind SQLi payloads to the **variant** endpoint
     `action=salesmanago_export_contacts` with
     `data = base64(JSON{dateFrom:"2000-01-01' OR SLEEP(N) OR '1'='2", packageCount:1})`,
     measuring HTTP status and response time. The payload keeps `SLEEP(N)` as a
     standalone OR operand (so MySQL cannot skip it) while the constant-FALSE
     third OR operand yields 0 matching rows, so `exportContacts` skips the
     external SALESmanago API call and the delay is purely the in-DBMS SLEEP.
   - Repeats the flow against the **fixed 3.11.3** build (negative control):
     sends the `SLEEP(2)` payload as a Subscriber and expects HTTP 403.
   - Writes `runtime_manifest.json`, `validation_verdict.json`, per-run
     `summary.csv`, and `run.log` under `bundle/logs/vuln_variant/`.
3. **Expected evidence:** on vulnerable 3.11.2 the `SLEEP(0/1/2)` response
   times scale linearly (~6s per unit) with HTTP 200; on fixed 3.11.3 the
   Subscriber is blocked with HTTP 403 and no timing differential.

## Evidence

- **Run log:** `bundle/logs/vuln_variant/run.log`
- **Vulnerable (3.11.2) summary:** `bundle/logs/vuln_variant/vulnerable/summary.csv`
  ```
  sleep0|200|0.073038
  sleep1|200|6.067677
  sleep2|200|12.012328
  baseline|200|0.010659
  ```
- **Fixed (3.11.3) summary:** `bundle/logs/vuln_variant/fixed/summary.csv`
  ```
  sleep2|403|0.074118
  ```
- **Vulnerable `sleep2` response body** (`bundle/logs/vuln_variant/vulnerable/req_sleep2.txt`):
  ```json
  {"packageSize":400,"packageCount":1,"lastExportedPackage":-1,...,"status":"done",
   "dateFrom":"2000-01-01' OR SLEEP(2) OR '1'='2","dateTo":"2026-07-05","count":0,...}
  200 12.012328
  ```
  The `status:"done"` + `count:0` confirms 0 rows were returned (the 0-rows
  payload worked) and the 12.01s delay is purely the in-DBMS `SLEEP(2)`.
- **Vulnerable `baseline` response body** (no injection,
  `bundle/logs/vuln_variant/vulnerable/req_baseline.txt`): `status:"done"`,
  `0.010659s` — confirms the endpoint works and the SLEEP delay is from
  injection, not the export API call.
- **Fixed `sleep2` response body** (`bundle/logs/vuln_variant/fixed/req_sleep2.txt`):
  ```json
  {"success":false,"data":{"message":"forbidden"}}
  403 0.074118
  ```
- **Environment:** Docker `wordpress:6.7-php8.2-apache` + `mysql:8.0`; plugin
  versions 3.11.2 (vulnerable) and 3.11.3 (fixed) from
  `downloads.wordpress.org`. Exact source identity (zip SHA-256) in
  `bundle/vuln_variant/source_identity.json`.

## Recommendations / Next Steps

- **The 3.11.3 fix already covers this variant** (authorization die +
  `manage_options` gate + input validation + `$wpdb->prepare()`). No additional
  code change is required for `salesmanago_export_contacts` specifically.
- **Defense in depth (recommended for the maintainer):** add an integration test
  that sends a SQLi payload (single quote, `SLEEP`, `UNION`) to **every**
  `wp_ajax_salesmanago_export_*` action as a Subscriber and asserts HTTP 403 +
  no timing differential, so a future regression that re-introduces an
  unguarded handler or string interpolation is caught automatically.
- **Static guard:** add a repo-wide scan forbidding `{$...}` interpolation
  inside any SQL string passed to `$wpdb->get_var/get_row/get_results/query`,
  and forbidding `wp_ajax_*` handlers that do not begin with a
  `manage_options` (or equivalent) capability check.
- **Centralize authorization:** the pre-existing typo
  (`validate_ajax_nonce('salesmanago_count_contacts')` in `exportContacts`)
  shows per-handler action-name strings are error-prone; consider deriving the
  expected action from `$_REQUEST['action']` inside `validate_ajax_nonce`
  (as 3.11.3 now does) rather than passing it per-call.

## Additional Notes

- **Bypass vs alternate trigger:** this is an **alternate trigger**, not a
  bypass. The variant reproduces on the vulnerable 3.11.2 path and is blocked
  (HTTP 403) on the fixed 3.11.3 path. The script exits **1** (alternate-trigger
  / no bypass); it would exit **0** only if the SLEEP also executed on the fixed
  version.
- **Idempotency confirmation:** `reproduction_steps.sh` was run **five**
  consecutive times; runs 2–5 (after the payload was corrected to keep SLEEP as
  a standalone OR operand) all completed without crashing and produced
  identical confirming results (vulnerable SLEEP scaling `yes`, fixed
  `403`). The Docker stack is fully torn down (`docker compose down -v`) at the
  end of each instance, so each run starts from a clean MySQL volume.
- **Payload note:** an initial `OR SLEEP(N) AND '1'='2` payload produced *no*
  timing differential because MySQL constant-folded the constant-FALSE `AND`
  and skipped evaluating `SLEEP`. The corrected `OR SLEEP(N) OR '1'='2` payload
  keeps `SLEEP` as a standalone OR operand (which MySQL cannot skip) while still
  returning 0 rows — this is the form used in the final script and artifacts.
- **Negative search boundedness:** fewer than 3 materially distinct SQLi
  candidates exist in this plugin. The only sink that interpolates
  attacker-controlled input is `getExportContactsQuery()` (reached by
  `countContacts` and `exportContacts`). The events path uses WooCommerce's
  parameterized `wc_get_orders`; the products path uses int-cast
  `LIMIT/OFFSET`; all other `$wpdb` sites are either already `prepare()`-d or
  use constant queries; the REST/cart/cron surfaces do not reach a raw-SQL sink.
  These are documented in `patch_analysis.md` rather than re-run as "attempts,"
  since they are rule-outs by code inspection, not runtime variants.
