欢迎光临
我们一直在努力

两层 AES 加一次 RSA:破了网易云之后,我才发现难的不是破

两层 AES 加一次 RSA:破了网易云之后,我才发现难的不是破

先划清界限:这篇不是"网易云逆向教程第二季"。
闲鱼那篇讲的是"反爬长什么样",这篇讲的是**“你怎么知道自己写对了”**——
破译过程只占开头几百字,剩下的全是验证。

前一篇写闲鱼的时候我说过一句话:最常见的反爬,本质上就是一个 MD5。写完那篇我就有点不服气——不可能都是 MD5 吧。

于是我挑了一个更硬的:网易云音乐的 weapi。它的请求体里没有明文,只有两个字段:params 和 encSecKey。你在 DevTools 里点开任何一个搜索请求,看到的都是两串乱码。

这篇文章就是把它拆开的过程。结论先放这儿:params 是加密了两次的 AES,encSecKey 是用 RSA 把第二次的密钥封装起来。听起来吓人,但每一层都只有十几行代码。

先看整体:三层结构

JS 源码里那个函数叫 asrsea,去掉混淆之后逻辑其实只有三行:

i = 随机16位密钥(a-zA-Z0-9)
encText = AES_encrypt(明文, "0CoJUm6Qyw8W8jud") # 第一层:固定 key
encText = AES_encrypt(encText, i) # 第二层:随机 key
encSecKey = RSA_encrypt(i) # 把随机 key 用 RSA 封起来

为什么要加密两次? 因为第一层的 key 是写死在 JS 里的,全世界都知道。所以真正起保护作用的是第二层那个随机的 i。而服务端要能解开第二层,就必须知道 i——于是 i 被 RSA 加密成 encSecKey 一起发过去。

这是一个完整的"密钥交换",不是什么高深算法。 理解了这句话,剩下的都是细节。

完整代码

# -*- coding: utf-8 -*-
import base64
import json
import random
import string

from Crypto.Cipher import AES # pip install pycryptodome

# 固定参数(页面 JS 里用 emoji 混淆存的,解码后就是这三个常量)
MODULUS = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b7\\
25152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda9\\
2557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e\\
82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"
PUBKEY = "010001"
NONCE = b"0CoJUm6Qyw8W8jud" # 第一层 AES 固定 key
IV = b"0102030405060708" # AES IV
CHUNK_SIZE = 126 # JS RSAKeyPair.chunkSize = 2*biHighIndex(m)

_CHARS = string.ascii_letters + string.digits

def create_random_key(length=16):
return "".join(random.choice(_CHARS) for _ in range(length))

def _pkcs7_pad(data: bytes) –> bytes:
pad = 16 – len(data) % 16
return data + bytes([pad]) * pad

def aes_encrypt(text, key) –> str:
key_b = key if isinstance(key, bytes) else key.encode("utf-8")
data = text if isinstance(text, bytes) else text.encode("utf-8")
cipher = AES.new(key_b, AES.MODE_CBC, IV)
return base64.b64encode(cipher.encrypt(_pkcs7_pad(data))).decode("utf-8")

def rsa_encrypt(text) –> str:
bs = list(text.encode("utf-8"))
while len(bs) % CHUNK_SIZE != 0:
bs.append(0)
value = 0
for i, b in enumerate(bs):
value |= b << (8 * i)
n = int(MODULUS, 16)
e = int(PUBKEY, 16)
return format(pow(value, e, n), "x")

def weapi(data: dict) –> dict:
text = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
random_key = create_random_key(16)
params = aes_encrypt(text, NONCE)
params = aes_encrypt(params, random_key)
enc_sec_key = rsa_encrypt(random_key)
return {"params": params, "encSecKey": enc_sec_key}

不到 60 行。下面逐块讲。

第一层 AES:参数对上了就完事

AES.new(key_b, AES.MODE_CBC, IV)

三件事必须全对,错一个就是乱码:

  • 模式是 CBC,不是 ECB。ECB 不需要 IV,CBC 必须给。
  • IV 是 0102030405060708,八个字节,不是十六个字符的字符串。
  • 填充是 PKCS7,而且要自己补——Crypto 不像 Java 会自动处理。

第一层用的是固定 key 0CoJUm6Qyw8W8jud。你在 JS 里搜这个字符串,基本就能定位到整个加密逻辑,因为它是全大写的驼峰命名,在压缩过的代码里非常显眼。

第二层 AES:把上一层的输出当明文再加密一次

params = aes_encrypt(text, NONCE) # 第一层
params = aes_encrypt(params, random_key) # 第二层

注意第二层的输入不是原始明文,是第一层输出的 base64 字符串。所以它是一个 str,又被 encode('utf-8') 了一次。这个细节很容易踩坑——直接拿 bytes 往里套,长度会差一位,密文全错。

RSA 这一段,是被逼出来的

理论上 RSA 加密用 cryptography 库三行就写完了。但这里不能用它。

网易云用的是 JS 版的 RSAKey,它有几个地方和标准 PKCS#1 不一样:

  • 没有 OAEP / PKCS#1 v1.5 填充。它就是把明文字节补零到 126 字节。
  • 字节序是小端。标准实现按大端读整数,这里是从低位往高位拼。
  • 明文长度固定 126 字节,来自 chunkSize = 2 * biHighIndex(m),也就是模长的一半。

所以只能自己动手:

bs = list(text.encode("utf-8"))
while len(bs) % CHUNK_SIZE != 0: # 补零到 126 的整数倍
bs.append(0)
value = 0
for i, b in enumerate(bs):
value |= b << (8 * i) # 小端:第 0 个字节在最低位
n = int(MODULUS, 16)
e = int(PUBKEY, 16)
return format(pow(value, e, n), "x")

pow(value, e, n) 就是整个 RSA。 没有魔法,就是模幂运算。Python 的大整数支持让它变成了一行。

最后一个坑:format(…, "x") 出来的 hex 不带前导零。JS 那边用字符串补齐到 256 位,所以如果你的结果长度不够,前面要补 0。我第一版没补,接口一直返回"参数错误",查了两个小时。

怎么确认自己写对了

这是我觉得逆向里最值得分享的一步:不要用"接口能返回数据"当验证标准。

因为接口能返回数据,可能只是你恰好触发了某种降级逻辑,或者服务端对错误参数宽容。真正的验证是逐字节比对:

plain = '{"s":"林俊杰","limit":"8","csrf_token":"01562503b22650749c554440c4a3a38d"}'
assert aes_encrypt(plain, NONCE) == "1GPYHo/IkS9hDiM0Dj6g7yYRy2s46MsRe/YPaVJnnCHH4i/sGJPZ1Vc6TbOGW8u4LzsCgZkC3h74qNiV/2NIyLoatqKqCLKPOFn0fTI74tk="
assert rsa_encrypt("AbCd1234EfGh5678") == "d446acb2826cbd3e9b453a2e992cc4c00eb97eba00c8841f288e92c8a2a610a4c5c543caf87b09503be5919ccca9b22cb3adfe8d21d49f3659e61abdc9a26c1afb45c011679dc168b52597250904effa1e11c08d1a478c750ed6d3715677b3d389fdf84b7e442e89c09905f92a703bc46c68e8e18091bc01662abe125f5497c6"

这两个期望值怎么来的?在浏览器里断点,用页面自己的 CryptoJS 和 encryptedString() 跑同样的输入,把输出抄下来。

有了这两条 assert,我改任何一个参数(IV、填充方式、字节序),测试立刻红。逆向最难的不是写出来,是知道自己写错了。

这套结构在哪都能见到

params + encSecKey 这个组合不是网易独有的。它的本质是**「对称加密传数据 + 非对称加密传密钥」**,也就是 HTTPS 自己在做的事,只不过被搬到了应用层。

见到类似结构,可以按这个顺序猜:

  • 请求体里有一个特别长的 base64 → 大概率是对称加密后的业务数据
  • 旁边还有一个定长的 hex(256 或 512 位)→ 大概率是被 RSA 封起来的密钥
  • 去 JS 里搜 CryptoJS.AES、JSEncrypt、RSAKey、getPublicKey
  • 字段名会变,骨架不会变。

    写在最后

    这一篇我最想说的不是"网易云怎么破",而是验证方式。

    我见过太多人(包括我自己)写完加密函数,看到接口返回 200 就以为成了。可万一你只是碰巧命中了服务端的容错分支呢?下次换个接口就崩。

    所以我现在给自己定了条规矩:凡是"我算出来的值要和别人算出来的一样"的场景,一律写 assert,不写"看起来对"。

    下一步我打算把这份实现包成一个可复用模块,把 MODULUS / NONCE / IV 抽成配置,这样换一个站点只要换三个常量。做完再回来更新这篇。

    免责声明(请务必读完)

    本文的代码与思路仅用于技术学习和安全研究,请务必守住以下边界:

    • 不要用于批量抓取、绕过付费或商业分发。 平台的服务条款明确禁止未授权的大规模数据获取。技术上能做到,不代表你有权做。
    • 不要破解他人账号。 本文涉及的是公开接口的前端加密参数,与账号安全无关。任何尝试登录他人账号、窃取数据的行为,都不在"技术学习"的范围内,且涉嫌违反《网络安全法》《刑法》第 285 条。
    • 不要把这类代码放进生产系统。 加密算法随时会换,你的实现一夜间失效;而且这类行为在商业场景下法律风险极高。
    • 本文代码可能随时过时。 网易云改过好几次 weapi 的实现,本文记录的是我验证时的版本。以你手上的实际源码为准。
    • 一切后果由使用者自行承担。 因运行本文代码导致的账号封禁、数据损失、法律纠纷,均与本文无关。

    加密保护的从来不是算法,是边界。 我把边界写在文章最后,是因为它是这篇文章里最重要的部分。

    赞(0)
    未经允许不得转载:171主机测评 » 两层 AES 加一次 RSA:破了网易云之后,我才发现难的不是破
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址