本文摘要

分享一个关于代码审计挖掘漏洞,以及后续技术修复防御失败的案例,过程有点曲折,也有点意思 ( ̄▽ ̄)”

漏洞成因

对一套典型 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

/**
* 百度编辑器上传
* 版本 1.0.6
*/
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
/**
* 视频上传
* @param $param
*/
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()
{

// $page = isset($this->params['page']) ? $this->params['page'] : 1;
// $page_size = isset($this->params['page_size']) ? $this->params['page_size'] : PAGE_LIST_ROWS;
$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);
}
// 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
/**
* 查询门店 带有距离
* @param $condition
* @param $lnglat
*/
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
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006-2019 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------

// [ 应用入口文件 ]
namespace think;
if (version_compare(PHP_VERSION, '7.1.0', '<'))
die('require PHP > 7.1.0 !');

// 检测PHP环境 允许前端跨域请求
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();
}

//header("Content-Security-Policy: default-src 'self'; script-src 'self' object-src 'none'; base-uri 'none'; report-uri /csp-report.php");
function check_param($value=null) {
if(is_array($value)){
foreach($value as $k=>$v){
check_param($v);
}
return;
}
# select|insert|update|delete|\'|\/\*|\*|\.\.\/|\.\/|union|into|load_file|outfile

$str = '/select[^a-zA-Z]*|insert\s|\sand\s|\sor\s|update\s|delete\s|union\s|into\s|load_file|outfile|script/i';
//echo $_value;
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应用并响应
$http = (new App())->http;

$response = $http->run();

$response->send();

$http->end($response);

通过关键词递归检查的方式来拦截了 select 等关键词。说他有点水平,是因为这个注入点是在 st_distance ( point 中,他本身这个拦截有很多种可以尝试绕过的方式,但是因为注入点语句整体结构情况,直接导致了 select 无法绕过,绕过了也无法执行成功。

不过这套源码可不止这么一处注入呀,只能说,有点菜还得练 ( ̄, ̄ )。自写防御的行为可以点赞,但顺手嘲讽 [God bless you] 就有点飘了,任意上传那个漏洞也是因为这个嘲讽而诞生的 (^^)a。

修复建议

针对上述漏洞,给出正经的修复建议:

上传漏洞修复建议

  1. 鉴权:对编辑器的调用进行严格的后台鉴权,防止未授权访问。
  2. 白名单校验:对上传扩展名进行白名单校验,或者直接使用编辑器自带的上传方法,而不是调用 model 中的方法。这也是我不太理解的点,代码有些混乱,action 调用,有的是直接用的编辑器的,有的是用的 model 的,太乱了。
  3. 目录解析限制:可以通过在上传目录创建 .htaccess 文件(如果是 Apache),在文件中通过规则禁用目录的所有解析权限,防止上传的脚本文件被执行。

前台 SQL 注入修复建议

  • 参数过滤/预编译:这个就比较简单了,注入点防御方式都是一样,对传入的参数进行过滤或使用预编译语句。他代码很多地方写的都很规范,用的 ThinkPHP 标准写法,不知道为什么这个点会直接拼接。建议统一使用参数绑定或查询构造器,避免手动拼接 SQL。

安全声明
本站为网络安全技术科普博客,全部内容仅用于合法安全研究、代码审计学习、安全开发加固、网络安全普法防御用途。
站内所有技术文章、工具、分析资料,禁止用于未授权渗透、非法入侵、数据窃取、网络攻击等违反法律法规的行为。
任何人利用本站内容进行违规操作,责任由行为人自行承担,本站不提供任何攻击教程与非法工具,内容合规合法,有问题联系博主处理。


📝 文章: N 篇 | 🕐 已运行: N 天 | 📅 最后更新: 2026-04-26 | 🎨 主题:[Stellar] | 🔒 备案号:蜀ICP备16008285号