The Exploit
An unauthenticated attacker can craft a malicious URL targeting any WordPress site running a vulnerable Freemius SDK version, then trick a user into clicking the link via social engineering.
GET /wp-admin/admin.php?page=my-plugin&url=javascript:alert(document.domain) HTTP/1.1
Host: target.wordpress.local
The attacker observes the injected JavaScript executes in the victim's browser with the same origin as the WordPress admin panel. The payload appears directly in the iframe src attribute or JavaScript redirect handler without escaping, allowing arbitrary script execution under the victim's session context.
For stored variants: an attacker with access to plugin settings (or a compromised admin account) injects malicious data into a parameter that gets embedded into $query_params or $previous_theme_activation_url. Every user who visits the affected page thereafter executes the payload.
What the Patch Did
Before:
// vendor/freemius/wordpress-sdk/templates/connect.php (Line 839)
location.href = '<?php echo html_entity_decode( $previous_theme_activation_url ); ?>';
// vendor/freemius/wordpress-sdk/templates/checkout.php (Line 186)
src = base_url + '/?<?php echo http_build_query( $query_params ) ?>#' + encodeURIComponent(document.location.href),
// vendor/freemius/wordpress-sdk/includes/managers/class-fs-contact-form-manager.php (Line 62)
return array_merge( $_GET, array_merge( $context_params, array(
'plugin_version' => $fs->get_plugin_version(),
'wp_login_url' => wp_login_url(),
'site_url' => Freemius::get_unfiltered_site_url(),
) ) );
After:
// vendor/freemius/wordpress-sdk/templates/connect.php (Line 839)
location.href = '<?php echo esc_url( $previous_theme_activation_url ); ?>';
// vendor/freemius/wordpress-sdk/templates/checkout/frame.php
wp_enqueue_script( 'freemius-pricing', $pricing_js_url );
wp_add_inline_script( 'freemius-pricing', 'Freemius.pricing.new( ' . json_encode( $pricing_config ) . ' )' );
// vendor/freemius/wordpress-sdk/includes/managers/class-fs-contact-form-manager.php (Line 62)
$sanitized_get = array_map( 'sanitize_text_field', $_GET );
return array_merge( $sanitized_get, array_merge( $context_params, array(
'plugin_version' => $fs->get_plugin_version(),
'wp_login_url' => wp_login_url(),
'site_url' => Freemius::get_unfiltered_site_url(),
) ) );
The patch introduced three distinct security controls across multiple vulnerable templates:
-
esc_url()— WordPress output-escaping function that neutralizes protocol-based XSS (e.g.,javascript:URLs) by stripping disallowed schemes before embedding in HTML or JavaScript attribute context. -
sanitize_text_field()applied to$_GET— WordPress input sanitizer that removes tags and scripts from user-supplied query parameters before they are merged into context arrays. -
wp_add_inline_script()withjson_encode()— WordPress API that properly escapes JavaScript data for safe embedding, replacing unsafe string concatenation of query parameters into iframesrcattributes.
Additional fixes removed entire vulnerable template files (powered-by.php, pricing.php, checkout.php) that relied on constructing iframes through obfuscated string concatenation — a pattern that circumvents WordPress plugin review checks and conflates template rendering with JavaScript output encoding.
Root Cause
CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
The vulnerability stems from a two-step dataflow failure:
-
Unsanitized input entry:
$_GETparameters are read directly into context arrays without callingsanitize_text_field(),wp_sanitize_post_field(), or equivalent input validators. -
Unsafe output in JavaScript context: The unsanitized values are embedded into JavaScript strings or HTML attributes using raw
echoorhttp_build_query()without context-aware escaping. When the user's browser parses thesrcattribute or processes alocation.hrefassignment, the injected payload executes.
The url parameter, plugin_id, and plan_id query parameters are the primary attack vectors. An attacker appends &url=javascript:alert(1) or similar to any page that includes checkout, pricing, or contact form templates. The value flows through array_merge( $_GET, ... ) into http_build_query(), then into an iframe src or JavaScript redirect without escaping. The attacker's javascript: URL or encoded script tag bypasses the encodeURIComponent() call (which only escapes the document location hash, not the query string) and executes when the iframe loads or location changes.
Why It Works
The load-bearing fix is esc_url() on the redirect URL and sanitize_text_field() on the $_GET merge. Removing either one restores exploitability.
esc_url() is essential because it recognizes and strips malicious protocol schemes (javascript:, data:, vbscript:) before they reach a JavaScript execution context. A naive htmlspecialchars() or urlencode() would not catch a javascript: URL because the colon is valid in URLs; only esc_url() knows WordPress's whitelist of safe protocols and strips the rest.
The sanitize_text_field() call is the secondary gate. Even if esc_url() were used inconsistently across all sinks, sanitizing $_GET at ingestion prevents unexpected parameters and obviously-malicious payloads (e.g., <script>) from ever entering the context arrays. This is defence-in-depth: the sanitizer stops crude attacks early, and the output escaper stops protocol-based bypasses downstream.
The engineers also rewrote the template architecture to use wp_add_inline_script() instead of inline script generation. This removes the code smell of string concatenation ('<i' + 'frame') and ensures all data passed to JavaScript is properly JSON-encoded, moving the responsibility for safe escaping from individual template authors to a centralized WordPress API.
Hardening Checklist
-
Sanitize all
$_GET,$_POST, and$_REQUESTat ingestion: usesanitize_text_field(),sanitize_email(), orsanitize_url()the moment you populate any array that will be passed to templates. Neverarray_merge( $_GET, ... )without a precedingarray_map()sanitizer. -
Use
esc_url()for all URLs in HTML attributes and JavaScriptlocation.hrefassignments: do not rely onhtmlspecialchars()or manual escaping; the WordPress function recognizes and strips unsafe protocol schemes. -
Replace inline script generation with
wp_enqueue_script()andwp_add_inline_script(): avoid string concatenation of user data into<script>blocks. When you must embed data, usewp_add_inline_script()withwp_json_encode()to ensure JSON encoding and proper escaping. -
Audit all
http_build_query()calls: verify that every array passed to it has been sanitized at ingestion. Consider usingwp_kses_post()or a dedicated URL builder that enforces parameter whitelisting. -
Use a linter or SAST tool to flag raw
echoof variables in template files: many SAST tools (e.g., PHPCS with WordPress-specific rulesets) can detect unescaped output and flag it as a security issue during code review.