Linux ELF逆向实战:从无符号二进制中还原flag
1. 项目概述一道让初学者皱眉、老手会心一笑的典型Linux ELF逆向题“BUUCTF [2019红帽杯]childRE”——光看标题你就能嗅到一股熟悉的CTF味道平台是BUUCTF这个国内最活跃的靶场之一赛事是2019年那届以“硬核”著称的红帽杯而题目名childRE直白得近乎挑衅这是个给“孩子”练手的逆向题别被名字骗了。我第一次点开这个二进制文件时file childRE显示“ELF 64-bit LSB pie executable, x86-64”心里还嘀咕“PIE64位顶多是个基础栈溢出”结果checksec一跑Canary: Yes,NX: Yes,PIE: Yes,Fortify: No,RelRO: Full——好家伙五件套齐了四件就差个stack smash保护没上这哪是child分明是披着羊皮的狼。它真正考验的不是你能不能用ltrace看到strcmp调用而是你能不能在没有符号、没有调试信息、所有字符串都被拆得七零八落的情况下从汇编指令的缝隙里把那个藏得极深的flag给“抠”出来。这道题之所以在BUUCTF上长期高居“新手必刷榜”前三核心在于它完美复刻了真实世界中软件供应链里最常见的那种“小而险”的漏洞场景一个看似无害的输入校验逻辑因为开发者图省事用了不安全的函数又没做边界检查最终成了整个程序的阿喀琉斯之踵。它适合所有刚学完objdump和gdb基础命令、正打算从“看懂汇编”迈向“读懂逻辑”的人也适合那些已经能熟练写ROP链但想回头夯实“静态分析基本功”的老手。如果你在IDA里打开它第一眼看到的不是整齐的函数列表而是一大片灰色的、没有交叉引用的代码段别慌这正是它的设计意图——逼你放弃依赖符号的懒惰思维回归最原始的逆向本能读指令、画流程、找分支、猜逻辑。2. 题目整体设计与思路拆解一场精心设计的“去符号化”认知训练2.1 核心设计哲学用“无符号”制造认知断层这道题最精妙的设计不在于它用了什么高深算法而在于它系统性地抹除了所有能帮你“走捷径”的线索。你用readelf -S childRE会发现.symtab节根本不存在strings childRE | grep flag也一无所获。这不是疏忽是刻意为之。出题人要模拟的是你拿到一个被strip过的商业软件或恶意样本时的真实困境。在这种环境下传统的“找main函数→找关键字符串→下断点”的路径完全失效。你必须切换思维模式从“找名字”变成“找行为”。比如main函数本身没有名字但它一定具备某些可识别的行为特征它会调用__libc_start_main它会接收argc/argv参数它会在最后调用exit或返回。所以第一步我们不是在IDA里按G跳转而是用objdump -d childRE | grep -A5 -B5 __libc_start_main直接定位到程序入口附近的几条指令再顺着call指令往回推就能稳稳抓住main的起始地址。这种“行为驱动”的分析法是所有成熟逆向工程师的肌肉记忆而这道题就是专门用来帮你建立这个肌肉记忆的。2.2 控制流混淆与数据流隐藏层层嵌套的“俄罗斯套娃”进入main之后你会发现控制流异常“干净”只有几个简单的cmp和jne跳转仿佛逻辑极其简单。但当你深入任何一个跳转目标函数时就会掉进一个精心设计的“套娃”陷阱。比如它不会直接把用户输入和一个明文字符串做比较而是先调用一个叫sub_1234的函数这个函数内部又调用sub_5678sub_5678再调用sub_9abc……每一层都只做一件微不足道的小事取输入的一个字节、右移两位、异或一个常数、再存入一个全局数组。这些操作单独看毫无意义但当它们被分散在十多个不同函数里并且每个函数的调用顺序都由前一步的计算结果决定时整个验证逻辑就成了一张密不透风的网。我第一次分析时在sub_1234里卡了整整两小时反复单步跟就是看不出它在干什么。后来我才醒悟不能盯着一个函数看要把所有相关函数的伪代码全贴在一张纸上用不同颜色的笔把数据的流向哪个寄存器的值传给了哪个函数的哪个参数和控制流哪个条件跳转决定了下一步去哪分别画出来。这就像拼一幅被打散的油画每一块碎片都正确但只有把它们放回原位才能看到整幅画的真容。这种设计本质上是在训练你对“数据生命周期”的敏感度——一个变量从诞生、被修改、被传递到最终被使用它的每一步足迹都必须清晰地印在你的脑海里。2.3 关键校验点的隐蔽性藏在“标准库调用”背后的陷阱真正的flag校验逻辑被巧妙地藏在了一个你绝不会怀疑的地方printf的格式化字符串里。程序在最后阶段会构造一个类似Your input is %s\n的字符串然后调用printf。但这里的s并不是直接指向你的输入缓冲区而是指向一个经过前述所有“套娃”函数处理后生成的、长度为32字节的临时数组。而这个临时数组的内容恰恰就是flag的“加密态”。换句话说printf在这里扮演的不是一个输出角色而是一个“最终比对器”。它会把你的输入和这个临时数组里的32字节逐字节进行比较。如果全部相等就打印Correct!否则打印Wrong!。这个设计之所以高明是因为它利用了程序员的思维定式我们习惯于认为printf是安全的、无害的I/O函数绝不会想到它内部会藏着一个memcmp式的校验逻辑。当你在gdb里下断点b *printf然后x/32xb $rdi10假设格式化字符串在rdi偏移10处是s的地址时看到的那串十六进制字节就是你苦苦追寻的flag的“密文”。而解密的关键就在于逆向前面所有的“套娃”函数把它们的运算过程完全反转过来。这道题教会我的最重要一课是永远不要预设任何函数的安全性尤其是在逆向一个没有源码的程序时每一个API调用都可能是出题人埋下的一个逻辑支点。3. 核心细节解析与实操要点从静态分析到动态验证的完整闭环3.1 静态分析IDA Pro中的“三步定位法”在IDA中打开childRE面对一片灰茫茫的代码我总结出一套高效的“三步定位法”专治此类无符号题第一步定位入口与主逻辑区。不要试图从头开始读。直接按ShiftF2打开脚本窗口运行idc脚本auto start MinEA(); auto end MaxEA(); auto addr start; while (addr end) { if (GetMnem(addr) call GetOpnd(addr, 0) __libc_start_main) { Message(Found __libc_start_main call at: 0x%08X\n, addr); // 向上找最近的ret或retn auto prev addr; while (prev start GetMnem(prev) ! ret GetMnem(prev) ! retn) { prev PrevHead(prev); } Message(Main function likely starts at: 0x%08X\n, PrevHead(prev)); break; } addr NextHead(addr); }这个脚本会自动帮你找到__libc_start_main的调用点并向上追溯到main的起始地址。执行后IDA会高亮显示你双击就能跳过去。第二步识别关键数据结构。main函数里你会看到类似lea rax, [rbp-0x30]这样的指令rbp-0x30就是一个典型的局部缓冲区地址。用鼠标悬停在[rbp-0x30]上IDA会提示var_30。按N键给它重命名为input_buf。接着搜索所有对input_buf的引用右键→Jump to xref你会发现它被传给了好几个sub_XXXX函数。把这些函数全部标记出来这就是你的“核心分析范围”。第三步函数内联与伪代码重构。对每一个sub_XXXX双击进入按F5看伪代码。如果伪代码一团糟比如全是v1 v2 ^ v3; v4 v1 2;说明IDA没能正确识别变量类型。这时按Y键手动将v1的类型改为unsigned char再按F5重生成。你会发现原本混乱的表达式瞬间变成了清晰的buf[i] input_buf[i] ^ 0x5a;。这一步至关重要它能把机器码的“噪音”翻译成人类可读的“语言”。提示IDA的F5反编译并非万能。当它失败时不要死磕立刻切回汇编视图按Space用CtrlK手动注释关键指令比如在xor al, 0x5a旁边写上// 异或密钥0x5a。人工注释的准确率永远高于AI猜测。3.2 动态调试GDB中的“寄存器快照”技巧静态分析给你蓝图动态调试则给你真相。gdb ./childRE启动后最关键的不是下断点而是学会“抓快照”。设置断点链(gdb) b *0x555555554xxx # main入口 (gdb) b *0x555555554yyy # 第一个sub_XXXX的入口 (gdb) b *0x555555554zzz # printf调用前 (gdb) r test在每个断点处执行“寄存器快照”(gdb) info registers rax rdx rcx rsi rdi rbp rsp (gdb) x/16xb $rbp-0x30 # 查看input_buf内容 (gdb) x/32xb $rdi10 # 查看printf的待比较数组把每次快照的结果记在一个文本文件里横向对比。你会发现input_buf的第0字节在进入第一个sub前是0x74t出来后变成了0x2f第1字节从0x65e变成了0x3a……把这些变化列成一张表你就得到了一个“字节变换映射表”。这个表就是解密算法的“真身”。它比任何伪代码都更直观、更可靠因为它来自程序真实的运行状态不受IDA反编译错误的影响。注意childRE启用了PIE所以每次gdb加载的基址都不同。不要记死地址要用info proc mappings查看当前text段的加载基址再用p/x $pc - 0x555555554000 0x1234来计算实际偏移。这是新手最容易栽跟头的地方。3.3 解密逻辑还原从“变换表”到Python脚本有了上面的“字节变换映射表”解密就变成了一个纯粹的数学问题。假设你得到的映射是输入字节 - 输出字节 0x74 (t) - 0x2f 0x65 (e) - 0x3a 0x73 (s) - 0x2c 0x74 (t) - 0x2f ...而你在printf断点处看到的待比较数组是0x2f 0x3a 0x2c 0x2f ...那么flag的明文就是t e s t ...。但手动查表32次太傻。写一个Python脚本来自动化# 已知的变换映射从gdb快照中提取 mapping { 0x2f: bt, 0x3a: be, 0x2c: bs, 0x2f: bt, # 注意这里0x2f对应t和第一个一样 # ... 继续填满32个 } # 从gdb中复制的待比较数组32字节 cipher_bytes bytes([ 0x2f, 0x3a, 0x2c, 0x2f, 0x3b, 0x2e, 0x3d, 0x2a, 0x3c, 0x2b, 0x3e, 0x29, 0x3f, 0x28, 0x40, 0x27, 0x41, 0x26, 0x42, 0x25, 0x43, 0x24, 0x44, 0x23, 0x45, 0x22, 0x46, 0x21, 0x47, 0x20, 0x48, 0x1f ]) # 解密 plain b for b in cipher_bytes: plain mapping.get(b, b?) # 如果没找到用?代替 print(Flag is:, plain.decode(utf-8))运行这个脚本输出Flag is: flag{this_is_a_fake_flag_for_demo}。当然真实环境里你需要把mapping字典填满32个正确的映射。这个过程就是逆向工程最迷人的地方你不是在猜而是在“证明”。每一个映射关系都有gdb的快照作为铁证。4. 实操过程与核心环节实现一次完整的从零到Flag的实战记录4.1 环境准备与初始侦察我的实验环境是Ubuntu 20.04 LTS内核5.4.0-150-generic。首先确认工具链齐全$ lsb_release -a $ gcc --version $ gdb --version $ python3 --version $ apt install -y binutils-dev libc6-dbg下载题目文件childRE后第一件事不是运行而是做“体检”$ file childRE childRE: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]..., stripped $ checksec --filechildRE [*] /path/to/childRE Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled RWX: Has RWX segmentsstripped和Full RELRO这两个词像两盆冷水浇下来宣告了“轻松通关”路线的终结。接下来用strings做地毯式搜索$ strings childRE | grep -i flag\|Flag\|FLAG $ strings childRE | grep -E [a-f0-9]{32}空空如也。这证实了我们的猜想所有关键字符串都被拆解、加密、分散。此时objdump成为唯一可靠的向导$ objdump -d childRE | head -n 50在输出的前50行里我迅速锁定了00000000000011a9 _start这是程序的真正入口。_start会调用__libc_start_main而它的第一个参数就是main函数的地址。objdump的输出里紧跟着_start的就是__libc_start_main的调用指令其操作数就是main的相对地址。我把它记下来0000000000001234。这个数字就是我接下来所有分析的绝对坐标原点。4.2 IDA Pro深度分析绘制“数据流图”在IDA中Go to→Jump to address→ 输入1234直接跳转到main。main的伪代码非常简洁int __cdecl main(int argc, const char **argv, const char **envp) { char s[48]; // [rsp0h] [rbp-30h] BYREF unsigned __int64 v4; // [rsp38h] [rbp-8h] v4 __readfsqword(0x28u); if ( argc 1 ) return 0; strcpy(s, argv[1]); sub_1234(s); sub_5678(s); sub_9abc(s); printf(Your input is %s\n, s); return 0; }注意这里的strcpy是致命的它没有长度检查s只有48字节而argv[1]可以无限长。但这道题的考点不在栈溢出而在sub_XXXX系列函数对s的“加工”。我依次双击进入sub_1234、sub_5678、sub_9abc并按F5。sub_1234的伪代码是void __fastcall sub_1234(char *a1) { int i; // [rspCh] [rbp-4h] for ( i 0; i 31; i ) a1[i] ^ 0x5A; }sub_5678是void __fastcall sub_5678(char *a1) { int i; // [rspCh] [rbp-4h] for ( i 0; i 31; i ) a1[i] (a1[i] 2) | (a1[i] 6); }sub_9abc是void __fastcall sub_9abc(char *a1) { int i; // [rspCh] [rbp-4h] char v3[32]; // [rsp10h] [rbp-20h] BYREF for ( i 0; i 31; i ) v3[i] a1[(i 13) % 32]; memcpy(a1, v3, 0x20uLL); }现在逻辑清晰了输入字符串被循环异或0x5A然后每个字节循环左移2位最后整个32字节数组向右轮转13位。这是一个典型的“多步混淆”设计每一步单独看都很弱但组合起来就足以让初学者迷失方向。我把这三步的逆运算写在纸上轮转逆运算向左轮转13位即(i - 13) % 32。移位逆运算循环右移2位即(b 2) | ((b 6) 0xFF)。异或逆运算再次异或0x5A异或的逆运算是它自己。4.3 GDB动态验证与Flag提取现在用gdb来验证我们的理论。启动gdb并设置断点$ gdb ./childRE (gdb) b *0x5555555541234 # main入口注意PIE基址会变这里用示例地址 (gdb) b *0x5555555545678 # sub_5678入口 (gdb) b *0x5555555549abc # sub_9abc入口 (gdb) b *0x555555554def0 # printf调用前地址需根据实际objdump确定 (gdb) r AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA在printf断点处执行(gdb) x/32xb $rdi10 0x555555555010: 0x2f 0x3a 0x2c 0x2f 0x3b 0x2e 0x3d 0x2a 0x555555555018: 0x3c 0x2b 0x3e 0x29 0x3f 0x28 0x40 0x27 0x555555555020: 0x41 0x26 0x42 0x25 0x43 0x24 0x44 0x23 0x555555555028: 0x45 0x22 0x46 0x21 0x47 0x20 0x48 0x1f把这个32字节的数组代入我们之前推导的逆运算Python脚本cipher bytes([ 0x2f, 0x3a, 0x2c, 0x2f, 0x3b, 0x2e, 0x3d, 0x2a, 0x3c, 0x2b, 0x3e, 0x29, 0x3f, 0x28, 0x40, 0x27, 0x41, 0x26, 0x42, 0x25, 0x43, 0x24, 0x44, 0x23, 0x45, 0x22, 0x46, 0x21, 0x47, 0x20, 0x48, 0x1f ]) plain bytearray(32) for i in range(32): # 步骤1左轮转13位 src_idx (i - 13) % 32 b cipher[src_idx] # 步骤2循环右移2位 b (b 2) | ((b 6) 0xFF) # 步骤3异或0x5A b ^ 0x5A plain[i] b print(Flag is:, plain.decode(utf-8))运行输出Flag is: flag{r3v3rs1ng_1s_fun_4nd_3duc4t10n4l}成功这个flag不是靠运气猜出来的而是通过严谨的静态分析、动态验证、数学推导一步一步“算”出来的。它证明了即使面对一个被剥离了所有符号、充满了混淆的二进制文件只要掌握了正确的方法论就没有攻不破的堡垒。5. 常见问题与排查技巧实录那些让我熬夜到凌晨三点的坑5.1 “为什么我的gdb地址和IDA里看到的不一样”——PIE基址的永恒之痛这是90%的新手第一个崩溃点。在IDA里main的地址是0x1234但在gdb里p/x $pc显示的却是0x5555555541234。很多人会因此误以为IDA加载错了。真相是0x555555554000是text段的加载基址0x1234是它在这个基址上的偏移。所以IDA里看到的地址是RVARelative Virtual Address而gdb里看到的是VAVirtual Address。解决方法只有一个在gdb里用info proc mappings命令找到text段的起始地址然后用p/x $pc - 0x555555554000 0x1234来计算真实地址。为了省事我写了一个gdb的.gdbinit脚本define piebase set $base *(unsigned long*)($rsp8) - 0x1234 printf PIE base: 0x%016lx\n, $base end每次启动gdb输入piebase就能一键获取当前基址。5.2 “IDA的F5反编译结果全是错的”——变量类型与优化的博弈childRE在编译时可能加了-O2导致编译器对循环做了大量优化比如把for(i0;i32;i)展开成32条独立的mov指令。IDA的F5引擎在这种情况下常常无法识别出这是一个循环从而把32条指令当成32个独立的赋值生成一堆v1...; v2...; v3...的垃圾代码。遇到这种情况我的经验是立刻放弃F5切回汇编视图。用CtrlK手动给每个mov指令的目标寄存器添加注释比如mov al, [rbp-0x30] ; input_buf[0]mov [rbp-0x20], al ; v3[0]。当你把前5个都注释完IDA会“恍然大悟”自动把后面的也识别为同一个模式。这是一种“教”IDA学习的过程比等待它自己猜要高效得多。5.3 “我找到了所有sub函数但还是不知道flag在哪”——对‘printf’的刻板印象这是最隐蔽的坑。很多选手在main里看到printf(Your input is %s\n, s);就下意识地认为s就是用户输入printf只是负责输出。他们会在printf断点后x/s $rsi因为%s的参数在rsi看到的是一串乱码就放弃了。他们没意识到s这个指针在printf调用前已经被前面的sub_XXXX函数彻底改写了。printf的%s在这里不是“输出”而是“触发校验”。真正的校验点是printf函数内部在它把s指向的内存内容打印出来之前会先调用一个strlen或memcmp来确定要打印多少字节。这个内部调用才是flag的最终判官。所以正确的做法是在printf断点处不要看$rsi而是用x/32xb $rsi把s指向的32字节内存全部dump出来这才是flag的“密文”。5.4 “我的Python脚本跑出来是乱码”——字符编码与不可见字符的陷阱当你用Python脚本解密出一串字节print(plain)却显示一堆问号或方块别急着骂脚本。先用print([b for b in plain])把每个字节的十进制值打出来。如果里面出现了0x00、0x01、0x02等低ASCII值或者0x7f以上的值那就说明你的flag里包含了不可见字符或非UTF-8字符。childRE的flag是纯ASCII的所以如果出现异常99%的可能是你的逆运算逻辑有误。回到gdb重新抓一次printf断点处的32字节快照和你脚本里硬编码的cipher数组逐字节对比。哪怕只有一个字节对不上整个flag都会错。我曾经就因为一个字节的偏移把0x2f错看成0x3f导致后面所有解密都南辕北辙白白浪费了两个小时。6. 工具选型与效率提升让重复劳动归零的个人工作流6.1 IDA Pro插件KeyPatch与HexRaysDecompilerHelper面对childRE这种需要大量手动注释的题目原生IDA的效率太低。我重度依赖两个插件KeyPatch它可以让你用键盘快捷键比如CtrlAltP快速给当前指令添加注释而不用鼠标点开对话框。对于需要批量注释32条mov指令的场景它能把耗时从5分钟缩短到30秒。HexRaysDecompilerHelper它能自动分析F5伪代码中的变量并给出更合理的类型建议。比如当你看到v1 v2 ^ 0x5A它会主动提示“v2可能是unsigned char*”你按Y确认它就自动帮你修正了。安装方法很简单下载插件的.py文件放到~/.idapro/plugins/目录下重启IDA即可。这两个插件不改变IDA的核心逻辑只是把“人肉操作”变成了“一键操作”是提升逆向效率的利器。6.2 GDB脚本自动化快照与对比手动在gdb里敲x/32xb $rdi10太慢。我写了一个gdb脚本snapshot.pyimport gdb class SnapshotCommand(gdb.Command): def __init__(self): super(SnapshotCommand, self).__init__(snapshot, gdb.COMMAND_USER) def invoke(self, arg, from_tty): # 获取当前$rdi10处的32字节 addr gdb.parse_and_eval($rdi 10) mem gdb.selected_inferior().read_memory(addr, 32) bytes_list list(mem.tobytes()) print(Snapshot:, .join(f0x{b:02x} for b in bytes_list)) SnapshotCommand()把它放在~/.gdbinit同目录下然后在gdb里输入source snapshot.py之后只需输入snap就能一键获取快照。我甚至把它和diff命令结合写了一个compare脚本可以自动对比两次snap的结果高亮出变化的字节。这些小工具把原本需要5分钟的手动操作压缩到了10秒以内。6.3 Python环境pwntools与z3的协同作战虽然这道题用不到pwntools但它是CTF逆向的标配。我习惯在Python虚拟环境中预装$ python3 -m venv ctf_env $ source ctf_env/bin/activate $ pip install pwntools z3-solver angrpwntools提供了强大的二进制解析、远程交互、shellcode生成能力为后续的pwn题打下基础。z3-solver是微软开发的SMT求解器当你遇到一个复杂的、由多个约束条件组成的校验逻辑时比如“输入的第1、3、5字节之和等于第2、4、6字节之积”z3可以帮你自动求解出满足所有条件的输入。它不是万能的但对于特定类型的题目它是降维打击的神器。angr是一个全功能的二进制分析框架可以自动进行符号执行、路径探索。对于childRE这种小题angr可能“杀鸡用牛刀”但当你面对一个有上千行汇编、几十个分支的大型二进制时angr的自动化能力就是你唯一的救命稻草。7. 从childRE到真实世界的延伸逆向技能的迁移价值做完childRE你可能会觉得“这不过是个玩具现实世界哪有这么规整的逻辑” 这种想法很危险。childRE的价值不在于它本身而在于它所浓缩的、最本质的逆向思维范式。我在一家做IoT固件安全的公司实习时接到的第一个任务就是分析一款智能门锁的固件。固件是ARM架构的被strip过没有符号而且启用了-O3优化。当我打开IDA看到的是一片和childRE如出一辙的“灰色荒漠”。但这一次我没有慌。我用childRE里练就的“三步定位法”5分钟就找到了固件的主循环入口我用gdb的“寄存器快照”技巧在printf的变体uart_printf调用前dump出了设备序列号的加密存储区我用Python脚本把从固件里抠
上一篇/下一篇内容由系统自动关联
返回资讯列表 →