浅谈 RASP

作者:Lucifaer

作者博客:https://lucifaer.com/2019/09/25/浅谈RASP/

本篇将近一个月对rasp的研究成果进行汇总,具体讨论RASP的优劣势以及一些个人的理解和看法。

0x01 概述

RASP是Runtime application self-protection的缩写,中文翻译为应用程序运行时防护,其与WAF等传统安全防护措施的主要区别于其防护层级更加底层——在功能调用前或调用时能获取访问到当前方法的参数等信息,根据这些信息来判定是否安全。

RASP与传统的基于流量监测的安全防护产品来说,优势点在于可以忽略各种绕过流量检测的攻击方式(如分段传输,编码等),只关注功能运行时的传参是否会产生安全威胁。简单来说,RASP不看过程,只看具体参数导致方法实现时是否会产生安全威胁。简单类比一下,RASP就相当于应用程序的主防,其判断是更加精准的。

虽然RASP有很多优势,但是由于其本身的实现也导致了很多问题使其难以推广:

  • 侵入性过大。对于JAVA的RASP来说,它的实现方式是通过Instrumentation编写一个agent,在agent中加入hook点,当程序运行流程到了hook点时,将检测流程插入到字节码文件中,统一进入JVM中执行。在这里如果RASP本身出现了什么问题的话,将会直接对业务造成影响。
  • 效率问题。由于需要将检测流程插入到字节码文件中,这样会在运行时产生大量不属于业务流程本身的逻辑,这样会增加业务执行的流程,对业务效率造成一定的影响。
  • 开发问题。针对不同的语言,RASP底层的实现是不一样的,都需要重新基于语言特性进行专门的开发,开发的压力很大。
  • 部署问题。以Java RASP来举例子,Java RASP有两种部署方式,一种需要在启动前指定agent的位置,另一种可以在运行时用attach的方式进行部署,但是他们都存在不同的问题。

    • 在启动前指定agent的位置就以为着在进行部署时需要重启服务,会影响到正常的业务。
    • 在运行时进行attach部署时,当后期RASP进行版本迭代重新attach时,会产生重复添加代码的情况(由于JVM本身机制的问题,基本无法将修改的字节码重新转换到运行时的字节码上,所以没办法动态添加代理解决该问题)。

目前RASP的主方向还是Java RASP,受益于JVMTI,现在的Java RASP是很好编写的,效果也是比较错的。同时也受限于JVMTI,Java RASP的技术栈受到了一定的限制,很难在具体实现上更进一步,只能在hook点和其他功能上进行完善。

跳出乙方视角来审视RASP,其最好的实践场景还是在甲方企业内部,从某个角度来说RASP本来就是高度侵入业务方代码的一种防护措施,在纷繁复杂的业务场景中,只有甲方根据业务进行定制化开发才能达到RASP的最高价值,如果乙方来做很容易变成“纸上谈兵”的产品。

下面将以Java RASP为核心对RASP技术进行详细的阐述,并用跟踪源码的方式来解析百度OpenRASP的具体实现方式。

0x02 Java RASP技术栈

Java RASP核心技术栈:

  • Instrumentation通过JVMTI实现的Agent,负责获取并返回当前JVM虚拟机的状态或转发控制命令。
  • 字节码操作框架,用于修改字节码(如ASM、Javassist等)

其余技术栈:

  • Log4j日志记录
  • 插件系统(主要是用于加载检测规则)
  • 数据存储及转发(转发到soc平台或自动封禁平台进行封禁)

0x03 Java RASP实现方式

编写Java RASP主要分为两部分:

  • Java Agent的编写
  • 利用字节码操作框架(以下都以ASM来举例)完成相应hook操作

3.1 Java Agent简介

在Java SE 5及后续版本中,开发者可以在一个普通Java程序运行时,通过-javaagent参数指定一个特定的jar文件(该文件包含Instrumentation代理)来启动Instrumentation的代理程序,这个代理程序可以使开发者获取并访问JVM运行时的字节码,并提供了对字节码进行编辑的操作,这就意味着开发者可以将自己的代码注入,在运行时完成相应的操作。在Java SE 6后又对改功能进行了增强,允许开发者以用Java Tool API中的attach的方式在程序运行中动态的设置代理类,以达到Instrumentation的目的。而这两个特性也是编写Java RASP的关键。

javaagent提供了两种模式:

  • premain:允许在main开始前修改字节码,也就是在大部分类加载前对字节码进行修改。
  • agentmain:允许在main执行后通过com.sun.tools.attach的Attach API attach到程序运行时中,通过retransform的方式修改字节码,也就是在类加载后通过类重新转换(定义)的方式在方法体中对字节码进行修改,其本质还是在类加载前对字节码进行修改。

这两种模式除了在main开始前后调用的区别外,还有很多细枝末节的区别,这一点就导致了两种模式的泛用性不同:

  • agent运作模式不同:premain相当于在main前类加载时进行字节码修改,agentmain是main后在类调用前通过重新转换类完成字节码修改。可以发现他们的本质都是在类加载前完成的字节码修改,但是premain可以直接修改或者通过redefined进行类重定义,而agentmian必须通过retransform进行类重新转换才能完成字节码修改操作。
  • 部署方式不同:由于agent运作模式的不同,所以才导致premain需要在程序启动前指定agent,而agentmain需要通过Attach API进行attach。而且由于都是在类加载前进行字节码的修改,所以如果premain模式的hook进行了更新,就只能重启服务器,而agentmain模式的hook如果进行了更新的话,需要重新attach。

因为两种模式都存在一定的限制,所以在实际运用中都会有相应的问题:

  • premain:每次修改需要重启服务。
  • agentmain:由于attach的运行时中的进程,因JVM的进程保护机制,禁止在程序运行时对运行时的类进行自由的修改,具体的限制如下:

    • 父类应为同一个类
    • 实现的接口数要相同
    • 类访问符要一致
    • 字段数和字段名必须一致
    • 新增的方法必须是private static/final
    • 可是删除修改方法

    这样的限制是没有办法用代理模式的思路来避免重复插入的。同时为了实现增加hook点的操作我们必须将自己的检测字节码插入,所以只能修改方法体。这样一来如果使用agentmain进行重复的attach,会造成将相同代码多次插入的操作,会产生重复告警,极大的增加业务压力。

单单针对agentmain所出现的重复插入的问题,有没有方式能直接对运行时的java类做字节码插入呢?其实是有的,但是由于各种原因,其会较大的增加业务压力所以这里不过多叙述,想要了解详情的读者,可以通过搜索HotswapDCE VM来了解两种不同的热部署方式。

3.2 ASM简介

ASM是一个Java字节码操作框架,它主要是基于访问者模式对字节码完成相应的增删改操作。想要深入的理解ASM可以去仔细阅读ASM的官方文档,这里只是简单的介绍一下ASM的用法。

在开始讲ASM用法前,需要简单的介绍一下访问者模式,只有清楚的访问者模式,才能理解ASM为什么要这么写。

3.2.1 访问者模式

在面向对象编程和软件工程中,访问者模式是一种把数据结构和操作这个数据结构的算法分开的模式。这种分离能方便的添加新的操作而无需更改数据结构。

实质上,访问者允许一个类族添加新的虚函数而不修改类本身。但是,创建一个访问者类可以实现虚函数所有的特性。访问者接收实例引用作为输入,使用双重调用实现这个目标。

上面说的的比较笼统,直接用代码来说话:

package com.lucifaer.ASMDemo;

interface Person {

public void accept(Visitor v) throws InterruptedException;

}

class Play implements Person{

@Override

public void accept(Visitor v) throws InterruptedException {

v.visit(this);

}

public void play() throws InterruptedException {

Thread.sleep(5000);

System.out.println("This is Person's Play!");

}

}

interface Visitor {

public void visit(Play p) throws InterruptedException;

}

class PersonVisitor implements Visitor {

@Override

public void visit(Play p) throws InterruptedException {

System.out.println("In Visitor!");

long start_time = System.currentTimeMillis();

p.play();

long end_time = System.currentTimeMillis();

System.out.println("End Visitor");

System.out.println("Spend time: " + (end_time-start_time));

}

}

public class VisiterMod {

public static Person p = new Play();

public static void main(String[] args) throws InterruptedException {

PersonVisitor pv = new PersonVisitor();

p.accept(pv);

}

}

在这个例子中做了以下的工作:

  1. 添加void accept(Visitor v)Person类中
  2. 创建visitor基类,基类中包含元素类的visit()方法
  3. 创建visitor派生类,实现基类对PersonPlay的操作
  4. 使用者创建visitor对象,调用元素的accept方法并传递visitor实例作为参数

可以看到在没有改变数据结构的情况下只是实现了Visitor类就可以在visit方法中自行加入代码实现自定义逻辑,而不会影响到原本Person接口的实现类。

结果为:

3.2.2 ASM的访问者模式

在ASM中的访问者模式中,ClassReader类和MethodNode类都是被访问的类,访问者接口包括:ClassVistorAnnotationVisitorFieldVistorMethodVistor。访问者接口的方法集以及优先顺序可以在下图中进行查询:

通过该图可以清晰的看出调用顺序,对于新手来说可以简单的理解为下面这样的调用顺序:

  • 需要访问类,所以要声明ClassReader,来“获取”类。
  • 如果需要对类中的内容进行修改,就需要声明ClassWriter它是继承于ClassReader的。
  • 然后实例化“访问者”ClassVisitor来进行类访问,至此就以“访问者”的身份进入了类,你可以进行以下工作:

    • 如果需要访问注解,则实例化AnnotationVisitor
    • 如果需要访问参数,则实例化FieldVisitor
    • 如果需要访问方法,则实例化MethodVisitro

    每种访问其内部的访问顺序可以在图上自行了解。

    ClassReader调用accept方法

    完成整个调用流程

3.3 实际例子

在具体展示两种模式的例子前,先补充一下agent的运行条件,无论用那种模式写出来的agent,都需要将agent打成jar包,同时在jar包中应用META-INF/MANIFEST.MF中指定agent的相关信息,下面是个例子:

Manifest-Version: 1.0

Can-Redefine-Classes: true

Can-Retransform-Classes: true

Premain-Class: com.lucifaer.javaagentLearning.agent.PreMainTranceAgent

Agent-Class: com.lucifaer.javaagentLearning.agent.AgentMainTranceAgent

Premain-ClassAgent-Class是用来配置不同模式的agent实现类,Can-Redefine-ClassesCan-Retransform-Classes是用来指示是否允许进行类重定义和类重新转换,这两个参数在一定的情况下决定了是否能在agent中利用ASM对加载的类进行修改。

3.3.1 premain模式例子

下面用园长的一个demo来展示如何利用premain方式进行表达式监控。完整代码可以看这里,也可以看我整理后的代码

public class Agent implements Opcodes {

private static List<MethodHookDesc> expClassList = new ArrayList<MethodHookDesc>();

static {

expClassList.add(new MethodHookDesc("org.mvel2.MVELInterpretedRuntime", "parse",

"()Ljava/lang/Object;"));

expClassList.add(new MethodHookDesc("ognl.Ognl", "parseExpression",

"(Ljava/lang/String;)Ljava/lang/Object;"));

expClassList.add(new MethodHookDesc("org.springframework.expression.spel.standard.SpelExpression", "<init>",

"(Ljava/lang/String;Lorg/springframework/expression/spel/ast/SpelNodeImpl;" +

"Lorg/springframework/expression/spel/SpelParserConfiguration;)V"));

}

public static void premain(String agentArgs, Instrumentation instrumentation) {

System.out.println("agentArgs : " + agentArgs);

instrumentation.addTransformer(new ClassFileTransformer() {

public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {

final String class_name = className.replace("/", ".");

for (final MethodHookDesc methodHookDesc : expClassList) {

if (methodHookDesc.getHookClassName().equals(class_name)) {

final ClassReader classReader = new ClassReader(classfileBuffer);

ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS);

final int api = ASM5;

try {

ClassVisitor classVisitor = new ClassVisitor(api, classWriter) {

@Override

public MethodVisitor visitMethod(int i, String s, String s1, String s2, String[] strings) {

final MethodVisitor methodVisitor = super.visitMethod(i, s, s1, s2, strings);

if (methodHookDesc.getHookMethodName().equals(s) && methodHookDesc.getHookMethodArgTypeDesc().equals(s1)) {

return new MethodVisitor(api, methodVisitor) {

@Override

public void visitCode() {

if ("ognl.Ognl".equals(class_name)) {

methodVisitor.visitVarInsn(Opcodes.ALOAD, 0);

}else {

methodVisitor.visitVarInsn(Opcodes.ALOAD, 1);

}

methodVisitor.visitMethodInsn(

Opcodes.INVOKESTATIC, Agent.class.getName().replace(".", "/"), "expression", "(Ljava/lang/String;)V", false

);

}

};

}

return methodVisitor;

}

};

classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES);

classfileBuffer = classWriter.toByteArray();

}catch (Throwable t) {

t.printStackTrace();

}

}

}

return classfileBuffer;

}

});

}

public static void expression(String exp_demo) {

System.err.println("---------------------------------EXP-----------------------------------------");

System.err.println(exp_demo);

System.err.println("---------------------------------调用链---------------------------------------");

StackTraceElement[] elements = Thread.currentThread().getStackTrace();

for (StackTraceElement element : elements) {

System.err.println(element);

}

System.err.println("-----------------------------------------------------------------------------");

}

}

这里采用的是流式写法,没有将其中的ClassFileTransformer抽出来。

整个流程简化如下:

  • 根据className来判断当前agent拦截的类是否是需要hook的类,如果是,则直接进入ASM修改流程。
  • ClassVisitor中调用visitMethod方法去访问hook类中的每个方法,根据方法名判断当前的方法是否是需要hook的方法,如果是,则调用visitCode方法在访问具体代码时获取方法的相关参数(这里是获取表达式),并在执行逻辑中插入expression方法的调用,在运行时将执行流经过新添加的方法,就可以打印出表达式以及调用链了。

效果如下:

3.3.2 agentmain模式例子

下面用一个我自己写的例子来说一下如何利用agentmain模式增加执行流。

AgentMain.java

public class AgentMain {

public static void agentmain(String agentArgs, Instrumentation inst) throws UnmodifiableClassException {

// for (Class clazz : inst.getAllLoadedClasses()) {

// System.out.println(clazz.getName());

// }

CustomClassTransformer transformer = new CustomClassTransformer(inst);

transformer.retransform();

}

}

CustomClassTransformer.java

public class CustomClassTransformer implements ClassFileTransformer {

private Instrumentation inst;

public CustomClassTransformer(Instrumentation inst) {

this.inst = inst;

inst.addTransformer(this, true);

}

@Override

public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {

System.out.println("In Transform");

ClassReader cr = new ClassReader(classfileBuffer);

ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS);

ClassVisitor cv = new ClassVisitor(Opcodes.ASM5, cw) {

@Override

public MethodVisitor visitMethod(int i, String s, String s1, String s2, String[] strings) {

// return super.visitMethod(i, s, s1, s2, strings);

final MethodVisitor mv = super.visitMethod(i, s, s1, s2, strings);

if ("say".equals(s)) {

return new MethodVisitor(Opcodes.ASM5, mv) {

@Override

public void visitCode() {

super.visitCode();

mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");

mv.visitLdcInsn("CALL " + "method");

mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

}

};

}

return mv;

}

};

cr.accept(cv, ClassReader.EXPAND_FRAMES);

classfileBuffer = cw.toByteArray();

return classfileBuffer;

}

public void retransform() throws UnmodifiableClassException {

LinkedList<Class> retransformClasses = new LinkedList<Class>();

Class[] loadedClasses = inst.getAllLoadedClasses();

for (Class clazz : loadedClasses) {

if ("com.lucifaer.test_agentmain.TestAgentMain".equals(clazz.getName())) {

if (inst.isModifiableClass(clazz) && !clazz.getName().startsWith("java.lang.invoke.LambdaForm")) {

inst.retransformClasses(clazz);

}

}

}

}

}

可以看到agentmain模式和premain的大致写法是没有区别的,最大的区别在于如果想要利用agentmain模式来对运行后的类进行修改,需要利用Instrumentation.retransformClasses方法来对需要修改的类进行重新转换。

想要agentmain工作还需要编写一个方法来利用Attach API来动态启动agent:

public class AttachAgent {

public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {

List<VirtualMachineDescriptor> list = VirtualMachine.list();

for (VirtualMachineDescriptor vmd : list) {

if (vmd.displayName().endsWith("TestAgentMain")) {

VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());

virtualMachine.loadAgent("/Users/Lucifaer/Dropbox/Code/Java/agentmain_test/out/artifacts/agentmain_test_jar/agentmain_test.jar", "Attach!");

System.out.println("ok");

virtualMachine.detach();

}

}

}

}

效果如下:

3.3.3 agentmain坑点

这里有一个坑点也导致没有办法在agentmain模式下动态给一个类添加一个新的方法,如果尝试添加一个新的方法就会报错。下面是我编写利用agentmain模式尝试给类动态增加一个方法的代码:

public class DynamicClassTransformer implements ClassFileTransformer {

private Instrumentation inst;

private String name;

private String descriptor;

private String[] exceptions;

public DynamicClassTransformer(Instrumentation inst) {

this.inst = inst;

inst.addTransformer(this, true);

}

@Override

public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {

System.out.println("In transformer");

ClassReader cr = new ClassReader(classfileBuffer);

ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS);

ClassVisitor cv = new ClassVisitor(Opcodes.ASM5, cw) {

@Override

public MethodVisitor visitMethod(int i, String s, String s1, String s2, String[] strings) {

final MethodVisitor mv = super.visitMethod(i, s, s1, s2, strings);

if ("say".equals(s)) {

name = s;

descriptor = s1;

exceptions = strings;

}

return mv;

}

};

// ClassVisitor cv = new DynamicClassVisitor(Opcodes.ASM5, cw);

cr.accept(cv, ClassReader.EXPAND_FRAMES);

MethodVisitor mv;

mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "say2", "()V", null, null);

mv.visitCode();

Label l0 = new Label();

mv.visitLabel(l0);

mv.visitLineNumber(23, l0);

mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");

mv.visitLdcInsn("2");

mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

Label l1 = new Label();

mv.visitLabel(l1);

mv.visitLineNumber(24, l1);

mv.visitInsn(Opcodes.RETURN);

Label l2 = new Label();

mv.visitLabel(l2);

mv.visitLocalVariable("this", "Lcom/lucifaer/test_agentmain/TestAgentMain;", null, l0, l2, 0);

mv.visitMaxs(2, 1);

mv.visitEnd();

classfileBuffer = cw.toByteArray();

FileOutputStream fos = null;

try {

fos = new FileOutputStream("agent.class");

} catch (FileNotFoundException e) {

e.printStackTrace();

}

try {

assert fos != null;

fos.write(classfileBuffer);

} catch (IOException e) {

e.printStackTrace();

}

try {

fos.close();

} catch (IOException e) {

e.printStackTrace();

}

return classfileBuffer;

}

public void retransform() throws UnmodifiableClassException {

LinkedList<Class> retransformClasses = new LinkedList<Class>();

Class[] loadedClasses = inst.getAllLoadedClasses();

for (Class clazz : loadedClasses) {

if ("com.lucifaer.test_agentmain.TestAgentMain".equals(clazz.getName())) {

if (inst.isModifiableClass(clazz) && !clazz.getName().startsWith("java.lang.invoke.LambdaForm")) {

inst.retransformClasses(clazz);

}

}

}

}

}

结果如下:

这里尝试添加一个public方法是直接失败的,原因就在于原生的JVM在运行时时为了程序的线程及逻辑安全,禁止向运行时的类添加新的public方法并重新定义该类。JVM默认规则是只能修改方法体中的逻辑,所以这就意味着会有这么一个问题:当多次attach时,代码会重复插入,这样是不符合热部署逻辑的。

当然目前市面上也有一定的解决方案,如JRebelSpring-Loaded,它们的实现方式是在method callfield access的方法做了一层代理,而这一点对于RASP来说,无疑是加重了部署难度,反而与热部署简单快捷的方式背道而驰。

0x04 OpenRASP的具体实现方式

以上大致将Java RASP的相关内容介绍完毕后,这部分来深入了解一下OpenRASP的Java RASP这一部分是怎么写的,执行流是如何。

4.1 OpenRASP执行流

OpenRASP的执行流很简单主要分为以下几部分:

  1. agent初始化
  2. V8引擎初始化
  3. 日志配置模块初始化
  4. 插件模块初始化
  5. hook点管理模块初始化
  6. 字节码转换模块初始化

其中具体实现管理hook点以及添加hook点的部分主要集中于5、6这一部分,这里同样是我们最为关注的地方。

4.2 初始化流程

在这一部分不会对OpenRASP流程进行一步步的跟踪,只会将其中较为关键的点进行分析。

4.2.1 agent初始化

通过前面几节的介绍,其实是可以发现RASP类的编写共同点的——其入口就是premainagentmain方法,这些都会在META-INFO/MANIFEST.MF中标明:

所以其入口就是com.baidu.openrasp.Agent

这里在模块加载前做了一个非常重要的操作——将Java agent的jar包加入到BootStrap class path中,如果不进行特殊设定,则会默认将jar包加入到System class path中,对于研究过类加载机制的朋友们来说一定不陌生,这样做得好处就是可以将jar包加到BootStrapClassLoader所加载的路径中,在类加载时可以保证加载顺序位于最顶层,这样就可以不受到类加载顺序的限制,拦截拦截系统类。

当将jar包添加进BootStrap class path后,就是完成模块加载的初始化流程中,这里会根据指定的jar包来实例化模块加载的主流程:

这里的ENGINE_JAR是rasp-engine.jar,也就是源码中的engine模块。这里根据配置文件中的数值通过反射的方式实例化相应的主流程类:

然后就可以一目了然的看到模块初始化主流程了:

在主流程中,我们重点关注红框部分,这一部分完成了hook点管理模块初始化,以及字节码转换模块的初始化。

4.2.2 hook点管理模块初始化

hook点管理的初始化过程非常简单,就是遍历com.baidu.openrasp.plugin.checkerCheckParameter的Type,将其中的元素添加进枚举映射中:

在Type这个枚举类型中,定义了不同类型的攻击类型所对应的检测方式:

4.2.3 字节码转换模块初始化

字节码转换模块是整个Java RASP的重中之重,OpenRASP是使用的Javassist来操作字节码的,其大致的写法和ASM并无区别,接下来一步步跟进看一下。

com.baidu.openrasp.EngineBoot#initTransformer中完成了字节码转换模块的初始化:

这里可以看到在实例化了ClassFileTransformer实现的CustomClassTransformer后,调用了一个自己写的retransform方法,在这个方法中对Instrumentation已加载的所有类进行遍历,将其进行类的重新转换:

这里主要是为了支持agentmain模式对类进行重新转换。

在解释完了retranform后,我们来整体看一下OpenRASP是如何添加hook点并完成相应hook流程的。这一部分是在com.baidu.openrasp.transformer#CustomClassTransformer中:

我们都清楚inst.addTransformer的功能是在类加载时做拦截,对输入的类的字节码进行修改,也就是具体的检测流程插入都在这一部分。但是OpenRASP的hook点是在哪里加入的呢?其实就是在addAnnotationHook这里完成的:

这里会到com.baidu.openrasp.hook下对所有的类进行扫描,将所有由HookAnnotation注解的类全部加入到HashSet中,例如OgnlHook:

至此就完成了字节码转换模块的初始化。

4.3 类加载拦截流程

前文已经介绍过RASP的具体拦截流程是在ClassFileTransformer#transform中完成的,在OpenRASP中则是在CustomClassTransformer#transform中完成的:

可以看到先检测当前拦截类是否为已经注册的需要hook的类,如果是hook的类则直接利用javassist的方式创建ctClass,想要具体了解javassist的使用方式的同学,可以直接看javassist的官方文档,这里不再过多表述。

可以看到在创建完ctClass后,直接调用了当前hook的transformClass方法。由于接下来涉及到跟进具体的hook处理类中,所以接下来的分析是以跟进OgnlHook这个hook来跟进的。

OgnlHook是继承于AbstractClassHook的,在AbstractClassHook中预定义了很多虚方法,同时也提供了很多通用的方法,transformClass方法就是在这里定义的:

这里直接调用了每个具体hook类的hookMethod方法来执行具体的逻辑,值得注意的是这里的最终返回也是一个byte数组,具体的流程和ASM并无两样。跟进OgnlHook#hookMethod

这里首先生成需要插入到代码中的字节码,然后调用其自己写的inserAfter来将字节码插入到hook点的后面(其实就是决定是插在hook方法最顶部,还是return前的最后一行,这决定了调用顺序)。

可以简单的看一下插入的字节码是如何生成的:

很简单,就是插入一段代码,这段代码将反射实例化当前hook类,调用methodName所指定的方法,并将paramString所指定的参数传入该方法中。所以接下来看一下OgnlHook#checkOgnlExpression方法所执行的逻辑:

判断获取的表达式是不是String类型,如果是,将表达式放入HashMap中,然后调用HookHandler.doCheck方法:

在这里说一句题外话,可以看到在这里的逻辑设定是当服务器cpu使用率超过90%时,禁用全部的hook点。这也是RASP要思考解决的一个问题,当负载过高时,一定要给业务让步,也就一定要停止防护功能,不然会引发oom,直接把业务搞崩。所以如何尽量的减少资源占用也是RASP需要解决的一个大问题。

这里就是检测的主要逻辑,主要完成:

  • 检测计时
  • 获取检测结果
  • 根据检测结果判断是否要进行拦截

具体看一下如何获取的检测结果:

这里的checkers是在hook点管理模块初始化时设置的枚举类映射,所以这里调用的是:

V8Checker().check()方法,继承树如下:

所以具体的实现是在AbstractChecker#check中:

也就是V8Checker#checkParam

这里就一目了然了,是调用JS插件来完成检测的:

easygame,就是在JS插件(其实就是个js文件)中寻找相应的规则进行规则匹配。这个js文件在OpenRASP根目录/plugins/official/plugin.js中:

如果符合匹配规则则返回block,完成攻击拦截。

至此整个拦截流程分析完毕。

4.4 小结

从上面的分析中可以看出OpenRASP的实现方式还是比较简单的,其中非常有创新点的是利用js来编写规则,通过V8来执行js。利用js来编写规则的好处是更加方便热部署以及规则的通用性,同时减少了为不同语言重复制定相同规则的问题。

同样,OpenRASP也不免存在RASP本身存在的一些缺陷,这些缺陷将在“缺陷思考”这一节中具体的描述。

0x05 缺陷思考

虽然Java RASP是以Java Instrumentation的工作方式工作在JVM层,可以通过hook引发漏洞的关键函数,在关键函数前添加安全检查,这看上去像是一个“all in one”的通用解,但是其实存在很多问题。

5.1 “通用解”的通用问题

所有“通用解”的最大问题都出现在通用性上。在真实场景中RASP的应用环境比其在实验环境中复杂的多,如果想要一个RASP真正的运行在业务上就需要从乙方和甲方的角度双向思考问题,以下是我想到的一些问题,可能有些偏颇,但是还是希望能给一些参考性的意见:

5.1.1 语言环境的通配适用性

企业内部的web应用纷繁复杂,有用Java编写的应用,有用Go编写的,还有用PHP、Python写的等等...,那么如何对这些不同语言所构建的应用程序都实现相应的防护?

对于甲方来说,我购置一套安全防护产品肯定是要能起到通用防护的作用的,肯定不会只针对Java购进一套Java RASP,这样做未免也太亏了。

对于乙方来说,每一种语言都有不同的特性,都要用不同的方式构建RASP,对于开发和安全研究人员来说工作量是相当之大的,强如OpenRASP团队目前也只是支持PHP和Java两个版本的。

这很大程度上也是影响到RASP推广的一个原因。看看传统的WAF、旁路流量监测等产品,它并不受语言的限制,只关心流量中是否存在具有威胁的流量就好,巧妙的减少了一个变量,从而加强了泛用性,无论什么样的环境都可以快速部署发挥作用,对于企业来说,肯定是更愿意购入WAF的。

5.1.2 部署的通配适用性

由于开发人员所擅长的技能不同或不同项目组的技能树设定的不同,企业内部往往会存在使用各种各样框架实现的代码。而在代码部署上,如果没有一开始就制定严格的规范的话,部署环境也会存在各种各样的情况。就拿Java来说,企业内部可能存在Struts2写的、Spring写的、RichFaces写的等等...,同时这些应用可能部署在不同的中间件上:Tomcat、Weblogic、JBoss、Websphere等等...,不同的框架,不同的中间件部署方式都或多或少的有所不同,想要实现通配,真的不容易。

5.1.3 规则的通用性

这一点其实已经被OpenRASP较好的解决了,统一利用js做规则,然后利用js引擎解析规则。所以这一点不多赘述。

5.2 自身稳定性的问题

“安全产品首先要保证自己是安全的”,这句话说出来感觉是比较搞笑的,但是往往很多的安全产品其自身安全性就很差,只是仗着黑盒的不确定性才保持自己的神秘感罢了。对于RASP来说这句话更是需要严格奉行。因为RASP是将检测逻辑插入到hook点中的,只要到达了相应的hook点,检测逻辑是一定会被执行的,如果这个时候RASP实现的检测逻辑本身出现了问题,严重的话会导致整个业务崩溃,或直接被打穿。

5.2.1 执行逻辑稳定性

就像上文所说的一样,如果在RASP所执行的逻辑中出现了严重的错误,将会直接将错误抛出在业务逻辑中,轻则当前业务中断,重则整个服务中断,这对于甲方来说就是严重的事故,甚至比服务器被攻击还严重。

简单来举个例子(当然在真实写RASP的时候不会这么写,这里只是展示严重性),如果在RASP的检测逻辑中存在exit()这样的利用,将直接导致程序退出:

这也就是为什么很多甲方并不喜欢RASP这种方式,因为归根到底,RASP还是将代码插入到业务执行流中,不出问题还好,出了问题就会影响业务。相比来说,WAF最多就是误封,但是并不会down掉业务,稳定性上是有一定保障的。

5.2.2 自身安全稳定性

试想一个场景,如果RASP本身存在一定的漏洞,那是不是相当的可怕?即使原来的应用是没有明显的安全威胁的,但是在RASP处理过程中存在漏洞,而恰巧攻击者传入一个利用这样漏洞的payload,将直接在RASP处理流中完成触发。

举个实际的例子,比如在RASP中使用了受漏洞影响的FastJson库来处理相应的json数据,那么当攻击者在发送FastJson反序列化攻击payload的时候就会造成目标系统被RCE。

这其实并不是一个危言耸听的例子,OpenRASP在某版本使用的就是FastJson来处理json字符串,而当时的FastJson版本就是存在漏洞的版本。所以在最新的OpenRASP中,统一使用了较为安全的Gson来处理json字符串。

RASP的处理思路就决定了其与业务是联系非常紧密的,可以说就是业务的“一部分”,所以如果RASP自己的代码不规范不安全,最终将导致直接给业务写了一个漏洞。

5.2.3 规则的稳定性

RASP的规则是需要经过专业的安全研究人员反复打磨并且根据业务来定制化的,需要尽量将所有的可能性都考虑进去,同时尽量的减少误报。但是由于规则贡献者水平的参差不齐,很容易导致规则遗漏,从而根本无法拦截相关的攻击,或产生大量的攻击误报。这样对于甲方来说无疑是一笔稳赔的买卖——花费大量时间进行部署,花费大量服务器资源来启用RASP,最终的安全效果却还是不尽如人意。

如果想要尽量的完善规则,只能更加贴近业务场景,针对不同的情况做不同的规则判别。所以说规则和业务场景是分不开的,对乙方来说不深入开发、不深入客户是很难做好安全产品的,如果只是停留在实验阶段,是永远没有办法向工程化和产品化转换的。

5.3 部署复杂性的问题

在0x03以及0x04中不难看理想中最佳的Java RASP实践方式是使用agentmain模式进行无侵入部署,但是受限于JVM进程保护机制没有办法对目标类添加新的方法,所以就会造成多次attach造成的重复字节码插入的问题。目前主流的Java RASP推荐的部署方式都是利用premain模式进行部署,这就造成了必须停止相关业务,加入相应的启动参数,再开启服务这么一个复杂的过程。

对于甲方来说,重启一次业务完成部署RASP的代价是比较高的,所以都是不愿意采取这样的方案的。而且在甲方企业内部存在那么多的服务,一台台部署显然也是不现实的。目前所提出的自动化部署方案也受限于实际业务场景的复杂性,并不稳定。

0x06 总结

就目前来说RASP解决方案已经相对成熟,除非JDK出现新的特性,否则很难出现重大的革新。

目前各家RASP厂商主要都是针对性能及其他的辅助功能进行开发和优化,比如OpenRASP提出了用RASP构建SIEM以及实现被动扫描器的思路,这其实是一个非常好的思路,RASP配合被动扫描器能很方便的对企业内部的资产进行扫描,从而实现一定程度上的漏洞管控。

但是RASP不是万能的,并不能高效的防御所有的漏洞,其优劣势是非常明显的,应当正确的理解RASP本身的司职联合其他的防御措施构建完整的防御体系才能更好的做好安全防护。

个人认为RASP的最佳实践场所是甲方内部,甲方可以通过资产梳理对不同的系统进行相应的流量管控,这样RASP就能大大减少泛性检测所带来的的误报,同时更进一步的增加应用的安全性。

总体来说RASP是未来Web应用安全防护的方向,也同时是一个Web安全的发展趋势,其相较于传统安全防护产品的优势是不言而喻的,只要解决泛用性、稳定性、部署难等问题,可以说是目前能想出的一种较为理想方案了。

0x07 Reference

以上是 浅谈 RASP 的全部内容, 来源链接: utcz.com/p/199436.html

回到顶部