前言

Springboot项目仿天猫商城 :前台jsp页面(前后端在一起),mysql数据库,jdk1.8

项目地址:https://gitee.com/HaiTao87/TmallDemo

环境搭建

按照项目里面说的部署方式进行部署就完事了,下面我只要分享几点我自己搭建环境过程中所遇到的报错的解决方案

1
2
3
4
5
6
7
8
9
10
11
12
20:28:33.382 [restartedMain] ERROR org.springframework.boot.SpringApplication - Application run failed
java.lang.IllegalStateException: Failed to load property source from location 'classpath:/application.yml'
at org.springframework.boot.context.config.ConfigFileApplicationListener$Loader.load(ConfigFileApplicationListener.java:524)
.......
Caused by: java.nio.charset.MalformedInputException: Input length = 1
at java.nio.charset.CoderResult.throwException(CoderResult.java:281)
at sun.nio.cs.StreamDecoder.implRead(StreamDecoder.java:339)
at sun.nio.cs.StreamDecoder.read(StreamDecoder.java:178)
at java.io.InputStreamReader.read(InputStreamReader.java:184)
at org.yaml.snakeyaml.reader.UnicodeReader.read(UnicodeReader.java:125)
at org.yaml.snakeyaml.reader.StreamReader.update(StreamReader.java:183)
... 46 common frames omitted

这主要是说明了 application.yml文件可能是在某些编辑器(如 Windows 记事本)中以 GBK 或其它非 UTF-8 编码保存的,而 Spring Boot 默认使用 UTF-8 编码来读取配置文件

所以我验证的方式是用HxD这个软件来打开 application.yml文件,看到了中文全部都是以乱码的形式显示,解决的办法最简单的就是直接将 application.ymlapplication-dev.yml中的所有中文注释都删掉就可以了

在启动项目的时候还会遇到一个报错如下:

1
2
3
4
5
E:\safety\CodeAudit\TmallDemo-master\src\test\java\com\example\tmall\TmallApplicationTests.java:3:29
java: 无法访问org.junit.jupiter.api.Test
错误的类文件: /C:/Users/BD/.m2/repository/org/junit/jupiter/junit-jupiter-api/6.0.0-M2/junit-jupiter-api-6.0.0-M2.jar!/org/junit/jupiter/api/Test.class
类文件具有错误的版本 61.0, 应为 52.0
请删除该文件或确保该文件位于正确的类路径子目录中。

这是java版本不兼容的问题,项目里面该库用的是java17的版本,但目前编译和运行该项目的是java8

我的解决方法是更改pom.xml里面依赖的版本

1
2
3
4
5
6
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.9.3</version>
<scope>test</scope>
</dependency>

后面登录管理后台的时候也会报错如下

1
2
3
4
5
6
7
8
9
10
Whitelabel Error Page
This application has no explicit mapping for /error, so you are seeing this as a fallback.

Thu Sep 18 21:04:32 CST 2025
There was an unexpected error (type=Internal Server Error, status=500).
### Error querying database. Cause: java.sql.SQLSyntaxErrorException: Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'tmalldemodb.productOrder.productorder_pay_date' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by ### The error may exist in file [E:\safety\CodeAudit\TmallDemo-master\target\classes\mapper\ProductOrderMapper.xml] ### The error may involve defaultParameterMap ### The error occurred while setting parameters ### SQL: SELECT productOrder_pay_date,count(productOrder_id) as productOrder_count ,productOrder_status from productOrder WHERE productOrder_pay_date BETWEEN ? AND ? GROUP BY DATE_FORMAT(productOrder_pay_date,'%Y-%m-%d'),productOrder_status ### Cause: java.sql.SQLSyntaxErrorException: Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'tmalldemodb.productOrder.productorder_pay_date' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by ; bad SQL grammar []; nested exception is java.sql.SQLSyntaxErrorException: Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'tmalldemodb.productOrder.productorder_pay_date' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by
org.springframework.jdbc.BadSqlGrammarException:
### Error querying database. Cause: java.sql.SQLSyntaxErrorException: Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'tmalldemodb.productOrder.productorder_pay_date' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by
### The error may exist in file [E:\safety\CodeAudit\TmallDemo-master\target\classes\mapper\ProductOrderMapper.xml]
........

不知道这个报错是不是和我上面改了一个依赖的版本有关。。。。

该报错的核心在于你的 SQL 查询语句的写法 与你使用的 MySQL 版本默认的严格模式 不兼容

下面是我的解决方案

1
2
3
4
5
6
7
8
9
10
11
<select id="getTotalByDate" resultType="com.xq.tmall.entity.OrderGroup">
SELECT
MIN(productOrder_pay_date) AS productOrder_pay_date,
count(productOrder_id) as productOrder_count,
productOrder_status
FROM productOrder
<where>
productOrder_pay_date BETWEEN #{beginDate} AND #{endDate}
</where>
GROUP BY DATE_FORMAT(productOrder_pay_date,'%Y-%m-%d'), productOrder_status
</select>

这么改了之后是可以进入管理后台了,但代价就是近7天要是有购买数据的话后台就直接崩掉了。。。(幸好我们只是拿来测试,可以直接在数据库删完数据后再进入后台就可以了,不知道师傅们有没有更好的解决方案

审计

由于该项目的代码体量不多,这次我基本是直接看着源码查漏洞的

sql注入

是mybatis数据库,全局搜索关键词${ || +

这里选的是ProductMapper.xml中的一个搜索语句,位于 ORDER BY关键字后面

image-20250922154222155

然后我们一路向上查找用法,走到ProductServiceImpl.java的getList方法处,继续查找用法,这次要注意的是查找到的要是我们可以控的orderUtil参数的相关方法,而不是已经默认为null的

再加上有前台和后台之分,我们尽量找前台,而不是需要管理员权限的后台相关代码

走到了ForeProductListController.java的searchProduct方法中

image-20250922154548941

1
2
3
4
5
6
7
8
@RequestMapping(value = "product/{index}/{count}", method = RequestMethod.GET)
public String searchProduct(HttpSession session, Map<String, Object> map,
@PathVariable("index") Integer index/* 页数 */,
@PathVariable("count") Integer count/* 行数*/,
@RequestParam(value = "category_id", required = false) Integer category_id/* 分类ID */,
@RequestParam(value = "product_name", required = false) String product_name/* 产品名称 */,
@RequestParam(required = false) String orderBy/* 排序字段 */,
@RequestParam(required = false, defaultValue = "true") Boolean isDesc/* 是否倒序 */) throws UnsupportedEncodingException

除了index和count参数是必需外,剩下的参数都是可加可不加的,所以我们可以直接进行测试

这里用的是时间盲测的手法

image-20250922155243202

该篇就只讲这一处的sql漏洞,剩下几处就不多讲了

前台文件上传

全局搜索关键词:upload || 上传 || write || new File

这里找到前台的ForeUserController.java的uploadUserHeadImage方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RequestMapping(value = "user/uploadUserHeadImage", method = RequestMethod.POST, produces = "application/json;charset=utf-8")
public String uploadUserHeadImage(@RequestParam MultipartFile file, HttpSession session
){
String originalFileName = file.getOriginalFilename();
logger.info("获取图片原始文件名:{}", originalFileName);
String extension = originalFileName.substring(originalFileName.lastIndexOf('.'));
String fileName = UUID.randomUUID() + extension;
String filePath = session.getServletContext().getRealPath("/") + "res/images/item/userProfilePicture/" + fileName;
logger.info("文件上传路径:{}", filePath);
JSONObject jsonObject = new JSONObject();
try {
logger.info("文件上传中...");
file.transferTo(new File(filePath));
logger.info("文件上传成功!");
jsonObject.put("success", true);
jsonObject.put("fileName", fileName);
} catch (IOException e) {
logger.warn("文件上传失败!");
e.printStackTrace();
jsonObject.put("success", false);
}
return jsonObject.toJSONString();
}

可以发现对上传上来的文件没有做任何的校验,再加上该项目中是可以解析jsp文件的,所以我们可以上传Jsp马

前端做了一定的校验,只允许上传图片

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
//图片上传
function uploadImage(fileDom) {
//获取文件
var file = fileDom.files[0];
//判断类型
var imageType = /^image\//;
if (file === undefined || !imageType.test(file.type)) {
alert("请选择图片!");
return;
}
//判断大小
if (file.size > 512000) {
alert("图片大小不能超过500K!");
return;
}
//清空值
$(fileDom).val('');
var formData = new FormData();
formData.append("file", file);
//上传图片
$.ajax({
url: "/tmall/user/uploadUserHeadImage",
type: "post",
data: formData,
contentType: false,
processData: false,
dataType: "json",
mimeType: "multipart/form-data",
success: function (data) {
if (data.success) {
$(fileDom).prev("img").attr("src","/tmall/res/images/item/userProfilePicture/"+data.fileName);
$("#user_profile_picture_src_value").val(data.fileName);
} else {
alert("图片上传异常!");
}
},
beforeSend: function () {
},
error: function () {

}
});
}

通过bp抓包即可绕过

image-20250922160427748

上传成功,用蚁剑进行连接测试

image-20250922160506567

后台文件上传

该漏洞需要管理员权限才可以成功利用

首先是ProductController.java的uploadProductImage方法

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
@ResponseBody
@RequestMapping(value = "admin/uploadProductImage", method = RequestMethod.POST, produces = "application/json;charset=utf-8")
public String uploadProductImage(@RequestParam MultipartFile file, @RequestParam String imageType, HttpSession session) {
String originalFileName = file.getOriginalFilename();
logger.info("获取图片原始文件名:{}", originalFileName);
String extension = originalFileName.substring(originalFileName.lastIndexOf('.'));
String filePath;
String fileName = UUID.randomUUID() + extension;
if ("single".equals(imageType)) {
filePath = session.getServletContext().getRealPath("/") + "res/images/item/productSinglePicture/" + fileName;
} else {
filePath = session.getServletContext().getRealPath("/") + "res/images/item/productDetailsPicture/" + fileName;
}

logger.info("文件上传路径:{}", filePath);
JSONObject object = new JSONObject();
try {
logger.info("文件上传中...");
file.transferTo(new File(filePath));
logger.info("文件上传完成");
object.put("success", true);
object.put("fileName", fileName);
} catch (IOException e) {
logger.warn("文件上传失败!");
e.printStackTrace();
object.put("success", false);
}
return object.toJSONString();
}

一样也是没有进行任何的校验,查看了前端代码只有前端做了限制只允许上传图片

跟前台文件上传一样的手法就可以了

image-20250922161006329

用蚁剑成功连上

除了该处漏洞外后台还有其他几处文件上传漏洞

src/main/java/com/xq/tmall/controller/admin/CategoryController.java的uploadCategoryImage方法

src/main/java/com/xq/tmall/controller/admin/AccountController.java的uploadAdminHeadImage方法

越权

俗话说的好,好饭不怕晚,越权的洞当然是压轴的了

这里用的是其他类继承一个基础类BaseController来实现鉴权

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
package com.xq.tmall.controller;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import javax.servlet.http.HttpSession;

/**
* 基控制器
*/
public class BaseController {
//log4j
protected Logger logger = LogManager.getLogger(LogManager.ROOT_LOGGER_NAME);

//检查管理员权限
protected Object checkAdmin(HttpSession session){
Object o = session.getAttribute("adminId");
if(o==null){
logger.info("无管理权限,返回管理员登陆页");
return null;
}
logger.info("权限验证成功,管理员ID:{}",o);
return o;
}

//检查用户是否登录
protected Object checkUser(HttpSession session){
Object o = session.getAttribute("userId");
if(o==null){
logger.info("用户未登录");
return null;
}
logger.info("用户已登录,用户ID:{}", o);
return o;
}
}

从代码里面可以发现是没有任何的逻辑漏洞等等的,也就是说只要用好checkAdmin和checkUser这两个方法那么就完全绕过不了了

但是有时候代码无错不代表一定安全,因为开发人员也会出现错误

这里我翻代码的时候找到了ProductController.java的deleteProductImageById方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@ResponseBody
@RequestMapping(value = "admin/productImage/{productImage_id}",method = RequestMethod.DELETE,produces = "application/json;charset=utf-8")
public String deleteProductImageById(@PathVariable Integer productImage_id/* 产品图片ID */){
JSONObject object = new JSONObject();
logger.info("获取productImage_id为{}的产品图片信息",productImage_id);
ProductImage productImage = productImageService.get(productImage_id);

logger.info("删除产品图片");
Boolean yn = productImageService.deleteList(new Integer[]{productImage_id});
if (yn) {
logger.info("删除图片成功!");
object.put("success", true);
} else {
logger.warn("删除图片失败!事务回滚");
object.put("success", false);
throw new RuntimeException();
}
return object.toJSONString();
}

该类中就这么一个方法没有用上checkAdmin方法,于是我们就实现了垂直越权操作

image-20250922162724210

去后台查看发现确实是少了一张图片了

image-20250922162803543

在写这篇文章的时候我看了看这个方法的代码,发现它所需的参数中并没有session或者是cookie,那么是不是代表我们可以实现未登录的情况下任意删除图片,进行测试

image-20250922163128551

确实如此,意外之喜呐

还有一个方法是CategoryController.java的updateCategory方法

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
@ResponseBody
@RequestMapping(value = "admin/category/{category_id}", method = RequestMethod.PUT, produces = "application/json;charset=utf-8")
public String updateCategory(@RequestParam String category_name/* 分类名称 */,
@RequestParam String category_image_src/* 分类图片路径 */,
@PathVariable("category_id") Integer category_id/* 分类ID */) {
JSONObject jsonObject = new JSONObject();
logger.info("整合分类信息");
Category category = new Category()
.setCategory_id(category_id)
.setCategory_name(category_name)
.setCategory_image_src(category_image_src.substring(category_image_src.lastIndexOf("/") + 1));
logger.info("更新分类信息,分类ID值为:{}", category_id);
boolean yn = categoryService.update(category);
if (yn) {
logger.info("更新成功!");
jsonObject.put("success", true);
jsonObject.put("category_id", category_id);
} else {
jsonObject.put("success", false);
logger.info("更新失败!事务回滚");
throw new RuntimeException();
}

return jsonObject.toJSONString();
}

该方法同样是没有进行鉴权的操作

上述的两个方法虽然说都可以实现垂直越权,但是在真实环境中可以说是危害不大,直到我发现了下面这个

位于UserController.java的getUserBySearch方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
    @ResponseBody
@RequestMapping(value = "admin/user/{index}/{count}", method = RequestMethod.GET, produces = "application/json;charset=UTF-8")
public String getUserBySearch(@RequestParam(required = false) String user_name/* 用户名称 */,
@RequestParam(required = false) Byte[] user_gender_array/* 用户性别数组 */,
@RequestParam(required = false) String orderBy/* 排序字段 */,
@RequestParam(required = false, defaultValue = "true") Boolean isDesc/* 是否倒序 */,
@PathVariable Integer index/* 页数 */,
@PathVariable Integer count/* 行数 */) throws UnsupportedEncodingException {
//移除不必要条件
Byte gender = null;
if (user_gender_array != null && user_gender_array.length == 1) {
gender = user_gender_array[0];
}

if (user_name != null) {
//如果为非空字符串则解决中文乱码:URLDecoder.decode(String,"UTF-8");
user_name = "".equals(user_name) ? null : URLDecoder.decode(user_name, "UTF-8");
}
if (orderBy != null && "".equals(orderBy)) {
orderBy = null;
}
..........

可以发现该方法没有用到checkAdmin方法进行鉴权,直接测试垂直越权

image-20250922164741435

获取前十个管理员的所有信息,实现了任意管理员账户接管

我们也可以加上条件来查询一个特定管理员的信息

image-20250922164941694

结语

该项目审计起来难度不大,就是自己测试的时候环境搭建麻烦了点。。。。

还有其他思路的欢迎师傅与我进行交流