华夏ERPCMS v2.3的审计
华夏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- 这里主要是创建了一个名为LogCostFilter的过滤器,接着创建了两个核心参数
接着来看一下doFilter方法,重点看一下几个调用doFilter的地方
首先就是判断当前用户是否登录,如果没有登录就跳转等登录页
1
2
3
4
5Object userInfo = servletRequest.getSession().getAttribute("user");
if(userInfo!=null) { //如果已登录,不阻止
chain.doFilter(request, response);
return;
}接着判断url中是否包含doc.html、register.html、login.html,如果拦截这几个会导致业务无法正常运行
1
2
3
4
5if (requestUrl != null && (requestUrl.contains("/doc.html") ||
requestUrl.contains("/register.html") || requestUrl.contains("/login.html"))) {
chain.doFilter(request, response);
return;
}接下来就是一个verify方法,这个方法可以添加放行的路径
1
2
3
4if (verify(ignoredList, requestUrl)) {
chain.doFilter(servletRequest, response);
return;
}
SQL注入
在pom.xml里面我们得知使用了mybatis,在这个框架中使用${}、#{}来获取参数,那么我们就可以在mapper_xml文件夹下全局搜索
$、like、in、order by这几个关键字,但是有个前提就是获取的参数需要时可控的全局搜索like,在业务点最明显的UserMapperEx.xml里面找到两个可控点

进入UserMapperEx接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20public interface UserMapperEx {
//按照条件分页查询用户扩展信息
List<UserEx> selectByConditionUser(
String userName,
String loginName,
Integer offset,
Integer rows);
//按条件统计用户总数
Long countsByUser(
String userName,
String loginName);
List<User> getUserListByUserNameOrLoginName( String userName,
String loginName);
int batDeleteOrUpdateUser( String ids[], byte status);
List<TreeNodeEx> getNodeTree();
List<TreeNodeEx> getNextNodeTree(Map<String, Object> parameterMap);
}接下来去看对应service与controller接口
1
2
3
4
5
6
7
8
9public 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
public Object addUser( 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;
}尝试添加一个用户来测试一下这个功能点

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

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

我们来看一下这个路由为什么会存在sql注入
在之前查找countUser方法的时候其实还有一个地方调用了,在UserComponent的counts
1
2
3
4
5
6public 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,最后找到

接着来分析一下这段代码什么意思
- 首先会将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
public String getList( String apiName,
Integer pageSize,
Integer currentPage,
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
60package 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;
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这几个部分,那么我们就可以利用这个来绕过过滤器
发现没有登录也可以成功访问到主页面

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

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

修复方案,可以直接对../进行过滤
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
46package 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;
public class pathfilter implements Filter {
private static final String[] BLACKLIST = {"../","./","..//","%2e%2e%2f","%2e%2e%5c","..\\"};
public void init(FilterConfig filterConfig) throws ServletException {
}
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);
}
public void destroy() {
}
public boolean isblacklisted(String uri){
for(String str:BLACKLIST){
if(uri.contains(str)){
return true;
}
}
return false;
}
}
xss
随便找个输入点进行测试

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

同时测试一下别的输入点,发现基本都会触发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
80package 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;
public class xssfilter implements Filter {
FilterConfig filterConfig = null;
private List<String> urlExclusionList = null;
public void init(FilterConfig filterConfig) throws ServletException {
this.filterConfig = filterConfig;
urlExclusionList = new ArrayList<>();
urlExclusionList.add("/static/");
}
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("\\)", ")");
value = value.replaceAll("'", "'");
value = value.replaceAll("eval\\((.*)\\)", "");
value = value.replaceAll("[\\\"\\\'][\\s]*javascript:(.*)[\\\"\\\']", "\"\"");
value = value.replaceAll("script", "");
return value;
}
}
public void destroy() {
this.filterConfig = null;
}
}
fastjson反序列化
在前面看pom.xml的时候发现使用了fastjson,我们直接全局搜索parseObject

跟进到StringUtil.getInfo
1
2
3
4
5
6
7
8
9
10
11public 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"}

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

越权
重置密码
对应的接口是/updateUser,跟进来看一下
1
2
3
4
5
6
7
8
9
public Object updateUser( String beanJson, 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
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
public Object deleteUser( 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
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
public Object updateUser( String beanJson, Long id)throws Exception{
JSONObject result = ExceptionConstants.standardSuccess();
UserEx ue= JSON.parseObject(beanJson, UserEx.class);
ue.setId(id);
userService.updateUserAndOrgUserRel(ue);
return result;
}跟进,发现这里有一个检查用户名和登录名的,我们跟进去查看一下

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