前言
最近分析了百度开源的OpenRASP这款RASP产品的启动流程及Hook流程(Java RASP部分),感觉自己对于RASP产品有了更深的理解。本篇文章将会以OpenRASP为例,介绍RASP产品的部署、检测原理及bypass方法,并尝试自己完成一个RASP的简单实现——取名为CloseRASP。因为笔者水平有限,因此这篇文章可能干货不多,如有错误或不足,希望师傅们斧正。
RASP介绍
介绍RASP之前,我们先看看什么是WAF?
WAF(Web 应用程序防火墙)通过过滤和监控 Web 应用程序与互联网之间的 HTTP 流量来帮助保护 Web 应用程序。它通常可以保护 Web 应用程序,使其免受跨站点伪造、跨站点脚本 (XSS)、文件包含、SQL 注入及其他一些攻击的影响。
很多时候我们在进行攻击时遇到的都是基于流量规则对攻击行为进行过滤的WAF,WAF相比RASP误报率高,绕过率高,且市面上也有很多针对不同waf的绕过方式。而RASP是一种运行时应用程序自我保护的技术,全称“Runtime application self-protection”。它是通过JavaAgent利用Instrumentation在指定的class加载之后,调用指定的ClassFileTransformer实现类的transform方法,通过使用ASM(或者其他字节码修改框架)技术来hook目标class的method,在方法的开头或结尾插入检测攻击的代码,之后将hook好的字节码返回给transformer从而加载到JVM中。因此可以说JavaAgent就是Java RASP技术的基础。(建议读者先了解JavaAgent及Javassist再阅读本文)
OpenRASP研究
OpenRASP 抛弃了传统防火墙依赖请求特征检测攻击的模式,创造性的使用RASP技术(应用运行时自我保护),直接注入到被保护应用的服务中提供函数级别的实时防护,可以在不更新策略以及不升级被保护应用代码的情况下检测/防护未知漏洞,尤其适合大量使用开源组件的互联网应用以及使用第三方集成商开发的金融类应用。我们先看看OpenRASP在Tomcat中是怎样手动安装的:
在OpenRASP中除了RaspInstall.jar这个用来安装OpenRASP的jar包外,还有rasp.jar和rasp-engine.jar这两个jar包。可以看到在Tomcat中手动安装OpenRASP的话,需要增加-javaagent项设置为rasp.jar。老惯例,先看看MAINFEST.MF,以下是rasp.jar的MAINFEST.MF文件的内容:
1 | Manifest-Version: 1.0 |
我们只关心Premain-Class、Agent-Class、Can-Redefine-Classes、Main-Class和Can-Retransform-Classes这5个配置。可以看到Main-Class是com.baidu.openrasp.Agent,若是执行这个rasp.jar,代码将从com.baidu.openrasp.Agent#main开始。Premain-Class也是com.baidu.openrasp.Agent,因为设置了-javaagent项,因此若运行利用Tomcat部署Web项目时会首先执行com.baidu.openrasp.Agent#premain。Can-Redefine-Classes和Can-Retransform-Classes这两个配置项因为要重新定义class必然设置为true不必说。Agent-Class指定的也是com.baidu.openrasp.Agent,那么当attach目标class的时候就会执行com.baidu.openrasp.Agent#agentmain。
OpenRASP启动流程分析
在IDEA调试时,可以在右上角的Tomcat配置中加上VM Options,通过-javaagent项指定rasp.jar。因为配置了-javaagent,因此首先进入com.baidu.openrasp.Agent#premain:
跟进init方法:
以下是JarFileHelper的addJarToBootstrap方法:
这里通过Instrumentation的appendToBootstrapClassLoaderSearch方法,把rasp.jar包放到Bootstrap ClassLoader的搜索路径。解释一下为什么这样做,因为java的双亲委派机制,我们需要将rasp.jar包加入到了BootStrapClassLoader下,否则当我们需要hook像 java.io.File或者java.lang.ProcessImpl 这样由 BootstrapClassLoader 加载的类的时候,就无法检测到了。因此将 rasp.jar 添加到 BootstrapClassLoader 的ClassPath下,收到类加载委派任务时,就能通过该classpath加载到rasp.jar的所有类了,那么,也就意味着,任何一个类加载器中的任何一个类,都能通过显式或者隐式加载,加载到rasp.jar中的类。
之后调用了ModuleLoader.load(),这个函数的作用是用来加载和初始化引擎模块,即之前提到的rasp-engine.jar,跟进一下:
这里创建了一个ModuleLoader实例,继续跟进:
从函数名就可以知道这个setStartupOptionForJboss函数是用来为Jboss设置一些起始选项的,跟进一下:
这里通过获取到系统类加载器加载字节码class的路径后,判断是否有jboss-modules.jar,很明显,我们使用Tomcat没有这个jar包。若判断是Jboss中间件的话,会调用setSystemProperty函数设置相关属性和预加载包:
之后继续执行:
创建了一个ModuleContainer实例,传入了包名rasp-engine.jar,进入ModuleContainer的构造函数:
首先创建一个JarFile对象指向rasp-engine.jar文件,然后获取jar包中的MAINFEST.MF文件中的属性,将Rasp-Module-Name的值赋值给this.moduleName,将Rasp-Module-Class的值赋值给moduleEnterClassName。Rasp-Module-Class的值是com.baidu.openrasp.EngineBoot:
之后将com.baidu.openrasp.EngineBoot实例化赋值给this.module:
代码中是通过ModuleLoader.moduleClassLoader来加载com.baidu.openrasp.EngineBoot的,而moduleClassLoader在ModuleLoader类中的static块中有所定义:
1 | ClassLoader systemClassLoader; |
扩展类加载器的父类加载器为启动类加载器,即Bootstrap ClassLoader,因此moduleClassLoader是启动类加载器,这里需要记一下。之后ModuleContainer的构造方法结束,继续执行com.baidu.openrasp.ModuleContainer#start,以下是该方法:
调用了com.baidu.openrasp.EngineBoot的start方法,在该方法中进行了:输出Banner信息,加载V8引擎、初始化插件系统、配置核查、初始化字节码转换模块、初始化云管理模块等操作,我们跟进一下:
首先输出了Banner,我们可以在运行日志中看到:
之后执行了Loader.load();,跟进一下:
可以看到,加载了一个JNI库文件。此处完成V8引擎的加载,用于解释执行JavaScript。OpenRasp的一大特色就是将部分规则通过JS插件的形式来实现编写,这样做有两个优势,一是可以实现跨平台使用,减少了为不同语言重复制定相同规则的问题。另一个就是可以实现规则的热部署,添加或修改规则不需要重新启动服务。接着调用了com.baidu.openrasp.EngineBoot#loadConfig,完成初始化log4j、检查云控配置信息以及读取配置信息,初始化syslog服务连接等操作:
接着又执行了JS.Initialize(),跟进一下:
设置v8获取栈信息的getter方法,这里获得的栈信息,每一条信息包括类名、方法名和行号classname@methodname(linenumber)。紧接着是对插件的更新:
获取plugins目录下的文件进行过滤,其中要求后缀名为.js,之后获取到合法的plugin文件后,将文件的名字和内容添加到scripts,并执行UpdatePlugin(scripts);,使用V8引擎进行信息的提取。之后调用InitFileWatcher方法来实现对js配置文件的监听事件,从而实现热部署,动态的增删js中的检测规则:
紧接着调用CheckerManager.init():
Type类如下:
从枚举类com.baidu.openrasp.plugin.checker.CheckParameter.Type中读取所有的checker,包含三种类型的checker,一是js插件检测,意味着这个checker会调用js plugin进行攻击检测,二是java本地检测,意味着是调用本地java代码进行攻击检测,三是安全基线检测,是用于检测一些高风险类的安全性基线检测,检测其配置是否有安全隐患。
进接着调用this.initTransformer(inst),跟进:
CustomClassTransformer是ClassFileTransformer的实现类,以下是该类的一种构造方法:
addTransformer()中指定的transfromer为自己,也就是CustomClassTransformer,之后执行addAnnotationHook():
这个方法的作用就是扫描com.baidu.openrasp.hook包下有@HookAnnotation注解的类,之后循环调用addHook()方法,将所有不是配置文件中hooks.ignore指定的类型的对象添加到this.hooks中:
这个hooks字段是用来提供在后续类加载通过com.baidu.openrasp.transformer.CustomClassTransformer#transform的时候,对其进行匹配,判断是否需要hook。接下来回到com.baidu.openrasp.EngineBoot#initTransformer中,调用了com.baidu.openrasp.transformer.CustomClassTransformer#retransform:
通过调用isClassMatched判断这个类是否是Hook的类:
通过迭代this.hooks,调用对应的isClassMatched()方法进行匹配,如果需要hook,则调用retransformClasses()对该class进行重新定义,重新触发对该类的拦截,那我们接下来看一下com.baidu.openrasp.transformer.CustomClassTransformer#transform方法:
在这里依然不断循环迭代hooks,对class进行匹配,之后调用hook类的transformClass方法:
又调用了hookMethod()方法来进行字节码的修改。
OpenRASP Hook
那接下来,我们挑一个例子来看看OpenRASP的Hook流程,例如com.baidu.openrasp.hook.xxe.XXEHook。我们首先看看com.baidu.openrasp.hook.xxe.XXEHook#isClassMatched,了解一下是如何对需要hook的类做匹配的,该方法如下所示:
代码很简单,这里检测是否是可能存在XXE漏洞的类。之后查看com.baidu.openrasp.hook.xxe.XXEHook#hookMethod,看看是怎么更改这些类的代码的:
这里通过调用了com.baidu.openrasp.hook.AbstractClassHook#getInvokeStaticSrc方法获取了需要插入到这些类的expandSystemId()和setDescription()方法中的一些代码。getInvokeStaticSrc()如下所示:
1 | public String getInvokeStaticSrc(Class invokeClass, String methodName, String paramString, Class... parameterTypes) { |
大致逻辑就是获取XXEHook类的checkXXE方法的字节码并将其写入到了sink点的运行前,也就是我们上边提到的那两个方法。现在还记得我们之前看到的将jar包添加到BootstrapClassloader的搜索路径的操作吗?注意看getInvokeStaticSrc方法这里在sink点的运行前添加了一段src = "com.baidu.openrasp.ModuleLoader.moduleClassLoader.loadClass(\"" + invokeClassName + "\").getMethod(\"" + methodName + "\"," + parameterTypesString + ").invoke(null";,moduleClassLoader是BootstrapClassloader,因此当我们在由BootstrapClassloader加载的类中插入这段代码时,这个类就可以加载到了XXXHook中的checkXXX方法来进行检测防御了。在此处就是加载到了XXEHook中的checkXXE方法。
之后我们跟进一下checkXXX方法看看是怎么对恶意攻击进行防御的,以XXEHOOK的checkXXE方法为例:
调用到com.baidu.openrasp.HookHandler#doCheck,之后又调用到com.baidu.openrasp.HookHandler#doCheckWithoutRequest:
此处为RASP的熔断开关,当服务器的cpu使用率超过90%,禁用全部hook点 :
1 | if (Config.getConfig().getDisableHooks()) { |
因为业务大于安全的考录,熔断开关在许多的RASP产品都可以进行设置。因此有师傅提到也许可以通过DDOS,让CPU高负载,使用率超过90%,从而禁用全部hook点。当云控注册成功之前,不进入任何hook点:
1 | if (Config.getConfig().getCloudSwitch() && Config.getConfig().getHookWhiteAll()) { |
之后走到com.baidu.openrasp.HookHandler#doRealCheckWithoutRequest,在这里,首先判断RASP开关是否打开,即对enableHook.get()作判断。之后,做了一些参数的封装,以及失败日志、耗时日志等输出,并且在检测到攻击时(下一层返回),抛出异常:
1 | public static void doRealCheckWithoutRequest(CheckParameter.Type type, Map params) { |
接着执行到com.baidu.openrasp.plugin.checker.CheckerManager#check:
1 | public static boolean check(CheckParameter.Type type, CheckParameter parameter) { |
这里根据传入的type来选择调用相对应的checkers的check方法,假设此时的checker是V8AttackChecker,那么就会调用com.baidu.openrasp.plugin.checker.AbstractChecker#check:
调用了相应checker的checkParam方法,那我们看看com.baidu.openrasp.plugin.checker.v8.V8AttackChecker#checkParam:
继续跟进com.baidu.openrasp.plugin.js.JS#Check:
箭头指向的地方即是调用JS插件进行检测的地方了。
Demo RASP实现
众所周知,常见的Runtime.getRuntime().exec()执行命令的底层逻辑是通过ProcessBuilder的start方法去执行命令的,而ProcessBuilder执行命令实际上也是调用了ProcessImpl这个类的start方法。那我们如果想防止这种命令执行的话,就需要在ProcessImpl处进行hook。我们先来看看OpenRASP是怎么做的,首先看看如何匹配类的:
先判断java版本,如果大于等于1.9则对java/lang/ProcessImpl进行hook,这是因为在JDK9的时候把UNIXProcess合并到了ProcessImpl当中,参考https://hg.openjdk.org/jdk-updates/jdk9u/jdk/rev/98eb910c9a97。之后如果小于1.9继续判断,如果是Windows操作系统,则对java/lang/ProcessImpl进行hook,否则对java/lang/UNIXProcess进行hook。我们继续看看插入的代码:
根据相应的逻辑在ProcessImpl类的初始化方法或者UNIXProcess类的初始化方法中插入相应的checkCommand方法。现在我先来给大家写一个Demo RASP,并给出其相应bypass方法。首先给出实现了premain方法的PreMainDemo类:
1 | import java.io.*; |
接下来是ClassFileTransformer的实现类:
1 | import javassist.*; |
这个Demo非常的简单,与OpenRASP类似,都是将agent.jar添加到BootstrapClassloader,但是因为我们这个Demo并不在BootstrapClassloader加载的类中添加调用agent.jar的代码,因此在这个Demo中这部分可以忽略。之后我们还new了一个ProcessBuilder类,因为如果不创建这个实例的话,inst.getAllLoadedClasses()中将没有这个类。之后我们无脑对ProcessBuilder类的start方法添加了两行代码,首先输出Get Out!Hacker!,之后return。为什么说是无脑呢?因为我们这样做,会使得正常的命令执行的需求都被屏蔽了,而且hook的是ProcessBuilder类而非ProcessImpl类,为什么hook的是ProcessBuilder就有问题呢?接下来我来给大家做个演示,再来写一个执行命令的类来模拟黑客攻击:
1 | public class Main { |
一行很简单的弹计算器的代码,之后我们添加VM Options,添加-javaagent为我们刚刚打包好的Demo.jar:
之后运行,结果如下:
没有弹出计算器,执行到ProcessBuilder的start方法后输出Get Out!Hacker!并返回了,因此并没有成功执行命令。那我们换一种方法,因为Runtime.getRuntime().exec()的底层就是调用ProcessBuilder的start方法,那我们直接利用ProcessBuilder的start方法执行命令试试:
1 | public class Main { |
依然符合预期:
但是请注意,我们刚刚提到了,Runtime.getRuntime().exec()执行命令的底层逻辑是通过ProcessBuilder去执行命令的,而ProcessBuilder执行命令实际是调用了ProcessImpl这个类。那假如我们直接使用ProcessImpl类的start方法去执行命令,是不是就能绕过了?
1 | public class Main { |
成功bypass:
从这里我们就可以发现,如果我们要写一个针对某种漏洞的RASP,就必需将这种漏洞的底层原理搞清楚。否则就像上面提到的例子,绕过是很轻松的。这一点就与WAF有很大差别,WAF可能只需要对一些关键字进行过滤即可防御攻击,但是RASP却不行。
我们刚刚写这个Demo RASP的时候还遇到了一个问题,那就是我们过滤的时候直接将所有的命令都给屏蔽了。可是我们很多时候又确实有执行命令的需求,那对于这一点我们该怎么做呢?我们先看看万能的OpenRASP是怎么做的:
OpenRASP通过黑名单的方式对部分命令进行了正则过滤,可是很明显,这样做仍然很不安全,最大原因就是黑名单的命令不够完备,攻击者很轻松就可以绕过。这里又引出一个问题,如果继续完善黑名单,会耗费巨大的人力不说,还会使得应用的性能严重下降。那如果采用白名单呢?如果我们想执行什么命令,就对此命令加白,似乎又显得非常麻烦。因为本人水平所致,没有办法给出既能使得性能下降不明显,又能使防御能力大幅度上升的做法。不过笔者更倾向白名单的方式对命令进行过滤。
Bypass OpenRASP
Unsafe绕过
首先设置44行的all_log为false,即关闭观察模式:
之后将命令注入模块的action由log改为block,此时我们就打开了拦截模式:
我们先写一个HttpServlet,其中doGet方法为:
1 | Process process = Runtime.getRuntime().exec(request.getParameter("cmd")); |
传入cmd参数为whoami,以下是结果:
符合预期,按照正则表达式没有进行过滤。之后传入cmd=cat /etc/passwd,结果为:
接下来我们尝试bypass。在bypass之前,我们再回想一下OpenRASP的hook流程?比如这里提到的命令执行部分,OpenRASP在UNIXProcess或者ProcessImpl类的初始化方法处插入了自己的防御代码,使得我们无法执行黑名单中的命令。那麻烦思考一下,既然它在UNIXProcess或者ProcessImpl类的初始化方法中插入了防御代码,那么我们有没有一种办法能够不需要调用这两个类的初始化方法就能直接获取UNIXProcess或者ProcessImpl类呢?当然是有的,在Java中有一个叫Unsafe的类,它提供了非常底层的内存、CAS、线程调度、类、对象等操作、Unsafe正如它的名字一样它提供的几乎所有的方法都是不安全的。而Unsafe类中有一个allocateInstance方法,它可以无视构造方法从而直接创建这个类的实例。接下来给大家做个示范:
1 | public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { |
之后我们再传入?cmd=cat&cmd=/etc/passwd,结果如下:
对代码部分简略解释一下吧。首先我们通过反射获取一个unsafe实例:
1 | //反射获取unsafe实例 |
之后又获取了UNIXProcess实例:
1 | Class processClass = null; |
因为我们创建此UNIXProcess对象时并未 通过其构造函数来获取,因此并未执行OpenRASP的hook代码,从而完成绕过!
关闭OpenRASP开关
在一般的RASP产品中为了避免影响产品,都会设置一个开关,在OpenRASP中也不例外。OpenRASP的开关就是com.baidu.openrasp.HookHandler的enableHook字段:
在我们刚刚分析的hook流程中,有一个com.baidu.openrasp.HookHandler#doRealCheckWithoutRequest函数,在函数开始做了一个判断,就是判断OpenRASP的开关是否打开:
我们可以通过反射将这个字段设为new AtomicBoolean(false),即关闭这个字段:
1 | public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { |
之后成功bypass:
那既然这个方法可行,那我们不妨找找在刚刚的hook流程中,我们还能通过反射更改哪些字段,从而中止hook流程?还记得我们之前提到的那个熔断开关吗?那个是RASP为了在CPU占比过高的情况下设置的。我们也可以通过调用以下方式手动设置熔断:
1 | Config config = Config.getConfig(); |
之后中止hook:
与之类似的还有config类的cloudSwitch字段以及hookWhiteAll字段,这两个就交给读者了。
cp命令绕过RASP
以读取/etc/passwd为例,我们看一下OpenRASP的匹配规则:
这里只是匹配了cat.{1,5}/etc/passwd,那加入我们使用cp命令将/etc/passwd复制到/tmp/passwd,然后再查看/tmp/passwd不就可以绕过了吗?
1 | ?cmd=cp /etc/passwd /tmp/passwd |
同理,我们可以将某些禁止使用的命令,如/bin/nc、/bin/wget等copy到别的位置,之后再进行调用来进行bypass。
总结
本篇文章主要针对OpenRASP进行了启动流程的分析和Hook流程的分析,收获很大,算是RASP入门?时至今日,个人感觉RASP技术在大规模应用上仍然有比较大的困难。一方面就是令人诟病的性能原因,还有一方面要求安全人员对漏洞的原理必须深入到代码层面。因此感觉RASP技术可能就像Glassy师傅在KCon大会说得那样,不能像从前一样只是将恶意行为覆盖掉,因为恶意行为是不可穷举的,最好的解决办法就是在漏洞的入口点将攻击进行有效拦截。
























































