-
-
[翻译]Shellcode:x86优化 part 1
-
2017-6-12 10:41 4450
-
引言
本文要介绍的是几种用来精简shellcode的基本方法。在后面的文章中,我将介绍一些方法来混淆它们以躲过签名检测算法。
本文中的一些例子也同样适用于 boot loader, PE protector/compress,代码演示或者其他一些需要精简代码的地方。
文章中有些技巧是从其他地方借鉴来的,我在文末的致谢部分会给出部分人的名字。
我确实有想过要不要介绍x86架构,但是在其他地方已经有很多相关的信息了,所以我就假设你已经对它比较熟悉了。
本文讲包括一下四个部分:
声明和初始化变量/寄存器
测试变量/寄存器的值
条件跳转/控制流
字符转换
初始化寄存器
每个CPU寄存器就像是一个变量。
延伸模式( legacy mode )的x86 CPU有8个通用寄存器(General Purpose Register,GPR),每个寄存器可以存储32位或者4字节的信息。当然,我们不会用栈指针寄存器(ESP)来做除了栈管理之外的事,所以我们只有7个通用寄存器可用。其中有4个寄存器可以写8和16位的字。
一个很普遍的操作是设置一个变量(在这里是寄存器)为0。下面就是常见的用来完成这个操作的几种方法。有些会比其他要好一些,这都取决于具体情况。
// 8-bits "\x30\xc0" /* xor al, al */ "\x28\xc0" /* sub al, al */ "\xb0\x00" /* mov al, 0x0 */ "\x24\x00" /* and al, 0x0 */ // 16-bits "\x66\x31\xc0" /* xor ax, ax */ "\x66\x29\xc0" /* sub ax, ax */ "\x66\xb8\x00\x00" /* mov ax, 0x0 */ "\x66\x83\xe0\x00" /* and ax, 0x0 */ "\x66\x6a\x00" /* push 0x0 */ "\x66\x58" /* pop ax */ // 32-bits "\xb8\x00\x00\x00\x00" /* mov eax, 0 */ "\x31\xc0" /* xor eax, eax */ "\x29\xc0" /* sub eax, eax */ "\x6a\x00" /* push 0 */ "\x58" /* pop eax */ "\x83\xe0\x00" /* and eax, 0 */ "\x6b\xc0\x00" /* imul eax, eax, 0 */ "\xf8" /* clc */ "\x19\xc0" /* sbb eax, eax */ "\x6a\xff" /* push -1 */ "\x58" /* pop eax */ "\x40" /* inc eax */ "\x31\xd2" /* xor edx, edx */ "\x92" /* xchg eax, edx */ (when we know eax is < 0x80000000) "\x99" /* cdq */ "\x92" /* xchg eax, edx */ "\xb8\xff\xff\xff\xff" /* mov eax, -1 */ "\x40" /* inc eax */ "\x83\xc8\xff" /* or eax, -1 */ "\x40" /* inc eax */ "\x6a\xff" /* push -1 */ "\x58" /* pop eax */ "\x40" /* inc eax */ (64-bit mode) "\x48\x31\xc0" /* xor rax, rax */
这里没有列出所有可以用来初始化某一特定寄存器的方法,但是用同样的方法,MOV, XOR, SUB, AND 这些操作也能将其他变量设置为0。
关于最后一条指令XOR RAX, RAX我要说的是,你不一定要在RAX上执行这个操作,你也可以通过执行 XOR EAX, EAX 来节省一个字节,因为结果是扩展到64位的0。
将一个寄存器的值移到另一个寄存器、
// moving register or immediate value into register "\x83\xcb\xff" /* or ebx, 0xffffffff */ "\x21\xc3" /* and ebx, eax */ "\x31\xdb" /* xor ebx, ebx */ "\x09\xc3" /* or ebx, eax */ "\x31\xdb" /* xor ebx, ebx */ "\x01\xc3" /* add ebx, eax */ "\x31\xdb" /* xor ebx, ebx */ "\x31\xc3" /* xor ebx, eax */ "\x50" /* push eax */ "\x5b" /* pop ebx */
初始化为立即数也是一个比较常见的操作,但是在shellcode中初始化立即数会比较麻烦。
比如说你需要把1放到EAX/RAX中,这在linux下表示退出系统调用。
// "\x48\xc7\xc0\x01\x00\x00\x00" /* mov rax, 0x1 */ "\x48\x31\xc0" /* xor rax, rax */ "\x48\xff\xc0" /* inc rax */ "\x31\xc0" /* xor eax, eax */ "\xfe\xc0" /* inc al */ "\x6a\x01" /* push 0x1 */ "\x58" /* pop rax */ "\x83\xc8\xff" /* or eax, 0xffffffff */ "\xf7\xd8" /* neg eax */
用PUSH/POP这个组合不只是一个原因。首先它比其他的更简洁,其次,它兼容32位和64位,其他的可能就不兼容了。
一般来说,如果立即数是-128到+127之间的话,就用PUSH/POP组合。
对上面的值,操作码比较长。
假设你打算在一个 egg hunter shellcode中读内存。这通常会涉及到把寄存器设置为4096,4096代表一张页表的边界。
比较常见的是下面的方法:
// 32-bit "\x31\xd2" /* xor edx, edx */ "\x66\x81\xca\xff\x0f" /* or dx, 0xfff */ "\x42" /* inc edx */ // 64-bit "\x31\xd2" /* xor edx, edx */ "\x66\x81\xca\xff\x0f" /* or dx, 0xfff */ "\x48\xff\xc2" /* inc rdx */
这里还有一些其他的方法,其中最后两个是最简洁的。
// "\x66\x68\x00\x10" /* push 0x1000 */ "\x66\x5a" /* pop dx */ "\x0f\xb7\xd2" /* movzx edx, dx */ "\x68\x00\x10\x00\x00" /* push 0x1000 */ "\x5a" /* pop edx */ "\xba\x00\x10\x00\x00" /* mov edx, 0x1000 */ "\x31\xd2" /* xor edx, edx */ "\xb6\x10" /* mov dh, 0x10 */ "\x6a\x10" /* push 0x10 */ "\x5a" /* pop edx */ "\x86\xf2" /* xchg dl, dh */
当说到要将1或-1压入栈的时候,我常看到会使寄存器加1或减1的代码。
从 block_shell.asm 提取中这部分代码。
将1压入栈
"\x46" /* inc esi */ "\x56" /* push esi */ "\x4e" /* dec esi */
超完美的有没有的?但是你也可以直接把1压入栈然后节省一个字节。
"\x6a\x01" /* push 0x1 */
在代码最后也有同样的操作。
"\x4e" /* dec esi */ "\x56" /* push esi */ "\x46" /* inc esi */
我们可以用下面的方法节省一个字节。
"\x6a\xff" /* push 0xffffffff */
好了,我不是要告诉你优化metasploit 的所有方法……只是想用现实中的例子来说明一下。在现实中像这样的立即数,如果你只需要1,你应该把它压入栈。
在64位的版本中,用下面的方法:
为了压入立即数,将生成的字节数和2比较。
"\x49\xff\xc0" /* inc r8 */ "\x41\x50" /* push r8 */ "\x49\xff\xc8" /* dec r8 */
分配/初始化内存
编译器会用ADD, SUB或者在过去用ENTER(Pascal/Ada) 来分配栈内存。
比较简单的方法是用来分配PUSH/PUSHFD 大于等于4 byte的内存。不过PUSHAD 可以只用一个字节就能分配到32-byte的内存。
在使用PUSHAD 的时候,如果后面你不想用POPAD 来回收垃圾,你也可以用ADD, SUB 或者 LEA 来做这件事。下面是一些分配32byte空间的例子。
// "\xc8\x20\x00\x00" /* enter 0x20, 0x0 */ "\xc9" /* leave */ "\x55" /* push ebp */ "\x89\xe5" /* mov ebp, esp */ "\x83\xec\x20" /* sub esp, 0x20 */ "\xc9" /* leave */ "\x83\xec\x20" /* sub esp, 0x20 */ "\x83\xc4\x20" /* add esp, 0x20 */ "\x60" /* pushad */ "\x61" /* popad */
下面是分配8byte并初始化为0.
// FPU "\x83\xec\x08" /* sub esp, 0x08 */ "\x89\xe7" /* mov edi, esp */ "\xd9\xee" /* fldz */ "\xdf\x3f" /* fistp qword [edi] */ // MOV "\x83\xec\x08" /* sub esp, 0x08 */ "\x89\xe7" /* mov edi, esp */ "\x31\xc0" /* xor eax, eax */ "\x89\x07" /* mov [edi], eax */ "\x89\x47\x04" /* mov [edi+0x4], eax */ // STOSD "\x83\xec\x08" /* sub esp, 0x08 */ "\x89\xe7" /* mov edi, esp */ "\x31\xc0" /* xor eax, eax */ "\x57" /* push edi */ "\xab" /* stosd */ "\xab" /* stosd */ "\x5f" /* pop edi */ // PUSH "\x31\xc0" /* xor eax, eax */ "\x50" /* push eax */ "\x50" /* push eax */ "\x89\xe7" /* mov edi, esp */ // For 64-bit mode, we only need 1 push "\x31\xc0" /* xor eax, eax */ "\x50" /* push rax */ "\x54" /* push rsp */ "\x5f" /* pop rdi */
下面是我用来分配4096byte缓冲区的方法。
// allocate 4096 bytes on stack and initialize to zero "\x31\xc0" /* xor eax, eax */ "\x31\xc9" /* xor ecx, ecx */ "\xb5\x10" /* mov ch, 0x10 */ "\x29\xcc" /* sub esp, ecx */ "\x89\xe7" /* mov edi, esp */ "\xf3\xaa" /* rep stosb */
由于不同平台的栈限制(stack limit),上面的代码在Windows下可能会引发异常(在基于UNIX的系统下不确定)。
在Windows下默认的栈大小(stack size)最大值是1MB,在Linux下它至少是4MB。Windows预先分配给栈页的是64KB,Linux的是128KB。
当你想要分配超过最大值的栈内存的时候,你要确保该页是可用的。编译器如 MSVC 和MINGW 会自动完成这个操作,你无需担心,但是在组装程序的时候你要自己执行栈查探(stack probe)。
比如说,下面的代码要在一个4096-byte的内存块上申请近20KB的栈空间。
; allocate 20KB using stack probe "\x31\xc9" /* xor ecx, ecx */ "\xf7\xe1" /* mul ecx */ "\xb1\x05" /* mov cl, 0x5 */ "\xb6\x10" /* mov dh, 0x10 */ "\x29\xd4" /* sub esp, edx */ "\x85\x24\x24" /* test [esp], esp */ "\xe2\xf9" /* loop 0x8 */
“test [esp], esp ”这条指令会引发kernel 层异常强制扩大栈内存。如果那部分内存不可用,程序就会抛出异常。
测试寄存器
很多函数都喜欢用返回1表示成功(TRUE),0表示失败(FALSE)。有些也会用-1或者小于0表示失败。
检查这些值最好的方法就是对这些寄存器做一些能够反映状态标志的操作。
在这里你比较常用的几个是零标志(ZF)、符号标志(SF)、奇偶标志(PF)、进位标志(CF)。
当然你也可以用溢出标志(OF),但是在本部分的例子中我不会用到它。
辅助标志(AF)也可以用,但是很遗憾,没有哪个跳转操作码可以与它关联。
如果要测试这些标志位的值你要用PUSHFD/POP 组合将其压入栈,或者用一字节指令 LAHF 。
测试0或FALSE.
// "\x83\xf8\x00" /* cmp eax, 0x0 */ "\x74\x12" /* jz 0x18 */ "\x85\xc0" /* test eax, eax */ "\x74\x0e" /* jz 0x18 */ "\x09\xc0" /* or eax, eax */ "\x74\x0a" /* jz 0x18 */ "\x21\xc0" /* and eax, eax */ "\x74\x06" /* jz 0x18 */ "\x48" /* dec eax */ "\x78\x03" /* js 0x18 */ "\x91" /* xchg ecx, eax */ "\xe3\x00" /* jecxz 0x18 */
测试1或TRUE.
// "\x3c\x01" /* cmp al, 0x1 */ "\x75\x15" /* jnz 0x24 */ "\x66\x83\xf8\x01" /* cmp ax, 0x1 */ "\x75\x1e" /* jnz 0x2a */ "\x83\xf8\x01" /* cmp eax, 0x1 */ "\x75\x19" /* jnz 0x24 */ "\x0f\xba\xe0\x00" /* bt eax, 0x0 */ "\x73\x0f" /* jae 0x24 */ "\x85\xc0" /* test eax, eax */ "\x7a\x0b" /* jnp 0x24 */ "\x09\xc0" /* or eax, eax */ "\x7a\x07" /* jnp 0x24 */ "\x21\xc0" /* and eax, eax */ "\x7a\x03" /* jnp 0x24 */ "\x48" /* dec eax */ "\x75\x00" /* jnz 0x24 */ "\xf7\xd8" /* neg eax */ "\x78\x75" /* js 0x7a */
测试 -1
我看到的用来直接测试-1的代码最常用的就是第一个例子中的那样,但是像第二个例子那样用符号标志(SF)的会更高效和简洁。
如果是在延伸模式(legacy mode)下运算,我们可以通过inc该寄存器然后测试零标志(ZF)来节省一个字节。
因为加1后ZF=1,PF=1,SF=0,你就可以选择用JP或者JNS而不是像第三个例子那样用JZ。
如果像最后一个例子用dec ,那么 SF=1,ZF=0,PF=0,我们就可以用JS,JNP,JNZ或JL.
// "\x83\xf8\xff" /* cmp eax, 0xffffffff */ "\x74\x2e" /* jz 0x33 */ "\x85\xc0" /* test eax, eax */ "\x78\x2e" /* js 0x32 */ "\x40" /* inc eax */ "\x74\x16" /* jz 0x1d */ "\x48" /* dec eax */ "\x78\x19" /* js 0x22
关于后面那两个例子,有一个问题是,在64位模式下没有一字节的INC/DEC指令,因为这些是为REX prefix预留的。
在这种情况下,最后用TEST或者inc一个8位寄存器(如果可以的话)比如EAX/RAX的AL。你也可以只检查AL或-1,这样更简单些。
JLE可以用在调用BSD socket的类似recv或send函数之后,因为如果出错它会返回0或-1.
// jump if <= 0 "\x85\xc0" /* test eax, eax */ "\x7e\x19" /* jle 0x23 */
测试 0x80, 0x8000, 0x80000000
执行加倍/乘以2之后可能会溢出。
执行TEST指令后各标志位为:PF=1, SF=1, ZF=0 。
假如要测试的值是0x80000000
// jump if 0x80000000 "\x85\xc0" /* test eax, eax */ "\x78\x19" /* js 0x23 */
我们也可以用INC EAX来设置 SF=1, PF=0, OF=0 ,然后我们就可以用 JS, JNP, JNO 或 JL 了。
// jump if 0x80000000 "\x40" /* inc eax */ "\x78\x19" /* js 0x22 */
那么,如果用DEC EAX 来代替会怎样呢?这将使 SF=0, PF=1, OF=1 ,那我们可以用 JNS, JP, JO, 或JG 。
// jump if 0x80000000 "\x48" /* dec eax */ "\x79\x19" /* jns 0x22 */
加 0x80000000 后结果是0,将使ZF=1, OF=1, CF=1. 用JZ, JO 或者JC/JB。如果是减法,将使 ZF=1, OF=0, CF=0. 用 JZ, JNO 或 JNC/JNB.
// jump if 0x80000000 "\x01\xc0" /* add eax, eax */ "\x74\x19" /* jz 0x23 */
除了用add,sub,还可以左移1位。
// jump if 0x80000000 "\xd1\xe0" /* shl eax, 1 */ "\x74\x19" /* jz 0x23 */
用edx
// jump if 0x80000000 "\x99" /* cdq */ "\x42" /* inc edx */ "\x74\x0f" /* jz 0x13 */
用算术右移(Shift Arithmetic Right ,SAR)
; CF=1, ZF=1, SF=0 for < 0x80000000 ; CF=0, ZF=0, SF=1 for >= 0x80000000 "\xc1\xf8\x1f" /* sar eax, 0x1f */ "\x78\x5b" /* js 0x60 */
条件跳转/控制流
这部分会涉及到高级语言中FOR,WHLIE和DO/WHILE语句。
NOP指令只是填充材料,用它来取代一些有用的地方。
通常如果ECX空闲的话我会用它来配合LOOP指令只用,这都得看情况。
循环2 次
// Parity Flag "\x31\xc0" /* xor eax, eax */ "\x90" /* nop */ "\x90" /* nop */ "\x48" /* dec eax */ "\x7a\xfb" /* jp 0x3 */ // Sign Flag "\x31\xc0" /* xor eax, eax */ "\x90" /* nop */ "\x90" /* nop */ "\x04\x40" /* add al, 0x40 */ "\x79\xfa" /* jns 0x3 */ // Zero Flag "\x31\xc0" /* xor eax, eax */ "\x90" /* nop */ "\x90" /* nop */ "\x04\x80" /* add al, 0x80 */ "\x75\xfa" /* jnz 0x3 */
另一种循环两次的方法是如果你所有的寄存器都用完了,你可以使用进位标志。我们首先清空它(如果还没有的话),将该标志保存到栈中,执行我们的代码,重新保存标志符,填充,然后再循环。
// using the carry flag "\xf8" /* clc */ "\x9c" /* pushfd */ "\x90" /* nop */ "\x90" /* nop */ "\x9d" /* popfd */ "\xf5" /* cmc */ "\x72\xf9" /* jb 0x2 */
循环3次
对于PF,将一个寄存器设置为0,并inc 直到PF=1.
对于SF,将一个寄存器设置为0,并增加43到63的一个数,直到SF=1。
对于ZF,将一个寄存器设置为0,并增加85知道ZF=1.
// Parity Flag "\x31\xc0" /* xor eax, eax */ "\x90" /* nop */ "\x90" /* nop */ "\x40" /* inc eax */ "\x7b\xfb" /* jnp 0x3 */ // Sign Flag "\x31\xc0" /* xor eax, eax */ "\x90" /* nop */ "\x90" /* nop */ "\x04\x30" /* add al, 0x30 */ "\x79\xfa" /* jns 0x3 */ // Zero Flag "\x6a\x01" /* push 0x1 */ "\x58" /* pop eax */ "\x90" /* nop */ "\x90" /* nop */ "\x04\x55" /* add al, 0x55 */ "\x75\xfa" /* jnz 0x4 */
你可能会说,我们也可以简单的讲EAX设置为3,然后dec直到0,这也是正确的。
// Zero Flag "\x6a\x03" /* push 0x3 */ "\x58" /* pop eax */ "\x90" /* nop */ "\x90" /* nop */ "\x48" /* dec eax */ "\x75\xfb" /* jnz 0x4 */
如果可以用ECX,我们可以用LOOP
// ECX "\x6a\x03" /* push 0x3 */ "\x59" /* pop ecx */ "\x90" /* nop */ "\x90" /* nop */ "\xe2\xfc" /* loop 0x4 */
当ECX空闲,就用LOOP。
如果我们不能用ECX,将AL设置为-1然后用sub,相比于第2个例子,这样能够为我们节省一个字节。如果你喜欢用加法,你也可以将AL设置为1。
// Zero Flag "\x0c\xff" /* or al, 0xff */ "\x90" /* nop */ "\x90" /* nop */ "\x2c\x55" /* sub al, 0x55 */ "\x75\xfa" /* jnz 0x3 */
循环4 次
这个会比较简单,因为对于ZF,256能被4整除,对于SF,256能被128整除。
// Sign "\x31\xc0" /* xor eax, eax */ "\x90" /* nop */ "\x90" /* nop */ "\x04\x20" /* add al, 0x20 */ "\x79\xfa" /* jns 0x3 */ // Zero "\x31\xc0" /* xor eax, eax */ "\x90" /* nop */ "\x90" /* nop */ "\x04\x40" /* add al, 0x40 */ "\x75\xfa" /* jnz 0x3 */
你可以继续这样做,但是从上面的例子中你应该学会自己写。
关于实现涉及条件跳转的控制流的最后一部分是使用相对偏移量。比如说,现在情况是这样,有一个其他函数的返回值,你想伪造一个函数来混淆代码,让分析代码变得更困难。这是很基础的。
虽然这里只测试了TRUE或FALSE,但如果要测试-1或小于0的有符号值也很简单啊。
字符转换
在很多情况下你会想要把一串小写字母转换为大写以及相反操作,或者实现unicode到ansi的转换。这里只是展示如何转换拉丁字母表。
大/小写转换
转换大小写不过是开关切换键的事。观察下面的字母以及它们的二进制值。
a = 01100001 A = 01000001
b = 01100010 B = 01000010
c = 01100011 C = 01000011
对于小写,第五位是1(我是从零开始算的)。
如果你想要一串字符串全是小写或全是大写,用XOR。
// flip the case "\x34\x20" /* xor al, 0x20 */
如果你想将字符串都变成小写但又不想每一位都比较,用OR。
// convert to lowercase "\x0c\x20" /* or al, 0x20 */
至于要转换成大写,将它和0xDF进行 and,这样第五位就会变为0. 这里有个问题是,如果是数字或者该字母没有对应的小写字母的话,这个方法将失效。
// convert to uppercase "\x24\xdf" /* and al, 0xdf */
当然你也可以用BTS把它设置为小写,但是这需要占用更多字节。
// set to lowercase "\x0f\xba\xe8\x05" /* bts eax, 0x5 */
或者如果你只想改变那个字母,用BTR。
// flip case "\x0f\xba\xf0\x05" /* btr eax, 0x5 */
如果你的那串字符串里有数字或者其他字符会发生什么呢?有一次我在shellcode里用了转化小写而不是转换为大写,因为大写需要条件跳转而当时我并不想跳转。
拿metasploit的代码 block_api.asm 来举个例子。
将字符串转换为小写,你可以用下面的代码。记住,这里是从InLoadOrderLinks 读取DLL的相关信息,跟Metasploit 读的不一样。
; movzx ecx, word[edi+44] ; len = BaseDllName.Length mov esi, [edi+48] ; str = BaseDllName.Buffer shr ecx, 1 ; len /= 2 xor eax, eax ; c = 0 cdq ; h = 0 hash_dll: ; do { lodsw ; c = *str++ or al, 0x20 ; c = tolower(c) ror edx, 13 ; h = ROTR32(h, 13) add edx, eax ; h += c loop hash_dll ; while (--len)
你肯定不能直接把这段代码插入到原来的metasploit代码中去,因为生成的hash值完全不一样。这只是给你介绍一直方法。
如果你用OR来设置数字0~9的第五位,它们不会有任何改变,因为它们的第五位本来就是1 啊。
同样的,对于用来做分开模块名和后缀的“.”也一样不会改变。比如,用OR可以将KERNEL32.DLL 变成 kernel32.dll 而不需要任何条件跳转。
但是如果是用SUB指令来讲其转换为大写,你需要条件跳转。
Ansi 和 Unicode
好吧,这并不完全是unicode转换因为我们只用到拉丁字母表。Unicode字符串以两个null字节作为结束标志,所以一旦遇到null应该停止。
; esi = unicode in ; edi = ansi out uni2ans: movsb ; convert it to asciiz format dec edi cmpsb jnz uni2ans
致谢
有很多人通过分享他们的知识和想法帮我间接完成了这篇博文。文章中的代码借鉴了这些人的想法:
drizz, r!sc, d0ris, jb, Z0MBiE, WiteG, Vecna, Mental Driller, GriYo, JPanic, Qkumba/Peter Ferrie, Jacky Qwerty, Super, hh86, benny 还有一些我忘了的。
原文链接:https://modexp.wordpress.com/2017/06/07/x86-trix-one/?winzoom=1
本文由 看雪翻译小组 lumou 编译
[培训]《安卓高级研修班(网课)》月薪三万计划,掌 握调试、分析还原ollvm、vmp的方法,定制art虚拟机自动化脱壳的方法