攻击者们为了绕过安全软件的检测并且加大分析师们的工作量,常常用各种手法保护隐藏病毒的核心代码。这其中就包含了一种利用shellcode的技术。在过去这种技术常用于漏洞利用中,但目前越来越多的样本也开始使用shellcode隐藏核心代码,同时也加大了分析师们的分析难度。了解shellcode的原理是一名病毒分析师必备的基础技能。这样在以后的工作中即使遇到这类样本也不会无从下手。
shellcode常指一段二进制数据,这段二进制数据独立存在,不像常规PE数据那样加载运行。它没有导入表导出表之类的结构,这使得shellcode极为灵活,同时也加大了它的编写难度。一段健壮的shellcode应具备很好的兼容性,在同一次攻击活动中应保证在不同的机器中都能无错执行。因此这使得一段shellcode代码永远离不开几个核心技术:
寻址纯偏移化 识别执行位置 手动搜寻API模拟导入表 空指令雪橇
一个PE文件拥有着健壮清晰的PE结构,这样的结构保证了程序的正常执行,其中如函数地址 全局变量
等数据以绝对地址
的形式存储在PE结构中。PE结构中的ImageBase
字段指定了程序将要加载的起始地址。但并不是所有PE文件都可以如愿以偿的加载到ImageBase
处,因此,PE结构中提供了一种叫做重定位表
的数据结构,通过重定位表可以让程序在任意基质都能实现绝对地址的正确访问。
但shellcode仅是一段二进制数据,在被利用时无法指定自身代码的起始执行位置,这就导致了类似全局变量的绝对地址寻址在shellcode中变得不可靠。而全局变量这种形式的数据又避免不了要存在于shellcode代码中。
因此开发者们大开脑洞,将一段shellcode模拟为一个PE文件,将宿主进程模拟为操作系统。这样对于shellcode来讲,全局变量其实就变成了对于自身代码起始位置固定偏移处的一个内存地址
,由于shellcode代码是编写者自己指定的,因此这种通过模拟ImageBase
的方式在shellcode中成为了核心技术。
既然确定了寻址模式,那么就需要考虑如何获得shellcode的起始地址?这又引出了另一种技术,识别执行位置
。
当shellcode被加载并执行时,EIP所指向的应是整段shellcode的起始处,这样就保证了一点,shellcode的模拟ImageBase已经存在于EIP寄存器中了,下一步就变成了如何从EIP中取值
。
Intel并未提供任何对于指令指针寄存器EIP的直接访问指令。但有一些指令却可以通过组合来获得EIP中的值。这其中有两种思路:call pop
和fnstencv
当call指令执行时,call后进阶的指令的起始地址会被压入堆栈以供retn使用。通过call pop组合可以取出下一个指令的地址,减去call指令的长度就可以得到call指令的起始地址,通过一个例子可以直观体现:
fstenv指令用于将保存了FPU寄存器信息的结构体
保存到指定内存地址中。具体含义不用多考虑,我们只需要关注这个结构体:
该结构体偏移0xC处的fpu_instruction_pointer保存着被FPU使用的最后一条指令(修改了FPU堆栈的指令
)的地址。
配合fldz fld1
等浮点运算指令一起使用可以为这个结构体赋值。如下面这个例子:
通过以上两种方式我们已经可以获取到了EIP,并将其存入EAX寄存器中,下面通过一个小例子感受下这种技术在shellcode中的神奇应用:
可以看到我们以EDX作为基址指针完成了一个简单的加法运算,期间还调用了一个call指令。call指令内部正确使用了全局变量。
我们将shellcode二进制数据放到IDA和OD上看看效果:
可以看到所有寻址都是正确的,后面我们会用一个小工具来调试我们这段shellcode。
这是一个最简单也是最直观的例子来演示shellcode是如何寻址的。
讲完了寻址我们还要继续思考一个问题:一个程序可能不用任何API函数而纯靠自身PE数据去完成功能执行吗?
在用户层,所有涉及与硬件、系统进行交互的操作,都必须依赖Windows提供的API函数。
如果一个用户层程序不包含任何API,那么它能做的也只能是疯狂的对自身进程内存进行读写,而无法走出自身进程去与外界交互。
所以在shellcode中使用API是必要的,但是没有了导入表,如何才能获取到自己想要的API地址呢。在正常开发中,我们使用LoadLibrary与GetProcAddress来动态加载Dll库获取想要的API函数。
但在shellcode中,即使这两个函数的地址我们也不知道。这就引出了Windows的强大设计:PEB
。通过PEB结构我们可以通过内存搜索找到kernel32.dll的基地址,从而搜索LoadLibrary与GetProcAddress的地址。
在开始搜索前,我们需要简单了解下PEB。PEB是windows为开发者提供的一个包含了当前进程大量信息的结构体数据
。通过PEB中的成员向下查找可以找到一个保存着进程内所有模块信息的结构体链表
,通过遍历链表可以找到kernel32的基址。
如果想了解这种技术的原理可以看我上一篇文章:PEB结构:获取模块kernel32基址技术及原理分析
我们直接来看下简单的搜索kernel32.dll的shellcode代码:
得到了kernel32基址后,通过解析PE结构找到导出名称表和导出地址表和导出序号表,然后通过对比函数名来找到函数地址。
只要写过PE解析器的小伙伴做这个解析那不要太简单,这里就不赘述具体方法了。毕竟本章不是教你写shellcode,而是教你识别、分析shellcode
如果shellcode中引用了过多的导出函数,那么如果依次进行字符串比较,会占用大量的空间预先存储想要导入的函数名字符串。某些场景当shellcode数据大小被限制时,这种方法将无法满足需求。这时需要使用散列算法来进行比较。算法可以非常简单,只需要保证当前shellcode所使用的函数名的散列值不同
即可。
例如:32位旋转向右累加散列算法。然后通过将预先计算好的散列值与实际计算出来的散列值比较来判断函数名。
由于使用散列算法节省了很多空间,并且还达到了隐藏字符串的目的,所以在恶意样本中很常见,因此必须要学会识别散列算法。只有知道了某段shellcode是在参与函数名比较才能继续向下分析。
当shellcode用于漏洞攻击中时,它必须使自身看起来像是一个合法的数据。从而通过函数内部的检测来实现漏洞的利用。
如:strcat和strcpy,其对于参数中的字符串并未进行长度限制,因此可以通过缓冲区溢出来执行shellcode。但由于其将参数试做字符串处理,因此当遇到00
字节时,会解析为字符串结尾。因此在shellcode中无法出现00数据。
为了克服这一问题,编写者们通常采用解码器的方式来运行shellcode。只需将解码器的代码进行仔细编写即可。而核心代码则通过解码器解码后执行。
空指令雪橇不是必需的,它会增加shellcode成功运行的几率。
通过向shellcode前插入大段nop指令来实现,这样只要漏洞的跳转地址命中了雪橇中的任意一个nop代码。都会像雪橇一样一直向后滑行,直至命中真正的shellcode代码开始执行。
在PE文件中寻找shellcode是相对简单的,因为程序会使用混淆技术编写。或是将shellcode注入另一个进程内执行。
因此可以通过查找API调用来定位shellcode :
VirtualAllocEx WriteProcessMemory CreateRemoteThread
或是通过硬编码方式识别shellcode解码器:
其他格式文件中也可能会包含shellcode,如Adobe Reader的CVE-2010-0188漏洞,则允许PDF文件中包含shellcode并运行。
本文主要记录下shellcode中一些核心技术的原理和基础写法,这样在日后分析样本涉及shellcode时不会不知所措。
这段shellcode是如何编码的?
这段shellcode手动导入了哪个函数?
这段shellcode和哪个网络主机通信?
这段shellcode在文件系统上留下了什么迹象?
这段shellcode做了什么?
这段shellcode被注入到什么进程中?
这段shellcode位于哪里?
这段shellcode是如何被编码的?
这段shellcode手动导入了哪个函数?
这段shellcode和什么网络主机进行通信?
这段shellcode做了什么?
这个PDF中使用了什么漏洞?
这段shellcode是如何编码的?
这段shellcode手动导入了哪个函数?
这段shellcode在文件系统上留下了什么迹象?
这段shellcode做了什么?
0x4000
E8
00
00
00
00
call
0x4005
/
/
call目标地址
=
起始地址
+
偏移
+
指令长度(
5
) 偏移设置为
0
即可call下一行
0x4005
58
pop eax
/
/
pop eax可以将
4005
存入eax寄存器
0x4006
83
E8
05
sub eax,
5
/
/
sub eax,
5
将eax减
5
后 eax执行
4000
整段代码起始地址
0x4000
E8
00
00
00
00
call
0x4005
/
/
call目标地址
=
起始地址
+
偏移
+
指令长度(
5
) 偏移设置为
0
即可call下一行
0x4005
58
pop eax
/
/
pop eax可以将
4005
存入eax寄存器
0x4006
83
E8
05
sub eax,
5
/
/
sub eax,
5
将eax减
5
后 eax执行
4000
整段代码起始地址
0x4000
D9 EE fldz
/
/
将浮点数
0
压入FPU堆栈,结构体成员fpu_instruction_pointer被赋值为
0x4000
0x4002
83
EC
20
sub esp,
0x20
/
/
提升栈顶,为结构体开辟空间
0x4005
D9
74
24
F4 fstenv [esp
-
0xC
]
/
/
将结构体从esp
-
C处开始赋值,则ESP处刚好为fpu_instruction_pointer成员
0x4009
58
pop eax
/
/
取出fpu_instruction_pointer成员至eax,此时eax为
0x4000
,记得恢复堆栈
0x4000
D9 EE fldz
/
/
将浮点数
0
压入FPU堆栈,结构体成员fpu_instruction_pointer被赋值为
0x4000
0x4002
83
EC
20
sub esp,
0x20
/
/
提升栈顶,为结构体开辟空间
0x4005
D9
74
24
F4 fstenv [esp
-
0xC
]
/
/
将结构体从esp
-
C处开始赋值,则ESP处刚好为fpu_instruction_pointer成员
0x4009
58
pop eax
/
/
取出fpu_instruction_pointer成员至eax,此时eax为
0x4000
,记得恢复堆栈
/
/
该shellcode计算
1
+
2
的值,所用变量全部模拟全局变量
0x4000
E8
00
00
00
00
call
0x4005
/
/
将
0x4005
压入堆栈
0x4005
5A
pop edx
/
/
将
4005
存入edx寄存器
0x4006
83
EA
05
sub edx,
5
/
/
将shellcode基址存入edx
0x4009
C7
82
22
00
00
00
01
00
00
00
mov dword ptr ds:[edx
+
0x22
],
1
/
/
为全局变量赋值
0x4013
C7
82
26
00
00
00
02
00
00
00
mov dword ptr ds:[edx
+
0x26
],
2
/
/
为全局变量赋值
0X401D
E8
10
00
00
00
call
0x4035
/
/
调用函数,实现加法
0x4022
00
00
00
00
00
00
00
00
00
00
00
00
00
00
00
00
/
/
存放全局变量所用数据区
0x4032
8B
42
22
mov eax,dword ptr ds:[edx
+
0x22
]
/
/
加法汇编
0x4035
03
42
26
add eax,dword ptr ds:[edx
+
0x26
]
0x4038
89
42
2A
mov dword ptr ds:[edx
+
0x2A
],eax
/
/
将结果存入第三个全局变量
0x403B
C3 retn
/
/
该shellcode计算
1
+
2
的值,所用变量全部模拟全局变量
0x4000
E8
00
00
00
00
call
0x4005
/
/
将
0x4005
压入堆栈
0x4005
5A
pop edx
/
/
将
4005
存入edx寄存器
0x4006
83
EA
05
sub edx,
5
/
/
将shellcode基址存入edx
0x4009
C7
82
22
00
00
00
01
00
00
00
mov dword ptr ds:[edx
+
0x22
],
1
/
/
为全局变量赋值
0x4013
C7
82
26
00
00
00
02
00
00
00
mov dword ptr ds:[edx
+
0x26
],
2
/
/
为全局变量赋值
0X401D
E8
10
00
00
00
call
0x4035
/
/
调用函数,实现加法
0x4022
00
00
00
00
00
00
00
00
00
00
00
00
00
00
00
00
/
/
存放全局变量所用数据区
0x4032
8B
42
22
mov eax,dword ptr ds:[edx
+
0x22
]
/
/
加法汇编
0x4035
03
42
26
add eax,dword ptr ds:[edx
+
0x26
]
0x4038
89
42
2A
mov dword ptr ds:[edx
+
0x2A
],eax
/
/
将结果存入第三个全局变量
0x403B
C3 retn
E8
00
00
00
00
5A
83
EA
05
C7
82
22
00
00
00
01
00
00
00
C7
82
26
00
00
00
02
00
00
00
E8
10
00
00
00
00
00
00
00
00
00
00
00
00
00
00
00
00
00
00
00
8B
42
22
03
42
26
89
42
2A
C3
E8
00
00
00
00
5A
83
EA
05
C7
82
22
00
00
00
01
00
00
00
C7
82
26
00
00
00
02
00
00
00
E8
10
00
00
00
00
00
00
00
00
00
00
00
00
00
00
00
00
00
00
00
8B
42
22
03
42
26
89
42
2A
C3
push esi
/
/
保存esi寄存器
xor eax,eax
/
/
清空eax
mov eax,fs:[
0x30
]
/
/
取PEB结构首地址
mov eax,[eax
+
C]
/
/
取PEB结构中的Ldr成员(_PEB_LDR_DATA结构首地址)
mov esi,[eax
+
1C
]
/
/
取_PEB_LDR_DATA
-
> InInitializationOrderModuleList
-
> Flink 找到_LDR_DATA_TABLE_ENTRY节点中的Flink
mov eax,[esi]
/
/
找到第二个_LDR_DATA_TABLE_ENTRY节点中的Flink
mov eax,[eax
+
8
]
/
/
取出
0x18
处的DllBase成员
pop esi
/
/
恢复ESI
retn
/
/
退出
/
/
本例假设第二个节点为kernel32.dll 在旧系统中第二个固定为kernel32.dll 目前已经不固定,需要加dllname的判断
push esi
/
/
保存esi寄存器
xor eax,eax
/
/
清空eax
mov eax,fs:[
0x30
]
/
/
取PEB结构首地址
mov eax,[eax
+
C]
/
/
取PEB结构中的Ldr成员(_PEB_LDR_DATA结构首地址)
mov esi,[eax
+
1C
]
/
/
取_PEB_LDR_DATA
-
> InInitializationOrderModuleList
-
> Flink 找到_LDR_DATA_TABLE_ENTRY节点中的Flink
mov eax,[esi]
/
/
找到第二个_LDR_DATA_TABLE_ENTRY节点中的Flink
mov eax,[eax
+
8
]
/
/
取出
0x18
处的DllBase成员
pop esi
/
/
恢复ESI
retn
/
/
退出
/
/
本例假设第二个节点为kernel32.dll 在旧系统中第二个固定为kernel32.dll 目前已经不固定,需要加dllname的判断
HashString:
/
/
计算字符串
hash
push esi
push edi
mov esi,[esp
+
0xC
]
calc_hash:
xor edi,edi
cld
hash_iter:
xor eax,eax
lodsb
cmp
al,ah
je hash_done
ror edi,
0xD
add edi,eax
jmp hash_iter
hash_done:
mov eax,edi
pop edi
pop esi
retn
4
HashString:
/
/
计算字符串
hash
push esi
push edi
mov esi,[esp
+
0xC
]
calc_hash:
xor edi,edi
cld
hash_iter:
xor eax,eax
lodsb
cmp
al,ah
je hash_done
ror edi,
0xD
add edi,eax
jmp hash_iter
hash_done:
mov eax,edi
pop edi
pop esi
retn
4
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!