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, thinkdbbuilderMysql->parseArrayData()
Builder.php:147, thinkdbBuilder->parseData()
Builder.php:1139, thinkdbBuilder->update()
Connection.php:1149, thinkdbConnection->update()
Query.php:2571, thinkdbQuery->update()
Index.php:18, appindexcontrollerIndex->testsql()
Container.php:285, ReflectionMethod->invokeArgs()
Container.php:285, thinkContainer->invokeReflectMethod()
Module.php:139, thinkroutedispatchModule->run()
Url.php:31, thinkroutedispatchUrl->run()
App.php:378, thinkApp->think{closure}()
Middleware.php:119, call_user_func_array:{C:wamp64wwwthink518thinkphplibrarythinkMiddleware.php:119}()
Middleware.php:119, thinkMiddleware->think{closure}()
Middleware.php:74, call_user_func:{C:wamp64wwwthink518thinkphplibrarythinkMiddleware.php:74}()
Middleware.php:74, thinkMiddleware->dispatch()
App.php:399, thinkApp->run()
index.php:21, {main}()
缺陷关键点为thinkphp解析用户传递过来的Data可控,且可以绕过安全检查。
根据文件 Connection.php:1149,thinkdbConnection->update()第1102行update函数分析,这个函数的主要功能是用于执行update SQL语句。
//Connection.php:1149, thinkdbConnection->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;
}
第1146行, $query->setOption(‘data’,$data);这里将用户传递的 $dataset到 $query变量中,为下一步的生成 UPDATE SQL语句做准备,执行 $sql=$this->builder->update($query);语句,重点马上要来了,跟进 Builder.php:1139,thinkdbBuilder->update()函数
//Builder.php:1139, thinkdbBuilder->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);
}
刚刚我们将用户可控的 $dataset到 $query[‘options’]中,这里我们先获取 $query[‘options’]内容到 $options中,然后对Data进行解析 $data=$this->parseData($query,$options[‘data’]);
//Builder.php:147, thinkdbBuilder->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;
}
在第115行,通过 foreach($dataas$key=>$val)处理 $data,然后解析 $key保存到 $item变量中去,之后执行下面的判断逻辑,想要合理地进入各个判断分支,就要巧妙的构造 $key和 $value也就是 $data的值。紧接着我们进入漏洞触发点 $value=$this->parseArrayData($query,$val);,跟进函数 $value=$this->parseArrayData($query,$val);
//Mysql.php:200, thinkdbbuilderMysql->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:
$result = false;
}
return $result;
}
这里 $type、 $value和 $data均为可控值,那么函数返回的 $result也就是可控的。回到上一个 Builder.php文件中,将返回的结果赋值到 $result[$item]=$value;中,之后的生成SQL语句和常见的流程没有任何差别不再展开具体分析。
验证截图
修复建议
更新受影响ThinkPHP版本到最新版本
关于我们
360代码安全实验室是360企业安全集团旗下专门从事源代码、二进制漏洞挖掘和分析的研究团队,主要研究方向包括:Windows/Linux/MacOS操作系统、应用软件、开源软件、网络设备、IoT设备等。团队成员既有二进制漏洞挖掘高手,微软全球TOP100贡献白帽子,Pwn2own2017冠军队员,又有开源软件安全大拿,人工智能安全专家。实验室安全团队的研究成果获得微软、Adobe、各种开源组织等的50多次致-谢。
参考
ThinkPHP5.0.17&5.1.9版本发布——包含安全更新