查看原文
其他

ThinkPHP 框架SQL注入技术分析(文末有彩蛋)

代码卫士 2022-04-06

 聚焦源代码安全,网罗国内外最新资讯!


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操作的过程。为了方便理解,先直接放出函数的调用栈。

  1. Mysql.php:200, think\db\builder\Mysql->parseArrayData()

  2. Builder.php:147, think\db\Builder->parseData()

  3. Builder.php:1139, think\db\Builder->update()

  4. Connection.php:1149, think\db\Connection->update()

  5. Query.php:2571, think\db\Query->update()

  6. Index.php:18, app\index\controller\Index->testsql()

  7. Container.php:285, ReflectionMethod->invokeArgs()

  8. Container.php:285, think\Container->invokeReflectMethod()

  9. Module.php:139, think\route\dispatch\Module->run()

  10. Url.php:31, think\route\dispatch\Url->run()

  11. App.php:378, think\App->think\{closure}()

  12. Middleware.php:119, call_user_func_array:{C:\wamp64\www\think518\thinkphp\library\think\Middleware.php:119}()

  13. Middleware.php:119, think\Middleware->think\{closure}()

  14. Middleware.php:74, call_user_func:{C:\wamp64\www\think518\thinkphp\library\think\Middleware.php:74}()

  15. Middleware.php:74, think\Middleware->dispatch()

  16. App.php:399, think\App->run()

  17. index.php:21, {main}()

缺陷关键点为thinkphp解析用户传递过来的Data可控,且可以绕过安全检查。

根据文件 Connection.php:1149,think\db\Connection->update()第1102行update函数分析,这个函数的主要功能是用于执行update SQL语句。

  1. //Connection.php:1149, think\db\Connection->update()

  2. public function update(Query $query)

  3.    {

  4.        $options = $query->getOptions();

  5.        if (isset($options['cache']) && is_string($options['cache']['key'])) {

  6.            $key = $options['cache']['key'];

  7.        }

  8.        $pk   = $query->getPk($options);

  9.        $data = $options['data'];

  10.        if (empty($options['where'])) {

  11.            // 如果存在主键数据 则自动作为更新条件

  12.            if (is_string($pk) && isset($data[$pk])) {

  13.                $where[$pk] = [$pk, '=', $data[$pk]];

  14.                if (!isset($key)) {

  15.                    $key = $this->getCacheKey($query, $data[$pk]);

  16.                }

  17.                unset($data[$pk]);

  18.            } elseif (is_array($pk)) {

  19.                // 增加复合主键支持

  20.                foreach ($pk as $field) {

  21.                    if (isset($data[$field])) {

  22.                        $where[$field] = [$field, '=', $data[$field]];

  23.                    } else {

  24.                        // 如果缺少复合主键数据则不执行

  25.                        throw new Exception('miss complex primary data');

  26.                    }

  27.                    unset($data[$field]);

  28.                }

  29.            }

  30.            if (!isset($where)) {

  31.                // 如果没有任何更新条件则不执行

  32.                throw new Exception('miss update condition');

  33.            } else {

  34.                $options['where']['AND'] = $where;

  35.                $query->setOption('where', ['AND' => $where]);

  36.            }

  37.        } elseif (!isset($key) && is_string($pk) && isset($options['where']['AND'][$pk])) {

  38.            $key = $this->getCacheKey($query, $options['where']['AND'][$pk]);

  39.        }

  40.        // 更新数据

  41.        $query->setOption('data', $data);

  42.        // 生成UPDATE SQL语句

  43.        $sql  = $this->builder->update($query);

  44.        $bind = $query->getBind();

  45.        if (!empty($options['fetch_sql'])) {

  46.            // 获取实际执行的SQL语句

  47.            return $this->getRealSql($sql, $bind);

  48.        }

  49.        // 检测缓存

  50.        $cache = Container::get('cache');

  51.        if (isset($key) && $cache->get($key)) {

  52.            // 删除缓存

  53.            $cache->rm($key);

  54.        } elseif (!empty($options['cache']['tag'])) {

  55.            $cache->clear($options['cache']['tag']);

  56.        }

  57.        // 执行操作

  58.        $result = '' == $sql ? 0 : $this->execute($sql, $bind);

  59.        if ($result) {

  60.            if (is_string($pk) && isset($where[$pk])) {

  61.                $data[$pk] = $where[$pk];

  62.            } elseif (is_string($pk) && isset($key) && strpos($key, '|')) {

  63.                list($a, $val) = explode('|', $key);

  64.                $data[$pk]     = $val;

  65.            }

  66.            $query->setOption('data', $data);

  67.            $query->trigger('after_update');

  68.        }

  69.        return $result;

  70.    }

第1146行, $query->setOption('data',$data);这里将用户传递的 $dataset到 $query变量中,为下一步的生成 UPDATE SQL语句做准备,执行 $sql=$this->builder->update($query);语句,重点马上要来了,跟进 Builder.php:1139,think\db\Builder->update()函数

  1. //Builder.php:1139, think\db\Builder->update()

  2. public function update(Query $query)

  3.    {

  4.        $options = $query->getOptions();

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

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

  7.        if (empty($data)) {

  8.            return '';

  9.        }

  10.        foreach ($data as $key => $val) {

  11.            $set[] = $key . ' = ' . $val;

  12.        }

  13.        return str_replace(

  14.            ['%TABLE%', '%SET%', '%JOIN%', '%WHERE%', '%ORDER%', '%LIMIT%', '%LOCK%', '%COMMENT%'],

  15.            [

  16.                $this->parseTable($query, $options['table']),

  17.                implode(' , ', $set),

  18.                $this->parseJoin($query, $options['join']),

  19.                $this->parseWhere($query, $options['where']),

  20.                $this->parseOrder($query, $options['order']),

  21.                $this->parseLimit($query, $options['limit']),

  22.                $this->parseLock($query, $options['lock']),

  23.                $this->parseComment($query, $options['comment']),

  24.            ],

  25.            $this->updateSql);

  26.    }

刚刚我们将用户可控的 $dataset到 $query['options']中,这里我们先获取 $query['options']内容到 $options中,然后对Data进行解析 $data=$this->parseData($query,$options['data']);

  1. //Builder.php:147, think\db\Builder->parseData()

  2. protected function parseData(Query $query, $data = [], $fields = [], $bind = [], $suffix = '')

  3.    {

  4.        if (empty($data)) {

  5.            return [];

  6.        }

  7.        $options = $query->getOptions();

  8.        // 获取绑定信息

  9.        if (empty($bind)) {

  10.            $bind = $this->connection->getFieldsBind($options['table']);

  11.        }

  12.        if (empty($fields)) {

  13.            if ('*' == $options['field']) {

  14.                $fields = array_keys($bind);

  15.            } else {

  16.                $fields = $options['field'];

  17.            }

  18.        }

  19.        $result = [];

  20.        foreach ($data as $key => $val) {

  21.            $item = $this->parseKey($query, $key);

  22.            if ($val instanceof Expression) {

  23.                $result[$item] = $val->getValue();

  24.                continue;

  25.            } elseif (!is_scalar($val) && (in_array($key, (array) $query->getOptions('json')) || 'json' == $this->connection->getFieldsType($options['table'], $key))) {

  26.                $val = json_encode($val);

  27.            } elseif (is_object($val) && method_exists($val, '__toString')) {

  28.                // 对象数据写入

  29.                $val = $val->__toString();

  30.            }

  31.            if (false !== strpos($key, '->')) {

  32.                list($key, $name) = explode('->', $key);

  33.                $item             = $this->parseKey($query, $key);

  34.                $result[$item]    = 'json_set(' . $item . ', \'$.' . $name . '\', ' . $this->parseDataBind($query, $key, $val, $bind, $suffix) . ')';

  35.            } elseif (false === strpos($key, '.') && !in_array($key, $fields, true)) {

  36.                if ($options['strict']) {

  37.                    throw new Exception('fields not exists:[' . $key . ']');

  38.                }

  39.            } elseif (is_null($val)) {

  40.                $result[$item] = 'NULL';

  41.            } elseif (is_array($val) && !empty($val)) {

  42.                switch ($val[0]) {

  43.                    case 'INC':

  44.                        $result[$item] = $item . ' + ' . floatval($val[1]);

  45.                        break;

  46.                    case 'DEC':

  47.                        $result[$item] = $item . ' - ' . floatval($val[1]);

  48.                        break;

  49.                    default:

  50.                        $value = $this->parseArrayData($query, $val);

  51.                        if ($value) {

  52.                            $result[$item] = $value;

  53.                        }

  54.                }

  55.            } elseif (is_scalar($val)) {

  56.                // 过滤非标量数据

  57.                $result[$item] = $this->parseDataBind($query, $key, $val, $bind, $suffix);

  58.            }

  59.        }

  60.        return $result;

  61.    }

在第115行,通过 foreach($dataas$key=>$val)处理 $data,然后解析 $key保存到 $item变量中去,之后执行下面的判断逻辑,想要合理地进入各个判断分支,就要巧妙的构造 $key和 $value也就是 $data的值。紧接着我们进入漏洞触发点 $value=$this->parseArrayData($query,$val);,跟进函数 $value=$this->parseArrayData($query,$val);

  1. //Mysql.php:200, think\db\builder\Mysql->parseArrayData()

  2. protected function parseArrayData(Query $query, $data)

  3.    {

  4.        list($type, $value) = $data;

  5.        switch (strtolower($type)) {

  6.            case 'point':

  7.                $fun   = isset($data[2]) ? $data[2] : 'GeomFromText';

  8.                $point = isset($data[3]) ? $data[3] : 'POINT';

  9.                if (is_array($value)) {

  10.                    $value = implode(' ', $value);

  11.                }

  12.                $result = $fun . '(\'' . $point . '(' . $value . ')\')';//需要简单的构造一下sql语句

  13.                break;

  14.            default:

  15.                $result = false;

  16.        }

  17.        return $result;

  18.    }

这里 $type、 $value和 $data均为可控值,那么函数返回的 $result也就是可控的。回到上一个 Builder.php文件中,将返回的结果赋值到 $result[$item]=$value;中,之后的生成SQL语句和常见的流程没有任何差别不再展开具体分析。

验证截图

修复建议

更新受影响ThinkPHP版本到5.1.9或5.0.17版本以上

关于我们

360代码安全实验室是360企业安全集团旗下专门从事源代码、二进制漏洞挖掘和分析的研究团队,主要研究方向包括:Windows/Linux/MacOS操作系统、应用软件、开源软件、网络设备、IoT设备等。团队成员既有二进制漏洞挖掘高手,微软全球TOP100贡献白帽子,Pwn2own2017冠军队员,又有开源软件安全大拿,人工智能安全专家。实验室安全团队的研究成果获得微软、Adobe、各种开源组织等的50多次致谢。

参考

ThinkPHP5.0.17&5.1.9版本发布——包含安全更新

(http://www.thinkphp.cn/topic/55757.html)


【彩蛋】

现在,和技术大神共事的机会来了!360代码安全实验室正在寻找漏洞挖掘安全研究员,针对常见操作系统、应用软件、网络设备、智能联网设备等进行安全研究、漏洞挖掘。

如果你:

  • 对从事漏洞研究工作充满热情

  • 熟悉操作系统原理,熟悉反汇编,逆向分析能力较强

  • 了解常见编程语言,具有一定的代码阅读能力

  • 熟悉Fuzzing技术及常见漏洞挖掘工具

  • 挖掘过系统软件、网络设备等漏洞者(有cve编号)优先

  • 具有漏洞挖掘工具开发经验者优先

那么,你将得到:

  • 白花花的银子——月薪20K-60K+年底双薪+项目奖,优秀者还有股票期权哦

  • 暖心的福利——六险一金+各种补贴+下午茶+节假日礼品

  • 重点重点重点——志同道合、暖心的我们

心动不如行动!无论你是经验丰富的大咖儿,还是志向从事安全研究的菜鸟儿,不要犹豫!

赶紧把简历投到 liubenjin@360.net 吧!我会在3个工作日内找到你~

转载请注明 “转自360代码卫士www.codesafe.cn”。


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存