本文要介绍的是几种用来精简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压入栈然后节省一个字节。
在代码最后也有同样的操作。
我们可以用下面的方法节省一个字节。
好了,我不是要告诉你优化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之后可能会溢出。
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课