实例分析JVM安全体系:双亲委派、命名空间、保护域、策略

在了解双亲委派模型之前,先了解一下类加载器的概念:

类加载器的作用就是将真实的class文件根据位置将该Java类的字节码装入内存,并生成对应的Class对象。用户可以通过继承ClassLoader和重写findClass方法来定义自己的类加载器进行加载,系统类加载器按照层次,分为:
(1).启动类加载器(Bootstrap ClassLoader):将加载 /JAVAHOME/lib以及为-Xbootclasspath所指定的目录下的类库,是核心Java API的class文件,用于启动Java虚拟机
(2).扩展类加载器(Extension ClassLoader):将加载/JAVAHOME/lib/ext以及为java.ext.dirs所指定的目录下的类库
(3).应用程序类加载器(Application/System ClassLoader):将加载ClassPath下所指定的类库,或者称为类路径加载器

1.双亲委派
类的加载将使用双亲委派的方式,注意这里的双亲关系并非通过继承来实现,而是加载器之间指定或默认的委托加载关系,可以看到在 /java/lang/ClassLoader.java中,通过ClassLoader的构造方法显式指定了其父加载器,而若没有指定父加载器,那么将 会把系统类加载器AppClassLoader作为默认的父加载器

private ClassLoader(Void unused, ClassLoader parent) {
        this.parent = parent;
        //...
}

protected ClassLoader() {
        //getSystemClassLoader()
        this(checkCreateClassLoader(), getSystemClassLoader());
}

加载器对类的加载调用loadClass()方法实现:

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class c = findLoadedClass(name);
            // 该类没有被加载
            if (c == null) { 
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                    // 先交由父加载器尝试加载
                        c = parent.loadClass(name, false);
                    } else {
                    // 父加载器为空,即为BootstrapClassLoader,那么查看启动类中是否有该类
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }
                //父类无法加载该类,则由自己尝试加载
                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

可见首先会检查该类是否已经被加载,若没有被加载,则会委托父加载器进行装载,只有当父加载器无法加载时,才会调用自身的findClass()方法进行加载。这样避免了子加载器加载一些试图冒名顶替可信任类的不可靠类,也不会让子加载器去实现父加载器实现的加载工作。
比如某个用户自定义的类加载器试图加载一个叫做“java.lang.String”的类,那么,该类会最终委派给启动类加载器 BootstrapClassLoader尝试加载,那么启动类加载器将加载Java API中的”java.lang.String”类而不会通过用户自定义的类加载器去获得和加载这个看上去不怀好意的冒名类。

但是仅仅依赖双亲委派是远远不够的,假设这个用户自定义的类加载器试图加载一个叫做“java.lang.Bomb”的危险类,而父类加载器无 法加载该类,那么加载工作将由用户定义的加载器负责实现。由于一个类的同一包内的类(和其子类)可以访问其protected成员,这个 “java.lang.Bomb”则可能访问可信任类的一些敏感信息,所以就必须将这个类与可信任类的访问域隔离,Java虚拟机只把这样彼此访问的特殊 权限授予由同一个类加载器加载的同一包内的类型,这样一个由同一个类加载器加载的、属于同一个包的多个类型集合称为运行时包。

2.命名空间
类加载体系为不同类加载器加载的类提供不同的命名空间,同一命名空间内的类可以互相访问,不同命名空间的类不知道彼此的存在(除非显式提供访问机制)。同一类可以再不同的命名空间内,但无法在同一命名空间内重复出现。

命名空间是这样定义的:实际完成加载类的工作的加载器为定义类加载器,而加载的双亲委托路径上的所有加载器为初始类加载器,某个加载器的命名空间就是所有以该加载器为初始类加载器的类所组成。

可以预见,子加载器的命名空间包括其父/祖先加载器的命名空间和只有自己才可以加载的类所组成。根据加载体系结构的安全机制,同一命名空间内的类可 以互相访问,所以父加载器所加载的类不一定可以访问子加载器所加载的类,但子加载器所加载的类必然可以访问父加载器加载的类。父加载器加载的类就好像小箱 子,子加载器加载的类可能用到父加载器加载的类,就像一个大箱子,只能把小箱子放进大箱子,而不能反过来做(当然显式的访问机制除外)

以自己实现的类加载器为例:

  package com.ice.classloader;


import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;

public class MyClassLoader extends ClassLoader {

    private String name;    //加载器名称
    private String path = "E:\\WorkSpace\\ClassLoaderTest\\";  //加载路径
    private static final String HOME = "E:\\WorkSpace\\ClassLoaderTest\\";
    private final String classFileType = ".class"; 

    public MyClassLoader(String name) {
        super(); 
        this.name = name;
    }

    public MyClassLoader(ClassLoader parent, String name) {
        super(parent); 
        this.name = name;
    }

    @Override
    public String toString() {
        return this.name;
    }

    public String getPath() {
        return path;
    }

    public void setPath(String path) {
        this.path = path;
    }

    @Override
    public Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] data = this.loadClassData(name);
        if(data == null)
            throw new ClassNotFoundException();
        return this.defineClass(name, data, 0, data.length);

    }

    private byte[] loadClassData(String name) {

        InputStream is = null;
        byte[] data = null;
        ByteArrayOutputStream baos = null;
//        System.out.println("  classloader:" + this.name + " try to load");
        try {
            //类名转化为路径
            name = name.replace(".", "\\");
            is = new FileInputStream(new File(path + name + classFileType));

            baos = new ByteArrayOutputStream();
            int ch = 0;
            while (-1 != (ch = is.read())) {

                baos.write(ch);
            }

            data = baos.toByteArray();
        }
        catch (FileNotFoundException e) {
//            e.printStackTrace();
            return null;
        }
        catch (IOException ioe) {
            ioe.printStackTrace();
      }
        finally {
            try {
                is.close();
                baos.close();
            }
            catch (Exception e2) {
            }
        }
        return data;
    }


    public static void main(String[] args) throws Exception {
        //假定的系统加载器
        MyClassLoader father = new MyClassLoader("father");
        father.setPath(HOME + "syslib\\");

        MyClassLoader child = new MyClassLoader(father, "child");
        child.setPath(HOME + "ext\\");

        MyClassLoader user = new MyClassLoader("user");
        user.setPath(HOME + "usr\\");
        System.out.println("-------------test parent--------------");
        //测试父加载器关系
        traverseParent(child);
        System.out.println("-------------test load begin from child--------------");
        //测试加载
        test(child);
        //测试命名空间
        System.out.println("-------------test namespace--------------");
        testNameSpace(user);

    }

    public static void traverseParent(ClassLoader loader) throws Exception{
        if(loader == null) return;
        System.out.println("travase classloader:" + loader.toString());
        while(loader.getParent() != null){
            System.out.println(loader.getParent());
            loader = loader.getParent();
        }
    }

    public static void test(ClassLoader loader) throws Exception {
        Class<?> clazz = loader.loadClass("com.ice.classloader.LoadedClass");
        Object object = clazz.newInstance();
    }

    public static void testNameSpace(ClassLoader loader) throws Exception {
        Class<?> clazz = loader.loadClass("com.ice.classloader.LoadedClass");
        Object object = clazz.newInstance();
        try{
            LoadedClass lc = (LoadedClass) object;
        }catch(Exception e){
            e.printStackTrace();
        }
    }
}

被加载类LoadedClass的定义如下:

//被加载类
package com.ice.classloader;

public class LoadedClass {

    public LoadedClass() {
        System.out.println("LoadedClass is loaded by: "
                + this.getClass().getClassLoader());

    }

}

(1).双亲委派结果
child加载器会委托father进行加载,若father的加载目录下存在着对应的class文件,则会由 父加载器father进行对应的加载工作(father也会交由AppClassLoader和ExtClassLoader尝试进行加载,但这两个加载 器并不知道如何加载,故而最后会自己尝试进行加载)

当father的加载目录下没有对应的class文件,则会交由child进行加载

(2).命名空间隔离
由于MyClassLoader是通过系统的(应用程序类加载器/类路径加载器加载的),而 LoadedClass是由user加载器所加载的,AppClassLoader加载器是user加载器的父加载器,故由父加载器加载的类 MyClassLoader无法看见子加载器user所加载的LoadedClass类,在MyClassLoader中尝试实例化 LoadedClass类时就会出现如下错误:

对应出错的正是尝试实例化LoadedClass类的那一行

try{
    LoadedClass lc = (LoadedClass) object;
}catch(Exception e){

(3).运行时包
当请求加载一个com.ice.classloader.virus类时,AppClassLoader路径下没有 该类的class文件,那么attaker加载器将会加载这个virus类,并暗示其为com.ice.classloader的一部分,该类想要获取 com.ice.classloader包下被信任类的访问权限。但由于权限检查时,由于该Virus类由attacker加载而非 AppClassLoader加载,故对MyClassLoader受保护成员的访问将会被阻止。

package com.ice.classloader;

public class Virus {

    public Virus() {
        System.out.println("Virus is loaded by: "
                + this.getClass().getClassLoader());
        MyClassLoader cl = (MyClassLoader) this.getClass().getClassLoader();
        System.out.println("secret is:" + cl.secret);
    }

}

MyClassLoader 由AppClassLoader所加载,而Virus由用户自定义的加载器attacker所加载,虽然AppClassLoader是attacker 的父加载器,即MyClassLoader对Virus可见,但由于两者不是由同一个加载器所加载,即不属于同一个运行时包,那么Virus对 MyClassLoader的受保护成员访问受限

public class MyClassLoader extends ClassLoader {
    protected int secret = -1;
//...
    public static void main(String[] args) throws Exception {
            //其父加载器为Bootstrap ClassLoader
            MyClassLoader loader = new MyClassLoader(null, "loader");
            loader.setPath(HOME + "usr\\");

            MyClassLoader attacker = new MyClassLoader("attacker");
            attacker.setPath(HOME + "attacker\\");

            System.out.println("MyClassLoader's classloader:" + MyClassLoader.class.getClassLoader());

            System.out.println("-------------test parent--------------");
            //测试父加载器关系
            traverseParent(attacker);

            System.out.println("-------------test in-package access--------------");
            testVirus(attacker);

        }

        public static void traverseParent(ClassLoader loader) throws Exception{
            if(loader == null) return;
            System.out.println("travase classloader:" + loader.toString());
            while(loader.getParent() != null){
                System.out.println(loader.getParent());
                loader = loader.getParent();
            }
        }


        public static void testVirus(ClassLoader loader) throws Exception {
            Class<?> clazz = loader.loadClass("com.ice.classloader.Virus");
            Object object = clazz.newInstance();
        }
    }

结果如下:

注意命名空间的隔离与运行时包隔离的区别,不同命名空间的类之间不可见,而同一命名空间内的类可能由不同的加载器进行加载,如启动类加载器加载 的核心JavaAPI和用户自定义加载器加载的类,这些类及时声明定义为同一个包,但是由于不是由同一个加载器加载的,即不属于同一个运行时包,那么不同 运行时包内的类之间就存在对包可见成员的访问限制。

3.策略与保护域
除了命名空间的访问隔离和双亲委派的受信类保护,类加载器体系还是用保护域来定义代码在运行时可以获得的权限。同样在分析保护域之前,先了解类Java虚拟机的安全访问控制及策略。

Java的沙箱模型可以由用户自定义,这是通过用户定制沙箱的安全管理器(SecurityManager)来定义沙箱的安全边界,以为程序运 行指定用户自定义的安全策略和访问控制。应用程序通过 System.setSecurityManager()/“-Djava.security.manager”来指定/启动安全管理器,每当 JavaAPI执行一些可能不安全的操作时,如对文件的读写和删除等,就会向安全管理器进行权限检查,若权限检查不通过,将会抛出一个安全异常,若权限检 查通过,则允许该操作的执行。

比如创建一个FileInputStream时,会调用SecurityManager的checkRead()进行读取权限的检查:

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();
        }
        fd = new FileDescriptor();
        fd.incrementAndGetUseCount();
        open(name);
    }

checkRead()即以读动作的FilePermission为参数调用checkPermission()

public void checkRead(String file) {
        checkPermission(new FilePermission(file,
            SecurityConstants.FILE_READ_ACTION));
    }

jdk1.2版本后,可以使用checkPermission(Permission perm)和checkPermission(Permission perm, Object context)来进行权限检查,其中perm为请求执行操作所需要的权限,如java.io.FilePermission对“/usr /indata.txt”请求“read”操作。checkPermission()实际上在对当前线程的方法栈进行优化后,获得一个访问控制环境 AccessControlContext,并调用其checkPermission()方法

public static void checkPermission(Permission perm)
                 throws AccessControlException
    {
        //System.err.println("checkPermission "+perm);
        //Thread.currentThread().dumpStack();

        if (perm == null) {
            throw new NullPointerException("permission can't be null");
        }

        AccessControlContext stack = getStackAccessControlContext();
        // if context is null, we had privileged system code on the stack.
        if (stack == null) {
           //...debug相关
            return;
        }

        AccessControlContext acc = stack.optimize();
        acc.checkPermission(perm);
    }

checkPermission()会从方法的栈顶向栈底遍历(检查方法所在类的保护域权限,context是一个 ProtectionDomain数组),当遇到一个没有权限的栈帧就会抛出一个AccessControlException。即对于一次需要进行权限 检查的访问,对于该访问的方法的每一个调用层次都必须具有对应的访问权限。
对权限的判定是通过implies()来进行的,implies()在Permission类、PermissionCollection、ProtectionDomain类中声明。在Permission类(具体实现的子类)中,该方法将确定由该Permission所代表的对象,是否隐含了将要判断的Permission对象的权限中, 如对”/test/*”目录的读写权限testAllPermission,隐含了对”/test/test.txt”文件的读写权限 testFilePermission,即testAllPermission.implies(testFilePermission) 的值为true,反之为false。 在ProtectionDomain(其PermissionCollection)中,将进行权限集合内 implies()的判定,实际上就是在PermissionCollection中遍历保护域所拥有的权限,调用implies()判定其是否具有对应的访问权限。

public void checkPermission(Permission perm)
        throws AccessControlException
    {
       //...
        if (context == null)
            return;

        for (int i=0; i< context.length; i++) {
            if (context[i] != null &&  !context[i].implies(perm)) {
                //...
                throw new AccessControlException("access denied "+perm, perm);
            }
        }

        // ...

        return;
    }

那么,类的访问权限(保护域)是如何指定的?
(1).类与访问权限是什么?
每个class文件均和一个代码来源相关联,这个代码来源(java.security.CodeSource)通过URL类成员location指向代码库和对该class文件进行签名的零个或多个证书对象的数组(class文件在进行代码认证的过程中可能经过多个证书签名,也可能没有进行签名) 。
访问控制策略Policy对权限的授予是以CodeSource为基础进行的,每个CodeSource拥有若干个Permission,这些Permission对象会被具体地以其子类,如FilePermission、SocketPermission等描述,并且和CodeSource相关联的Permission对象将被封装在java.security.PermissionCollection(抽象类)的一个子类实例中,以描述该CodeSource所获取的权限。
(2).从类的加载到保护域探寻类访问权限的指定:
加载器会调用defineClass解析和加载类的Class实例:

protected final Class<?> defineClass(String name, byte[] b, int off, int len,
                                         ProtectionDomain protectionDomain)
        throws ClassFormatError
    {
        protectionDomain = preDefineClass(name, protectionDomain);

        Class c = null;
        String source = defineClassSourceLocation(protectionDomain);

        try {
            c = defineClass1(name, b, off, len, protectionDomain, source);
        } catch (ClassFormatError cfe) {
            c = defineTransformedClass(name, b, off, len, protectionDomain, cfe,
                                       source);
        }

        postDefineClass(c, protectionDomain);
        return c;
    }

在defineClass()中,会调用preDefineClass()获取ProtectionDomain:

private ProtectionDomain preDefineClass(String name,
                                            ProtectionDomain pd)
    {
        if (!checkName(name))
            throw new NoClassDefFoundError("IllegalName: " + name);

        if ((name != null) && name.startsWith("java.")) {
            throw new SecurityException
                ("Prohibited package name: " +
                 name.substring(0, name.lastIndexOf('.')));
        }
        if (pd == null) {
            pd = defaultDomain;
        }

        if (name != null) checkCerts(name, pd.getCodeSource());

        return pd;
    }

当没有指定保护域时,就会为其指定一个空的保护域,若指定了保护域则使用加载器所指定的保护域。

类加载器的实现可以通过将代码来源(CodeSource),即代码库和该class文件的所有签名者信息,传递给当前的 Policy对象的getPermissions()方法,来查询该代码来源所拥有的权限集合PermissionCollection(在策略初始化时 生成),并以此构造一个保护域传递给defineClass(),以此指定类的保护域。

(3).Java应用程序访问控制策略是由抽象类java.security.Policy的子类实例所描述的,通过设置 policy.provider属性值来指定Policy的实现类,该属性值定义在/jre/lib/security/java.security文件 中

#
# Class to instantiate as the system Policy. This is the name of the class
# that will be used as the Policy object.
#
policy.provider=sun.security.provider.PolicyFile

可见默认是使用PolicyFile类来实现访问控制策略,该类将使用从策略文件中读取并解析访问控制策略的方式形成策略。
也可以通过实现自己的Policy并调用Policy的setPolicy()方法来替换当前Policy对象。

对Java应用程序的访问控制策略是由抽象类java.security.Policy的子类实例实现的,其实现方式可以采用很多种方法,如从一个 结构化ASCII文件中读取,从一个Policy的二进制class文件中读取,从一个数据库中读取,PolicyFile就是使用了从ASCII策略文 件中读取的方法,策略文件定义在/jre/lib/security/java.security中:

1 # The default is to have a single system-wide policy file,
2 # and a policy file in the user's home directory.
3 policy.url.1=file:${java.home}/lib/security/java.policy
4 policy.url.2=file:${user.home}/.java.policy

可以在java.security文件中修改或添加policy.url.x来指定用户自己想要的策略,也可以在运行时使用”-Djava.security.policy”命令行参数进行设置,如:
-Djava.security.manager -Djava.security.policy = mypolicy.txt
其中如果没有指定java.security.manager,那么应用程序就不会安装任何的安全管理器,而代码也就没有任何权限限制。mypolicy.txt就是用户自顶一个策略文件,这里使用的是相对路径,将使用程序的启动目录

以/jre/lib/security/java.policy为例说明策略文件,在该文件中使用上下文无关文法描述安全策略
如:

grant codeBase "file:${{java.ext.dirs}}/*" {
    permission java.security.AllPermission;
};

policy文件的基本语法如下:

keystore "keystore_url",
"keystore_type";

grant [SignedBy "signer_names"] [, CodeBase "URL"] [,principal principal_class_name "principal_name",]{
Permission permission_class_name
[ "target_name" ]
[, "action"] [, SignedBy "signer_names"];
Permission ...
};
  • keystore:
    keystore_url指定了签名者的公钥的证书文件所在位置,可以使相对URL,如keystore “mykey”,这个相对路径指向了程序使用该策略文件的启动目录,比如,该策略文件由policy.url.x指定在”e://security /policy/mypolicy.txt”,那么公钥证书就在”e://security/policy/mykey”文件中。当然也可以使用绝对路径 指定公钥路径。
    keystore_type指定了密钥仓库信息的存储和数据格式,也定义了保护密钥仓库中私钥和密钥仓库完整性的算法,默认将使”JKS”类型
  • grant子句:
    授予 指定类型(代码) 指定权限
    其中对代码类型的描述有两种:
    signedBy表示签名者别