Page Builder: Pagelayer <= 2.0.8 - Authenticated (Contributor+) Stored Cross-Site Scripting via Button Widget Custom Attributes
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:NTechnical Details
What Changed in the Fix
Changes introduced in v2.0.9
Source Code
WordPress.org SVN# 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 thepagelayer_ajaxaction.
- 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
- Entry Point: The AJAX request hits
wp_ajax_pagelayer_save_contentdefined inmain/ajax.php. - Nonce Validation:
check_ajax_referer('pagelayer_ajax', 'pagelayer_nonce')verifies the request integrity. - Permission Check:
pagelayer_user_can_edit($postID)(inmain/functions.php) checks if the current user has permissions to edit the specified post. - Decoding: The content is retrieved from
$_POST['pagelayer_update_content']and decoded viabase64_decode(). - Vulnerable Sink (Filter): The plugin calls
$is_xss = pagelayer_xss_content($content).pagelayer_xss_content(inmain/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.
- Conditional Block: If
pagelayer_user_can_add_js_content()(which checks for theunfiltered_htmlcapability) returns false AND$is_xssis non-empty, the save is aborted. Since the bypass allows$is_xssto remain empty/false, the check is bypassed. - Storage: The malicious content is saved to the database via
wp_slash($content)andwp_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.
- Identify Target: Create or find a post owned by the Contributor user.
- Navigate to Editor: Use the
browser_navigatetool to go to the Pagelayer editor for that post:URL: /wp-admin/post.php?post={POST_ID}&action=pagelayer-editor - Extract Nonce: The nonce is typically stored in a global JavaScript object. Use
browser_evalto retrieve it:// Check common Pagelayer localization objects window.pagelayer_config?.nonce || window.pagelayer_settings?.nonce || window.pagelayer_ajax - Verification: Confirm the extracted string is a 10-character alphanumeric nonce.
5. Exploitation Strategy
- Login: Authenticate as a Contributor user.
- Setup: Create a new post to obtain a
postID. - Nonce Extraction: Follow the "Nonce Acquisition Strategy" to get the
pagelayer_ajaxnonce. - Payload Crafting: Create a Pagelayer shortcode for a button that includes a malicious event handler in the
custom_attributesfield.- 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"]
- Payload:
- Base64 Encoding: Encode the entire content string to Base64.
- 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}
- Method:
- 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
- User: Contributor-level user (e.g., username
editor_user). - Post: Create a post via WP-CLI:
wp post create --post_type=page --post_title="XSS Test" --post_status=publish --post_author={USER_ID} - 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 frompagelayer_json_output). - The post content in the database should contain the shortcode with the
onpointerenterattribute. - 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
- Database Check: Use WP-CLI to verify the content was saved correctly:
wp post get {POST_ID} --field=post_content - Frontend Check: Use
http_requestto fetch the post's permalink and grep for the payload:curl {PERMALINK} | grep "onpointerenter=" - Browser Verification: Use
browser_navigateto the post andbrowser_evalto check if thealertor a custom global variable set by the payload exists.
9. Alternative Approaches
- Different Handlers: If
onpointerenteris blocked, try:onpointeroveronanimationstart(requires CSS animation)ontransitionendonauxclick
- 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)'.
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
@@ -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) @@ -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.