0x01 介绍
https://github.com/baidu/openrasp
Introduction
Unlike perimeter control solutions like WAF, OpenRASP directly integrates its protection engine into the application server by instrumentation. It can monitor various events including database queries, file operations and network requests etc.
When an attack happens, WAF matches the malicious request with its signatures and blocks it. OpenRASP takes a different approach by hooking sensitive functions and examines/blocks the inputs fed into them. As a result, this examination is context-aware and in-place. It brings in the following benefits:
- Only successful attacks can trigger alarms, resulting in lower false positive and higher detection rate;
- Detailed stack trace is logged, which makes the forensic analysis easier;
- Insusceptible to malformed protocol.
我的理解
在我阅读了OpenRASP的源码后,再次解读官方对其的介绍,我认为OpenRASP就是一个不同于WAF,它是通过JavaAgent,然后利用Instrumentation在class加载时,通过javassist的方式hook目标class的method,在其method插桩,以进行一系列的安全基准测试或运行时的安全检查,它相对于WAF来说,具有非常大的优势,但这是相对的,它的优点的存在恰恰也造成了一定的缺点。
优点:
- WAF依靠特征检测攻击,但会造成一定的误报率,而OpenRASP不一样,必须是成功的攻击才会触发报警
- OpenRASP插桩到代码层面,可以记录详细的栈堆跟踪信息
缺点:
- 因为侵入到代码层面,导致必然会造成一定的性能损耗,并且一个不合格的rasp更容易影响到业务代码
重点阅读部分
从github clone下来项目之后,我们可以看到具体的目录大致构成是这样的:
1 | LICENSE build-cloud.sh build-php7.sh docker plugins rasp-vue travis |
而我主要关心的是:
- agent/java/boot(OpenRASP JavaAgent源码)
- agent/java/engine(OpenRASP主要唯一的module)
- rasp-install/java(OpenRASP安装源码)
- plugins(js插件,OpenRASP检查攻击的主要源码,因为js的热部署性而采用)
0x02 OpenRASP的安装原理
java源码实现,位置rasp-install/java
宏观上的审视
查看源码工程,其具有两个package
1 | -install |
其实就是对OpenRASP的安装和卸载做的封装,interfece Installer和interface Uninstaller分别是它们的抽象定义
1 | public interface Installer { |
包中都是对Installer、Uninstaller基于不同操作系统、web服务器的实现,并通过了工厂模式,根据参数、环境变量、目录信息特征等,选择对应的实现
1 | public abstract class InstallerFactory |
OpenRASP的安装程序主要入口位于:
1 | package com.baidu.rasp |
微观细节的跟踪
应用入口:
1 | public static void main(String[] args) { |
主要代码:
1 | public static void operateServer(String[] args) throws RaspError, ParseException, IOException { |
代码跟进:
- showBanner():通过该方法输出了OpenRASP安装程序的一些banner信息
- argsParser(args):该方法主要是对程序启动参数的解析和校验,它通过commons-cli的功能,对启动参数进行一系列的解析和校验
1 | install:指定该操作为安装 |
- checkArgs():对程序启动参数格式、范围等校验
1 | appId、appSecret、raspId、url、heartbeatInterval |
- 根据参数判断是安装还是卸载,若是安装,则通过安装工厂newInstallerFactory获取安装实例进行执行安装,若是卸载,则通过卸载工厂newUninstallerFactory获取卸载实例进行执行卸载,安装、卸载工厂的不同操作系统实现,是根据系统变量os.name进行判断
1 | private static InstallerFactory newInstallerFactory() { |
获取安装实例:
1 | public Installer getInstaller(File serverRoot, boolean noDetect) throws RaspError { |
可以看到,对于使用了启动参数nodetect的安装,选择的是GenericInstaller通用安装实例,否则会通过detectServerName(String serverRoot)方法进行web服务器的特征检测
1 | public static String detectServerName(String serverRoot) throws RaspError { |
特征检测的方式,无一不是通过检测特定目录是否存在shell脚本实现
执行安装:
安装核心方法:install()
- GenericInstaller通用安装:
先是根据当前jar的目录获取到其子目录rasp,若不存在则新建1
2
3
4
5String jarPath = getLocalJarPath();
File srcDir = new File(new File(jarPath).getParent() + File.separator + "rasp");
if (!(srcDir.exists() && srcDir.isDirectory())) {
srcDir.mkdirs();
}
接着通过设定的安装目录,检测openrasp.yml是否存在,用以判断是否第一次安装,然后拷贝rasp文件夹至目标安装目录1
2
3
4
5
6
7
8
9
10
11File installDir = new File(getInstallPath(serverRoot));
File configFile = new File(installDir.getCanonicalPath() + File.separator + "conf" + File.separator + "openrasp.yml");
if (!configFile.exists()) {
firstInstall = true;
}
if (!srcDir.getCanonicalPath().equals(installDir.getCanonicalPath())) {
// 拷贝rasp文件夹
System.out.println("Duplicating \"rasp\" directory\n- " + installDir.getCanonicalPath());
FileUtils.copyDirectory(srcDir, installDir);
}
删除官方js插件1
2
3
4
5
6
7//安装rasp开启云控,删除官方插件
if (App.url != null && App.appId != null && App.appSecret != null) {
File plugin = new File(installDir.getCanonicalPath() + File.separator + "plugins" + File.separator + "official.js");
if (plugin.exists()) {
plugin.delete();
}
}
若不是第一次安装,则会把目标安装目录下原有配置文件修改名称为openrasp.yml.bak,然后拷贝当前jar目录下的openrasp.yml到目标安装目录的conf子目录1
2
3
4// 生成配置文件
if (!generateConfig(installDir.getPath(), firstInstall)) {
System.exit(1);
}
1 | private boolean generateConfig(String dir, boolean firstInstall) { |
其中通过setCloudConf()方法,把云控所需的程序启动参数,写到配置文件openrasp.yml中
1 | appid:OpenRASP连接到RASP Cloud的认证appid |
写入的格式(yml):
1 | cloud: |
- TomcatInstaller
相对于通用安装的主要流程,它们并没有什么区别,区别仅仅在于配置文件写完后,TomcatInstaller会tomcat的安装目录下的bin/catalina.sh脚本进行修改
1 | 位于:com.baidu.rasp.install.BaseStandardInstaller#generateStartScript |
在修改脚本时,会找到对应的位置写入或删除原有OpenRASP内容,写入新的脚本,然后根据程序启动参数prepend选择插入不同的rasp启动方式:
比web容器bootstrap更先启动:1
private static String PREPEND_JAVA_AGENT_CONFIG = "\tJAVA_OPTS=\"${JAVA_OPTS} -javaagent:${CATALINA_HOME}/rasp/rasp.jar\"\n";
较web容器bootstrap更后启动:
1 | private static String JAVA_AGENT_CONFIG = "\tJAVA_OPTS=\"-javaagent:${CATALINA_HOME}/rasp/rasp.jar ${JAVA_OPTS}\"\n"; |
总结下来:
1 | /** |
0x03 OpenRASP的启动工作
java源码实现,位置:agent/java/boot
入口代码:
1 | /** |
具有两种方式的启动,一种是JVMTI调用premain方式,一种是attach机制加载agent的方式。
1 | String START_MODE_ATTACH = "attach"; |
核心:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16/**
* attack 机制加载 agent
*
* @param mode 启动模式
* @param inst {@link Instrumentation}
*/
public static synchronized void init(String mode, String action, Instrumentation inst) {
try {
JarFileHelper.addJarToBootstrap(inst);
readVersion();
ModuleLoader.load(mode, action, inst);
} catch (Throwable e) {
System.err.println("[OpenRASP] Failed to initialize, will continue without security protection.");
e.printStackTrace();
}
}
- JarFileHelper.addJarToBootstrap(inst):添加当前执行的jar文件至jdk的跟路径下,启动类加载器能优先加载,后续在这个javaagent会对启动类加载的class进行插桩,插桩代码点会调用rasp代码,因为启动类加载器加载的类是没办法去调用得到启动类加载器加载不到的类,因为每个类加载器都有自己的类加载目录
- readVersion():读取MANIFEST.MF相关信息
- ModuleLoader.load(mode, action, inst):加载rasp-engine.jar中的module实现(目前为止,仅有这一个module实现)
1 | /** |
可以看到,这里的实现是创建容器并启动,容器的实现是rasp-engine.jar,如果细看ModuleContainer的源码,可以发现,在其构造方法中,读取了rasp-engine.jar中MANIFEST.MF文件的Rasp-Module-Name、Rasp-Module-Class信息,此信息用于指定rasp-engine.jar中module容器的实现类,然后agent中的module加载器根据此信息加载module容器并调用start方法启动
0x04 OpenRASP engine的启动
java源码实现,位置:agent/java/engine
入口代码(com.baidu.openrasp.EngineBoot):
1 | @Override |
可以看到,一共就做了以下这些工作:
- 输出banner信息
- V8引擎的加载,用于解释执行JavaScript
- loadConfig():初始化配置
1 | private boolean loadConfig() throws Exception { |
LogConfig.ConfigFileAppender():初始化log4j
CloudUtils.checkCloudControlEnter():检查云控配置信息
LogConfig.syslogManager():读取配置信息,初始化syslog服务连接
- JS.Initialize():初始化插件系统
为V8配置java的logger以及栈堆信息Getter(用于在js中获取当前栈堆信息)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37public synchronized static boolean Initialize() {
try {
V8.Load();
if (!V8.Initialize()) {
throw new Exception("[OpenRASP] Failed to initialize V8 worker threads");
}
V8.SetLogger(new com.baidu.openrasp.v8.Logger() {
@Override
public void log(String msg) {
PLUGIN_LOGGER.info(msg);
}
});
V8.SetStackGetter(new com.baidu.openrasp.v8.StackGetter() {
@Override
public byte[] get() {
try {
ByteArrayOutputStream stack = new ByteArrayOutputStream();
JsonStream.serialize(StackTrace.getParamStackTraceArray(), stack);
stack.write(0);
return stack.getByteArray();
} catch (Exception e) {
return null;
}
}
});
Context.setKeys();
if (!CloudUtils.checkCloudControlEnter()) {
UpdatePlugin();
InitFileWatcher();
}
return true;
} catch (Exception e) {
e.printStackTrace();
LOGGER.error(e);
return false;
}
}
UpdatePlugin():读取plugins目录下的js文件,过滤掉大于10MB的js文件,然后全部读入,最后加载到V8引擎中
1 | public synchronized static boolean UpdatePlugin() { |
这里有一个commonLRUCache,主要是用于在hook点去执行js check的时候,进行一个并发幂等(应该是这样。。。)。
InitFileWatcher():初始化一个js plugin监视器,在js文件有所变动的时候,重新去加载所有插件,实现热更新的特性
1 | public synchronized static void InitFileWatcher() throws Exception { |
- CheckerManager.init():初始化所有的checker,从枚举类com.baidu.openrasp.plugin.checker.CheckParameter.Type中读取所有的checker,包含三种类型的checker,一是js插件检测,意味着这个checker会调用js plugin进行攻击检测,二是java本地检测,意味着是调用本地java代码进行攻击检测,三是安全基线检测,是用于检测一些高风险类的安全性基线检测,检测其配置是否有安全隐患。
1 | // js插件检测 |
- initTransformer(inst):核心代码,通过加载class,在加载前使用javassist对其进行hook插桩,以实现rasp的攻击检测功能
1 | /** |
可以看到,addAnnotationHook()读取了com.baidu.openrasp.hook包中所有被@HookAnnotation注解的class,然后缓存到集合hooks中,以提供在后续类加载通过com.baidu.openrasp.transformer.CustomClassTransformer#transform的时候,对其进行匹配,判断是否需要hook
1 | public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, |
看细节,可以发现,先根据isClassMatched(String className)方法判断是否对加载的class进行hook,接着调用的是hook类的transformClass(CtClass ctClass)->hookMethod(CtClass ctClass)方法进行了字节码的修改(hook),然后返回修改后的字节码并加载,最终实现了对class进行插桩
例子(com.baidu.openrasp.hook.ssrf.HttpClientHook):
HttpClient中发起请求前,都会先创建HttpRequestBase这个类的实例,然后才能发起请求,该实例中包含着URI信息,而对于SSRF的攻击检测,就是在请求发起前,对URI进行检测,检测是否是SSRF,因此需要hook到HttpRequestBase类1
2
3public boolean isClassMatched(String className) {
return "org/apache/http/client/methods/HttpRequestBase".equals(className);
}
既然要检测SSRF,那么就选择在setURI时,就对其URI进行检测,hookMethod方法其实就是通过javassist生成了一段调用com.baidu.openrasp.hook.ssrf.HttpClientHook#checkHttpUri方法的代码,并插入到HttpRequestBase.setURI方法中,以实现检测SSRF1
2
3
4
5protected void hookMethod(CtClass ctClass) throws IOException, CannotCompileException, NotFoundException {
String src = getInvokeStaticSrc(HttpClientHook.class, "checkHttpUri",
"$1", URI.class);
insertBefore(ctClass, "setURI", "(Ljava/net/URI;)V", src);
}
checkHttpUri方法通过取出相关信息,host、port、url等,然后通过一系列方法,对检测ssrf的js插件进行调用以检测攻击,当然,过程中会加入一些机制,对其可用性的增强1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44public static void checkHttpUri(URI uri) {
String url = null;
String hostName = null;
String port = "";
try {
if (uri != null) {
url = uri.toString();
hostName = uri.toURL().getHost();
int temp = uri.toURL().getPort();
if (temp > 0) {
port = temp + "";
}
}
} catch (Throwable t) {
LogTool.traceHookWarn("parse url " + url + " failed: " + t.getMessage(), t);
}
if (hostName != null) {
checkHttpUrl(url, hostName, port, "httpclient");
}
}
->com.baidu.openrasp.hook.ssrf.AbstractSSRFHook#checkHttpUrl
protected static void checkHttpUrl(String url, String hostName, String port, String function) {
HashMap<String, Object> params = new HashMap<String, Object>();
params.put("url", url);
params.put("hostname", hostName);
params.put("function", function);
params.put("port", port);
LinkedList<String> ip = new LinkedList<String>();
try {
InetAddress[] addresses = InetAddress.getAllByName(hostName);
for (InetAddress address : addresses) {
if (address != null && address instanceof Inet4Address) {
ip.add(address.getHostAddress());
}
}
} catch (Throwable t) {
// ignore
}
Collections.sort(ip);
params.put("ip", ip);
HookHandler.doCheck(CheckParameter.Type.SSRF, params);
}
流程汇总:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
231.com.baidu.openrasp.hook.ssrf.HttpClientHook#checkHttpUri
2.com.baidu.openrasp.hook.ssrf.AbstractSSRFHook#checkHttpUrl
3.com.baidu.openrasp.HookHandler#doCheck
4.com.baidu.openrasp.HookHandler#doCheckWithoutRequest
在这里,做了一些云控注册成功判断和白名单的处理
5.com.baidu.openrasp.HookHandler#doRealCheckWithoutRequest
在这里,做了一些参数的封装,以及失败日志、耗时日志等输出,并且在检测到攻击时(下一层返回),抛出异常
6.com.baidu.openrasp.plugin.checker.CheckerManager#check
7.com.baidu.openrasp.plugin.checker.AbstractChecker#check
在这里,对js或者其他类型的安全检测之后的结果,进行事件处理并返回结果
8.com.baidu.openrasp.plugin.checker.v8.V8Checker#checkParam
9.com.baidu.openrasp.plugin.js.JS#
在这里,做了一些commonLRUCache的并发幂等处理
10.com.baidu.openrasp.v8.V8#Check(java.lang.String, byte[], int, com.baidu.openrasp.v8.Context, boolean, int)
总的来说,大概整个OpenRASP的核心就是如此了,还有一些关于cloud的云控实现,这里的篇幅暂且不对其就行研究