上周末打了由ROIS举办的RCTF2020,zsx出了道php 反序列化的签到题-swoole(一下午出完了),然而我肝了两天,但是没有做出来。
这道题和以往找thinkphp的pop gadget不太一样,以往的难度是php文件数量多,方法多,硬着头皮找基本都能找到能够可控的参数。(上述类型的机械劳动应该能用程序搞定,codeql快支持全世界最好的语言呀,或者用AST自己撸一个,甚至有篇paper专门研究了如何自动找gadget)(此处挖坑加一)
这次php文件不太多,但是构造的角度还是非常巧妙的。但是我思路不够开阔,没能把两部分联系起来。
但是真正的强者wupco,还是搞出了RCE。
我觉得这个题目可以作为面试题目:谈谈你印象最深的一道CTF题目?的候选答案。
总结
array_walk
第一个参数可以是$object
- php自定义函数的参数数量可以多传但是不能少传
- 可以把
s
换成S
然后\x00
换成\00
- 反序列化也可以通过外带的方式读文件(比如curl上传文件,mysql客户端读文件)
- 可以通过反射修改对象的属性
题目介绍
题目直接给出了源码
1 | #!/usr/bin/env php |
代码非常简短,而且给了phpinfo和flag的位置。通过查看phpinfo发现swoole对应的版本是4.4.18。swoole是个扩展,然后还有个library有php代码。
分析
FBI警告 下面的分析有部分是在做题的时候真正分析的,有的是看了writeup之后分析的(这部分可能有种事后诸葛亮的感觉)
绕过\x00
的前一段时间刚在P神的小密圈见到,就是将s
换成S
,\x00
换成 \00
就行了。这个绕过不是重点。
直接将传的$code
进行了反序列化,然后直接执行了$a()
。直接调用对象会触发__invoke()
这个魔术方法。在library里面搜索__invoke
能够找到一处。
这里我们可以发现,__object
是可控的,也就是$object
是可控的但是...$arguments
是空的。到这一步我们有能力调用一次$func()
。根据 https://www.php.net/manual/zh/functions.variable-functions.php $func
可以是
- 字符串,比如
$x='phpinfo';$x();
- 可以是个数组
- 可以调用静态方法,比如
["A", "static_method"]
- 也可以调用一个对象的方法,比如
[$obj, "method"]
, 如果传[$obj, "setatic_method"]
也是可以调用静态方法的。
- 可以调用静态方法,比如
- 还可以是个闭包函数
$f = function () {echo 2333;}; $f();
$f = create_function('', 'echo 2333;');$f();
比较遗憾的是闭包函数不支持序列化和反序列化。
对第一种情况进行序列化,显示出PHP Warning: Uncaught Exception: Serialization of 'Closure' is not allowed
create_function
创造出的匿名函数是有函数名的\x00lambda_02
但是这个也没在服务器那边创建,所以也不能用。
如果只传一个字符串,干不了啥事,连phpinfo
都显示不出来,执行了phpinfo
只会在控制台显示,不会在响应里面显示。
所以我们貌似只剩下了一种选择,传个数组[$obj, "method"]
, 唯一的限制是没有参数。其实我这里思维狭隘了,以为得有__invoke
才行,不是这样的,如果$code
直接反序列化出来的是一个数组,然后再执行,也是可以的。这两者的效果是一样的,但是我用__invoke
多了一步。
根据上面的分析,下一步就是搜索不带参数的函数。
public function \S+\(\)
有100多个,通过一个个筛查,排除不调用其他函数的,找到了这么几个有用的。
ConnectionPool.php
fill()
get()
Server.php
start()
Handler.php
exec()
PDOProxy.php
reconnect()
上面可能会漏掉,可变参数...$arg
这种形式。
所以再搜索一次public function \S+\(.*\.\.\.\$\S+\)
这样的没有
还得再搜一次__call
1 | public function __call(string $name, array $arguments) |
大部分的__call
是这种形式的。
如果调用$obj->xxx()
会转化成$this->__object->xxx()
回去找$this->__object
这个对象有没有xxx
这个方法,而不是找它的xxx
属性,如果是用属性当做函数名得这样写:($this->__object->{$name})(...$arguments);
有__call
方法的类有
PDOProxy
MysqliProxy
MysqliStatementProxy
PDOStatementProxy
刚开始看的是Handler.php
实现了CURL
,又给出了flag的位置,本来打算看能不能利用它进行文件上传。
控制上传的地方有两处
第一个需要传的$this->infile
是个resource
类型的不支持反序列列化。第二个CURLFile
也不支持反序列化。然后又试了直接传xxx=@/flag
也不行。文件上传至此就夭折了。
继续看handler.php
发现有两个可控函数headFunction
和 readFunction
RCE
但是第一个参数是个object
,当时没找到一个函数能够这样搞,只能想到一个create_function
但是要求第一个参数是字符串,而且这个字符串也有要求得是''
, $x,$y
这种。但是这个$this
没有toString
的方法 。 然后就凉了,这个有办法搞吗?确实有办法搞,wupco就是找到了一个函数。不清楚他是怎么找的,我能不能Fuzz一下呢。
fuzz 脚本
9test.php
1 | class A{ |
fuzz.py
1 | #!/usr/bin/env python3 |
下面是fuzz脚本跑出来的结果
在去掉参数数目和类型不对后,然后在400多行里面筛选出了这些
PHP Warning: preg_replace_callback(): Requires argument 2, ‘123444’, to be a valid callback
PHP Warning: mb_ereg_replace_callback() expects parameter 2 to be a valid callback
PHP Warning: array_walk() expects parameter 2 to be a valid callback, function ‘123444’ not found
PHP Warning: array_walk_recursive() expects parameter 2 to be a valid callback, function ‘123444’ not found
PHP Warning: array_intersect_ukey() expects parameter 3 to be a valid callback
PHP Warning: array_uintersect() expects parameter 3 to be a valid callback
PHP Warning:array_uintersect_assoc() expects parameter 3 to be a valid callback
PHP Warning: array_uintersect_assoc() expects parameter 3 to be a valid callback
PHP Warning: array_intersect_uassoc() expects parameter 3 to be a valid callback
PHP Warning: array_diff_ukey() expects parameter 3 to be a valid callback
PHP Warning: array_udiff() expects parameter 3 to be a valid callback
PHP Warning: array_udiff_assoc() expects parameter 3 to be a valid callback
PHP Warning: array_diff_uassoc() expects parameter 3 to be a valid callback
我们只能控制第二个参数,所以着重检查了preg_replace_callback
, mb_ereg_replace_callback
, array_walk()
, array_walk_recursive()
preg_replace_callback
和mb_ereg_replace_callback
不行 第一个参数要能转换成string
PHP Catchable fatal error: Object of class A could not be converted to string
array_walk
array_walk($this, "system", 1234);
PHP Warning: system() expects at most 2 parameters, 3 given
貌似只是因为参数数目不对,查看
array_walk
文档典型情况下
callback
接受两个参数。array
参数的值作为第一个,键名作为第二个如果提供了可选参数
userdata
,将被作为第三个参数传递给 callbackfuncname
如果
callback
函数需要的参数比给出的多,则每次 array_walk() 调用callback
时都会产生一个 E_WARNING 级的错误这里虽然传的是数组,但是可能
array_walk
把object的变量名当成了key,变量值当成了value。
找个能够执行函数,并且是三个参数的,可以去翻文档,或者再fuzz一波,这里选择翻文档,可以找到
exec ( string
$command
[, array&$output
[, int&$return_var
]] ) : string
所以换成
array_walk($this, "exec", 1234);
PHP Warning: exec(): Cannot execute a blank command
这表示有变量没有值,导致传的是空命令,我们做一个小验证
1 |
|
是能够执行的。
你以为到这里就结束了,我本来以为到这里就结束了,但是看writeup 发现用了两次array_walk
这是为啥呢?
好像由于命名空间的问题,在这里进行了hook,https://github.com/swoole/swoole-src/blob/f1a66611d8779114afbb0638d18c528689194ac8/swoole_runtime.cc#L1270 调用exec实际调的是swoole_exec
如果一个不成功,他产生的不是warning
,而是fatal error
就不往下执行了。
不知道array_walk 第一个遍历的是啥,看了一下顺序貌似是变量定义的顺序。也许我们在反序列化的时候将要执行的变量放到第一个就能执行了。试试。不行,是根据定义的时候的顺序决定的。Handler
第一个变量是clinet
不能随便赋值成要执行的命令,后面会用到他。所以还得想个办法让他能够遍历到我们控制的那个属性。wupco是再次利用array_walk
1 | array_walk($this, array_walk, 123); |
遇到第一个属性client
的时候
array_walk($client_obj, “client”, 123); 由于client这个callback不存在,只会产生
PHP Warning: array_walk() expects parameter 2 to be a valid callback, function ‘client’ not found
如果遇到了某个属性的值不是Array
或不是object
就会报
PHP Warning: array_walk() expects parameter 1 to be array
这两种warning
都不影响继续运行下去。
他在给对象额外设置了一个
1 | $this->exec = array("whoami") |
当遍历到这个属性的时候会调用
1 | array_walk(array("whoami"), "exec", 123) |
然后出发
1 | exec("whoami",0,123) |
就执行了whoami
妙
能不能用system呢?也是可以的,可以用call_user_func
来助攻, 他的参数数量没有限制。
1 | class B{ |
会变成调用
1 | call_user_func("system", "whoami", 123); |
最后一步调用
1 | system("whoami", 123); |
哦,看起来好像也可以,本地执行也可以,然后和wupco交流的时候,他发现他那边不行,我觉得可能是php版本的问题,然后在php7.4.6上面跑了一下没有空格的能执行成功,但是有空格的那个ls -al
竟然 segment fault
了。
然后就夭折了。
出错的地方不是在赋值那里,是在array_walk 那句出现了问题,后面涉及到了php 源码啥的,目前还不会就跟不动了。wupco调了一下core dump 给了我一个函数名zend_verify_ref_assignable_zval
我看了一下php-src 也没看太懂是为啥。这个就到这里吧。
那么到底能不能执行system呢?
xxxx($value, $key, xxxx)
可控的是$value
和 $key
而且key
不能有空格,所以还是最好$key
来当callback
,$value
来当参数。
所以我们的目标是找一个函数第二个参数是callback
, 并且能接受三个参数的。好像又会到了最初的起点。所以可以用array_walk
。 但是这次条件比上次宽了一点,第一个$value
参数的类型我们可以控制,array的回调函数有很多,这里我选择去翻了文档。找到了一个
array_filter ( array
$array
[, callable$callback
[, int$flag
= 0 ]] )
1 |
|
终于能执行system
预期做法
感觉预期做法也很巧妙。上面也提到了题目给出了flag的位置,我只想到了可能利用curl
上传文件。直到放出hint,给了个github
的issue。https://github.com/swoole/library/issues/34
看到这个issue 恍然大悟,也可以用mysql 客户端读文件,而且可以设置一些options, 根据文档,设置https://www.php.net/manual/zh/mysqli.options.php
MYSQLI_OPT_LOCAL_INFILE
启用或禁用 LOAD LOCAL INFILE 语句
MYSQLI_INIT_COMMAND
成功建立 MySQL 连接之后要执行的 SQL 语句
但是有个限制
mysqli_options() 需要在 mysqli_init() 函数之后、 mysqli_real_connect() 函数之前被调用
当时审的代码是最新的代码,那里面的mysqli已经按照zsx提的issue改过来了。
通过审计上述列出的无参函数,可以在connectionpool中 发现下面的代码。
connectionpool.php
1 | public function get() |
1 | protected function make(): void |
在make方法里面有个new可以控制class name 和 参数。所以我们可以通过调用connectionpool的get方法得到一个new生成的对象
再去找__construct
mysqlipool.php
1 | public function __construct(MysqliConfig $config, int $size = self::DEFAULT_SIZE) |
这是mysqlipool 的构造函数。但是题目对应的是4.4.18 还没有按照zsx的改过来。当时想当然以为zsx自己改过来了。
所以我们可以找一条链,通过调用connectionpool的get方法,new一个mysqlipool 出来。当时以为这样就是万事大吉了。
但是再仔细一看,mysqli的构造函数只是生成了一个回调函数,当真正有方法调用的时候才会调用那个回调函数,去连mysql服务器。
去跟一下mysqlipool 的parent 也就是connectionpool构造方法
1 | public function __construct(callable $constructor, int $size = self::DEFAULT_SIZE, ?string $proxy = null) |
只是进行了赋值,也没有执行传进去的回调函数。
想找个方法能$obj->get()->get()
又审了其他无参的函数。
mysqliproxy
的__call
方法
1 | public function __call(string $name, array $arguments) |
以及 reconnect
函数
1 | public function reconnect(): void |
parent::__construct($constructor());
这步会对$this->__object
进行重新赋值。
所以一条链浮现在了眼前
调用mysqliproxy
的get
方法,get
方法不存在,然后调用__call
有三次调用$ret = @$this->__object->{$name}(...$arguments);
通过控制第一次的$ret = @$this->__object->get(...$arguments);
返回false,然后调用reconnect
对$this->__object
进行重新赋值($this->construct
可以为[connectionpool, 'get']
),执行完reconnect
$this->object
就会变成new mysqlipool($config)
这样就再次来到$ret = @$this->__object->get(...$arguments);
就会触发。
看到这里已经开始窃喜了,但是冷静下来一看不对呀, (!preg_match(static::IO_METHOD_REGEX, $name))
这里对方法名进行了检查,而且是静态变量,不能通过反序列化进行修改。
搞到这里就有点黔驴技穷了。
那么到底如何触发$obj->get()->get()
呢?
看了writeup 才恍然大悟。还记得handler.php
里面的两个函数吗?headerFunction
和 readFunction
首先你需要知道自定义函数的参数能多传,不能少传
1 |
|
这个是能执行的。
虽然圈出来的都有参数,但是我们调用这种[$obj,'xxxx']('xx', 'xxx')
($obj.xxxx
是无参函数) 这种也是可以的。
下面的$obj
是mysqliproxy
对象
控制headerFunction
为[$obj, "reconnect"]
会对__object
赋值为new mysqlipool
再控制readerFunctioin
为[$obj, "get"]
但是由于没有mysqliproxy
没有get
方法就会调用__call
就会调用
$ret = @$this->__object->get(...$arguments)
这样就触发了。
妙
这里还有个地方connectionpool里面的$this->pool
用的是chanle
这个不支持反序列化,需要找个替代,这里可以用ArrayObject
代替因为他对应的方法也都有,zsx用的是SplDoublyLinkedList
这是php自带的一个。
EXP
上面的分析是基于mysqli,下面的exp也是基于mysqli的,虽然在题目中不能用,但是可以用在最新的swoole。
反序列化的构造payload以前都是把类单独摘出来,然后往里面塞参数。看了zsx的writeup又学到了一种,可以在目标环境中生成类,然后通过反射修改属性,这样可能更方便一点。
1 |
|
在构造上面exp的时候遇见了几个坑
port 要设置成整数,这个通过报错容易发现
ArrayObject 不能直接写成
1
2
3class ArrayObject{
protected $array = [];
}这是上面序列化的结果
O:18:"Swoole\ArrayObject":1:{s:8:"\00*\00array";a:0:{}}
这个才是真正的序列化的结果
C:18:"Swoole\ArrayObject":6:{a:0:{}}
而且在凑这个的时候发下了他的一个bug
如果返回除了字符串或
NULL
之外的其他类型,将抛出 Exception。代码里面强行返回了一个SpringObject,会导致在序列化的时候出现
Fatal error: Uncaught Exception: Swoole\ArrayObject::serialize() must return a string or NULL
size的值要设置的大一些, 因为有for循环会多次调用num值会变大,如果num值大于了size就会pop空,找这个问题在哪,找的我怀疑狗生。
1
2
3
4#0 @swoole-src/library/core/Database/MysqliProxy.php(88): Swoole\ObjectProxy->__construct(NULL)
#1 @swoole-src/library/core/Curl/Handler.php(759): Swoole\Database\MysqliProxy->reconnect(Object(Swoole\Curl\Handler), 'server: SimpleH...')
#2 @swoole-src/library/core/Curl/Handler.php(156): Swoole\Curl\Handler->execute()
#3 /app/server.php(38): Swoole\Curl\Handler->exec()
另外在构造的时候忽然想到
[$mysqliproxy, 'reconnect']
和[$mysqliproxy, 'get']
中的$mysqliproxy
反序列化的时候会不会被序列化成两个不同的对象,如果被反序列化成了两个不同的对象就GG了。用下面的程序模拟了一下。
1 |
|
输出是
1 | O:1:"A":3:{s:1:"c";s:1:"x";s:2:"f1";O:1:"B":1:{s:1:"h";s:4:"haha";}s:2:"f2";r:3;} |
后来查到了这个 http://www.phpinternalsbook.com/php5/classes_objects/serialization.html
As objects in PHP exhibit a reference-like behavior
serialize
also makes sure that the same object occurring twice will really be the same object on unserialization