COFF文件格式与COFF加载器

0 阅读10分钟

在安全产品(比如反病毒、反外挂)的开发中,由于版本更新周期较长,通常需要一种动态代码执行机制来灵活扩展产品的检测能力,来应对快速变化的对抗情况。常见的做法是集成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-layout.png

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_tablenumber_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_datapointer_to_raw_data: 节的数据的大小和起始位置(相对文件开始的偏移)
  • pointer_to_relocationsnumber_of_relocations: 节中包含的重定位项的起始位置(相对文件开始的偏移)和个数
  • characteristics: 节映射到内存后的内存属性,比如代码节有可执行属性,数据节有读写属性等

3. 节区数据(Section Data)

节区数据包含实际的代码和数据内容,通过CoffSectionHeader中pointer_to_raw_datasize_of_raw_data定位到节区的位置和大小,常见的节区包括:

  • .text:可执行代码
  • .data:已初始化的数据
  • .rdata:只读数据
  • .bss:未初始化数据

4. 重定位表(Relocation Table)

重定位表记录需要在加载时修正的地址信息,重定位源于代码中对内部或外部符号(如全局变量、函数等)的引用,COFF文件在编译时并不知道这些符号最终加载的地址,所以需要重定位操作来修正代码中对这些符号的引用。

重定位表是被节表引用的,通过CoffSectionHeaderpointer_to_relocationsnumber_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字段表示符号在节区内的偏移

        internal-function.png

      • 符号在本模块内,但没有所属节区,比如未初始化的全局变量:section_number == 0,value > 0

        internal-uninitialized-global-variable.png

      • 符号在外部模块,比如调用的API:section_number == 0, value == 0

        external-imported-function.png

    • IMAGE_SYM_CLASS_STATIC:3,静态符号,比如源码中的字符串字面量等,value字段表示符号在对应节区内的偏移。msvc的编译工具cl.exe还会为每个节名产生一个符号,name为节名,value值为0,section_number为对应节区号。这会导致一个问题,节名符号与其他静态符号的指向可能会产生冲突,比如下图中的.data节符号和$SG99522字符串字面值符号,都是static类型,valuesection_number的值也都一样,如果都按照节区+偏移的方式解析符号,则节符号会解析错误。不过好在写loader时可以不关注节符号,因为我们只关心和重定位那些在代码中被引用的符号,而节名符号没有引用,可以不关注。如果非要区分二者,可以通过辅助符号,cl会为每个节符号生成一个辅助符号。

      static-symbols.png

    • 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结尾。

string-table.png

二、CoffLoader的实现细节

了解了COFF文件的格式,加载过程就比较直观了,主要包括以下几个步骤:

1. 解析 COFF 文件结构

CoffLoader首先读取并解析COFF文件头,然后计算得到节表、符号表、字符串表的地址。

2. 计算并申请加载COFF所需的内存空间

所需的内存空间用于存放各个节区的内容,以及导入函数的地址表(类似PE中的IAT)。有些节区仅在静态链接的时候会用到,不需要加载到内存,可以根据CoffSectionHeader.characteristics将它们过滤掉。

加载后的各个节区不要求是连续的,所以可以单独为各个节区申请内存空间,但这样可能会导致节区间的距离相距较远,如果超过0xffffffff,COFF内部符号的重定位就会出现问题,在x64下需要注意这个问题。

所以,我们选择为COFF分配一整块可读可写可执行的内存,然后将节区内容逐个写入到该内存中对应位置。严谨点的话,需要将各个节区的大小按页面大小进行对齐,并根据CoffSectionHeader.characteristics为各个节区分配不同的内存属性,我们这里偷懒一点,就不去修改内存属性了。

bool CoffLoader::isSectionNeedMap(CoffSectionHeader &section) {
  // 有这三个属性的节区可以不加载
  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 &section = 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 &section = 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)。

bss-data2.png

4. 处理重定位信息

重定位场景

哪些场景需要重定位:

  1. 模块内符号引用:被引用的符号在COFF文件内
    • 函数调用:编译成 E8 xxxxxxxx,相对偏移进行调用,重定位方式为REL32
    • 全局变量读写:使用变量地址,重定位方式x86为I386_DIR32(比如a1 xxxxxxxx mov eax, dword [_b]),x64为AMD64_REL32(比如8B05 xxxxxxxx mov eax,dword [b]
  2. 模块外符号引用,看声明方式:被引用的符号在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 &section = 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 &section = 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

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…