有很长一段时间没写文章了,也很久没搞Java相关的漏洞研究了,因为工作需要,忙碌了好一段时间的后端开发,都有点落下安全相关的研究了,实属堕落。
最近研究了一下多个业内使用的RASP实现,对于RASP又有了更加深入的了解,其中,RASP的类加载机制,我个人觉得应该是RASP中最核心的地方,也是最容易出BUG的地方了。对于这个RASP比较核心的类加载机制,我在这篇文章中,将会以OpenRASP为基础例子,去对其原理进行讲解,并给出与之不同的实现,分析出各自实现的优劣点。
通过这篇文章,大伙应该能理解到OpenRASP的类加载设计,更甚者可能会想到其它更好的设计,希望大家可以多多交流,互相进步。
使用过OpenRASP的小伙伴都知道,OpenRASP存在rasp.jar和rasp-engine.jar这两个jar包,其中,稍微看过OpenRASP代码的小伙伴,应该都了解,OpenRASP中的Agent,也就是rasp.jar,它有两种使用方式:
无论是通过哪种方式,在Agent加载到JVM运行时,都会在代码中,加载Engine,也就是rasp-engine.jar,然后启动Engine,Engine中是RASP主要的业务实现,它包含了和后端云控的联系相关代码,也包含了hook、detector等等相关代码
前面讲到了,在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所在的目录地址。
接下来才是需要我们关注的地方,它先调用了ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
取得了当前类的类加载器(就是加载当前类的类加载器),可能很多人不太了解类的隐式加载是什么,这里先稍微讲一下吧。
类加载其实可以分为两种加载方式,一种是显式加载,另一种是隐式加载,而到底什么是显式加载,什么是隐式加载呢?
顾名思义,显式加载,就是通过代码,比如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();
"才能调用到该方法,那么,到底是如何调用呢?
前一节,留下来了一个问题,启动类加载器加载的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的类加载原理搞得清清楚楚了?
前面大伙彻底搞懂OpenRASP的类加载原理后,我觉得,应该能想到,它的实现,虽然有其优点,但是依然存在着一点小缺点。
优点就是,对于一些扩展类加载器、应用类加载器加载的类中,对检测代码的调用(例:"com.xxx.A.a()
"),它无需反射就能直接调用,会减少了部分性能的损耗。
而缺点也正是优点导致的,对于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的类和方法。
这种实现方式,优点是做到了完全的加载器隔离,完全不会出现类冲突的问题了。
而缺点也非常明显,对于无论是启动类加载器、扩展类加载器还是应用类加载器加载的类,它想要调用Engine中的类和方法,都只能通过反射的方式去调用了,这意味着会多出一部分性能的损耗。
好久没写文章了,这次写了个痛快,其实也希望大伙在看完这篇文章后,可以一起多交流交流,看能不能集思广益,做到既要又要,毕竟都是成年人了!