华夏ERPCMS v2.3的审计

  • 环境搭建:直接打开然后加载一些maven,接着创建数据库,导入文件即可

前期准备

  • 首先先来看pom.xml,看有没有使用什么特殊的依赖

    • 低版本的fastjson

      1
      2
      3
      4
      5
      <dependency>
      <groupId>com.alibaba</groupId>
      <artifactId>fastjson</artifactId>
      <version>1.2.55</version>
      </dependency>
    • 数据库使用的是mybatis框架

    • 同时还发现了一个log4j,可能存在相关漏洞

  • 接下来来看一下filter,看一下过滤器

    • 首先就是

      • 这里主要是创建了一个名为LogCostFilter的过滤器,接着创建了两个核心参数
        • 第一个就是ignoredUrl,这里会忽略css、js、jpg等后缀的资源文件
        • 第二个就是filterPath,这里主要是会忽略/user/login、/user/registerUser以及/v2/api-docs这些路径
      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")})
    • 接着来看一下doFilter方法,重点看一下几个调用doFilter的地方

      • 首先就是判断当前用户是否登录,如果没有登录就跳转等登录页

        1
        2
        3
        4
        5
        Object userInfo = servletRequest.getSession().getAttribute("user");
        if(userInfo!=null) { //如果已登录,不阻止
        chain.doFilter(request, response);
        return;
        }
      • 接着判断url中是否包含doc.html、register.html、login.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);
        return;
        }
      • 接下来就是一个verify方法,这个方法可以添加放行的路径

        1
        2
        3
        4
        if (verify(ignoredList, requestUrl)) {
        chain.doFilter(servletRequest, response);
        return;
        }

SQL注入

  • 在pom.xml里面我们得知使用了mybatis,在这个框架中使用${}、#{}来获取参数,那么我们就可以在mapper_xml文件夹下全局搜索$likeinorder by这几个关键字,但是有个前提就是获取的参数需要时可控的

  • 全局搜索like,在业务点最明显的UserMapperEx.xml里面找到两个可控点

    ScreenShot_2025-12-30_211142_165

  • 进入UserMapperEx接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    public interface UserMapperEx {
    //按照条件分页查询用户扩展信息
    List<UserEx> selectByConditionUser(
    @Param("userName") String userName,
    @Param("loginName") String loginName,
    @Param("offset") Integer offset,
    @Param("rows") Integer rows);
    //按条件统计用户总数
    Long countsByUser(
    @Param("userName") String userName,
    @Param("loginName") String loginName);

    List<User> getUserListByUserNameOrLoginName(@Param("userName") String userName,
    @Param("loginName") String loginName);

    int batDeleteOrUpdateUser(@Param("ids") String ids[], @Param("status") byte status);

    List<TreeNodeEx> getNodeTree();
    List<TreeNodeEx> getNextNodeTree(Map<String, Object> parameterMap);
    }
  • 接下来去看对应service与controller接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public Long countUser(String userName, String loginName)throws Exception {
    Long result=null;
    try{
    result=userMapperEx.countsByUser(userName, loginName);
    }catch(Exception e){
    JshException.readFail(logger, e);
    }
    return result;
    }
  • 接下来去寻找看谁调用了countUser,这里主要就是判断当前在线人数是否大于限制认数,然后调用addUserAndOrgUserRel添加对应用户的信息

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    @PostMapping("/addUser")
    @ResponseBody
    public Object addUser(@RequestParam("info") String beanJson, HttpServletRequest request)throws Exception{
    JSONObject result = ExceptionConstants.standardSuccess();
    Long userNumLimit = Long.parseLong(request.getSession().getAttribute("userNumLimit").toString());
    Long count = userService.countUser(null,null);
    if(count>= userNumLimit) {
    throw new BusinessParamCheckingException(ExceptionConstants.USER_OVER_LIMIT_FAILED_CODE,
    ExceptionConstants.USER_OVER_LIMIT_FAILED_MSG);
    } else {
    UserEx ue= JSON.parseObject(beanJson, UserEx.class);
    userService.addUserAndOrgUserRel(ue);
    }
    return result;
    }
  • 尝试添加一个用户来测试一下这个功能点

    20260104195329

  • 对info进行解码

    1
    {"loginName":"222","username":"222","orgAbr":"","orgaId":"","selectType":"org","orgaUserRelId":"","id":"","position":"","phonenum":"","email":"","userBlngOrgaDsplSeq":"","description":""}
  • 尝试注入,但是发现这个功能点存在转义的部分

    20260104200617

  • 尝试这个页面其他的功能点,在搜索的功能点成功触发

    20260104201044

  • 我们来看一下这个路由为什么会存在sql注入

    • 在之前查找countUser方法的时候其实还有一个地方调用了,在UserComponent的counts

      1
      2
      3
      4
      5
      6
      public Long counts(Map<String, String> map)throws Exception {
      String search = map.get(Constants.SEARCH);
      String userName = StringUtil.getInfo(search, "userName");
      String loginName = StringUtil.getInfo(search, "loginName");
      return userService.countUser(userName, loginName);
      }
    • 接着找谁调用了counts,最后找到

      20260104202101

    • 接着来分析一下这段代码什么意思

      • 首先会将request中的所有请求参数转换成MAP格式,然后将search参数放入parameterMap
      • 接着会进行分页以及分页参数的校验
      • 然后回将查询到的列表数据放入分页对象中,调用counts来查询该业务模块的总数据调试,最后返回响应
      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
      @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}/list,就可能存在sql注入

  • 修复,我们可以写个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
    package com.jsh.erp.filter;

    import org.springframework.stereotype.Component;

    import javax.servlet.*;
    import javax.servlet.annotation.WebFilter;
    import javax.servlet.annotation.WebServlet;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;
    import java.util.Enumeration;

    import static org.reflections.Reflections.log;

    @Component
    @WebFilter(urlPatterns = "/user/list" ,filterName = "sqlfilter")
    public class sqlfilter implements Filter {
    public void destroy() {
    }
    public void init(FilterConfig filterConfig) throws ServletException {

    }
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    HttpServletRequest req = (HttpServletRequest) request;
    HttpServletResponse resp = (HttpServletResponse) response;
    //获取参数名
    Enumeration paramNames = req.getParameterNames();
    String sql = "";
    while (paramNames.hasMoreElements()) {
    String paramName = (String) paramNames.nextElement();
    String[] paramValues = req.getParameterValues(paramName);
    for (int i=0;i<paramValues.length;i++) {
    sql = sql + paramValues[i];
    }
    }
    if (sqlValidate(sql)) {
    log.error("[SQL注入拦截] 恶意参数:{},IP:{}", sql, request.getRemoteAddr());
    response.getWriter().write("{\"code\":403,\"msg\":\"参数非法\"}");
    return;
    }else{
    chain.doFilter(request, response);
    }
    }
    public static boolean sqlValidate(String str) {
    str = str.toLowerCase();
    String badStr =
    "'|--|#|;|%27|%2D%2D|%23|%3B|" +
    "and|or|not|xor|&|\\|\\||=" + "|" +
    "select|insert|update|delete|drop|truncate|alter|create|rename|replace|grant|revoke|" +
    "exec|execute|call|declare|master|table|into|from|where|union|join|having|group by|order by|" +
    "sleep|benchmark|count|sum|substr|ascii|char|hex|extractvalue|updatexml|floor|rand|concat";
    String[] badStrs = badStr.split("\\|");
    for (int i = 0; i < badStrs.length; i++) {
    if (str.indexOf(badStrs[i]) >= 0) {
    return true;
    }
    }
    return false;
    }
    }

未授权

  • 在前面我们说到了有几个部分在没有登录的时候就可以访问,就是doc.html、register.html、login.html这几个部分,那么我们就可以利用这个来绕过过滤器

  • 发现没有登录也可以成功访问到主页面

    20260104212820

  • 第二个地方就是css、js、jpg等后缀的资源文件,可以发现就算没有对应的资源文件也可以访问主页面

    20260104213500

  • 第三个地方就是/user/login、/user/registerUser以及/v2/api-docs这几个路径

    20260104213632

  • 修复方案,可以直接对../进行过滤

    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
    package com.jsh.erp.filter;

    import org.springframework.stereotype.Component;
    import javax.servlet.*;
    import javax.servlet.annotation.WebFilter;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;
    import java.nio.charset.StandardCharsets;
    import static org.reflections.Reflections.log;

    @Component
    @WebFilter(urlPatterns = "/*" , filterName = "pathfilter")
    public class pathfilter implements Filter {
    private static final String[] BLACKLIST = {"../","./","..//","%2e%2e%2f","%2e%2e%5c","..\\"};
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    HttpServletRequest req = (HttpServletRequest) servletRequest;
    HttpServletResponse resp = (HttpServletResponse) servletResponse;
    String uri = req.getRequestURI().toLowerCase();
    if(isblacklisted(uri)){
    log.error("恶意参数:{},IP:{}", uri, req.getRemoteAddr());
    resp.getOutputStream().write("{\"code\":403,\"msg\":\"参数非法\"}".getBytes(StandardCharsets.UTF_8));
    return;
    }
    filterChain.doFilter(servletRequest, servletResponse);
    }

    @Override
    public void destroy() {

    }
    public boolean isblacklisted(String uri){
    for(String str:BLACKLIST){
    if(uri.contains(str)){
    return true;
    }
    }
    return false;
    }
    }

xss

  • 随便找个输入点进行测试20260106190109

  • 保存后发现直接触发了xss,而且每次编辑都会触发

    20260106190205

  • 同时测试一下别的输入点,发现基本都会触发xss,这个版本基本没对xss做过滤

  • 这里的修复方案主要是使用白名单

    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
    package com.jsh.erp.filter;

    import org.springframework.stereotype.Component;

    import javax.servlet.*;
    import javax.servlet.annotation.WebFilter;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletRequestWrapper;
    import java.io.IOException;
    import java.util.ArrayList;
    import java.util.List;

    @Component
    @WebFilter(filterName = "Xssfilter" , urlPatterns = "/*")
    public class xssfilter implements Filter {
    FilterConfig filterConfig = null;
    private List<String> urlExclusionList = null;

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
    this.filterConfig = filterConfig;
    urlExclusionList = new ArrayList<>();
    urlExclusionList.add("/static/");
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    HttpServletRequest req = (HttpServletRequest) servletRequest;
    String servletPath = req.getServletPath();
    if (urlExclusionList!=null&&urlExclusionList.contains(servletPath)) {
    filterChain.doFilter(servletRequest, servletResponse);
    }else{
    filterChain.doFilter((ServletRequest) new XssHttpServletRequestWrapper((HttpServletRequest)servletRequest), servletResponse);
    }
    }

    public List<String> getUrlExclusionList() {
    return urlExclusionList;
    }
    public void setUrlExclusionList(List<String> urlExclusionList) {
    this.urlExclusionList = urlExclusionList;
    }

    class XssHttpServletRequestWrapper extends HttpServletRequestWrapper {
    public XssHttpServletRequestWrapper(HttpServletRequest servletRequest) {
    super(servletRequest);
    }
    public String[] getParameterValues(String parameter) {
    String[] values = super.getParameterValues(parameter);
    if (values == null) return null;
    String[] encodedValues = new String[values.length];
    for (int i = 0; i < values.length; i++) {
    encodedValues[i] = cleanXSS(values[i]);
    }
    return encodedValues;
    }
    public String getParameter(String parameter) {
    String value = super.getParameter(parameter);
    if (value == null) return null;
    return cleanXSS(value);
    }
    public String getHeader(String name) {
    String value = super.getHeader(name);
    if (value == null) return null;
    return cleanXSS(value);
    }
    private String cleanXSS(String value) {
    value = value.replaceAll("\\(", "&\\#40;").replaceAll("\\)", "&#41;");
    value = value.replaceAll("'", "&#39;");
    value = value.replaceAll("eval\\((.*)\\)", "");
    value = value.replaceAll("[\\\"\\\'][\\s]*javascript:(.*)[\\\"\\\']", "\"\"");
    value = value.replaceAll("script", "");
    return value;
    }
    }
    @Override
    public void destroy() {
    this.filterConfig = null;
    }
    }

fastjson反序列化

  • 在前面看pom.xml的时候发现使用了fastjson,我们直接全局搜索parseObject

    20260106195412

  • 跟进到StringUtil.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);
    value = obj.getString(key);
    if(value.equals("")) {
    value = null;
    }
    }
    return value;
    }
  • 接下来找谁调用了getInfo,发现了一个很熟悉的类UserComponent,发现里面的counts调用了这个方法,并且根据上面sql注入的部分可以知道,在search里面存在json参数,那么这里就可能导致json反序列化

  • 构造一个urldns的请求

    1
    search={"@type":"java.net.Inet4Address","val":"vaarolyxib.lfcx.eu.org"}

    20260106203328

  • 成功触发dns请求,但是这个没开启checkAutoType

    20260106203347

越权

重置密码

  • 对应的接口是/updateUser,跟进来看一下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @PostMapping("/updateUser")
    @ResponseBody
    public Object updateUser(@RequestParam("info") String beanJson,@RequestParam("id") Long id)throws Exception{
    JSONObject result = ExceptionConstants.standardSuccess();
    UserEx ue= JSON.parseObject(beanJson, UserEx.class);
    ue.setId(id);
    userService.updateUserAndOrgUserRel(ue);
    return result;
    }
  • 跟到userService来看看,这里其实就很明显了,只对用户名作了判断,并没有使用cookie或者session来判断权限,在判断完用户名就直接到setPassword,所以这里有个很明显的密码重置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    @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;
    }

删除用户

  • 也是先去找对应的路由

    1
    2
    3
    4
    5
    6
    7
    @PostMapping("/deleteUser")
    @ResponseBody
    public Object deleteUser(@RequestParam("ids") String ids)throws Exception{
    JSONObject result = ExceptionConstants.standardSuccess();
    userService.batDeleteUser(ids);
    return result;
    }
  • 查看batDeleteUser,这里甚至都没有任何鉴权的操作,直接就可以删除用户

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    @Transactional(value = "transactionManager", rollbackFor = Exception.class)
    public void batDeleteUser(String ids) throws Exception{
    StringBuffer sb = new StringBuffer();
    sb.append(BusinessConstants.LOG_OPERATION_TYPE_DELETE);
    List<User> list = getUserListByIds(ids);
    for(User user: list){
    sb.append("[").append(user.getLoginName()).append("]");
    }
    logService.insertLog("用户", sb.toString(),
    ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest());
    String idsArray[]=ids.split(",");
    int result =0;
    try{
    result=userMapperEx.batDeleteOrUpdateUser(idsArray,BusinessConstants.USER_STATUS_DELETE);
    }catch(Exception e){
    JshException.writeFail(logger, e);
    }
    if(result<1){
    logger.error("异常码[{}],异常提示[{}],参数,ids:[{}]",
    ExceptionConstants.USER_DELETE_FAILED_CODE,ExceptionConstants.USER_DELETE_FAILED_MSG,ids);
    throw new BusinessRunTimeException(ExceptionConstants.USER_DELETE_FAILED_CODE,
    ExceptionConstants.USER_DELETE_FAILED_MSG);
    }
    }

用户信息修改

  • 查看对应接口updateUser

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @PostMapping("/updateUser")
    @ResponseBody
    public Object updateUser(@RequestParam("info") String beanJson,@RequestParam("id") Long id)throws Exception{
    JSONObject result = ExceptionConstants.standardSuccess();
    UserEx ue= JSON.parseObject(beanJson, UserEx.class);
    ue.setId(id);
    userService.updateUserAndOrgUserRel(ue);
    return result;
    }
  • 跟进,发现这里有一个检查用户名和登录名的,我们跟进去查看一下

    20260106205439

  • 里面主要的问题就是只对id进行判断,但是没有进行id与name之间的判断

参考