Windows x86/x64 动态库劫持
版权声明:原创文章,未经授权,请勿转载
1. 引子
很多时候出于各种各样的原因,我们需要用到动态库劫持技术来做一些研究或者实现某种功能,这时候我们会用到一些DLL劫持工具来简化工作。
比如 AheadLib:它可以根据目标DLL的导出函数,自动生成劫持相关的代码,免去了我们手动提取导出函数、编写劫持代码。我们将生成的代码编译成DLL, 冒名顶替目标DLL,就可以将目标函数的调用转接到新的DLL。
之前用过AheadLib来分析一个商业软件与第三方组件的交互流程, 效果不错所以印象深刻.
这里主要描述Windows x64/x86动态库劫持技术,以及由此展开的相关调用约定简述,默认读者具备以下基础:
- C/C++基础
- x86汇编(INTEL风格)
就在不久前我又有了一个类似的需求,目标是64位软件,而手里面的AheadLib是32位。经过一番搜索在看雪论坛找到了64位的版本,但结果却不如我的预期,这才有了接下来的故事;
64位的AheadLib生成的代码比之前有了很大的改变,原因是Win x64环境下函数的前4个参数是通过寄存器传递, 与x86环境下所有参数经过压栈传递(cdecl, stdcall, ...)大有不同;
2. 简述x86 与 x64 下C/C++函调用的差异
x86下C/C++函数的调用方式(也就是调用约定)主要有如下几种方式, 其参数均通过压栈传递:
- cdecl (Microsoft C++系列编译器的默认调用方式)
- stdcall (Windows绝大部分API的默认调用方式, 有时候也称为pascal)
- thiscall (C++成员函数的调用方式)
下面我们来看看在汇编层面上x86与x64下调用约定的细节:
// x86 cdecl调用约定下的参数传递及返回值
int main()
{
test_function(0x5555aaaa, 0x3333eeee,
3.141592656245, 0x2222dddd, 0x1111bbbb, 0x6666ffff);
return 0;
}
; 这是对应的汇编代码
fld qword ptr [__real@400921fb549f688a]
push 6666FFFFh ; 参数6, 放到栈上
push 1111BBBBh ; 参数5, 放到栈上
push 2222DDDDh ; 参数4, 放到栈上
sub esp,8
fstp qword ptr [esp] ; 参数3, 放到栈上
push 3333EEEEh ; 参数2, 放到栈上
push 5555AAAAh ; 参数1, 放到栈上
call dword ptr [__imp_test_function] ; 调用
add esp,1Ch ; 平栈
xor eax,eax ; 返回值清0
ret
而到了x64就只剩下了一种快速调用约定(x64) fastcall, 虽然在x86下该约定也存在但极少见到我们这里不讨论:
// x64 环境下的参数传递
int main()
{
test_function(0x5555aaaa, 0x3333eeee,
3.141592656245, 0x2222dddd, 0x1111bbbb, 0x6666ffff);
return 0;
}
; 这是对应的汇编代码
sub rsp,38h ; 20h(影子空间) + 10h(参数5,6放在栈上) + 8h(对齐用的)
movsd xmm2,mmword ptr [__real@400921fb549f688a] ; 参数3, 浮点数放在xmm2
mov r9d,2222DDDDh ; 参数4
mov edx,3333EEEEh ; 参数2
mov ecx,5555AAAAh ; 参数1
mov dword ptr [rsp+28h],6666FFFFh ; 参数6, 放到栈上
mov dword ptr [rsp+20h],1111BBBBh ; 参数5, 放到栈上
call qword ptr [__imp_test_function] ; 调用
xor eax,eax ; 清空返回值
add rsp,38h ; 平栈
ret
从上面我们可以看到x64 fastcall
的前4个参数通过寄存器传递, 如果参数大于4才会走栈, 而x86的调用约定都是通过栈传递;
为了推动剧情的发展, 在这里要重点说明一下x64 fastcall
调用约定的细节 (略过x86 如果对x86的调用约定感兴趣的话可以参考相关的资料)
3. x64 fastcall调用约定
参考文档: https://docs.microsoft.com/en-us/cpp/build/x64-calling-convention?view=vs-2019
总的来说x64 fastcall约
定了以下几个关键点:
- 函数参数如何传递
- 前四个浮点型以外的参数从左至右通过
RCX, RDX, R8, R9
传递; - 前四个浮点型参数从左至右通过
XMM0L, XMM1L, XMM2L, XMM3L
传递; - 除了前面1, 2列出情况之外的参数均从右到左压栈传递;
- 调用者需要为4个寄存器(无论参数个数与类型)分配栈空间, 我们称为影子空间, 被调用方选择性地将寄存器中的参数保存到该空间(比如调用子函数);
- 若有参数需要入栈那么应该存储在
影子空间
的前面(高地址方向) - 编译器对参数总是从右至左处理: 如某函数有6个参数, 那么首将参数6, 5入栈, 再将参数4, 3, 2, 1存储到对应寄存器;
- 前四个浮点型以外的参数从左至右通过
- 函数参数的栈空间由哪方开辟, 哪方释放?
- 由调用方开辟, 也由调用方恢复;
- 函数返回值如何返回
- 不大于8字节的返回值通过RAX返回;
- 函数如何使用寄存器
- 寄存器RAX、RCX、RDX、R8、R9、R10、R11、XMM0-5以及YMM0-15和ZMM0-15的上半部分被认为是易失性的,必须在函数调用时考虑被销毁(除非通过整个程序优化等分析可以安全证明)。在AVX512VL上,ZMM、YMM和XMM寄存器16-31是易失的。
- 寄存器RBX、RBP、RDI、RSI、RSP、R12、R13、R14、R15和XMM6-15被认为是非易失性的,必须由使用它们的函数保存和恢复。
- 函数的栈地址必须保证16字节对齐
一下子看到这么多是不是跟我一样懵逼了, 不过没关系, 下面跟着代码再看着图会清晰很多;
4. 简述x86与x64下DLL导出函数劫持
动态库劫持技术分为如下几个步凑:
- 提取目标DLL中所有导出函数
- 根据提取的导出函数编写
转发
或劫持
代码 - 通过代码编译生成DLL, 并替换目标DLL(目标DLL需改个名字, 为了不影响宿主进程,最后还是要调用目标DLL的导出函数)
而导出函数(下面统一称为原始函数)的劫持又分为以下步骤:
(因为这篇文章的内容的是劫持宿主进程的函数调用所以这里不讨论转发)
- 编写我们自己的函数(下面统一称为劫持函数), 并通过"声明"将其导出为与原始函数相同的名称
- 目标进程调用我们的劫持函数之后再调用原始函数;(这一步我们可以拿到调用参数)
- 等待原始函数返回后, 再通过其返回值返回到宿主进程领空; (此时我们可以拿到返回值或者被修改后的参数)
我滴个乖乖, 要做的事情还真不少!
不过幸运的是关于编码的部分AheadLib已经为我们做的差不多了, 下面是AheadLib生成的部分劫持代码(x64的比较复杂故先上x86的预热一下):
4.1 x86原始函数劫持
// 下面代码告诉编译器:
// 1. 将劫持函数_AheadLib_OpenDataBase以OpenDataBase名称导出
// 2. 当目标进程通过该DLL调用原始函数OpenDataBase时, 实际调用的是AheadLib_OpenDataBase函数
#pragma comment(linker, "/EXPORT:OpenDataBase=_AheadLib_OpenDataBase,@1")
// 省略代码若干...
// 获取原始函数地址
FARPROC WINAPI GetAddress(PCSTR pszProcName)
{
FARPROC fpAddress;
CHAR szProcName[16];
TCHAR tzTemp[MAX_PATH];
fpAddress = GetProcAddress(m_hModule, pszProcName);
if (fpAddress == NULL)
{
if (HIWORD(pszProcName) == 0)
{
wsprintfA(szProcName, "%d", pszProcName);
pszProcName = szProcName;
}
wsprintf(tzTemp, TEXT("无法找到函数 %hs,程序无法正常运行。"), pszProcName);
MessageBox(NULL, tzTemp, TEXT("AheadLib"), MB_ICONSTOP);
ExitProcess(-2);
}
return fpAddress;
}
// 省略代码若干...
// ALCDECL 被定义为: extern "C" __declspec(naked) void __cdecl
// 目的是告诉编译器:
// 1. 使用C语言的名称改编规则
// 2. 内部使用的汇编代码不需要修饰
// 3. 使用cdecl调用约定
ALCDECL AheadLib_OpenDataBase(void)
{
// 此时原始函数的参数已经保存在栈上
// 可以在这里直接通过栈寄存器ESP+4直接访问, 此时注意好栈平衡不要破坏数据即可;
// 注意ESP是AheadLib_OpenDataBase的返回地址
GetAddress("OpenDataBase");
__asm JMP EAX;
// 上面两行代码作用如下:
// 1. 首先找到原始函数的地址, 该地址作为返回值保存在寄存器EAX中;
// 2. 通过JMP指令直接将CPU的指令指针跳转到原始函数并开始执行
// 3. 由于JMP跳转前栈中第一个保存的是AheadLib_OpenDataBase的返回地址, 故原始函数执行完成后会直接返回到宿主进程领空, 所以不会执行后面的代码了;
// 若这里需要处理原始函数的返回值则稍微要修改下汇编代码, 这里不多赘述因为主要还是讨论x64 ^_^
}
4.2 x64原始函数劫持
// 这部分流程与x86一样
#pragma comment(linker, "/EXPORT:OpenDataBase=_AheadLib_OpenDataBase,@1")
// 这里多了一个函数指针
PVOID pfnOpenDataBase;
// 省略代码若干...
// 这里也差不多
FARPROC WINAPI GetAddress(PCSTR pszProcName)
{
FARPROC fpAddress;
CHAR szProcName[16];
TCHAR tzTemp[MAX_PATH];
fpAddress = GetProcAddress(m_hModule, pszProcName);
if (fpAddress == NULL)
{
if (HIWORD(pszProcName) == 0)
{
wsprintfA(szProcName, "%d", pszProcName);
pszProcName = szProcName;
}
wsprintf(tzTemp, TEXT("无法找到函数 %hs,程序无法正常运行。"), pszProcName);
MessageBox(NULL, tzTemp, TEXT("AheadLib"), MB_ICONSTOP);
ExitProcess(-2);
}
return fpAddress;
}
// 这里多了个函数, 与x86不同的是 在DLL加载的时候先把原始函数的指针缓存起来了
inline VOID WINAPI InitializeAddresses()
{
pfnOpenDataBase = GetAddress("OpenDataBase");
// 省略代码若干...
}
// 重要的是:这三个外部函数
extern "C" extern void prevFunc();
extern "C" extern void setFunc(LPVOID p);
extern "C" extern void endFunc();
// 省略代码若干...
// ALCDECL 定义略有修改: extern "C" void __cdecl
// 目的是告诉编译器:
// 1. 使用C语言的名称改编规则
// 2. 使用cdecl调用约定
ALCDECL AheadLib_OpenDataBase(void)
{
// 诶, 这里不是上面多出来的三个外部函数吗?
// 等等... 实现哪儿去了?
prevFunc();
setFunc(&pfnOpenDataBase);
endFunc();
}
上面就是整个x64的劫持实现, 看起来也没有比x64多复杂嘛, 不过别忘了还有三个找不到定义的外部函数;
如果有用过AheadLib x64的同学应该会发现:随着生成劫持代码, 还会生成一个同名的.obj文件 这里面就是那三个鬼函数的实现;
那么问题来了, 为什么会将实现放在obj文件里面呢?
答案是: x64环境下MSVC编译器(Visual Studio系列)已经不再支持内联汇编代码了, 所以放在了.obj文件中
我们用ida反编译来看看代码长什么样
prevFunc PROC
mov qword ptr [rsp+38h],rcx ; 将各参数保存至它们的影子空间(栈),
; 因在劫持函数内,栈增加了28h的空间以及该函数的返回地址共计30h,所以影子空间从38h开始
mov qword ptr [rsp+40h],rdx
mov qword ptr [rsp+48h],r8
mov qword ptr [rsp+50h],r9
ret
prevFunc ENDP
setFunc PROC
mov rax,rcx ; 保存真实函数地址到rax
ret
setFunc ENDP
endFunc PROC
pop rbx ; 弹出endFunc的返回地址
add rsp,28h ; 去除劫持函数中预留的28h大小空间(内存对齐用的8字节+影子空间)
pop rbx ; 弹出劫持函数的返回地址到rbx中保留
pop rcx ; 弹出参数1
pop rdx ; 弹出参数2
pop r8 ; 弹出参数3
pop r9 ; 弹出参数4
sub rsp,20h ; 恢复因弹出导致的栈顶变化
push rbx ; 压栈外部的返回地址
jmp qword ptr [rax] ; 跳转到原始函数的入口
ret ; 这里执行不到
endFunc ENDP
我们结合劫持函数AheadLib_OpenDataBase的汇编代码分析:
; ALCDECL AheadLib_OpenDataBase(void)
; {
sub rsp,28h ; 分配内存对齐用的8字节+下面三个函数的影子空间
call prevFunc ; prevFunc();
lea rcx,[pfnOpenDataBase]
call setFunc ; setFunc(&pfnOpenDataBase);
call endFunc ; endFunc();
add rsp,28h
ret
; }
再让我们来结合下面的图来仔细分析这些代码到底干了什么?
我们假设OpenDataBase有6个参数(没有浮点数), 那么前四个通过RCX, RDX, R8, R9传递, 后两个通过栈传递;
- 目标进程为了调用OpenDataBase函数, 通过sub rsp, 50h分配了一个栈空间(影子空间, 参数空间, 其他局部变量存储空间共计50h)
- 目标进程将参数6放在rsp+28h, 参数5放在rsp+20h, rsp指向RCX, RDX, R8, R9也就是参数1,2,3,4的影子空间;
- 目标进程调用OpenDataBase进入到AheadLib_OpenDataBase, 此时返回地址入栈
- AheadLib_OpenDataBase为了调用三个外部函数所以需要分配20h的影子空间(即使它们最多只有一个参数), 又因为x64 fastcall栈空间16字节对齐的缘故所以开辟的28h字节的栈空间;
- 调用prevFunc, 此时rsp与原始函数的影子空间距离为:
- 8h字节(prevFunc 返回地址) +
- 28h字节(AheadLib_OpenDataBase 开辟的栈空间) +
- 8h字节(AheadLib_OpenDataBase 返回地址)
- 共计38h, 即rsp+38h指向参数1, rsp+50h指向参数4
- prevFunc分别将参数1,2,3,4保存到他们自己的影子空间, 以备后用;
- setFunc没啥好说的就是将原始函数指针的地址放在rax;
- 到了endFunc重头戏终于来了:
- 通过上面代码的注释应该可以看出, 这里重新将影子空间的值读取到RCX, RDX, R8, R9中;
- 然后将AheadLib_OpenDataBase返回地址 放入栈, 直接将其替换为原始函数的返回地址, 故原始函数会直接返回到目标进程领空;
5. AheadLib x64的bug
在通过AheadLib x64生成的代码在生产环境下,可能会遇到崩溃,别问我怎么知道的 o(╥﹏╥)o
;
如果细心看调用约定的同学已经看出来了, 对, 就是它!
endFunc在未经保存下使用了非易失性寄存器rbx;
通过文档我们可以得知易失性寄存器除了作为参数的4个RCX, RDX, R8, R9
以外还有R10、R11
等。
我们就替换成r10吧, 结果问题完美解决;
修改后的代码如下:
endFunc PROC
pop r10 ; 弹出endFunc的返回地址
add rsp,28h ; 去除劫持函数中预留的0x28大小空间(内存对齐用的8字节+影子空间)
pop r10 ; 弹出劫持函数的返回地址到r10中保留
pop rcx ; 弹出参数1
pop rdx ; 弹出参数2
pop r8 ; 弹出参数3
pop r9 ; 弹出参数4
sub rsp,20h ; 恢复因弹出导致的栈顶变化
push r10 ; 压栈外部的返回地址
jmp qword ptr [rax] ; 跳转到真实函数的入口
ret ; 这里应该是执行不到的
endFunc ENDP
6. AheadLib x64的不足及解决方案
我们在前面说过,可以在原始函数的调用前后,分别拿到它的参数及返回值。那我们该如何做呢?
首先我们要考虑的是,在原始函数调用前以及调用后,让宿主进程暂停一下,等我们读取完成参数与返回值的值之后再继续执行。最好设计一个通用的机制以便应用到所有情况。
那么我们可以设计两个回调函数,分别在原始函数调用前、后,将函数栈帧地址及返回值传递给用户, 为了保证在回调函数过程执行中不破坏这些寄存器我们还需要将其保存起来;
但是这里会遇到另一个问题, 我们如何让原始函数返回到劫持函数中?
在原有的设计中肯定不行, 我们必须要重新设计下劫持代码, 而且为了最小改动最好能兼容现有的代码;
有两种方式可以达到要求:
- 在调用原始函数时,需要将劫持函数的返回地址从栈里面拿出来保存, 然后将
JMP
替换为CAll
, 使原始函数返回后直接跳转到劫持函数; - 将原始函数的调用栈拷贝到一个新的栈空间, 然后在这个位置上实现原始函数的调用;
基于某些原因, 以及存储方式的考虑个人采用的第二种方案, 大概实现如下图:
实现代码如下:
; 声明外部函数:
; void BeforeOriginFunctionCall(
; LPVOID pfuncOrgin, LPVOID paramAddress);
; void AfterOriginFunctionCall(
; LPVOID pfuncOrgin, LPVOID paramAddress, LPVOID returnValue);
extrn BeforeOriginFunctionCall:near
extrn AfterOriginFunctionCall:near
.CODE
prevFunc PROC
mov qword ptr [rsp+38h], rcx ; 将各参数保存至它们的影子空间
mov qword ptr [rsp+40h], rdx
mov qword ptr [rsp+48h], r8
mov qword ptr [rsp+50h], r9
ret
prevFunc ENDP
; setFunc函数目在于将原始函数的影子空间及参数空间
; 拷贝到一个新的地方, 然后在这里调用BeforeOriginFunctionCall回调
; 将原始函数地址以及其影子空间(参数栈)作为参数传递过去
setFunc PROC
mov qword ptr [rsp+8], rcx ; 首先保存参数到影子空间
mov r10, rsp ; 并且保存rsp到r10
sub rsp, 88h ; 开辟80h大小的空间, 可以容纳16个参数, 为了栈16字节对齐故分配88h
push rsi
push rdi
pushf
mov rcx, 10h ; 重复16次
mov rsi, r10
add rsi, 38h ; r10 + 38h, 为原地址, 即从影子空间往下拷贝
mov rdi, r10
sub rdi, 88h ; r10 - 88h, 为目的地
cld ; 将movsd的方向设置为向下, 即si, di递增
; 开始拷贝
rep movs qword ptr [rdi], qword ptr [rsi]
popf
pop rdi
pop rsi
mov rcx, qword ptr [r10+8h]
mov rcx, qword ptr [rcx] ; 参数1 原始函数指针
mov rdx, r10
sub rdx, 88h ; 参数2 原始函数参数栈地址
sub rsp, 20h ; 为即将调用的函数开辟影子空间
call BeforeOriginFunctionCall ; 调用处理函数
add rsp, 20h
add rsp, 88h
mov rax, qword ptr [rsp+8h] ; 将影子空间中的参数保存至rax
ret
setFunc ENDP
; endFunc函数在拷贝的调用栈副本的基础上调用原始函数,
; 并在其返回后将原始函数地址, 参数以及其返回值作为参数调用AfterOriginFunctionCall
endFunc PROC
sub rsp, 88h ; 重置栈到之前开辟的88h处, 这里存放了setFunc拷贝的原始参数
mov rcx, qword ptr [rsp+00h] ; 读取原始参数
mov rdx, qword ptr [rsp+08h]
mov r8 , qword ptr [rsp+10h]
mov r9 , qword ptr [rsp+18h]
call qword ptr [rax] ; 调用原始函数
mov r10, rsp
add r10, 88h ; 获得endFunc函数的初始rsp
mov rcx, qword ptr [r10+8h]
mov rcx, qword ptr [rcx] ; 从影子空间中读取原始函数的地址, 作为参数1
mov rdx, rsp ; 原始函数的参数地址, 作为参数2
mov r8 , rax ; 原始函数的返回值, 作为参数3
push rax ; 保存原始函数的返回值
sub rsp, 20h ; 为即将调用的函数开辟影子空间
call AfterOriginFunctionCall ; 调用处理函数
add rsp, 20h
pop rax
add rsp, 88h
ret
endFunc ENDP
END
这样只需要在回调中实现目标关键函数的解析器即可, 在原始函数调用前拿到其参数列表, 调用后拿到返回值, 完美的API Monitor, 还可以根据配置写断点 ^_^
好了文章到这里就结束了, 写的不好请多多指教, 如果有更好的方案也可以提出来让我们共同学习, 提高;
Comments ()