
简介面向Linux平台开发者的64位ELF文件加密与格式解读资源包解决可执行文件防篡改、防逆向及ELF内部结构理解问题。压缩包共7个文件、约4KB体积小巧包含3个C源文件、1个汇编文件、Makefile构建脚本、GDB调试记录及README说明覆盖构建、启动、参数解析与汇编底层实现源码精简适合快速阅读与二次改造。包内项目围绕64位ELF加密展开核心调用链包括主程序、测试用例、参数解析和汇编段等模块使用GCC编译链接即可构建配合README可理清项目结构。资源内容围绕ELF64位结构展开可帮助开发者厘清程序头、节区表、重定位等关键概念。资源已有370人学习适合对Linux二进制安全、可执行文件保护、系统底层机制感兴趣的初、中级开发者也可作为相关课题的入门参考。实际生产环境需按自身算法或场景修改部分代码例如调整加密范围、密钥生成方式或适配不同编译选项结合构建脚本与调试记录交叉验证可加深对ELF加密原理和Linux加载机制的理解。1. 一个能看懂 ELF64 结构、也能跑通的加密示例在 Linux 下做二进制交付最常用的方式就是直接发可执行文件但strings一把梭就能把关键字符串和调用逻辑看个七八成。稍微讲究一点的团队会考虑加壳packer或者做代码段加密但商业加壳器要么收费要么不开放源码出了问题只能干瞪眼。这份elf64_pack.zip里提供的正是一个完整的 ELF64 加壳示例main.c负责读取并改写可执行文件asm.s用一段汇编 stub 在运行时解密par.c处理命令行参数test.c用来验证加密后程序行为不变。它不是一个开箱即用的商用产品而是一个值得拆开研究的学习样本——你能从里面看到 ELF64 程序入口是如何被改写的、运行时自解密是怎么跳转的、以及为什么说“实际生产请修改部分代码”。适合需要深入 ELF 格式、想做轻量级二进制保护的开发者。2. 从 ELF64 头解析到程序入口elf_pack 的加密目标落在哪2.1 ELF64 头与节区表的定位ELF64 文件的头部固定 64 字节前 16 字节是魔数和类别等标识从偏移 0x18 开始是程序入口e_entry0x20 是程序头表偏移e_phoff0x28 是节区头表偏移e_shoff。加壳器拿到一个输入文件后第一步就是读取这些字段确认它确实是 ELF64而不是 32 位格式或者普通数据文件。// parse_elf64.c 示意读取 ELF64 头关键字段 #include stdio.h #include stdint.h #include string.h #include elf.h int read_elf64_header(FILE *fp, Elf64_Ehdr *ehdr) { if (fread(ehdr, sizeof(Elf64_Ehdr), 1, fp) ! 1) { return -1; } // 检查魔数 e_ident[0..3] if (memcmp(ehdr-e_ident, ELFMAG, SELFMAG) ! 0) { fprintf(stderr, not a valid ELF file\n); return -1; } if (ehdr-e_ident[EI_CLASS] ! ELFCLASS64) { fprintf(stderr, not a 64-bit ELF\n); return -1; } // e_machine: 0x3e 对应 x86_64 if (ehdr-e_machine ! EM_X86_64) { fprintf(stderr, unsupported machine: 0x%x\n, ehdr-e_machine); return -1; } return 0; }这段代码主要做了三件事确认魔数、确认是 64 位、确认目标架构是 x86_64。因为加密后要改e_entry如果机器类型不对改写入口没有意义。e_entry是加载器加载完成后跳转的虚拟地址默认对应_start符号所在位置。加壳的思路就是把e_entry改成我们注入的 stub 地址让程序先跑我们的解密逻辑再跳回原来的入口。2.2 加密的粒度代码段、数据段还是整个文件整个文件加密在理论上是可行的但代价是操作系统无法直接加载——必须有外部 loader 先解密再还原文件。这种方案适合做安装包保护不适合普通的可执行文件。elf_pack 的做法是只加密代码段或者整个 text 段保留 ELF 头和程序头表不动这样内核加载时不报错只是在程序实际运行到入口时发现代码还是密文再用 stub 原地解密。具体加密哪个段取决于程序头Program Header里PT_LOAD段的权限组合。通常第一个可读可执行的段就是代码段包括.text和.init。你可以用readelf -l查看readelf -l ./test | grep LOAD典型输出LOAD 0x000000 0x0000000000400000 0x0000000000400000 0x0005a8 0x0005a8 R 0x1000 LOAD 0x001000 0x0000000000401000 0x0000000000401000 0x0008fd 0x0008fd R E 0x1000 LOAD 0x002000 0x0000000000402000 0x0000000000402000 0x0001b0 0x0001b8 RW 0x1000第二个LOAD段权限为R E即可读可执行。加壳器需要记录这个段的文件偏移p_offset、虚拟地址p_vaddr和大小p_filesz然后只对这一段做加密。这样能减少解密时间也能避免破坏只读数据段中的字符串信息——虽然从保护角度来说字符串本身也可能泄露关键逻辑。2.3 par.c 与 Makefile 里的编译约束par.c在这个项目里的作用是解析命令行参数。典型用法是./elf_pack -i original -o packed -k 0x0102030405060708par.c会处理-i、-o、-k这类选项-k可以传入一个密钥没有传就用默认值。从项目打包结构看Makefile里应该有main.c par.c asm.o的编译链接规则。因为asm.s生成的目标代码要和加密后的 ELF 拼接通常需要避免 PIE位置无关可执行文件开启。# Makefile 示意 CC gcc CFLAGS -Wall -O2 -fno-stack-protector -no-pie AS as all: elf_pack elf_pack: main.o par.o asm.o $(CC) $(CFLAGS) main.o par.o asm.o -o elf_pack main.o: main.c $(CC) $(CFLAGS) -c main.c par.o: par.c $(CC) $(CFLAGS) -c par.c asm.o: asm.s $(AS) -o asm.o asm.s clean: rm -f *.o elf_pack-no-pie是关键因为加密工具本身需要把 stub 的绝对地址写进新的可执行文件如果工具自己都是 PIE运行地址会随机化不便调试和演示。对于要加密的目标文件建议编译时也使用-no-pie否则e_entry是相对地址而实际运行时基址会变stub 计算跳转地址时要额外处理重定位复杂度立刻上升。3. main.c asm.s 协同运行时解密的实现路径3.1 加壳器的流程读取、加密、写入main.c负责整体流程。常见做法是先读入整个 ELF 文件到内存然后修改程序头表增加一个PT_LOAD段来存放 stub或者直接复用原程序的代码段把 stub 塞进去并对原代码段加密。更简单的做法是把 stub 追加到文件末尾同时把它映射为新的可读可执行段并修改e_entry指向该段地址。伪代码流程如下// main.c 加壳流程示意 unsigned char *buf read_whole_file(input_path, file_size); Elf64_Ehdr *ehdr (Elf64_Ehdr *)buf; Elf64_Phdr *phdr (Elf64_Phdr *)(buf ehdr-e_phoff); // 1. 找出代码段记录偏移和大小 for (int i 0; i ehdr-e_phnum; i) { if (phdr[i].p_type PT_LOAD (phdr[i].p_flags PF_X)) { code_segment_offset phdr[i].p_offset; code_segment_vaddr phdr[i].p_vaddr; code_segment_size phdr[i].p_filesz; break; } } // 2. 对代码段加密这里用简单异或 for (size_t j 0; j code_segment_size; j) { buf[code_segment_offset j] ^ key[j % 16]; } // 3. 在文件尾部追加 stub 数据 off_t stub_offset file_size; append_stub(buf, file_size); // 4. 新增一个 PT_LOAD 段用于加载 stub Elf64_Phdr new_phdr {0}; new_phdr.p_type PT_LOAD; new_phdr.p_flags PF_R | PF_X; new_phdr.p_offset stub_offset; new_phdr.p_vaddr STUB_VADDR; new_phdr.p_paddr STUB_VADDR; new_phdr.p_filesz stub_size; new_phdr.p_memsz stub_size; new_phdr.p_align 0x1000; // 5. 修改程序头表增加一项并更新 e_phnum // 6. 修改 e_entry 为 STUB_VADDR // 7. 写回文件注意这里第 4 步新增PT_LOAD段时需要处理程序头表在文件中的位置可能要把程序头表整体挪到文件末尾重新放。这也是最容易出错的地方因为原来的程序头表可能没有预留空间。很多教学代码直接覆盖掉最后一个程序头或把e_phnum加一后把新段头写到原表后面如果原表后面紧跟的是节区头则会相互覆盖。稳妥的做法是检查段表布局后再决定或者干脆只覆盖一个不用的程序头。3.2 asm.s 里的自解密 stub 原理asm.s是用汇编实现的一小段解密代码它在程序启动时比原始_start更早执行。stub 需要完成三件事保存现场、解密原始代码段、跳转到原入口。下面是 x86_64 下的示例 stub# asm.s 示意运行时解密 stub .global _start_stub .text _start_stub: # 保存所有寄存器 pushq %rax pushq %rbx pushq %rcx pushq %rdx pushq %rsi pushq %rdi pushq %r8 pushq %r9 pushq %r10 pushq %r11 # rs指向代码段起始虚拟地址 movabs $CODE_START, %rsi # 解密字节数 movabs $CODE_SIZE, %rcx # 密钥起始地址存放在此数据段附近 lea key_data(%rip), %rdi decrypt_loop: movzbq (%rsi), %rax movzbq (%rdi), %rbx xorq %rbx, %rax movb %al, (%rsi) incq %rsi incq %rdi # 循环处理密钥索引 cmpq $16, %rdi jl skip_key_reset lea key_base(%rip), %rdi skip_key_reset: decq %rcx jnz decrypt_loop # 恢复寄存器 popq %r11 popq %r10 popq %r9 popq %r8 popq %rdi popq %rsi popq %rdx popq %rcx popq %rbx popq %rax # 跳转到原始入口地址 movabs $ORIG_ENTRY, %rax jmp *%rax .section .rodata key_base: .byte 0x11,0x22,0x33,0x44,0x55,0x66,0x77,0x88 .byte 0x99,0xaa,0xbb,0xcc,0xdd,0xee,0xff,0x00 key_data: .quad key_base这段代码里CODE_START、CODE_SIZE、ORIG_ENTRY都是加壳器在生成最终文件时通过修改汇编二进制或者用链接脚本填充的宏值。stub 本身被放在新增的PT_LOAD段映射为可执行段因此可以正常执行。decrypt_loop按字节对代码段做异或解密密钥长度为 16 字节循环使用。内存中代码段在映射时通常是可读可执行的但不可写——现代 Linux 默认开启 KERNEL TEXT 只读保护所以如果直接在原代码段解密会触发段错误。这也是为什么很多加壳器会把代码段重新映射到可写的匿名内存或者用mprotect修改段权限。在这个示例里如果生成的可执行文件段的PT_LOAD权限是R Estub 直接写代码段会崩溃。常见做法是把原代码段的p_flags改成RW E或把解密后的代码拷贝到栈上执行。3.3 密钥与加密算法怎么换par.c中传入的-k参数会被转成一组字节用于在加密时对代码段做异或。异或的好处是实现简单、性能开销小但安全性很弱只要攻击者知道密文格式用已知明文比如 ELF 中固定的字节就能恢复密钥。实际生产环境至少要换成 AES。如果要替换为 AES 加密通常使用 OpenSSL 库// 用 AES-128-CBC 替换异或 #include openssl/evp.h int encrypt_segment(unsigned char *data, size_t len, unsigned char *key, unsigned char *iv) { EVP_CIPHER_CTX *ctx EVP_CIPHER_CTX_new(); int outlen 0, finalLen 0; unsigned char *out malloc(len 16); EVP_EncryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, key, iv); EVP_EncryptUpdate(ctx, out, outlen, data, len); EVP_EncryptFinal_ex(ctx, out outlen, finalLen); memcpy(data, out, outlen finalLen); free(out); EVP_CIPHER_CTX_free(ctx); return outlen finalLen; }注意AES-CBC 是块密码长度必须是 16 的倍数而代码段大小不一定对齐。处理方式有两种一是只加密对齐后的大小且解密时多解密几个字节二是在加密前把代码段补齐到 16 字节的倍数但这样会改变段大小需要同步修改p_filesz和p_memsz。为了避免改动过多也可以使用 AES-CTR 模式它是流密码不需要处理填充长度完全一致。stub 中解密时走 OpenSSL 显然不可能因为 stub 是裸汇编最实际的做法是密钥固定在 stub 里运行时调用简单的解密函数——比如仍然用异或但密钥长度加长到 64 字节并混入随机数轮转增加分析难度。4. 实际编译与测试Makefile、gdb 验证和常见失败4.1 编译整个项目并生成加密样本拿到源码后先按项目自带的 Makefile 编译。如果make直接通过会生成elf_pack工具。然后用它加密一个测试程序cd elf_pack-master make gcc -o test test.c -no-pie -fno-stack-protector -Wall ./elf_pack -i test -o test_packed -k 0xDEADBEEF我一般会在编译test.c时就加上-no-pie否则生成的可执行文件是 PIE 形式代码段的虚拟地址在运行时才确定stub 中硬编码的CODE_START会失效。-fno-stack-protector是为了避免 gcc 默认加骶保护让反汇编结果更干净。编译完成后用readelf -h test_packed检查程序入口readelf -h test_packed | grep Entry如果正常入口点应该指向 stub 的虚拟地址而不是原来的0x401000之类的值。同时用readelf -l test_packed查看程序段会看到多了一个R E的LOAD段。4.2 用 test.c 验证加密前后行为test.c内容可能是打印一句话或者执行一个简单计算。运行加密后的文件./test_packed如果输出和./test完全一致说明解密 stub 成功。如果直接段错误先用dmesg | tail看内核日志常见的报错是segfault at ... ip ... sp ... error 7其中error 7表示写只读内存。这时需要用gdb观察gdb ./test_packed (gdb) b *0x401020 # 原入口地址 (gdb) r在断点处查看当前 RIP 和代码段内容确认代码是否已被正确解密。如果发现代码段字节仍然和加密前一样说明 stub 解密地址算错了。在 stub 中加几个nop作为标记然后用objdump -d test_packed对照偏移很快就能定位问题。.gdb_history文件的存在也说明作者当时就是靠 gdb 一步步调出来的。4.3 常见失败PIE、strip、段权限下面是我在调试类似项目时整理的排错表现象原因解决方式加密后执行报Segmentation fault代码段不可写stub 写回失败修改目标文件代码段的p_flags增加PF_W或解密到栈上再跳转入口地址正确但程序行为异常加密时把.init或.fini也加密了解密不完整只加密从入口开始的_start函数区域或者按 PT_LOAD 段加密readelf提示Impossible或无法加载程序头表被覆盖或新增 PT_LOAD 段时与原有段重叠检查e_phoff和新增段头位置重新布局程序头表加密后文件大小暴涨stub 对齐补丁过大将 stub 按页对齐避免创建大量无意义区块gdb显示入口在非映射地址e_entry写入时使用了未按页对齐的虚拟地址新增 PT_LOAD 段的p_vaddr必须按p_align对齐strip 的问题也很典型。strip test会移除节区头表动态符号表和调试信息但程序头表仍然保留。加壳器如果基于节区头section header来定位.textstrip 后节区头没了就会崩溃。这也是为什么建议加壳器尽量基于程序头program header来定位代码段因为程序头在 strip 后依然存在且是内核加载唯一依赖的信息。5. 把示例改成可落地的加密工具三个必须动的点5.1 用更安全的加密算法并管理好密钥示例中的异或 16 字节密钥在反汇编面前几乎没有防护力。在实际落地时我建议使用 AES-128-CTR 模式因为它是流密码且不需要处理分组对齐与逐字节解密天然兼容。密钥可以是从用户输入派生也可以编译进 stub但不要以明文常量放在 rodata 段。更稳妥的做法是先用外部密钥对 stub 中的密文密钥进行二次加密运行时再在栈上解密出来避免直接静态查找。// 加密代码段时调用 OpenSSL CTR 模式 unsigned char aes_key[16]; EVP_BytesToKey(EVP_aes_128_ctr(), EVP_sha256(), NULL, user_key, strlen(user_key), 1, aes_key, NULL); int tmp_len 0; EVP_EncryptUpdate(ctx, out, tmp_len, seg_data, seg_size); // 输出长度与输入完全一致不需要填充解密侧的 stub 无法依赖 OpenSSL因此需要自己用汇编或内嵌 C 写一个精简 AES 实现。这对嵌入式 Linux 环境尤其有用因为没有 glibc 依赖。5.2 处理动态链接与重定位如果目标程序是动态链接的_start会在调用main之前先执行动态链接器ld-linux的初始化代码。加密整个.interp或重定位节区会导致链接器无法工作。正确做法是只加密从入口地址开始的一部分内容或者在程序入口处先调用动态链接器的初始化再执行解密。一种可行的方案是保留e_entry不动在动态链接器完成初始化后、控制权到达原入口前拦截——这需要借助DF_1_INITFIRST标志或者 LD_PRELOAD 机制但那样就不算真正的加壳了。更实际的做法是对静态链接的可执行文件做整体代码段加密动态链接文件则只加密用户自定义函数保留 PLT 和 GOT 不动。攻击者虽然能看到导入函数但核心逻辑仍然被隐藏。5.3 反调试与完整性校验加壳后的程序最怕两种情况一是被调试器在 stub 解密后下断点二是被脱壳机直接把解密后的内存 dump 出来。常用的对策是加一段 200 毫秒的延时循环用于干扰自动调试或者对rsp缓冲区里的返回地址做 CRC 检查防止在 stub 执行期间被篡改。但也别做过火反调试做太强容易触发杀软误报尤其在内核有安全模块的系统中。更好的方式是在解密完成后立刻清零 stub 和密钥区域减小攻击面。elf_pack的.gdb_history里应该记录了很多这样的调试痕迹你可以自己复现一遍从run到x/20bx $rip逐字节观察解密前后变化。理解了入口转移的那一刻你对 ELF64 加载执行的理解会比其他只会用 packer 的人深一个层次。把这套示例代码跑通、改稳再移植到你自己的加密方案里会少走很多弯路。本文还有配套的精品资源点击获取