{"repro_id":"REPRO-2026-00241","version":6,"title":"SALESmanago & Leadoo WordPress plugin SQL injection","repro_type":"security","status":"published","severity":"high","description":"The SALESmanago & Leadoo WordPress plugin before 3.11.3 does not properly sanitize and escape user input, leading to SQL injection. Reproduce: install the vulnerable WordPress plugin, send a request containing a SQL injection payload, and observe database error or extract data.","root_cause":"# CVE-2026-10835 — Root Cause Analysis\n\n## Summary\n\nThe SALESmanago & Leadoo WordPress plugin (before 3.11.3) is vulnerable to\nauthenticated SQL injection via the `salesmanago_export_count_contacts` AJAX\naction. The plugin registers this action on `wp_ajax_` (authenticated users\nonly) but the authorization helper `SecureHelper::validate_ajax_nonce()` returns\n`false` early for any non-admin user **without** calling `die()`/`wp_send_json_error()`,\nand `ExportController::countContacts()` ignores the return value — so an\nauthenticated user with the minimal **Subscriber** role reaches the vulnerable\nquery with **no nonce required**. `ExportModel::parseArgs()` reads the\nbase64-decoded JSON `data` request parameter and assigns `$data->dateFrom`\ndirectly to `$this->dateFrom` with no sanitization; `ExportModel::getExportContactsQuery()`\nthen interpolates that value directly into a SQL string\n(`A.post_date >= '{$this->dateFrom}'`), which is classic SQL injection. The\ninjection was confirmed end-to-end against a real WordPress + MySQL stack: a\ntime-based blind `SLEEP()` payload produced a response delay that scales\nlinearly with the sleep duration, and a `UNION ALL` payload extracted the\n`wp_users` row count directly through the injection.\n\n## Impact\n\n- **Package/component affected:** `SALESmanago & Leadoo` WordPress plugin\n  (slug `salesmanago`), specifically `src/Admin/Model/ExportModel.php`\n  (`parseArgs()` + `getExportContactsQuery()`) and `src/Includes/SecureHelper.php`\n  (`validate_ajax_nonce()`).\n- **Affected versions:** all versions up to and including **3.11.2** (fixed in **3.11.3**).\n- **Risk level:** High (CVSS 7.7 per the WPScan advisory). An authenticated\n  Subscriber (or any role) can append arbitrary SQL to the export-contacts count\n  query and exfiltrate sensitive data such as user password hashes, secret keys,\n  and the contents of any database table via time-based/boolean/UNION techniques.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** SQL injection allowing an authenticated\n  attacker (Subscriber+) to extract sensitive information from the database.\n- **Reproduced impact from this run:**\n  1. Time-based blind SQL injection — `SLEEP(N)` executed inside the SQL engine,\n     response delay scaling linearly with N (≈6 s per unit).\n  2. Data extraction — a `UNION ALL` injection returned the `wp_users` row count\n     (`count = 2`, matching the ground-truth value read independently via `$wpdb`).\n- **Parity:** `full`. The claimed SQL-injection/data-extraction impact was\n  demonstrated through the real remote `/wp-admin/admin-ajax.php` endpoint with\n  an authenticated Subscriber session.\n- **Not demonstrated:** This proof focused on counting/extraction primitives; a\n  full dump of password hashes was not performed (out of scope for a\n  reproduction). The demonstrated primitives are sufficient to mount such an\n  extraction with standard SQLi tooling (e.g. sqlmap on the `data`/`dateFrom` parameter).\n\n## Root Cause\n\nTwo defects combine to produce the vulnerability:\n\n1. **Missing input sanitization + string interpolation in SQL.**\n   `ExportModel::parseArgs()` (vulnerable 3.11.2):\n   ```php\n   $data = json_decode( base64_decode( $_REQUEST['data'] ) );\n   $this->dateFrom = empty( $data->dateFrom ) ? '2000-01-01' : $data->dateFrom; // NO sanitization\n   ```\n   `ExportModel::getExportContactsQuery()` then builds the query by direct\n   interpolation:\n   ```php\n   $query .= \"WHERE A.post_type = 'shop_order' AND A.post_date >= '{$this->dateFrom}' AND A.post_date <= '{$this->dateTo}'\";\n   ```\n   `$this->dateFrom` is attacker-controlled and unsanitized, so a single quote\n   in `dateFrom` breaks out of the string literal and injects arbitrary SQL.\n   Note the query is post-processed with `preg_replace('/\\s\\s+/', ' ', $query)`,\n   which collapses newlines to single spaces; this is why a `-- ` line comment\n   must not be used (it would also comment out the closing `) AS qwerty`), and\n   why the working payloads balance the SQL instead.\n\n2. **Broken authorization on the AJAX action.**\n   `SecureHelper::validate_ajax_nonce()` (vulnerable 3.11.2):\n   ```php\n   public static function validate_ajax_nonce($action) {\n       if ( function_exists('current_user_can') && ! current_user_can('manage_options') ) {\n           return false;          // <-- Subscriber hits this: returns, but does NOT die()\n       }\n       ...                          // check_ajax_referer() / wp_send_json_error() never reached for non-admins\n   }\n   ```\n   `ExportController::countContacts()` calls `SecureHelper::validate_ajax_nonce(...)`\n   but **ignores its return value**, so execution continues to `parseArgs()` and\n   the vulnerable query regardless. A Subscriber therefore reaches the SQL with\n   no nonce and no capability check.\n\n**Fix (3.11.3):**\n- `validate_ajax_nonce()` now enforces both a valid nonce (`check_ajax_referer('salesmanago_admin','sm_nonce',false)`)\n  **and** `current_user_can('manage_options')`, calling `wp_send_json_error(['message'=>'forbidden'], 403)`\n  (which dies) on failure — so a Subscriber is blocked with HTTP 403.\n- `parseArgs()` now validates input: `sanitize_text_field()`, strict base64\n  decode, JSON-error checking, and a new `validate_date_format()` for `dateFrom`.\n- `getExportContactsQuery()` now uses `$wpdb->prepare()` with `%s` placeholders:\n  ```php\n  $query .= $this->db->prepare(\" AND A.post_date >= %s AND A.post_date <= %s \", $this->dateFrom, $this->dateTo);\n  ```\n  eliminating the string interpolation.\n\nFixed-version reference: WordPress.org plugin download\n`https://downloads.wordpress.org/plugin/salesmanago.3.11.3.zip`; advisory\n`https://a8cteam5105.wordpress.com/vulnerability/3c7b37ab-b069-4257-82b2-5b4c54f7e503/`.\n\n## Reproduction Steps\n\n1. **Reference script:** `bundle/repro/reproduction_steps.sh` (self-contained;\n   also uses `bundle/repro/docker-compose.yml`).\n2. **What the script does:**\n   - Stands up a real WordPress 6.7 (PHP 8.2) + MySQL 8.0 stack via Docker\n     Compose. Because the sandbox host cannot reach the Docker bridge network\n     (different mount/network namespace), all HTTP interaction is executed\n     **inside** the WordPress container via `docker compose exec`.\n   - Runs the WordPress 5-minute install, seeds a minimal `salesmanago_configuration`\n     option (via `wp eval-file`/`$wpdb`, since the `wordpress:apache` image ships\n     no `mysql` CLI), copies the plugin into the container via `docker cp`\n     (bind mounts do not work across the namespace boundary), activates it, and\n     creates a **Subscriber** user plus two dummy posts.\n   - Logs in as the Subscriber through the real `wp-login.php` cookie flow.\n   - Sends SQL-injection payloads to the real `/wp-admin/admin-ajax.php` endpoint\n     (`action=salesmanago_export_count_contacts`, `data` = base64 JSON with an\n     injected `dateFrom`), measuring HTTP status and response time, and parses the\n     `count` field from the JSON response.\n   - Repeats the entire flow against the **fixed 3.11.3** plugin as a negative\n     control, then compares results and writes `runtime_manifest.json`.\n3. **Expected evidence of reproduction:**\n   - Vulnerable 3.11.2: `SLEEP(0)≈0.07 s`, `SLEEP(1)≈6.07 s`, `SLEEP(2)≈12.01 s`\n     (linear timing → blind SQLi), and `UNION ALL` extraction `count = 2`\n     matching the ground-truth `wp_users` row count.\n   - Fixed 3.11.3: every Subscriber request returns HTTP **403**\n     `{\"success\":false,\"data\":{\"message\":\"forbidden\"}}` with no timing delay and\n     no `count` field.\n\n## Evidence\n\n- **Manifest:** `bundle/repro/runtime_manifest.json` (valid JSON; concrete timing,\n  extraction, and fixed-control values).\n- **Verdict:** `bundle/repro/validation_verdict.json`.\n- **Run logs:** `bundle/logs/vulnerable/run.log`, `bundle/logs/fixed/run.log`.\n- **Per-request captures:** `bundle/logs/vulnerable/req_*.txt`,\n  `bundle/logs/fixed/req_*.txt` (each file is the raw curl body followed by a\n  final line `HTTP_CODE TIME_TOTAL`).\n- **Summaries:** `bundle/logs/vulnerable/summary.csv`, `bundle/logs/fixed/summary.csv`\n  (`name|http|time|count`).\n- **Ground truth:** `bundle/logs/vulnerable/groundtruth_usercount.txt` (`2`).\n\nKey excerpts (vulnerable 3.11.2):\n\n```\n# Time-based blind SQLi — linear scaling (each row sleeps N seconds)\nsleep0 | 200 | 0.075820s | count=1\nsleep1 | 200 | 6.071948s | count=1\nsleep2 | 200 | 12.012303s | count=1\n\n# Data extraction — UNION ALL reads wp_users row count through the injection\nunion_usercount | 200 | 0.009666s | count=2     # matches ground-truth (admin + subscriber = 2)\nunion_nouser    | 200 | 0.017291s | count=0     # WHERE ID=99999 -> 0 rows (control)\nbaseline        | 200 | 0.010917s | count=0     # no injection\n```\n\nRaw vulnerable `union_usercount` response body (the injected `dateFrom` is\nreflected back and `count` is the extracted value):\n```json\n{\"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\",...}\n```\n\nFixed 3.11.3 negative control — Subscriber is blocked, no SQL executes:\n```\nsleep2          | 403 | 0.009891s | count=\nunion_usercount | 403 | 0.011896s | count=\n```\nRaw fixed `union_usercount` response: `{\"success\":false,\"data\":{\"message\":\"forbidden\"}}` (HTTP 403).\n\n**Environment:** Docker `wordpress:6.7-php8.2-apache` + `mysql:8.0`; plugin\nversions 3.11.2 (vulnerable) and 3.11.3 (fixed) downloaded from\n`downloads.wordpress.org`. No sanitizer used; the oracle is wall-clock response\ntime and the JSON `count` field returned by the real product endpoint.\n\n## Recommendations / Next Steps\n\n- **Upgrade** to SALESmanago & Leadoo **3.11.3** or later immediately.\n- **Fix approach (already applied in 3.11.3):** use `$wpdb->prepare()` with\n  placeholders for every user-controlled value, validate/whitelist `dateFrom`\n  with a date-format check, and enforce both a valid nonce and `manage_options`\n  capability on every export AJAX action (die on failure).\n- **Defense in depth:** register export actions only for `manage_options` users\n  via `add_action('wp_ajax_...', ...)` combined with an explicit\n  `current_user_can('manage_options')` gate at the top of each handler; audit all\n  other `$wpdb->get_var/get_row/get_results/query` call sites for raw interpolation.\n- **Testing:** add an integration test that sends a SQLi payload (single quote,\n  `SLEEP`, `UNION`) to each export AJAX action as a Subscriber and asserts a 403\n  and no timing differential; add a static scan forbidding `{$...}` interpolation\n  inside SQL strings passed to `$wpdb`.\n\n## Additional Notes\n\n- **Idempotency confirmation:** `reproduction_steps.sh` was run **twice\n  consecutively**; both runs exited 0 and produced identical confirming results\n  (timing scaling `yes`, `union_usercount == groundtruth == 2`, fixed control\n  `403`). The stack is fully torn down (`docker compose down -v`) at the end of\n  each instance, so each run starts from a clean MySQL volume.\n- **Sandbox constraints handled:** (a) the Docker daemon runs in a separate\n  mount namespace from the shell, so bind mounts see an empty directory — the\n  plugin is injected with `docker cp` instead; (b) the `wordpress:apache` image\n  has no `mysql` client, so DB seeding/reads use `wp eval-file` (pure `$wpdb`);\n  (c) the host cannot reach the container network, so all HTTP requests\n  (`wp-login.php`, `admin-ajax.php`) run inside the container via\n  `docker compose exec curl http://127.0.0.1/...`.\n- **Payload note:** the `SLEEP` payload is balanced as\n  `2000-01-01' OR SLEEP(N) OR '1'='1` rather than using a `-- ` comment, because\n  the plugin's `preg_replace('/\\s\\s+/', ' ', $query)` whitespace collapse turns\n  newlines into spaces and would make `-- ` comment out the closing `) AS qwerty`,\n  breaking the query (observed as `count:null` during development).\n- **Limitations:** WooCommerce is not installed, so the export query's\n  `shop_order` filter matches zero rows by design; the time-based proof relies on\n  the `OR SLEEP(N)` term being evaluated for the default `post`/`page` rows that\n  the `WHERE` scans. The data-extraction proof uses a derived table\n  `(SELECT 1 AS post_date FROM wp_users) A` so the trailing\n  `AND A.post_date <= '<dateTo>'` clause stays valid inside the UNION second\n  SELECT. No destructive SQL was executed; only `SLEEP`, `UNION ALL`, and\n  `COUNT` were used.\n","cve_id":"CVE-2026-10835","source_url":"wordpress.org/plugins/salesmanago","reproduced_at":"2026-07-06T08:32:10.786706+00:00","duration_secs":3002.0,"tool_calls":284,"handoffs":2,"total_cost_usd":6.589147460000003,"agent_costs":{"hypothesis_generator":0.0082152,"judge":0.01404815,"repro":4.248092609999998,"support":0.06120348,"vuln_variant":2.25758802},"cost_breakdown":{"hypothesis_generator":{"accounts/fireworks/models/glm-5p2":0.0082152},"judge":{"gpt-5.4-mini":0.01404815},"repro":{"accounts/fireworks/routers/glm-5p2-fast":4.248092609999998},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.06120348},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":2.25758802}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-06T08:32:36.713198+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":20291,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":12415,"category":"analysis"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":15359,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":25966,"category":"reproduction_script"},{"path":"bundle/repro/docker-compose.yml","filename":"docker-compose.yml","size":879,"category":"other"},{"path":"bundle/logs/run.log","filename":"run.log","size":6955,"category":"log"},{"path":"bundle/artifact_promotion_manifest.json","filename":"artifact_promotion_manifest.json","size":14438,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":2005,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":4390,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1124,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1217,"category":"other"},{"path":"bundle/logs/vulnerable/summary.csv","filename":"summary.csv","size":150,"category":"other"},{"path":"bundle/logs/fixed/summary.csv","filename":"summary.csv","size":143,"category":"other"},{"path":"bundle/logs/vulnerable/req_sleep2.txt","filename":"req_sleep2.txt","size":325,"category":"other"},{"path":"bundle/logs/vulnerable/resp_union_usercount_raw.txt","filename":"resp_union_usercount_raw.txt","size":409,"category":"other"},{"path":"bundle/logs/fixed/req_union_usercount.txt","filename":"req_union_usercount.txt","size":62,"category":"other"},{"path":"bundle/logs/vulnerable/groundtruth_usercount.txt","filename":"groundtruth_usercount.txt","size":2,"category":"other"},{"path":"bundle/logs/vulnerable/req_union_usercount.txt","filename":"req_union_usercount.txt","size":409,"category":"other"},{"path":"bundle/logs/vuln_variant/vulnerable/summary.csv","filename":"summary.csv","size":83,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed/summary.csv","filename":"summary.csv","size":20,"category":"other"},{"path":"bundle/logs/vuln_variant/vulnerable/req_sleep2.txt","filename":"req_sleep2.txt","size":318,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed/req_sleep2.txt","filename":"req_sleep2.txt","size":62,"category":"other"},{"path":"bundle/logs/vuln_variant/run.log","filename":"run.log","size":5572,"category":"log"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":6049,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":1962,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1751,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":11240,"category":"documentation"},{"path":"bundle/logs/vuln_variant/vulnerable/req_baseline.txt","filename":"req_baseline.txt","size":294,"category":"other"}]}