https://bbs.kanxue.com/thread-191649.htm

ELF 文件结构

typedef struct {
  unsigned char  e_ident[EI_NIDENT];  /* File identification. */
  Elf32_Half  e_type;    /* File type. */
  Elf32_Half  e_machine;  /* Machine architecture. */
  Elf32_Word  e_version;  /* ELF format version. */
  Elf32_Addr  e_entry;  /* Entry point. */
  Elf32_Off  e_phoff;  /* Program header file offset. */
  Elf32_Off  e_shoff;  /* Section header file offset. */
  Elf32_Word  e_flags;  /* Architecture-specific flags. */
  Elf32_Half  e_ehsize;  /* Size of ELF header in bytes. */
  Elf32_Half  e_phentsize;  /* Size of program header entry. */
  Elf32_Half  e_phnum;  /* Number of program header entries. */
  Elf32_Half  e_shentsize;  /* Size of section header entry. */
  Elf32_Half  e_shnum;  /* Number of section header entries. */
  Elf32_Half  e_shstrndx;  /* Section name strings section. */
} Elf32_Ehdr;

010 打开之后是这样的:

ELF header ELF 文件头 里面是基础信息,装载时看的是 Program header,涉及到 ELF header 里的变量 e_phoff 偏移 e_phentsize 每个的大小和 e_phnum 数量。同样,链接时看的是 Section header ,涉及到 ELF header 里的变量 e_shoff 偏移,e_shentsize 每个的大小和 e_shnum 数量,e_shstrndx section 的字符串表

Program header table 一般紧贴着 ELF header,里面长这样

类型、权限、文件里的位置、要加载到虚拟内存中的位置、(现在没用的物理位置)、在文件中的大小、在内存中的大小、对齐大小

Section header 里面长这样 段表

名字、类型、权限、虚拟内存位置(addr)、对应的文件偏移(offset)、大小、关联的 section、其他信息、对齐、某些特别的段的表的每一项的大小(如重定位表、符号表、哈希表、动态链接表这些)

s_name64_t s_name;               /* Section name */
s_type64_e s_type;               /* Section type */
s_flags64_e s_flags;             /* Section attributes */
Elf64_Addr s_addr;               /* Virtual address in memory */
Elf64_Off s_offset <format=hex>; /* Offset in file */
Elf64_Xword s_size;              /* Size of section */
Elf64_Word s_link;               /* Link to other section */
Elf64_Word s_info;               /* Miscellaneous information */
Elf64_Xword s_addralign;         /* Address alignment boundary */
Elf64_Xword s_entsize;           /* Entry size, if section has table */

dynamic 段里长这样:

linker 需要获取里面的比较关键的信息是:(d_tag

  • DT_NEEDED :依赖的共享库列表。
  • DT_SYMTAB :动态符号表( .dynsym )地址。
  • DT_RELA / DT_REL :重定位表( .rela.plt )地址。
  • DT_JMPREL :PLT 重定位表(通常与 .rela.plt 相同)。
  • DT_HASH / DT_GNU_HASH :符号哈希表地址。

动态链接的步骤和实现

指向原始笔记的链接

正向视角

Important

linker 是基于装载视图解析 so 文件的

  1. e_type(文件类型)、e_machine、e_version 和 e_flags (架构)之类的完全可以做手脚
  2. 动态链接库不用 e_entry ,设定的跳转是动态链接器的位置
  3. 在内存装载这一步,与链接视图无关,所以 e_shoff、e_shentsize、e_shnum 和 e_shstrndx 也是可以随便动手脚的。但是可惜的是 IDA 分析需要的是链接视图(毕竟要分析符号、代码、不同的表这些)

section table 到最后跳到链接器的时候就要用到了吧?

并不会。section table 的作用只影响到静态链接目标文件(合并 section),之后的动态链接的事情,完全可以由 segment 处理了,因为 segment 里面有专门的 dynamic 标志,只要动态链接器能够知道这个 segment 在哪就够了

根据上面的知识,我们能够设计一套比较隐蔽的加密特定 section 的方案

加密流程

  1. 加密用取反来作为实例:预先的操作就是在文件中找到需要加密的段的数据,全部取反
  2. 找的方法是找 section header 里面的数据
  3. 把在内存中找到这个地方的所需要的数据——虚拟内存中的偏移地址、大小,一起塞到 e_shoff 这种无关紧要的地方(顺便直接给 IDA 干爆了)(高位地址、低位大小)(或者多用几个都行啦),获取的方式是找 section table 对应的数据

解密流程

  1. 程序运行解密:在 init_array 里面留一个解密函数
  2. 函数里面这么找到需要解密的地方:调用 getLibAddr(或者其他找到基址的函数) 得到 so 文件中的起始地址
  3. 通过读取一个偏移直接在 ELF header 里面找到 e_shoff,获取大小和位置
  4. 修改这个地方的内存权限,解密,然后改回来原来的权限

这里不能一股脑全部把 .text 全加密了,因为重定位会用到这里的代码。initArray的加载问题 里的原理中也可以看出,如果解密不是第一个的时候,可能会出大问题

// 解密函数(需保留在未加密的.text中)
void __attribute__((constructor)) decrypt_mytext() {
    // 1. 获取基地址
    void* base = get_module_base();
    
    // 2. 从ELF头读取元数据
    Elf64_Ehdr* ehdr = (Elf64_Ehdr*)base;
    Elf64_Off offset = (ehdr->e_entry >> 32) & 0xFFFFFFFF;
    size_t size = ehdr->e_entry & 0xFFFFFFFF;
    
    // 3. 计算虚拟地址
    void* mytext_vaddr = base + offset;
    
    // 4. 修改内存权限并解密
    mprotect(mytext_vaddr, size, PROT_READ | PROT_WRITE);
    for (size_t i = 0; i < size; i++) {
        ((uint8_t*)mytext_vaddr)[i] = ~((uint8_t*)mytext_vaddr)[i];
    }
    mprotect(mytext_vaddr, size, PROT_READ | PROT_EXEC);
}

上面的方式还有挺大的改进空间,如只要把 e_shoff 修好了,那么 section 的情况就暴露了,会直接看到多一个 section,还有什么 init_array 之类的全出来了(如果没删干净的话)

我想,在 dynamic segment 里面乱找也可以看出来吧,只要看懂了话还是挺好弄的。可能得弄个什么 1000 多个假函数在 dynamic 里上工作量难度对抗

一种更精细的方案是加密某个函数:这里设加密的函数是导出函数,所以能够在

加密方案

  1. 找到 dynamic 段的位置,然后在里面遍历 tag 找到 dynsym(符号表)dynstr(符号表里面指向的对应的字符串表)hash(字符串哈希后映射到这个 so 里的所有导出符号的地址的哈希表)
  2. 根据函数名称,计算哈希,然后在哈希表里找到 Sym 里面就有函数地址和大小之类的东西了
  3. 然后就对这块地方加密吧

这里利用了外面的 so 调用这里面的符号,根据字符串找函数地址这么个路径才搞定的。挺偷鸡的吧

解密方案

  1. 找基地址、然后找 Program header,找到 dynamic segment 里面的 p_vaddr(虚拟地址位置)和 p_filesz (段大小)
  2. 然后和上面一个样啦

逆向修复

于是乎,修复 so 的思路就是把 section 信息找回来,segment 修复好、重定位那些地址修复好的过程。为了内存对齐什么的,内存中的东西和文件上会有微妙的偏移,把文件相应的扩大就行了。 因为是装载视图,section header 什么的就可能已经被丢光了,所以也要根据.dynamic 里的东西修复出一个全新的 section header。

下面用 https://github.com/Chenyangming9/SoFixer 这个版本介绍

修复 segment(program header) 信息

bool ElfRebuilder::RebuildPhdr() {
    auto phdr = (Elf_Phdr*)elf_reader_->loaded_phdr();
    for(auto i = 0; i < elf_reader_->phdr_count(); i++) {
        phdr->p_filesz = phdr->p_memsz;     // 扩展文件大小到内存大小
        phdr->p_paddr = phdr->p_vaddr;      // 修复物理地址
        phdr->p_offset = phdr->p_vaddr;     // 修复文件偏移
        phdr++;
    }
}

最重要的修复 section 信息(IDA 看这个)

bool ElfRebuilder::RebuildShdr() {
    // 根据segment信息重建各个section信息
    // 包括.dynsym, .dynstr, .hash, .rel.dyn, .rel.plt等关键section
    // 计算section地址、大小等
    if(sDYNSYM != 0) {
        shdrs[sDYNSYM].sh_link = sDYNSTR;
        auto sNext = sDYNSYM + 1;
        shdrs[sDYNSYM].sh_size = shdrs[sNext].sh_addr - shdrs[sDYNSYM].sh_addr;
    }
}

修复重定位信息

bool ElfRebuilder::RebuildRelocs() {
    SaveImportsymNames(); // 保存导入符号名称
    
    if(elf_reader_->dump_so_base_ == 0) return true;
    
    // 处理两种类型的重定位表
    if (si.plt_type == DT_REL) {
        // .rel.dyn 段的重定位
        auto rel = si.rel;
        for (auto i = 0; i < si.rel_count; i++, rel++){
            relocate<false>(si.load_bias, rel, elf_reader_->dump_so_base_);
        }
        // .rel.plt 段的重定位 
        rel = si.plt_rel;
        for (auto i = 0; i < si.plt_rel_count; i++, rel++){
            relocate<false>(si.load_bias, rel, elf_reader_->dump_so_base_);
        }
    }
}

符号重定位

  • 处理 .rel.dyn.rel.plt 两个重定位表
  • 对每个重定位项:
    • 如果是 R_ARM_RELATIVE 类型,进行基址修正,因为是偏移,所以知道 base 的情况下直接相减就行了 * prel = * prel - dump_base
    • 如果是外部符号引用,需要重建导入表
case R_386_RELATIVE:
case R_ARM_RELATIVE:
    *prel = *prel - dump_base;
    break;

导入表重建

  • 保存所有导入符号名到 mImports 数组
  • 若符号地址( st_value )已知,直接写入目标地址。
  • 若符号未解析(如动态库的导入符号),通过符号名查找索引,并计算新地址。
  • 地址计算 : 新地址 = 内存加载大小 + 符号索引 × 指针大小(模拟导入表布局,后面文件中会重建一个)。
auto sym = ELF64_R_SYM(rel->r_info);
...
auto syminfo = si.symtab[sym];
if (syminfo.st_value != 0) {
	*prel = syminfo.st_value; // 这里成功得到偏移
} else {
	auto load_size = si.max_load - si.min_load;
	if (mImports.size() == 0){
		*prel = load_size + external_pointer;
		external_pointer += sizeof(*prel);
	}else{ //这里如果获取了导入符号内容,并且不为空,则从保存的导入符号数组中获取导入表索引值
		const char* symname = si.strtab + syminfo.st_name;
		int nIndex = GetIndexOfImports(symname);
		if (nIndex != -1){
	  		*prel = load_size + nIndex*sizeof(*prel);
		}
	}
}
break;

符号表定义如下:

typedef struct {
    Elf64_Word  st_name;   // 符号名在字符串表中的偏移
    unsigned char st_info; // 符号类型(低4位)和绑定属性(高4位)
    unsigned char st_other;
    Elf64_Half  st_shndx;  // 符号所在段的索引
    Elf64_Addr  st_value;  // 符号的地址或偏移(核心字段)
    Elf64_Xword st_size;   // 符号大小
} Elf64_Sym;

这个过程本质上是:

  1. 将内存中的绝对地址转换回相对地址
  2. 重建符号链接信息
  3. 确保 SO 文件可以在新的加载地址正确运行

最后通过

bool ElfRebuilder::Rebuild() {
    return RebuildPhdr() &&    // 修复segment
           ReadSoInfo() &&     // 读取SO信息
           RebuildShdr() &&    // 重建section 
           RebuildRelocs() &&  // 修复重定位
           RebuildFin();       // 生成最终文件
}

串联起来,然后生成完整文件

bool ElfRebuilder::RebuildFin() {
    auto load_size = si.max_load - si.min_load;
    rebuild_size = load_size + shstrtab.length() + shdrs.size() * sizeof(Elf_Shdr);
    rebuild_data = new uint8_t[rebuild_size];
    // 复制段数据
    memcpy(rebuild_data, (void*)si.load_bias, load_size);
    // 添加section字符串表
    memcpy(rebuild_data + load_size, shstrtab.c_str(), shstrtab.length());
    // 添加section headers
    memcpy(rebuild_data + shdr_off, (void*)&shdrs[0], shdrs.size() * sizeof(Elf_Shdr));
}