首页 / 资讯中心 / 文章详情

CTF逆向核心:RC4、TEA、Base64魔改识别与还原实战

CTF逆向核心:RC4、TEA、Base64魔改识别与还原实战 ★ FEATURED ARTICLE
先说明一点CTF逆向里所谓“识别算法”本质不是让你背特征码而是让你在拿到一个二进制文件后用最短的时间判断“这东西到底是不是某个算法”以及“它哪里被改过”。RC4、TEA、Base64这三兄弟是目前CTF出题人最爱的三个“改装目标”。RC4和TEA天生就有极强的结构特征Base64虽然只是个编码但换张表就够你喝一壶。这篇文章我会结合自己的比赛和做题经验把这三种算法的原始形态、常见魔改点、静态定位方法、动态确认手段、以及还原思路一次性讲透。这篇文章适合刚入门CTF逆向、被各种魔改加密折磨到怀疑人生的选手也适合在真实软件分析里频繁遇到自定义加密、需要快速定位算法的安全从业者。全程以可落地实操为主线不聊虚的。1. 魔改命门为什么出题人总盯着这三种算法改1.1 三种算法各自的结构弱点出题人改算法本质上是在“标准实现”上动手脚。要识别魔改你得先知道标准实现的骨架长什么样以及改哪里最不容易被发现。先看RC4。标准RC4分为KSA密钥调度和PRGA伪随机生成两个阶段。KSA里有一个256字节的S盒初始化循环PRGA里有一个经典的i、j双索引交换循环。这两个循环的结构在反汇编里非常扎眼因为S盒是连续256字节的数组初始化代码往往是这样的unsigned char S[256]; for (int i 0; i 256; i) { S[i] i; }出题人改RC4九成会改这个S盒初始值或者改KSA里j的计算方式。比如把S[i] i改成S[i] (i ^ 0x37) 0xFF甚至直接塞一个硬编码的256字节表进去。S盒一变密钥流就全变了但外层循环结构还在你就还有迹可循。TEA就更有意思了。标准TEA加密的核心是那个黄金常量0x9E3779B9以及每轮对左右两个32位块做的位移、异或、加法混合。它的结构可以用一句话概括在32位无符号整数上反复做“左移4位加key右移5位加key再加sum”这三件事。你会在反编译代码里看到大量类似的表达式v0 ((v1 4) k0) ^ (v1 sum) ^ ((v1 5) k1); sum delta; v1 ((v0 4) k2) ^ (v0 sum) ^ ((v0 5) k3);出题人改TEA第一个下刀的地方就是delta常量。把0x9E3779B9换成0x61C88647、0x9E3779B1甚至一个随机数表面上看整个加密过程还是TEA但你和标准TEA脚本对不上就会很痛苦。第二个下刀点是轮数标准是32轮改成16轮或64轮比比皆是。第三个是密钥参与方式比如把k0到k3的顺序打乱或加掩码。Base64严格来说不是加密是编码。它的标准实现核心是3字节转4字符的位操作加上一个64字符索引表。最经典的魔改就是换表把ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789/换成任意一个长度为64的字符串。反编译时你只需要在数据段找一个长度64的字符串基本就能锁定。1.2 魔改判定的通用逻辑这三种算法的魔改本质上都是在保留结构、改动参数。RC4改的是S盒初始状态和索引计算参数TEA改的是delta、轮数和密钥调度参数Base64改的是字符映射表。你识别魔改的顺序应该是先确认结构是否匹配标准算法再逐个核对关键参数是否被替换。很多新手拿到一个反编译代码看到有循环、有异或、有移位就断言是RC4或TEA结果解密一堆乱码。问题往往出在没核对参数细节。我的习惯是先用标准实现跑一遍如果输出对不上立刻去对照反编译代码里的常量、初值、轮数而不是怀疑整个算法结构。这个习惯在实战中非常救命。2. 静态特征定位不运行程序先把代码指纹挖出来2.1 RC4的固定骨架识别RC4的静态特征在反编译结果里相当好找。你要找的是两层循环结构。外层循环从0到255对256字节S盒做初始化内层循环同样从0到255做密钥置换。伪代码长这样for (i 0; i 256; i) { S[i] i; } j 0; for (i 0; i 256; i) { j (j S[i] key[i % key_len]) 0xFF; swap(S[i], S[j]); }识别时我一般会注意几个细节。第一S盒数组的大小必须是256字节。第二初始化循环里必须有S[i] i的痕迹或者是等价操作。第三PRGA阶段必须有两次交换和两次索引更新的痕迹。第四加密输出通常是对明文逐字节异或一个密钥流字节。如果看到反编译代码里出现一个大小为256的全局数组同时有一个循环在对它初始化随后又有一个循环在反复交换数组元素那基本可以断定RC4家族。此时再往后看交换时的索引计算如果带了额外的加数、异或值或者数组初始值不是i而是其他表达式就可以判断魔改点。2.2 TEA黄金常量的存在与消失TEA最显著的静态特征是在二进制中可以直接搜索到0x9E3779B9这个32位常量。用IDA或Ghidra打开文件后在常量搜索里直接搜十六进制9E3779B9或者在小端序下搜B9799E37经常能一击命中。x86程序里存储小端序所以你在Hex窗口里看到的往往是B9 79 9E 37。如果搜不到这个常量不代表不是TEA恰恰可能是魔改。此时应该回到反编译代码里去数结构特征。TEA的轮函数特征极其明显反编译后就是一组嵌套的移位和异或加法我用Ghidra看到的典型反编译长这样void tea_encrypt(unsigned int *v, unsigned int *k) { unsigned int v0 v[0], v1 v[1]; unsigned int sum 0; unsigned int delta 0x9e3779b9; for (int i 0; i 32; i) { v0 ((v1 4) k[0]) ^ (v1 sum) ^ ((v1 5) k[1]); sum delta; v1 ((v0 4) k[2]) ^ (v0 sum) ^ ((v0 5) k[3]); } v[0] v0; v[1] v1; }这种“左移4、右移5、加sum、加key、异或”的表达式组合在反编译里只要出现十有八九是TEA家族。如果delta是常量直接看常量值如果delta是从某处计算来的或者是某个宏定义就需要单步跟了。XTEA和XXTEA也属于这个家族。XTEA的轮函数不同之处在于会用(sum 11) 3来选key下标而XXTEA是块级加密结构更复杂。识别TEA家族的关键还是看那三件套运算不在delta值本身。2.3 Base64的索引表识别与长度特征Base64识别是最容易也最容易翻车的。容易之处在于标准Base64字符表是一串非常显眼的ASCII字符串直接搜ABCDEFGHIJKLMNOPQRSTUVWXYZ就能定到。翻车之处在于出题人把表换掉之后这串字符串就变得毫无规律你可能搜不到任何标准字符串。我的经验是先搜索所有长度64的可打印字符串。用IDA的Strings窗口或者Ghidra的Defined Strings筛选长度在60到70之间、内容全部是可见ASCII字符的字符串。一个长度正好64、内容看起来像乱序字母和数字的字符串极大概率就是Base64换表。另一种辅助判断方法是看处理逻辑。Base64编码过程中会有连续移位和与操作比如取c 2、(c 3) 4、查表输出。反编译代码里会有一个函数反复做这些操作并且调用一个查表函数。查表函数内部往往是对一张静态字符表进行下标访问。如果你看到这类逻辑但字符表不是标准表那就能确认是换表魔改。2.4 特征检索的实操手法实际做题时我不会只靠肉眼在反编译窗口里翻。用Ghidra的话我会直接打开Symbol Tree和Functions窗口按函数名或者字符串引用倒着找。用IDA的话ShiftF12打开字符串窗口然后右键搜索十六进制常量。还有一个野路子直接在二进制文件里用strings命令把可打印字符串全导出来再用Python过滤长度。strings babyre | grep -E ^[A-Za-z0-9/]{60,70}$这个命令会把长度60到70的可打印字符串全部捞出来Base64换表基本无处可逃。如果题目还有压缩壳就先脱壳再跑strings不然看到的都是加壳后的数据。3. 动态确认代码会骗人但运行时的内存不会3.1 单步调试RC4观察256字节S盒静态识别会有模棱两可的情况尤其当出题人把代码混淆或者内联展开后反编译结果可读性极差。这时候就得靠动态调试来确认。RC4的动态特征就是那256字节S盒。用gdb或x64dbg在可疑函数入口下断点然后单步到S盒初始化完成后去看内存中那一片连续256字节的数据。如果是标准RC4初始化后能看到S[0]0, S[1]1, S[2]2这样的递增序列。如果看到的是乱序但后续又有一个循环在交换说明S盒被改过初值。我常用的命令是gdb里用x/256bx直接dump内存。确认S盒后还可以在PRGA的交换处下断点观察每次生成的密钥流字节跟自己写的模拟脚本对拍这样能快速确认索引算法和标准RC4的差异。3.2 TEA轮函数的动态辨识TEA的动态确认重点在轮数和delta。在可疑函数入口下断点后单步走循环数一下它实际循环了多少次。标准TEA是32轮看到循环变量每次加1、到32退出就是标准或接近标准的实现。如果看到判断条件是i 16或者i 64那就是被调整过轮数。delta的动态确认稍微麻烦。你可以在循环体内部观察sum寄存器值的变化。标准TEA每轮会给sum加上0x9E3779B9所以第一轮后sum应该等于这个值。如果发现sum加的是别的数字直接在内存窗口里搜这个值就能精准定位delta常量存储在哪个地址然后提取出来还原算法。3.3 Base64长度关系的快速验证Base64最硬的动态特征不在内存而在输入输出长度关系。你随便输入3个字符如果输出是4个字符输入1个字符输出2个字符且带两个基本可以断定编码逻辑是Base64。换表与否不影响长度关系所以这一步只能确认“是不是Base64类似物”最终解密还是得靠找表。还有一种情况是出题人把Base64做了层花指令表面看不出来。这时可以故意输入特殊字节来观察输出比如输入字节0x00 0x00 0x00标准Base64会输出AAAA。如果输出不是这四个字符说明字符表换了但换法还得靠静态逆向找表。3.4 用预期明文辅助反推动态确认还有一个高效的思路拿已知明文去喂。CTF题目里往往存在一个“判断输入是否等于flag”的比较函数你可以在比较函数处下断点观察它比较的数据。如果比较的是密文那你已经拿到了加密输出如果比较的是加密后的数据那你就能在内存里同时看到明文和密文直接反推密钥流。我在实战中经常这样干在strcmp或memcmp处下断点看参与比较的两个缓冲区。一个缓冲区是用户输入加密后的结果另一个是存储的密文。缓冲区里的字节序、长度、以及是否为ASCII可见字符能帮你快速判断加密算法的类型。密文如果是不可见字节且长度是8的倍数那多半是TEA长度任意且看起来随机多半是RC4长度恰好是4的倍数且字符都是可见的多半是Base64。4. 魔改变种的识别与还原从“像”到“是”的跨越4.1 RC4变形S盒替换、密钥流后处理、索引扰动RC4的魔改点主要集中在三处S盒初值、KSA的j计算、PRGA的密钥流生成方式。第一处S盒初值最直接的识别方法是看初始化循环中S[i] 表达式的右侧。如果是i本身标准如果是i const、i ^ const、i * const那就有魔改。处理方法是把那个表达式还原成Python里的等价操作然后用标准RC4框架套进去跑。第二处KSA的j计算标准是j (j S[i] key[i % keylen]) 0xFF。出题人喜欢在尾部加个常数seed变成j (j S[i] key[i % keylen] seed) 0xFF或者把i % keylen换成i % 16之类固定长度或者对key[i]先做一次异或再参与计算。识别方法是反编译后仔细看那条赋值语句的右侧有几个加数、几个异或项逐个提取操作数。第三处PRGA生成的密钥流标准是t (S[i] S[j]) 0xFF; k S[t]。魔改可能是k S[(S[i] S[j] 0x10) 0xFF]也可能在异或明文之前先对密钥流字节做一次k ^ 0xAA之类的额外变换。这种后处理很隐蔽因为你不对比标准输出根本看不出来。写解密脚本时我习惯把这三处参数全部参数化。比如def ksa(key, seed0, xor_keyFalse): S list(range(256)) # 如果魔改S盒初值在这里改 j 0 for i in range(256): k key[i % len(key)] ^ 0x5A if xor_key else key[i % len(key)] j (j S[i] k seed) 0xFF S[i], S[j] S[j], S[i] return S这样每遇到一个魔改点只需要改一两个参数就能快速跑通。4.2 TEA变形delta替换、轮数调整、密钥调度扰动TEA的还原难点在于delta替换后的值往往没有任何规律。如果你搜不到0x9E3779B9不要急着放弃可以先用IDA脚本扫描所有32位常量挑出那些在循环体里被累加到某个变量的值。TEA的delta在加密和解密过程中方向相反加密时sum delta解密时sum - delta所以这个常量一定是被加载后做加法的。也有出题人会把delta拆成两个16位常量再拼起来比如0x9E37 0x79B9的某种组合反编译里看起来是两次加法。这种情况下你直接看反编译里sum 0x9E3779B9旁边的注释往往能发现蛛丝马迹。轮数调整的识别相对简单。找循环体数循环次数。32轮标准16轮、64轮都是常见变体。轮数一变解密脚本里的迭代次数就要同步变这个不能想当然以为是32。密钥调度扰动则比较阴险。标准TEA四个32位key按k0、k1、k2、k3固定使用有的魔改会把它们替换成可变的查询比如key[(i % 2) * 2]、key[3 - (i % 4)]导致你以为自己在做标准TEA实际每轮用的子密钥顺序完全不同。遇到这种情况别急着还原整个算法先静态提取反编译代码里每一轮实际使用的key下标再写死到你的解密脚本中。4.3 Base64变形字符表替换、填充符变化、索引变换Base64的魔改变种最常见的就是换表。识别后还原方法是把那张64字符表直接提取出来替换标准表即可完成解码。这里有个极其实用的技巧用Python的str.translate或base64.b64decode(altchars...)很容易处理但如果字符表顺序和标准表不一样直接传altchars是不行的因为altchars只支持换最后两个字符。正确做法是手写一个自定义解码函数建立字符到6位数值的映射表import string def custom_b64decode(data, custom_table): # 建立字符到索引的映射 table_map {c: i for i, c in enumerate(custom_table)} bits for c in data: if c : break bits f{table_map[c]:06b} # 按8位切分还原字节 bytes_out bytearray() for i in range(0, len(bits) - 7, 8): bytes_out.append(int(bits[i:i8], 2)) return bytes(bytes_out)另一种魔改是填充符变化。标准Base64用做填充有的题目会把填充符改成!、?甚至直接去掉。识别方法是在反编译代码里找填充相关的判断看它比较的字符是什么。如果是!那解码时也要按!处理。还有一种是索引变换即字符虽然还是标准表但6位索引到字符的映射不是一一对应而是做了某种置换。这种比较少见但一旦遇到上面的手写解码函数也能处理只需要把table_map的构造改一下。4.4 组合使用与多层加密的判断真实题目经常把三种算法叠在一起。最常见的是先Base64编码再RC4加密或者先RC4加密再TEA加密。这种组合题目的识别难度不在于算法识别而在于调用顺序的判断。我的方法是先找密文和明文之间的数据流向。在反编译里找到处理用户输入的函数沿着函数调用关系往下走看每一步输出去了哪里、又被谁调用。加密函数的输入输出往往通过指针传递你在Ghidra里看函数的参数和返回值能理清链条。还有一种巧办法动态调试时在关键函数入口打印输入输出长度长度变化能给你重要提示。RC4输入输出等长TEA会把输入padding到8的倍数Base64会把输入变成4/3倍长度。这三个特征的组合能帮你判断到底套了几层。5. 实战复盘一道RC4魔改题从拿到文件到解出flag我拿一道典型的babyre题目串一遍完整流程。题目文件是一个Linux ELF运行后会让你输入一段字符串输入错误就输出Try again。拿到文件后第一步跑file和strings。file结果说是64位ELFstrings里看到了Try again、Correct和一段看起来像数组的十六进制数据。这段数据长度256字节我立刻警觉这可能是个S盒。第二步用Ghidra打开定位到main函数。main函数先打印提示然后调用一个encrypt_input函数再把加密结果和一段固定的全局数组比较。encrypt_input里有一个for循环循环变量从0到255看到这句反编译local_108[i] i;标准RC4的S盒初始化。但还没来得及高兴我发现紧接着的第二个循环里j的计算多了一个加数j (j local_108[i] key[i % keylen] 0x37) 0xff;多出的0x37就是魔改点。这直接导致从这段代码生成的密钥流和标准RC4完全不同。第三步提取key。key是哪里来的呢回到main函数发现encrypt_input的第二个参数是一个固定字符串。把这个字符串提取出来当作RC4的key。第四步用Python模拟魔改RC4。我的解密脚本是def solve(key, enc_data): S list(range(256)) j 0 keylen len(key) for i in range(256): j (j S[i] key[i % keylen] 0x37) 0xFF S[i], S[j] S[j], S[i] i j 0 out [] for byte in enc_data: i (i 1) 0xFF j (j S[i]) 0xFF S[i], S[j] S[j], S[i] t (S[i] S[j]) 0xFF out.append(byte ^ S[t]) return bytes(out) enc bytes.fromhex(...固定的十六进制密文...) key bsecret_key print(solve(key, enc))跑完之后输出的就是flag而且以flag{}开头。整个过程从拿到文件到解出flag不到半小时。这道题的关键点只有一个识别出那个额外的0x37。如果不知道RC4的KSA标准形态就会在这上面卡很久。这道题的教训是识别魔改不能只看“有没有两层循环”要看循环体里比标准实现“多出来的那部分”。那部分就是出题人的签名。6. 常见卡壳点与排查技巧实录6.1 问题速查表现象可能原因解决思路反编译看到S盒但密钥流不对KSA或PRGA有额外操作数逐条对比标准实现找出多加的常量或异或项搜不到0x9E3779B9但轮函数明显是TEAdelta被替换在循环体里找被累加到sum的常量提取后替换解密结果半截正常半截乱码轮数或padding方式不对检查循环次数、块对齐方式、是否有多轮迭代输出长度恰好是4的倍数但字符不可见Base64后加额外加密先按Base64尝试不行再按RC4/TEA解调用strings看到疑似S盒但脱壳后变了加壳导致数据段被动态解密先脱壳再分析或者动态dump内存动态调试断点不命中花指令/反调试用硬件断点或patch掉反调试分支6.2 独家避坑经验第一个坑是盲目套脚本。很多人下载个标准RC4、TEA的Python实现直接把密文塞进去发现输出乱码就懵了。我的建议是标准脚本只用来“对拍”绝不能直接用来解题。CTF里的加密算法几乎没有100%原版的你把它当作“近似参考”才能保持警觉。第二个坑是忽略字节序。TEA处理的是32位无符号数在x86上小端存储但你dump密文时得到的是按字节排列的十六进制。如果你直接把dump的十六进制当作TEA输入等于把每4个字节反转了一次解密结果当然是乱码。正确做法是先把密文按小端转成32位整数数组再传入TEA解密函数。第三个坑是不知道何时该放弃静态转向动态。静态看半天看不出来就一直死磕这是新手的通病。我的经验是如果在一个可疑函数上花了30分钟还没理清结构果断上gdb或x64dbg用下断点、改内存、观察寄存器的方式直接“试”出结论。动态调试往往比静态分析快得多因为它直接告诉你数据怎么流动。第四个坑是忽略Base64在整体流程中的位置。Base64出现在加密链中时往往是最后一层“输出层”用来把二进制密文变成可打印字符串。我遇到很多题目是RC4加密后用Base64编码如果你先解Base64得到密文再解RC4顺序就对了如果反过来直接拿Base64字符串去当RC4密文必错无疑。判断顺序的方法就是看比较函数的输入如果参与比较的是可见字符串那外层就是编码如果是不可见二进制外层就是加密。第五个坑是没有养成保存现场的习惯。CTF比赛时间紧张我经常遇到一种情况好不容易分析出算法和密钥但重启之后忘了之前动态调试时的内存地址和S盒状态又得重新调试一遍。现在我的做法是每识别出一个关键函数、一个魔改点就立刻写到注释里或者单独存一个笔记。甚至会把动态dump出来的S盒、密钥流保存成文件留着后面和脚本对拍用。这个习惯帮我省下了大量重复劳动的时间。还有一个非常实用的技巧如果你怀疑某种算法但拿不准就找一段已知明文喂进去观察输出。RC4输出长度等于输入长度TEA输出长度是8的倍数Base64输出长度是4的倍数且为输入长度的4/3。这个长度维度上的判断在反编译混乱时几乎是最可靠的锚点。我甚至会在比赛前把这三种算法各写一个最小可运行的脚本放在手边遇到题目先跑一遍标准版本再用魔改点去修正。这种“先标准后修正”的流程比从零开始逆向高效得多。最后再分享一个个人习惯。我在分析完一道题目后会把出题人埋魔改点的位置记录下来形成自己的知识库。时间久了你会发现所谓“魔改”的套路其实很有限来来去去就是常量替换、循环变化、输入输出变换、填充处理这几类。识别多了看一眼反编译代码心里基本就有数了。希望这篇文章能帮你少走一些弯路在赛场上少卡几次壳。
阅读完成 · 觉得有帮助?
咨询建站