在安全产品(比如反病毒、反外挂)的开发中,由于版本更新周期较长,通常需要一种动态代码执行机制来灵活扩展产品的检测能力,来应对快速变化的对抗情况。常见的做法是集成Lua或Python脚本语言的引擎来执行后台下发的动态脚本,其优点是:有成熟稳定的引擎代码方便集成到产品中,而且脚本语言编写逻辑代码相对容易;但也有其缺点:脚本语言与native代码交互比较麻烦,通常会涉及繁琐的类型转换或封装操作,同时脚本的解释执行也会引入一些性能开销。
而在对抗场景中,动态代码通常会涉及较多的native操作,因此如果能直接使用C/C++编写动态代码,不仅可以使脚本代码更简单直观,还能获得更好的运行时性能。CobaltStrike中就支持C编写的动态代码,它可以在运行时加载和执行BOF(Beacon Object File)文件[1],即C语言编译后的目标文件(即.o/.obj文件)。
我们接下来就介绍如何实现一款类似的.obj文件加载器,因为.obj文件是COFF格式(即Common Object File Format,一种用于存储可重定位目标代码和数据的二进制文件格式)的,我们称这个加载器为CoffLoader。
一、COFF文件布局
要实现一个CoffLoader,首先要了解COFF文件格式。COFF文件的总体布局如下:
Coff文件布局说明:
- 文件头:包含机器类型、节区数量等基本信息
- 节表:描述每个节区的位置和属性
- 节区数据:实际的代码和数据内容
- 重定位表:记录需要修正的地址信息
- 符号表:包含所有符号的信息
- 字符串表:存储符号的长名称
1. 文件头(File Header)
文件头位于 COFF 文件的最开始,包含机器类型、节区数量、符号表偏移等基本信息,和PE中的文件头结构一样。
struct CoffFileHeader {
uint16_t machine; // 机器类型(如 x86、x64)
uint16_t number_of_sections; // 节区数量
uint32_t time_date_stamp; // 时间戳
uint32_t pointer_to_symbol_table; // 符号表偏移
uint32_t number_of_symbols; // 符号数量
uint16_t size_of_optional_header; // 可选头大小,0,忽略
uint16_t characteristics; // 文件特征
};
machine
: 指明文件对应的机器类型[2],Windows中主要是:- x86: 0x14c, IMAGE_FILE_MACHINE_I386
- x64: 0x8664, IMAGE_FILE_MACHINE_AMD64
number_of_sections
:节区数量time_date_stamp
:文件生成时的时间戳pointer_to_symbol_table
和number_of_symbols
: 符号表的位置(相对文件起始的偏移)和符号个数characteristics
:文件属性[3]
2. 节表(Section Header Table)
节表紧跟在文件头之后,包含多个CoffSectionHeader
结构,每个CoffSectionHeader
描述一个节区的信息。
struct CoffSectionHeader {
char name[8]; // 节区名称
uint32_t virtual_size; // 虚拟内存大小,0,忽略
uint32_t virtual_address; // 虚拟地址,0,忽略
uint32_t size_of_raw_data; // 节区数据的大小
uint32_t pointer_to_raw_data; // 节区数据的偏移
uint32_t pointer_to_relocations; // 该节区的重定位表的偏移
uint32_t pointer_to_line_numbers; // 行号表偏移,忽略,已废弃
uint16_t number_of_relocations; // 重定位项数量
uint16_t number_of_line_numbers; // 行号项数量,忽略,已废弃
uint32_t characteristics; // 节区特征
};
size_of_raw_data
和pointer_to_raw_data
: 节的数据的大小和起始位置(相对文件开始的偏移)pointer_to_relocations
和number_of_relocations
: 节中包含的重定位项的起始位置(相对文件开始的偏移)和个数characteristics
: 节映射到内存后的内存属性,比如代码节有可执行属性,数据节有读写属性等
3. 节区数据(Section Data)
节区数据包含实际的代码和数据内容,通过CoffSectionHeader中pointer_to_raw_data
和 size_of_raw_data
定位到节区的位置和大小,常见的节区包括:
.text
:可执行代码.data
:已初始化的数据.rdata
:只读数据.bss
:未初始化数据
4. 重定位表(Relocation Table)
重定位表记录需要在加载时修正的地址信息,重定位源于代码中对内部或外部符号(如全局变量、函数等)的引用,COFF文件在编译时并不知道这些符号最终加载的地址,所以需要重定位操作来修正代码中对这些符号的引用。
重定位表是被节表引用的,通过CoffSectionHeader
的pointer_to_relocations
和 number_of_relocations
确定该节区的重定位表的起始位置和重定位项的个数。
struct CoffReloc {
uint32_t virtual_address; // 需要重定位的地址
uint32_t symbol_table_index; // 符号表索引
uint16_t type; // 重定位类型
};
virtual_address
: 需要重定位的地址,相对于对应节区起始位置的偏移symbol_table_index
: 符号表索引,从0开始type
: 重定位类型,不同的类型处理方式不一样
常见重定位类型如下:
-
x86平台[4]:
IMAGE_REL_I386_ABSOLUTE
:不需要重定位IMAGE_REL_I386_DIR32
:符号的32位绝对地址IMAGE_REL_I386_DIR32NB
:符号地址相对于基址的32位偏移IMAGE_REL_I386_REL32
:符号地址相对于下一条指令的32位偏移
-
x64平台[5]:
IMAGE_REL_AMD64_ABSOLUTE
:不需要重定位IMAGE_REL_AMD64_ADDR64
:符号的64位绝对地址IMAGE_REL_AMD64_ADDR32
:符号的32位绝对地址IMAGE_REL_AMD64_ADDR32NB
:符号地址相对于基址的32位偏移IMAGE_REL_AMD64_REL32
:符号地址相对于下一条指令的32位偏移IMAGE_REL_AMD64_REL32_1
~IMAGE_REL_AMD64_REL32_5
:符号地址相对于下一条指令的32位偏移的基础上,再加上1~5字节的偏移
具体处理参见后面的重定位代码。
5. 符号表(Symbol Table)
符号表符号表包含所有符号的定义和引用信息,被文件头和重定位表引用,每个符号表项包含以下信息:
struct CoffSymbol {
union {
char name[8]; // 短名称(8字节以内)
uint32_t value[2]; // 长名称偏移(value[0]=0时,value[1]是字符串表中的偏移)
} first;
uint32_t value; // 符号值(节区内的偏移)
uint16_t section_number; // 节区号(0表示外部符号)
uint16_t type; // 符号类型
uint8_t storage_class; // 存储类
uint8_t number_of_aux_symbols; // 辅助符号数量
};
-
name/value[2]
,符号名:- 如果名称长度 ≤ 8 字节,直接存储在 name 字段
- 如果名称长度 > 8 0,value[0] = 0,value[1] 指向字符串表中的偏移,相对字符串表起始
-
value
: 不同的情况有不同的意义,见后面storage_class
的介绍 -
section_number
,符号所在节区号:- 0:表示外部符号,或无节区的符号,比如未初始化的全局变量
- -1:表示绝对符号
- -2:表示调试符号
- ≥1:表示符号所在的节区号(注意节区号是从1开始,转换成节表中的索引需要减1)
-
type
,指明符号的类型信息,但微软工具中只使用0x00(IMAGE_SYM_DTYPE_NULL
,非函数)和0x20(IMAGE_SYM_DTYPE_FUNCTION
,函数)两个值用于区分符号是否是函数[6] -
storage_class
,符号表示的定义类型,windows中常用的有四类:-
IMAGE_SYM_CLASS_EXTERNAL
:2,外部符号,包括本模块内但属于其他区块的和其他模块-
符号在本模块内,但属于其他节区,比如模块内函数、全局变量:
section_number > 0, value >= 0
,value
字段表示符号在节区内的偏移 -
符号在本模块内,但没有所属节区,比如未初始化的全局变量:
section_number == 0,value > 0
-
符号在外部模块,比如调用的API:
section_number == 0, value == 0
-
-
IMAGE_SYM_CLASS_STATIC
:3,静态符号,比如源码中的字符串字面量等,value
字段表示符号在对应节区内的偏移。msvc的编译工具cl.exe
还会为每个节名产生一个符号,name
为节名,value
值为0,section_number
为对应节区号。这会导致一个问题,节名符号与其他静态符号的指向可能会产生冲突,比如下图中的.data
节符号和$SG99522
字符串字面值符号,都是static
类型,value
和section_number
的值也都一样,如果都按照节区+偏移的方式解析符号,则节符号会解析错误。不过好在写loader时可以不关注节符号,因为我们只关心和重定位那些在代码中被引用的符号,而节名符号没有引用,可以不关注。如果非要区分二者,可以通过辅助符号,cl
会为每个节符号生成一个辅助符号。 -
IMAGE_SYM_CLASS_FUNCTION
:0x65,函数符号,用的不多,通过type
字段区分函数和其他符号 -
IMAGE_SYM_CLASS_FILE
:0x67,文件名符号 -
其他值:表示不同的存储类型
-
-
number_of_aux_symbols
,辅助符号的数量:某些符号类型需要额外的信息,辅助符号紧跟在主符号之后,辅助信息的大小与CoffSymbol大小一致,但内容的解析对不同主符号是不同的。目前看只有节符号后面有辅助符号,编写loader时可以先不考虑辅助符号。
6. 字符串表(String Table)
字符串表存储符号表中用到的长字符串名称(函数名、全局变量名等),用于存储超过8个字节的符号名。字符串表的位置没有直接存到CoffFileHeader
中,不能直接定位到其位置,但字符串表是紧跟在符号表之后,所以可以通过下面的公式计算出其相对文件起始的偏移:
string_table_offset = file_header->pointer_to_symbol_table + file_header->number_of_symbols * sizeof(CoffSymbol);
字符串表开始的4字节是表的大小,大小包括开始的这4个字节,后面就是字符串顺序排列了,每个字符串以0结尾。
二、CoffLoader的实现细节
了解了COFF文件的格式,加载过程就比较直观了,主要包括以下几个步骤:
1. 解析 COFF 文件结构
CoffLoader首先读取并解析COFF文件头,然后计算得到节表、符号表、字符串表的地址。
2. 计算并申请加载COFF所需的内存空间
所需的内存空间用于存放各个节区的内容,以及导入函数的地址表(类似PE中的IAT)。有些节区仅在静态链接的时候会用到,不需要加载到内存,可以根据CoffSectionHeader.characteristics
将它们过滤掉。
加载后的各个节区不要求是连续的,所以可以单独为各个节区申请内存空间,但这样可能会导致节区间的距离相距较远,如果超过0xffffffff,COFF内部符号的重定位就会出现问题,在x64下需要注意这个问题。
所以,我们选择为COFF分配一整块可读可写可执行的内存,然后将节区内容逐个写入到该内存中对应位置。严谨点的话,需要将各个节区的大小按页面大小进行对齐,并根据CoffSectionHeader.characteristics
为各个节区分配不同的内存属性,我们这里偷懒一点,就不去修改内存属性了。
bool CoffLoader::isSectionNeedMap(CoffSectionHeader §ion) {
// 有这三个属性的节区可以不加载
const uint32_t kDiscarded = IMAGE_SCN_LNK_INFO | IMAGE_SCN_LNK_REMOVE | IMAGE_SCN_MEM_DISCARDABLE;
return (section.characteristics & kDiscarded) == 0;
}
uint32_t CoffLoader::mapSections() {
// 计算加载所需空间大小,包括节区数据和函数地址表
size_t image_size = 0;
size_t reloc_count = 0;
for (uint16_t i = 0; i < file_header_->number_of_sections; ++i) {
CoffSectionHeader §ion = section_table_[i];
if (isSectionNeedMap(section)) {
image_size += section.size_of_raw_data;
image_size = align(image_size, 0x10); // 对齐节,可选操作
reloc_count += section.number_of_relocations;
}
}
image_size += reloc_count * sizeof(void *); // 为函数地址表预留空间
// 申请一块可读可写可执行的内存
uint8_t *image_base = (uint8_t *)VirtualAlloc(nullptr, image_size, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (image_base == nullptr) {
return kErrAllocCoffImageFailed;
}
memset(image_base, 0xCC, image_size); // 初始化为0xCC
...
}
上面代码为节区做了0x10的对齐,这是可选的。对齐操作,以及后面将内存初始化为0xCC,只是为了方便人工查看加载后的汇编代码和数据。
注意:未初始化的全局变量,在COFF文件中是没有对应的节区的,需要加载时进行分配,所以上述计算得到的image_size
不包含它们,如果我们在编写C源文件时保证不定义未初始化的全局变量,则使用上述代码计算image_size
即可。如果想要兼容未初始化的全局变量,需要遍历符号表,找section_number == 0,value > 0
(见上面对CoffSymbol.storage_class
的介绍)的符号,累加value
值获取变量所需的总大小,然后加到上面的image_size
中。
3. 拷贝节区内容到内存
内存分配好后,接着就是逐个将节区内容拷贝到内存中,这里要记录下每个节区的起始地址,后面重定位时会用到。
uint32_t CoffLoader::mapSections() {
// 计算加载所需空间大小,包括节区数据和函数地址表
...
section_addresses_.resize(file_header_->number_of_sections, nullptr);
// 拷贝节区数据
size_t rva = 0;
for (uint16_t i = 0; i < file_header_->number_of_sections; ++i) {
CoffSectionHeader §ion = section_table_[i];
if (isSectionNeedMap(section)) {
LOG("section %d: name=%s, addr=%p, size=%x\n", i, getSectionName(i).c_str(), image_base + rva,
section.pointer_to_raw_data);
if (section.pointer_to_raw_data > 0) {
memcpy(image_base + rva, raw_data_ + section.pointer_to_raw_data, section.size_of_raw_data);
} else { // pointer_to_raw_data == 0, .bss节, 空字符串""会被编译到.bss节
memset(image_base + rva, 0, section.size_of_raw_data);
}
section_addresses_[i] = image_base + rva; // 记录下每个节区的起始地址
rva += section.size_of_raw_data;
rva = align(rva, 0x10);
} else {
LOG("section %d: name=%s, discarded\n", i, getSectionName(i).c_str());
}
}
image_base_ = image_base;
image_size_ = image_size;
api_table_ = (void **)(image_base_ + rva);
LOG("image: base=%p, size=%zx, api_table=%p\n", image_base_, image_size_, api_table_);
return kErrSuccess;
}
注意:上面有个特殊处理,当section.pointer_to_raw_data == 0
时,说明这个节的内容是空的,但是它的size_of_raw_data
是大于0的,目前看到的例子就是.bss
节,C源码中的空字符串""
,会被编译到.bss
节,这时候要把对应的内容清0(因为上面申请空间的时候是初始化成了0xCC)。
4. 处理重定位信息
重定位场景
哪些场景需要重定位:
- 模块内符号引用:被引用的符号在COFF文件内
- 函数调用:编译成
E8 xxxxxxxx
,相对偏移进行调用,重定位方式为REL32
- 全局变量读写:使用变量地址,重定位方式x86为
I386_DIR32
(比如a1 xxxxxxxx mov eax, dword [_b]
),x64为AMD64_REL32
(比如8B05 xxxxxxxx mov eax,dword [b]
)
- 函数调用:编译成
- 模块外符号引用,看声明方式:被引用的符号在COFF文件外,比如其他COFF文件或PE文件
- 使用
__declspec(dllimport)
声明导入函数,说明是以导入函数的形式调用,编译后函数名称会被修饰成以__imp__
(x86)或__imp_
(x64)开头,调用函数的指令为FF15 xxxxxxxx
,重定位方式x86为I386_DIR32
,x64为AMD64_REL32
,都是通过函数地址表(IAT)间接调用 - 非导入函数,编译成
E8 xxxxxxxx
,相对偏移进行调用,重定位方式为REL32
【但我们应该避免使用这种,会造成重定位麻烦】
- 使用
重定位过程有两个重要环节:先获取符号地址,然后将符号地址按重定位类型修正到重定位位置。
符号地址获取
模块内符号:符号地址 = 节区起始地址 + 节区内偏移
模块外符号:这里是指以导入函数形式引用的外部函数,通过LoadLibrary + GetProcAddress
动态获取地址,所以导入函数名需要指明所属dll,在C源码中可以通过dll_name$api_name
的方式声明导入函数,比如:
DECLSPEC_IMPORT HANDLE WINAPI KERNEL32$CreateFileA(....);
编译后对应的符号是__imp__KERNEL32$CreateFileA@28
(x86)或__imp_KERNEL32$CreateFileA
(x64),这样就可以从符号名中分离出dll名和函数名,然后动态解析函数地址。
注意:导入函数是间接通过地址表(类似PE的IAT)间接调用的,所以函数地址要写入到地址表中,最终返回的符号地址是地址表中对应项的地址。
void *CoffLoader::getSymbolAddress(uint32_t sym_index) {
if (sym_index >= file_header_->number_of_symbols) {
LOG("error: symbol index %d out of range %d\n", sym_index, file_header_->number_of_symbols);
return nullptr;
}
void *sym_addr = symbol_to_address_[sym_index];
if (sym_addr != nullptr) {
LOG("symbol %d: parsed, addr=%p\n", sym_index, sym_addr);
return sym_addr;
}
CoffSymbol &symbol = symbol_table_[sym_index];
char short_name[sizeof(symbol.first.name) + 1] = "";
char *sym_name = nullptr;
if (symbol.first.value[0] != 0) {
memcpy(short_name, symbol.first.name, sizeof(symbol.first.name));
sym_name = short_name;
} else {
sym_name = string_table_ + symbol.first.value[1];
}
if (symbol.section_number == 0) { // COFF外符号,这里不支持未初始化的全局变量
if (isImportedSymbol(sym_name)) {
void *addr = getImportedAddress(sym_name); // LoadLibrary + GetProcAddress
sym_addr = insertToApiTable(addr); // 导入函数返回的是地址表中的地址
}
} else { // COFF内符号
uint16_t section_index = symbol.section_number - 1;
sym_addr = section_addresses_[section_index] + symbol.value;
}
if (sym_addr != nullptr) {
symbol_to_address_[sym_index] = sym_addr;
}
LOG("symbol %d: name=%s, addr=%p\n", sym_index, sym_name, sym_addr);
return sym_addr;
}
// 将地址插入API地址表中,返回插入的位置的地址
void *CoffLoader::insertToApiTable(void *sym_addr) {
if (sym_addr == nullptr) {
return nullptr;
}
void *addr = (void *)&api_table_[api_count_]; // 函数地址表中的对应地址
api_table_[api_count_] = sym_addr;
++api_count_;
return addr;
}
指令修正
地址修正过程需要根据重定位的类型分别进行处理,上面介绍重定位表时已经介绍过各种常见的重定位类型,它们的具体修正方式见下面的代码,对于一些不熟悉的重定位类型,可以报错不处理,或者参考其他项目中的实现,比如Ghidra,Ghidra也支持加载COFF文件,一定也有重定位处理[7][8]。
uint32_t CoffLoader::relocInstr(uint8_t *reloc_addr, uint16_t reloc_type, uint8_t *sym_addr) {
#ifdef _WIN64
// x64
if (reloc_type == IMAGE_REL_AMD64_ABSOLUTE) {
return kErrSuccess;
}
if (reloc_type == IMAGE_REL_AMD64_ADDR64) {
*(uint64_t *)reloc_addr += (uint64_t)sym_addr;
} else if (reloc_type == IMAGE_REL_AMD64_ADDR32) {
uint8_t *dst = sym_addr + *(uint32_t *)reloc_addr;
*(uint32_t *)reloc_addr = (uint32_t)(uintptr_t)dst;
} else if (reloc_type == IMAGE_REL_AMD64_ADDR32NB) {
*(uint32_t *)reloc_addr += (uint32_t)(sym_addr - image_base_);
} else if (reloc_type == IMAGE_REL_AMD64_REL32) {
*(uint32_t *)reloc_addr += (uint32_t)(sym_addr - (reloc_addr + 4));
} else if (reloc_type == IMAGE_REL_AMD64_REL32_1) {
*(uint32_t *)reloc_addr += (uint32_t)(sym_addr - (reloc_addr + 4 + 1));
} else if (reloc_type == IMAGE_REL_AMD64_REL32_2) {
*(uint32_t *)reloc_addr += (uint32_t)(sym_addr - (reloc_addr + 4 + 2));
} else if (reloc_type == IMAGE_REL_AMD64_REL32_3) {
*(uint32_t *)reloc_addr += (uint32_t)(sym_addr - (reloc_addr + 4 + 3));
} else if (reloc_type == IMAGE_REL_AMD64_REL32_4) {
*(uint32_t *)reloc_addr += (uint32_t)(sym_addr - (reloc_addr + 4 + 4));
} else if (reloc_type == IMAGE_REL_AMD64_REL32_5) {
*(uint32_t *)reloc_addr += (uint32_t)(sym_addr - (reloc_addr + 4 + 5));
} else {
return kErrUnSupportedRelocationType;
}
#else
// x86
if (reloc.type == IMAGE_REL_I386_ABSOLUTE) {
return kErrSuccess;
}
if (reloc_type == IMAGE_REL_I386_DIR32) {
*(uint32_t *)reloc_addr += (uint32_t)sym_addr;
} else if (reloc_type == IMAGE_REL_I386_DIR32NB) {
*(uint32_t *)reloc_addr += (uint32_t)(sym_addr - image_base_);
} else if (reloc_type == IMAGE_REL_I386_REL32) {
*(uint32_t *)reloc_addr += (uint32_t)(sym_addr - (reloc_addr + 4));
} else {
return kErrUnSupportedRelocationType;
}
#endif // _WIN64
return kErrSuccess;
}
合起来,整个重定位的代码如下:遍历每个节,找到其重定位表,然后逐个处理每个重定位项,获取重定位项对应的符号地址,然后按重定位类型修正对应指令。
uint32_t CoffLoader::relocSymbols() {
for (uint16_t i = 0; i < file_header_->number_of_sections; ++i) {
uint8_t *sec_addr = section_addresses_[i];
if (section_addresses_[i] == nullptr) {
continue; // 未加载的节
}
CoffSectionHeader §ion = section_table_[i];
CoffReloc *reloc_list = (CoffReloc *)(raw_data_ + section.pointer_to_relocations);
for (uint32_t j = 0; j < section.number_of_relocations; ++j) {
CoffReloc &reloc = reloc_list[j];
uint8_t *reloc_addr = sec_addr + reloc.virtual_address;
// 获取符号地址
uint8_t *sym_addr = (uint8_t *)getSymbolAddress(reloc.symbol_bable_index);
if (sym_addr == nullptr) {
return kErrGetSymbolAddressFailed;
}
// 修正指令
uint32_t err = relocInstr(reloc_addr, reloc.type, sym_addr);
if (err != kErrSuccess) {
return err;
}
}
}
return kErrSuccess;
}
5. 执行入口点
上述步骤完成后,COFF文件加载过程就结束了,如果想要执行COFF中的代码,需要约定一个入口函数,然后通过符号表找到该入口点函数的地址,然后调用它,COFF文件中的代码就像普通程序一样在当前进程中运行了。
// 获取入口函数 `run` 的地址
void *CoffLoader::getEntryAddress() {
for (uint32_t i = 0; i < file_header_->number_of_symbols; ++i) {
CoffSymbol &symbol = symbol_table_[i];
#ifdef _WIN64
if (symbol.first.value[0] == '\0nur' && symbol.section_number > 0) { // x64入口函数的符号名"run"
#else
if (symbol.first.value[0] == 'nur_' && symbol.section_number > 0) { // x86入口函数的符号名"_run"
#endif
return section_addresses_[symbol.section_number - 1] + symbol.value;
}
}
return nullptr;
}
// 执行入口函数
uint32_t CoffLoader::run() {
typedef void (*PEntryFunction)();
PEntryFunction entry = (PEntryFunction)getEntryAddress();
if (entry == nullptr) {
return kErrNoEntryFunction;
}
entry();
return kErrSuccess;
}
三、扩展功能与注意事项
1. 扩展函数
我们可以将一些常用函数注册到loader中,作为对loader功能的扩展,供C代码调用。在加载时需要将COFF文件和这些函数进行链接,保证调用正常。对于COFF文件来说,调用扩展函数属于模块外引用,有两种使用方式:
- 非导入调用:函数调用被编译成相对地址调用
E8 xxxxxxxx
,x86下OK,但是x64下不行,因为函数地址和重定位地址的距离会超过32位,无法通过32位偏移调用 - 导入调用:函数调用被编译成间接通过地址表调用
FF15 xxxxxxxx
,和系统API调用方式一样,可兼容x86、x64
所以我们选择以导入调用的方式去声明和处理扩展函数,为了让loader能够区分扩展函数和系统API,源码中声明扩展函数时可以不加dll名,或者指定一个特殊的dll名:
// C源码中声明扩展函数和系统函数示例:
// 扩展函数声明不带dll名,`MyFunction`编译成 `__imp__MyFunction`(x86) 或 `__imp_MyFunction`(x64)
DECLSPEC_IMPORT void MyFunction(uint32_t a, uint32_t b, char *c);
// 系统函数带dll名,`KERNEL32$CreateFileA`编译成 `__imp__KERNEL32$CreateFileA@28`(x86) 或 `__imp_KERNEL32$CreateFileA`(x64)
DECLSPEC_IMPORT HANDLE WINAPI KERNEL32$CreateFileA(....);
loader只需要在上述重定位操作获取导入函数的符号地址时,兼容下扩展函数即可,可以判断__imp_
符号中有无$
来区分扩展函数和系统函数,或者通过函数名的hash进行匹配。
// 注册扩展函数
void CoffLoader::registerFunction(const char *name, void *addr) {
uint32_t hash = sshCrc32(name, strlen(name));
registered_functions_[hash] = addr; // 使用map保存扩展函数的名称和地址的对应关系: 函数名crc => 函数地址
}
// 获取导入函数的地址
void *CoffLoader::getImportedAddress(const char *sym_name) {
// map中能找到符号名hash,说明是扩展函数,直接返回注册的函数地址
uint32_t sym_name_hash = sshCrc32(sym_name, strlen(sym_name));
void *sym_addr = registered_functions_[sym_name_hash];
if (sym_addr != nullptr) {
return sym_addr;
}
// 否则按系统函数处理,切分dll名和函数名,动态解析函数地址
char dll_name[100] = "";
char api_name[100] = "";
size_t offset = IMP_SYMBOL_PREFIX_LEN;
const char *splitter = strchr(sym_name + offset, '$');
if (splitter == nullptr) {
return nullptr; // invalid imp symbol
}
size_t len = splitter - sym_name - offset;
memcpy(dll_name, sym_name + offset, len);
*(uint32_t *)(dll_name + len) = 'lld.'; // .dll
const char *splitter2 = strchr(splitter + 1, '@');
if (splitter2 != nullptr) {
len = splitter2 - splitter - 1;
} else {
len = strlen(splitter + 1);
}
memcpy(api_name, splitter + 1, len);
HMODULE hmod = GetModuleHandleA(dll_name);
if (hmod == nullptr) {
hmod = LoadLibraryA(dll_name);
if (hmod == nullptr) {
return nullptr;
}
}
return GetProcAddress(hmod, api_name);
}
2. COFF文件保护
COFF文件中有一些敏感的内容容易泄露文件的功能,在一些场景中(比如反外挂场景中下发COFF文件检测外挂),我们不想COFF文件的功能被轻易分析,需要对COFF文件进行一些保护:
- 混淆代码:比如使用ollvm混淆代码,提升分析难度
- 抹除非必要节:不需要加载到内存中的节,可以直接抹除文件中的对应内容,比如
.drectve
和.debug$S
节// 清除`.drectve`和`.debug$S`节的内容 for (uint16_t i = 0; i < file_header->number_of_sections; ++i) { CoffSectionHeader §ion = section_table[i]; if (memcmp(section.name, ".drectve", 8) == 0 || memcmp(section.name, ".debug$S", 8) == 0) { memset(&file_data[section.pointer_to_raw_data], 0, section.size_of_raw_data); } memset(section.name, 0, sizeof(section.name)); }
- 符号名混淆:变量名、函数名
- COFF内符号:只有COFF内部的引用,不涉及外部链接操作,符号名不必须,根据符号索引可以定位到符号位置,所以可以清除符号名,也可以使用符号名hash替换
- COFF外符号中注册的扩展函数:主要是用来从注册的扩展函数列表中查找对应函数的地址,但不一定使用函数名查找,可以使用函数名hash去查找
- COFF外符号中导入的系统函数:需要动态加载dll和获取API地址,所以需要保留符号名,但可以进行加密,在加载dll前进行解密
// 符号名保护 for (uint32_t i = 0; i < file_header->number_of_symbols; ++i) { CoffSymbol &symbol = symbol_table[i]; char *name = nullptr; size_t name_len = 0; if (symbol.first.value[0] != 0) { name = symbol.first.name; while (name_len < 8 && name[name_len] != '\0') { ++name_len; } } else { name = string_table + symbol.first.value[1]; name_len = strlen(name); } if (memcmp(name, "__imp_", 6) == 0 && strchr(name, '$') != nullptr) { // 导入的系统函数,加密符号名 encryptSymbol(name, name, symbol.first.value[1]); } else { // 扩展函数,或COFF内部符号,清除符号名,并记录符号名hash uint32_t name_hash = sshCrc32(name, name_len); memset(name, 0, name_len); symbol.first.value[0] = 0xffffffff; // value[0]为0xffffffff,表明是处理后的符号名,value[1]符号名的hash symbol.first.value[1] = name_hash; } if (symbol.number_of_aux_symbols > 0) { i += symbol.number_of_aux_symbols; // 略过辅助符号 } }
3. 源文件编写规则
- 使用C而不是C++
- C更轻量化、代码体积小,避免C++运行时的一大堆内容
- C的ABI接口更统一,编译后的变量名/函数名规则更简单统一,loader处理起来更简单
- 不使用C运行时函数:如printf等,会产生外部模块符号引用,需要的话,可以通过扩展函数进行封装
- 全局变量要初始化,除非loader支持未初始化的情况
- 关闭安全特性:使用微软的
cl
工具编译时,要关闭缓冲区安全检查/GS-
, 避免产生security cookie相关的外部模块符号引用- debug编译:
cl /GS- /c Test.c /FoTest.obj /Od
- release编译:
cl /GS- /c Test.c /FoTest.obj /O2 /DNDEBUG
- debug编译:
4. 开源地址
开源地址:gitcode.com/lsec096/Cof…, 包括CoffLoader,以及Coff文件保护工具CoffProtect
参考
[1] Beacon Object Files. hstechdocs.helpsystems.com/manuals/cob…
[2] Machine Types. learn.microsoft.com/en-us/windo…
[3] File Characteristics. learn.microsoft.com/en-us/windo…
[4] 重定位类型-x86. learn.microsoft.com/en-us/windo…
[5] 重定位类型-x64. learn.microsoft.com/en-us/windo…
[6] 符号的Type. learn.microsoft.com/en-us/windo…
[7] Ghidra中的重定位处理-x86. github.com/NationalSec…
[8] Ghidra中的重定位处理-x64. github.com/NationalSec…