前言
很久前其实看过一遍源码,但是过于久远。这次重新学习一下顺便做个笔记,避免后续忽略一些细节。
关于rasp的基础就不说了,其实JavaAgent内存马那里就把基本原理差不多说说明了,简单的demo就是在ClassFileTransformer下面对每个加载的类用字节码的一些库,检查是否有一些敏感操作,比如调用Runtime.getRuntime().exec()这种命令执行的方法等等。
项目地址:baidu/openrasp: 🔥Open source RASP solution
源码分析
从release下载的话是有三个jar包的。RaspInstall.jar、rasp.jar、rasp-engine.jar。入口是自然是RaspInstall.jar安装rasp了。
RaspInstall.jar
从MANIFEST.MF得知入口类是com.baidu.rasp.App。
从入口类的main方法跟到operateServer,首先进行了一些解析和检查参数的操作。然后根据参数是install还是uninstall分别走进安装和卸载的逻辑。我们这里是安装,可以直接跟进到BaseStandardInstaller#install。
然后将剩下两个jar包rasp.jar、rasp-engine.jar复制到目标文件夹下,接着生成更新一些配置。
然后回去修改服务的启动脚本,比如“-javaagent”等参数,这样是后续用脚本启动就会加载rasp的jar包。来在服务器上运行rasp。
如果App.isAttach参数为true,还会去动态注入agent。这个默认是flase,在前面提到解析参数时,如果启动参数有pid,就会改为true,毕竟动态注入需要这个值。
这里不论通过动态注入agent还是修改服务启动参数加载agent。都是对于rasp.jar这个jar包,因此接下来我们就可以看这个jar包。
Rasp.jar
启动类是com.baidu.openrasp.Agent,包括agent的入口也是这个。
premain和agentmain都调用init方法,但是传入参数不同。这个方法会将rasp.jar添加到启动类加载器的搜索路径中,使其中的类可以被优先加载。当然对于Bootstrap ClassLoader还是会优先加载 JVM 核心类库,然后才是rasp.jar。
然后调用ModuleLoader.load(mode, action, inst)来加载模块。实际上就是new了一个MoudleLoader。
MoudleLoader
这里不急着先看构造函数,因此该类有静态代码块,在走进构造函数之后会先执行静态代码块里面的代码。
主要干了两件事,首先是获取rasp.jar所在路径,然后赋值给baseDirectory。然后遍历所有的系统类加载器找到sun.misc.Launcher$ExtClassLoader赋值给moduleClassLoader。
这里为什么需要一个扩展类加载器呢?在三梦师傅的文章就有很好的解释:以OpenRASP为基础-展开来港港RASP的类加载 | 大彩笔threedr3am
简单来说,就是为了避免agent(rasp.jar)无法调用到engine(rasp-engine.jar)的类。在一个服务中大部分自己编写的代码,包括服务器的核心代码,是由应用类加载器加载的,如果说我们的engine也是又某个应用类加载器加载的话,我们在hook服务中的类,插入了调用engine方法的代码时,由于隐式加载机制,这个engine中的类是要被加载的,此时又由于双亲委派机制,服务中的应用类加载器会一路委派到启动类加载器,启动类加载器自然是找不到的,因此又会让扩展类加载器加载,当然也是找不到的,因为engine是在另一个应用类加载器加载。而扩展类加载器找不到后,只能回到服务的应用类加载器,由于engine服务在另一个应用类加载器,所以导致整个过程无法加载到engine中的类。因此会产生ClassNoFoundError。
另一个情景,就是如何要在一个启动类加载器中的类插入hook代码,那么由于启动类加载器已经是最顶层,没有委派的对象了,因此只能自己加载,但是engine又不属于启动类加载器的范畴,那不就找不到这个类了吗?
因此,此时扩展类加载器就又派上用场了,我们可以通过扩展类加载器去加载不就行了,因为engine我们使用扩展类加载器加载的。而且我们是将这个扩展类加载器存在ModuleLoader.moduleClassLoader这个静态变量中的,ModuleLoader是agent(rasp.jar)下,还记得我们前文提到rasp.jar是会被添加到启动类加载器的搜索路径中吗?
因此如果我们hook时需要用到engine中的类时候,就可以从启动类加载器中获取ModuleLoader类中的moduleClassLoader,也就是加载engine的扩展类加载,然后用它去加载hook时用到的代码即可。
ok那么静态代码块这部分就算结束,我觉得大家能从这段代码收获不少东西的,因此多费点笔墨。
我们回到刚刚是要去new一个ModuleLoader类的,因此可以看到构造函数。如果启动模式是normal,也就是从premain进入的init方法,就会设置一些jboss的启动选项。
接着是new了一个ModuleContainer类,并传递一个参数,engine-rasp.jar。可以看出是要对engine的jar包做一些封装处理的。
获取engine-rasp.jar中MANIFEST.MF中的一些属性,然后获取Rasp-Moudle-Name的值,这里是com.baidu.openrasp.EngineBoot。然后判断当前系统类加载器是否是URLClassloader的子类,一般情况没啥问题,扩展类加载器和应用类加载器都是URLClassloader的子类。然后就是去用刚刚提及的扩展类加载器去加载com.baidu.openrasp.EngineBoot这个类。
那么非一般情况是什么情况呢,注释给的比较清楚了。那就是weblogic环境或者jdk9,10,11。weblogic是因为类加载器是自定义的,jdk高版本是原本的类加载器的改变。
jdk9之后就没有扩展类加载器了,取而代之的是平台类加载器platform class loader。并且由于应用类加载器和这个平台类加载器不再是继承自URLClassloader,而是BuiltinClassLoader。所以不能用if语句的处理方式加载类了。
那么这里的加载方式可以看到是通过反射调用了appendToClassPathForInstrumentation方法。就是说将engine的jar包添加到当前系统的类加载器的类路径中。加载agent一般是用ApplicationClassLoader程序类加载器加载的。而自定义的类加载器一般继承于此,加载是也会委派到程序类加载器,所以可以在类路径找到engine的jar包。从而达到效果。
因此最后就是实例化了engine中的com.baidu.openrasp.EngineBoot类,接着调用其start方法启动engine。
engine-rasp.jar
那我们来到EngineBoot.start方法,engine的入口点。
首先进入Loader.load()中,这里面是加载一个openrasp_v8_java.dll的动态链接库。
然后是加载日志配置,读取agent的信息,设置模型之类的。然后是JS.Initialize做一些js引擎相关的初始化。
用setLogger和setStackGetter设置了日志记录器和堆栈获取器,这个堆栈获取器用于序列化当前调用堆栈。
Context.setKeys()设置了JS上下文用到的一些键值。
接着CloudUtils.checkCloudControlEnter()判断是否开启了云端控制,没有的话调用UpdatePlugin()和InitFileWatcher()加载插件并且监听插件目录的变化。
CheckerManager.init()会遍历下面的Type,将其put进检查器CheckerManager.checker中。
接着调用initTransformer方法,到这里了解过javaagent的朋友就会觉得熟系了。初始化了一个自定义的类转换器CustomClassTransformer,然后调用 retransform() 方法,对已加载的类进行重新转换。
看看CustomClassTransformer的初始化。主要是看构造函数中调用的addAnnotationHook方法。用AnnotationScanner扫描指定包路径下带有HookAnnotation注解的类,然后把扫描到的类实例化,如果创造出来的实例是AbstractClassHook类型,就调用addHook方法进行注册。
如果hook是必需的,就添加到necessaryHookType集合,然后获取忽略列表,检查当前hook是否可以忽略,如果能被忽略。就记录日志直接返回,否则加入到hooks集合中。
对于hook类的结构可以随便找一个有HookAnnotation注解的类参考。check方法用于检测,hookMethod就是将检测方法插入到hook点中。
接着回到CustomClassTransformer,初始化完成会就是调用其retransform方法了。这个方法中会获取所有已经加载的类,然后遍历这些类用isClassMatched方法判断其是否满足hook规则。
实际上就是遍历刚刚添加到hooks集合中的所有hook类,用hook类的isClassMatched方法,将加载的类的类名作为参数传递进去判断。
匹配到后,还有一层判断,需要匹配到的该类可被修改,并非LambdaForm类,这样就会retransformClasses方法去转换加载的类。这时就是冲加载,会走进自定义转换器的transform方法。
前面两个if语句是记录依赖路径和缓存jsp类加载器,暂且不管,看到后面,依旧是遍历hooks的所有hook类,用其isClassMatched方法判断类名是否匹配。毕竟还有别的方法手动调用了transform方法。匹配上的话,就会用Javassist 加载类并且调用AbstractClassHook的transformClass方法修改字节码。
AbstractClassHook是抽象类,其实现就是具体的hook类,因此调用hookMethod方法实际是去调用到具体实现的hook类的hookMethod方法。
比如com.baidu.openrasp.hook.system.ProcessBuilderHook。从它的isClassMatched方法可以看到hook的是ProcessImpl、UNIXProcess这两个类。
接着看其hookMethod方法,这里就是要插入代码的地方了。我们直接跟进第一个分支吧,毕竟平时都是windows+jdk8。
有点长,我们拆开来看,首先是对参数类型的处理,我们传的是String[].class, String.class,首先是判断是否空参,我们这里有参数,走第一个判断if里面,然后会去判断是否是数组类型,如果是参数类型是数组的话就会用Class.forName()获取其类对象。否则还是不动。
比如这里String[].class就是Class.forName(“[Ljava.lang.String;”);,String.class获取类型是String,后面加一个.class就还是String.class。
最后拼在一起时new Class[]{Class.forName("[Ljava.lang.String;"), String.class}
接着看getInvokeStaticSrc方法后半截。根据是否是启动类加载器加载的来走进不同分支语句。
如果是的话就使用ModuleLoader.moduleClassLoader加载类并反射调用静态方法,最后拼接的效果如下,$1和$2用于占位,后续用于获取hook点传入的参数然后替换checkCommand方法的两个参数。
1 | com.baidu.openrasp.ModuleLoader.moduleClassLoader.loadClass("com.baidu.openrasp.hook.system.ProcessBuilderHook").getMethod("checkCommand",new Class[]{Class.forName("[Ljava.lang.String;"), String.class}).invoke(null,new Object[]{"$1,$2"}); |
可以看到其实是用了之前存储的扩展类加载器去加载,和之前解释的一样,如果当前的类加载器是启动类加载器,是无法获取到engine中的Hook类的。更具体的解释看前面把。
如果不是启动类加载器就直接拼接成如下代码,因为如果不在启动类加载器的话是可以直接加载的engine中的类的:
1 | com.baidu.openrasp.hook.system.ProcessBuilderHook.checkCommand($1,$2); |
接着回到hookMethod方法,接着是去调用了insertBefore这个方法,这里的ctClass是我们匹配到的要去hook的类,也就是java.lang.ProcessImpl这个类,然后methodName前面能看到是<init>
初始化方法。
也就是如下方法,这段代码其实就是将刚刚拼接得到的代码给插入到hook到的方法前面,也就是插入到processImpl方法的开头。
所以上面的方法就变成了如下:
1 | private ProcessImpl(String cmd[], |
其实就是去反射调用hook类的checkCommand方法,然后参数就是前两个参数替换下来,检查程序走到此处的参数是否有危险。最后是走到这里:
可以看到命令,环境以及堆栈信息都put进了params这个HashMap中,然后去调用HookHandler的doCheckWithoutRequest方法。其中还有个参数是枚举变量。
做了一些判断,跟一些特殊情况禁用hook以及白名单相关,先不理会,继续走进doRealCheckWithoutRequest。
new了一个CheckParameter,然后将其作为参数,传递到CheckerManager的check方法。
根据当前type选取一个Checker检查器去检查,在刚进入engine的时候,入口点是EngineBoot的start方法。当时进行了CheckManager的初始化,所以put进了type和其对应的检查器到checkers中,此时我们就可以直接获取到当前type对应的一个检查器。直接去看CheckParameter.Type也可以看出我们这里是用的V8AttackChecker。
虽然检查器是V8AttackChecker,但是到check方法还是先走到父类AbstractChecker的check中,可以看到拦截与否实际是从checkParam方法中得出的,这个方法返回的eventInfos列表,后续遍历该列表,对每一个eventinfo判断里面的isBlock属性是否为true,如果最后返回的是ture,HookHandler类中的handleBlock(parameter)就会拦截我们的请求。所以我们跟进checkParam看看。
这个是要看子类的实现,我们前面提到用的检查器是V8AttackChecker,所以走V8AttackChecker的checkParam方法里。
调用的JS.Check,继续跟进。将传入的检查参数(checkParameter.getParams())通过 JSON 序列化写入字节流,后面通过V8.Check()方法进行攻击检测。
后面就是c代码,具体位置可以定位到openrasp-v8\java\com_baidu_openrasp_v8_V8.cc
这里是V8引擎在JNI实现的检测入口,后续会创建一个Isolate,然后对参数做转换,毕竟是跨语言了。然后调用Isolate的Check方法。
找到openrasp-v8\base\isolate.cc
把当前上下文作为参数,调用重载的Check方法。然后就是通过check->Call(…)调用一个JS注册的检查函数,传入请求类型、参数和上下文。后续就不再多说了。具体可定位openrasp-v8\base\js\rasp.js自己再看看。
绕过
正则绕过
主要是针对拦截规则的不完善去绕过规则,比如正则匹配是否存在被忽视的情况,比如这里主要拦截读取/etc/passwd文件,对其他文件没有做拦截。
或者读取多个文件,用变量拼接,a=”cat /et” b=”c/passwd” $a$b,设置别名,反斜杠换行等等。
覆盖插件
OpenRasp对于上传js文件的行为是ignore忽略,所以不会拦截js的上传,如果有上传点且知道路径,上传js覆盖插件,然后OpenRasp会监视js文件,有变化就是update插件,算是热加载了,无需重启服务。
黑名单绕过
针对黑名单不够完善进行绕过。
逻辑检测点
之前分析的时候提到一些有逻辑判断的地方,对一些特殊情况不进入hook,比如下面cpu使用率超过90和注册云控。
还有doRealCheckWithoutRequest方法中的enableHook.get的判断,如果存在代码执行,能通过反射修改属性,在判断时就会被判成不进入hook的逻辑或者阻断了走向检测逻辑。