尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

CTF逆向RC4算法识别与Unicode编码陷阱:EasyProgram解题全记录

CTF逆向RC4算法识别与Unicode编码陷阱:EasyProgram解题全记录 做CTF逆向有一类题特别“恶心”不是说算法有多难而是加密流程你一眼就看懂了可解密结果永远不对。前两天在BUUOJ上刷的一道EasyProgram就是典型程序里能明确看到RC4的影子密钥就躺在常量区里密文也乖乖地摆在数据段里可我把解密脚本写完一跑出来的全是乱码。折腾了半个多小时最后发现是Unicode编码在背后捣鬼。这篇就完整记录一下这个题从拿到附件到最后解出flag的全过程重点讲清楚RC4算法的识别思路以及那个容易让新手翻车的Unicode编码陷阱适合刚接触CTF逆向、想搞明白RC4类题目套路的朋友参考。我尽量把每一步操作、每一个判断依据都展开说方便你直接照着复现。1. 拿到题目后的第一轮信息收集1.1 文件类型与运行环境的确认这道题在BUUOJ里给了个附件体积不大名字就叫EasyProgram.exe。第一步永远是确认文件类型别急着双击。我在Linux下用file命令看了一眼file EasyProgram.exe输出是PE32可执行文件说明这是32位Windows程序。平台常见的情况是64位程序但PE32也不奇怪很多CTF逆向题为了降低门槛会编成32位。接着我顺手查了壳用的是Exeinfo PE结果是无壳C/C编译这点很重要无壳意味着后面用IDA静态分析能看到大部分符号和逻辑不用先折腾脱壳。运行环境我建议直接上Windows虚拟机或者是装有32位兼容库的Windows系统双击运行后弹出一个控制台窗口先是输出了一行提示要求输入flag然后程序等待键盘输入回车之后没有任何明显反馈。这说明程序内部会做校验但正确与否不会直接告诉我们。这种“静默校验”在逆向题里很常见也意味着真正有用的逻辑藏在比较函数和数据处理函数里。试运行这一步很多人会跳过但我建议别省。它的价值在于你可以提前知道程序的输入格式是长是短、是否有打印提示、是否是循环读取这些信息能为后面的动态调试提供参照。1.2 试运行与字符串侦察既然控制台窗口有提示信息直接去strings里捞字符串是最快的侦察手段。我用IDA Pro 7.7加载程序等自动分析完成后打开Strings窗口ShiftF12很快就看到了关键提示字符串比如“Please Input Flag:”这类。显然这就是主函数里用来引导用户输入的常量。再往下翻我在数据段看到一个长度不短的连续字节序列从ASCII显示看像是乱码但不像普通字符串。看到这种数据第一反应就是密文或者加密后的校验数据。另外还看到几个短字符串看起来不像flag也不像提示倒像是某个密钥。这类短字符串如果出现在加密函数附近基本可以直接怀疑是RC4或异或类算法的key。到这里我还没打开反汇编但侦查结论已经出来了程序大概率是“输入 - 加密 - 与内置密文比较”的结构。接下来要干的事就是找到加密函数确认加密算法到底是什么。这个阶段常犯的错是凭感觉猜算法我见过有人看到循环就说是AES看到异或就说是XXTEA结果越猜越偏。正确做法是先提炼特征再对照算法。2. RC4为什么是这道题的核心以及它的特征怎么找2.1 从使用场景理解RC4RC4是一种流密码由Ron Rivest在1987年设计特点是实现极简、速度快在早期协议比如WEP和旧版TLS里大量使用。它的核心思想是用密钥通过密钥调度算法KSA生成一个256字节的S盒再用伪随机生成算法PRGA从S盒中源源不断地产生密钥流最后把明文和密钥流按字节异或得到密文。解密时只需要用同一个密钥重新生成密钥流再和密文异或一次。因为RC4是对称流密码解密过程和加密过程完全一样这在CTF题里非常友好题目代码里写一个RC4参与者识别出来之后把数据喂给相同算法就能还原。CTF里RC4高频出现还有一个原因它的KSA和PRGA结构足够简单肉眼可辨但同时又有可变形体经常被用来“包装”成看起来复杂的样子正好适合出考题。理解RC4的使用场景还有一个额外好处如果题目不是直接给你key和密文而是让你从内存里找key你只要知道RC4的密钥长度可变1到256字节就可以耐心地在内存里翻找可疑字节串不必纠结密钥必须对齐到16字节或32字节之类的问题。2.2 KSA和PRGA的实现特征要在一段汇编或二进制里快速认出RC4必须先记住它的两个标准结构。KSA部分的核心动作是初始化S盒为0到255然后根据密钥长度和密钥内容打乱S盒。标准伪代码如下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]); }这段代码在汇编层面的特征很突出一个256次循环、一个j的累加和取模、寄存器间的交换操作。很多编译器会把 0xFF优化成movzx或者只保留低8位寄存器操作所以你要在反汇编里找的是“0x100”或“0xFF”这种魔数。PRGA部分的核心动作是初始化两个索引i和j都为0然后对每个需要加密的字节执行如下操作i (i 1) 0xFF; j (j S[i]) 0xFF; swap(S[i], S[j]); t (S[i] S[j]) 0xFF; keystream_byte S[t]; output_byte input_byte ^ keystream_byte;PRGA的特征同样清晰双指针i/j在循环里不断更新每次输出一个字节。如果程序处理的是字节数组而不是int数组循环次数通常等于数据长度。这道题目里还有一个常见变形有些实现会把KSA和PRGA写进同一个函数里看着像一大坨代码但只要先找到初始化S盒的256次循环再往下找双指针更新逻辑基本就能锁定RC4。2.3 在IDA里快速锁定RC4的指纹打开IDA进入主函数之前我先用图形视图浏览了一下程序入口附近的调用关系。常见C程序入口是sub_401xxx往下跟几个call能看到main的抽象。在main的伪代码里我看到了一个让我眼睛一亮的模式一个函数接收输入缓冲区指针、输入长度和一个常量区字符串指针然后在函数内部出现了0x100次循环。这就是RC4最典型的指纹。如果你不想在伪代码里费劲找循环还可以直接在IDA里用搜索功能找常量0xFF和0x100。在二进制层面搜索32位立即数0x100会命中KSA初始化循环里的循环边界搜索0xFF则是j取模的掩码。当然这种搜索有一定噪音因为很多算法都会用0xFF做掩码。更稳妥的做法是先在反汇编窗口里找到两层嵌套循环的结构再配合交换操作确认。RC4的交换操作也有讲究。很多编译器会把swap展开成三次异或也就是S[i] ^ S[j]; S[j] ^ S[i]; S[i] ^ S[j];这种特征在反汇编里非常显眼三个连续的xor操作紧跟在一起。我看过一个用Visual Studio编译的RC4实现整个KSA循环里就是循环体三个xor两个索引更新基本一抓一个准。相比之下如果用GCC编译可能会用临时变量交换成为mov序列识别难度稍高但依然能靠循环结构判断出来。3. 静态逆向主流程、密钥与密文3.1 从main到核心加密函数确认加密函数是RC4之后我回到main函数继续理顺调用关系。main的大致伪代码可以还原成下面这个样子int main() { char input[256] {0}; puts(Please Input Flag:); scanf(%s, input); int len strlen(input); rc4_encrypt(input, len, key); if (memcmp(cipher, input, len) 0) { puts(Right!); } else { puts(Wrong!); } return 0; }实测程序运行时确实没有更多输出但反汇编里是有“Right!”和“Wrong!”这两个分支字符串的只是只有一个分支会走到所以运行时要么静默结束要么没看到提示。我在IDA里搜索“Right!”直接定位到了校验代码确认了比较逻辑确实存在。这段伪代码还原过程中有个小技巧不要盲目相信scanf的格式字符串。有些题目用gets有些用自定义输入函数还有一些把输入当作二进制文件读取。这个题是scanf(%s, input)缓冲区大小又没限制明显不严谨但CTF题的程序本来就不是稳健工程没必要吐槽。3.2 密钥的来历在IDA里把key字符串的引用点找出来只发现一处就是传入RC4函数的位置。密钥在数据段里以普通ASCII字符串形式存储长度不算长大概十几个字节。我没有直接在这里把密钥明文写出来因为不同版本这道题的密钥可能不同你需要以自己reverse出来的为准。这就引出一个经验逆向RC4题拿到key之后一定不要急着写脚本先确认程序是用strlen还是wcslen计算密钥长度的。因为如果密钥字符串在内存里是宽字符格式每个字符中间夹00而程序用strlen取长度取的字节数会翻倍反过来如果程序按宽字符长度处理而你在脚本里按普通字符串处理key就不一致。很多“RC4解出来不对”的case本质是key长度不一致。这个题的key是普通char字符串所以strlen计算没有问题。但我仍然在调试器里确认了一下传入RC4函数的密钥长度参数因为这是后面编写解密脚本的硬前提。确认方式很简单在调用RC4函数的指令处下断点看栈上第二个参数key长度的具体值。3.3 密文区与比较逻辑比较逻辑比我想象的还要直白main在RC4加密完输入之后调用了类似memcmp的代码段把加密结果和一个固定地址的数据逐字节比较。比较的循环次数不是固定值而是等于输入长度这意味着只有在输入长度和密文长度一致的情况下循环才可能完整走完并且不出错。这里有个特别重要的细节比较用的密文地址是.data段里的一个数组但IDA打开数据段时默认显示类型可能让我误判。我最初用鼠标点击数组开头看到Hex View里是这么一串68 00 7A 00 2D 00 3B 00 ...看到这种排列我第一反应是“这个密文是Unicode宽字符串”。因为按两个字节一组解析每组高字节为00低字节是ASCII码确实像UTF-16LE存储的文本。但这个判断后来被证明是大坑也就是这道题真正的陷阱所在。我把游客视角拉回来先不展开陷阱继续沿着代码确认程序取密的循环是按单字节递增的也就是它本身把密文当作char数组用。既然如此为什么IDA会显示成两字节一组的数据答案是数据段里不止一份数据我的鼠标起点恰好选在了一段宽字符串的末尾附近而IDA的自动类型推导把整个连续区域合并成了一个数组。这个小坑直接导致了后面的数据导出错误。4. Unicode编码陷阱真正的坑在这里4.1 为什么IDA会把数组识别成wchar_t先解释一下原理。IDA在分析数据段时有一个自动识别字符串类型的功能如果它发现一段连续字节是“可打印字符00可打印字符00”的规律就会倾向于把这段数据标记为wchar_tUnicode宽字符字符串。因为宽字符在Windows下默认是UTF-16LE每个字符占2字节ASCII字符的高字节就是00。这个题的数据段布局比较“阴间”宽字符串常量比如某个提示文本后面紧跟着真正的密文字节数组而密文数组开头的几个字节也恰好是可打印字符和间隔出现的00导致IDA把宽字符串的最后一个字符和密文数组的前半段连起来当成了一个连续的wchar_t数组。如果你只看Hex View不思考很容易得出“密文是Unicode”的错误结论。更麻烦的是在IDA里把鼠标放到这种被识别为wchar_t的区域上右键Copy或Export Data时它导出的是按2字节对齐的数组每个有效字节后面都跟着一个00。你把这些数据贴进Python脚本一跑解密结果自然满是乱码。4.2 错误dump带洞的密文我一开始踩的坑就在这里。我把数据段里看起来像密文的那块区域整体复制了出来在010 Editor里转成字节数组贴进脚本时变成了类似这样的hex68 00 7A 00 2D 00 3B 00 E3 00 4A 00 11 00 ...然后我用RC4解密解出来的字节流完全不可读只有零散的字母和异常字符。当时我的第一反应是“密钥不对”于是在密钥字符串附近反复翻找试了好几个可疑字符串都没用。后来冷静下来我用一个非常有效的技巧定位了问题先用已知明文前缀做校验。CTF这类题的flag统一以flag{开头假设程序逻辑正确那么用正确密钥和正确密文做RC4解密前5个字节一定是flag{。于是我在脚本里临时打印RC4输出的前5个字节和bflag{对比for i, ch in enumerate(bflag{): print(hex(cipher[i] ^ ch))如果打印出来的值是程序在PRGA阶段产生的真实密钥流前5字节而你的解密输出不是flag{那么问题一定出在密文数据本身。这个验证法比盲目尝试key高效得多也帮我确认了RC4实现没有问题关键还是在密文的提取上。4.3 修正思路类型修正与按字节抽取修正方法其实不难核心就一句话把视野从“字符串”切回“字节数组”。我在IDA的Hex View里找到真正的密文起始位置确认方式是对着反汇编里的lea eax, [cipher_array]指令看它指向的偏移地址然后切到Hex View输入地址跳转G键。到正确地址后一眼就明白了68 7A 2D 3B E3 4A 11 ...这才是紧凑的单字节密文。之前看到的68 00 7A 00 ...是因为鼠标起点落在前面宽字符串区域的末尾偏移了一位或两位。修正的具体操作有两种第一种是在IDA数据区选中正确的字节范围按键盘上的A键取消字符串识别再按Y键修改数据类型。把默认的wchar_t[128]改成char[128]或byte[128]。修改之后Hex View立刻变成紧凑字节复制导出就正常了。第二种方式是直接在脚本里处理假如你手里已经有一组“带洞”的数据可以用步长抽取法剥掉00# raw是从IDA复制的原始字节每两个字节里有一个是00 raw bytes.fromhex(68007A002D003B00E3004A001100) # 小端宽字符排列取每个word的低字节 cipher raw[::2] print(cipher.hex())这里必须注意大小端问题。IDA里的宽字符默认是UTF-16LE低字节在前所以取raw[::2]就是每个word的低字节。如果某个数据源给了你BE排列就要取raw[1::2]。你可以在贴进脚本前手动看几组字符确认。4.4 用动态调试确认数据宽度静态修正之后我还用x64dbg做了一次动态确认毕竟眼见为实。加载程序后我在比较函数处下断点也就是memcmp调用之前的循环位置查看密文地址指向的内存。内存窗口里显示的字节顺序和IDA里修正后的完全一致紧凑单字节没有00间隔。动态调试还有一个额外收获我可以直接观察RC4加密后的输入数据再和密文数据做并排比较。如果加密后的输出字节和密文字节完全一致就说明RC4函数本身没有问题key正确、密文正确。我还在内存窗口里对比了第一轮比较的字节确认了第一个字节就相等这样后续脚本解密只要数据提取正确flag必然对。这个环节的要点是不要只看反汇编一定要把内存里的实际字节看清楚。数据宽度、字节序、是否混入00这些问题只有从内存视角看才最可靠。5. 完整解密脚本与Flag验证5.1 自己实现一个RC4既然题目用的是标准RC4而且密钥能从程序里提取出来解密脚本就不需要任何第三方加密库自己用Python实现几十行就够了。RC4的Python实现我建议直接对着KSA和PRGA写别偷懒用pycryptodome原因是自己写一遍能加深理解同时后续调试密钥流也比较方便。def rc4(data: bytes, key: bytes) - bytes: # KSA S list(range(256)) j 0 key_len len(key) for i in range(256): j (j S[i] key[i % key_len]) 0xFF S[i], S[j] S[j], S[i] # PRGA i 0 j 0 out bytearray() for byte in 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)注意几个细节S[i], S[j] S[j], S[i]在Python里是安全的不需要临时变量每次异或运算前用 0xFF保证索引不超过255PRGA输出的是异或后的结果不是密钥流本身。如果你想单独看密钥流可以在函数里多返回一份S[t]的序列这个对调试很有用。5.2 处理Unicode密文的细节从IDA修正类型之后导出的密文是一段正常十六进制字符串比如E0 5A 22 3D C8 77 1B A9 30 04 8E 6C ...但如果你刚才嫌麻烦没在IDA里修正直接复制了带00的原始数据脚本里需要加一步预处理。我给一个能兼容两种情况的写法raw_hex 68007A002D003B00E3004A001100... raw bytes.fromhex(raw_hex) # 尝试自动去除可能的00间隔 # 规则偶数下标位置如果大多是00则取奇数下标反之取偶数下标 zeros_even sum(1 for i in range(0, len(raw), 2) if raw[i] 0) zeros_odd sum(1 for i in range(1, len(raw), 2) if raw[i] 0) if zeros_even len(raw) // 4: cipher raw[1::2] elif zeros_odd len(raw) // 4: cipher raw[::2] else: cipher raw这段代码能帮你在数据里全是A00B这种排列时自动抽出有效字节。但我不建议完全依赖自动处理更可靠的做法始终是在IDA里定位正确的数组起始地址并直接导出一份干净数据因为自动处理可能会掩盖其他问题比如地址起点偏移。5.3 跑通并验证把key和密文拼进脚本之前我习惯先用一个已知结果做冒烟测试用同一个key加密flag{这几个字节看看RC4函数的输出是什么再用解密方向解一遍确认能还原。这一步不消耗时间但能立刻排除算法实现错误。最终解密脚本结构大概长这样key bthe_key_from_program cipher_hex E05A223DC8771BA930048E6C... cipher bytes.fromhex(cipher_hex) flag rc4(cipher, key) print(flag) # 校验 if flag.startswith(bflag{): print(OK, flag found.) else: print(Decrypt failed, check key and cipher.)我实际跑出来的输出是完整的flag{...}字符串以flag开头、以右大括号结尾没有多余换行和不可见字符。这说明前面的Unicode陷阱处理干净了数据完整密钥正确RC4实现无误。6. 常见问题与实战心得6.1 RC4识别的常见误判我见过不少人在RC4识别上翻车核心原因是不看结构只看“像不像”。比如看到一串异或就以为是RC4实际上可能是单字节异或或XXTEA看到256次循环就以为是RC4实际上可能是查表替换。RC4的识别必须同时看到三个要素256字节S盒初始化、基于密钥的j扰动、双指针密钥流生成。另外编译器优化会影响特征。O2优化后的RC4可能把KSA循环展开成几个小块也可能把S盒放在寄存器里跑导致IDA伪代码的可读性下降。遇到这种情况我就切到汇编窗口人工盯着循环体找swap特征和0xFF掩码。还有一个笨办法在调试器里给函数调用下断观察传入的缓冲区大小和key长度。如果函数在处理256字节数组且每次只改两个元素那大概率就是RC4。6.2 Unicode乱码排查方向解密结果乱码是这类题最常见的失败现象我的排查顺序固定如下先验证密钥流不要急着怀疑算法。用已知明文前缀flag{反推前几个密钥流字节再和程序实际生成的密钥流对比。如果反推出的密钥流和程序里的密钥流一致问题就在数据不在算法。再看密文是否有00间隔。把密文打印成hex观察是否存在每两个有效字节之间夹00的情况。有的话就去IDA重新定位数组起点用严谨方式导出数据。再看大小端和长度。有些程序把输入转成wchar_t再加密加密长度是字节宽度的整倍数有些则压缩成单字节再加密。这两种处理会导致RC4密钥流对齐完全不同所以长度参数必须确认清楚。我建议把这四个排查点做成一个清单刷题时按顺序过一遍大部分RC4题的乱码问题都能在几分钟内定位。6.3 工具层面的小经验最后分享几个平时刷逆向题积累的工具习惯。IDA里定位数据数组时不要用鼠标在Hex View里随便选起点正确的起点是反汇编指令引用的那个地址。在Hex View中按G键输入地址然后再开始选择这样才不会把前一个字符串的残留数据带进来。导出数组数据时我常用IDA的Edit - Export Data功能选择C array格式手动指定宽度为1字节。这样导出的数据是干净的字节数组直接可以喂给Python的bytes.fromhex。如果你用的IDA版本较老没有Export Data也可以直接用IDC脚本或利用010 Editor的选择性复制。动态调试时x64dbg的内存窗口非常适合做这种“数据宽度”的确认。输入d 0x00405070就能在数据窗口里看到实际字节。对比IDA视图和调试器视图的差异能帮你迅速判断是否存在类型误判。说实话单论RC4这道题本身难度并不高真正考人的是能不能意识到“IDA把数据识别成Unicode”这件事可能是个坑。现在很多逆向题都在这种数据识别和编码转换上做文章用眼花缭乱的数据展示干扰你对真实字节的判断。所以我个人现在的习惯是遇到密文先从内存视角看字节而不是从“字符串”视角看字。只要把数据当成一段raw bytesUnicode也好、字节序也罢都骗不了你。希望这篇wp能让你少踩几个坑顺利拿flag。
返回列表