字符串/模式匹配算法是当前最流行和最简单的检测shellcode方法。原理很简单:所有代码都有其独特的特征,可以根据这种特征去在内存中验证。即使没有事先分析shellcode,我们也可以得到一些有用的变量,或者至少看起来它是一串可疑代码,需要进一步分析。
我提到的有用变量是指API字符串以及hash值,它们已经在检测恶意代码历史上使用了20多年。如果想要绕过这种检测方法,编写shelcode过程中我们要考虑使用像C或C++这样的HLL语言,进行非常规的编程,保证shellcode中的内容是可以变换的,不固定的。
这一过程中不止可执行代码可以变换,任何内容都可以变换:代码,数据,常量,字符串等等。今天我要展示的是使用名为Maru的hash函数生成可排列的API散列。Maru使用分组密码生成字符串的hash值,当然你大可不必使用分组密码做此事,但是这样可以防止使用SMT进行hash碰撞攻击。
想要获取更多有关信息,你可以阅读:使用SAT和SMT消除简单的散列算法
签名检测
在MS-DOS病毒在30年前刚出现时,基于签名的检测是很容易的。为了隐藏恶意代码,攻击者使用八比特的变量对代码进行异或运算,举个例子,在1991年出现的Skism Rythem Stack Virus-808病毒,使用了感染时间时的毫秒数字进行异或运算,所以对于100个版本来说,每一个都是不同的。
这是该代码的入口,以及其解密过程:
jmp virus_start encrypt_val db 00h virus_start: call encrypt ; encrypt/decrypt file jmp virus ; go to start of code encrypt: push cx mov bx, offset virus_code ; start encryption at data xor_loop: mov ch, [bx] ; read current byte xor ch, encrypt_val ; get encryption key mov [bx], ch ; switch bytes inc bx ; move bx up a byte cmp bx, offset virus_code + virus_size ; are we done with the encryption jle xor_loop ; no? keep going pop cx ret
执行完这一串代码之后,病毒会使用当前时间值的毫秒数对'encrypt_val'进行更新:
; mov ah, 2ch ; get time int 21h mov encrypt_val, dl ; save m_seconds to encrypt val so ; theres 100 mutations possible
攻击者为做到隐蔽自己做了一些努力,但是解密器是可以被察觉到的,并且病毒很容易被杀掉。此外,杀毒软件可以模拟解密程序,通过验证签名,在病毒执行前阻止他。
在1997年Win32病毒出现时,已经不存在MS-DOS中断,相反,可以通过动态链接库dll对API函数进行访问,这使得检测恶意软件变得更加容易,当存在大量调用API函数的代码块时,系统就会发出警报。
攻击者最终使用了32位校验码,其中最受欢迎的就是CRC32。不久之后,LSD-PL首先使用了基于ADD-Rotate—Xor方法,在他们的WASM包中,代码如下:
i3: mov esi,[4*eax+ebx] ; ptr to symbol name add esi,edx ; rva2va push ebx ; hash: h=((h<<5)|(h>>27))+c push eax xor ebx,ebx i4: xor eax,eax ; hash loop lodsb rol ebx,5 add ebx,eax cmp eax,0 jnz i4 ror ebx,5 cmp ebx,ecx ; hash compare pop eax pop ebx je i5 ; same: symbol found inc eax jmp i3 ; different: go to the next symbol
大部分合法代码是不需要调用api的,也不会手动导入地址表或者导出地址表的。如果碰到这种行为代码,那么通常它会对你的计算机造成危害。
API散列是很有趣的,它不会被字符串签名验证给杀掉,所以如果杀毒软件只是对是否包含调用API的代码进行检测,那么这种检测方法是无效的。尽管现在很多攻击软件(BeEF,meterpreter,Veil)都会使用无熵或者随机化的散列算法。但是他们是不会被改变的,所以检测起来还是很容易的。
当然,这些工具都提供了像RC4、AES这样的加密算法,不过这样即使通过了杀毒软件和IDS进行的简单扫描,但是通过仿真器去解密主要代码,观察它的行为,对它防御是完全没有问题的。
字符串问题
在理想情况下,攻击者希望在编译过程中对所有代码进行混淆或者加密,并且在解密过程中不会使用冗余的代码和工具。
constexpr是使用c++代码的理想选择,不过对于C来说,则没有这样的功能,除非编译器在编译过程中对所有字符串进行修改和加密。
一些汇编程序比如MASM,NASM是支持基于宏的编译,但是工作过程中是基于很复杂的函数的(并不是你不能)。Z0MBiE已经在2002年在”Meta病毒中的编码“以及”在HLL中解决字符串问题“中对这一问题进行了讨论,但是如果进一步查看这些解决方法,这里只是使用很少的熵值而已。
使用熵对API字符串进行加密的目的很显然是增加检测有效载荷的难度。不过这不是一个长久之计,迟早会被发现,但是这种方法肯定比现有的没有熵的hash算法要好很多。
散列函数构造
我们可以从分组密码中构造新的hash算法,于是对于提供的不同key值,就会生成不同的API散列。但是,很少的分组密码是适用于shellcode生成的。
这里有三个可以使用的分组密码机制(其实有很多的, 只是目前发现了三个)。
1.Davies–Meyer
将每个块中的消息(mi)作为块密文的key,然后将先前的hash值(Hi-1)作为要被加密的明文。然后将输出的密文和之前的hash值(Hi-1)进行异或,生成下一个hash值(Hi)。在加密的第一轮中,之前并没有hash值产生,会使用我们预先设定的一个常量(H0)进行加密操作。
2.Matyas–Meyer–Oseas
将每块中的消息(mi)作为要被加密的明文。输出的密文同样和相同的明文块进行异或,产生下一组hash值(Hi)。前一组hash值会作为这一组加密的密钥。在第一轮中,并没有之前产生的hash值,所以使用之前设定的初始化变量H0.
3. Miyaguchi–Preneel
每一块消息都会作为要加密的明文,输出之后的密文和同样的明文进行异或运算,然后再和前一组产生的hash进行异或运算,产生这一组hash值(Hi)。前一组产生的hash值作为了加密的密钥。
在第一轮中,并没有之前产生的hash值,所以使用之前设定的初始化变量H0。这些是我认为使用的3种简单设计。对于Maru哈希,我使用Davies-Meyer与一些非标准填充。
选择分组密码
我们知道在加密过程中需要一些静态值,与加密常数相比,有什么更好的静态值吗?
你可以对这些静态值进行改变,不过可能会影响加密函数的安全性,所以最好使用一个不包含任何静态变量的密码。在查看可能使用的分组密码时,我选择的前三个是基于ADD-Rotate-Xor(ARX)设计实现的。
1. Speck
由NSA设计,于2013年进行发布(https://eprint.iacr.org/2013/404)。对于不同的架构,产生不同的密钥以及分组大小。成为大多数架构可用的最小的基于软件的块密码。
32位机器的参数为64位的分组大小以及128位的密钥长度。64位模式下的参数为128位的分组大小,以及256为的密钥长度。这两种加密代码长度分别为64字节以及86字节。
如下是x86代码:
2. Chaskey-LTS
在2015年发布(https://eprint.iacr.org/2015/1182),基于Even-Mansour加密方式,使用SipHash中的置换函数进行产生。参数为128位的密钥,和128位的分组大小。在x86机器上实现大小为90字节:
3.XTEA
拓展微加密算法。一种Feistel密码,在1997年发现微加密算法弱点之后发表(http://www.cix.co.uk/~klockstone/xtea.pdf).参数为128比特的密钥,分组大小为64比特。他有一个已知的常量,0x9E3779B9,这使他容易被基于签名检测出来,大小约80个字节:
Speck是轻量级应用程序更好的设计,不能通过签名轻松识别。 (至少不是从任何常数)所以我选择它生成hash。
Maru Hash
那么在关于需要消除shellcode中常量的讨论之后,我在这里介绍hash函数的一部分。在转换为整数之前,64位常数是137素数的乘法逆元。它是加密过程中的初始化参数H0。
进一步来说:
1. 0.00729927007299270072992700729927是137的乘法逆元。 2. 舍弃小数部分中的前两个0,将剩下的转换为64位整数。 3. 结果为:0x654C37754C5E9939
H是通过用Maru常数和64位密钥进行异或初始化的。然后将API字符串作为密钥重复使用Speck进行加密,直到达到终止符为止。
下方代码是C原型:
uint64_t maru(const void *str, const void *key);
str参数指向我们的API字符串,长度不应超过128字节(即使函数接受任意长度的字符串)。key参数应该指向我们唯一的64位值,这应该是理想的随机选择。
uint64_t maru(const void *str, const void *key) { uint8_t *s=(uint8_t*)str; w64_t *k=(w64_t*)key; w64_t h; w128_t m; uint32_t idx, end=0, i, t, k0, k1, k2, k3, x0, x1; // set the initial hash which is 64-bit key xor 1/137 h.q = k->q ^ MARU_INIT_H; // now update the hash using string as key for (idx=0; !end; ) { // if bytes remaining if (*s != 0) { // add byte to buffer m.b[idx++] = *s++; } else { // this is last block // pad with end bits while (idx < MARU_BLK_LEN) { m.b[idx] = (uint8_t)idx; idx++; } end++; } // if m filled if (idx == MARU_BLK_LEN) { // copy 128-bit key to local registers k0 = m.w[0]; k1 = m.w[1]; k2 = m.w[2]; k3 = m.w[3]; // copy H to local space x0 = h.w[0]; x1 = h.w[1]; for (i=0; i<27; i++) { // encrypt block x0 = (ROTR32(x0, 8) + x1) ^ k0; x1 = ROTL32(x1, 3) ^ x0; // create next subkey k1 = (ROTR32(k1, 8) + k0) ^ i; k0 = ROTL32(k0, 3) ^ k1; XCHG(k3, k2); XCHG(k3, k1); } // update previous hash h.w[0] ^= x0; h.w[1] ^= x1; // reset index idx = 0; } } // return 64-bit hash return h.q; }
hash举例
以下是对API以及潜在密钥的测试案例,他们不必是字符串。
从示例散列可以看出,一旦密钥被更改,生成的API哈希是完全不同的。通过选择不同的密钥,这使得我们的哈希值也会不同。
总结
设计自己的hash函数或者加密算法通常会很好的绕过杀毒软件。但是像Maru这样的hash算法并不适用于对防碰撞要求很高的场合。Maru hash真的是一个关于如何对shellcode中的字符串进行随机变换,进而绕过杀毒软件的字符串检测的方法。