有很长一段时间没写文章了,也很久没搞Java相关的漏洞研究了,因为工作需要,忙碌了好一段时间的后端开发,都有点落下安全相关的研究了,实属堕落。

最近研究了一下多个业内使用的RASP实现,对于RASP又有了更加深入的了解,其中,RASP的类加载机制,我个人觉得应该是RASP中最核心的地方,也是最容易出BUG的地方了。对于这个RASP比较核心的类加载机制,我在这篇文章中,将会以OpenRASP为基础例子,去对其原理进行讲解,并给出与之不同的实现,分析出各自实现的优劣点。

通过这篇文章,大伙应该能理解到OpenRASP的类加载设计,更甚者可能会想到其它更好的设计,希望大家可以多多交流,互相进步。


0x01 OpenRASP的Agent和Engine的关联

使用过OpenRASP的小伙伴都知道,OpenRASP存在rasp.jar和rasp-engine.jar这两个jar包,其中,稍微看过OpenRASP代码的小伙伴,应该都了解,OpenRASP中的Agent,也就是rasp.jar,它有两种使用方式:

  • 一种是通过安装器,把其安装到服务器根目录,例如tomcat,然后修改tomcat的启动脚本,插入-javaagent的vm参数,达到tomcat启动时引入OpenRASP
  • 另一种方式则是通过JVM提供的attach机制,在jar运行时,加载OpenRASP到JVM中,动态修改运行中的class

无论是通过哪种方式,在Agent加载到JVM运行时,都会在代码中,加载Engine,也就是rasp-engine.jar,然后启动Engine,Engine中是RASP主要的业务实现,它包含了和后端云控的联系相关代码,也包含了hook、detector等等相关代码


0x02 Agent是如何加载Engine

前面讲到了,在Agent加载到JVM运行时,都会在代码中,加载Engine,也就是rasp-engine.jar,那么,它是通过何种形式被加载的呢?此处看com.baidu.openrasp.ModuleLoader的相关代码

com.baidu.openrasp.ModuleLoader的静态代码块:

static {
    ...
    Class clazz = ModuleLoader.class;
    // path值示例: file:/opt/apache-tomcat-xxx/rasp/rasp.jar!/com/fuxi/javaagent/Agent.class
    String path = clazz.getResource("/" + clazz.getName().replace(".", "/") + ".class").getPath();
    if (path.startsWith("file:")) {
        path = path.substring(5);
    }
    if (path.contains("!")) {
        path = path.substring(0, path.indexOf("!"));
    }
    try {
        baseDirectory = URLDecoder.decode(new File(path).getParent(), "UTF-8");
    } catch (UnsupportedEncodingException e) {
        baseDirectory = new File(path).getParent();
    }
    ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
    while (systemClassLoader.getParent() != null
            && !systemClassLoader.getClass().getName().equals("sun.misc.Launcher$ExtClassLoader")) {
        systemClassLoader = systemClassLoader.getParent();
    }
    moduleClassLoader = systemClassLoader;
}

看代码前半部分,可以看到(其实注释都有了,非常明确),是为了拿到当前Agent的jar,也就是rasp.jar所在的目录地址,因为后续将会通过获取到类加载器,通过类加载器去加载rasp-engine.jar,从而实现Agent加载Engine。

接下来的几行代码才是需要我们关注的地方,它先是调用了

ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();

取得了当前类的类加载器(就是加载当前类的类加载器),既然是要加载rasp-engine.jar,那么就得获取到类加载器去加载。

在讲这几行代码前,可能很多人不太了解类的显式、隐式加载是什么,这里有必要先稍微讲一下吧。


类加载其实可以分为两种加载方式,一种是显式加载,另一种是隐式加载,那么,到底什么是显式加载,什么是隐式加载呢?

  • 显式加载:

显式加载就是通过代码,比如

Class.forName

classLoader.loadClass

等方式加载。

  • 隐式加载:

隐式加载就是JVM在加载某个类的时候,默认会使用引用了该类,并触发该类加载的那个类所属加载器进行加载,有点绕,做个例子:“A类是被类加载器CLA加载的,然后A类中import了B类,并且在A类的构造方法中,调用了B类的某个field或method,那么,在A类被实例化前,JVM会默认通过类加载器CLA去把B类也加载进JVM”。


明白了什么是显式、隐式加载后,我们回到前面的代码中来,前面说到,调用了

ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();

取得了当前类的类加载器,接下来,做了一个while循环,循环中的条件是不断的判断

systemClassLoader.getParent() != null

以及

!systemClassLoader.getClass().getName().equals("sun.misc.Launcher$ExtClassLoader")

这个循环的意义是什么呢?

其实,这里是为了获取到扩展类加载器,通过扩展类加载器去加载Engine。在JVM中,类加载器从上到下,一共分为三级,分别为启动类加载器、扩展类加载器、应用类加载器,其中,启动类加载器在Java代码中,是没办法获取到的,那么,我们能获取到最高级的类加载器就是扩展类加载器了,到这里,大伙可能会问,为什么要获取扩展类加载器,而不是获取应用类加载器呢?

按照类的双亲委派机制,应用类加载器在加载一个类时,首先会委派给它的母亲,也就是扩展类加载器先去尝试加载,而扩展类加载器,也会把这个加载任务委派给它的母亲,启动类加载器去先尝试加载。众所周知,每个类加载器,都会有其读取类文件的路径classpath,通过其classpath所在去加载类的字节码,从而把其转化为JVM中的类。

就这?好像也没解释清楚为什么要获取扩展类加载器?其实并不是的,你细品!

其实,很多时候,比如tomcat,它在运行中,大部分类都是由实现的应用类加载器进行加载的,那么,假如Engine是通过某个应用类加载器进行加载的,而我们的hook代码,在tomcat中应用类加载器加载的某个类,插入了某段代码,这段代码直接(com.xxx.A.a();)调用了Engine的某个类的方法,那么,按照双亲委派机制,以及隐式加载的规范,将会抛出ClassNoFoundError的错误,这里还会有小伙伴问为什么?其实已经讲得很清楚了,因为Engine的应用类加载器和tomcat的应用类加载器产生了隔离。

到这里,应该有小伙伴想到了,如果hook代码,在jdk中的rt.jar的某个类的方法,比如java.lang.Runtime.exec中,插入了某段代码"com.xxx.A.a();",因为类java.lang.Runtime是在rt.jar中的,所以,它是由启动类加载器加载的,那么,按照隐式加载以及双亲委派机制,也会导致找不到类com.xxx.A,然后抛出ClassNoFoundError的错误啊?

是的,没错,理论以及实际上,必然会是这样的,但是,我也没说在java.lang.Runtime.exec()中插入的调用代码必须是"com.xxx.A.a();"才能调用到该方法,那么,到底是如何调用呢?


0x03 把Agent(rasp.jar)追加到启动类加载器的classpath

前一节,留下来了一个问题,启动类加载器加载的java.lang.Runtime中,如何调用com.xxx.A.a();这个方法?

其实也很简单啊,我直接拿到加载com.xxx.A的类加载器,也就是扩展类加载器,不就可以通过classLoader.loadClass加载到类com.xxx.A并调用其方法a了吗?

那么,这个扩展类加载器要怎么才能取到呢?我们看Agent,其启动时,调用的com.baidu.openrasp.Agent#init方法中,调用了JarFileHelper.addJarToBootstrap(inst);,我们看看它的实现:

public static void addJarToBootstrap(Instrumentation inst) throws IOException {
    String localJarPath = getLocalJarPath();
    inst.appendToBootstrapClassLoaderSearch(new JarFile(localJarPath));
}

public static String getLocalJarPath() {
    URL localUrl = Agent.class.getProtectionDomain().getCodeSource().getLocation();
    String path = null;
    try {
        path = URLDecoder.decode(
                localUrl.getFile().replace("+", "%2B"), "UTF-8");
    } catch (UnsupportedEncodingException e) {
        System.err.println("[OpenRASP] Failed to get jarFile path.");
        e.printStackTrace();
    }
    return path;
}

可以看到,它首先获取到了当前Agent的rasp.jar所在路径,然后通过JVM的api,把其路径追加到了启动类加载器的classpath中,这样,启动类加载器,收到类加载委派任务时,就能通过该classpath加载到rasp.jar的所有类了,那么,也就意味着,任何一个类加载器中的任何一个类,都能通过显式或者隐式加载,加载到rasp.jar中的类。

到这里,大伙还是会有个疑问,我能拿到rasp.jar中的类,和在java.lang.Runtime.exec()(启动类加载器加载的类)调用到com.xxx.A.a()(扩展类加载器)方法并没有什么关系啊?

到了最后解谜的时候了,贴上代码:

Agent->com.baidu.openrasp.ModuleLoader

public class ModuleLoader {

    ...
    public static ClassLoader moduleClassLoader;
    ...
    static {
        ...
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        while (systemClassLoader.getParent() != null
                && !systemClassLoader.getClass().getName().equals("sun.misc.Launcher$ExtClassLoader")) {
            systemClassLoader = systemClassLoader.getParent();
        }
        moduleClassLoader = systemClassLoader;
    }

}

看到了吗?它把扩展类加载器,寄存在了启动类加载器加载的com.baidu.openrasp.ModuleLoader.moduleClassLoader静态变量中了,那么,也就是说,我只要在hook插入到java.lang.Runtime.exec()的代码中,判断一下,如果,当前类是由启动类加载器加载的,这时候,我就通过com.baidu.openrasp.ModuleLoader.moduleClassLoader.loadClass("com.xxx.A").getMethod("a").invoke(null,null)去加载要调用的类(扩展类加载器加载的类)进行调用,否则,当前类就一定是应用类加载器或者扩展类加载器加载的了,那么,根据隐式加载,我就直接通过com.xxx.A.a()去调用该类的方法就行了。

看到这里,大伙是不是对OpenRASP的类加载原理搞得清清楚楚了?


0x04 OpenRASP类加载的优缺点

前面大伙彻底搞懂OpenRASP的类加载原理后,我觉得,应该能想到,它的实现,虽然有其优点,但是依然存在着一点小缺点。

  1. 优点:

对于一些扩展类加载器、应用类加载器加载的类中,对检测代码的调用(例:"com.xxx.A.a()"),它无需反射就能直接调用,会减少了部分性能的损耗。

  1. 缺点:

也正是优点导致的,对于Engine的类(com.xxx.A就在Engine的jar中),没有做到和业务应用隔离,因为它是扩展类加载器加载的,那么,将有可能导致出现一些类冲突问题。比如,业务应用jar是由应用类加载器加载的,它引入了a.jar、b.jar,在a.jar中有public修饰的类com.yyy.Z,这个类继承了b.jar中protected修饰的类com.yyy.X,而恰巧,在Engine的rasp-engine.jar中也依赖了b.jar,那么,根据双亲委派机制,类com.yyy.X将会交由其母亲类加载器,也就是扩展类加载器加载,这时候因为a.jar和b.jar不是同一个类加载器加载的,最后将会导致抛出异常。


针对这样的缺点,我们除了尽量避免在Engine中使用到会造成冲突的jar以外(这种太难保证了),还有一种方法就是对其进行加载器隔离,也即是通过一个应用类加载器去加载Engine,而不是使用扩展类加载器进行加载。

具体的实现,我们可以考虑在hook插入代码的时候,提前对其要调用的方法method进行反射加载,然后寄存在启动类加载器加载的Agent中,这样,我们就能做到Engine的jar和业务应用的jar,加载器隔离,也能在隔离实现的同时,达到在任意的类的任意的方法中调用到Engine的类和方法。

  1. 优点:

做到了完全的加载器隔离,完全不会出现类冲突的问题了。

  1. 缺点:

也非常明显,对于无论是启动类加载器、扩展类加载器还是应用类加载器加载的类,它想要调用Engine中的类和方法,都只能通过反射的方式去调用了,这意味着会多出一部分性能的损耗。


0x05 写在最后

好久没写文章了,这次写了个痛快,其实也希望大伙在看完这篇文章后,可以一起多交流交流,看能不能集思广益,做到既要又要,毕竟都是成年人了!

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