动态调试分析BOF符号解析和重定位
2026-03-14 21:02:23 # 杂谈

本文将结合Visual Studio动态调试过程,重点从内存映射、符号解析和重定位三个方面对COffLoader的工作流程进行详细阐述,这篇文章是对《COffLoader For Windows(BOFLoader)》的补充。

一、COFF文件结构

COFF_FILE_HEADER

1
2
3
4
5
6
7
8
9
10
11
12
#pragma pack(push,1)
typedef struct _COFF_FILE_HEADER
{
UINT16 Machine; // 目标机器架构,例如 x86 / x64
UINT16 NumberOfSections; // 节区数量
UINT32 TimeDateStamp; // 时间戳,记录目标文件生成时间
UINT32 PointerToSymbolTable; // 符号表在文件中的偏移
UINT32 NumberOfSymbols; // 符号表项数量
UINT16 SizeOfOptionalHeader; // 可选头大小;对普通 COFF OBJ 通常为 0
UINT16 Characteristics; // 文件属性标志
} COFF_FILE_HEADER, *PCOFF_FILE_HEADER;
#pragma pack(pop)

COFF_SECTION

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#pragma pack(push,1)
typedef struct _COFF_SECTION
{
CHAR Name[8]; // 节名,例如 .text / .data / .bss
UINT32 VirtualSize; // 节的虚拟大小,OBJ 中通常意义不强
UINT32 VirtualAddress; // 节的虚拟地址,OBJ 中通常未真正映射
UINT32 SizeOfRawData; // 节原始数据大小
UINT32 PointerToRawData; // 节原始数据在文件中的偏移
UINT32 PointerToRelocations; // 重定位表在文件中的偏移
UINT32 PointerToLineNumbers; // 行号表偏移,现代场景中很少使用
UINT16 NumberOfRelocations; // 当前节的重定位项数量
UINT16 NumberOfLinenumbers; // 行号项数量,通常不用
UINT32 Characteristics; // 节属性标志,例如代码/数据/可读/可写```

} COFF_SECTION, *PCOFF_SECTION;
#pragma pack(pop)

COFF_RELOC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#pragma pack(push,1)
typedef struct _COFF_RELOC
{
UINT32 VirtualAddress; // 需要修补的位置,相对于所在节起始地址的偏移
UINT32 SymbolTableIndex; // 关联的符号在符号表中的索引
UINT16 Type; // 重定位类型,例如 REL32 / ADDR64
} COFF_RELOC, *PCOFF_RELOC;
#pragma pack(pop)
COFF_SYMBOL

#pragma pack(push,1)
typedef struct _COFF_SYMBOL
{
union
{
CHAR Name[8]; // 短符号名,长度不超过 8 字节时直接存这里
UINT32 Value[2]; // 长符号名时使用,指向字符串表中的偏移信息
} First;

UINT32 Value; // 符号值;通常表示节内偏移,或 .bss 符号大小等
UINT16 SectionNumber; // 所属节编号;为 0 时通常表示未定义外部符号
UINT16 Type; // 符号类型
UINT8 StorageClass; // 存储类别,例如外部符号、静态符号
UINT8 NumberOfAuxSymbols; // 关联辅助符号数量
} COFF_SYMBOL, *PCOFF_SYMBOL;
#pragma pack(pop)

COFFEE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#pragma pack(push,1)
typedef struct _COFFEE
{
PVOID Data; // 原始 BOF/COFF 文件数据缓冲区
PCOFF_FILE_HEADER Header; // 指向 COFF 文件头
PCOFF_SECTION Section; // 当前正在处理的节表项
PCOFF_RELOC Reloc; // 当前正在处理的重定位项
PCOFF_SYMBOL Symbol; // 指向符号表起始位置
PVOID ImageBase; // BOF 映射到内存后的基址
SIZE_T BofSize; // 映射后的 BOF 总大小
PSECTION_MAP SecMap; // 节区映射表,记录每个节的运行时地址和大小
PULONG_PTR GOT; // GOT 表基址,保存外部函数真实地址
SIZE_T GOTSize; // GOT 表大小
PULONG_PTR BSS; // 自建 .bss 区基址
SIZE_T BSSSize; // .bss 区大小
} COFFEE, *PCOFFEE;
#pragma pack(pop)
1
2
#pragma pack(push,1)
#pragma pack(pop)

表示结构体按1字节对齐,这个可是有大用处的,对于自定义文件结构,按1字节对齐不易出错。

不按1字节对齐,则可能出现下面的错误

本文会使用下面的BOF

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <windows.h>
#include "beacon.h"

int num1;
char* str1;

DECLSPEC_IMPORT int WINAPI USER32$MessageBoxA(
HWND hWnd,
LPCSTR lpText,
LPCSTR lpCaption,
UINT uType
);

#define MessageBoxA USER32$MessageBoxA

void go(char* args, int len)
{
num1 = 123;
str1 = "Hello from BOF2!";
BeaconPrintf(CALLBACK_OUTPUT, "%d %s", num1, str1);

MessageBoxA(
NULL,
str1,
"BOF2",
MB_OK | MB_ICONINFORMATION
);
}

其汇编代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.text$mn:0000000000000000
.text$mn:0000000000000000 mov [rsp+arg_8], edx ; $LN3
.text$mn:0000000000000004 mov [rsp+arg_0], rcx
.text$mn:0000000000000009 sub rsp, 28h
.text$mn:000000000000000D mov cs:num1, 7Bh ; '{'
.text$mn:0000000000000017 lea rax, $SG74380 ; "Hello from BOF2!"
.text$mn:000000000000001E mov cs:str1, rax
.text$mn:0000000000000025 mov r9, cs:str1
.text$mn:000000000000002C mov r8d, cs:num1
.text$mn:0000000000000033 lea rdx, $SG74381 ; "%d %s"
.text$mn:000000000000003A xor ecx, ecx
.text$mn:000000000000003C call cs:__imp_BeaconPrintf
.text$mn:0000000000000042 mov r9d, 40h ; '@'
.text$mn:0000000000000048 lea r8, $SG74382 ; "BOF2"
.text$mn:000000000000004F mov rdx, cs:str1
.text$mn:0000000000000056 xor ecx, ecx
.text$mn:0000000000000058 call cs:__imp_USER32$MessageBoxA
.text$mn:000000000000005E nop
.text$mn:000000000000005F add rsp, 28h
.text$mn:0000000000000063 retn
.text$mn:0000000000000063 go endp
.text$mn:0000000000000063
.text$mn:0000000000000063 ;

⚠注意:只分析x64的情况!

二、内存映射

  1. 首先找到节的地址,此时节还是以磁盘文件形式存在。
1
pCoffee->Section = (PVOID)(((ULONG_PTR)pCoffee->Data) + sizeof(COFF_FILE_HEADER) + (ULONG_PTR)(sizeof(COFF_SECTION) * dwSecCnt));
  1. 将映射后的节的大小和地址存放到 SecMap 里,Size表示这个节当前按原始数据拷贝的大小。Ptr表示这个节被映射到内存后的起始地址

后面所有重定位,都会靠 SecMap把“节号 + 节内偏移”换算成“真实运行时地址”。

1
2
pCoffee->SecMap[dwSecCnt].Size = pCoffee->Section->SizeOfRawData;
pCoffee->SecMap[dwSecCnt].Ptr = pNextBase;
  1. pNextBase指针移动 pCoffee->Section->SizeOfRawData 字节的大小,随后pNextBase按页对齐,页的大小为4096B,即0x1000B。
1
2
((ULONG_PTR)pNextBase) += pCoffee->Section->SizeOfRawData;
((ULONG_PTR)pNextBase) = PAGE_ALLIGN(pNextBase);
  1. 将磁盘文件形式的节数据复制到新的运行时地址。
1
_Memcpy(pCoffee->SecMap[dwSecCnt].Ptr, (PVOID)((ULONG_PTR)pCoffee->Data + pCoffee->Section->PointerToRawData), pCoffee->Section->SizeOfRawData);
  1. 所有节数据复制完后,紧接着放GOTBSS
1
2
pCoffee->GOT = pNextBase;
pCoffee->BSS = (PVOID)((ULONG_PTR)pNextBase + pCoffee->GOTSize);

前面的文章我说过,GOT 的大小是所有函数符号在汇编中出现的次数,而 BSS 的大小则是bss变量符号在汇编中出现的次数。随然会冗余统计大小,但这无伤大雅。

loader没有单独给.bss建section,而是自己在尾部划了一块匿名内存出来当 .bss。

下图是mingw编译的obj文件

下图是msvc编译的obj文件,可以明显看出没有.bss节

下图是Cen4enCen师傅的解释

这里请先记住GOT和BSS的地址,下文将正式开启VS调试,请各位坐好。

三、符号解析

  1. 首先在 CoffeeProcessSection 调式出 pCoffSymbol,因为每一个重定位项都对应一个符号。

  1. 将符号名提取到pSymbolName。如果符号名是短名,则从 pCoffSymbol->First.Name 获取;如果是长名,则去符号字符串表里拿。

提取出的符号名为 num1

  1. 判断符号是外部符号还是本地符号。只有外部符号才需要通过 CoffeeProcessSymbol 解析出函数地址或者在bss中的偏移地址。

本地符号:

  • SectionNumber > 0:表示当前目标文件中的某个节给出定义。其值表示在节映射表 SecMap 的第几个节。请注意 SecMap 是以0为起始索引。

外部符号需要同时满足:

  • StorageClass == IMAGE_SYM_CLASS_EXTERNAL:表示这个符号是“外部符号”。但“外部符号”不一定意味着“未定义”,因为有些外部符号也可能已经在当前obj的某个节里给出定义。
  • SectionNumber == 0x0:这个符号没有定义在当前目标文件的任何节中

  1. 对于函数符号,给出最终函数地址;对于bss变量,则返回它在BSS段中的偏移

终于到CoffeeProcessSymbol了,请注意我的CoffeeProcessSymbol和Cen4enCen师傅的代码是不一样的,毕竟要适配x86。

因为 num1 是bss变量,故 dwImportPrefixSizedwBeaconPrefixSize 都为0。所以会来到下面的这个分支。

因为 pCoffSymbol 是第一次出现,没有被记录,故BssEntry记录这个符号在bss节的偏移pvSysmbolAddr

最后返回 num1 这个bss变量在bss节中的偏移,为4,跳过前面的“哨兵”。

下面看第二次出现 num1 符号时,代码会不再记录已经记录过的 pCoffSymbol

接下来分析函数符号如何解析。函数符号有两类,一类是Beacon API,另一类是Windows API。

首先分析Beacon API,dwImportPrefixSize = 6,即 __imp_ 的长度,表示这是需要导入的外部函数符号。

dwImportPrefixSize = 0xc ,即 __imp_Beacon 的长度,表示这是需要导入的Beacon API。

dwBeaconPrefixSize != 0,进入到这个条件分支。

把BOF中引用的Beacon API名字,经过去前缀和哈希匹配后,映射到当前 Loader自己实现的Beacon API函数地址。你可以把它理解成一个“Beacon 运行时导入表解析器”。

另一种就是 __imp_libraryName$functionName , __imp_functionName

  • 先判断是不是标准BOF导入格式。即存在 $
  • 去掉 __imp_ 前缀,准备拆库名和函数名。
  • 规范化DLL名
  • 先算DLL名哈希
  • 如果是标准格式,就先找模块,再找导出函数
  • 处理函数名
  • 构造 ANSI_STRING,调LdrGetProcedureAddress解析真实函数地址
  • 不是标准模式,则从LdrApi里找。

代码太多了,略过一些步骤,看最终的返回结果。

非标准模式

四、重定位

对于 num1 这个bss变量,重定位的时候,会来到这段代码

BSS节的地址是 0x00007ffcebb77010,通过 CoffeeProcessSection 得出 num1 在BSS节内偏移0x4的位置。

ulBssAddr = 0x00007ffcebb77010 + 4 = 0x00007ffcebb77014num1 这个bss变量的地址。

修正的目的是得出重定位字段地址与bss变量地址之间的差值。

公式:

1
Offset = Target - NextIP - extra

对于 num1 这个变量

Target = ulBssAddr = 0x00007ffcebb77014

NextIP = pvRelocAddr + 4。pvRelocAddr 是重定位字段的地址,具体看下图

重定位字段的地址 pvRelocAddr = 0x00007ffcebb7200f

这个汇编指令看着是不是很眼熟呢?其实就是BOF文件的text节里的指令。

Type = IMAGE_REL_AMD64_REL32_4,extra = 8 - 4 = 4

我们手工计算一下偏移:

1
2
3
Offset = Target - NextIP - extra
Offset = 0x00007ffcebb77014 - (0x00007ffcebb7200f + 4) - (8 - 4)
Offset = 0x4FFD

代码计算出来的偏移

最后看重定位字段有没有被修正

外部函数符号的重定位和BSS变量的重定位是一样的分析过程,在此略过,本篇文章结束。

本篇文章是对上一篇文章的补充扩展,如果有不懂或者本文哪里有解释错误地方,可以和我探讨!

Prev
2026-03-14 21:02:23 # 杂谈