3-回答先知上的问题:影子空间有必要吗?

1、问题

佬,我调试的时候发现这里的sub rsp,20h是不是没有必要啊,jmp rax进api后,api会自己sub和add,也没有保存这四个寄存器的值

然后后面也没有再add rsp,20h回来,我跟完一个GetProcAddressByHash后,调用前和调用后的堆栈是不相等的我汇编不是很熟,可能理解的不对😣

以上是先知社区上 Windows Shellcode开发(x64 stager)-先知社区某位师傅的疑问,可能我我文章写的不够明白,所以有必要在这里解释一下。

2、解释

为什么要预留20h(32)字节的影子空间?

这里说一下有歧义的点一般来说影子空间是预留存放API的4个64 位寄存器参数寄存器RCX、RDX、R8、R9 ,调用方分配了32字节的影子空间,被调用方一定要用吗?其实“一般”这个修饰词就暗示了影子空间的作用,也就是被调用方可用可不用

我查阅了网上很多资料,最权威的解释肯定是微软官方文档x64 调用约定 | Microsoft Learn

ok,我们提取上面两个图对于影子空间的解释:调用者必须在CALL前给足32B影子空间;被调者把它当成自己的临时草稿纸,可随意乱写,用完不必还原,调用者最后统一收拾。

我就以MessageBoxA举例

#include <windows.h>

int main(void)
{
    MessageBoxA(
        NULL,                       /* 父窗口句柄,无父窗口填 NULL */
        "Hello, Win32 MessageBoxA!",/* 提示文本 */
        "Demo",                     /* 标题栏文本 */
        MB_OK | MB_ICONINFORMATION  /* 按钮与图标风格 */
    );
    return 0;
}

下断点,如下图。

我们继续跟进 MessageBoxA 的内部,来到跟你给的图差不多相同的汇编代码,这里的 MessageBoxA 的实现内部,MessageBoxA 其实也不是最终实现,它最后还会调用 MessageBoxTimeoutA。可以看到 MessageBoxA 并未使用调用方提供的影子空间,而是自己又分配了38h字节的栈空间,这个栈空间的作用看下文。

我们主要看与栈相关的寄存器,也就是RSP,可以看到汇编代码中是有

①sub         rsp,38h 
……
②or          dword ptr [rsp+28h],0FFFFFFFFh 
……
③word ptr [rsp+20h],r11w 
……
④add         rsp,38h

sub rsp,38h :我们来看看MessageBoxTimeoutA的函数声明,就能明白为什么会是分配38h字节的栈空间了。

	int WINAPI MessageBoxTimeoutA(
	IN HWND hWnd, 
	IN LPCSTR lpText, 
	IN LPCSTR lpCaption, 
	IN UINT uType, 
	IN WORD wLanguageId, 
	IN DWORD dwMilliseconds);

其中前4个参数就是MessageBoxA的4个参数,而后面是第5个参数是wLanguageId,第6个参数是dwMilliseconds。按照前面对影子空间的解释,我们需要给前4个参数预留20h字节的影子空间;按照windows x64调用约定,后面的参数要存放在栈上,故分配38h=20h(影子空间)+10h(存放后续参数)+8(对齐用的),就如下图所示。有两种不同的RSP视角,一种是以原本RSP,比如说rsp-8,另一种是分配栈空间的RSP,比如说rsp+8。我假设 MessageBoxTimeoutA 的内部需要将rcx、rdx、r8、r9的值转储到影子空间,得到下图栈分配图。

⚠注意:为什么要分配8字节用于对齐,这主要是Windows x64调用约定的,即在调用(call)之前,rsp必须保证位16字节对齐,即以0结尾,这是为了使返回地址存放在地址以8结尾的栈区域,这个我在非PEB获取ntdll和kernel32模块基址的精妙之道-先知社区说过。

or dword ptr [rsp+28h],0FFFFFFFFh:就是将第6个参数存放到栈上

word ptr [rsp+20h],r11w :将第5个参数存放到栈上

add rsp,38h:按照约定Windows x64调用约定,谁分配了栈空间谁来清理,保证调用前后 rsp 一致,之前rsp-38h,函数返回前rsp+38h,保持rsp平衡。

我们继续跟进到 MessageBoxTimeoutA 的内部,请看下图,这是进入到内部后的初始栈空间布局。

观察涉及栈的操作,MessageBoxTimeoutA 并未将影子空间存储4个参数寄存器,而是用来存储rbx、rbp、rsi这三个非易失性寄存器。

可以看到 mov rax,rsp,这意味着rax就是rsp指针

00007FFF0424ADC0 48 8B C4             mov         rax,rsp  
00007FFF0424ADC3 48 89 58 08          mov         qword ptr [rax+8],rbx  
00007FFF0424ADC7 48 89 68 18          mov         qword ptr [rax+18h],rbp  
00007FFF0424ADCB 48 89 70 20          mov         qword ptr [rax+20h],rsi  
00007FFF0424ADCF 57                   push        rdi  
00007FFF0424ADD0 41 54                push        r12  
00007FFF0424ADD2 41 56                push        r14  
00007FFF0424ADD4 48 83 EC 40          sub         rsp,40h

我们截取关键的,继续看下面的汇编代码,这个代码主要是存放非易失性寄存器,这没问题。

00007FFF0424ADC3 48 89 58 08          mov         qword ptr [rax+8],rbx  
00007FFF0424ADC7 48 89 68 18          mov         qword ptr [rax+18h],rbp  
00007FFF0424ADCB 48 89 70 20          mov         qword ptr [rax+20h],rsi  

这里就很好的回答了我上文提到的:影子空间不是“必须”放参数,而是“预留”,被调函数可存可不用,随意怎么用,但始终存在

这么做的前提是:RCX、RDX、R8、R9存放的值不能丢失,无论你是存放在自己开辟的栈空间还是存放在其他寄存器上,参数值是一定不能丢失的

验证一下rcx为 0000000000000000 这没问题,rdx存放"Hello, Win32 MessageBoxA!"字符串的地址

r8存放"Demo"的地址,r9的值为0000000000000040

我们再以其他API函数来举例:WSAStartup

WSASocketA 调用前

调用后,进入内部。

又举例个例子,我不分配影子空间会怎么样,使用 Linux Shellcode开发(Stager & Reverse Shell)-先知社区提供的windows 反弹shell的代码。修改 GetProcAddressByHash 的一处代码。不分配20h字节的影子空间。

3.调用WSAStartup函数 下断点,如下图

上一步成功断下来后,在 GetProcAddressByHash 的下面断一个断点

停在这个断点后,步进,进入到 WSAStartup 内部,如下

由于我们只分配了10h字节的影子空间,如果要使用rsp+8,rsp+10h的栈空间,这没问题,可是rsp+18h,rsp+20h我并为分配给被调用函数,所以调用者的栈上的值被覆盖,比如说覆盖返回地址,覆盖某个变量等等,最终导致程序出错。

我举的这个例子并没有报错,而是不能成功反弹shell。如果各位师傅还想尝试,可以不分配影子空间,这样程序会运行后出错,我就不举例说明了。

这一切的一切都源于 x64 调用约定 | Microsoft Learn,请不懂的师傅查阅相关文档。这是一篇意识流的解答性文章,如果还不懂也可以跟我交流一下。