CVE-2026-10835: Verified Repro With Script Download
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://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()
Alternate entry point to the same SQL-injection sink as the parent CVE-2026-10835 claim. The parent repro exploited salesmanago_export_count_contacts (ExportController::countContacts -> ExportModel::getExportContactsQuery(true) -> $wpdb->get_var). This variant uses the sibling AJAX action salesmanago_export_contacts (…
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.
Variant Analysis & Alternative Triggers for CVE-2026-10835
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.phpline 64 — registrationwp_ajax_salesmanago_export_contacts → exportContacts.src/Admin/Controller/ExportController.phplines 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-returnfalsehappens regardless). - line 141:
$this->ExportModel->parseArgs()(sets unsanitizeddateFrom). - line 130:
$query = $this->ExportModel->getExportContactsQuery( false )(the sink;$count=falseaddsLIMIT/OFFSETinstead of theCOUNT(*)wrapper used bycountContacts). - line 131:
$results = $this->db->get_results( $query, ARRAY_A )(the SQL executes here, with the injectedSLEEP()/UNION).
- line 123:
src/Admin/Model/ExportModel.phplines 138–148 —parseArgs(): unsanitized$this->dateFrom = $data->dateFrom.src/Admin/Model/ExportModel.phplines 341–406 —getExportContactsQuery():A.post_date >= '{$this->dateFrom}'(string interpolation).src/Includes/SecureHelper.phplines 81–104 —validate_ajax_nonce(): returnsfalsefor 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).
- Package/component affected:
SALESmanago & LeadooWordPress plugin (slugsalesmanago),src/Admin/Controller/ExportController.php(exportContacts()) sharingsrc/Admin/Model/ExportModel.php(parseArgs()+getExportContactsQuery()) andsrc/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_contactsaction 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_contactsaction —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:
fullfor 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 echoedcountfield; that specific direct-echo extraction is not available onexportContacts(rows are not echoed), but the same data is extractable via time-based/boolean blind SQLi (the demonstratedSLEEPprimitive 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
- Reference script:
bundle/vuln_variant/reproduction_steps.sh(self-contained; reuses the plugin zips underbundle/artifacts/andbundle/repro/wp-cli.phar, and writes its owndocker-compose.yml). - 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_configurationoption (soExportController's constructor completes and thewp_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 realwp-login.phpcookie flow. - Sends time-based blind SQLi payloads to the variant endpoint
action=salesmanago_export_contactswithdata = base64(JSON{dateFrom:"2000-01-01' OR SLEEP(N) OR '1'='2", packageCount:1}), measuring HTTP status and response time. The payload keepsSLEEP(N)as a standalone OR operand (so MySQL cannot skip it) while the constant-FALSE third OR operand yields 0 matching rows, soexportContactsskips 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-runsummary.csv, andrun.logunderbundle/logs/vuln_variant/.
- 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
- 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.csvsleep0|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.csvsleep2|403|0.074118 - Vulnerable
sleep2response body (bundle/logs/vuln_variant/vulnerable/req_sleep2.txt):
The{"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.012328status:"done"+count:0confirms 0 rows were returned (the 0-rows payload worked) and the 12.01s delay is purely the in-DBMSSLEEP(2). - Vulnerable
baselineresponse 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
sleep2response body (bundle/logs/vuln_variant/fixed/req_sleep2.txt):{"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) fromdownloads.wordpress.org. Exact source identity (zip SHA-256) inbundle/vuln_variant/source_identity.json.
Recommendations / Next Steps
- The 3.11.3 fix already covers this variant (authorization die +
manage_optionsgate + input validation +$wpdb->prepare()). No additional code change is required forsalesmanago_export_contactsspecifically. - Defense in depth (recommended for the maintainer): add an integration test
that sends a SQLi payload (single quote,
SLEEP,UNION) to everywp_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 forbiddingwp_ajax_*handlers that do not begin with amanage_options(or equivalent) capability check. - Centralize authorization: the pre-existing typo
(
validate_ajax_nonce('salesmanago_count_contacts')inexportContacts) shows per-handler action-name strings are error-prone; consider deriving the expected action from$_REQUEST['action']insidevalidate_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.shwas 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 scalingyes, fixed403). 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'='2payload produced no timing differential because MySQL constant-folded the constant-FALSEANDand skipped evaluatingSLEEP. The correctedOR SLEEP(N) OR '1'='2payload keepsSLEEPas 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 bycountContactsandexportContacts). The events path uses WooCommerce's parameterizedwc_get_orders; the products path uses int-castLIMIT/OFFSET; all other$wpdbsites are either alreadyprepare()-d or use constant queries; the REST/cart/cron surfaces do not reach a raw-SQL sink. These are documented inpatch_analysis.mdrather than re-run as "attempts," since they are rule-outs by code inspection, not runtime variants.
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.