【web安全系列】scriptEngine注入防御

ScriptEngine注入

Java SE 6内嵌了对脚本支持,提供了一些接口来定义一个脚本规范,也就是JSR223。通过实现这些接口,Java SE 6可以支持任意的脚本语言(如PHP或Ruby)。

ScriptEngine官方定义

ScriptEngine is the fundamental interface whose methods must be fully functional in every implementation of this specification. These methods provide basic scripting functionality. Applications written to this simple interface are expected to work with minimal modifications in every implementation. It includes methods that execute scripts, and ones that set and get values.

在java1.8以前,java内置的javascript解析引擎是基于Rhino。自JDK8开始,使用新一代的javascript解析名为Oracle Nashorn。Nashorn在jdk15中被移除。所以下面的命令执行在JDK8-JDK15都是适用的。

ScriptEngineManager,脚本引擎的管理类,用来创建脚本引擎,在类加载的时候通过spi的方式,扫描classpath中已经包含实现的所有ScriptEngineFactory,载入后用来负责生成具体的ScriptEngine。

– 获取支持的所有js引擎信息

public static void main(String[] args) {     
	ScriptEngineManager manager = new ScriptEngineManager(); 
	List<ScriptEngineFactory> factories = manager.getEngineFactories();
 	for (ScriptEngineFactory factory: factories){  
	    System.out.printf( "Name: %s%n" + "Version: %s%n" + "Language name: %s%n" +  "Language version: %s%n" +                    "Extensions: %s%n" + "Mime types: %s%n" +                    "Names: %s%n", factory.getEngineName(), 
	    factory.getEngineVersion(), 
	    factory.getLanguageName(),    
        factory.getLanguageVersion(),  
        factory.getExtensions(),   
        factory.getMimeTypes(),      
        factory.getNames()               
        ]);        } }

Name: Oracle Nashorn
Version: 1.8.0_261
Language name: ECMAScript
Language version: ECMA - 262 Edition 5.1
Extensions: [js]
Mime types: [application/javascript, application/ecmascript, text/javascript, text/ecmascript]
Names: [nashorn, Nashorn, js, JS, JavaScript, javascript, ECMAScript, ecmascript]

通过结果中的Names,我们知道了所有的js引擎名称故getEngineByName的参数可以填[nashorn, Nashorn, js, JS, JavaScript, javascript, ECMAScript, ecmascript],举个例子:

String test="function fun(a,b){ return a+b; }; print(fun(1,4));";
ScriptEngineManager manager = new ScriptEngineManager(null);
//根据name获取解析引擎,在jdk8环境下下面输入的js和nashorn获取的解析引擎是相同的。
ScriptEngine engine = manager.getEngineByName("js");
engine.eval(test);
//执行结果
//5

上面的代码很简单就是定义了一个js函数加法函数fun,然后执行fun(1,4),就会得到结果。

脚本攻击

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

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

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

java安全处理器

安全管理器 SecurityManager
java.lang.SecurityManager
具体来说, SecurityManager 可以对JAVA中的诸如文件访问、命令执行、反射的方法的精准控制。
安全管理器是Java沙箱的基础组件。我们一般所说的打开沙箱,也就是加-Djava.security.manager选项。
其实日常的很多API都涉及到安全管理器,它的工作原理一般是:

  1. 请求Java API
  2. Java API使用安全管理器判断许可权限
  3. 通过则顺序执行,否则抛出一个Exception
    文件读取的控制

以 FileInputStream() 为例进行说明:

public FileInputStream(File file) throws FileNotFoundException {
    String name = (file != null ? file.getPath() : null);
    SecurityManager security = System.getSecurityManager();
    if (security != null) {
        security.checkRead(name);
    }
    if (name == null) {
        throw new NullPointerException();
    }
    if (file.isInvalid()) {
        throw new FileNotFoundException("Invalid file path");
    }
    fd = new FileDescriptor();
    fd.attach(this);
    path = name;
    open(name);
}

其中Java代码,

SecurityManager security = System.getSecurityManager();
if (security != null) {
    security.checkRead(name);
}

就是利用 SecurityManager 对权限进行校验。

命令执行的控制

public Process start() throws IOException {
    // Must convert to array first -- a malicious user-supplied
    // list might try to circumvent the security check.
    String[] cmdarray = command.toArray(new String[command.size()]);
    cmdarray = cmdarray.clone();

    for (String arg : cmdarray)
        if (arg == null)
            throw new NullPointerException();
    // Throws IndexOutOfBoundsException if command is empty
    String prog = cmdarray[0];

    SecurityManager security = System.getSecurityManager();
    if (security != null)
        security.checkExec(prog);

    // ...
    // other code
}

可以看到同样利用 SecurityManager 对权限进行校验

SecurityManager security = System.getSecurityManager();
if (security != null)
    security.checkExec(prog);
反射的控制

java.lang.Class:getDeclaredMethods()

public Method[] getDeclaredMethods() throws SecurityException {
    checkMemberAccess(Member.DECLARED, Reflection.getCallerClass(), true);
    return copyMethods(privateGetDeclaredMethods(false));
}

跟踪进入到 java.lang.Class:checkMemberAccess() :

private void checkMemberAccess(int which, Class<?> caller, boolean checkProxyInterfaces) {
    final SecurityManager s = System.getSecurityManager();
    if (s != null) {
        /* Default policy allows access to all {@link Member#PUBLIC} members,
            * as well as access to classes that have the same class loader as the caller.
            * In all other cases, it requires RuntimePermission("accessDeclaredMembers")
            * permission.
            */
        final ClassLoader ccl = ClassLoader.getClassLoader(caller);
        final ClassLoader cl = getClassLoader0();
        if (which != Member.PUBLIC) {
            if (ccl != cl) {
                s.checkPermission(SecurityConstants.CHECK_MEMBER_ACCESS_PERMISSION);
            }
        }
        this.checkPackageAccess(ccl, checkProxyInterfaces);
    }
}

同样存在 SecurityManager 利用 checkPermission() 对操作的校验。

自定义 SecurityManager

通过上述的示例演示,可以知道 SecurityManager 在很多关键的位置都进行了动作的校验。在上一节中,我们通过策略文件同样就是对一些关键操作进行了权限定义。当JAVA程序运行至该操作时就会检查此权限。当然我们也可以通过自定义 SecurityManager 来实现对某些文件的访问控制、某些操作的访问控制。

如果我们需要实现自定义的访问控制,我们需要继承 SecurityManager 类,然后在其中实现自己的权限控制的方法。在 java.lang.SecurityManager 中定义了很多的权限检测的方法,包括 checkConnect() 、 checkDelete() 、 checkExec() 、 checkListen() 、 checkRead() 、 checkPropertiesAccess() 等等。所有的这些方法都会最终调用 checkPermission() 。所以如果我们要实现自定义的访问控制,那么我们就可以尝试重载 checkPermission() 方法。

对文件的访问控制

以文件访问控制为例:

import java.io.FileInputStream;
import java.io.IOException;

public class TestSecurityManager {
    public static void main(String args[]) throws IOException {
        System.setSecurityManager(new MySecurityManager());
        FileInputStream fis = new FileInputStream("./test.txt");
        byte[] bs = new byte[1024];
        fis.read(bs);
        fis.close();
    }
}

class MySecurityManager extends SecurityManager {
    @Override
    public void checkPermission(Permission perm) {
        if(perm instanceof FilePermission) {
            String action = perm.getActions();
            if (action.equals("read")) {
                String filename = perm.getName();
                if (filename.contains(".txt")) {
                    throw new SecurityException("No Access" + filename);
                }
            }
        }
    }
}

这样写的比较的复杂。因为在 SecurityManager 中直接存在 checkRead() 方法用于对访问文件的控制,所以我们也可以选择直接重载 checkRead() 方法。如下:

class MySecurityManager extends SecurityManager {

    @Override
    public void checkRead(String file) {
        if (file.contains(".txt")) {
            throw new SecurityException("No Access " + file);
        }
    }

    @Override
    public void checkRead(String file, Object context) {
        checkRead(file);
    }
}

运行程序之后就会抛出 SecurityException 的错误。

限制命令执行

import java.io.File;
import java.io.FileInputStream;
import java.io.FilePermission;
import java.io.IOException;
import java.security.Permission;

public class TestSecurityManager {
    public static void main(String args[]) throws IOException {
        System.setSecurityManager(new MySecurityManager());
        Runtime.getRuntime().exec("calc.exe");
    }
}

class MySecurityManager extends SecurityManager {

    @Override
    public void checkExec(String cmd) {
        if (cmd.contains("calc.exe")) {
            throw new SecurityException("forbidden execute");
        }
    }
}

运行上述的程序就会抛出 SecurityException 错误。

以上就是一个简单的Demo。这个仅仅只是实现了对 calc.exe 的禁止,如果要实现对其他方法的限制,上述Demo的方式是明显不行的。下面是相对来说一个禁止命令执行的通用版本。

public class TestSecurityManager {
    public static void main(String args[]) throws IOException {
        System.setSecurityManager(new MySecurityManager());
        Runtime.getRuntime().exec("calc.exe");
    }
}

class MySecurityManager extends SecurityManager {
    @Override
    public void checkPermission(Permission perm) {
        if (perm instanceof FilePermission) {
            String action = perm.getActions();
            if (action != null && action.contains("execute")) {
                throw new SecurityException("forbidden execute");
            }
        }
    }
}

注意需要通过 FilePermission 来对权限进行控制。因为最终的命令执行其实最终都会调用本地文件来执行代码,所以通过对 FilePermission 的检测,判断是否存在 execute 的动作,从而禁止命令执行。

其他

通过这种防护是不是就一定万无一失了呢?如果 setSecurityManager 被攻击者设置为null,这样就导致了我们所有的安全检查全部失效了,所以我们也需要保护我们自定义的 SecurityManager 。如下:

class MySecurityManager extends SecurityManager {
    @Override
    public void checkPermission(Permission perm) {
        // 禁止设置新的SecurityManager,保护自己
        if (perm instanceof java.lang.RuntimePermission) {
            String name = perm.getName();
            if (name != null && name.contains("setSecurityManager")) {
                throw new SecurityException("System.setSecurityManager denied!");
            }
        }
    }
}

当我们设置了之后,我们通过检查 setSecurityManager 方法禁止其他人对 SecurityManager 进行设置。

总结

总的来说,当需要执行第三方的未知代码时,使用 SecurityManager 来设置一些白名单、黑名单也是一个非常好的方法。至于如何到底是选择策略文件还是通过代码的方式来实现,主要是看自己项目的需求

黑名单

国内一些单那个如阿里云提供java沙箱:
https://help.aliyun.com/document_detail/27967.html?spm=5176.doc51823.6.647.rt0efa

mport 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() {};

黑名单总是存在潜在的风险,总会出现新的绕过思路。而白名单就比黑名单好很多,但是又失去了很多灵活性。

如果没有禁用eval,会有什么样的绕过方式呢?下面的套娃,就可以实现

var a = mainOutput(); function mainOutput() { new javax.script.ScriptEngineManager().getEngineByName("js").eval("var a = test(); function test() { var x=java.lang."+"Runtime.getRuntime().exec(\"calc\");};"); };

其它绕过

因为黑名单中已经禁用了java.lang.ClassLoader和java.lang.Class当时就是想着防止反射调用和ClassLoader加载。
这个绕过还是很有意思的,先通过子类获取ClassLoader类,然后通过反射执行ClassLoader的definClass方法,从字节码中加载一个恶意类。下面的classBytes存储的就是一个恶意类,后面通过实例恶意类完成攻击

String test55 = "var clazz = java.security.SecureClassLoader.class;\n" +
                "        var method = clazz.getSuperclass().getDeclaredMethod('defineClass', 'anything'.getBytes().getClass(), java.lang.Integer.TYPE, java.lang.Integer.TYPE);\n" +
                "        method.setAccessible(true);\n" +
                "        var classBytes = 'yv66vgAAADQAHwoABgASCgATABQIABUKABMAFgcAFwcAGAEABjxpbml0PgEAAygpVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBABJMb2NhbFZhcmlhYmxlVGFibGUBAAR0aGlzAQAJTEV4cGxvaXQ7AQAKRXhjZXB0aW9ucwcAGQEAClNvdXJjZUZpbGUBAAxFeHBsb2l0LmphdmEMAAcACAcAGgwAGwAcAQAEY2FsYwwAHQAeAQAHRXhwbG9pdAEAEGphdmEvbGFuZy9PYmplY3QBABNqYXZhL2lvL0lPRXhjZXB0aW9uAQARamF2YS9sYW5nL1J1bnRpbWUBAApnZXRSdW50aW1lAQAVKClMamF2YS9sYW5nL1J1bnRpbWU7AQAEZXhlYwEAJyhMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9Qcm9jZXNzOwAhAAUABgAAAAAAAQABAAcACAACAAkAAABAAAIAAQAAAA4qtwABuAACEgO2AARXsQAAAAIACgAAAA4AAwAAAAQABAAFAA0ABgALAAAADAABAAAADgAMAA0AAAAOAAAABAABAA8AAQAQAAAAAgAR';" +
                "        var bytes = java.util.Base64.getDecoder().decode(classBytes);\n" +
                "        var constructor = clazz.getDeclaredConstructor();\n" +
                "        constructor.setAccessible(true);\n" +
                "        var clz = method.invoke(constructor.newInstance(), bytes, 0 , bytes.length);\nprint(clz);" +
                "        clz.newInstance();";

恶意类的代码如下。上面的classBytes就是Exploit类的字节码

import java.io.IOException;

public class Exploit {
    public Exploit() throws IOException {
        Runtime.getRuntime().exec("calc");
    }
}

从上面的代码让我意识到禁用java.lang.Class是不可能就阻止反射的,于是我开始思考一个反射poc中的哪些是重要的关键字。反射方法的调用和实例化都是关键的一步,他们一定需要执行。所以我禁掉了这两个关键字。
新的黑名单就这么形成了。

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",
            //反射关键字
            "invoke","newinstance",
            // JavaScript 方法
            "eval", "new function",
            //引擎特性
            "Java.type","importPackage","importClass","JavaImporter"
            );

还可以通过nicode方式绕过:

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

最后的修复方案

class KeywordCheckUtils7 {

    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",
            //反射关键字
            "invoke","newinstance",
            // JavaScript 方法
            "eval", "new function",
            //引擎特性
            "Java.type","importPackage","importClass","JavaImporter"
            );

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

    public static void checkInsecureKeyword(String code) throws Exception {
        // 去除注释
        String removeComment = StringUtils.replacePattern(code, "(?:/\\*(?:[^*]|(?:\\*+[^*/]))*\\*+/)|(?://.*[\n\r\u2029\u2028])", " ");
        //去除特殊字符
        removeComment =StringUtils.replacePattern(removeComment,"[\u2028\u2029\u00a0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\ufeff]","");
        // 去除空格
        String removeWhitespace = StringUtils.replacePattern(removeComment, "\\s+", "");
        // 多个空格替换为一个
        String oneWhiteSpace = StringUtils.replacePattern(removeComment, "\\s+", " ");
        System.out.println(removeWhitespace);
        System.out.println(oneWhiteSpace);
        Set<String> insecure = blacklist.stream().filter(s -> StringUtils.containsIgnoreCase(removeWhitespace, s) ||
                StringUtils.containsIgnoreCase(oneWhiteSpace, s)).collect(Collectors.toSet());

        if (!CollectionUtils.isEmpty(insecure)) {
            System.out.println("存在不安全的关键字:"+insecure);
            throw new Exception("存在安全问题");
        }else{
            ScriptEngineManager manager = new ScriptEngineManager(null);
            ScriptEngine engine = manager.getEngineByName("js");
            engine.eval(code);
        }
    }
}```


  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值