RASP是Runtime Application Self-Protection(运行时应用自我保护)的缩写,是一种应用程序安全技术。RASP 技术能够在应用程序运行时检测并阻止应用级别的攻击。随着云计算和大数据的发展,应用程序安全越来越受到重视。RASP 技术作为一种新型的安全防护手段,正在逐渐被业界接受并广泛应用。其中Java RASP 是一种针对 Java 应用程序的 RASP 技术。通过在 Java 虚拟机(JVM)级别进行监控和防护,能够有效防止对 Java 应用程序的攻击。
在业界,RASP的部署形式一般有agentmain
、premain
两种方式,二者各有优劣。适合不同的业务场景,以及安全需求。
agentmain
:业务无需改动,无需重启,热插拔,动态升级。有性能抖动,业务有感知。美团的RASP建设时,大部分业务都已经在线上运营,而且有多个发布平台,没有提供一个统一的方式来更改启动参数,也就是说无法通过premain
方式是实现快速部署。为了抓住主要矛盾,快速解决大部分风险问题,我们选择了agentmain
方式。
技术方案的设计,依赖于业务形态。美团内部的业务服务中,Java语言占比80%以上,是主要的风险所在。2010年至今,有特别复杂的业务部署形态、业务依赖环境、繁多的JDK等等,这些都是RASP技术方案的挑战。
问题的拆解思路依旧是抓住主要矛盾,以JDK版本为例,各个版本JDK的主机占比如下图1;
图1 公司JDK版本分布占比
业务目标确定后,解决方案同样具体到某一类的JDK上。同样,在发布环境、Web中间件的差异上,对RASP也有了更多的兼容要求。
agentmain
的动态注入机制,对JVM的影响是不可规避的。影响大小可以从与其他安全防护产品的部署位置看出,下图2是常见的基础安全防护产品:WAF、HIDS和RASP,他们与业务的隔离方式有以下几类:
图2 主机安全防护产品与业务的隔离等级
与其他的安全产品相比,如网络应用防火墙(WAF)和主机入侵检测系统(HIDS),RASP与业务部署在同一Java虚拟机(JVM),其隔离级别是最低的。这就意味着,当RASP自身出现BUG或者与业务不兼容时,对业务造成直接影响。RASP 一旦出现故障那至少是S4级别(核心功能受影如资损、客诉,且预判5分钟无法恢复) 。从业务指标上分为cpu和执行耗时,执行耗时方面主要是对服务的TP9999影响较大,而CPU方面出现cpu.busy
指标抖动情况。对于业务的指标影响,有以下几种:
下图3为特殊情况下运行时注入cpu.busy
指标抖动情况,在RASP注入时间内(CPU分钟级别采样),Java 进程的CPU从0%飙升到50%,然后又恢复。如果RASP注入之前Java进程的CPU已经很高了,注入时CPU会直接打满(注入前后10分钟)。
图3 运行时注入cpu.busy
指标抖动情况
下图4为运行时注入TP9999
指标抖动情况。单机维度,注入时TP9999
从5ms飙升到1000ms,大幅度增加,TP9999
出现明显的尖刺,对响应时间敏感的服务影响特别大。
图4 运行时注入TP9999
指标抖动情况
在RASP启动时,大量请求进入到检测流程中,此时RASP检测代码没有完成预热,检测方法处于字节码解释运行模式,执行效率低,从而导致启动时TP线高。如果正常的请求检测耗时过长,将严重影响业务的TP线,甚至导致请求超时。 在RASP运行过程中,因为检测引擎执行耗时长也会导致业务超时。
由于原生Java Agent的限制问题,JVM一旦加载了Agent,就无法进行更新,只能等待JVM重启。
图5 运行时Java Agent的实现原理与升级过程
图5左边的图展示了一个典型的运行时Java Agent的实现原理。在这个过程中,守护进程(这里指主动发起Attach的进程RASP Daemon)会attach到目标JVM上,然后RASP Agent的jar包会被JVM的AppClassLoader
加载,接着Agent就会初始化并开始运行。然而,由于JVM类加载机制的限制,同一个类(Agent入口类)无法被AppClassLoader
加载器加载两次。使用新的Agent jar包重新attach,即使attach成功,也不会加载新的类。因此想要增加新的功能或者进行bug修复,就必须等待业务进程重启后才能实现。
这也就是说,RASP功能的升级完全依赖于业务进程的重启时机。然而,我们发现线上有些业务,如大数据服务的核心节点,其重启时间可能长达半年甚至更长时间,这就使得RASP的功能升级过程变得异常漫长。由于服务长期未重启,RASP版本无法进行更新。影响主要有2个方面,一方面长期未重启服务的RASP版本低于最新版本,RASP Daemon需要兼容多种RASP Agent版本,这无疑提升了代码工程向下兼容的工作量和稳定性;另一方面,未重启的服务最新的hook点无法生效,也带来一定的安全风险。
在美团内部,安全部门需要不对业务有过多打扰的前提下保障业务安全运行。大规模重启服务风险高,不具备可实施性。如果遇到紧急漏洞或者重大bug时,这种升级难的问题尤为突出。升级难的问题是RASP在部署中遇到的第一个重大问题。
当JVM加载Java Agent后,由于其运行在业务的同一层面,必然会对业务产生一定的影响。这些影响可能包括CPU使用率飙高、TP9999
线的波动,甚至可能出现故障如内存泄漏、磁盘打满、核心转储(core dump)、触发JDK Bug、线程死锁、GC时间变长等等各种问题。业务反馈的线上各类问题的占比如下图6所示。
图6 RASP各类故障占比
由于RASP接入对用户无感知,一旦出现这些问题,业务方定位问题的源头往往耗费大量时间。业务需要对业务状态日志、GC日志、系统变更日志等进行详细的排查,以确定问题的根因。在实际的运行过程中,往往是业务最先反馈RASP影响,而RASP不能做到对故障及时感知与处理。
美团 RASP 利用 Java agent 和instrumentation
技术,通过 ASM 修改类字节码,实时分析检测命令执行、文件访问、反序列化、JNDI、SQL注入等入侵行为。它最初是从开源项目btrace 演化而来,后使用Golang重写了btrace的进程注入的功能,即架构中的 RASP Daemon 部分,在 Java Agent 端也参考了一些开源项目和公司内部的性能诊断工具。经过多年的迭代,RASP 逐渐形成目前的架构。
通过RASP管理端进行主机维度的配置下发,将最新配置更新应用到 RASP Daemon。日志收集和jar包下载使用公司基础组件,通过这些组件的协同工作,实现对 RASP 部署过程的管理,包括支持灰度发布、配置回滚、降级和一键关闭操作。下图7为 RASP 的配置分发流程。
图7 RASP的配置分发流程
传统的RASP直接修改JVM启动参数增加RASP的Java Agent参数,即premain
方式。而美团的RASP在最初只支持运行注入agentmain
方式,不支持premain
。原因主要是下面的2个方面:
综合业务现状与安全诉求,比较符合技术选型的是agentmain
机制。无需业务改动,也不依赖统一的代码发布平台,做到安全部门可控的能力覆盖。
经过多年部署,RASP已经覆盖大部分业务,具备相应安全能力。但也逐步遇到业务抱怨RASP注入带来的性能抖动问题。随着公司基础组件建设,也逐步统一了代码发布系统,在JAVA类服务的管控上有了统一的控制入口。同时,IDC内服务形态逐渐从VM虚拟机演化到容器,RASP的服务环境也与以往不一样。
当下主要矛盾发生变化,业务形态发生变化,支持premain的技术方案迫在眉睫。RASP联合服务发布与镜像团队在拉起服务之前将RASP的Java Agent以环境变量的方式设置到服务启动脚本的上下文中。下面为部署脚本中关于RASP环境变量的设置片段。
// 前置检查...
// 增加环境变量
if [[ $RASP_SWITCH=="ON" ]];then
JAVA_TOOL_OPTIONS="$JAVA_TOOL_OPTIONS -javaagent:rasp-premain.jar" && export JAVA_TOOL_OPTIONS
fi
// 启动Java进程...
在 RASP 升级新版本时,为尽可能地提高稳定性,需要按照一定策略进行灰度升级。
RASP Daemon(golang语言) 通过内核进程事件,感知新进程。再识别进程的cmdLine、JDK、Tomcat、Jetty、Spring Boot等的关键jar包,解析出JDK版本、Web类型和版本。对于已经兼容的服务可以开启注入,对于无法识别或者与RASP不兼容的服务关闭注入(es、jetty等个别版本),最大程度的减少对业务的影响。
JDK 兼容性:美团RASP除了使用ASM包之外基本上不使用第三方组件,降低供应链攻击,同时减少对不同版本JDK的专有特性依赖,对于JDK的代码也尽可能的本地化到RASP工程中,屏蔽JDK的版本差异性。
Java Agent 兼容性:公司有多种Java Agent 包括性能诊断,安全扫描、动态调试、流量录制、热部署、链路追踪等约十多种,这些工具实现原理都是基于Instrument。 冲突主要在还是在字节码修改上,例如RASP与jdwp的兼容上,最初版本的RASP在业务类中增加方法数量,当用户开启远程debug时,本地代码的方法数量与远程不一样,导致JVM崩溃。Java Agent应该遵循的规范:
字节码的修改应该遵循下面的基本原则:不允许新增、修改和删除成员变量 ;不允许新增和删除方法 ;不允许修改方法签名(来源于:Java 字节码规范);
Java Agent的jar包应该采用自定义类加载加载,依赖包名称前缀替换等方式,避免与其他Java Agent和业务依赖的冲突;
与其他Java Agent约定,在类查找遍历修改时排除其他的Java Agent的包名称,避免相互引用;
对于热部署等Java Agent,由于它不遵循字节码修改的基本规范,很遗憾,目前无法兼容,只能排除关闭注入;
运行时注入方式解决了RASP的首次注入不依赖业务重启服务的问题,但是随着部署场景的增加,不可避免的要对RASP进行更新迭代,如何升级成为一个让人头疼的问题。于是更新也不依赖业务重启,成为一个需要解决的最大问题。
插件热更新是一项具有挑战性的技术,也是RASP建设初期要求具备的核心特征之一。由于美团拥有上百万个Java服务节点,一般的Java Agent安装和升级都需要重启Java进程,对于如此庞大规模的服务来说,这并非易事。在超大规模下,如果依赖业务重新发布的方式来使RASP生效,需要等待所有的服务重启一遍。RASP项目没有权限重启业务。因此,对于RASP来说,插件热更新是至关重要的。
在最初的版本中,当RASP注入到业务中后,如果需要更新功能(如修改策略或hook点),仍然需要重新启动Java进程。如果业务不重启,之前版本的RASP会残留在进程中无法卸载,而新版本需要兼容这些无法卸载的部分。这导致线上存在多个不同版本的RASP,不同版本之间的兼容性几乎无法实现,这种方式是行不通的。
因此, RASP借鉴了Tomcat的类加载器架构,将功能分为两类:第一类是需要频繁迭代的功能,如hook点、资源监控、检测引擎、通信等;第二类是几乎不需要改动的部分,如插件加载和初始化部分。将第一类功能抽取出来,形成一个单独的插件包(RASP Plugin),插件包由自定义类加载器加载,使得这部分具备运行时更新的能力。而RASP Agent引导包仅保留几个类,负责初始化插件jar包。下图8展示了拆分前后的对比:
图8 mt-rasp jar包拆分前后对比
对于拆分后的架构,首次注入 RASP Agent 加载V1.0的插件,在需要对插件进行更新时,清除RASP PluginV1.0对象的引用和PluginClassLoader
对象,然后创建新的PluginClassLoader
实例重新加载并初始化V1.1版本插件,从而实现插件的卸载与热更新。上面拆分方案实现依靠自定义RASP类加载器,RASP的类加载器层次结构(agentmain)如下图9所示:
图9 RASP的类加载器层次结构
从顶层类加载器开始依次说明RASP包的功能和所属的类加载器。
BootstrapClasLoader
加载;agentmain/premain
等Agent初始方法、加载plugin并初始化,使用AppClassLoader
加载;RaspClassLoader
加载;RaspClassLoader
,使得脚本类能够访问rasp-plugin.jar中的类,使用自定义类加载器ScriptClassLoader
加载,并且脚本在磁盘加密在运行时解密。agentmain
和premain
方是Java Agent的两种启动方式,agentmain
在Java进程启动后加载,而premain
在Java进程启动前加载。由于启动时机不一样,带来的差异主要有agentmain
更新加载更加灵活,但是字节码修改时存在性能问题,特别是对性能比较敏感的服务;而premain
需要将javaagent参数加入到JVM启动命令行中,完全依赖业务启动,不太灵活,但是性能上比较稳定。美团RASP采用agentmain
与premain
结合方式,平衡灵活性与性能。原则上premain
逻辑尽可能的简单,避免频繁的迭代与升级。
RASP在加载时,Java进程的CPU会短暂的升高甚至打满,并且CPU核数越少,升高越明显持续时间越长。根因是Java Agent首次加载时会触发JVM中的code cache区域清零机制(可以认为是JDK的bug),大量热点代码的编译导致JIT编译线程将CPU打满,并且这种现象在CPU核数低于4核时表现尤为明显。
Manifest-Version: 1.0
Premain-Class: com.meituan.rasp.agent.RaspAgent
Agent-Class: com.meituan.rasp.agent.RaspAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Can-Set-Native-Method-Prefix: true
为了解决运行时CPU飙高问题,我们引入空的premain
包(premain v1.0)(仅开启上面的字节码转换的开关Can-Redefine-Classes
,无任何逻辑,也不修改字节码),在应用启动前加载,该方案取得较大优化效果。因为无任何代码,代码兼容性风险极小(并不是没有),因此能快速上线解决CPU飙高问题。以某个业务的主机为例子,在优化前后的cpu.busy
指标如下图10所示(注入前后10分钟)。
图10 cpu.busy指标优化前后对比
图10中红色为优化前的cpu.busy
指标,优化前即使注入前系统负载很低(4核8G,cpu.busy <2%),注入瞬间CPU依然飙升很高(28%);蓝色为优化后的cpu.busy
指标,优化后cpu.busy曲线较平滑,无明显尖刺。
采用premain
一期方案的原因是代码足够简单,几乎没有兼容性问题,因此能够快速大规模部署解决棘手的cpu抖动问题,上线效果较好。但是大部分服务虽然CPU不飙高了,但是还有少部分的服务TP9999指标依然影响较大。
premain
一期方案主要用来快速解决cpu.busy
抖动问题,但是对于性能比较敏感(如tp9999 < 50ms)的业务,运行时字节码修改不可避免的造成STW,从而导致TP9999 线升高。因为字节码修改时需要进入JVM的safepoint,执行字节码转换的方法VM_RedefineClasses::doit
会导致应用短暂地暂停响应,这部分代码执行慢就会影响应用TP9999
。大批量修改字节码测得火焰图如下图11所示。
图11 批量字节码转换时的Java进程火焰图
从火焰图11看出,RedefineClasses
耗时主要在VM_RedefineClasses::AdjustCpoolCacheAndVtable::do_klass
,hotspot JDK官方也有类似的issue。转换一个类的主要耗时在redefine_single_class
方法,初步测试耗时占比在40%~80% 之间,并且一次转换类越多,占STW总耗时越大。
在同一服务(硬件配置8核8G)测试了修改类的个数、服务负载和STW时间的关系如下图12所示:
图12 类转换STW时间与服务QPS、转换类数量的关系
从上面数据可以对比看出:
从上面的分析可以看出,修改字节码无法避免的产生STW(当然,优化这部分JDK代码理论上是可以实现的,但是技术难度较高,短期内无法解决),因此只能从规避的角度出发来解决。原则上只要保证字节码修改时没有请求即可。
一种可行的方案是将字节码转换的逻辑前移到JVM启动前(即业务没有流量或者主动摘除流量),并且尽量避免有请求时大批量的回滚/修改字节码,能够在一定程度上避开或者缓解STW影响业务请求响应时间。
对于高频率调用的方法如http body参数读取、sql.execute
等,使用premain
修改字节码插入RASP检测逻辑,premain
agent做到轻量级。对RASP的架构做出相应修改,新增rasp-premain.jar
,让服务启动前进行加载并初始化,将字节码的转换逻辑前置到启动时,如下图13所示,蓝色jar包为新增的rasp-premain.jar
。
图13 RASP类加载器增加premain agent
为了最大程度复用之前的系统架构,premain
加载后虽然字节码已经被转换,但 RASP的功能在逻辑上是关闭的,需要等到 agentmain
注入之后打开检测开关。premain
只做字节码转换,没有日志和通信等功能,不能单独工作。如图14所示,优化前后TP9999指标有较明显改善。
图14 优化前后注入时TP9999指标
在RASP的流量控制层增加对流量的计数,RASP初次接入流量时控制接入流量的比例(如1%),使得业务业务流量能够预热RASP检测逻辑,预热时间或者次数达到设定的阈值后,再开启100%的流量检测。
业务负载较高的场景(CPU飙高、hook逻辑执行严重超时等),为了避免RASP检测逻辑加剧性能恶化,RASP采样软降级措施,关闭对应hook类的逻辑开关,使得部分流量不执行检测逻辑。如果性能进一步恶化,RASP运行模式降级为观察上报模式,待系统资源检恢复正常过后,资源监测通过后自动恢复到检测阻断模式。
RASP更新插件代码时,需要将plugin的全部对象置空,否则会有内存泄漏问题,特别是元空间的内存泄漏,将导致业务将运行越来越慢,直到停止运行。从前面的STW时间结论来看,运行时的字节码回滚(和修改机制相同)也会产生STW,因此RASP将hook代码的逻辑开关关闭后,字节码依然留在业务类中,在清理完各种对象引用关系后,依然能够卸载plugin插件。
全局维度的监控指标:
单机维度指标:从业务层面到系统层面如下(列举部分)
图15 RASP监控的指标分布
系统指标和进程指标对于Golang来说很容易获取,相关api较多。这里仅以JVM指标元空间使用率(MetaSpace)的检测为例子说明。RASP Daemon 执行attach获取目前JVM的最大元空间(MaxMetaSpaceSize
)指标,然后读取 /tmp/hsperfdata_${user}/pid
文件解析元空间的占用(usedMetaspaceSize
参数在jvm里面是sun.gc.metaspace.used
),计算出元空间的占用比例和剩余空间,当剩余空间不足时,禁止RASP Agent注入,防止RASP成为压垮业务的最后一根稻草。
测试配置: 8核/8G/150G
压力:QPS梯度100,持续120s,稳定施压 120s
表1 注入前后的cpu.busy
指标
基准数据(不加载RASP) | 注入数据(加载RASP) | CPU指标增量值 | |
---|---|---|---|
QPS=20 | 3.47% | 4.18% | 0.71% |
QPS=100 | 11.70% | 11.76% | 0.06% |
QPS=200 | 20.95% | 21.05% | 0.15% |
QPS=300 | 32.12% | 32.78% | 0.66% |
QPS=400 | 41.23% | 44.2% | 2.97% |
QPS=500 | 52.78% | 56.5% | 3.73% |
最大QPS | 620 | 587.8 | – |
拟合方程 | 拟合方程:y = 0.103x + 1.203 拟合度:0.999 | 拟合方程:y = 0.109x + 0.827拟合度:0.998 |
cpu.busy
绝对值增加: 0.06%~3.73%,整体性能与开源的RASP相当
QPS超过350时系统cpu达到35%,触发弹性扩容,QPS压测到350可以测出最大内存损耗。
表2 注入前后的内存增加值
最小值 | 平均值 | 最大值 | |
---|---|---|---|
基准(QPS=350) | 422.38MB | 638.34MB | 3.10GB |
注入(QPS=350) | 457.69MB | 821.59MB | 3.30GB |
差值 | 32MB | 183MB | 0.2GB |
注入前后对比,压测到系统弹性扩容的最大QPS,最大堆内存增加约200M,整体性能与开源的RASP相当
元空间/永久代增加2MB,优于开源RASP产品
当前请求耗时控制在5ms内,优于开源RASP产品
经过近多年的研发迭代,目前具备的漏洞检测类型如下,基本覆盖常见漏洞(部分):命令执行 (支持native方法)、SQL注入、文件访问、反序列化攻击、JNDI、表达式等等。
开源方案中采用了JavaScript引擎作为实现方式,JS脚本可以被Java、php和c++等各种语言兼容,具备较强的通用性。但是经过测试,与原生Java相比,这些方案在性能上存在较大的差距。尽管JavaScript引擎具有不同语言通用性的大优势,但在执行性能方面并不满足高性能场景下RASP的需求。在美团,相比于性能,检测引擎的语言通用性并不是最重要的考虑因素。下面简单对比一下JavaScript和Java实现的检测引擎的性能。因为检测脚本主要涉及字符串的各种操作,我们选择了字符串累加的for循环作为测试场景。
// java
c+='c'
// javascript
c=c+'c'
经过测试,我们发现Java在执行这种字符串操作的性能方面表现更好。Java作为一种编译型语言,具有较高的执行效率和优化能力。它可以通过使用StringBuilder
等高效的字符串操作类来提高性能。相比之下,JavaScript作为一种解释型语言,执行效率相对较低。因此,在高性能场景下,使用Java实现的检测引擎往往能够更好地满足需求。尽管JavaScript引擎具有通用性,但在性能要求较高的场景下,选择使用Java实现的原生检测引擎更为合适。
表3 10万量级的for循环中跑出结果如下(单位ms)
javascript | java | |
---|---|---|
平均执行耗时 | 585 | 6.5 |
可以看出Java语言实现的检测引擎,性能上具备优越性。美团RASP使用Java语言构建检测引擎,能够满足性能上的需求。
在RASP Plugin中定义了检测脚本需要实现的接口,脚本的实现类由RASP Server下载到磁盘上;RASP Agent定时检测脚本文件是否更新,如果脚本更新,使用新的类加载器加载磁盘上的class文件,并创建实例。
在RASP中,通常会在hook方法的执行之前(before)、返回(return)和抛出异常处(throw)增加检测逻辑。RASP通过使用ASM字节码框架,在方法的before、return和throw处织入检测逻辑的字节码(下图16黄色框)。
图16 RASP阻断热修复控制流程
这里以在方法返回之前增加hook逻辑为例子说明阻断/热修复的流程:
instrument
的restransform
api将修改后的字节码替换原来的字节码。热修复与阻断的区别在于热修复返回的是一个对象,这个对象是修复后的正确的对象。
美团RASP经过多年的建设,在覆盖对象、部署方式、性能优化、兼容性和安全策略等多个方面逐步迭代,现在已覆盖绝大多数Java服务,支持众多web容器部署,基本覆盖常见的安全漏洞,整体覆盖率上达到了较高水位,并且多次检测出海量的漏洞攻击,成为美团IDC基础安全纵深防御体系中最重要的安全能力。
本文主要介绍了美团RASP在研发过程中遇到的问题和解决方案。首先介绍了RASP的痛点问题,包括业务场景复杂、升级变更难、对业务性能影响大和缺少监控等。对于RASP的升级问题,引入了插件热更新的技术,可以在不重启Java进程的情况下,即时地更新RASP的功能。为了降低对业务性能的影响,介绍了采取的优化措施,包括低峰期注入、启动时流量预热、软降级与逻辑开关以及插件卸载时不回滚字节码等关键技术。然后介绍了RASP的监控体系建设,包括监控指标的定义和收集。最后介绍了RASP的性能与灰度策略,通过对性能损耗的测试和分析,可以看出RASP对CPU和QPS的影响较小。在灰度策略方面,RASP结合了业务形态,特性影响等,选择合适的验证机制和测试方法。
许乐 、孙绥 、石东华、陈驰、郁丛祥、卢世宇等来自于美团信息安全部。
范围包括不限于HIDS、RASP、EDR、零信任产品等基础安全方向研发
负责美团应用程序自我防护系统的开发,通过Java字节码技术实现Java应用程序的防护;工程能力做到百万级别的Java应用覆盖,保障稳定性和性能;RASP/IAST等安全产品技术预研;
CFC4N的博客 由 CFC4N 创作,采用 知识共享 署名-非商业性使用-相同方式共享(3.0未本地化版本)许可协议进行许可。基于https://www.cnxct.com上的作品创作。转载请注明转自:美团RASP大规模研发部署实践总结