资讯动态

逆向分析figugegl:从ELF到TEA变种算法的完整还原

发布时间:2026/10/21 22:22:22 来源:尧图企业网站定制
1. 接到这个“figugegl”任务的第一天先摸清它是什么1.1 看文件、看壳、看行为拿到二进制先别急着反汇编第一次看到“figugegl”这个名字我脑子里闪过的是某个CTF题的随机flag或者哪个程序员随手敲出来的乱码。但真正拿到手的时候它其实是一个大约 40KB 的 Linux ELF 可执行文件没有版本信息没有作者签名连符号表都被剥得干干净净。同事那边只给了一句需求“把这个工具的核心算法还原出来我们另一个平台要复用它。”这种活儿听起来玄乎实操起来核心就那么几步确认文件类型、确认有没有加壳、确认程序到底干了什么。我先把文件拖到终端里跑了一下file反馈是“ELF 64-bit LSB executable, stripped”。stripped 意味着符号被删了但至少没加壳。再用strings扫了一把发现里面有一堆类似usage: figugegl input output的提示信息还有一些看着像 base64 的字符串。初步判断这是一个命令行数据转换工具接收输入文件输出处理后的文件。别急着反汇编先把行为跑一遍。我准备了一个 16 字节的纯文本测试文件内容就是abcdefghijklmnop然后执行./figugegl test.txt out.bin。程序没有任何报错跑完后out.bin出现了但file out.bin提示它是“data”。用xxd看了下输出变成了 16 字节的二进制内容跟输入完全不同。接着我又换了几个不同长度的输入发现输出长度始终等于输入长度而且同样的输入每次运行输出都一样——这说明核心逻辑是确定性变换极有可能就是某种加密或者编码处理。到这里任务的目标就清晰了找到这个变换函数的具体实现并且用另一种编程语言复刻出来。1.2 为什么叫“figugegl”从名字和导入表里找线索很多逆向任务卡住的第一个原因是太早钻进汇编泥潭。我习惯先把程序的外部依赖搞清楚。readelf -d看动态依赖objdump -T看动态符号表。figugegl 没有链接任何外部库只依赖 libc 的read、write、open、malloc、printf这几个基础函数。这说明算法完全是自己实现的没有任何加密库辅助好处是少了一堆逆向库函数的活坏处是所有逻辑都得硬啃。至于“figugegl”这个名字我一开始觉得毫无意义但后来在源码还原完成后回头看才发现它其实是“figugegl”的作者把某个英文单词做了字母错位之后得到的至于原本是什么词已经不重要了。逆向分析中这种别名很常见要么是随手敲的要么是故意让人摸不着头脑。真正有价值的信息永远在代码里而不是名字里。2. 静态分析把混淆后的代码一层层剥开2.1 用Ghidra反编译先找到main和关键函数我选的是 Ghidra免费、跨平台、对 Linux ELF 支持好而且反编译出来的伪代码可读性比纯汇编高得多。把文件拖进 Ghidra 后直接分析入口点会自动定位到_start再追踪到main。因为是 stripped 文件函数名是FUN_00101234这种格式但没关系从main的函数视图往下看很快就能梳理出调用关系。main的逻辑很简单检查argc是否为 3如果不是打印 usage如果是就分别open两个文件读取输入文件内容到缓冲区然后调用一个处理函数再把结果写到输出文件。整个流程只有一层封装没有复杂的虚表、回调或事件循环这让我松了口气。但真正麻烦的是处理函数本身。Ghidra 反编译出来的结果里充满了大量看起来毫无意义的局部变量、重复的位运算和跳转。一眼扫过去能看出它是一个循环循环次数等于输入长度循环体里面大概有十来个操作步骤。伪代码里到处是(uint)((int)iVar1 31) 29这种绕来绕去的右移逻辑还有一些魔数比如0x9E3779B9、0x85EBCA6B之类的。看到这类操作我第一反应就是作者可能做了轻量级的混淆但骨子里是某种标准结构。2.2 跟踪关键算法从加密/解密循环里恢复逻辑在 Ghidra 中把处理函数反编译出来后我先用“结构归纳法”把大块逻辑拆解成几段。第一步是确认输入和输出之间的对应关系函数接收一个char *输入指针、一个char *输出指针、一个长度参数。然后内部有四个看起来像状态变量的uint初始化时用了两个不同的常数还调用了另一个函数来生成初始状态。继续往下看发现循环体每次处理四个字节先取输入的前四个字节组合成一个 32 位整数然后跟状态变量做若干轮异或、加法、移位操作最后再拆成四个字节写入输出。这种“每次处理一个块、状态不断更新”的模式很像是流密码里的输出反馈模式或者一种自定义的块变换。我试着用一个 16 字节的已知输入输出去对应循环次数发现程序对 16 字节输入只执行了一次循环就结束了不对仔细看循环条件是while (length ! 0)每次循环长度减 4所以 16 字节走了四次循环每次处理 4 字节。但每次循环内部又有一个小循环迭代次数是固定的 32 次。32 这个数字太典型了很多算法里都有 32 轮迭代比如 TEA、XTEA、Salsa20 的轮数。我立刻去翻看循环内部的操作模式每次小循环中会执行state delta; state ((state 4) k) ^ (state sum) ^ ((state 5) k2)这类形式。这不就是 TEA 家族算法的变形吗原版 TEA 的加密循环中就是通过左移 4、右移 5加上轮常数 delta然后与密钥做异或、加法组合。看到这里我基本可以断定figugegl 的核心算法是对 TEA 的一种魔改改了轮数、常数以及密钥参与运算的方式。这种“基于标准算法改一改”的做法在自定义工具里非常常见——作者不想直接用现成的库又不愿意从零发明于是把 TEA 或者 XTEA 改得面目全非以为别人看不出来。2.3 利用动态调试验证推测让程序“自己跑出来”静态分析只能提供“合理的猜测”真要确认得看数据流。我把程序放到 Linux 下直接用 gdb 跑在main里找到处理函数调用的地址下断点然后单步进入。动态调试的核心技巧是盯着关键寄存器和内存变化尤其是输入缓冲区、输出缓冲区以及循环计数器的值。我准备了一个只有四字节的输入61 62 63 64在 gdb 中设置断点在处理函数的入口查看 RSI第二个参数指向的内存是61 62 63 64。然后单步执行逐步观察一个 32 位寄存器eax的变化先看到它被填充成0x64636261小端序组合正常。随后开始执行一轮操作加减法、异或、移位之后可以看到eax的值不断翻滚。等整个处理完成再看输出缓冲区变成了3c 1d 2f 9a这样的随机数据。我再把静态分析中反编译出来的伪代码和动态调试实际执行的指令序列对照了一下发现 Ghidra 的伪代码有几处把和的优先级搞错了导致看起来比实际复杂。这提醒我一个老经验反编译工具只是辅助最终要以动态执行和数据特征为准。比如我看到伪代码里有(uint)((iVar1 31) 29)这种鬼东西实践中其实是编译器对“无符号右移 32 位”这种未定义行为做优化时产生的指令序列并不是算法真正做了什么。遇到这种情况最靠谱的方式是直接看汇编指令最原始的逻辑不要被伪代码的迷惑性写法带偏。3. 核心攻坚figugegl里的自定义散列算法还原3.1 算法特征提取位运算、轮转、常数表经过前两轮的分析我已经锁定了核心函数的位置和大致结构。现在开始逐行“翻译”汇编/伪代码成清晰的 C 语言逻辑。由于没有符号我先把反编译结果整理成一段带注释的伪代码然后反复对照数据流验证。最终还原出来的核心函数大概是这样的uint32_t xtea_like_round(uint32_t v, uint32_t k0, uint32_t k1, uint32_t sum) { uint32_t left v; uint32_t right (left 4) k0; right ^ left sum; right ^ (left 5) k1; return right; }实际代码当然比这个复杂因为原始实现还用了查表操作。我看到一个 256 项的表被初始化成固定值每次操作后会把某个字节作为索引去查表再跟中间状态异或。这种操作特征非常明显查表代替简单的非线性变换是自定义加密中常见的手法。表的内容我直接抓取出来了是一串看似随机但固定的十六进制数总共 256 个值。还原这类算法时我习惯把过程分成三个步骤第一步抓出所有“魔数”常数比如0x9E3779B9、初始化状态值、轮数 32、表数据第二步画出数据流图明确每个操作数的来源和去向搞清楚哪些是输入、哪些是状态、哪些是密钥第三步在本地用 Python 或者 C 写出等价实现然后用原始程序的输入输出做验证。这个算法实际的结构可以理解为把输入分成 4 字节一组每一组与当前 128 位状态混合混合过程里同时用到加法、异或、移位和查表每一组处理完以后状态会被更新下一组输入再来的时候用更新后的状态操作。这已经不完全是 TEA 了更像是一种自定义的“分组密码 状态反馈”的结构。但对逆向来说有没有正式的名字不重要我能复现等价逻辑就够了。3.2 用Python脚本验证并生成等价实现拿到完整逻辑后我直接用 Python 写了一个等价实现。因为 Python 的整数没有固定位数所有位运算都要手动加上 0xFFFFFFFF掩码。下面是我还原出的关键代码片段def figugegl_core(data: bytes, key: bytes) - bytes: state [0x01234567, 0x89ABCDEF, 0xFEDCBA98, 0x76543210] # 用 key 初始化状态实际过程要复杂一些 for i in range(4): state[i] ^ int.from_bytes(key[i*4:(i1)*4], little) delta 0x9E3779B9 table [...256个常数省略...] out bytearray() assert len(data) % 4 0 for offset in range(0, len(data), 4): v int.from_bytes(data[offset:offset4], little) s 0 for r in range(32): v (v (((v 4) 0xFFFFFFFF) ^ (v s) 0xFFFFFFFF) 0xFFFFFFFF) 0xFFFFFFFF v ^ (v 5) 0xFFFFFFFF v ^ table[v 0xFF] v v ^ state[r % 4] s (s delta) 0xFFFFFFFF for n in range(4): state[n] (state[n] ^ v) 0xFFFFFFFF v (v 8) 0xFFFFFFFF out.extend(v.to_bytes(4, little)) return bytes(out)这里为了简化我把某些细节略掉了实际工作时每个位运算都要仔细核。我第一次写出来的脚本跑出来的结果跟原程序不一致差了 8 个字节。排查后发现是“查表索引”的问题——原程序用的是“当前字节异或状态低八位”的结果作为索引我写成了“当前字节的低八位”。这种细微差别如果不靠输入输出比对单靠肉眼很难发现。写还原脚本的核心价值不是“写出来”而是“跑一致”。我准备了三组不同长度的测试样本4字节、8字节、16字节把每个样本分别丢给原程序和脚本逐一比对输出。前两组很快通过了第三组失败了。通过对比十六进制定位到第四个以及第六个状态更新位置不一致最终倒推出一个此前被我忽略的细节原程序在处理完一组数据后更新状态时有一个字节交换操作。修正后三组样本全部通过。这也意味着figugegl 的核心逻辑已经被完全还原。3.3 参数怎么确定逆向中的“黑盒白盒”交叉验证有些读者可能会问你凭什么认为那个0x9E3779B9就是轮常数为什么轮数是 32这里我分享一下交叉验证的经验。所谓“黑盒”是指用原程序作为一个黑盒给定任意输入记录输出所谓“白盒”是指我们已经通过反汇编看到了代码逻辑。纯靠看代码会漏纯靠猜参数会胡两者必须结合。具体做法是先从反编译代码里提取所有立即数常数列成一张表。再用 gdb 动态修改输入观察程序运行结果。比如我把一个测试输入从00000000改成FFFFFFFF看输出值的哪些字节发生变化从而推断数据扩散规律。我还专门构造了全零输入、全 FF 输入、递增序列、递减序列等边界样本用这些样本去跑原程序得到一组“已知答案”的测试向量。然后用这些测试向量去校准我的 Python 实现。如果某个字节对不上就说明某个操作符或常数猜错了如果全对上基本可以宣告算法恢复完成。这里面最关键的参数是状态初始值。有的算法会把初始值叫做 IV有的叫 nonce。我通过动态调试发现程序在执行核心处理前会用一段“密钥扩展”来重新初始化状态数组这段扩展逻辑也藏得很深。我建议所有同行在做这类还原时一定要先找到“初始化函数”把它单独拎出来因为它决定了后续所有操作的基础。如果你初始化的结果跟原程序不一样后面怎么写都是错的。4. 实战中遇到的坑与排查方法4.1 花指令与反调试运行时动态patchfigugegl 虽然没加壳但作者在这里塞了不少“花指令”——就是故意插入一些无意义的跳转、垃圾字节让反汇编工具在分析时产生错误流程。比如我在 Ghidra 中看到一个函数中间突然插入了一段jmp到jmp的跳转跳转后的地址落在一条指令的中间。这种设计会让线性反汇编失效导致 Ghidra 无法识别后面的指令甚至把数据和代码混淆。对付花指令我的常用招数是“动态跟踪”。在 gdb 里沿着真实执行路径走一遍把所有执行过的地址列出来跟 Ghidra 的静态分析结果对比就能发现哪些代码是真正有效的哪些是干扰。走完后我看到有几段“垃圾指令”从未被执行于是直接在二进制里把这些字节 patch 成 0x90NOP然后重新加载到 Ghidra 中分析函数流程立刻清爽了不少。当然patch 只是为了让分析工具解码正确最终我们仍然以动态实际执行的逻辑为准。还有一处反调试策略是“检测系统时间”。程序在初始化和处理之间调用了gettimeofday如果两次调用时间差小于某个阈值就正常继续否则就跳到错误分支。一开始我没注意这个细节导致在 gdb 里单步时间被拉长程序直接走异常分支输出全是随机的垃圾数据。排查了很久才发现。解决方案也不难用 gdb 的set environment LD_PRELOAD搭一个假的gettimeofday或者干脆直接在程序比较时间长度的跳转指令上改成强制跳转。4.2 字符串加密解密后用脚本批量恢复这个程序的字符串也做了手脚。用strings扫描时能看到 usage 信息但看不到任何有意义的错误提示。那些错误提示其实都在内存里但存储在二进制里的形态是加密后的字节运行到 printf 前才临时解密。我是在动态调试中看到某个断点处内存区域突然出现了一串可读的英文才意识到这点。后来我直接从程序的数据段抓取了一段“加密后的字符串”结合地址和上下文把解密逻辑还原成了 Python 脚本批量将所有字符串恢复出来。结果发现里面藏着几条调试日志比如round %d, delta %08x这些日志正常情况下不打印但存在说明作者开发时用它调试过。这种信息很有价值可以让你确认轮数和状态是否推断正确。这里也分享一个经验逆向一个程序时不要放过任何“字符串特征”。哪怕只是usage: ...这种简单的提示也能推断出参数结构。而加密字符串更值得优先解密因为作者往往会在里面留下关键算法名称或者日期特征。4.3 时序问题动态分析时程序跑飞用硬件断点解决有一次让我最揪心的是在 gdb 里单步执行到第 32 轮循环附近时程序突然跳到一个完全无关的地址然后崩溃。我刚开始以为是反调试后来发现是编译器优化导致的“指令重叠”——原本一条指令的后半部分被另一条指令重新解释导致寄存器状态异常。当你在调试器中强行“单步”时有些跳转是落在指令中间的单步可能会触发处理器异常。解决方式是改用硬件断点也就是在目标地址上设置hbreak不要使用普通软件断点。硬件断点通过处理器调试寄存器实现不修改指令能避免很多由于int3中断带来的指令缓存不一致问题。另外遇到那种“跑飞”的情况不要急着认为程序防调试先检查是不是自己在修改内存时误改了代码段。我之前在 patch 花指令时把地址算偏了导致一运行就段错误这种错误跟反调试一点关系都没有。4.4 快速解决问题整理了一份踩坑速查表现象可能原因排查方法反编译结果中大量无意义跳转花指令或反汇编线性化失效动态跟踪以真实执行地址为准动态调试到一半程序崩溃软件断点修改了指令缓存或指令重叠改用硬件断点hbreak输出与还原脚本不一致位运算优先级、查表索引、状态更新顺序有误构造边界测试向量逐字节对比程序在 gdb 中行为异常程序检测到时间变化或调试端口动态 patch 跳转或 hook 系统调用字符串扫出来全是乱码字符串被加密在 printf 下断点抓取解密后的内存Ghidra 中别名太多函数看不完未去掉花指令或未识别编译优化先 patch NOP再重新分析希望这张表能帮后来的人少走点弯路。很多问题都属于“所见即所骗”你看到的现象背后往往不是恶意对抗只是编译器和指令调度的正常结果。5. 复盘这次逆向分析教会我的事回头再看整个“逆向分析figugegl”的过程最让我感慨的并不是最后成功恢复了算法而是中间那种反复“猜测-验证-推翻-再验证”的节奏。我一开始以为它是个特别复杂的加解密算法甚至怀疑是作者自己发明的密码体系结果一步步拆下去发现核心还是 TEA 的变种只是叠了查表和状态反馈。这种事在真实逆向项目中太常见了——你以为的高深设计背后往往就是几个经典算法组合了一下。我个人实操中的体会是逆向分析最稀缺的不是工具多熟练而是“心里有没有一把尺子”。这把尺子就是你对常见算法的熟悉程度、对数据流的敏感度、对调试技巧的掌握量。你见过 TEA就能一眼认出 0x9E3779B9你玩过查表就会条件反射去抓那 256 字节的表。这些经验没有捷径只能靠一个个项目、一个个坑慢慢堆出来。最后再分享一个小技巧在整个分析过程中我坚持为每一个结论都写测试用例。原程序的输入输出就是最权威的“答案”你每还原一块逻辑就立刻跑一遍数据比对。哪怕只对了一个字节也说明你走对了方向。逆向本来就是一件和“未知”较劲的事但只要你能让还原代码和原始二进制在越来越多的数据上保持一致你就离胜利越来越近了。希望你下一次遇到被剥了符号的二进制也能沉住气一层一层把它看穿。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑