1. 逆向场景里为什么总绕不开 RC4
做逆向分析的人,迟早会在某个加密函数里撞见 RC4。它不像 AES 那样有一眼可辨的 S 盒初始化常量,也不像 DES 那样有固定的置换表,代码往往短得让人怀疑"这真的是加密吗"。但正是这种"其貌不扬",让 RC4 成了大量 App、小程序、H5 页面里最常被随手塞进去的对称加密算法之一。
RC4 全称 Rivest Cipher 4,是 1987 年由 Ron Rivest 设计的一种流密码。它的核心只有两个动作:用密钥打乱一个 256 字节的状态数组(KSA,Key Scheduling Algorithm),然后从这个数组里不断"抽牌"生成密钥流,与明文逐字节异或(PRGA,Pseudo-Random Generation Algorithm)。整个算法不到二十行代码就能写完,加解密用的是同一套逻辑,这也是它在逆向场景里高频出现的根本原因——开发者图省事,逆向者就得反复和它打交道。
这篇文章面向的是正在做逆向分析、需要识别和还原 RC4 加密逻辑的人。不管你是刚接触 JS 逆向、安卓逆向,还是已经在处理 App 协议、H5 游戏、注册登录参数这类具体场景,只要你能读懂基本的循环和数组操作,就能跟着把 RC4 从识别到还原走一遍。我会把 KSA 和 PRGA 的每一步拆开讲清楚,给出可直接复现的 C 语言实现和 Python 还原脚本,再补上我在实际项目里踩过的坑和排查思路。目标很明确:看完之后,你遇到一个疑似 RC4 的函数,能自己判断它是不是、能自己把它还原出来。
2. RC4 的算法骨架与识别特征
2.1 两个循环就能认出来的 KSA
RC4 的密钥调度部分,也就是 KSA,是整个算法里最有辨识度的片段。它的标准写法是这样的:先初始化一个长度 256 的数组 S,令 S[i] = i;然后用密钥对 S 做一次打乱。打乱的过程用一个循环完成,循环变量 i 从 0 到 255,每次把 S[i] 和 S[j] 交换,其中 j 的更新依赖密钥字节和当前的 S 值。
for (i = 0; i < 256; i++) { S[i] = i; } j = 0; for (i = 0; i < 256; i++) { j = (j + S[i] + key[i % keylen]) % 256; swap(S[i], S[j]); }这段代码在反编译结果里几乎是一眼可辨的:一个 256 次的初始化循环,紧接着一个 256 次的交换循环,交换的下标计算里出现了% 256和密钥长度取模。哪怕变量名被混淆成a、b、c,这个"256 + 256 + 取模"的结构也很难藏住。我在实际逆向中,只要看到连续两个 256 次循环,第二个循环里还有数组元素互换,第一反应就是 RC4。
2.2 PRGA:逐字节异或的密钥流生成
KSA 之后是 PRGA,也就是伪随机生成算法。它维护两个指针 i 和 j,每次递增 i,用 S[i] 更新 j,交换 S[i] 和 S[j],然后取 S[(S[i] + S[j]) % 256] 作为密钥流字节,与明文异或得到密文。
i = 0; j = 0; for (k = 0; k < datalen; k++) { i = (i + 1) % 256; j = (j + S[i]) % 256; swap(S[i], S[j]); keystream = S[(S[i] + S[j]) % 256]; output[k] = input[k] ^ keystream; }PRGA 的特征同样明显:一个和明文等长的循环,循环里做两次取模、一次交换、一次异或。异或操作在反编译里通常表现为^或者xor指令,配合前面的 KSA,基本可以锁定。
2.3 为什么逆向者要专门记住这些特征
原因很实际。逆向的时间成本主要花在"识别"上,而不是"还原"上。一旦你确认某段逻辑是 RC4,剩下的工作就是找到密钥、找到明文输入点、把算法用脚本重写一遍,这些都是机械劳动。但如果识别不出来,你可能要花几个小时去猜一个自定义的异或逻辑,最后发现它其实就是 RC4 换了个马甲。
我把 RC4 的识别特征整理成一张表,方便你在反编译工具里快速对照:
| 特征位置 | 典型表现 | 说明 |
|---|---|---|
| 初始化 | 256 次循环,S[i] = i | 状态数组初始化 |
| 密钥调度 | 256 次循环,含% 256和% keylen | KSA 打乱 |
| 交换操作 | 数组两元素互换 | KSA 和 PRGA 都有 |
| 密钥流 | 循环内两次取模后取值 | PRGA 生成 |
| 输出 | 与输入逐字节异或 | 加解密同一逻辑 |
注意:有些实现会把 256 写成
0x100,或者把取模换成& 0xFF。这两种写法等价,识别时不要被迷惑。
3. 从零手写一遍 RC4:C 语言实现与参数细节
3.1 完整可编译的 C 实现
要真正理解 RC4,最好的办法是自己写一遍。下面这份 C 语言实现是我在分析时常用的参考版本,逻辑清晰,可以直接编译运行。
#include <stdio.h> #include <string.h> void rc4_crypt(unsigned char *key, int keylen, unsigned char *data, int datalen) { unsigned char S[256]; int i, j, k; unsigned char tmp; for (i = 0; i < 256; i++) { S[i] = (unsigned char)i; } j = 0; for (i = 0; i < 256; i++) { j = (j + S[i] + key[i % keylen]) % 256; tmp = S[i]; S[i] = S[j]; S[j] = tmp; } i = 0; j = 0; for (k = 0; k < datalen; k++) { i = (i + 1) % 256; j = (j + S[i]) % 256; tmp = S[i]; S[i] = S[j]; S[j] = tmp; data[k] ^= S[(S[i] + S[j]) % 256]; } } int main() { unsigned char key[] = "secretkey"; unsigned char data[] = "hello rc4"; int len = strlen((char *)data); rc4_crypt(key, strlen((char *)key), data, len); printf("cipher: "); for (int i = 0; i < len; i++) { printf("%02x", data[i]); } printf("\n"); rc4_crypt(key, strlen((char *)key), data, len); printf("plain: %s\n", data); return 0; }这份代码里,rc4_crypt同时承担加密和解密,因为异或的对称性,对密文再跑一遍同样的流程就能还原明文。这一点在逆向里非常关键:你不需要分别找加密和解密函数,找到其中一个就够了。
3.2 密钥长度和数据类型的影响
密钥长度对 RC4 的安全性有影响,但对算法结构没有影响。KSA 里用的是key[i % keylen],所以密钥多长都能跑。实际逆向中,密钥可能是硬编码的字符串,也可能是从某个参数派生出来的,比如时间戳、设备 ID、用户 token 的哈希值。
数据类型方面,要注意字节序和无符号问题。C 语言里char可能是有符号的,做加法时如果值是负数,取模结果会出错。所以我在实现里统一用unsigned char。在 JS 逆向里,这个问题表现为charCodeAt返回的是 0 到 65535,需要& 0xFF截断到字节。在 Python 里,bytes对象取出来就是 0 到 255 的整数,相对省心。
3.3 一个容易忽略的细节:密钥流不能重复使用
RC4 是流密码,同一个密钥生成的密钥流是固定的。如果开发者用同一个密钥加密多条消息,密钥流就会被复用,两条密文异或就能消掉密钥流,直接得到两条明文的异或结果。这是 RC4 的经典弱点,也是逆向时可以利用的突破口。
我在分析某个 H5 游戏协议时,就遇到过这种情况:登录请求和某个数据请求用了同一个硬编码密钥,我把两个密文异或之后,发现里面出现了可读的 ASCII 片段,顺着这个线索很快就定位到了密钥。所以逆向时如果怀疑是 RC4,不妨先看看有没有多条密文可以对比。
4. 逆向实战:定位密钥与还原加密逻辑
4.1 在 JS 逆向里找 RC4
JS 逆向是 RC4 出现频率最高的场景之一。很多网站的登录参数、请求签名,都是用一段混淆过的 JS 做 RC4 加密。定位方法通常是先抓包,找到加密参数,然后在 Sources 面板里搜索参数名,或者用 XHR 断点回溯调用栈。
找到疑似函数后,看它是不是 RC4,就对照前面说的特征。JS 里常见的写法是这样:
function rc4(key, data) { var S = [], j = 0, x, res = ''; for (var i = 0; i < 256; i++) { S[i] = i; } for (i = 0; i < 256; i++) { j = (j + S[i] + key.charCodeAt(i % key.length)) % 256; x = S[i]; S[i] = S[j]; S[j] = x; } i = 0; j = 0; for (var k = 0; k < data.length; k++) { i = (i + 1) % 256; j = (j + S[i]) % 256; x = S[i]; S[i] = S[j]; S[j] = x; res += String.fromCharCode(data.charCodeAt(k) ^ S[(S[i] + S[j]) % 256]); } return res; }这段代码和 C 版本几乎一一对应。逆向时,你只需要把key和data的来源搞清楚,就能在本地用 Python 或 Node 复现整个加密过程。
4.2 密钥从哪里来
密钥是 RC4 逆向的核心。常见来源有几类:
- 硬编码字符串:直接在代码里写死,比如
"a1b2c3d4"。这种最好办,搜字符串就能找到。 - 参数拼接:把多个字段拼起来再做一次哈希或截取,比如
md5(uid + timestamp).substring(0, 16)。 - 动态生成:从服务端下发,或者从本地存储读取。这种需要动态调试,在密钥生成的地方下断点。
我在处理一个 App 协议时,密钥是md5(deviceId + "salt")的前 16 位。定位方法是先在反编译代码里搜md5,找到调用点,再看它的输入参数,顺着往上追就找到了deviceId和盐值。这个过程没有捷径,就是耐心跟调用链。
4.3 用 Python 还原并验证
找到密钥和算法后,用 Python 写一个还原脚本是最稳妥的验证方式。下面是我常用的模板:
def rc4(key: bytes, data: bytes) -> bytes: S = list(range(256)) j = 0 for i in range(256): j = (j + S[i] + key[i % len(key)]) % 256 S[i], S[j] = S[j], S[i] i = 0 j = 0 out = bytearray() for byte in data: i = (i + 1) % 256 j = (j + S[i]) % 256 S[i], S[j] = S[j], S[i] out.append(byte ^ S[(S[i] + S[j]) % 256]) return bytes(out) key = b"secretkey" cipher = bytes.fromhex("a1b2c3d4") print(rc4(key, cipher))验证的标准很简单:用脚本对抓到的密文解密,看结果是不是可读的明文或者结构化的 JSON。如果是,说明密钥和算法都对上了。如果不对,优先检查密钥是不是少了某个前缀后缀,或者数据类型有没有做 Base64、Hex 的转换。
提示:很多实现会在 RC4 之后再做一次 Base64 编码,所以抓到的密文可能是 Base64 字符串。解密前先 Base64 解码,再跑 RC4。
5. 常见问题与排查技巧实录
5.1 解密出来是乱码怎么办
乱码是 RC4 还原里最常见的问题,原因通常有三类。第一,密钥不对,可能是拼接顺序错了,或者漏了某个字段。第二,密文没有做正确的编码转换,比如该 Base64 解码的没解,或者 Hex 字符串没转成字节。第三,算法实现有细微差异,比如取模写成了& 0xFF但中间某步用了有符号运算。
排查顺序我一般是这样:先确认密文的编码格式,用base64 -d或者bytes.fromhex试一遍;再确认密钥,把可能的拼接组合都试一下;最后对照算法,逐行比对反编译代码和自己的实现。
5.2 密钥是动态的怎么跟
动态密钥的排查要靠断点。在 JS 里,可以在密钥生成函数的返回处下断点,看返回值是什么。在安卓逆向里,可以用 Frida hook 相关方法,打印参数和返回值。关键是找到密钥生成的"源头",而不是在加密函数里死磕。
我遇到过一个案例,密钥是每次请求随机生成的,但生成逻辑是Math.random()加上时间戳。这种情况下,与其还原随机数生成器,不如直接在加密函数入口 hook,把密钥和明文都打印出来,这样最省事。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 解密结果是乱码 | 密钥错误 | 检查拼接顺序、盐值、截取长度 |
| 解密结果是乱码 | 编码未转换 | 确认 Base64/Hex 解码 |
| 解密结果部分可读 | 密钥流偏移 | 检查是否有丢弃前 N 字节 |
| 找不到加密函数 | 代码混淆 | 用断点回溯调用栈 |
| 密钥每次不同 | 动态生成 | Hook 密钥生成函数 |
5.4 几个我踩过的坑
第一个坑是"丢弃前 N 字节"。标准 RC4 从密钥流第一个字节开始用,但有些实现会先丢弃前 256 或 768 字节,据说是为了规避某些统计攻击。如果你按标准 RC4 解不出来,试试在 PRGA 里先空跑几百次再开始异或。
第二个坑是密钥的字符编码。JS 里字符串是 UTF-16,charCodeAt返回的可能超过 255,需要& 0xFF。如果密钥里有中文,这个细节会直接导致解密失败。
第三个坑是大小写。Hex 字符串转字节时,有些库对大小写敏感,bytes.fromhex在 Python 3.7 之前不接受大写。虽然现在问题不大,但养成统一转小写的习惯没坏处。
6. 把 RC4 放进更大的逆向工作流里
RC4 只是逆向中众多加密算法的一种,但它的识别和还原方法具有代表性:先看结构特征,再找密钥来源,最后用脚本验证。这套流程换成 AES、DES、TEA 也一样适用,区别只在于特征不同。
我在实际项目里,会把常见的加密算法特征整理成一个速查笔记,RC4 的部分就是前面那张表。遇到新的加密逻辑,先对照笔记,能匹配上就直接套模板,匹配不上再仔细分析。这样能把大量重复劳动压缩掉,把时间留给真正复杂的业务逻辑。
另外,RC4 的变种也值得留意。有些实现会把 KSA 和 PRGA 合并,或者把状态数组换成 128 长度,这些改动不影响整体识别思路,但会影响还原时的细节。遇到解不出来的情况,不要急着否定"这是 RC4",先看看是不是变种。
最后分享一个实用习惯:每次还原出一个加密算法,都把密钥来源、算法实现、验证脚本整理成一个小文档存起来。下次遇到同一个 App 或同一个网站的其他接口,很可能用的是同一套加密逻辑,直接复用能省下大量时间。这个习惯我坚持了几年,积累下来的笔记已经成了我逆向时最常用的参考资料。