代码审计案例:Ueditor 任意文件上传与 SQL 注入的“迷之”防御
分享一个关于代码审计挖掘漏洞,以及后续技术修复防御失败的案例,过程有点曲折,也有点意思 ( ̄▽ ̄)”
漏洞成因
对一套典型 PHP 商城框架代码进行审计学习,本次审计主要发现了两个高危漏洞:后台任意文件上传和前台 SQL 报错注入。
任意文件上传
该源码项目在后台调用了 Ueditor 编辑器,关键问题在于没有对上传接口做鉴权,并且还调用了其他的上传方法。
先看编辑器上传入口处的部分代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141
|
class Ueditor extends Controller { public function index() { date_default_timezone_set("Asia/chongqing"); error_reporting(E_ERROR); header("Content-Type: text/html; charset=utf-8");
$CONFIG = json_decode(preg_replace("/\/\*[\s\S]+?\*\//", "", file_get_contents("./public/static/ext/ueditor/php/config.json")), true); $action = $_GET[ 'action' ];
switch ( $action ) { case 'config': $result = json_encode($CONFIG); break; case 'uploadimage': $fieldName = $CONFIG[ 'imageFieldName' ]; $result = $this->upImage($fieldName); break; case 'uploadscrawl': $config = array ( "pathFormat" => $CONFIG[ 'scrawlPathFormat' ], "maxSize" => $CONFIG[ 'scrawlMaxSize' ], "allowFiles" => $CONFIG[ 'scrawlAllowFiles' ], "oriName" => "scrawl.png" ); $fieldName = $CONFIG[ 'scrawlFieldName' ]; $base64 = "base64"; $result = $this->upBase64($config, $fieldName); break; case 'uploadvideo': $fieldName = $CONFIG[ 'videoFieldName' ]; $result = $this->upVideo($fieldName, $CONFIG[ 'videoMaxSize' ]); break; case 'uploadfile': $fieldName = $CONFIG[ 'fileFieldName' ]; $result = $this->upFile($fieldName); break; case 'listimage': $allowFiles = $CONFIG[ 'imageManagerAllowFiles' ]; $listSize = $CONFIG[ 'imageManagerListSize' ]; $path = $CONFIG[ 'imageManagerListPath' ]; $get = $_GET; $result = $this->fileList($allowFiles, $listSize, $get); break; case 'listfile': $allowFiles = $CONFIG[ 'fileManagerAllowFiles' ]; $listSize = $CONFIG[ 'fileManagerListSize' ]; $path = $CONFIG[ 'fileManagerListPath' ]; $get = $_GET; $result = $this->fileList($allowFiles, $listSize, $get); break; case 'catchimage': $config = array ( "pathFormat" => $CONFIG[ 'catcherPathFormat' ], "maxSize" => $CONFIG[ 'catcherMaxSize' ], "allowFiles" => $CONFIG[ 'catcherAllowFiles' ], "oriName" => "remote.png" ); $fieldName = $CONFIG[ 'catcherFieldName' ]; $list = array (); isset($_POST[ $fieldName ]) ? $source = $_POST[ $fieldName ] : $source = $_GET[ $fieldName ];
foreach ($source as $imgUrl) { $info = json_decode($this->saveRemote($config, $imgUrl), true); array_push($list, array ( "state" => $info[ "state" ], "url" => $info[ "url" ], "size" => $info[ "size" ], "title" => htmlspecialchars($info[ "title" ]), "original" => htmlspecialchars($info[ "original" ]), "source" => htmlspecialchars($imgUrl) )); }
$result = json_encode(array ( 'state' => count($list) ? 'SUCCESS' : 'ERROR', 'list' => $list )); break; default: $result = json_encode(array ( 'state' => '请求地址出错' )); break; }
if (isset($_GET[ "callback" ])) { if (preg_match("/^[\w_]+$/", $_GET[ "callback" ])) { echo htmlspecialchars($_GET[ "callback" ]) . '(' . $result . ')'; } else { echo json_encode(array ( 'state' => 'callback参数不合法' )); } } else { echo $result; } } public function upVideo($fieldName, $size) { $upload_service = new UploadModel(); if (!empty($_FILES[ $fieldName ])) { $info = $upload_service->setPath("common/video/" . date("Ymd") . '/')->video([ 'name' => $fieldName, ]); if ($info[ 'code' ] >= 0) { $data = array ( 'state' => 'SUCCESS', 'url' => $info[ 'data' ][ 'path' ], 'title' => $info[ 'data' ][ 'path' ], 'original' => $info[ 'data' ][ 'path' ] ); } else { $data = array ( 'state' => 'FAIL' ); } } else { $data = array ( 'state' => '上传文件为空', ); } return json_encode($data); }
}
|
我们可以看到,index 方法的 action 参数如果设置为 uploadvideo,就可以直接调用 upVideo 上传方法。而在 upVideo 方法中,又调用了 model 中的 upload 方法,这才是真正产生任意上传漏洞的核心点。
继续看 video 方法的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
|
public function video($param) { $check_res = $this->checkFile(); if ($check_res[ "code" ] >= 0) { $file = request()->file($param[ "name" ]); try { $extend_name = $file->getOriginalExtension(); $new_name = $this->createNewFileName() . "." . $extend_name;
$file_path = $this->path; \think\facade\Filesystem::disk('public')->putFileAs($file_path, $file, $new_name); $file_name = $file_path . $new_name; $result = $this->fileCloud($file_name); return $this->success([ "path" => $result[ 'data' ] ?? '' ], "UPLOAD_SUCCESS"); } catch (\think\exception\ValidateException $e) { return $this->error('', $e->getMessage()); } } else { return $check_res; } }
|
漏洞点在于 disk('public')->putFileAs($file_path, $file, $new_name);。从入口调用到 video 方法,没有任何的过滤限制,最终形成了任意文件上传漏洞。
前台 SQL 报错注入
再来看看前台 SQL 注入的问题。入口代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| class Store extends BaseApi {
public function page() {
$latitude = isset($this->params[ 'latitude' ]) ? $this->params[ 'latitude' ] : null; $longitude = isset($this->params[ 'longitude' ]) ? $this->params[ 'longitude' ] : null; $store_id = isset($this->params[ 'store_id' ]) ? $this->params[ 'store_id' ] : 0; $store_model = new StoreModel(); $condition = [ [ 'site_id', "=", $this->site_id ], [ 'status', '=', 1 ], [ 'is_frozen', '=', 0 ], [ 'is_pickup', '=', 1 ] ];
$latlng = array ( 'lat' => $latitude, 'lng' => $longitude, ); $field = '*'; $list_result = $store_model->getLocationStoreList($condition, $field, $latlng);
$list = $list_result[ 'data' ];
if (!empty($longitude) && !empty($latitude) && !empty($list)) { foreach ($list as $k => $item) { if ($item[ 'longitude' ] && $item[ 'latitude' ]) { $distance = getDistance((float) $item[ 'longitude' ], (float) $item[ 'latitude' ], (float) $longitude, (float) $latitude); $list[ $k ][ 'distance' ] = $distance / 1000; } else { $list[ $k ][ 'distance' ] = 0; } } array_multisort(array_column($list, 'distance'), SORT_ASC, $list); }
$default_store_id = 0; if (!empty($list)) { $default_store_id = $list[ 0 ][ 'store_id' ]; } return $this->response($this->success([ 'list' => $list, 'store_id' => $default_store_id ])); }
}
|
漏洞点位于 getLocationStoreList 方法中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
public function getLocationStoreList($condition, $field, $lnglat) { $order = ''; if ($lnglat[ 'lat' ] !== null && $lnglat[ 'lng' ] !== null) { $field .= ' , ROUND(st_distance ( point ( ' . $lnglat[ 'lng' ] . ', ' . $lnglat[ 'lat' ] . ' ), point ( longitude, latitude ) ) * 111195 / 1000, 2) as distance '; $condition[] = [ '', 'exp', Db::raw(' FORMAT(st_distance ( point ( ' . $lnglat[ 'lng' ] . ', ' . $lnglat[ 'lat' ] . ' ), point ( longitude, latitude ) ) * 111195 / 1000, 2) < 10000') ]; $order = 'distance asc'; } $list = model('store')->getList($condition, $field, $order); return $this->success($list); }
|
可以看到,整个数据传递没有任何过滤,然后在 getLocationStoreList 方法中直接将参数 $lnglat 拼接到了 SQL 语句中,导致了 SQL 注入。
技术防御方式
面对这两个漏洞,开发者的修复方式可谓是“别出心裁”。
上传漏洞的“迷之”修复
对于上传点漏洞,技术的防御方式是真的太粗暴了——直接禁了 common/video/ 这个上传目录的写入权限。这也是大写的服 (¬_¬”)。
SQL 注入的“自研”WAF
对于前台 SQL 报错注入,技术倒是有点水平了,但也就只是有点点水平了。他在 index.php 入口文件增加了一段全局过滤代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| <?php
namespace think; if (version_compare(PHP_VERSION, '7.1.0', '<')) die('require PHP > 7.1.0 !');
header("Access-Control-Allow-Origin:*");
header('Access-Control-Allow-Methods:GET, POST, PUT, DELETE');
header('Access-Control-Allow-Headers:x-requested-with, content-type');
$query_string = substr($_SERVER["QUERY_STRING"], -3); $array = [ 'jpg', 'png', 'css', '.js', 'txt', 'doc', 'ocx', 'peg' ]; if (in_array($query_string, $array)) { exit(); }
function check_param($value=null) { if(is_array($value)){ foreach($value as $k=>$v){ check_param($v); } return; } $str = '/select[^a-zA-Z]*|insert\s|\sand\s|\sor\s|update\s|delete\s|union\s|into\s|load_file|outfile|script/i'; if($value&&preg_match($str, $value)) { $res['state']='fail'; $res['code']=0; $res['msg']='method不可为空[God bless you]'; echo json_encode($res,JSON_UNESCAPED_UNICODE); exit; } } check_param($_GET); check_param($_POST); check_param(file_get_contents('php://input'));
require __DIR__ . '/vendor/autoload.php';
$http = (new App())->http;
$response = $http->run();
$response->send();
$http->end($response);
|
通过关键词递归检查的方式来拦截了 select 等关键词。说他有点水平,是因为这个注入点是在 st_distance ( point 中,他本身这个拦截有很多种可以尝试绕过的方式,但是因为注入点语句整体结构情况,直接导致了 select 无法绕过,绕过了也无法执行成功。
不过这套源码可不止这么一处注入呀,只能说,有点菜还得练 ( ̄, ̄ )。自写防御的行为可以点赞,但顺手嘲讽 [God bless you] 就有点飘了,任意上传那个漏洞也是因为这个嘲讽而诞生的 (^^)a。
修复建议
针对上述漏洞,给出正经的修复建议:
上传漏洞修复建议
- 鉴权:对编辑器的调用进行严格的后台鉴权,防止未授权访问。
- 白名单校验:对上传扩展名进行白名单校验,或者直接使用编辑器自带的上传方法,而不是调用 model 中的方法。这也是我不太理解的点,代码有些混乱,action 调用,有的是直接用的编辑器的,有的是用的 model 的,太乱了。
- 目录解析限制:可以通过在上传目录创建
.htaccess 文件(如果是 Apache),在文件中通过规则禁用目录的所有解析权限,防止上传的脚本文件被执行。
前台 SQL 注入修复建议
- 参数过滤/预编译:这个就比较简单了,注入点防御方式都是一样,对传入的参数进行过滤或使用预编译语句。他代码很多地方写的都很规范,用的 ThinkPHP 标准写法,不知道为什么这个点会直接拼接。建议统一使用参数绑定或查询构造器,避免手动拼接 SQL。