引言
这篇文章将描述为Linux x86_64创建一个简单的可执行文件打包程序。 假定读者熟悉C编程语言,x86_64体系结构的汇编语言以及设备ELF文件。 为了确保清晰,本文中的代码已删除了错误处理,并且未显示某些功能的实现,可以通过单击指向github的链接(
loader ,
packer )找到完整的代码。
想法是这样的:我们将ELF文件传输到打包程序,然后在输出中得到一个具有以下结构的新文件:
为了进行压缩,决定使用霍夫曼算法进行加密-具有256位密钥的AES-CTR,即kokke
tiny-AES-c的实现 。 256个字节的随机数据用于使用伪随机数生成器初始化AES密钥和初始化向量,如下所示:
for(int i = 0; i < 32; i++) { seed = (1103515245*seed + 12345) % 256; key[i] = buf[seed]; }
该决定是由于使逆向工程复杂化的愿望引起的。 到目前为止,我意识到并发症并不重要,但是我并没有开始消除它,因为我不想花时间和精力在它上面。
引导程序
首先,将考虑引导加载程序的工作。 加载程序不应具有任何依赖关系,因此标准C库中的所有必需功能都必须独立编写(这些功能的实现可通过
参考获得 )。 它在位置上也应该独立。
_启动功能
引导加载程序从_start函数开始,该函数仅将argc和argv传递给main:
.extern main .globl _start .text _start: movq (%rsp), %rdi movq %rsp, %rsi addq $8, %rsi call main
主要功能
main.c文件首先定义几个extern变量:
extern void* loader_end;
为了找到与打包程序中的变量(Elf64_Sym)对应的字符的位置并更改其值,所有这些字符都声明为extern。
主要功能本身非常简单。 第一步是初始化指向打包的ELF文件,256字节缓冲区和堆栈顶部的指针。 然后将ELF文件解密并展开,然后使用load_elf函数将其放置在内存中的正确位置,最后,rsp寄存器的值返回其原始状态,并跳到程序入口点:
#define SET_STACK(sp) __asm__ __volatile__ ("movq %0, %%rsp"::"r"(sp)) #define JMP(addr) __asm__ __volatile__ ("jmp *%0"::"r"(addr)) int main(int argc, char **argv) { uint8_t *payload = (uint8_t*)&loader_end;
为了安全起见,重置AES和解压缩的ELF文件的状态是为了使密钥和解密的数据仅在使用期间存储在内存中。
接下来,我们将考虑一些功能的实现。
load_elf
由于最初的功能在ET_DYN之类的文件上崩溃,因此我从github用户的昵称bediger那里获取了该函数,并对其进行了最终确定。 发生故障是由于以下事实:mmap系统调用的第一个参数的值设置为NULL,并且返回的地址与主程序非常接近,在随后的mmap调用中,将段复制到它们返回的地址中,主程序的代码被覆盖,并发生了段错误。 因此,决定将起始地址作为参数添加到load_elf函数。 该函数本身遍历所有程序头,为ELF文件的PT_LOAD段分配内存(其数量必须是页面大小的倍数),将其内容复制到已分配的内存区域,并为这些区域设置相应的读取,写入,执行权限:
elf_load_addr
ET_EXEC ELF文件的此函数返回NULL,因为此类型的文件应位于文件中指定的地址处。 对于ET_DYN文件,首先计算等于主程序基址(即引导加载程序),将ELF放入内存所需的内存量以及4096、4096之间的差值的地址,以免ELF文件不紧挨主程序。 计算完该地址后,检查从给定地址到主程序的基地址,内存区域是否与从解压缩的ELF文件的开头到结尾的区域相交。 在相交的情况下,返回的地址等于解压缩的ELF的起始地址与放置该地址所需的内存量之差,否则返回先前计算的地址。
通过从辅助向量(ELF辅助向量)中提取程序头的地址来找到程序的基地址,该辅助向量位于堆栈中指向环境变量的指针之后,并从中减去ELF头的大小:
--------------------------------------------------------------------------- -> [ argc ] 8 [ argv[0] ] 8 [ argv[1] ] 8 [ argv[..] ] 8 * x [ argv[n – 1] ] 8 [ argv[n] ] 8 (= NULL) [ envp[0] ] 8 [ envp[1] ] 8 [ envp[..] ] 8 [ envp[term] ] 8 (= NULL) [ auxv[0] (Elf64_auxv_t) ] 16 [ auxv[1] (Elf64_auxv_t) ] 16 [ auxv[..] (Elf64_auxv_t) ] 16 [ auxv[term] (Elf64_auxv_t) ] 16 (= AT_NULL) [ ] 0 - 16 [ ] >= 0 [ ] >= 0 [ ] 8 (= NULL) < > 0 ---------------------------------------------------------------------------
描述辅助向量的每个元素的结构具有以下形式:
typedef struct { uint64_t a_type;
有效的a_type值之一是AT_PHDR,然后a_val将指向程序头。 以下是elf_load_addr函数的代码:
void* elf_base_addr(void *rsp) { void *base_addr = NULL; unsigned long argc = *(unsigned long*)rsp; char **envp = rsp + (argc+2)*sizeof(unsigned long);
链接描述文件
有必要为上述extern变量定义字符,并确保编译后的代码和加载器数据在同一.text节中。 通过从文件中简单地切出本节的内容,便可以方便地提取装载机的机器代码。 为了实现这些目标,编写了以下链接描述文件:
ENTRY(_start) SECTIONS { . = 0; .text :{ *(.text) *(.text.startup) *(.data) *(.rodata) payload_size = .; QUAD(0) key_seed = .; QUAD(0) iv_seed = .; QUAD(0) loader_end = .; } }
值得说明的是,QUAD(0)放置8个字节的零,而不是打包程序将替换特定值的字节。 为了切出机器代码,编写了一个小实用程序,该实用程序还将机器代码的开头写入了从引导加载程序开始到引导加载程序的入口点的偏移量,以及自引导加载程序开始以来有效负载_size,key_seed和iv_seed字符值的偏移量。 此实用程序的代码
在此处提供 。 这样就结束了引导加载程序的描述。
直接包装
考虑打包机的主要功能。 它使用两个命令行参数:输入文件的名称为argv [1],输出文件的名称为argv [2]。 首先,输入文件显示在内存中,并检查与打包程序的兼容性。 该打包程序仅适用于两种类型的ELF文件:ET_EXEC和ET_DYN,并且仅适用于静态编译的文件。 引入此限制的原因是不同的Linux系统具有不同版本的共享库,即 动态编译程序无法在父系统以外的系统上启动的可能性非常高。 主要功能中的相应代码:
size_t mapped_size; void *mapped = map_file(argv[1], &mapped_size); if(check_elf(mapped) < 0) return 1;
此后,如果输入文件通过兼容性检查,则将其压缩:
size_t comp_size; uint8_t *comp_buf = huffman_encode(mapped, &comp_size);
接下来,生成AES状态,并对压缩的ELF文件进行加密。 AES的状态由以下结构确定:
#define AES_ENTROPY_BUFSIZE 256 typedef struct { uint8_t entropy_buf[AES_ENTROPY_BUFSIZE];
main中的对应代码:
AES_state_t aes_st; for(int i = 0; i < AES_ENTROPY_BUFSIZE; i++) state.entropy_buf[i] = rand() % 256; state.key_seed = rand(); state.iv_seed = rand(); AES_init_ctx_iv(&state.ctx, state.entropy_buf, state.key_seed, state.iv_seed); AES_CTR_xcrypt_buffer(&aes_st.ctx, comp_buf, comp_size);
此后,将初始化存储有关引导加载程序信息的结构,并将引导加载程序中的payload_size,key_seed和iv_seed值更改为上一步中生成的值,然后重置AES状态。 有关引导加载程序的信息存储在以下结构中:
typedef struct { char *loader_begin;
main中的对应代码:
loader_t loader; init_loader(&loader); *loader.payload_size_patch_offset = comp_size; *loader.key_seed_pacth_offset = aes_st.key_seed; *loader.iv_seed_patch_offset = aes_st.iv_seed; memset(&aes_st.ctx, 0, sizeof(aes_st.ctx));
在最后一部分,我们创建一个输出文件,在其中写入一个ELF头,一个程序头,一个加载器代码,一个压缩和加密的ELF文件以及一个256字节的缓冲区:
int out_fd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0755);
打包程序的主要代码到此结束,然后将考虑以下功能:初始化有关引导加载程序的信息的功能,编写ELF标头的功能和编写程序标头的功能。
初始化引导程序信息
使用以下简单代码,将加载程序机器代码嵌入到packer可执行文件中:
.data .globl _loader_begin .globl _loader_end _loader_begin: .incbin "loader" _loader_end:
为了确定其在内存中的地址,在main.c文件中声明了以下变量:
extern void* _loader_begin; extern void* _loader_end;
接下来,考虑init_loader函数。 首先,从中依次读取以下值:入口点与引导加载程序起始位置的偏移量(entry_offset),打包的ELF文件的大小与引导加载程序起始位置的偏移量(payload_size_patch_offset),密钥生成器的初始值与引导加载程序的起始位置之间的偏移量(key_seed_patch_offset的矢量偏移量),从引导加载程序(iv_seed_patch_offset)开始进行初始化。 然后,将加载程序地址添加到最后三个值,因此在取消引用指针并为其分配值时,我们将用所需的值替换在布局阶段分配的零(QUAD(0))。
void init_loader(loader_t *l) { void *loader_begin = (void*)&_loader_begin; l->entry_offset = *(size_t*)loader_begin; loader_begin += sizeof(size_t); l->payload_size_patch_offset = *(void**)loader_begin; loader_begin += sizeof(void*); l->key_seed_pacth_offset = *(void**)loader_begin; loader_begin += sizeof(void*); l->iv_seed_patch_offset = *(void**)loader_begin; loader_begin += sizeof(void*); l->payload_size_patch_offset = (size_t)l->payload_size_patch_offset + loader_begin; l->key_seed_pacth_offset = (size_t)l->key_seed_pacth_offset + loader_begin; l->iv_seed_patch_offset = (size_t)l->iv_seed_patch_offset + loader_begin; l->loader_begin = loader_begin; l->loader_size = (void*)&_loader_end - loader_begin; }
write_elf_ehdr
void write_elf_ehdr(int fd, loader_t *loader) {
这里发生ELF标头的标准初始化,并随后将其写入文件,唯一要注意的事实是,在ET_DYN ELF文件中,第一个程序标头描述的段不仅包括可执行代码,还包括ELF标头和所有标头。程序。 因此,它与起始位置的偏移量应等于零,其大小应为ELF标头,所有程序标头和可执行代码的总和,而入口点应确定为ELF标头,所有程序标头的大小以及与可执行代码开头的总和。
write_elf_phdr
void write_elf_phdr(int fd, loader_t *loader, size_t payload_size) {
在此,程序头被初始化,然后写入文件。 您应注意相对于文件开头的偏移以及程序头文件描述的段的大小。 如前一段所述,此标头描述的段不仅包括可执行代码,还包括ELF标头和程序标头。 我们还使具有可执行代码的段可访问以进行写入,这是因为在引导加载程序中使用的AES实现对数据进行了加密和解密。
关于打包机工作的一些事实
在测试过程中,注意到使用glibc静态编译的程序在启动时根据以下说明进入segfault:
movq%fs:0x28,%rax
我不知道为什么会这样,如果您分享有关此主题的信息,我将非常高兴。 可以使用musl-libc来代替glibc,一切都可以正常使用。 另外,该打包程序还使用静态编译的golang程序(例如,http服务器)进行了测试。 对于golang程序完全静态崩溃,必须使用以下标志:
CGO_ENABLED = 0 go build -a -ldflags'-extldflags“ -static”'。
打包程序测试的最后一件事是没有动态链接程序的ET_DYN ELF文件。 是的,使用这些文件时,elf_load_addr函数可能会失败。 实际上,可以从引导加载程序中剪切它,并使用固定地址,例如0x10000。
结论
显然,该打包程序没有用在预期的目的上,因为受其保护的文件很容易解密。 该项目的目的是更好地掌握使用ELF文件,生成它们的实践以及为创建更完整的打包程序做准备。