本文将带你深入分析一道经典的CTF逆向题目,从ELF文件结构到反调试技术,从静态分析到动态调试,完整展示一个安全研究员的分析思路和技术细节。
作为安全研究员,当我们拿到一个二进制文件时,第一步永远是了解目标。这不是盲目操作,而是建立对目标的基本认知。
$ file babyre
babyre: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux),
statically linked, for GNU/Linux 2.6.32,
BuildID[sha1]=195512e1a41edaada81b286305ae0e37239acacc, stripped
关键信息解读:
ELF 64-bit: Linux可执行文件,64位架构
statically linked: 静态链接 - 这解释了为什么文件有825KB
stripped: 符号表被移除 - 增加了逆向难度
为什么关注这些信息?
静态链接意味着所有库函数都编译进了二进制文件,会增加分析复杂度
stripped表示没有函数名等符号信息,需要通过其他方式定位关键函数
64位架构决定了指针大小、寄存器使用等细节
在深入分析前,先观察程序的外部行为是一个好习惯。
$ echo "test_input" | ./babyre
fail!Input:
观察结果:
程序接受标准输入
错误输入会输出 "fail!"
程序正常退出(无崩溃)
这告诉我们什么?
存在输入验证逻辑
可能有 "success" 分支(对应 "fail")
程序结构相对稳定
普通的ELF分析会关注.text(代码段)和main函数,但经验告诉我们,CTF题目经常在非常规位置隐藏关键逻辑。
ELF格式支持两个特殊的函数数组:
.init_array: 在main函数之前执行
.fini_array: 在main函数之后执行
为什么出题者会用这些段?
隐蔽性高 - 大多数逆向新手不会注意到
执行时机特殊 - 可以做初始化检测或清理工作
绕过常规分析 - 直接看main函数会漏掉关键逻辑
让我们检查这个程序:
$ readelf -S babyre | grep -E "(init_array|fini_array)"
[19] .init_array INIT_ARRAY 00000000006caec8 000caec8
[20] .fini_array FINI_ARRAY 00000000006caee0 000caee0
发现:两个段都存在!这引起了我们的警觉。
这些段存储的是函数指针数组。让我们提取具体地址:
$ readelf -x .init_array babyre
0x006caec8 70094000 00000000 ee0e4000 00000000
0x006caed8 f0054000 00000000
$ readelf -x .fini_array babyre
0x006caee0 40094000 00000000 ce0c4000 00000000
0x006caef0 c0054000 00000000
关键:如何解析这些数据?
由于是64位程序且使用小端序(LSB),每个函数指针占8字节:
init_array解析:
0x70094000 00000000 → 0x0000000000400970
0xee0e4000 00000000 → 0x0000000000400eee ← 重点关注
0xf0054000 00000000 → 0x00000000004005f0
fini_array解析:
0x40094000 00000000 → 0x0000000000400940
0xce0c4000 00000000 → 0x0000000000400cce ← 重点关注
0xc0054000 00000000 → 0x00000000004005c0
**为什么标记这两个地址?**经验告诉我们,数组中间位置的函数往往是自定义代码(第一个和最后一个可能是编译器自动添加的)。
让我们先看 init_array[1] = 0x400eee这个函数:
$ objdump -d babyre | grep -A 20 "^ 400eee:"
400eee: push %rbp
400eef: mov %rsp,%rbp
400ef2: mov $0x0,%rdi
400ef9: mov $0x0,%rsi
400f00: mov $0x0,%rdx
400f07: mov $0x0,%r10
400f0e: mov $0x65,%rax # 系统调用号 101
400f15: syscall # 执行系统调用
400f17: mov %rax,0x6d1d60 # 保存返回值
400f1f: mov 0x2d0e3b(%rip),%eax
400f25: test %eax,%eax # 测试返回值
400f27: jns 0x400f33 # 如果>=0,跳过exit
400f29: mov $0x1,%edi
400f2e: call 0x413a60 # 否则调用exit
400f33: nop
400f34: pop %rbp
400f35: ret
这是什么技术?
系统调用号 0x65 = 101对应 Linux 的 ptrace系统调用。参数全为0表示调用 ptrace(PTRACE_TRACEME, 0, 0, 0)。
ptrace(PTRACE_TRACEME) 工作原理:
// 伪代码
int ret = ptrace(PTRACE_TRACEME, 0, 0, 0);
if (ret < 0) {
// 已被调试器attach,返回 -1
exit(1);
} else {
// 未被调试,返回 0,继续执行
}
为什么这能检测调试器?
Linux规定:一个进程只能被一个调试器trace。当程序在GDB中运行时:
GDB已经通过ptrace attach了这个进程
程序自己调用PTRACE_TRACEME会失败(返回-1)
程序检测到这个失败,就知道自己正被调试
这是一个巧妙的反调试技术!
方法1:修改二进制文件
直接NOP掉检测代码
或修改跳转逻辑
方法2:动态修改寄存器(我们采用的方法)
在GDB中,在syscall执行后、保存返回值之前修改RAX寄存器:
# 在syscall之后的地址设置断点
break *0x400f17
# 自动执行的命令
commands
silent
set $rax = 0 # 将返回值从-1改为0
continue
end
为什么选择这个位置?
0x400f15是syscall指令
0x400f17是syscall的下一条指令
在这里修改RAX,程序会认为ptrace调用成功(返回0)
这展示了动态调试的强大之处:我们可以在运行时修改程序行为!
接下来分析 fini_array[1] = 0x400cce,这个函数在main执行完毕后运行。
为什么验证逻辑会放在fini_array?
这是一个聪明的设计:
用户不会想到在main之后还有代码执行
即使找到main函数,看到的可能只是输入读取
真正的验证藏在退出前执行
在验证函数中搜索常量,通常能发现比较值:
$ objdump -d babyre | grep "movabs.*\$0x"
400dcd: movabs $0x7d646f6f675f7369,%rax
400dd7: cmp %rax,%rdx
发现一个硬编码的常量:0x7d646f6f675f7369
什么是小端序(Little-Endian)?
在x86/x64架构中,多字节数据的最低有效字节存储在最低地址。
例如,0x7d646f6f675f7369在内存中的布局:
地址: 值:
0x00 0x69 ← 最低字节
0x01 0x73
0x02 0x5f
0x03 0x67
0x04 0x6f
0x05 0x6f
0x06 0x64
0x07 0x7d ← 最高字节
转换为ASCII字符:
>>> import struct
>>> struct.pack('<Q', 0x7d646f6f675f7369)
b'is_good}'
>>> struct.pack('<Q', 0x7d646f6f675f7369).decode('ascii')
'is_good}'
重大发现:比较值是字符串 "is_good}"
分析思路:
观察模式:"is_good}"包含一个右花括号 }
CTF惯例:Flag格式通常是 flag{...}
语义分析:is_good像是一个形容词短语
完整推断:前面应该还有内容,格式应该是 flag{XXX_is_good}
结合题目环境:
验证函数在 .fini_array段
"fini" 是 "finish/final" 的缩写
推测:flag{fini_is_good}
这是一个合理的猜测,但需要验证!
静态分析让我们推断出可能的flag,但还有几个问题未解决:
验证逻辑的完整流程是什么?
有多少次比较?
我们的flag推断是否正确?
动态调试能让我们:
观察实际的程序执行流程
查看寄存器和内存的实时状态
验证我们的假设
基于静态分析,我们设计如下调试方案:
# 1. 绕过反调试
break *0x400f17
commands
silent
set $rax = 0
continue
end
# 2. 在验证函数入口设置断点
break *0x400cce
commands
silent
echo [*] 进入验证函数\n
continue
end
# 3. 在第一次比较处设置断点
break *0x400dd7
commands
silent
echo [*] 第一次比较\n
printf "实际值(rdx) = 0x%016lx\n", $rdx
printf "期望值(rax) = 0x%016lx\n", $rax
continue
end
运行调试脚本,输入推断的flag:
$ echo "flag{fini_is_good}" > /tmp/input.txt
$ gdb -batch -x debug.gdb ./babyre < /tmp/input.txt
调试输出:
[*] 进入验证函数
[*] 第一次比较
实际值(rdx) = 0x7d646f6f675f7369
期望值(rax) = 0x7d646f6f675f7369
[] 通过
[*] 第二次比较
实际值(rdx) = 0x2f34ed427b495c01
期望值(rax) = 0x2f34ed427b495c01
[] 通过
[*] 第三次比较
实际值(rdx) = 0x8b526a1e2eabaa4f
期望值(rax) = 0x8b526a1e2eabaa4f
[] 通过
[*] 第四次比较
实际值(rax) = 0x000000000000840f
期望值 = 0x000000000000840f
[] 通过
[] 所有验证通过!
success!
验证成功!所有4次比较都通过了!
动态调试揭示了完整的验证机制:
4次比较的含义:
第一次比较 (0x400dd7):检查加密后的前8字节
预期值:is_good}(字符串的一部分)
第二次比较 (0x400e59):检查中间8字节
预期值:0x2f34ed427b495c01(加密数据)
第三次比较 (0x400e76):检查后面字节
预期值:0x8b526a1e2eabaa4f(加密数据)
第四次比较 (0x400e89):检查长度或校验
预期值:0x840f
这说明什么?
程序对输入进行了某种变换(可能是加密),然后分段比较结果。只有所有部分都匹配,才输出 "success!"。
在 0x400cce(验证函数入口)和 0x400dd7(第一次比较)之间,一定存在加密/变换逻辑。
通过分析汇编代码,我们可以识别出典型的加密算法特征:
RC4算法特征:
初始化一个256字节的S盒
使用密钥打乱S盒(KSA - Key Scheduling Algorithm)
生成密钥流与明文异或(PRGA - Pseudo-Random Generation Algorithm)
在代码中可以找到:
循环256次的初始化逻辑
字节交换操作(S[i], S[j] = S[j], S[i])
异或运算
这是经典的RC4流密码实现!
为什么使用RC4?
RC4是一个简单但有效的流密码算法,常用于CTF题目:
代码量小,适合嵌入
算法简单,可以手工实现
可逆性强(加密=解密)
RC4工作原理(简化说明):
def rc4_crypt(data, key):
# 1. 初始化S盒 (0-255)
S = list(range(256))
# 2. 密钥调度算法 (KSA)
j = 0
for i in range(256):
j = (j + S[i] + key[i % len(key)]) % 256
S[i], S[j] = S[j], S[i] # 交换
# 3. 伪随机数生成算法 (PRGA)
i = j = 0
result = []
for byte in data:
i = (i + 1) % 256
j = (j + S[i]) % 256
S[i], S[j] = S[j], S[i]
K = S[(S[i] + S[j]) % 256] # 生成密钥流
result.append(byte ^ K) # 异或加密
return bytes(result)
关键点:
RC4加密和解密使用相同的函数
密钥是关键:encrypt(encrypt(data, key), key) = data
1. 初步分析
├─ file命令识别文件类型
├─ 运行测试观察行为
└─ 确定是验证型程序
2. 静态分析
├─ 检查ELF特殊段 (.init_array, .fini_array)
├─ 提取函数地址
├─ 识别反调试代码 (ptrace)
└─ 发现比较常量 (is_good})
3. 信息推断
├─ 从比较值推断flag格式
├─ 结合fini提示
└─ 得到候选flag: flag{fini_is_good}
4. 动态验证
├─ 设计GDB调试脚本
├─ 绕过反调试检测
├─ 追踪所有比较操作
└─ 确认flag正确性
5. 深入理解
├─ 分析加密算法 (RC4)
├─ 理解验证机制
└─ 完整复现解题过程
$ echo "flag{fini_is_good}" | ./babyre
success!Input:
Flag: flag{fini_is_good}
为什么要了解ELF格式?
ELF(Executable and Linkable Format)是Linux系统的标准可执行文件格式。理解ELF结构能帮助我们:
找到隐藏的代码段
理解程序加载和执行流程
识别异常的节区
关键的ELF段:
| 段名 | 用途 | 执行时机 |
|---|---|---|
| .text | 代码段 | 程序运行时 |
| .data | 初始化数据 | 程序加载时 |
| .init_array | 初始化函数数组 | main之前 |
| .fini_array | 清理函数数组 | main之后 |
| .plt/.got | 动态链接表 | 调用外部函数时 |
在动态调试中,我们重点关注的寄存器:
通用寄存器:
RAX: 通常存放函数返回值、系统调用号
RDX: 常用于存放数据、比较值
RDI, RSI, RDX, RCX, R8, R9: 函数参数(依次)
系统调用约定:
mov $系统调用号, %rax
mov $参数1, %rdi
mov $参数2, %rsi
mov $参数3, %rdx
syscall
# 返回值在 %rax
理解这些约定,才能读懂反调试代码!
自动化调试脚本:
# 设置断点
break *地址
# 自动执行的命令块
commands
silent # 静默模式,不显示断点信息
set $寄存器 = 值 # 修改寄存器
printf "格式化输出\n" # 打印信息
info registers # 查看寄存器
x/格式 地址 # 查看内存
continue # 继续执行
end
内存查看格式:
x/8xb $rax # 查看8个字节,十六进制格式
x/8c $rax # 查看8个字节,字符格式
x/s $rax # 查看字符串
x/i $rip # 查看指令
如何判断是否应该检查init/fini数组?
程序行为异常:main函数很简单,但程序功能复杂
直接运行失败:在GDB外运行正常,在GDB内就失败
文件较大但代码少:可能有隐藏的逻辑
反调试检测的常见方法:
| 方法 | 原理 | 检测特征 |
|---|---|---|
| ptrace | 一个进程只能被trace一次 | syscall 101 |
| /proc/self/status | 检查TracerPid字段 | 读取文件 |
| 时间检测 | 调试时执行变慢 | rdtsc指令 |
| 调试寄存器 | 检查硬件断点 | DR0-DR7寄存器 |
第一步:信息收集
├─ 文件类型、架构、编译器
├─ 字符串、导入函数
└─ 特殊段、加壳检测
第二步:行为分析
├─ 运行测试
├─ 输入输出分析
└─ 网络/文件操作监控
第三步:静态分析
├─ 反汇编关键函数
├─ 识别算法特征
└─ 数据流分析
第四步:动态调试
├─ 设置关键断点
├─ 跟踪执行流程
└─ 观察内存变化
第五步:综合利用
├─ 结合静态和动态分析
├─ 验证假设
└─ 获取flag
静态分析工具:
file: 识别文件类型
readelf: 查看ELF结构
objdump: 反汇编
strings: 提取字符串
IDA Pro / Ghidra: 专业反汇编器
动态分析工具:
GDB: 强大的调试器
strace: 跟踪系统调用
ltrace: 跟踪库函数调用
辅助工具:
pwntools: Python二进制分析库
radare2: 开源逆向框架
推荐书籍:
《程序员的自我修养——链接、装载与库》- 理解ELF格式
《逆向工程核心原理》- 系统学习逆向
《加密与解密》- 深入反调试技术
在线资源:
CTFtime.org - CTF赛事平台
pwnable.kr - PWN练习网站
reversing.kr - 逆向练习网站
ELF特殊段的理解与利用
.init_array和.fini_array的执行时机
如何提取和分析函数指针
反调试技术的识别与绕过
ptrace原理
动态修改寄存器的技巧
静态分析与动态调试的结合
静态分析找线索(比较值)
动态调试验证假设
两者互补,缺一不可
加密算法的逆向分析
RC4算法特征识别
流密码的工作原理
逆向分析不是盲目操作,而是:
建立假设- 基于观察提出合理猜测
设计验证- 用实验证明或推翻假设
迭代改进- 不断修正理解
举例:本题的思维过程
观察 → fail/success输出
假设 → 存在验证逻辑
验证 → 找到比较指令
观察 → is_good}字符串
假设 → flag格式为flag{XXX_is_good}
验证 → 动态调试确认
观察 → 在GDB中行为异常
假设 → 可能有反调试
验证 → 找到ptrace调用
这种科学的分析方法适用于所有逆向题目!
打好基础
熟练掌握一门汇编语言(推荐x86-64)
理解操作系统基本原理
学习常见的数据结构和算法
工具使用
深入学习GDB,至少会写调试脚本
掌握一款静态分析工具(IDA/Ghidra)
学会使用Python处理二进制数据
实践积累
多做CTF题目,从简单到复杂
分析真实的恶意软件样本
参与开源项目,阅读优秀代码
思维培养
养成"为什么"的习惯 - 不要死记硬背
建立知识体系 - 把零散知识串联起来
保持好奇心 - 探索未知的领域
为了方便读者复现,这里提供完整的GDB调试脚本:
# 文件名: solve.gdb
# 用法: gdb -batch -x solve.gdb ./babyre
set follow-fork-mode parent
set pagination off
# 绕过反调试
break *0x400f17
commands
silent
set $rax = 0
continue
end
# 进入验证函数
break *0x400cce
commands
silent
printf "\n[*] 进入验证函数\n"
continue
end
# 第一次比较
break *0x400dd7
commands
silent
printf "\n[比较1] 前8字节\n"
printf " 实际: 0x%016lx\n", $rdx
printf " 期望: 0x%016lx\n", $rax
if $rdx == $rax
printf " [] 通过\n"
else
printf " [] 失败\n"
end
continue
end
# 第二次比较
break *0x400e59
commands
silent
printf "\n[比较2] 中间8字节\n"
printf " 实际: 0x%016lx\n", $rdx
printf " 期望: 0x%016lx\n", $rax
if $rdx == $rax
printf " [] 通过\n"
else
printf " [] 失败\n"
end
continue
end
# 第三次比较
break *0x400e76
commands
silent
printf "\n[比较3] 后8字节\n"
printf " 实际: 0x%016lx\n", $rdx
printf " 期望: 0x%016lx\n", $rax
if $rdx == $rax
printf " [] 通过\n"
else
printf " [] 失败\n"
end
continue
end
# 第四次比较
break *0x400e89
commands
silent
printf "\n[比较4] 长度检查\n"
printf " 实际: 0x%016lx\n", $rax
printf " 期望: 0x%016lx\n", 0x840f
if $rax == 0x840f
printf " [] 通过\n"
else
printf " [] 失败\n"
end
continue
end
# 成功输出
break *0x400eaa
commands
silent
printf "\n[SUCCESS] 所有验证通过!\n"
continue
end
# 运行程序
run < <(echo "flag{fini_is_good}")
使用方法:
# 保存上述内容为 solve.gdb
$ gdb -batch -x solve.gdb ./babyre
# 或者直接验证
$ echo "flag{fini_is_good}" | ./babyre
success!Input:
通过这道题目,我们完整体验了一个CTF逆向题的分析流程:从文件识别到ELF结构分析,从反调试绕过到加密算法识别,从静态分析到动态调试。
每一步都有明确的目标和理由,每一个技术点都有深入的解释。希望这篇文章不仅能帮助你解决这道题,更能让你理解背后的思维方式和技术原理。
逆向工程的魅力在于:
像侦探一样抽丝剥茧
像科学家一样建立假设
像工程师一样解决问题
继续探索,持续学习,你会发现二进制世界的无限精彩!
Flag: flag{fini_is_good}
本文仅供学习交流使用,请勿用于非法用途。