先说明一点: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的话,Shift+F12打开字符串窗口,然后右键搜索十六进制常量。还有一个野路子:直接在二进制文件里用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, seed=0, xor_key=False): 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:i+8], 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位ELF,strings里看到了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 = b"secret_key" print(solve(key, enc))跑完之后输出的就是flag,而且以flag{}开头。整个过程从拿到文件到解出flag,不到半小时。这道题的关键点只有一个:识别出那个额外的0x37。如果不知道RC4的KSA标准形态,就会在这上面卡很久。
这道题的教训是:识别魔改,不能只看“有没有两层循环”,要看循环体里比标准实现“多出来的那部分”。那部分就是出题人的签名。
6. 常见卡壳点与排查技巧实录
6.1 问题速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 反编译看到S盒但密钥流不对 | KSA或PRGA有额外操作数 | 逐条对比标准实现,找出多加的常量或异或项 |
| 搜不到0x9E3779B9但轮函数明显是TEA | delta被替换 | 在循环体里找被累加到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。这个长度维度上的判断,在反编译混乱时几乎是最可靠的锚点。我甚至会在比赛前把这三种算法各写一个最小可运行的脚本放在手边,遇到题目先跑一遍标准版本,再用魔改点去修正。这种“先标准后修正”的流程,比从零开始逆向高效得多。
最后再分享一个个人习惯。我在分析完一道题目后,会把出题人埋魔改点的位置记录下来,形成自己的知识库。时间久了你会发现,所谓“魔改”的套路其实很有限,来来去去就是常量替换、循环变化、输入输出变换、填充处理这几类。识别多了,看一眼反编译代码,心里基本就有数了。希望这篇文章能帮你少走一些弯路,在赛场上少卡几次壳。