CVE-2026-2509

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

mediumImproper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
6.4
CVSS Score
6.4
CVSS Score
medium
Severity
2.0.9
Patched in
2d
Time to patch

Description

The Page Builder: Pagelayer plugin for WordPress is vulnerable to Stored Cross-Site Scripting via the Button widget's Custom Attributes field in all versions up to, and including, 2.0.8. This is due to an incomplete event handler blocklist in the 'pagelayer_xss_content' XSS filtering function, which blocks common, but not all, event handlers. This makes it possible for authenticated attackers, with Contributor-level access and above, to 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: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<=2.0.8
PublishedApril 7, 2026
Last updatedApril 8, 2026
Affected pluginpagelayer

What Changed in the Fix

Changes introduced in v2.0.9

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Exploitation Research Plan: CVE-2026-2509 - Pagelayer Stored XSS ## 1. Vulnerability Summary The **Page Builder: Pagelayer** plugin (versions <= 2.0.8) contains a stored cross-site scripting (XSS) vulnerability. The plugin implements a custom XSS filtering function, `pagelayer_xss_content`, inten…

Show full research plan

Exploitation Research Plan: CVE-2026-2509 - Pagelayer Stored XSS

1. Vulnerability Summary

The Page Builder: Pagelayer plugin (versions <= 2.0.8) contains a stored cross-site scripting (XSS) vulnerability. The plugin implements a custom XSS filtering function, pagelayer_xss_content, intended to sanitize page content before saving. However, this function uses an incomplete blocklist for HTML event handlers. Authenticated users with Contributor-level access or higher can bypass this filter by using less common event handlers (e.g., onpointerenter, onfocusin) within the "Custom Attributes" field of the Button widget. This allows for the injection of arbitrary JavaScript that executes when a user views the affected page.

2. Attack Vector Analysis

  • Endpoint: wp-admin/admin-ajax.php
  • Action: pagelayer_save_content
  • Vulnerable Parameter: pagelayer_update_content (POST parameter, base64 encoded)
  • Additional Parameters:
    • postID: (GET parameter) The ID of the post to modify.
    • pagelayer_nonce: (POST/REQUEST parameter) A valid nonce for the pagelayer_ajax action.
  • Authentication: Required (Contributor or higher). Contributors can modify their own posts, which is sufficient for this attack.
  • Preconditions: The attacker must have a post they are authorized to edit.

3. Code Flow

  1. Entry Point: The AJAX request hits wp_ajax_pagelayer_save_content defined in main/ajax.php.
  2. Nonce Validation: check_ajax_referer('pagelayer_ajax', 'pagelayer_nonce') verifies the request integrity.
  3. Permission Check: pagelayer_user_can_edit($postID) (in main/functions.php) checks if the current user has permissions to edit the specified post.
  4. Decoding: The content is retrieved from $_POST['pagelayer_update_content'] and decoded via base64_decode().
  5. Vulnerable Sink (Filter): The plugin calls $is_xss = pagelayer_xss_content($content).
    • pagelayer_xss_content (in main/functions.php) scans the string for common event handlers (e.g., onclick, onmouseover, onload).
    • Because the blocklist is incomplete, it fails to identify and block handlers like onpointerenter.
  6. Conditional Block: If pagelayer_user_can_add_js_content() (which checks for the unfiltered_html capability) returns false AND $is_xss is non-empty, the save is aborted. Since the bypass allows $is_xss to remain empty/false, the check is bypassed.
  7. Storage: The malicious content is saved to the database via wp_slash($content) and wp_update_post(['ID' => $postID, 'post_content' => $content]).

4. Nonce Acquisition Strategy

The pagelayer_ajax nonce is required to call pagelayer_save_content. This nonce is localized for the Pagelayer editor.

  1. Identify Target: Create or find a post owned by the Contributor user.
  2. Navigate to Editor: Use the browser_navigate tool to go to the Pagelayer editor for that post:
    URL: /wp-admin/post.php?post={POST_ID}&action=pagelayer-editor
  3. Extract Nonce: The nonce is typically stored in a global JavaScript object. Use browser_eval to retrieve it:
    // Check common Pagelayer localization objects
    window.pagelayer_config?.nonce || window.pagelayer_settings?.nonce || window.pagelayer_ajax
    
  4. Verification: Confirm the extracted string is a 10-character alphanumeric nonce.

5. Exploitation Strategy

  1. Login: Authenticate as a Contributor user.
  2. Setup: Create a new post to obtain a postID.
  3. Nonce Extraction: Follow the "Nonce Acquisition Strategy" to get the pagelayer_ajax nonce.
  4. Payload Crafting: Create a Pagelayer shortcode for a button that includes a malicious event handler in the custom_attributes field.
    • Payload: [pl_button custom_attributes="onpointerenter='alert(document.domain)'" title="Hover Me" link="#" id="pl-button-1"]
    • Alternative (bypass check): [pl_button custom_attributes="onfocusin='alert(1)'" title="Tab to Me" link="#" id="pl-button-2"]
  5. Base64 Encoding: Encode the entire content string to Base64.
  6. Execution: Send the malicious AJAX request using http_request.
    • Method: POST
    • URL: /wp-admin/admin-ajax.php?postID={POST_ID}
    • Headers: Content-Type: application/x-www-form-urlencoded
    • Body:
      action=pagelayer_save_content&pagelayer_nonce={NONCE}&pagelayer_update_content={BASE64_PAYLOAD}
      
  7. Trigger: Navigate to the public URL of the post and perform the action (hover or focus) to trigger the JavaScript execution.

6. Test Data Setup

  1. User: Contributor-level user (e.g., username editor_user).
  2. Post: Create a post via WP-CLI:
    wp post create --post_type=page --post_title="XSS Test" --post_status=publish --post_author={USER_ID}
  3. Plugin State: Ensure Pagelayer is active and at version 2.0.8.

7. Expected Results

  • The AJAX request should return a JSON response indicating success (e.g., {"success": true} or similar output from pagelayer_json_output).
  • The post content in the database should contain the shortcode with the onpointerenter attribute.
  • When viewing the post on the frontend, the HTML source for the button should look like:
    <a ... onpointerenter='alert(document.domain)' ...>Hover Me</a>
  • Hovering over the button in the browser should trigger the alert.

8. Verification Steps

  1. Database Check: Use WP-CLI to verify the content was saved correctly:
    wp post get {POST_ID} --field=post_content
  2. Frontend Check: Use http_request to fetch the post's permalink and grep for the payload:
    curl {PERMALINK} | grep "onpointerenter="
  3. Browser Verification: Use browser_navigate to the post and browser_eval to check if the alert or a custom global variable set by the payload exists.

9. Alternative Approaches

  • Different Handlers: If onpointerenter is blocked, try:
    • onpointerover
    • onanimationstart (requires CSS animation)
    • ontransitionend
    • onauxclick
  • Widget Variations: If the Button widget is specifically patched, investigate other widgets that allow "Custom Attributes," such as the Title or Icon widgets, as they likely share the same rendering logic in main/shortcode_functions.php.
  • Attribute Breakout: Try breaking out of the attribute context if quotes are not handled: custom_attributes='href="#" onpointerenter=alert(1)'.
Research Findings
Static analysis — not yet PoC-verified

Summary

The Pagelayer plugin for WordPress (<= 2.0.8) is vulnerable to Stored Cross-Site Scripting because its 'pagelayer_xss_content' function uses an incomplete blocklist for HTML event handlers. This allows authenticated attackers with Contributor-level access to bypass sanitization by injecting less common event handlers, such as 'onpointerenter', into the Button widget's Custom Attributes field.

Vulnerable Code

// main/functions.php around line 1290
	// 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 = implode('|', $not_allowed);

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/pagelayer/2.0.8/init.php /home/deploy/wp-safety.org/data/plugin-versions/pagelayer/2.0.9/init.php
--- /home/deploy/wp-safety.org/data/plugin-versions/pagelayer/2.0.8/init.php	2026-02-18 10:29:02.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/pagelayer/2.0.9/init.php	2026-03-10 12:15:42.000000000 +0000
@@ -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));

// ... (truncated diff from input)

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/pagelayer/2.0.8/main/functions.php /home/deploy/wp-safety.org/data/plugin-versions/pagelayer/2.0.9/main/functions.php
--- /home/deploy/wp-safety.org/data/plugin-versions/pagelayer/2.0.8/main/functions.php	2026-02-18 10:29:02.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/pagelayer/2.0.9/main/functions.php	2026-03-10 12:15:42.000000000 +0000
@@ -1290,7 +1290,7 @@
 	}
 	
 	// These events not start with on
-t$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');
+t$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);

Exploit Outline

The exploit target is the `pagelayer_save_content` AJAX action. An authenticated attacker with Contributor-level access or higher can acquire a valid `pagelayer_ajax` nonce by loading the Pagelayer editor for a post they own. The attacker then crafts a Button widget shortcode (e.g., `[pl_button]`) where the `custom_attributes` parameter contains a JavaScript event handler that is missing from the plugin's blocklist, such as `onpointerenter='alert(1)'`. This shortcode is Base64 encoded and sent via a POST request to `/wp-admin/admin-ajax.php`. Because the filter function `pagelayer_xss_content` fails to detect the bypass handler, the malicious content is saved to the database. The script executes whenever any user views the public page and interacts with the button (e.g., hovering their mouse).

Check if your site is affected.

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