4月12号,ThinkPHP官方团队发布“ThinkPHP5.0.17&5.1.9版本发布——包含安全更新”通知,提醒用户第一时间更 新框架版本,在这次更新中,包含了由360企业安全集团代码卫士团队报送的一个高危安全漏洞。本文针对该漏洞 的技术细节进行分析。
ThinkPHP是一个免费开源的,快速、简单的面向对象的轻量级PHP开发框架,是为了敏捷WEB应用开发和简化企 业应用开发而诞生的。ThinkPHP从诞生的12年间一直秉承简洁实用的设计原则,在保持出色的性能和至简的代码 的同时,也注重易用性。目前ThinkPHP框架是国内使用量最大的框架之一,国内用户量众多。近日,360企业安全 集团代码卫士团队安全研究人员发现该框架V5.1.7-V5.1.8 版本在底层数据处理驱动解析数据的时候存在缺陷,一 定场景下,攻击者可以通过构造恶意数据包利用SQL注入的方式获取用户数据库内容。360企业安全集团代码卫士 团队已第一时间和ThinkPHP团队进行沟通修复,建议相关用户及时更新官方发布的新版本。
注:改漏洞ThinkPHP官方团队在报送当天(2018-04-06)紧急进行了修复处理,详细请参考:
https://github.com/top-think/framework/commit/39bb0fe6d50ee77e0779f646b10bce08c442a5e3
以下漏洞分析基于ThinkPHP V5.1.8(2018-04-05未更新版)
这里我们主要跟进分析执行update操作的过程。为了方便理解,先直接放出函数的调用栈。
Mysql.php:200, think/db/builder/Mysql->parseArrayData() Builder.php:147, think/db/Builder->parseData() Builder.php:1139, think/db/Builder->update() Connection.php:1149, think/db/Connection->update() Query.php:2571, think/db/Query->update() Index.php:18, app/index/controller/Index->testsql() Container.php:285, ReflectionMethod->invokeArgs() Container.php:285, think/Container->invokeReflectMethod() Module.php:139, think/route/dispatch/Module->run() Url.php:31, think/route/dispatch/Url->run() App.php:378, think/App->think/{closure}() Middleware.php:119, call_user_func_array: {C:/wamp64/www/think518/thinkphp/library/think/Middleware.php:119}() Middleware.php:119, think/Middleware->think/{closure}() Middleware.php:74, call_user_func: {C:/wamp64/www/think518/thinkphp/library/think/Middleware.php:74}() Middleware.php:74, think/Middleware->dispatch() App.php:399, think/App->run() index.php:21, {main}()
缺陷关键点为thinkphp解析用户传递过来的Data可控,且可以绕过安全检查。
根据文件 Connection.php:1149, think/db/Connection->update() 第1102行update函数分析,这个函数的主要功 能是用于执行update SQL语句。
//Connection.php:1149, think/db/Connection->update() public function update(Query $query) { $options = $query->getOptions(); if (isset($options['cache']) && is_string($options['cache']['key'])) { $key = $options['cache']['key']; } $pk = $query->getPk($options); $data = $options['data']; if (empty($options['where'])) { // 如果存在主键数据 则自动作为更新条件 if (is_string($pk) && isset($data[$pk])) { $where[$pk] = [$pk, '=', $data[$pk]]; if (!isset($key)) { $key = $this->getCacheKey($query, $data[$pk]); } unset($data[$pk]); } elseif (is_array($pk)) { // 增加复合主键支持 foreach ($pk as $field) { if (isset($data[$field])) { $where[$field] = [$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; $query->setOption('where', ['AND' => $where]); } } elseif (!isset($key) && is_string($pk) && isset($options['where']['AND'][$pk])) { $key = $this->getCacheKey($query, $options['where']['AND'][$pk]); } // 更新数据 $query->setOption('data', $data); // 生成UPDATE SQL语句 $sql = $this->builder->update($query); $bind = $query->getBind(); if (!empty($options['fetch_sql'])) { // 获取实际执行的SQL语句 return $this->getRealSql($sql, $bind); } // 检测缓存 $cache = Container::get('cache'); 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; } $query->setOption('data', $data); $query->trigger('after_update'); }
return $result; 刚刚我们将用户可控的 $data set到 $query['options'] 中,这里我们先获取 $query['options'] 内容到 $options 中,然后对Data进行解析 $data = $this->parseData($query, $options['data']);
第1146行, $query->setOption(‘data’, $data); 这里将用户传递的 $data set到 $query 变量中,为下一步的生 成 UPDATE SQL语句做准备,执行 $sql = $this->builder->update($query); 语句,重点马上要来了,跟进
Builder.php:1139, think/db/Builder->update() 函数
//Builder.php:1139, think/db/Builder->update() public function update(Query $query) { $options = $query->getOptions(); $table = $this->parseTable($query, $options['table']); $data = $this->parseData($query, $options['data']); if (empty($data)) { return ''; } foreach ($data as $key => $val) { $set[] = $key . ' = ' . $val; } return str_replace( ['%TABLE%', '%SET%', '%JOIN%', '%WHERE%', '%ORDER%', '%LIMIT%', '%LOCK%', '%COMMENT%'], } [ $this->parseTable($query, $options['table']), implode(' , ', $set), $this->parseJoin($query, $options['join']), $this->parseWhere($query, $options['where']), $this->parseOrder($query, $options['order']), $this->parseLimit($query, $options['limit']), $this->parseLock($query, $options['lock']), $this->parseComment($query, $options['comment']), ], $this->updateSql);
刚刚我们将用户可控的 $data set到 $query['options'] 中,这里我们先获取 $query['options'] 内容到 $options 中,然后对Data进行解析 $data = $this->parseData($query, $options['data']);
//Builder.php:147, think/db/Builder->parseData() 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; } //Mysql.php:200, think/db/builder/Mysql->parseArrayData() 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';
在第115行,通过 foreach ($data as $key => $val) 处理 $data ,然后解析 $key 保存到 $item 变量中去,之后 执行下面的判断逻辑,想要合理地进入各个判断分支,就要巧妙的构造 $key 和 $value 也就是 $data 的值。紧接 着我们进入漏洞触发点 $value = $this->parseArrayData($query, $val); ,跟进函数 $value = $this- >parseArrayData($query, $val);
//Mysql.php:200, think/db/builder/Mysql->parseArrayData() 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 . ')/')';//需要简单的构造一下sql语 break; default: }
这里 $type 、 $value 和 $data 均为可控值,那么函数返回的 $result 也就是可控的。回到上一个 Builder.php 文件中,将返回的结果赋值到 $result[$item] = $value; 中,之后的生成SQL语句和常见的流程没有任何差别不在 展开具体分析。
更新受影响ThinkPHP版本到5.1.8版本以上
360代码安全实验室是360企业安全集团旗下专门从事源代码、二进制漏洞挖掘和分析的研究团队,主要研究方向 包括:Windows/Linux/MacOS操作系统、应用软件、开源软件、网络设备、IoT设备等。团队成员既有二进制漏洞 挖掘高手,微软全球TOP100贡献白帽子,Pwn2own2017冠军队员,又有开源软件安全大拿,人工智能安全专 家。实验室安全团队的研究成果获得微软、Adobe、各种开源组织等的50多次致谢。
ThinkPHP5.0.17&5.1.9版本发布——包含安全更新
*本文作者:360企业安全,转载请注明来自FreeBuf.COM