拿到一道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的代码量很小,一个完整的实现只需要两个主要过程:KSA(Key Scheduling Algorithm,密钥调度算法)和PRGA(Pseudo-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",但在某个脚本里你把它读成了b"hello",本质上是一样的;但如果题面里强调密钥是按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 0x00(UTF-16 LE),'l'对应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 = b"example_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是'f',6C是'l',61是'a',67是'g',7B是'{',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: can't concat str to bytes或者类似的消息。这类问题的根源,往往是你在代码里把字符串类型和字节串类型混用了。比如你在KSA里写j = (j + S[i] + key[i % key_len]),如果key是str而不是bytes,key[i % key_len]取出来的是一个字符串字符,而不是整数,这时候加上去就会报错。
修复方法很简单,就是确保key一开始就是bytes类型。如果你从函数参数里接收key,建议第一行做一次强制转换:if isinstance(key, str): key = key.encode()。这样即使后面传入的是字符串也不会炸。
另一个容易遇到的问题,是UTF-16 LE解码时出现“奇数长度”报错。UTF-16编码要求字节流的长度必须是偶数,如果你解出来的数据长度是奇数,说明前面RC4解密环节可能多了一个字节或者少了一个字节。这时候回顾一下密文长度和解密后的长度是否一一对应,确认一下过程中是否有多余的字节混入。
5.3 用对比法快速验证RC4脚本是否正确
这个方法我强烈推荐。在正式解目标密文之前,先用一段已知的明文和密钥测试你的脚本。比如我经常用的测试用例是key=b"Key",明文b"Plaintext"。正常情况下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和乱码绕得晕头转向。后来我养成了一个习惯:凡是涉及编码的题目,都先看一眼十六进制形态,再决定用什么解码方式。这个习惯让我的解题效率提高了不少,也希望分享出来对你有用。