JS逆向实战:RSA加密定位、分析与Python复现全解析
1. 项目概述:为什么JS逆向绕不开RSA?
如果你正在学习或者已经接触过Web安全、爬虫或者前端安全审计,那么“JS逆向”这个词对你来说一定不陌生。而在这个领域里,RSA加密算法就像一座绕不开的大山。无论是登录密码的加密、关键API请求参数的签名,还是某些核心业务数据的传输,前端JavaScript代码中大量使用RSA来对抗自动化脚本和逆向分析。我见过太多新手朋友,面对一个经过RSA加密的请求参数,抓耳挠腮,不知从何下手。他们能定位到加密函数,但面对那一串串看似随机的长字符串(密文)和复杂的数学运算,往往感到无从下手。
简单来说,JS逆向中的RSA,核心目标不是去破解RSA算法本身(这在现有计算能力下几乎不可能),而是逆向出前端JavaScript是如何调用RSA的,从而在本地复现整个加密流程。这包括了找到加密所用的公钥(通常是模数N和指数e)、理解数据拼接和填充方式(如PKCS1_v1_5或OAEP)、以及最关键的一步——模拟JavaScript的加密逻辑,用Python、Java等后端语言重新实现。这个过程,更像是一场“侦探游戏”,你需要仔细阅读混淆或压缩过的JS代码,找到关键的密钥片段和函数调用链。
对于爬虫工程师,掌握RSA逆向意味着能突破更高级别的反爬机制;对于安全研究员,这是分析前端安全漏洞和加密实现弱点的基本功;对于前端开发者,理解这个过程能帮助你写出更安全的代码。接下来,我将以一个典型的实战场景为例,带你一步步拆解RSA逆向的全过程,分享我踩过的坑和总结出的有效技巧。
2. 核心思路拆解:定位、分析与复现
面对一个使用了RSA加密的网站或应用,我们的逆向工作可以系统性地分为三个核心阶段:定位加密点、分析加密逻辑、本地化复现。这三个阶段环环相扣,缺一不可。
2.1 第一阶段:精准定位加密发生地
一切始于一个被加密的参数。通常,你在浏览器的开发者工具(F12)的网络(Network)选项卡中,会发现某个重要的POST请求,其请求体(Payload)或请求头(Headers)里包含一长串看似无规律的Base64字符串或十六进制字符串。这就是我们的“猎物”。
第一步,使用“搜索大法”定位关键代码。
全局搜索:在开发者工具的源代码(Sources)面板,直接使用
Ctrl+Shift+F(Windows)或Cmd+Option+F(Mac)进行全局搜索。搜索的内容可以是:- 加密参数名:比如你发现了一个叫
encryptedData的参数,就直接搜它。 - 加密字符串的片段:复制密文开头和结尾的几个字符(避免因编码问题搜不到)进行搜索。
- 关键函数名:如
encrypt、RSA、public、setPublicKey、JSEncrypt(一个常用库)等。
- 加密参数名:比如你发现了一个叫
XHR/断点辅助:如果全局搜索效果不佳,可以在网络面板中找到那个发送加密请求的XHR/Fetch请求,右键选择“Reveal in Sources panel”或类似选项,这能直接带你到发起这个网络请求的JavaScript代码附近。然后,你可以在该行代码上打上“XHR/Fetch断点”,重新触发请求,代码执行就会在此处暂停,方便你一步步跟踪。
实操心得:很多网站的加密操作可能在Webpack打包的模块里。这时,找到的代码可能是一个数字标识符(如
(0, i.encrypt)(t))。不要慌,继续在附近搜索encrypt或查看该模块的导出定义,往往就能找到真正的函数实现位置。
2.2 第二阶段:深入分析加密逻辑与密钥
找到加密函数后,真正的挑战才开始。眼前的代码很可能是经过混淆的,变量名可能是a,b,c,函数名可能是_0x123abc。
1. 密钥(公钥)的寻找RSA加密需要公钥,公钥的核心是模数N和指数e。它们通常以以下几种形式存在:
- 明文硬编码:最理想的情况,在JS代码中直接能找到类似
setPublicKey("MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...")的调用,引号里的长字符串就是Base64编码的公钥(通常是PKCS#1或PKCS#8格式)。你可以直接复制出来。 - 分段变量拼接:
N和e可能被拆分成多个字符串或十六进制数,散落在代码不同地方,最后通过拼接、反转等操作组合起来。你需要仔细跟踪代码执行流,找到最终拼接成的字符串。 - 网络请求获取:公钥可能由前端在初始化时通过另一个API请求从服务器获取。你需要检查更早的网络请求,找到一个返回包含
publicKey或modulus、exponent字段的响应。
2. 加密库的识别前端常用的RSA库有JSEncrypt、node-rsa(浏览器版)、forge以及各大厂自研的加密模块。识别出使用的库有助于你理解其默认的填充模式和数据格式。
new JSEncrypt()是JSEncrypt库的典型用法。setPublicKey方法也很常见。- 观察加密函数的输入输出:输入是否是原始字符串?输出是否是Base64?这关系到填充方式。
3. 数据预处理分析在调用RSA加密前,原始数据(如密码、时间戳等)往往需要经过一系列处理:
- 拼接:
username + "|" + timestamp - 哈希:先对数据进行MD5或SHA256哈希。
- 编码转换:将字符串转为
ArrayBuffer或特定的字节序列。 - 填充(Padding):RSA加密本身要求输入数据长度必须小于密钥长度。因此需要对数据进行填充。常见的填充方案有PKCS#1 v1.5和OAEP。填充方案是复现时最容易出错的地方!必须通过代码逻辑或库的默认行为确定。
2.3 第三阶段:本地化复现加密流程
分析清楚后,目标是在Python(以pycryptodome或cryptography库为例)或你使用的其他语言中,完全复现前端的加密过程。
复现步骤:
- 还原公钥:将找到的模数
N和指数e,或者整个公钥PEM字符串,在Python中正确构造出公钥对象。 - 还原数据预处理:严格按照JS代码的逻辑,进行相同的拼接、哈希、编码等操作,得到待加密的原始字节数据。
- 应用相同的填充方案进行加密:使用与前端相同的填充模式(如PKCS1_v1_5)进行加密。
- 输出格式转换:将加密后的二进制密文,按照前端的方式(通常是Base64)进行编码,得到最终的加密字符串。
验证成功与否的唯一标准是:你用本地代码生成的加密结果,与浏览器发送的加密参数完全一致。
3. 实战拆解:一个模拟登录场景的RSA逆向
假设我们遇到一个登录页面,其密码字段被加密,表单数据如下:
{ "username": "testUser", "password": "oX4fL9k...(很长的一段Base64)" }我们的目标是逆向这个password的生成过程。
3.1 定位与初步分析
通过搜索password或加密后的片段,我们定位到一个主要的JavaScript文件login.xxxxxx.js。在其中搜索encrypt,找到如下关键代码段(经过一定美化):
function encryptPassword(pwd) { var rsa = new JSEncrypt(); rsa.setPublicKey('-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDlOJu6TyygqxfWT7eLtGDwajtN\nFOb9I5XRb6khyfD1Yt3YiCgQWMNW649887VGJiGr/L5i2XblLPMyNah+MBQYz5T4\nI/1n1zF8V6dN1kC4gLThX4+QJzVcM6eR5pV5t8vJc7P0Z2ZQIDAQAB\n-----END PUBLIC KEY-----'); var encrypted = rsa.encrypt(pwd); return encrypted; } // 调用处 var inputPwd = document.getElementById('pwd').value; var timestamp = Date.now(); var dataToEncrypt = inputPwd + '|' + timestamp; var finalEncryptedPwd = encryptPassword(dataToEncrypt);分析结果:
- 使用的库:
JSEncrypt。 - 公钥:直接硬编码在代码中,是标准的PEM格式公钥。
- 数据预处理:密码明文与当前时间戳用
|连接,形成待加密字符串。 - 加密与输出:调用
rsa.encrypt(),该库默认使用PKCS#1 v1.5填充,并输出Base64编码的字符串。
3.2 使用Python进行复现
现在,我们在Python中复现这一过程。我们将使用pycryptodome库。
from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_v1_5 import base64 import time # 1. 准备公钥 (直接从JS代码中复制) public_key_pem = """-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDlOJu6TyygqxfWT7eLtGDwajtN FOb9I5XRb6khyfD1Yt3YiCgQWMNW649887VGJiGr/L5i2XblLPMyNah+MBQYz5T4 I/1n1zF8V6dN1kC4gLThX4+QJzVcM6eR5pV5t8vJc7P0Z2ZQIDAQAB -----END PUBLIC KEY-----""" # 2. 加载公钥 key = RSA.import_key(public_key_pem) cipher = PKCS1_v1_5.new(key) # 使用与JSEncrypt默认相同的PKCS#1 v1.5填充 # 3. 模拟前端的数据预处理 password_plain = "MySecretPassword123" timestamp = int(time.time() * 1000) # JS的Date.now()是毫秒时间戳 data_to_encrypt = f"{password_plain}|{timestamp}".encode('utf-8') # 转为bytes # 4. 加密 # 注意:PKCS1_v1_5加密的输入数据长度不能超过 (密钥长度/8 - 11)字节。 # 本例密钥1024位(128字节),所以最大数据长度为 128 - 11 = 117字节。 encrypted_bytes = cipher.encrypt(data_to_encrypt) # 5. 输出Base64 final_encrypted_password = base64.b64encode(encrypted_bytes).decode('utf-8') print(f"加密后的密码: {final_encrypted_password}")运行这段代码,将password_plain替换为你想测试的密码,生成的final_encrypted_password应该与浏览器发送的加密密码(在相同时间戳下)完全一致。
3.3 处理更复杂的情况:分段密钥与自定义填充
并非所有网站都如此友好。更常见的情况是这样的:
var n = "a5c7d8...(很长十六进制)"; var e = "10001"; // 十六进制,对应十进制65537,这是最常用的公钥指数 function customEncrypt(t) { // ... 一系列复杂的位运算和拼接,最终调用了某个内部RSA函数 return rsaFunc(t, n, e); }应对策略:
- 构造公钥:你需要将十六进制的模数
n和指数e转换成Python的整数,然后构造RSA公钥。from Crypto.PublicKey import RSA import base64 n_hex = "a5c7d8..." e_hex = "10001" n_int = int(n_hex, 16) e_int = int(e_hex, 16) # 构造RSA key对象 key = RSA.construct((n_int, e_int)) # 后续加密步骤同上 - 确定填充:如果代码中没有明确显示填充方式,你需要:
- 查阅库文档:如果识别出是特定库(如
forge),查看其默认填充。 - 逆向填充函数:跟踪
rsaFunc内部或之前的代码,看是否有对输入数据添加特定前缀(如\x00\x02...)的步骤,这是PKCS#1 v1.5填充的特征。 - 经验猜测与测试:最常用的填充是PKCS#1 v1.5。可以先尝试用它复现,如果结果对不上,再尝试其他填充或无填充(极少数情况)。
- 查阅库文档:如果识别出是特定库(如
4. 核心工具链与调试技巧
工欲善其事,必先利其器。一套高效的JS逆向工具链能极大提升效率。
4.1 浏览器开发者工具进阶用法
- 条件断点(Conditional Breakpoint):当加密函数被频繁调用(如按键事件),你可以在该行代码的断点上右键,添加条件,例如
t.indexOf('password') > -1,这样只有当加密的数据包含“password”时才会暂停,避免无效中断。 - 调用栈(Call Stack):在断点暂停时,查看调用栈面板,可以清晰地看到函数是如何被一层层调用的,帮助你理解加密触发的完整路径。
- 作用域(Scope):在断点暂停时,查看局部作用域和闭包作用域,可以直接看到当前函数内所有变量的值,这是获取密钥、中间计算结果的黄金时刻。
- Overrides(本地替换):在Sources面板的Overrides标签下,你可以选择一个本地文件夹,然后将线上JS文件保存并映射到本地。之后你可以在本地修改这个JS文件(例如,添加一些
console.log来打印关键变量),刷新页面后浏览器会加载你修改过的本地版本。这是动态分析和修改代码逻辑的神器。
4.2 辅助工具推荐
- CryptoJS识别与模拟:很多网站使用CryptoJS库进行哈希(MD5, SHA256)和AES加密。如果你在代码中看到
CryptoJS.MD5(...)或CryptoJS.AES.encrypt(...),就需要在Python中对应使用hashlib或pycryptodome库来模拟。注意CryptoJS的AES加密默认输出可能是一个包含盐、密文等信息的特殊对象,需要解析其toString()或ciphertext属性。 - Python
execjs库:当JS加密逻辑极其复杂,涉及大量浏览器环境特有的对象或难以翻译的位操作时,可以考虑使用execjs库直接在Python中执行JavaScript代码片段。这相当于一个“降维打击”,但会牺牲一些性能和可移植性。import execjs with open('encrypt.js', 'r', encoding='utf-8') as f: js_code = f.read() ctx = execjs.compile(js_code) result = ctx.call('encryptPassword', 'myPassword', '1234567890') print(result) - AST(抽象语法树)解析工具:对于高度混淆的代码,可以借助
esprima、babel等工具将JS代码解析成AST,然后编写脚本进行反混淆,比如还原变量名、控制流平坦化等。这是高阶逆向技能,入门阶段可以先了解。
5. 常见问题排查与避坑指南
在实际操作中,你一定会遇到各种问题导致本地加密结果与前端不一致。下面是一个常见问题排查清单:
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| 加密结果长度不一致 | 1. 编码方式不同。 2. 填充模式不同。 3. 公钥不一致。 | 1. 确认JS和Python端对明文、密文的编码(UTF-8, ASCII, Hex, Base64)每一步都一致。 2. 核对填充方案。JSEncrypt默认PKCS#1 v1.5,Python的 PKCS1_v1_5需对应。3. 检查公钥字符串或(N, e)值是否完全一致,注意换行符、头尾标记。 |
| 加密结果完全不同 | 1. 待加密数据源不同。 2. 使用了随机盐或IV(多见于混合加密)。 3. 密钥是动态的。 | 1. 在JS加密函数入口打断点,精确捕获传入的原始字符串,与Python准备的字符串进行逐字符比对(包括不可见字符)。 2. 检查加密前是否有生成随机数并参与运算。如果是,需要将随机数也一并捕获并在Python中固定使用。 3. 确认公钥是否是每次页面加载或请求时动态从服务器获取的。 |
Python报错:ValueError: Plaintext is too long. | 待加密数据长度超过了所选填充模式下的最大允许长度。 | 对于1024位密钥的PKCS#1 v1.5,最大明文长度为117字节。检查你的明文数据(转为bytes后)长度。如果超长,前端可能采用了分段加密或先哈希再加密的策略。 |
| 能加密但服务器不认可 | 1. 时间戳等动态参数未同步。 2. 请求头(如User-Agent, Referer)缺失或不对,触发了风控。 3. 加密逻辑有细微差别(如字节序)。 | 1. 确保时间戳等动态值与浏览器请求时完全一致。可以尝试硬编码一个成功请求的时间戳来测试。 2. 在Python请求中,尽量完整模拟浏览器的请求头。 3. 使用最笨但最有效的方法:在JS加密函数的每一步都打印出中间变量的值(字符串、Hex、Base64),然后在Python中完全复现每一步,进行比对。 |
遇到ReferenceError: window/ document is not defined(在execjs中) | JS代码运行在Node.js环境(execjs),但代码中使用了浏览器特有的BOM对象。 | 1. 在execjs执行前,在JS代码全局注入模拟对象,如window = {}; document = {};。2. 更彻底的方法是,分析代码到底用这些对象做了什么(可能是获取cookie或userAgent),然后在Python中直接提供这些值。 |
核心避坑技巧:“二分法”调试。不要试图一次性复现整个复杂流程。将过程拆解:先确保从相同输入(如固定字符串“test”)到相同输出。如果不对,就在JS代码中,在加密函数调用前一刻,把输入数据打印出来,在Python中用完全相同的数据加密。还不对,就打印出JS中加载的公钥的N和e,与Python中加载的进行比对。还不对,就单独测试填充函数。通过这种逐步缩小范围的方法,总能定位到差异点。
6. 从RSA到更广阔的前端加密逆向
掌握了RSA的逆向,你就拿到了打开前端加密世界大门的钥匙。许多更复杂的加密方案都是基于类似原理的叠加:
- 对称加密(AES/DES):关键在于找到密钥(Key)和初始向量(IV)。它们可能硬编码、由RSA加密传输、或通过特定算法动态生成。逆向思路同样是定位密钥生成或获取逻辑。
- 哈希与加盐(MD5, SHA, HMAC):重点是找到盐(Salt)值和哈希的轮次。盐可能固定、与用户相关或来自服务器。
- 混合加密:最常见的是“RSA加密AES密钥,AES加密业务数据”。你需要先逆向RSA部分拿到AES密钥,再用该密钥解密数据。
- 代码混淆与反调试:网站会使用
obfuscator等工具混淆代码,增加阅读难度;还会设置反调试(如检测开发者工具打开、无限debugger循环)。对付混淆,需要耐心和AST工具;对付反调试,可以禁用断点、使用override功能替换检测代码。
逆向的本质是理解。理解开发者的保护思路,理解加密库的运作方式,理解数据在网络中的生命轨迹。这个过程没有一成不变的公式,每一个网站都可能是一个新的谜题。但只要你掌握了本文所述的核心方法论——定位、分析、复现,并辅以耐心和细致的调试,绝大多数前端加密的壁垒都将被你逐一攻克。记住,每一次成功的逆向,不仅是获得了一段可用的代码,更是对你逻辑思维和解决问题能力的一次锤炼。
