Windows x86/x64 动态库劫持

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++函数的调用方式(也就是调用约定)主要有如下几种方式, 其参数均通过压栈传递:

  1. cdecl (Microsoft C++系列编译器的默认调用方式)
  2. stdcall (Windows绝大部分API的默认调用方式, 有时候也称为pascal)
  3. 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约定了以下几个关键点:

  1. 函数参数如何传递
    1. 前四个浮点型以外的参数从左至右通过RCX, RDX, R8, R9传递;
    2. 前四个浮点型参数从左至右通过XMM0L, XMM1L, XMM2L, XMM3L传递;
    3. 除了前面1, 2列出情况之外的参数均从右到左压栈传递;
    4. 调用者需要为4个寄存器(无论参数个数与类型)分配栈空间, 我们称为影子空间, 被调用方选择性地将寄存器中的参数保存到该空间(比如调用子函数);
    5. 若有参数需要入栈那么应该存储在影子空间的前面(高地址方向)
    6. 编译器对参数总是从右至左处理: 如某函数有6个参数, 那么首将参数6, 5入栈, 再将参数4, 3, 2, 1存储到对应寄存器;
  2. 函数参数的栈空间由哪方开辟, 哪方释放?
    1. 由调用方开辟, 也由调用方恢复;
  3. 函数返回值如何返回
    1. 不大于8字节的返回值通过RAX返回;
  4. 函数如何使用寄存器
    1. 寄存器RAX、RCX、RDX、R8、R9、R10、R11、XMM0-5以及YMM0-15和ZMM0-15的上半部分被认为是易失性的,必须在函数调用时考虑被销毁(除非通过整个程序优化等分析可以安全证明)。在AVX512VL上,ZMM、YMM和XMM寄存器16-31是易失的。
    2. 寄存器RBX、RBP、RDI、RSI、RSP、R12、R13、R14、R15和XMM6-15被认为是非易失性的,必须由使用它们的函数保存和恢复。
  5. 函数的栈地址必须保证16字节对齐

一下子看到这么多是不是跟我一样懵逼了, 不过没关系, 下面跟着代码再看着图会清晰很多;

4. 简述x86与x64下DLL导出函数劫持

动态库劫持技术分为如下几个步凑:

  1. 提取目标DLL中所有导出函数
  2. 根据提取的导出函数编写转发劫持代码
  3. 通过代码编译生成DLL, 并替换目标DLL(目标DLL需改个名字, 为了不影响宿主进程,最后还是要调用目标DLL的导出函数)

而导出函数(下面统一称为原始函数)的劫持又分为以下步骤:
(因为这篇文章的内容的是劫持宿主进程的函数调用所以这里不讨论转发)

  1. 编写我们自己的函数(下面统一称为劫持函数), 并通过"声明"将其导出为与原始函数相同的名称
  2. 目标进程调用我们的劫持函数之后再调用原始函数;(这一步我们可以拿到调用参数)
  3. 等待原始函数返回后, 再通过其返回值返回到宿主进程领空; (此时我们可以拿到返回值或者被修改后的参数)

我滴个乖乖, 要做的事情还真不少!

不过幸运的是关于编码的部分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
; }

再让我们来结合下面的图来仔细分析这些代码到底干了什么?

image_01.png

我们假设OpenDataBase有6个参数(没有浮点数), 那么前四个通过RCX, RDX, R8, R9传递, 后两个通过栈传递;

  1. 目标进程为了调用OpenDataBase函数, 通过sub rsp, 50h分配了一个栈空间(影子空间, 参数空间, 其他局部变量存储空间共计50h)
  2. 目标进程将参数6放在rsp+28h, 参数5放在rsp+20h, rsp指向RCX, RDX, R8, R9也就是参数1,2,3,4的影子空间;
  3. 目标进程调用OpenDataBase进入到AheadLib_OpenDataBase, 此时返回地址入栈
  4. AheadLib_OpenDataBase为了调用三个外部函数所以需要分配20h的影子空间(即使它们最多只有一个参数), 又因为x64 fastcall栈空间16字节对齐的缘故所以开辟的28h字节的栈空间;
  5. 调用prevFunc, 此时rsp与原始函数的影子空间距离为:
    1. 8h字节(prevFunc 返回地址) +
    2. 28h字节(AheadLib_OpenDataBase 开辟的栈空间) +
    3. 8h字节(AheadLib_OpenDataBase 返回地址)
    4. 共计38h, 即rsp+38h指向参数1, rsp+50h指向参数4
  6. prevFunc分别将参数1,2,3,4保存到他们自己的影子空间, 以备后用;
  7. setFunc没啥好说的就是将原始函数指针的地址放在rax;
  8. 到了endFunc重头戏终于来了:
    1. 通过上面代码的注释应该可以看出, 这里重新将影子空间的值读取到RCX, RDX, R8, R9中;
    2. 然后将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的不足及解决方案

我们在前面说过,可以在原始函数的调用前后,分别拿到它的参数及返回值。那我们该如何做呢?

首先我们要考虑的是,在原始函数调用前以及调用后,让宿主进程暂停一下,等我们读取完成参数与返回值的值之后再继续执行。最好设计一个通用的机制以便应用到所有情况。

那么我们可以设计两个回调函数,分别在原始函数调用前、后,将函数栈帧地址返回值传递给用户, 为了保证在回调函数过程执行中不破坏这些寄存器我们还需要将其保存起来;

但是这里会遇到另一个问题, 我们如何让原始函数返回到劫持函数中?

在原有的设计中肯定不行, 我们必须要重新设计下劫持代码, 而且为了最小改动最好能兼容现有的代码;

有两种方式可以达到要求:

  1. 在调用原始函数时,需要将劫持函数的返回地址从栈里面拿出来保存, 然后将JMP替换为CAll, 使原始函数返回后直接跳转到劫持函数;
  2. 将原始函数的调用栈拷贝到一个新的栈空间, 然后在这个位置上实现原始函数的调用;

基于某些原因, 以及存储方式的考虑个人采用的第二种方案, 大概实现如下图:

image_02.png

实现代码如下:

; 声明外部函数:
;    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, 还可以根据配置写断点 ^_^

好了文章到这里就结束了, 写的不好请多多指教, 如果有更好的方案也可以提出来让我们共同学习, 提高;