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

资讯详情

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

CTF实战:从RC4加密到Unicode陷阱的完整解密思路

CTF实战:从RC4加密到Unicode陷阱的完整解密思路 拿到一道CTF题最先要做的不是急着找flag而是先看穿出题人埋的坑。BUUOJ上的EasyProgram就是这样一道题——名字叫“Easy”实际上加密和编码两层陷阱叠在一起卡住过不少人。这道题的核心是RC4加密但好玩的地方在于它用Unicode编码做了个障眼法让很多人在第一步就解出一堆乱码还以为是自己脚本写错了。这篇就把我完整的解题过程、踩坑记录和最终脚本都整理出来从识别算法到复现解密一步步拆开讲希望给同样卡在这道题上的朋友一点参考。1. 项目题目分析与核心思路拆解1.1 EasyProgram到底在考什么EasyProgram这道题在BUUOJ的crypto分类下题目会给你一段加密过程的伪代码或者源码描述要求你写出对应的解密脚本还原出最初的flag。为什么它叫“Easy”因为核心算法本身非常经典——RC4一种对称加密算法实现起来也就几十行。但为什么它又没那么Easy因为题目在加密之前做了一次Unicode转换如果没注意到这个细节加密方向和解密方向全都会对不上。这道题对新手特别友好的一点是它不会让你去爆破或者猜密钥所有信息都写在题面里。你需要做的仅仅是读懂加密流程按照逆序把每一步还原。和现实中的密码分析相比CTF里的题目更像是“已知算法、已知密钥、求明文”的送分题你只需要模拟反向过程就行。这也是我建议初学者从这类题入手的原因——它不考脑洞只考基本功。一句话总结题目逻辑明文flag先被某种方式转成Unicode宽字符形式然后送入RC4加密流程输出密文。解密就是把顺序倒过来先对密文做RC4解密再把得到的结果还原成原始字符串。1.2 为什么RC4在CTF里这么常见RC4全称Rivest Cipher 4是Ron Rivest在1987年设计的一种流密码属于对称加密。它最突出的优点是速度快、实现极其简单适合在资源受限的环境中使用。虽然今天RC4在很多安全场景已经被淘汰了——比如WPA、TLS都因为RC4的偏置问题放弃使用它——但在CTF题目中它依旧非常受欢迎原因无非两点。第一RC4的代码量很小一个完整的实现只需要两个主要过程KSAKey Scheduling Algorithm密钥调度算法和PRGAPseudo-Random Generation Algorithm伪随机数生成算法。整个算法加起来不到30行非常适合放进题目里不让题目篇幅失控。第二RC4属于流密码加密和解密过程完全对称写解密脚本时几乎可以直接复用加密代码只需要把输入输出对调。这种特性让RC4题目在“难度适中”和“能考基础”之间取得了非常好的平衡。和AES这类分组密码比起来RC4没有复杂的S盒轮变换和密钥扩展过程理解门槛低得多。和凯撒密码、维吉尼亚密码这类古典密码比起来RC4又包含了S盒初始化、状态交换等现代密码学的核心元素能考察对算法本质的理解。所以RC4在CTF入门题里几乎是标配。1.3 这道题的核心陷阱在哪里这道题真正卡住大多数人的地方反而不是RC4本身而是它前面那层Unicode编码转换。你需要意识到题面里给的加密流程描述的是对“字符串表示形式”的操作而不是对“字节存储形式”的操作。很多人在这一步直接踩坑——把字符串按ASCII字节处理结果RC4解出来的东西根本没法读就开始怀疑自己是不是RC4实现写错了。Unicode的陷阱本质在于同一个字符在不同编码方案下对应的字节完全不同。比如英文字母“A”在ASCII下是一个字节0x41在UTF-16 LE下是两个字节0x41 0x00在UTF-16 BE下是0x00 0x41。如果题面里说的Unicode和你脚本里实际使用的编码不一致哪怕算法全对最终结果也是错的。这道题里出现的Unicode陷阱具体来说是在加密前把每个字符的高字节和低字节拆开分别作为两个字符送入RC4加密。解密的时候如果顺序搞反或者没有把宽字节重组回去就会得到一堆中间夹杂着空字符的乱码。这个细节我在后面会详细展开。2. RC4算法原理与题目源码识别2.1 RC4算法的两个核心过程要解这道题必须先吃透RC4的算法流程。RC4的工作过程分两阶段。第一阶段叫作KSA密钥调度算法。简单说就是用一个长度可变的密钥通常1到256字节去初始化一个256字节的S盒。初始化时S盒先按顺序填0到255然后根据密钥的每个字节反复交换S盒中的元素让S盒的状态变得不可预测。伪代码如下# KSA密钥调度 S list(range(256)) j 0 for i in range(256): j (j S[i] key[i % key_len]) 0xFF S[i], S[j] S[j], S[i]注意这里每轮i都会前进一格而j会根据S[i]和密钥字节动态变化。以0xFF做掩码是为了保证下标不越过255因为S盒的索引范围就是0到255。第二阶段叫作PRGA伪随机数生成。KSA完成之后S盒处于一个被打乱的状态。PRGA的作用是持续从S盒中生成密钥流每一轮生成一个字节k然后把k和明文的一字节做异或得到密文的一字节。PRGA内部也会不断地交换S盒元素让密钥流始终保持变化# PRGA生成密钥流 i 0 j 0 for each byte in plaintext: i (i 1) 0xFF j (j S[i]) 0xFF S[i], S[j] S[j], S[i] k S[(S[i] S[j]) 0xFF] cipher_byte plaintext_byte ^ k从这个过程可以看出来RC4的加密和解密完全共享同一套代码只是输入输出反过来。这也是为什么RC4的解密脚本写起来特别顺手——你甚至可以把加密函数复用只要把密文当“明文”再跑一遍输出的就是原始明文。2.2 如何从题目描述里认出RC4拿到题面后首先要做的事就是识别算法。EasyProgram的题面通常会给你一段类似这样的伪代码key abcd s init_s_box(key) data flag.encode(utf-16) # 或者类似的宽字符转换 cipher rc4_encrypt(data, s) print(cipher)虽然每道题的描述形式不同但有几个信号是共通的。第一题面里会出现一个名为s或者S的列表长度是256。这就是S盒。第二题面里会出现两层循环外层循环遍历0到255内层涉及key的取模运算。这就是KSA。第三加密过程逐字节进行异或操作运算符看起来像“xor”或者“^”。这就是流密码的标志。把这三个信号对上之后就可以基本确定题目用的是RC4。接下来需要确认的是密钥是什么、数据在加密前经历了什么变换。密钥通常直接写在题面里是一个字符串。数据变换则可能包含编码、解码、反转、交错等操作——EasyProgram里的操作就是Unicode宽字符转换。2.3 RC4密钥长度对解密的影响RC4的密钥长度是可变的从1字节到256字节都可以。但在CTF题目里密钥通常是几到几十字节的ASCII字符串。这里有一个容易被忽略的注意点key的长度直接影响KSA中的取模运算。如果你的脚本里把密钥的长度写错了或者密钥本身某个字符的编码方式和题面不一致KSA生成的S盒就会完全不同解密结果也会彻底错乱。打个比方如果题面里的密钥是字符串hello但在某个脚本里你把它读成了bhello本质上是一样的但如果题面里强调密钥是按Unicode编码的每个字符占两个字节而你按ASCII处理密钥的字节序列就完全变了。KSA对密钥的每个字节都非常敏感任何一个字节不同最终生成的密钥流都完全不一样。这就是为什么在处理RC4题目时第一步永远是搞清楚密钥的精确字节表示。3. Unicode编码陷阱深度拆解3.1 Python里各种Unicode编码的区别在Python里处理字符串时最容易混的就是UTF-8和UTF-16。默认情况下Python的str类型使用Unicode存储字符但一旦涉及到encode你就必须显式指定编码方式。UTF-8是变长编码英文和数字占1字节中文占3字节。UTF-16则是定长编码基本平面内每个字符固定占2字节英文在UTF-16下会多出一个0x00字节。看个直观的例子。字符串AB在UTF-8下是41 42在UTF-16 LE下是41 00 42 00在UTF-16 BE下是00 41 00 42。注意UTF-16 LE和UTF-16 BE的顺序是完全相反的这取决于你的机器是大端还是小端。CTF题面里如果提到了Unicode通常说的是UTF-16 LE也就是Windows平台常见的宽字符表示。对于解密来说这个区别是致命的。如果你用UTF-8去解码一个UTF-16 LE编码后的数据你会得到一堆乱码和空字符。如果你用UTF-16 BE去解码你会把每个字符的高低字节顺序搞反虽然有些地方看起来像英文但整体完全无法还原成正确flag。3.2 这道题里的Unicode加密过程还原EasyProgram题面里所谓的Unicode编码陷阱它的加密流程还原出来是这样的。假设flag是flag{...}这个字符串里的每一个字符在Unicode编码下都对应两个字节。比如f对应0x66 0x00UTF-16 LEl对应0x6C 0x00。EllipticCurve题面在加密时可能不是直接对这个字符串整体做encode而是把每个字符的高8位和低8位拆开。于是字符串flag就变成了8个字节0x66, 0x00, 0x6C, 0x00, 0x61, 0x00, 0x67, 0x00。然后这8个字节才被送入RC4加密。当你拿到密文开始解密时你得到的是一串字节。如果你只是简单地跑RC4解密得到的是上面那串包含大量0x00的宽字符字节流。这时候必须把每两个字节重新组合成一个宽字符再解码成字符串。如果这个重组过程没有做或者做的顺序不对你就只能看到每个字符之间夹着一个空字符或者干脆解出来全是一堆不可读字符——这就是这道题最经典的卡点。3.3 手动验证Unicode编码是否正确的小技巧我发现一个特别好用的方法。当你怀疑自己解出来的数据可能存在编码问题时直接把结果用hex打印出来一列一列地看。如果看到的是66 00 6C 00这种规律说明你解出来的确实是UTF-16 LE编码的数据需要做字节重排如果你看到的是00 66 00 6C这种规律说明你的数据是大端序但你的解密方向可能和题面反了如果你看到的是66 6C 61 67这种连续的ASCII字符说明你的RC4解密已经把编码陷阱绕过去了可以直接转字符串。平时写脚本的时候我习惯在关键节点加一行print(result.hex())这样出问题的时候能直观地看到字节流向。很多时候解出来的数据不是完全乱码而是“部分对、部分错”这时候用hex查看往往比直接打印字符串更容易定位问题。4. 完整解密实操过程与脚本实现4.1 编写RC4解密脚本的完整步骤先把完整的解密流程列出来再逐步讲解每一步的实现。给定密文和密钥后解密步骤总共四步第一步解析密文把它从十六进制字符串转成字节数组第二步用密钥执行KSA初始化S盒第三步用PRGA生成密钥流并逐字节异或得到解密后的数据第四步把解密后的字节流按UTF-16 LE解码成字符串输出flag。4.2 RC4解密脚本代码详解下面是我最终使用的完整脚本加了注释方便直接对照理解def rc4_ksa(key): RC4密钥调度算法KSA 根据密钥生成初始S盒 key_len len(key) S list(range(256)) j 0 for i in range(256): j (j S[i] key[i % key_len]) 0xFF S[i], S[j] S[j], S[i] return S def rc4_prga(S, data_len): RC4伪随机数生成PRGA 根据S盒生成密钥流长度与data一致 i 0 j 0 keystream [] for _ in range(data_len): i (i 1) 0xFF j (j S[i]) 0xFF S[i], S[j] S[j], S[i] k S[(S[i] S[j]) 0xFF] keystream.append(k) return keystream def rc4_encrypt_decrypt(data, key): RC4加密/解密统一入口 流密码天然支持加解密复用 S rc4_ksa(key) keystream rc4_prga(S, len(data)) return bytes([d ^ k for d, k in zip(data, keystream)]) # 这里的key需要根据题目实际给出内容替换 key bexample_key # 这里的cipher_hex需要根据题目实际给出的密文替换 cipher_hex 0000000000000000000000000000000000000000000000000000000000000000 cipher bytes.fromhex(cipher_hex) # 第一步解密 decrypted rc4_encrypt_decrypt(cipher, key) # 关键调试步骤打印十六进制结果方便观察编码结构 print(Decrypted hex:, decrypted.hex()) # 第二步将宽字符组合成Unicode字符串 flag decrypted.decode(utf-16-le) print(Flag:, flag)有几个地方我需要特别提醒。第一k S[(S[i] S[j]) 0xFF] 这一步如果忘了加 0xFF在Python里不会报错但结果可能会超过255导致索引越界或者密钥流错误。Python的列表索引允许负数和超大数吗如果S[i]S[j] 300你直接访问S[300]就会IndexError。所以这个掩码操作不能省。第二rc4_prga里的s盒是在KSA返回的那个S盒上直接修改的。也就是说如果你调用两次rc4_encrypt_decrypt用的是同一个S盒初始化结果第一次调用会改变S盒状态第二次结果就完全不同了。所以函数的实现逻辑已经保证了每次调用内部都重新跑一遍KSA千万不要把S盒缓存起来跨多次调用复用。第三bytes.fromhex在处理奇数长度的十六进制字符串时会报错。如果你从题目里复制的密文末尾包含换行符或者多余的空格建议先用strip()清理干净再转。4.3 从BUUOJ平台获取输入数据并填入脚本实际的解题过程中你需要从BUUOJ的题目页面拿到两个关键信息第一题目给出的密钥key第二题目给出的密文cipher。这两个信息一般以两种形式出现一种直接写在题目描述里一种是密文写在附件里密钥写在描述中。把获取到的密钥和密文分别填入脚本的key和cipher_hex变量即可。注意密钥是字节串需要用b...的形式传入。如果你从题面拿到的密钥是十六进制形式比如“6b6579”需要先用bytes.fromhex(6b6579)把它转成字节串而不是直接当成文本字符串。结合这道题的实际输出解密后的十六进制应该长这样66 6C 61 67 7B ... 7D。66是f6C是l61是a67是g7B是{7D是}。如果你看到这个模式的输出说明RC4解密和解码都成功了flag就在眼前。4.4 一道新手容易写错的RC4异或细节RC4的异或运算有个很容易被忽略的细节Python中bytes类型和int进行异或时如果你直接写data[i] ^ keystream[i]得到的是int类型。但如果用列表推导式组织结果最后要记得bytes([...])包一层否则输出会是列表而不是字节串后面的decode调用会直接报错。我见过很多人在这个地方卡住大概思路都对就是最后类型对不上。用一个小时排查后才发现是少了个bytes()转换。建议在写脚本的时候就加上类型标注或者在关键位置用type()函数做一次断言能省不少事。5. 常见问题排查与避坑技巧实录5.1 解密结果乱码时的五步排查法如果你按照上面的脚本跑完输出的不是flag而是乱码不用慌按顺序排查下面几个点基本能解决95%的问题。第一步检查key是否正确。重新读一遍题面确认密钥里的每一个字符都一模一样特别注意大小写和下划线。KSA对密钥极其敏感哪怕只错一个字符解密结果也会面目全非。第二步检查密文是否完整。从题目页面复制密文时末尾容易丢失字符。可以先看密文长度是否是偶数不是偶数说明肯定复制出了问题。如果是偶数但依然报错就尝试把十六进制字符串前端的0x或者后端的空格、换行都清理干净再试。第三步检查S盒交换逻辑。KSA和PRGA中都有一个交换S[i]和S[j]的操作。Python里写S[i], S[j] S[j], S[i]是很自然的但如果你手写代码的时候用了临时变量tmp S[i]这一套记得交换顺序不要写反。很多人就是在这里错位导致整个S盒的排列顺序和题目不一致。第四步检查解码方式。如果rc4解密后得到的数据里包含大量0x00字节或者显示为中间夹着空字符的字符串说明还需要进行Unicode字节重排可以用.decode(utf-16-le)直接处理。如果你用utf-8解大概率会报UnicodeDecodeError这时候回到hex里看数据再做判断。第五步检查字节序。如果你尝试了utf-16-le和utf-16-be两种解码结果都是乱码可能问题不在解码而在子节本身的高低字节顺序反了。这时候需要把每两个字节做一次高低位交换再尝试解码。具体来说就是遍历字节流每次取两个字节交换顺序后重组。5.2 Python字符串与字节串混用的典型报错及修复写解密脚本时最常见的一类报错是TypeError: cant concat str to bytes或者类似的消息。这类问题的根源往往是你在代码里把字符串类型和字节串类型混用了。比如你在KSA里写j (j S[i] key[i % key_len])如果key是str而不是byteskey[i % key_len]取出来的是一个字符串字符而不是整数这时候加上去就会报错。修复方法很简单就是确保key一开始就是bytes类型。如果你从函数参数里接收key建议第一行做一次强制转换if isinstance(key, str): key key.encode()。这样即使后面传入的是字符串也不会炸。另一个容易遇到的问题是UTF-16 LE解码时出现“奇数长度”报错。UTF-16编码要求字节流的长度必须是偶数如果你解出来的数据长度是奇数说明前面RC4解密环节可能多了一个字节或者少了一个字节。这时候回顾一下密文长度和解密后的长度是否一一对应确认一下过程中是否有多余的字节混入。5.3 用对比法快速验证RC4脚本是否正确这个方法我强烈推荐。在正式解目标密文之前先用一段已知的明文和密钥测试你的脚本。比如我经常用的测试用例是keybKey明文bPlaintext。正常情况下RC4会输出固定的一串密文通过和网上在线RC4工具对比能立刻判断你的脚本实现是否有问题。具体做法是先用你的脚本跑test_case拿到密文然后去任何一个在线RC4解密网站输入相同的密钥和明文验证或者你直接拿着你脚本生成的密文再用同一个脚本解密一次如果输出能和原文对上就说明你的标准RC4实现是正确的。这个方法能帮你把“RC4写错了”和“Unicode解码出错了”两个问题彻底分开避免排查时互相干扰。5.4 BUUOJ平台交flag的格式与注意事项BUUOJ平台对flag的提交格式一般有明确要求通常是以flag{...}形式包裹的字符串。如果你解出来的内容带着两边的引号或者空格要先清理干净再提交。另外有的题目要求保留大括号内部的所有字符有些题目的flag里可能包含下划线、数字、特殊符号复制的时候要特别小心。还有一点如果你确信解密结果是正确的但平台却报错建议看下题目描述里是否对flag有额外处理比如要求MD5后再提交或者要求去掉首尾字符。这种附加要求虽然不多见但一旦遇到就很坑我本人就被这种题目坑过白白浪费了二十分钟在错误的提交格式上。6. 这道题背后更通用的编码陷阱认知6.1 CTF中Unicode陷阱的常规出现位置EasyProgram这道题所考的Unicode陷阱在CTF中其实是无处不在的它不仅仅出现在RC4题目里。在web方向的有SQL注入绕过中宽字节注入就是利用Unicode编码和数据库编码不一致来构造payload在逆向方向很多二进制程序内部使用UTF-16的字符串比较函数导致直接用ASCII码搜索字符串搜不到在misc方向有些隐藏信息就以UTF-16格式藏在文本文件里打开后全是NUL字符肉眼根本看不见。所以你在刷CTF的过程中反复看到Unicode相关的报错不要觉得烦这恰恰说明它是出题人喜欢埋坑的经典位置。掌握好这道题的解法对各种Unicode编码的字节规律熟悉起来之后遇到类似题目就能一眼看穿。6.2 字节序在解密中的决定性作用字节序这个词听起来很学术但本质上就是“谁先谁后”的问题。在大端序中一个多字节数值的最高有效位排在前面在小端序中最低有效位排在前面。Windows平台上的宽字符UTF-16默认是小端序所以字母A在内存里是0x41 0x00而不是0x00 0x41。结合这道题如果你把Unicode解码方向选错每个字符的高低字节都会互换最终呈现出的字符串会是一堆反向的字符看起来像是字母的顺序被神秘地打乱了。我见过有些人在解题群里求助截图显示解密结果里有大量不可见字符就是用反了字节序的典型表现。记住小端序0x41 0x00才是大多数CTF题目的默认设定。6.3 从这道题延伸到其他流密码的解题思路把RC4吃透之后你会发现流密码类题目的大致套路都相通。RC4、Chacha20、Salsa20本质上都是用密钥生成一个密钥流然后把密钥流和明文做异或。区别仅仅在于密钥流的生成算法不同。拿到这类题目时你的解题流程基本一致识别算法 - 获取密钥 - 用给定轮次生成密钥流 - 异或还原。这也提醒我们刷CTF题目不能只背代码模板真正要理解的是算法的输入输出关系和状态流转逻辑。理解了流密码“密钥流与明文异或”这个核心思想哪怕碰到魔改版的算法也能根据题目描述推测出正确方向。EasyProgram就是这样一个把基础概念组合起来考的好例子做完它会觉得RC4和Unicode这两个看似无关的坑在这道题里结合得恰到好处。我在实际做题过程中最大的体会是很多看似复杂的题目卡点往往不是算法本身而是一些细节层面的“想当然”。EasyProgram这道题尤其如此——如果我一拿到密文就直接当成ASCII解码估计也会被那些0x00和乱码绕得晕头转向。后来我养成了一个习惯凡是涉及编码的题目都先看一眼十六进制形态再决定用什么解码方式。这个习惯让我的解题效率提高了不少也希望分享出来对你有用。
返回列表