[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$fIv5b65tzMwlFC6lozX8aNGqKTIEsOrNSEvvWz8J13v4":3},{"id":4,"url_slug":5,"title":6,"description":7,"plugin_slug":8,"theme_slug":9,"affected_versions":10,"patched_in_version":11,"severity":12,"cvss_score":13,"cvss_vector":14,"vuln_type":15,"published_date":16,"updated_date":17,"references":18,"days_to_patch":20,"patch_diff_files":21,"patch_trac_url":9,"research_status":29,"research_verified":30,"research_rounds_completed":31,"research_plan":32,"research_summary":33,"research_vulnerable_code":34,"research_fix_diff":35,"research_exploit_outline":36,"research_model_used":37,"research_started_at":38,"research_completed_at":39,"research_error":9,"poc_status":9,"poc_video_id":9,"poc_summary":9,"poc_steps":9,"poc_tested_at":9,"poc_wp_version":9,"poc_php_version":9,"poc_playwright_script":9,"poc_exploit_code":9,"poc_has_trace":30,"poc_model_used":9,"poc_verification_depth":9,"poc_exploit_code_gated":30,"source_links":40},"CVE-2026-2509","page-builder-pagelayer-authenticated-contributor-stored-cross-site-scripting-via-button-widget-custom-attributes","Page Builder: Pagelayer \u003C= 2.0.8 - Authenticated (Contributor+) Stored Cross-Site Scripting via Button Widget Custom Attributes","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.","pagelayer",null,"\u003C=2.0.8","2.0.9","medium",6.4,"CVSS:3.1\u002FAV:N\u002FAC:L\u002FPR:L\u002FUI:N\u002FS:C\u002FC:L\u002FI:L\u002FA:N","Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')","2026-04-07 00:00:00","2026-04-08 13:26:01",[19],"https:\u002F\u002Fwww.wordfence.com\u002Fthreat-intel\u002Fvulnerabilities\u002Fid\u002F915c119d-2bae-4ea6-babb-7e8e99054cd0?source=api-prod",2,[22,23,24,25,26,27,28],"init.php","main\u002Fajax.php","main\u002Fclass.php","main\u002Ffunctions.php","main\u002Fshortcode_functions.php","pagelayer.php","readme.txt","researched",false,3,"# Exploitation Research Plan: CVE-2026-2509 - Pagelayer Stored XSS\n\n## 1. Vulnerability Summary\nThe **Page Builder: Pagelayer** plugin (versions \u003C= 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.\n\n## 2. Attack Vector Analysis\n- **Endpoint**: `wp-admin\u002Fadmin-ajax.php`\n- **Action**: `pagelayer_save_content`\n- **Vulnerable Parameter**: `pagelayer_update_content` (POST parameter, base64 encoded)\n- **Additional Parameters**:\n    - `postID`: (GET parameter) The ID of the post to modify.\n    - `pagelayer_nonce`: (POST\u002FREQUEST parameter) A valid nonce for the `pagelayer_ajax` action.\n- **Authentication**: Required (Contributor or higher). Contributors can modify their own posts, which is sufficient for this attack.\n- **Preconditions**: The attacker must have a post they are authorized to edit.\n\n## 3. Code Flow\n1. **Entry Point**: The AJAX request hits `wp_ajax_pagelayer_save_content` defined in `main\u002Fajax.php`.\n2. **Nonce Validation**: `check_ajax_referer('pagelayer_ajax', 'pagelayer_nonce')` verifies the request integrity.\n3. **Permission Check**: `pagelayer_user_can_edit($postID)` (in `main\u002Ffunctions.php`) checks if the current user has permissions to edit the specified post.\n4. **Decoding**: The content is retrieved from `$_POST['pagelayer_update_content']` and decoded via `base64_decode()`.\n5. **Vulnerable Sink (Filter)**: The plugin calls `$is_xss = pagelayer_xss_content($content)`.\n    - `pagelayer_xss_content` (in `main\u002Ffunctions.php`) scans the string for common event handlers (e.g., `onclick`, `onmouseover`, `onload`).\n    - Because the blocklist is incomplete, it fails to identify and block handlers like `onpointerenter`.\n6. **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\u002Ffalse, the check is bypassed.\n7. **Storage**: The malicious content is saved to the database via `wp_slash($content)` and `wp_update_post(['ID' => $postID, 'post_content' => $content])`.\n\n## 4. Nonce Acquisition Strategy\nThe `pagelayer_ajax` nonce is required to call `pagelayer_save_content`. This nonce is localized for the Pagelayer editor.\n\n1. **Identify Target**: Create or find a post owned by the Contributor user.\n2. **Navigate to Editor**: Use the `browser_navigate` tool to go to the Pagelayer editor for that post:\n   `URL: \u002Fwp-admin\u002Fpost.php?post={POST_ID}&action=pagelayer-editor`\n3. **Extract Nonce**: The nonce is typically stored in a global JavaScript object. Use `browser_eval` to retrieve it:\n   ```javascript\n   \u002F\u002F Check common Pagelayer localization objects\n   window.pagelayer_config?.nonce || window.pagelayer_settings?.nonce || window.pagelayer_ajax\n   ```\n4. **Verification**: Confirm the extracted string is a 10-character alphanumeric nonce.\n\n## 5. Exploitation Strategy\n1. **Login**: Authenticate as a Contributor user.\n2. **Setup**: Create a new post to obtain a `postID`.\n3. **Nonce Extraction**: Follow the \"Nonce Acquisition Strategy\" to get the `pagelayer_ajax` nonce.\n4. **Payload Crafting**: Create a Pagelayer shortcode for a button that includes a malicious event handler in the `custom_attributes` field.\n   - **Payload**: `[pl_button custom_attributes=\"onpointerenter='alert(document.domain)'\" title=\"Hover Me\" link=\"#\" id=\"pl-button-1\"]`\n   - **Alternative (bypass check)**: `[pl_button custom_attributes=\"onfocusin='alert(1)'\" title=\"Tab to Me\" link=\"#\" id=\"pl-button-2\"]`\n5. **Base64 Encoding**: Encode the entire content string to Base64.\n6. **Execution**: Send the malicious AJAX request using `http_request`.\n   - **Method**: `POST`\n   - **URL**: `\u002Fwp-admin\u002Fadmin-ajax.php?postID={POST_ID}`\n   - **Headers**: `Content-Type: application\u002Fx-www-form-urlencoded`\n   - **Body**: \n     ```\n     action=pagelayer_save_content&pagelayer_nonce={NONCE}&pagelayer_update_content={BASE64_PAYLOAD}\n     ```\n7. **Trigger**: Navigate to the public URL of the post and perform the action (hover or focus) to trigger the JavaScript execution.\n\n## 6. Test Data Setup\n1. **User**: Contributor-level user (e.g., username `editor_user`).\n2. **Post**: Create a post via WP-CLI:\n   `wp post create --post_type=page --post_title=\"XSS Test\" --post_status=publish --post_author={USER_ID}`\n3. **Plugin State**: Ensure Pagelayer is active and at version 2.0.8.\n\n## 7. Expected Results\n- The AJAX request should return a JSON response indicating success (e.g., `{\"success\": true}` or similar output from `pagelayer_json_output`).\n- The post content in the database should contain the shortcode with the `onpointerenter` attribute.\n- When viewing the post on the frontend, the HTML source for the button should look like:\n  `\u003Ca ... onpointerenter='alert(document.domain)' ...>Hover Me\u003C\u002Fa>`\n- Hovering over the button in the browser should trigger the `alert`.\n\n## 8. Verification Steps\n1. **Database Check**: Use WP-CLI to verify the content was saved correctly:\n   `wp post get {POST_ID} --field=post_content`\n2. **Frontend Check**: Use `http_request` to fetch the post's permalink and grep for the payload:\n   `curl {PERMALINK} | grep \"onpointerenter=\"`\n3. **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.\n\n## 9. Alternative Approaches\n- **Different Handlers**: If `onpointerenter` is blocked, try:\n    - `onpointerover`\n    - `onanimationstart` (requires CSS animation)\n    - `ontransitionend`\n    - `onauxclick`\n- **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\u002Fshortcode_functions.php`.\n- **Attribute Breakout**: Try breaking out of the attribute context if quotes are not handled: `custom_attributes='href=\"#\" onpointerenter=alert(1)'`.","The Pagelayer plugin for WordPress (\u003C= 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.","\u002F\u002F main\u002Ffunctions.php around line 1290\n\t\u002F\u002F These events not start with on\n\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');\n\t\n\t$not_allowed = implode('|', $not_allowed);","diff -ru \u002Fhome\u002Fdeploy\u002Fwp-safety.org\u002Fdata\u002Fplugin-versions\u002Fpagelayer\u002F2.0.8\u002Finit.php \u002Fhome\u002Fdeploy\u002Fwp-safety.org\u002Fdata\u002Fplugin-versions\u002Fpagelayer\u002F2.0.9\u002Finit.php\n--- \u002Fhome\u002Fdeploy\u002Fwp-safety.org\u002Fdata\u002Fplugin-versions\u002Fpagelayer\u002F2.0.8\u002Finit.php\t2026-02-18 10:29:02.000000000 +0000\n+++ \u002Fhome\u002Fdeploy\u002Fwp-safety.org\u002Fdata\u002Fplugin-versions\u002Fpagelayer\u002F2.0.9\u002Finit.php\t2026-03-10 12:15:42.000000000 +0000\n@@ -5,7 +5,7 @@\n \n define('PAGELAYER_BASE', plugin_basename(PAGELAYER_FILE));\n define('PAGELAYER_PREMIUM_BASE', 'pagelayer-pro\u002Fpagelayer-pro.php');\n-define('PAGELAYER_VERSION', '2.0.8');\n+define('PAGELAYER_VERSION', '2.0.9');\n define('PAGELAYER_DIR', dirname(PAGELAYER_FILE));\n define('PAGELAYER_SLUG', 'pagelayer');\n define('PAGELAYER_URL', plugins_url('', PAGELAYER_FILE));\n\n\u002F\u002F ... (truncated diff from input)\n\ndiff -ru \u002Fhome\u002Fdeploy\u002Fwp-safety.org\u002Fdata\u002Fplugin-versions\u002Fpagelayer\u002F2.0.8\u002Fmain\u002Ffunctions.php \u002Fhome\u002Fdeploy\u002Fwp-safety.org\u002Fdata\u002Fplugin-versions\u002Fpagelayer\u002F2.0.9\u002Fmain\u002Ffunctions.php\n--- \u002Fhome\u002Fdeploy\u002Fwp-safety.org\u002Fdata\u002Fplugin-versions\u002Fpagelayer\u002F2.0.8\u002Fmain\u002Ffunctions.php\t2026-02-18 10:29:02.000000000 +0000\n+++ \u002Fhome\u002Fdeploy\u002Fwp-safety.org\u002Fdata\u002Fplugin-versions\u002Fpagelayer\u002F2.0.9\u002Fmain\u002Ffunctions.php\t2026-03-10 12:15:42.000000000 +0000\n@@ -1290,7 +1290,7 @@\n \t}\n \t\n \t\u002F\u002F These events not start with on\n-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');\n+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');\n \t\n \t$not_allowed = implode('|', $not_allowed);","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 `\u002Fwp-admin\u002Fadmin-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).","gemini-3-flash-preview","2026-04-17 21:07:15","2026-04-17 21:07:44",{"type":41,"vulnerable_version":42,"fixed_version":11,"vulnerable_browse":43,"vulnerable_zip":44,"fixed_browse":45,"fixed_zip":46,"all_tags":47},"plugin","2.0.8","https:\u002F\u002Fplugins.trac.wordpress.org\u002Fbrowser\u002Fpagelayer\u002Ftags\u002F2.0.8","https:\u002F\u002Fdownloads.wordpress.org\u002Fplugin\u002Fpagelayer.2.0.8.zip","https:\u002F\u002Fplugins.trac.wordpress.org\u002Fbrowser\u002Fpagelayer\u002Ftags\u002F2.0.9","https:\u002F\u002Fdownloads.wordpress.org\u002Fplugin\u002Fpagelayer.2.0.9.zip","https:\u002F\u002Fplugins.trac.wordpress.org\u002Fbrowser\u002Fpagelayer\u002Ftags"]