原文首发:看雪论坛
引言
本文要介绍的是几种用来精简 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。下面就是常见的用来完成这个操作的几种方法。有些会比其他要好一些,这都取决于具体情况。
这里没有列出所有可以用来初始化某一特定寄存器的方法,但是用同样的方法,MOV, XOR, SUB, AND 这些操作也能将其他变量设置为0。
关于最后一条指令 XOR RAX, RAX 我要说的是,你不一定要在 RAX 上执行这个操作,你也可以通过执行 XOR EAX, EAX 来节省一个字节,因为结果是扩展到 64 位的0。
将一个寄存器的值移到另一个寄存器。
初始化为立即数也是一个比较常见的操作,但是在 shellcode 中初始化立即数会比较麻烦。
比如说你需要把 1 放到 EAX/RAX 中,这在 linux 下表示退出系统调用。
用 PUSH/POP 这个组合不只是一个原因。首先它比其他的更简洁,其次,它兼容 32 位和 64 位,其他的可能就不兼容了。
一般来说,如果立即数是 -128 到 +127 之间的话,就用 PUSH/POP 组合。
对上面的值,操作码比较长。
假设你打算在一个 egg hunter shellcode 中读内存。这通常会涉及到把寄存器设置为4096,4096 代表一张页表的边界。
比较常见的是下面的方法:
这里还有一些其他的方法,其中最后两个是最简洁的。
当说到要将 1 或 -1 压入栈的时候,我常看到会使寄存器加1或减1的代码。
从 block_shell.asm 提取中这部分代码。
将 1 压入栈
超完美的有没有的?但是你也可以直接把 1 压入栈然后节省一个字节。
"\x6a\x01" /* push 0x1 */
在代码最后也有同样的操作。
我们可以用下面的方法节省一个字节。
"\x6a\xff" /* push 0xffffffff */
好了,我不是要告诉你优化 metasploit 的所有方法……只是想用现实中的例子来说明一下。在现实中像这样的立即数,如果你只需要1,你应该把它压入栈。
在 64 位的版本中,用下面的方法:
为了压入立即数,将生成的字节数和2比较。
分配/初始化内存
编译器会用 ADD, SUB 或者在过去用 ENTER(Pascal/Ada) 来分配栈内存。
比较简单的方法是用来分配 PUSH/PUSHFD 大于等于4 byte 的内存。不过PUSHAD 可以只用一个字节就能分配到 32-byte 的内存。
在使用 PUSHAD 的时候,如果后面你不想用 POPAD 来回收垃圾,你也可以用 ADD, SUB 或者 LEA 来做这件事。下面是一些分配 32byte 空间的例子。
下面是分配 8byte 并初始化为 0.
下面是我用来分配 4096byte 缓冲区的方法。
由于不同平台的栈限制(stack limit),上面的代码在 Windows 下可能会引发异常(在基于UNIX的系统下不确定)。
在 Windows 下默认的栈大小(stack size)最大值是1MB,在 Linux 下它至少是4MB。Windows 预先分配给栈页的是64KB、Linux 的是128KB。
当你想要分配超过最大值的栈内存的时候,你要确保该页是可用的。编译器如 MSVC 和 MINGW 会自动完成这个操作,你无需担心,但是在组装程序的时候你要自己执行栈查探(stack probe)。
比如说,下面的代码要在一个 4096-byte 的内存块上申请近 20KB 的栈空间。
“test [esp], esp ”这条指令会引发 kernel 层异常强制扩大栈内存。如果那部分内存不可用,程序就会抛出异常。
测试寄存器
很多函数都喜欢用返回 1 表示成功(TRUE),0 表示失败(FALSE)。有些也会用-1 或者小于 0 表示失败。
检查这些值最好的方法就是对这些寄存器做一些能够反映状态标志的操作。
在这里你比较常用的几个是零标志(ZF)、符号标志(SF)、奇偶标志(PF)、进位标志(CF)。
当然你也可以用溢出标志(OF),但是在本部分的例子中我不会用到它。
辅助标志(AF)也可以用,但是很遗憾,没有哪个跳转操作码可以与它关联。
如果要测试这些标志位的值你要用 PUSHFD/POP 组合将其压入栈,或者用一字节指令 LAHF 。
测试 0 或 FALSE.
测试 1 或 TRUE.
测试 -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.
关于后面那两个例子,有一个问题是,在64位模式下没有一字节的 INC/DEC 指令,因为这些是为 REX prefix 预留的。
在这种情况下,最后用 TEST 或者 inc 一个 8 位寄存器(如果可以的话)比如EAX/RAX 的 AL。你也可以只检查 AL 或 -1,这样更简单些。
JLE 可以用在调用 BSD socket 的类似 recv 或 send 函数之后,因为如果出错它会返回0或-1.
测试 0x80, 0x8000, 0x80000000
执行加倍/乘以 2 之后可能会溢出。
执行 TEST 指令后各标志位为:PF=1, SF=1, ZF=0 。
假如要测试的值是 0x80000000
我们也可以用 INC EAX 来设置 SF=1, PF=0, OF=0 ,然后我们就可以用 JS, JNP, JNO 或 JL 了。
那么,如果用 DEC EAX 来代替会怎样呢?这将使 SF=0, PF=1, OF=1 ,那我们可以用 JNS, JP, JO, 或JG 。
加 0x80000000 后结果是0,将使ZF=1, OF=1, CF=1. 用JZ, JO 或者JC/JB。如果是减法,将使 ZF=1, OF=0, CF=0. 用 JZ, JNO 或 JNC/JNB.
除了用 add,sub,还可以左移 1 位。
用 edx
用算术右移(Shift Arithmetic Right ,SAR)
本文由 看雪翻译小组 lumou 编译,来源 modexp
如果你喜欢的话,不要忘记点个赞哦!
明日续更!