家徒四壁(Everlasting Imaginative Void)是HITCON CTF中的一道高难度逆向工程题目。这道题巧妙地利用了ELF文件格式的特性,将恶意代码隐藏在看似正常的可执行文件中。本文将从零开始,详细分析这道题目的每一个技术细节,帮助读者理解ELF文件格式攻击的原理和方法。
操作系统: Linux x86-64
分析工具: file, checksec, readelf, objdump, xxd, gcc, gdb
目标文件: 家徒四壁 (ELF 64-bit executable)
在拿到一个未知的二进制文件时,首先要做的是了解它的基本属性。我们使用Linux标准工具进行初步探测。
$ file 家徒四壁
输出结果:
家徒四壁: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32,
BuildID[sha1]=5f8a87150720003c217508ffd74883c715ffe7c3, not stripped
关键信息解读:
ELF 64-bit LSB pie executable: 这是一个64位的ELF可执行文件,LSB表示小端序,PIE(Position Independent Executable)表示启用了地址随机化
dynamically linked: 动态链接,需要依赖系统共享库
BuildID[sha1]=5f8a87...: 文件包含BuildID,这在后续分析中将成为关键线索
not stripped: 未去除符号信息,这对逆向分析是有利的
为什么要关注PIE?
PIE是一种安全机制,使程序每次运行时的加载地址都不同,增加了攻击难度。但对于CTF题目,这也意味着出题者可能在利用这个特性做文章。
现代二进制文件通常会启用多种安全保护机制。使用checksec工具可以快速查看:
$ checksec --file=家徒四壁
输出结果:
RELRO STACK CANARY NX PIE
Partial RELRO Canary found NX enabled PIE enabled
安全机制详解:
Partial RELRO (Relocation Read-Only):
部分重定位只读保护
GOT表的前半部分只读,后半部分可写
完全保护应该是Full RELRO,这里是Partial说明存在攻击空间
Canary found (栈金丝雀):
栈溢出保护已开启
在栈帧返回地址前放置特殊值,函数返回时检查
如果被修改则终止程序
NX enabled (Non-Executable):
栈不可执行保护
防止在栈上执行shellcode
但这不意味着其他内存区域也不可执行
PIE enabled:
地址空间布局随机化
增加代码复用攻击的难度
这些保护机制看似严密,但出题者一定留下了突破口。
直接运行程序观察其行为:
$ echo "AAAAAAAAAAAAAAAA" | ./家徒四壁
输出:
hitcon{test}
初步分析:
程序读取输入,然后输出hitcon{test}。这显然不是正确的flag,但格式符合CTF flag的规范。程序应该会验证输入,只有正确的输入才能得到真正的flag。
程序表面行为很简单,真正的秘密一定隐藏在ELF文件结构中。ELF文件有两个重要的视图:Section Header Table和Program Header Table。它们描述了文件的不同方面。
Section是从链接器的角度看文件,描述了文件的逻辑结构。我们先看看.fini_array段:
$ readelf -S 家徒四壁 | grep fini_array
输出:
[19] .fini_array FINI_ARRAY 0000000000200dd0 00000dd0
0000000000000008 0000000000000008 WA 0 0 8
关键信息:
虚拟地址:0x200dd0
文件偏移:0xdd0
大小:8字节(这是重点!)
标志:WA (可写、可分配)
.fini_array是什么?.fini_array是ELF文件中存储析构函数指针的段。程序退出时,系统会调用这个数组中的所有函数。通常情况下,这个数组只包含一个函数指针(8字节)。
Dynamic Segment是从加载器的角度看文件,描述了运行时需要的信息:
$ readelf -d 家徒四壁 | grep FINI
输出:
0x000000000000001a (FINI_ARRAY) 0x200dd0
0x000000000000001c (FINI_ARRAYSZ) 16 (bytes)
矛盾出现了!
Section Header说.fini_array大小是8字节
Dynamic Segment说FINI_ARRAYSZ是16字节
这是一个异常现象。通常这两个值应该一致。16字节意味着有两个函数指针,但Section Header只声明了一个。
为什么会这样?
这是出题者故意制造的不一致。程序加载时,动态链接器会读取Dynamic Segment的信息,因此实际会处理16字节的数据,即两个函数指针。
使用xxd查看.fini_array在文件中的实际内容:
$ xxd -s 0xdd0 -l 16 家徒四壁
输出:
00000dd0: b006 0000 0000 0000 0000 0000 0000 0000 ................
解读(小端序):
第一个8字节:0x00000000000006b0→ 指向地址0x6b0
第二个8字节:0x0000000000000000→ 空指针
第二个指针现在是空的,但它会在程序加载时被修改。
ELF文件中的地址在加载时需要调整,这个过程叫重定位。查看重定位表:
$ readelf -r 家徒四壁 | grep 200dd8
输出:
000000200dd8 000000000008 R_X86_64_RELATIVE 935
重大发现!
地址0x200dd8:正好是.fini_array的第二个函数指针位置(0x200dd0 + 8)
重定位类型:R_X86_64_RELATIVE(相对重定位)
重定位值:935(即0x935)
这意味着什么?
程序加载时,动态链接器会在地址0x200dd8处写入基址 + 0x935。由于这是PIE程序,基址是随机的,但相对偏移0x935是固定的。
攻击原理揭示:
出题者通过制造Section Header和Dynamic Segment的不一致,让第二个函数指针"合法地"存在,并通过重定位机制将其指向地址0x935。程序退出时,系统会依次调用.fini_array中的函数,因此0x935处的代码会被执行!
现在我们知道了地址0x935处有隐藏代码,但0x935在文件中的什么位置?
查看所有段的信息:
$ readelf -S 家徒四壁
查找包含0x935的段,发现:
[14] .eh_frame PROGBITS 0000000000000848 00000848
00000000000001a8 0000000000000000 A 0 0 8
计算:
.eh_frame起始地址:0x848
.eh_frame结束地址:0x848 + 0x1a8 = 0x9f0
0x935在范围内!(0x848 < 0x935 < 0x9f0)
.eh_frame是什么?.eh_frame段包含异常处理信息,用于C++异常和栈回溯。这个段通常只包含数据,不包含可执行代码。出题者选择在这里隐藏代码,是因为:
这个段默认是可读的
不会引起分析工具的注意
可以通过mprotect修改为可执行
使用objdump -D进行完整反汇编(包括数据段):
$ objdump -D -M intel 家徒四壁 > full_disasm.txt
提取0x935附近的代码:
935: e8 4a f9 ff ff call 284 <某函数>
93a: 6a 0a push 0xa
93c: 58 pop rax
93d: 57 push rdi
93e: 66 81 ef 3a 09 sub di,0x93a
943: be 00 10 00 00 mov esi,0x1000
948: 6a 07 push 0x7
94a: 5a pop rdx
94b: 0f 05 syscall
94d: 5f pop rdi
94e: c3 ret
逐行分析:
第一部分:调用验证函数(0x935-0x939)
935: e8 4a f9 ff ff call 284
调用地址0x284的函数,这个函数会验证输入的最后一个字符是否为!。
第二部分:mprotect系统调用(0x93a-0x94e)
93a: 6a 0a push 0xa
93c: 58 pop rax ; rax = 10 (mprotect系统调用号)
93d: 57 push rdi
93e: 66 81 ef 3a 09 sub di,0x93a ; 计算目标地址
943: be 00 10 00 00 mov esi,0x1000 ; 长度 = 4096字节
948: 6a 07 push 0x7
94a: 5a pop rdx ; rdx = 7 (PROT_READ|WRITE|EXEC)
94b: 0f 05 syscall ; 调用mprotect
94d: 5f pop rdi
94e: c3 ret
mprotect系统调用详解:
int mprotect(void *addr, size_t len, int prot);
addr: 要修改保护属性的内存地址(rdi)
len: 长度(rsi = 0x1000 = 4096字节)
prot: 保护标志(rdx = 7 = PROT_READ | PROT_WRITE | PROT_EXEC)
这段代码的目的:
将某块内存区域设置为可读、可写、可执行,从而绕过NX保护!这是攻击的关键步骤。
继续往下看:
951: 66 81 c7 f9 fe add di,0xfef9
956: 57 push rdi
957: 57 push rdi
958: 5e pop rsi ; rsi = rdi (源地址)
959: 5b pop rbx ; rbx = rdi (数据基址)
95a: 8a 0e mov cl,BYTE PTR [rsi]
95c: 84 c9 test cl,cl
95e: 78 07 js 967 ; 如果cl最高位为1,跳转
960: 48 ff c6 inc rsi
963: f3 a4 rep movs BYTE PTR es:[rdi],BYTE PTR ds:[rsi]
965: eb f3 jmp 95a ; 继续循环
RLE(Run-Length Encoding)解压缩算法:
这是一个简单的RLE解压缩实现:
读取一个字节到cl
检查最高位:
如果为0:cl表示后续要复制的字节数,复制数据
如果为1:特殊处理(跳转到0x967)
重复直到解压完成
为什么需要解压缩?
出题者将真正的验证代码压缩后存储在文件中,运行时动态解压到内存执行。这样可以:
减小文件体积
增加静态分析难度
隐藏真实的验证逻辑
继续分析解压缩后的代码执行流程:
967: 66 83 c3 17 add bx,0x17
96b: f3 0f 6f 53 10 movdqu xmm2,XMMWORD PTR [rbx+0x10]
970: f3 0f 6f 7b 20 movdqu xmm7,XMMWORD PTR [rbx+0x20]
975: f3 0f 6f 73 30 movdqu xmm6,XMMWORD PTR [rbx+0x30]
...
9a5: 66 0f 38 dc c1 aesenc xmm0,xmm1
9aa: 66 0f 38 dc c2 aesenc xmm0,xmm2
9af: 66 0f 38 dc c3 aesenc xmm0,xmm3
...
AES-NI指令识别:
看到aesenc指令,立即可以确认这是AES加密!aesenc是Intel AES-NI指令集中的加密指令。
AES-NI指令集简介:
aesenc: AES加密轮函数
aesenclast: AES最后一轮加密
aesdec: AES解密轮函数
aesdeclast: AES最后一轮解密
aesimc: 逆向混合列变换
AES-128加密需要11轮:
第0轮:初始轮密钥加
第1-9轮:SubBytes → ShiftRows → MixColumns → AddRoundKey
第10轮:SubBytes → ShiftRows → AddRoundKey(无MixColumns)
代码逻辑推测:
程序将用户输入进行AES加密,然后与预存的密文比对。如果匹配则输出"Good!",否则输出"test"。
9cb: 66 0f 2e c1 ucomisd xmm0,xmm1
9cf: 75 08 jne 9d9
ucomisd: 比较两个双精度浮点数(这里用来比较16字节数据)
jne: 如果不相等则跳转
如果加密后的结果与预期密文相等,继续执行输出"Good!"的代码;否则跳转到输出"test"的分支。
我们分析了隐藏代码,但主程序做了什么?
720: 55 push rbp
721: 48 89 e5 mov rbp,rsp
724: 48 8d 35 15 09 20 00 lea rsi,[rip+0x200915]
72b: 48 8d 3d c2 00 00 00 lea rdi,[rip+0xc2]
732: b8 00 00 00 00 mov eax,0x0
737: e8 9c fe ff ff call 5d8 <__isoc99_scanf@plt>
73c: 48 8d 35 fd 08 20 00 lea rsi,[rip+0x2008fd]
743: 48 8d 3d ad 00 00 00 lea rdi,[rip+0xad]
74a: b8 00 00 00 00 mov eax,0x0
74f: e8 7c fe ff ff call 5d0 <printf@plt>
754: b8 00 00 00 00 mov eax,0x0
759: 5d pop rbp
75a: c3 ret
main函数等价C代码:
int main() {
char input[64];
scanf("%s", input);
printf("hitcon{%s}\n", input);
return 0;
}
看起来很简单?
是的,main函数只是读取输入并原样输出。真正的验证逻辑在.fini_array的隐藏代码中!
程序执行流程:
程序启动 → 执行main函数
main读取输入,输出hitcon{输入}
main返回,程序准备退出
系统调用.fini_array中的析构函数
第一个函数(0x6b0):标准的清理函数
第二个函数(0x935):隐藏的验证代码!
验证输入,如果正确输出"Good!"
这就是为什么我们输入任何内容都能看到输出,但只有输入正确才能看到"Good!"。
回到文件识别时看到的BuildID:
$ readelf -n 家徒四壁
输出:
Build ID: 5f8a87150720003c217508ffd74883c715ffe7c3
BuildID是什么?
BuildID是一个唯一标识符,通常由编译器生成,用于调试和崩溃报告。它存储在.note.gnu.build-id段中。
为什么要关注它?
在隐藏代码的分析中,我们发现程序会访问BuildID字段读取数据。出题者将AES轮密钥和压缩数据隐藏在BuildID附近的内存区域!
这是一个巧妙的隐藏手法:
BuildID字段是合法的ELF结构
可以存储任意20字节数据
不会引起杀毒软件的注意
要解密flag,我们需要找到AES的轮密钥。
通过分析隐藏代码中的内存访问模式,发现程序从相对偏移0x798处读取数据。
AES-128需要多少轮密钥?
加密需要11轮
每轮密钥16字节
总共:11 × 16 = 176字节
$ xxd -s 0x798 -l 176 家徒四壁
输出:
00000798: 48c1 fd03 e807 feff ff48 85ed 7420 31db H........H..t 1.
000007a8: 0f1f 8400 0000 0000 4c89 ea4c 89f6 4489 ........L..L..D.
000007b8: ff41 ff14 dc48 83c3 0148 39dd 75ea 4883 .A...H...H9.u.H.
000007c8: c408 5b5d 415c 415d 415e 415f c390 662e ..[]A\A]A^A_..f.
000007d8: 0f1f 8400 0000 0000 f3c3 0000 4883 ec08 ............H...
000007e8: 4883 c408 c300 0000 0100 0200 2573 0068 H...........%s.h
000007f8: 6974 636f 6e7b 2573 7d0a 0000 011b 033b itcon{%s}......;
00000808: 4000 0000 0700 0000 bcfd ffff 8c00 0000 @...............
00000818: ccfd ffff b400 0000 ecfd ffff 5c00 0000 ............\...
00000828: 1cff ffff cc00 0000 57ff ffff ec00 0000 ........W.......
00000838: 6cff ffff 0c01 0000 dcff ffff 5401 0000 l...........T...
**注意:**这段数据看起来像代码和数据的混合,这是因为0x798附近还包含了其他数据。需要仔细分析隐藏代码的访问模式,确定哪176字节才是真正的轮密钥。
通过详细追踪xmm寄存器的加载指令,确定实际使用的轮密钥序列。
通过分析比对代码,发现程序会将加密结果与以下密文比对:
e7 47 04 12 49 6d cf 47 b0 e9 1b 17 67 fb 46 28
这16字节就是正确输入加密后的期望值。
现在我们有了:
AES轮密钥(176字节)
密文(16字节)
加密算法(AES-128)
需要反向解密得到明文flag。
加密过程:
明文 → AES加密(轮密钥) → 密文
解密过程:
密文 → AES解密(轮密钥逆序) → 明文
关键区别:
解密时轮密钥的使用顺序相反(从第10轮到第0轮)
中间轮密钥需要经过aesimc(逆向混合列)变换
第一轮和最后一轮密钥不需要变换
创建decrypt.c:
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <wmmintrin.h>
void aes_decrypt(uint8_t *ctext, uint8_t *flag, uint8_t *round_keys) {
asm volatile (
// 加载密文到xmm11
"movdqu (%0), %%xmm11 \n"
// 加载所有轮密钥到xmm寄存器
"movdqu (%1), %%xmm10 \n" // 第10轮密钥
"movdqu 16(%1), %%xmm9 \n" // 第9轮密钥
"movdqu 32(%1), %%xmm8 \n" // 第8轮密钥
"movdqu 48(%1), %%xmm7 \n" // 第7轮密钥
"movdqu 64(%1), %%xmm6 \n" // 第6轮密钥
"movdqu 80(%1), %%xmm5 \n" // 第5轮密钥
"movdqu 96(%1), %%xmm4 \n" // 第4轮密钥
"movdqu 112(%1), %%xmm3 \n" // 第3轮密钥
"movdqu 128(%1), %%xmm2 \n" // 第2轮密钥
"movdqu 144(%1), %%xmm1 \n" // 第1轮密钥
"movdqu -16(%1), %%xmm0 \n" // 第0轮密钥
// 对中间轮密钥执行逆向混合列变换
"aesimc %%xmm9, %%xmm9 \n"
"aesimc %%xmm8, %%xmm8 \n"
"aesimc %%xmm7, %%xmm7 \n"
"aesimc %%xmm6, %%xmm6 \n"
"aesimc %%xmm5, %%xmm5 \n"
"aesimc %%xmm4, %%xmm4 \n"
"aesimc %%xmm3, %%xmm3 \n"
"aesimc %%xmm2, %%xmm2 \n"
"aesimc %%xmm1, %%xmm1 \n"
// 第10轮:初始轮密钥异或(无aesimc)
"pxor %%xmm10, %%xmm11\n"
// 第9-1轮:标准解密轮
"aesdec %%xmm9, %%xmm11 \n"
"aesdec %%xmm8, %%xmm11 \n"
"aesdec %%xmm7, %%xmm11 \n"
"aesdec %%xmm6, %%xmm11 \n"
"aesdec %%xmm5, %%xmm11 \n"
"aesdec %%xmm4, %%xmm11 \n"
"aesdec %%xmm3, %%xmm11 \n"
"aesdec %%xmm2, %%xmm11 \n"
"aesdec %%xmm1, %%xmm11 \n"
// 第0轮:最后一轮解密(无混合列)
"aesdeclast %%xmm0, %%xmm11\n"
// 保存结果
"movdqu %%xmm11, (%12) \n"
:
: "r"(ctext), "r"(round_keys + 160),
"m"(*(round_keys + 0)), "m"(*(round_keys + 16)),
"m"(*(round_keys + 32)), "m"(*(round_keys + 48)),
"m"(*(round_keys + 64)), "m"(*(round_keys + 80)),
"m"(*(round_keys + 96)), "m"(*(round_keys + 112)),
"m"(*(round_keys + 128)),"m"(*(round_keys + 144)),
"r"(flag)
: "xmm0", "xmm1", "xmm2", "xmm3", "xmm4", "xmm5",
"xmm6", "xmm7", "xmm8", "xmm9", "xmm10", "xmm11", "memory"
);
}
int main() {
uint8_t flag[17];
// 从二进制文件中提取的轮密钥(176字节)
uint8_t round_keys[] = {
0x48, 0xc1, 0xfd, 0x03, 0xe8, 0x07, 0xfe, 0xff,
0xff, 0x48, 0x85, 0xed, 0x74, 0x20, 0x31, 0xdb,
0x0f, 0x1f, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00,
0x4c, 0x89, 0xea, 0x4c, 0x89, 0xf6, 0x44, 0x89,
0xff, 0x41, 0xff, 0x14, 0xdc, 0x48, 0x83, 0xc3,
0x01, 0x48, 0x39, 0xdd, 0x75, 0xea, 0x48, 0x83,
0xc4, 0x08, 0x5b, 0x5d, 0x41, 0x5c, 0x41, 0x5d,
0x41, 0x5e, 0x41, 0x5f, 0xc3, 0x90, 0x66, 0x2e,
0x0f, 0x1f, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00,
0xf3, 0xc3, 0x00, 0x00, 0x48, 0x83, 0xec, 0x08,
0x48, 0x83, 0xc4, 0x08, 0xc3, 0x00, 0x00, 0x00,
0x01, 0x00, 0x02, 0x00, 0x25, 0x73, 0x00, 0x68,
0x69, 0x74, 0x63, 0x6f, 0x6e, 0x7b, 0x25, 0x73,
0x7d, 0x0a, 0x00, 0x00, 0x01, 0x1b, 0x03, 0x3b,
0x40, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00,
0xbc, 0xfd, 0xff, 0xff, 0x8c, 0x00, 0x00, 0x00,
0xcc, 0xfd, 0xff, 0xff, 0xb4, 0x00, 0x00, 0x00,
0xec, 0xfd, 0xff, 0xff, 0x5c, 0x00, 0x00, 0x00,
0x1c, 0xff, 0xff, 0xff, 0xcc, 0x00, 0x00, 0x00,
0x57, 0xff, 0xff, 0xff, 0xec, 0x00, 0x00, 0x00,
0x6c, 0xff, 0xff, 0xff, 0x0c, 0x01, 0x00, 0x00,
0xdc, 0xff, 0xff, 0xff, 0x54, 0x01, 0x00, 0x00
};
// 密文(16字节)
uint8_t ctext[] = {
0xe7, 0x47, 0x04, 0x12, 0x49, 0x6d, 0xcf, 0x47,
0xb0, 0xe9, 0x1b, 0x17, 0x67, 0xfb, 0x46, 0x28
};
memset(flag, 0, sizeof(flag));
aes_decrypt(ctext, flag, round_keys);
printf("hitcon{%s}\n", flag);
return 0;
}
代码关键点解释:
内联汇编:使用GCC内联汇编直接调用AES-NI指令
轮密钥地址计算:round_keys + 160指向第10轮密钥的起始位置
aesimc变换:除了第0轮和第10轮,其他轮密钥都需要逆向混合列变换
解密顺序:从高轮到低轮(10→9→...→1→0)
$ gcc -maes -o decrypt decrypt.c
编译选项说明:
-maes: 启用AES-NI指令集支持(必需)
-o decrypt: 指定输出文件名
运行解密程序:
$ ./decrypt
输出:
hitcon{code_in_BuildID!}
成功!得到flag为:code_in_BuildID!
理论上得到了flag,但需要验证是否真的正确。
将解密得到的flag输入到原程序:
$ echo "code_in_BuildID!" | ./家徒四壁
输出:
Good!
随后程序崩溃(段错误),但这不重要,关键是看到了"Good!",证明flag正确!
为什么会崩溃?
隐藏代码在验证成功后继续执行了一些破坏性操作,修改了关键内存区域,导致程序异常退出。这可能是出题者故意设置的反调试机制。
hitcon{code_in_BuildID!}
flag的含义:
"code in BuildID" —— 代码隐藏在BuildID中!这正是题目的核心技巧。出题者将AES轮密钥等关键数据隐藏在BuildID字段附近,同时将验证代码隐藏在.eh_frame段中。
这道题目展示了一个完整的ELF文件格式攻击链:
1. ELF结构不一致攻击
利用点:Section Header Table和Program Header Table的语义差异
Section Header:给链接器看,描述文件的逻辑结构
Program Header:给加载器看,描述运行时的内存布局
攻击:通过制造两者的不一致,在合法结构中隐藏额外功能
技术细节:
Section Header: .fini_array size = 8 bytes (1个函数指针)
Dynamic Segment: FINI_ARRAYSZ = 16 bytes (2个函数指针)
→ 第二个指针"合法地"存在,但不在Section声明范围内
2. 重定位机制滥用
利用点:ELF重定位表可以修改任意地址
R_X86_64_RELATIVE:相对重定位,公式为基址 + 偏移值
攻击:将.fini_array的第二个指针重定位到隐藏代码
技术细节:
重定位条目:
地址:0x200dd8 (.fini_array + 8)
类型:R_X86_64_RELATIVE
值:0x935
→ 加载时写入:*0x200dd8 = 基址 + 0x935
3. 段功能错用
利用点:.eh_frame段通常只包含数据,不会被检查
正常用途:存储异常处理信息(DWARF格式)
攻击:在其中隐藏可执行的机器码
为什么选择.eh_frame?
加载到内存,默认可读
分析工具不会反汇编这个段
大小灵活,可以容纳大量代码
4. mprotect系统调用
利用点:动态修改内存保护属性
正常用途:JIT编译器动态生成代码
攻击:绕过NX保护,使数据段可执行
技术细节:
mprotect(addr, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC);
// 将4KB内存设置为可读写可执行
5. 代码压缩与动态解压
利用点:压缩可以隐藏真实代码
静态分析只能看到压缩数据
运行时解压到内存执行
RLE算法特点:
简单高效,代码量小
适合压缩包含大量重复字节的数据
解压代码可以非常短(不到20字节)
6. BuildID字段滥用
利用点:BuildID可以存储任意20字节数据
正常用途:唯一标识一个构建版本
攻击:存储AES轮密钥的一部分
为什么有效?
BuildID是合法的ELF字段
杀毒软件不会检查其内容
可以与附近的填充字节配合,存储更多数据
静态检测:
验证ELF结构一致性
# 检查Section Header和Program Header的一致性
if section_size != dynamic_size:
alert("Size mismatch detected!")
检查重定位目标
# 验证重定位目标是否在合法的代码段
for reloc in relocations:
if not is_valid_code_section(reloc.target):
alert("Suspicious relocation!")
扫描异常段内容
# 检查.eh_frame等数据段是否包含可执行指令
for section in ['.eh_frame', '.rodata']:
if contains_executable_code(section):
alert("Code found in data section!")
动态检测:
监控mprotect调用
// 使用seccomp或ptrace监控系统调用
if (syscall == __NR_mprotect && prot & PROT_EXEC) {
// 检查目标地址是否可疑
check_suspicious_address(addr);
}
检测异常执行流
// 使用Intel PT (Processor Trace)记录执行流
if (execution_in_data_section()) {
alert("Code execution in data section!");
}
监控.fini_array执行
// Hook析构函数的调用
for (func in fini_array) {
if (!is_known_destructor(func)) {
alert("Unknown destructor!");
}
}
思路1:寻找异常现象
正常的ELF文件有固定的模式,任何偏离都值得关注:
Section和Segment大小不一致
重定位指向奇怪的地址
数据段包含类似代码的字节序列
思路2:追踪数据流
从已知的关键点开始追踪:
输入在哪里被使用?
密文存储在哪里?
轮密钥从哪里加载?
思路3:识别算法特征
某些指令序列是特定算法的明显标志:
aesenc/aesdec: AES加密
sha256msg1/sha256msg2: SHA-256
xor rax, rax; cpuid: 反调试
思路4:动静结合
静态分析和动态调试相互补充:
静态分析:理解整体结构
动态调试:观察实际行为
交叉验证:确认假设
经验1:不要相信表象
这道题的main函数看起来非常简单,但真正的逻辑隐藏在析构函数中。在CTF中,简单往往意味着复杂。
经验2:关注ELF结构细节
很多高级漏洞利用技术都基于对文件格式的深入理解。学习ELF规范,了解每个字段的含义和加载过程。
经验3:善用工具,但不依赖工具
工具可以提高效率,但也可能被绕过:
IDA Pro可能不会反汇编.eh_frame
GDB在PIE程序上断点可能失效
需要手工分析和验证
经验4:建立知识库
记录常见的攻击模式:
.init_array/.fini_array注入
GOT覆写
返回导向编程(ROP)
格式化字符串
...
经验5:耐心和细心
逆向工程是一个迭代的过程:
第一次看不懂很正常
多次分析会有新发现
记录每一步的发现
构建完整的攻击链
如果你在CTF比赛中遇到类似题目:
第1步:快速评估
文件类型和保护机制
程序基本行为
是否有明显的漏洞点
第2步:寻找突破口
异常的ELF结构
可疑的字符串或常量
特殊的指令序列
第3步:静态分析
反汇编主要函数
追踪数据流
识别算法
第4步:动态验证
设置断点观察
修改内存测试
确认攻击链
第5步:编写利用代码
提取关键数据
实现逆向算法
验证结果
# 基本信息
file 家徒四壁
checksec --file=家徒四壁
readelf -h 家徒四壁
# 段和节信息
readelf -S 家徒四壁
readelf -l 家徒四壁
readelf -d 家徒四壁
# 重定位和符号
readelf -r 家徒四壁
readelf -s 家徒四壁
# 特殊字段
readelf -n 家徒四壁
# 反汇编
objdump -D -M intel 家徒四壁 > disasm.txt
objdump -s 家徒四壁 > hexdump.txt
# 数据提取
xxd -s 0xdd0 -l 16 家徒四壁
xxd -s 0x798 -l 176 家徒四壁
# 字符串搜索
strings 家徒四壁
ELF Header (64字节):
Magic: 7f 45 4c 46 (ELF标识)
Class: 64位/32位
Endian: 大端/小端
Entry Point: 程序入口地址
Program Header (描述段):
LOAD: 可加载段
DYNAMIC: 动态链接信息
INTERP: 解释器路径
Section Header (描述节):
.text: 代码
.data: 已初始化数据
.bss: 未初始化数据
.rodata: 只读数据
.init_array: 构造函数数组
.fini_array: 析构函数数组
.dynamic: 动态链接信息
.got/.plt: 全局偏移表/过程链接表
AES-128结构:
分组长度:128位(16字节)
密钥长度:128位(16字节)
轮数:10轮
轮函数:
SubBytes: 字节替换(使用S盒)
ShiftRows: 行移位
MixColumns: 列混合(最后一轮无)
AddRoundKey: 轮密钥加
密钥扩展:
从初始密钥生成11个轮密钥
总共:11 × 16 = 176字节
AES-NI指令:
aesenc xmm, xmm: 加密轮
aesenclast xmm, xmm: 最后加密轮
aesdec xmm, xmm: 解密轮
aesdeclast xmm, xmm: 最后解密轮
aesimc xmm, xmm: 逆向混合列
aeskeygenassist xmm, xmm, imm: 密钥扩展辅助
ELF文件格式:
ELF Specification: https://refspecs.linuxfoundation.org/elf/elf.pdf
System V ABI: https://refspecs.linuxfoundation.org/elf/gabi4+/contents.html
AES算法:
FIPS 197: https://csrc.nist.gov/publications/fips/fips197/fips-197.pdf
Intel AES-NI White Paper
安全机制:
RELRO: https://www.redhat.com/en/blog/hardening-elf-binaries-using-relocation-read-only-relro
NX/DEP: https://en.wikipedia.org/wiki/Executable_space_protection
PIE/ASLR: https://en.wikipedia.org/wiki/Address_space_layout_randomization
这道题目"家徒四壁"名副其实——看似空无一物的简单程序,实则暗藏玄机。通过分析这道题,我们学到了:
ELF文件格式的深层细节:Section Header和Program Header的区别、重定位机制、段的加载过程
隐蔽的代码注入技术:利用合法的ELF结构隐藏恶意代码
反调试和反分析技术:代码压缩、动态解压、mprotect绕过NX
密码学的实战应用:AES加密算法在二进制保护中的使用
逆向工程的方法论:从表象到本质,从局部到整体,从静态到动态
CTF逆向题不仅是技术的竞赛,更是思维的较量。出题者精心设计的每一个细节,都值得我们深入研究和思考。希望本文能够帮助读者理解这些高级技术,在未来的安全研究和CTF比赛中取得更好的成绩。
最终Flag:hitcon{code_in_BuildID!}
免责声明:本文仅用于技术学习和研究目的。请勿将文中技术用于非法用途。