MENU

RASP的基本原理

2024 年 06 月 04 日 • 访问: 303 次 • 安全

参考资料
java agent 动态字节码修改,无代码侵入(jar 启动时,启动后)
字节码增强技术探索
编译、类加载、解释

JAVA字节码

Java字节码是什么?估计在大学的时候学编译原理这门课的时候会有印象,实际在工作中的时候常用的编程语言是Java,但对字节码这一块的一些基本概念基本是没有涉及的(因为平时都是纯纯牛马),在工作的开发过程中主要关注的是业务逻辑,而非底层的Java虚拟机的运行逻辑,所以对这一块的技术长时间没有接触也就遗忘的差不多了。

但是如果需要接触RASP这项技术,那么能够阅读Java字节码就是一座不可避开的高山。

为什么RASP需要理解Java字节码呢?这里简单的提一嘴,后面我们会详细在展开分析,RASP全称叫做Runtime Application Self-Protection(程序运行时的自我保护,听起来是不是很牛的样子,也就是说我们要在Java运行的时候去保护程序的关键逻辑,而Java运行时是通过加载Java字节码来运行的,所以我们需要了解它。

Java和C的类比

如果将Java与C进行类比的话,Java字节码就等同于C的机器码。C/C++属于静态编译语言,其源代码经过编译器编译、连接后形成二进制的可执行文件,也就是我们常说的机器码,才可以提供给计算机的CPU(处理器)来执行;而Java属于动态编译语言,在运行的过程中,一边运行一边将源代码编译成Java字节码(.class)给Java虚拟机来执行。

CJava
静态编译,一次编译,多次执行动态编译,边编译边执行
源文件.c源文件.java
编译后为二进制可执行文件(机器码),windows下是后缀为.exe的可执行文件编译后为Java字节码,.class
机器码在CPU上执行,处理器是实际存在的硬件,存在多个核心,是以多个寄存器为基础的架构Java字节码在JVM(Java Virtual Machine),Java虚拟机上运行,它是通过软件实现的,不存在实际的硬件,是基于堆栈的架构
比较少用,C反编译成源码的难度高常用反编译成Java源码的软件有Procyon、CFR、JD-GUI
常用反汇编工具主要就是IDA Pro,可以将二进制反汇编为汇编代码,支持调试Javap(JDK提供的反汇编器)、ASM Textify(ASM反汇编器)、ASMifier(提供的是ASM框架操作的字节码的Java代码)

推荐工具

  • Bytecode Viewer 是一款比较好用的工具,主要是因为它内置了多款主流的Java反编译器反汇编器,能够自由切换,并且支持多窗口对比

  • ASM Bytecode Viewer,ASM是Java中编辑字节码的框架,Intellij IDE中有一个插件 ASM Bytecode Viewer,支持将Java代码转换成Java字节码,可以方便的学习Java字节码语法

什么是RASP?

既然我们知道,Java代码并不是实际执行的东西,而是通过转换为Java字节码的形式在JVM中跑的,那么我们是不是可以通过某种手段,无需修改源代码的情况下,在编译成Java字节码的时候去做一些“代理”操作,通过侦测一些特征的代码,删除、拦截一些恶意代码,或者修改字节码增添一些新的逻辑,比如说:

  • 记录反馈:侦测到恶意代码或者漏洞时,记录日志,方便情报搜集与后续问题定位排查
  • 修改入参:比如检测到恶意命令,对入参进行过滤
  • 修改方法体(暂时没有实现)
  • 修改返回值:比如返回值存在敏感信息时,也可以进行关键词的过滤
  • 退出线程:遇到严重问题与漏洞,或者侦测到高危行为时,终结线程,及时止损

RASP技术与WAF的对比

我们常常听到的,RASP技术喜欢与WAF进行对比,实际上WAF更具有通用性,许多云服务提供商都有成熟的WAF方案提供。而RASP往往需要针对具体的业务进行开发和定制,业界对于RASP技术更多的是停留在实验室、或者是仅仅是小规模商用的阶段,对于大规模的商用其实还有很长的路要走。最主要的还是RASP的强业务侵入性的特性,这既是它的优点,也是它的缺点,因为客户最关心的还是业务稳定性,这比安全性重要的多。

WAF

  • 不存在业务侵入性;
  • 对Web服务本身的性能无影响
  • 误报率高、更新特征库的频率高;
  • 匹配的规则越多,对性能影响越大

RASP

  • 防护快成本低、误报率低、规范应用编码安全
  • 开发难度高(比如Java RASP技术涉及到修改Java字节码)
  • 存在性能损耗
  • 漏洞依然存在
  • 强业务侵入性(可能导致服务启动失败,导致正常业务行为受影响)

如何将RASP的影响降到最小?

  • 多阶段部署上线:先关闭插件特性,最小集上线,逐步上线
  • 功能性SIT测试验证插件可靠性
  • 性能测试:测试驱动开发

RASP的实现原理

通过这些逻辑,我们可以让程序做到运行时的自我保护,让程序变得更安全。那这是否有技术可以做到呢?

答案是可以的。那就是使用javaagent技术。

Javaagent技术

JDK 从 JDK1.5 版本开始引入了java.lang.instrument包。它可以通过addTransformer方法设置一个 ClassFileTransformer,可以在这个ClassFileTransformer实现类的转换(网上搜的资料,确实厉害)。在 JVM 启动的时候添加一个javaagemt的代理,在执行Java程序的代码前,会先执行代理类中的premain方法,而在这个方法中就可以对class文件进行修改。

那么除了这种方式,还有其他实现方式吗?

而从 JDK 1.6 开始支持更加强大的动态 Instrument,在JVM 启动后通过 Attach API 远程加载。这种方式叫做agentmain方式,支持热部署,相较于prmain的方式需要agent和Java程序一起启动、一起销毁的方式,这种方式显然更加灵活自由,但同时也存在着稳定性没有premain好的问题。

那么两种修改方式,是在Java程序的编译、加载、运行哪一个阶段进行的呢?对此我们需要深入了解Java程序的底层JVM的加载逻辑。

我们知道从Java源码文件到最终运行Java程序需要经过编译、加载和执行三个阶段,编译是将java源码经过编译器(javac)编译成字节码文件即.class文件,接着JVM将二进制的字节码进行加载,读入到内存中,按照一定的数据结构进行存放,最后运行程序的逻辑。

我们可以看到要想实现Java字节码增强技术,无非就是在编译、加载和运行这三个阶段进行织入。premain方式是在JVM进行类加载的阶段,通过拦截class文件,去对class文件进行修改,而agentmain方式可以在类已经加载完后,在运行阶段,通过attach API触发类的重定义(redefineClasses)机制,实现class文件的重新加载。

Javaagent和SpringAOP有什么区别和联系呢?

两者都是属于字节码增强技术的范畴,都可以实现对字节码的修改。但javaagent可以不需要在原程序包中增加或者修改代码,而是单独做成一个程序jar,实现对目标程序jar的修改,可以在JVM启动前,或者运行时对类进行拦截,通过修改class字节码实现类中的实现。

字节码的修改框架

那么字节码是如何修改的呢?常用的两个修改字节码的框架,一个是ASM,一个是JavaAssit

  • ASM框架:ASM是一个Java 字节码操纵框架,它能被用来动态生成类或者增强既有类的功能。由于它直接操纵字节码,所以他的运行效率高,但对开发人员要求高,需要对字节码的指令集比较了解。
  • JavaAssit:基于反射的高级别字节码操作框架,它通过封装了Java字节码文件的API,提供了一组高级别的API来操作字节码。易于使用,开发者不需要了解虚拟机指令,但由于经过一层封装,所以对性能会有损耗。百度开源的的 OpenRASP 项目使用的就是JavaAssit。

程序示例

我们通过伪代码为例,解释一下RASP是如何工作的,假设我们现在有这样一个简单的判断一个整数是奇数还是偶数的方法:

// 原函数:
String getOddOrEven(int i) {
    if (i % 2 == 0) {
        return "EVEN";
    } else {
        return "ODD";
    }
}

反汇编为java bytecode(java字节码)格式:

static getOddOrEven(I)Ljava/lang/String;
    L_ORIG_START
01   ILOAD 0 // 将序号为0的本地变量推到栈顶 => i
02   ICONST_2 // 将 int 2 常量 推送至栈顶
03   IREM // 栈顶两数取模并将结果压入栈顶,计算i % 2,
04   IFNE L_not_important_in_orig_2 // 当栈顶int型数值不等于0时跳转到L_not_important_in_orig_2
05  L_not_important_in_orig_1
06   LDC "EVEN" // 将EVEN从常量池中推送至栈顶
07   ARETURN
08  L_not_important_in_orig_2
09  FRAME SAME // 啥也不做
10   LDC "ODD" // 将ODD从常量池中推送至栈顶
11   ARETURN // 从当前方法返回对象引用
    L_ORIG_END
     LOCALVARIABLE i I L_ORIG_START L_ORIG_END 0 // 本地变量 i int类型 L_ORIG_START~L_ORIG_END 序号为0
     MAXSTACK = 2 // 最大操作数栈:表示该方法运行时所需的最大操作数栈的深度,即在方法执行过程中所需要使用的最大的栈空间
     MAXLOCALS = 1 // 最大局部变量表大小:表示该方法运行时所需的最大局部变量表的大小,即该方法所使用的局部变量的数量

我们通过RASP agent的处理后,重新生成了字节码,这里代码太长了,感兴趣的同学可以自行展开来看一下

经过agent处理后的Java字节码

static getOddOrEven(I)Ljava/lang/String;
    // 异常处理器
    // L_TCB_S1 ~ L_TCB_E1 这一段就是 enter ~ return ODD 这一段
    TRYCATCHBLOCK L_TCB_S1 L_TCB_E1 L_HANDLE_REPLACE ReplaceResultException // catch ReplaceResultException
    TRYCATCHBLOCK L_TCB_S2 L_TCB_E2 L_HANDLE_REPLACE ReplaceResultException // catch ReplaceResultException
    TRYCATCHBLOCK L_TCB_S1 L_TCB_E1 L_HANDLE_THROWABLE java/lang/Throwable // catch Throwable
    TRYCATCHBLOCK L_TCB_S2 L_TCB_E2 L_HANDLE_THROWABLE java/lang/Throwable // catch Throwable
    TRYCATCHBLOCK L_TCB_S1 L_TCB_E1 L_HANDLE_NULL null // catch NullPointException
    TRYCATCHBLOCK L_TCB_S2 L_TCB_E2 L_HANDLE_NULL null // catch NullPointException
    // context.completeWithError(e); catch  ReplaceResultException e2
    TRYCATCHBLOCK L_HANDLE_THROWABLE_PROC L_HANDLE_THROWABLE_PROC_DONE L_HANDLE_INNER_REPLACE ReplaceResultException
    // (String) e.getResultReplacement(); catch NullPointException 
    TRYCATCHBLOCK L_HANDLE_REPLACE L_HANDLE_REPLACE_FINISH L_HANDLE_NULL null
    // context.completeWithError(e); catch NullPointException 
    TRYCATCHBLOCK L_HANDLE_THROWABLE L_HANDLE_INNER_REPLACE_FINISH L_HANDLE_NULL null
    // throw e; catch NullPointException
    TRYCATCHBLOCK L_HANDLE_THROWABLE_RETHROW L_HANDLE_NULL_PROC L_HANDLE_NULL null
    L_MODIFIED_START
        GETSTATIC NullMethodContext.NULL; // NullMethodContext.NULL -> 栈顶
        ASTORE 1 // NullMethodContext.NULL -> context
    L_MAIN_TRY_START / L_TCB_S1
        // < ... bytecodes to load vars for method XxxAopEntry.enter ... >
        INVOKESTATIC XxxAopEntry.enter; // 调用 enter方法
        ASTORE 1 // enter方法返回值 -> context
        ALOAD 1 // enter方法返回值 -> 栈顶
        INVOKEINTERFACE MethodContext.getArgumentsReplacement()[Ljava/lang/Object; // context.getArgumentsReplacement -> 栈顶
        DUP // 复制 argumentsReplacement 
        IFNULL L_ORIG_START // argumentsReplacement == null 跳转 L_ORIG_START
        DUP // 复制 argumentsReplacement 
        ICONST_0 // 0 -> 栈顶
        AALOAD // argumentsReplacement[0] -> 栈顶
        CHECKCAST java/lang/Integer // 检查int
        INVOKEVIRTUAL java/lang/Integer.intValue ()I // (int) 强制类型转换
        ISTORE 0 // 结果存在i中
    //================================================================= 原始代码Start
    L_ORIG_START
01      ILOAD 0 // i -> 栈顶
02      ICONST_2 // 2 -> 栈顶
03      IREM //i % 2 -> 栈顶
04      IFNE L_TCB_S2 // i % 2 != 0 -> L_TCB_S2
05  L_not_important_in_orig_1
06      LDC "EVEN" // EVEN -> 栈顶
        // 没有修改字节码前,这里直接 ARETURN EVEN
    //================================================================= 原始代码起始end
        ASTORE 2 // EVEN -> ReplaceResultException e
    L_modify_added_we_donot_care_x
        ALOAD 1 // context -> 栈顶
        ALOAD 2 // ReplaceResultException e (EVEN) -> 栈顶
        INVOKEINTERFACE MethodContext.completeWithResult (Ljava/lang/Object;)V (itf) // context.completeWithResult("EVEN"); -> 栈顶
    L_TCB_E1
        // finally代码块
        ALOAD 1 // context -> 栈顶
        INVOKEINTERFACE MethodContext.finish ()V (itf) // context.finish();
    L_modify_added_we_donot_care_x
        ALOAD 2 // ReplaceResultException e -> 栈顶
07      ARETURN // return
    //================================================================= 原始代码Start
08  L_TCB_S2 //  原始代码的 else 分支 L_not_important_in_orig_2
09      FRAME FULL [I MethodContext] []
10      LDC "ODD" // ODD推到栈顶
        // 没有修改字节码前,这里直接 ARETURN ODD
    //================================================================= 原始代码end
        ASTORE 2 // ODD ->  e ReplaceResultException
    L_modify_added_we_donot_care_x
        ALOAD 1 // context -> 栈顶
        ALOAD 2 // e ReplaceResultException (ODD) -> 栈顶
        INVOKEINTERFACE MethodContext.completeWithResult (Ljava/lang/Object;)V (itf) // context.completeWithResult("ODD");
    L_TCB_E2
        ALOAD 1 // context -> 栈顶
        INVOKEINTERFACE MethodContext.finish ()V (itf) // context.finish();
    L_modify_added_we_donot_care_x
        ALOAD 2 // ReplaceResultException e (ODD) -> 栈顶
11      ARETURN // return -> 栈顶引用
 
    // 遇到ReplaceResultException异常跳入
    L_HANDLE_REPLACE / L_ORIG_END // ##### ##### ##### ##### ##### ReplaceResultException
        FRAME FULL [I MethodContext] [ReplaceResultException]
        ASTORE 2 // 栈顶 -> ReplaceResultException e
    L_HANDLE_REPLACE_PROC
        ALOAD 2 // ReplaceResultException e -> 栈顶
        INVOKEVIRTUAL ReplaceResultException.getResultReplacement ()Ljava/lang/Object; // (String) e.getResultReplacement(); -> 栈顶
        CHECKCAST java/lang/String // 校验String类型
        ASTORE 3 // 替换后的值 -> e2 ReplaceResultException
    L_HANDLE_REPLACE_FINISH
        ALOAD 1 // context -> 栈顶
        INVOKEINTERFACE MethodContext.finish ()V (itf) // context.finish()
    L_modify_added_we_donot_care_x
        ALOAD 3 // e2 ReplaceResultException (替换后的值) -> 栈顶
        ARETURN // return -> 栈顶引用
    // 遇到 Throwable 异常跳入
    L_HANDLE_THROWABLE // ##### ##### ##### ##### ##### Throwable
        FRAME FULL [I MethodContext] [java/lang/Throwable]
        ASTORE 2 // 栈顶 -> ReplaceResultException e
    L_HANDLE_THROWABLE_PROC 
        ALOAD 1 // context -> 栈顶
        ALOAD 2 // Throwable e -> 栈顶
        INVOKEINTERFACE MethodContext.completeWithError (Ljava/lang/Throwable;)V (itf) // context.completeWithError(e); -> 栈顶
    L_HANDLE_THROWABLE_PROC_DONE
        GOTO L_HANDLE_THROWABLE_RETHROW
    // 遇到内部的 ReplaceResultException e2 跳入
    L_HANDLE_INNER_REPLACE // ==== ===== ===== ===== ===== inner ReplaceResultException
        FRAME FULL [I MethodContext java/lang/Throwable] [ReplaceResultException]
        ASTORE 3 // 栈顶 -> ReplaceResultException e2
    L_HANDLE_INNER_REPLACE_PROC
        ALOAD 3 // ReplaceResultException e2 -> 栈顶
        INVOKEVIRTUAL ReplaceResultException.getResultReplacement ()Ljava/lang/Object; // (String) e2.getResultReplacement(); -> 栈顶
        CHECKCAST java/lang/String // 检查String类型
        ASTORE 4 // 修改后的String -> 4 号变量
    L_HANDLE_INNER_REPLACE_FINISH
        ALOAD 1 // context -> 栈顶
        INVOKEINTERFACE MethodContext.finish ()V (itf) // context.finish();
    L_modify_added_we_donot_care_x
        ALOAD 4 // 4号变量 -> 栈顶
        ARETURN // return 栈顶引用
    // throw e 的部分
    L_HANDLE_THROWABLE_RETHROW
        FRAME FULL [I MethodContext java/lang/Throwable] []
        ALOAD 2 // Throwable e -> 栈顶
        ATHROW // throw e;
    // 处理空指针异常 
    L_HANDLE_NULL // ##### ##### ##### ##### ##### null
        FRAME FULL [I MethodContext] [java/lang/Throwable]
        ASTORE 5 // null -> 5号变量
    L_HANDLE_NULL_PROC
        ALOAD 1 // context -> 栈顶
        INVOKEINTERFACE MethodContext.finish ()V (itf) // context.finish();
    L_modify_added_we_donot_care_x
        ALOAD 5 // 5号变量 (null) -> 栈顶
        ATHROW // 抛出NullPointException
    L_MODIFIED_END
    // 局部变量表
    LOCALVARIABLE i I L_MODIFIED_START L_MODIFIED_END 0
    LOCALVARIABLE context MethodContext; L_TCB_S1 L_MODIFIED_END 1
    LOCALVARIABLE e ReplaceResultException; L_HANDLE_REPLACE_PROC L_HANDLE_THROWABLE 2
    LOCALVARIABLE e2 ReplaceResultException; L_HANDLE_INNER_REPLACE_PROC L_HANDLE_THROWABLE_RETHROW 3
    LOCALVARIABLE e java/lang/Throwable; L_HANDLE_THROWABLE_PROC L_HANDLE_NULL 2
    MAXSTACK = <?> // 最大操作数栈
    MAXLOCALS = <?> // 最大局部变量表大小

其对应的Java伪代码可抽象为:

String getOddOrEven(int i) {
  try {
    enterRaspContext(i); // 在进入函数的第一时刻做处理,并可接受this、i等参数
    if (i % 2 == 0) {
      leaveRaspContext("EVEN"); // 在return前做处理
      return "EVEN";
    } else {
      leaveRaspContext("ODD"); // 这是另一个return点,也要对应处理
      return "ODD";
    }
  } catch (e) {
    leaveRaspContextWithException(e); // 异常也要处理
  } finally {
    ...; // 必要的释放工作
  }
}

实际的Java代码为:

static String getOddOrEven(int i) {
  MethodContext context = NullMethodContext.NULL;
  try {
    context = XxxAopEntry.enter(".../ClassX", "getOddOrEven", "(I)Ljava/lang/String;", ClassX.class, new Object[] {new Integer(i)}, true, false);
    final Object[] argumentsReplacement = context.getArgumentsReplacement();
    if (argumentsReplacement != null) {
      i = (int)argumentsReplacement[0];
    }
    if (i % 2 == 0) {
      context.completeWithResult("EVEN");
      return "EVEN";
    } else {
      context.completeWithResult("ODD");
      return "ODD";
    }
  } catch (ReplaceResultException e) {
    return (String) e.getResultReplacement();
  } catch (Throwable e) {
    try {
      context.completeWithError(e);
    } catch (ReplaceResultException e2) {
      return (String) e2.getResultReplacement();
    }
    throw e;
  } finally {
    context.finish();
  }
}

我们通过在一些代码的关键点上插桩,比如在代码起始的地方、在return值前,插入我们的一些执行逻辑,从而达到在运行是保护的目的。

附录:阅读Java字节码

有三个特殊需要记一下,long类型的标识符是J,boolean的标识符是Z,对象类型是L,其余都是开头字母大写

标识字符含义
B基本类型byte
C基本类型char
D基本类型double
F基本类型float
I基本类型int
J基本类型long
S基本类型short
Z基本类型boolean
V特殊类型void
L对象类型,L开头,以分号结尾,如最常见的String类型:Ljava/lang/String;
  • 对于数组类型,每一位使用一个前置的"["字符来描述,如定义一个java.lang.String[]类型的数组,将被记录为[Ljava/lang/String;
  • 而对于二维数组,比如byte [][],则被记为[[B
  • 多维数组以此类推

方法functionA

public static boolean functionA(String id, String ip, int time, String userName)

其对应的Java字节码为

public static functionA(Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;)Z

()小括号包裹的是入参列表,Z是出参,代表boolean类型

这里我们可以看到,所有的变量都以一种非常紧凑的形式写在一起,可以理解为相对于Java,字节码的注记符更简洁、紧凑,但可读性也会随之降低

最后编辑于: 2024 年 10 月 22 日
返回文章列表 打赏
本页链接的二维码
打赏二维码