域安TEAM隶属于 渗透攻击红队 ,是一支集各个厂商攻击手的专业红队,专注于安全研究、红队培训以及渗透案例分析。文章将会不定时更新,请勿利用文中相关技术从事非法测试,如因此产生的一切不良后果与文章作者和本公众号无关!
利用非传统方法显示调用 api 函数
前言
本文将介绍一种在内存中查找函数地址的方法,从而隐藏导入表存在调用函数的痕迹。
下面将对该方法进行详细的介绍。
GetProcAddress()
我们的思路就是自己来实现GetProcAddress的方式来寻找API地址
同时我们不直接使用API名称,我们采用对API名称计算一个hash,通过这个hash去寻找比对需要的API ,我们这种方法在本文后续中简称为hash API
我们先了解一下 GetProcAddress的工作原理:
获取EAT结构的函数名称地址数组并跳转到该地址,即 IMAGE_EXPORT_DIRECTORY.AddressOfNames
此处存储着当前找到的模块中的所有的导出函数的名称,通过与这些字符串逐个比较,可以找到指定的函数名称。将此时数组的索引记作 Index
查找并跳转到 ordinal地址数组所在的位置,即 IMAGE_EXPORT_DIRECTORY.AddressOfNameOrdinals
在 ordinal 地址数组中利用之前找到的索引 Index作为偏移量,查找对应的 ordinal 值,类似 AddressOfNameOrdinals[
Index]
查找导出函数地址,流程转移到导出函数地址数组的位置,即 IMAGE_EXPORT_DIRECTORY.AddressOfFunctions
以得到的 ordinal的值为数组索引,得到函数起始地址,很像AddressOfFunctions[ordinal]
备注:有的模块是序号导出,因此直接 Index== ordinal 会导致找不到,只有按照上面的方法去查找才不会出错。
了解导出表的结构
导出表的结构如下:
IMAGE_EXPORT_DIRECTORY
,IMAGE_OPTIONAL_HEADER32.DataDirectory[0].VirtualAddress
的值就是 IMAGE_EXPORT_DIRECTORY
结构体数组的RVA起始地址。
其具体结构如下:
typedef struct _IMAGE_EXPORT_DIRECTORY {
// 标志, 未使用
DWORD Characteristics;
// 时间戳
DWORD TimeDateStamp;
// 未使用
WORD MajorVersion;
// 未使用
WORD MinorVersion;
// 指向该导出表的文件名字符串
DWORD Name;
// 导出函数的起始值
DWORD Base;
// 实际导出的函数个数
DWORD NumberOfFunctions;
// 导出的函数中具名的函数个数
DWORD NumberOfNames;
// 导出函数地址数组
DWORD AddressOfFunctions;
// 函数名称地址数组
DWORD AddressOfNames;
// Ordinal 地址数组 (数组元素个数=NumberOfNames)
DWORD AddressOfNameOrdinals;
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
Hash API 代码详解
1.对API名称hash可以极大的节省空间,这点在Shellcode中体现尤为明显。
2.版本兼容性好一些,因为其是遍历到指定的模块,然后检查指定模块的IAT表,从中获得函数名,转换成hash与要寻找的函数hash进行比较。有些shellcode作者的编写则会按照一些习惯或者编写shellcode时所处系统的特点去寻找模块,更换版本之后shellcode可能就出问题,比如XP和win7之间引入了kernelbase.dll就会导致某些找kernel32.dll的shellcode失效。
3.这相当于我们没有直接使用系统的GetProcAddress去寻找API,也没有使用loadlibrary去加载模块,正常开发没什么用,但对于某些场景,比如保护代码时,这种方法是有挺大作用的。
4.缺点也有,不如特定顺序寻址的方式灵活小巧。
我们对其中MSF的代码进行解读来了解这种技术,代码见 metasploit(https://github.com/rapid7/metasploit-framework/blob/master/external/source/shellcode/windows/x86/src/block/block_api.asm)。
windows环境中, FS
段寄存器指向线程环境块(TEB)的地址,当在内存区运行shellcode时,我们需要从 TEB 所在的地址处移动48字节来得到 PEB 的地址,InMemoryOrderModuleList则需要从PEB中寻找
如下:
api_call:
pushad ; 保存所有寄存器状态
mov ebp, esp ; 创建新的栈帧
xor eax, eax ; 将EAX清零
mov edx, [fs:eax+48] ; 将指针指向PEB
TEB的结构如图所示:
找到PEB之后呢,再从PEB 再偏移12字节就能得到Ldr数据结构的地址,PEB的结构如下图所示:
Ldr结构体包含了进程加载模块的信息,将其再偏移20字节,就是InMemoryOrderModuleList,
我们就能从 InMemoryOrderModuleList
结构体中获得第一个已加载的模块。
LDR结构体PEB_LDR_DATA的结构如图:
下面是我们拿到PEB之后,从PEB找到LDR,然后再从LDR找到InMemoryOrderModuleList
结构体的代码
; Get PEB->Ldr
mov edx, [edx+12]
; 得到 InMemoryOrderModuleList 的地址,也就是加载的第一个模块的地址
mov edx, [edx+20]
现在指针指向 InMemoryOrderModuleList
它是一个 LIST_ENTRY
的结构。
结构如下:
typedef struct _LIST_ENTRY
{
struct _LIST_ENTRY *Flink;
struct _LIST_ENTRY *Blink;
} LIST_ENTRY, *PLIST_ENTRY, *RESTRICTED_POINTER PRLIST_ENTRY;
微软官方文档中对该结构体的解释如下:
Thehead of a doubly-linked list that contains the loaded modules for the process.Each item in the list is a pointer to an LDR_DATA_TABLE_ENTRY structure
也就是这是个双向链表,指向了进程装载的模块,FLink和Blink都指向一个LDR_DATA_TABLE_ENTRY
结构,只不过Flink指向后一个,Blink指向前一个
LDR_DATA_TABLE_ENTRY
的结构如下:
typedef struct _LDR_DATA_TABLE_ENTRY
{
LIST_ENTRY InLoadOrderLinks;
LIST_ENTRY InMemoryOrderLinks;
LIST_ENTRY InInitializationOrderLinks;
PVOID DllBase;
PVOID EntryPoint;
ULONG SizeOfImage;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName;
ULONG Flags;
WORD LoadCount;
WORD TlsIndex;
union
{
LIST_ENTRY HashLinks;
struct
{
PVOID SectionPointer;
ULONG CheckSum;
};
};
union
{
ULONG TimeDateStamp;
PVOID LoadedImports;
};
_ACTIVATION_CONTEXT * EntryPointActivationContext;
PVOID PatchInformation;
LIST_ENTRY ForwarderLinks;
LIST_ENTRY ServiceTagLinks;
LIST_ENTRY StaticLinks;
} LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;
现在我们已经完成了 PEB -> TEB -> Ldr ->InMemoryOrderModuleList 得到了第一个被进程加载模块。
继续下面的代码
; 获得指向模块名称BaseDllName的地址
1.mov esi, [edx+40]
; 指向BaseDllName->MaximumLength,表示该内存缓冲区的总大小
2.movzx ecx, word [edx+38]
3.xor edi, edi
1处的指令中的edx+40表示 LDR_DATA_TABLE_ENTRY->BaseDllName
,也就是模块的名称,这是个UNICODE_STRING结构体。
继续看后面的代码
loop_modname:
; 逐字节读取模块名
lodsb
;这里是为了统一切换成大写
cmp al, 'a'
jl not_lowercase
sub al, 0x20
not_lowercase:
; 将hash值循环右移13位
ror edi, 13
; hash值与下一个字符相加,等到新的hash值
add edi, eax
; 循环相加,循环次数为ecx,即BaseDllName的缓冲区大小
loop loop_modname
我们这里采用了一个字符右移13次的方法,没什么特别的意义,出自一位师傅随手的hash算法,有以后再与新的字符相加,重复右移,最后得到一个摘要值,这种算法偏简单,碰撞机会可能也比较大。算法可以自己进行修改。
这里的循环右移13次没有什么特别的含义,只是为了确保得到一个唯一的值,与Hash算法的出发点一样。如果你愿意你可以修改一下改成自己的算法,如 左移,多重复移动几次,甚至与其他的运算混合使用,只要唯一就可以。 很多恶意软件的作者都对这个 Hash 算法进行了自己的修改以达到免杀的目的。
; 保存edx,edx的值指向InMemoryOrderModuleList
push edx
; 保存edi,edi为之前计算的模块hash
push edi
; [edx+16]为 `LDR_DATA_TABLE_ENTRY->DllBase` ,镜像加载内存中的基址
mov edx, [edx+16]
; [edx+60] 指向 IMAGE_DOS_HEADER结构体中的e_lfanew,表示PE头的RVA地址
mov ecx, [edx+60]
拿到了PE头和DllBase,我们就能去获得导出函数表EAT
; DllBase+PE头RVA地址+120等于导出表EAT的RVA地址
mov ecx, [ecx+edx+120]
; ECX为0则跳转,即没有导出函数则跳转到下一个模块
jecxz get_next_mod1
; 导出地址表EAT的RVA加上模块基地址DllBase为VA
add ecx, edx
; 保存当前模块的EAT的VA
push ecx
; 获取函数名称地址表 IMAGE_EXPORT_DIRECTORY->AddressOfNames 的RVA
mov ebx, [ecx+32]
; 函数名称地址数组VA 等于其 RVA + 模块的基地址DllBase
add ebx, edx
; 获取导出函数中具名的函数个数 IMAGE_EXPORT_DIRECTORY->NumberOfNames
mov ecx, [ecx+24]
(与讲解此处关系不大的代码略过)
get_next_mod:
; 弹出当前模块的eat到edi中
pop edi
get_next_mod1:
; 弹出之前的保存的hash
pop edi
; 还原之前保存的 InMemoryOrderModuleList 地址
pop edx
; InMemoryOrderModuleList是链表结构,[edx]指向指向下一个加载的模块
mov edx, [edx]
; 跳转去处理下一个模块
jmp short next_mod
PE头偏移120字节就是导出表EAT的地址即 IMAGE_OPTIONAL_HEADER32.DataDirectory[0].VirtualAddress
的RVA地址。
这段代码主要是在模块内遍历所有函数,计算hash,与目标函数的hash值进行比对,相等则表示成功找到。
get_next_func: ;
jecxz get_next_mod ; ECX为0,说明搜索结束,跳转到下一个模块,这里是从后往前进行搜索。
dec ecx ; ecx减一,即导出的函数中具名的函数个数NumberOfNames作为循环计数器
mov esi, [ebx+ecx*4] ; 获取导出函数的函数名字符串
add esi, edx ; 获取该 FunctionName 的 VA (DllBase + RVA)
xor edi, edi ; 清除保存函数hash的edi
loop_funcname: ;
lodsb ; 逐字节读取esi中保存的函数名称,结果放入eax中
ror edi, 13 ; 循环右移13次计算hash
add edi, eax ; 与下一次函数名字符相加
cmp al, ah ; 比较名字的下一个字节
jne loop_funcname ; 不为空则继续循环
add edi, [ebp-8] ; [ebp-8]为之前计算过的模块hash,与这里的函数hash相加得到总的hash
cmp edi, [ebp+36] ; 与目标函数的hash值比较
jnz get_next_func ; 如果不相等,则重复查找
值得注意的是代码 movesi, [ebx+ecx*4]
,其中 ebx 表示函数名称地址表 AddressOfNames
的VA地址,其中 ecx 作为索引,标准的数组通过索引找元素的汇编写法
如果成功找到,则修复堆栈,调用函数
pop eax ; 还原当前模块的EAT的VA
mov ebx, [eax+36] ; 获取 AddressOfNameOrdinals 的RVA
add ebx, edx ; 与DllBase相加得到VA
mov cx, [ebx+2*ecx] ; 在AddressOfNameOrdinals中获得目标函数的index
mov ebx, [eax+28] ; 获得函数地址表 AddressfFunctions 的RVA
add ebx, edx ; 与DllBase相加得到VA
mov eax, [ebx+4*ecx] ; 获得目标函数的RVA
add eax, edx ; 与DllBase相加得到最终函数的VA,
moveax, [ebx+4*ecx]
是获得目标函数的RVA,即 DllBase +IMAGE_EXPORT_DIRECTORY->AddressOfFunctions) + (4 * OrdinalIndex)
。
至此就基本完成了通过Hash 寻址API基本完成,剩下的就是处理细节以及调用函数的问题了。