☰
JS逆向实战:从定位断点到还原RSA前端加密全流程
2026/10/21 18:39:52 网站建设 项目流程

很多做Web安全测试或者接口分析的朋友,都听过"JS逆向"这个词。刚接触那会儿,我一度以为是什么玄乎的黑客技术,直到自己完整跑通一整套流程后,才明白它的核心其实很朴素:从浏览器前端运行的JavaScript代码里,还原出数据加密的逻辑,然后在你自己的脚本里复现一遍。而RSA,就是前段加密里最常见的那个"锁"。

这玩意儿能干什么?举个最简单的场景:某些网站登录时,你明明输的是明文密码,但按F12一看网络请求,发给服务器的却是一串乱码,这就是JavaScript在提交前,用RSA公钥把你密码加密了。如果你是做自动化操作或者合规的安全检测,就必须搞清楚这串乱码是怎么算出来的,不然模拟登录这一步就永远卡住。

适合谁来读?如果你刚接触Web安全、想做接口自动化、或者纯粹想搞明白"网页加密封装到底是怎么个事",这篇应该能帮到你。我会用一次实际的模拟项目拆解流程,把JS逆向里的RSA从定位、断点到算法还原,完整走一遍。整个过程不涉及什么越权的操作,全部基于你自己搭建的测试站点,合法也安全。

1. 先搞明白:JS逆向和RSA到底在解决什么问题

1.1 为什么网页前端要加"锁"

先说一个反直觉的事实:前端JavaScript代码是对任何人可见的,所以它里面写的任何加密逻辑,理论上都挡不住一个铁了心要分析的人。那网站为什么还要在前端费劲做RSA加密?

目的不是为了"绝对保密"——因为前端代码你能看到,公钥也能看到。目的主要有三个:

  • 防止明文传输被截获后直接复用:如果你在网络层拦截到明文密码,那不需要破解任何算法,直接拿去重放就完事了。RSA加密后,即使拦到密文,你也没法解密回明文(因为私钥在服务器)。
  • 避免用户密码因明文落库/留痕造成连带风险:很多链路日志会记录请求数据,如果密码明文存在日志里,一旦日志泄露,用户的密码就全网裸奔。
  • 提高自动化滥用门槛:虽然逆向能搞定,但毕竟需要专门分析,多一层防护多一层成本。

打个比方:前端加RSA,就像你家大门上了个锁。锁的图纸就贴在门旁边——但你要打开它,还得先看图纸、配个钥匙胚、再用工具慢慢打磨,而不是一脚踹开就行。高素质的访客看一眼图纸就走了,但你不能指望靠一张图纸防住所有人。

1.2 RSA加密的三个关键词:公钥、私钥、填充

RSA属于非对称加密:公钥用来加密,私钥用来解密。前端会用到的就是公钥,私钥一直存放在服务器端,根本不参与前端通信。

学JS逆向RSA,有几个绕不开的术语必须展开讲,不然看代码你会一脸懵:

  • 模数(Modulus)n:一个大整数,是公钥的核心组成部分,通常以1024位或2048位存在。
  • 指数(Exponent)e:公钥的另一个参数,常见值是65537(0x010001)。
  • 私钥指数d:锁的另一半,逆向过程中不需要你管,因为你只模拟加密。
  • 填充方案(Padding):RSA不能直接加密任意长度的明文,需要先给消息"补位"。常见的有RSA_PKCS1_PADDING(也常记为PKCS#1 v1.5)、RSA_PKCS1_OAEP_PADDING。前端用的最多的是PKCS#1,但OAEP也越来越常见。

在逆向JS时,JSEncrypt库(基本上前端RSA的标配)的默认填充就是PKCS#1 v1.5。而Node.js内置的crypto模块,默认填充反而是PKCS#1 v1.5,但有的新版版本默认变成OAEP——这就是"你用Node还原JS结果但对不上"的一个经典坑点,第五部分我会单独说。

明白了这些,你再看前端那段代码,就不会慌:

一个典型的前端RSA加密,大概是这个画风(用JSEncrypt库):

// 公钥往往是硬编码在JS文件里,或者后端接口下发 var publicKey = "-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----"; var encryptor = new JSEncrypt(); encryptor.setPublicKey(publicKey); var encrypted = encryptor.encrypt(password);

就这么几行公钥加密,背后的流程是:JSEncrypt内部生成一个随机会话密钥(混合加密体系)或直接拿公钥处理短数据,最终输出Base64编码的密文串。你要还原的,其实就是这套流程。

2. 定位加密入口:三种百试百灵的搜索思路

拿到一个目标页面,第一步永远不是去看完整源码,而是定位。我见过很多入门者直接打开一串几万行的混淆JS,一行行读,两个月过去还停留在第一行。正确的做法是带着"指纹"去搜。在模拟项目X里,我一般按下面三种思路走,通常5分钟内能找到关键加密点。

2.1 玩法一:直接搜字段名

目标登录接口提交的参数名,往往是加密函数和明文发生关系的直接纽带。

比如你抓包看到登录请求体长这样:

{"username": "test", "password": "12ab56cdef...", "clientType": "web"}

password这个字段,在整个前端里面,要么直接就是密文变量名,要么它就是连接明文密码变量和加密函数之间的桥梁。直接在DevTools的Sources面板里,Ctrl+Shift+F全局搜索password,你会看到一堆命中列表,此时盯着JS文件里那些"定义、赋值、使用"的位置往下找,大概率会看到一个类似function doEncrypt(content) { ... }的函数,函数里面十有八九藏着JSEncrypt的调用。

最近有个新玩法,也值得一试:有些站点会用window上的自定义对象存放加密方法,比如window.EncryptUtils、CryptoJS或者JSEncrypt,你直接在Console里输入window.EncryptUtils回车,看能不能打印出方法定义,能打印就直接读源码,效率翻倍。

2.2 玩法二:Hook大法,让程序自己告诉你

这个方法是我个人最推荐的入门姿势,也是很多导师教的第一课。原理很简单:在关键函数执行前,往它上面挂一个"监听器",把出入参数打出来,这样根本不用分析流程,程序自己就把加密前的明文和加密后的密文送到你眼前。

核心Code片段长这样,比如我们要Hook住window.atob(Base64解码函数,几乎任何RSA都会经过它):

// 在页面加载的早期(比如DevTools的Sources → Snippets里)执行 (function() { var originalAtob = window.atob; window.atob = function(str) { console.log("atob 被调用了,入参是:", str); var result = originalAtob.call(this, str); console.log("atob 返回值是:", result); return result; }; })();

看着是不是很简单。但这里有个关键,也是新手最爱踩的坑:Hook代码必须在原函数执行之前注入。你不能等页面全部加载完、加密早就跑完了,才想起去Hook,那什么都抓不到。常规姿势是:把Hook代码存成Snippet,或者直接在页面加载的Foo事件早期用脚本注入,或者通过重定向到一个本地JS文件提前执行。

Hook的维度有很多,我常用的一套:

目标Hook类型关注点
window.btoa/window.atobBase64编解码密文往往是Base64输出,找到它就能逆推出加密流程
JSEncrypt.prototype.encrypt加密函数本体一旦调用,直接打印明文入参和密文出参
window.CryptoJS加密对象看具体调用了哪种算法模式和Padding
XMLHttpRequest.send网络请求看到参数即将发出前的最终形态

把Hook当成"在程序门口装个监控",比你自己像侦探一样去翻代码快十倍。

2.3 玩法三:从Network的栈回溯

这个适合定位到XMLHttpRequest(XHR)发起的那个请求里,你想找它到底是哪一行触发时。DevTools的Network面板,点开这条请求,找到Initiator(发起者)标签,里面会给你列出调用栈——谁调用了谁,一层层往上翻,通常最后那个既是调用XHR的,也是离加密函数最近的。

这个方法的优势是精准锁定到"发起请求的那一刻",从那一步往回倒推,直接看到被提交的数据是什么,加密函数在哪一行被调用的。缺点是栈可能被压缩混淆,函数名全是a.b.c,对新手不太友好。

我个人的习惯是:先搜参数名,搜不到再Hook,Hook还不行再用栈回溯。三者组合,没有找不到的加密点。如果你实在找不到,或者命中的全是压缩混淆代码,那大概率不是RSA,是对方用了更复杂的防护(比如WebAssembly),那个属于进阶话题,这里先不展开。

3. 手把手还原一个RSA加密流程

下面我用一个自己搭的模拟项目X,完整演示从断点调试到算法还原的整个过程。所有代码都是我自己本地测试搭的,纯学习用途,验证RSA理解的正确姿势。

3.1 搭建一个最简测试页面

前端代码用CDN引入JSEncrypt,模拟真实站点提交加密后的密码:

<!DOCTYPE html> <html> <head> <script src="https://cdn.jsdelivr.net/npm/jsencrypt@3.3.2/bin/jsencrypt.min.js"></script> </head> <body> <script> var publicKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCz3WYyKkA6N0L..." + // 模拟公钥 "yFV4lFufMqQIDAQAB"; function encryptPassword(plainPwd) { var encryptor = new JSEncrypt(); encryptor.setPublicKey(publicKey); return encryptor.encrypt(plainPwd); } function doLogin() { var pwd = document.getElementById("pwd").value; var ePwd = encryptPassword(pwd); // 发送到后端 console.log("加密后的密码是:", ePwd); fetch("/api/login", { method: "POST", body: JSON.stringify({username: "test", password: ePwd}), headers: {"Content-Type": "application/json"} }); } </script> </body> </html>

测试页面最核心的价值,就是让你无干扰地观察正版逻辑。等你在实际站点上已经把逻辑分析透了,再拿到自己页面里来跑通,能非常直观地验证"我分析得对不对"。

3.2 通过断点观察加密前后的数据

打开页面,把Snippet(上一部分提到的那段Hook代码)跑起来,或者直接在encryptPassword函数内设断点。然后随便输入一个密码,比如abc123,看Console:

  • Hook捕获到的明文入参是abc123;
  • 它经历JSEncrypt.encrypt后,输出一串很长的Base64密文。

你可能会问,为什么abc123这么短,加密后却那么长?因为公钥加密有大小限制,加上填充占据空间,输出长度基本等价于密钥长度。比如1024位公钥,加密后Base64长度约172个字符左右。看到输出长度,你基本能反推密钥长度——这是逆向时判断RSA密钥位数的一个小技巧。

在Console里,把这行语句跑一遍:

encryptor.encrypt("abc123");

你会发现返回结果跟页面登录时打印的一致。这就是你的还原基准——之后你写的任何复现代码,只要用同一公钥加密abc123,得到的密文必须和它完全一致,否则算法就有问题。

3.3 还原算法:Node.js侧RSA复现

到了最关键的一步:把前端逻辑搬到后端脚本里。推荐用Node.js让你更快交叉验证。这里有两个做法,一个土办法,一个正规办法,我都演示一下。

土办法:直接调用前端库。如果你有本地Node环境,用npm install jsencrypt,然后在Node里把公钥和明文丢进去:

const JSEncrypt = require('jsencrypt'); function encryptByJSEncrypt(plainText, publicKey) { const encryptor = new JSEncrypt(); encryptor.setPublicKey(publicKey); return encryptor.encrypt(plainText); } const publicKey = "-----BEGIN PUBLIC KEY-----MIIBIjANBg...-----END PUBLIC KEY-----"; console.log(encryptByJSEncrypt("abc123", publicKey));

这样跑出来的密文,跟你在浏览器Console里看到的一模一样。为什么说这是"土办法"?因为本质上你只是把前端库原封不动搬到了Node里,并没有真正理解RSA算法本身,一旦对方前端改用国产加密库(比如国密SM2),你就又得去搬另一个库,永远处于被动。

正规办法:用Node内置crypto模块手动实现RSA公钥加密。你得先会解析公钥字符串。这里就要稍微动手了。Node的crypto.publicEncrypt可以直接吃PEM格式公钥:

const crypto = require('crypto'); function rsaEncrypt(plainText, publicKey) { // createPublicKey 可以把PEM格式公钥解析成KeyObject const keyObj = crypto.createPublicKey(publicKey); // 使用 PKCS1 v1.5 填充(与JSEncrypt默认一致) const encrypted = crypto.publicEncrypt( { key: keyObj, padding: crypto.constants.RSA_PKCS1_PADDING }, Buffer.from(plainText, 'utf8') ); return encrypted.toString('base64'); } const pemPublicKey = "-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----"; console.log(rsaEncrypt("abc123", pemPublicKey));

跑一下,看结果是否和浏览器一致。理论上应该一致;如果不一致,直接看第五部分的排查表,99%是padding设置问题。

这里还有个入门级核心知识点:前端传来的公钥未必是PEM完整格式。有些站点只给你一段Base64字符串,里面存的是n和e两个大整数,你需要先拼成DER再转PEM才能喂给Node。这种情况我会写个小工具:

const crypto = require('crypto'); function parseBase64PublicKey(base64Key) { const der = Buffer.from(base64Key, 'base64'); // DER -> KeyObject return crypto.createPublicKey({ key: der, format: 'der', type: 'spki' }); } const b64 = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCz3WYy..."; const keyObj = parseBase64PublicKey(b64); console.log(keyObj.asymmetricKeyType); // 打印 'rsa'

把这段代码救急用的场景太多了,我常常在分析某些老系统时,发现它们只传了一个奇怪的b64key参数,里面既不是PEM也没头尾标记,就是DER裸数据。

4. 主动提取公钥参数,生成任意语言的RSA加密脚本

学会了照搬前端库,等于拿到了别人配好的钥匙;但真正的逆向能力,是哪怕今天前端用了C++写成的库,你也能在Python里把加密逻辑还原出来。关键在于,所有RSA加密,底层都逃不过那三样东西:模数n、指数e、填充模式。你只要把公钥参数逐个提取出来,任何语言都能搞定。

4.1 从PEM公钥中拆出n和e

一个真相是,前端JS里经常出现类似这样的代码,是加密私货的核心:

var publicKey = "008ab422a34..."; // 看起来就是个十六进制串

这不一定是设置公钥本体,而可能是你要把n和e拼起来构造公钥。常见套路是公钥参数n用Base64或Hex编码,e固定为010001(十六进制65537)。

用OpenSSL命令让你在本地从PEM文件里拆出n、e:

openssl rsa -pubin -in public_key.pem -text -noout

会输出类似:

Public-Key: (1024 bit) Modulus: 00:b3:...:67:3d Exponent: 65537 (0x010001)

拿到这两个值,你就能用各种脚本库自由发挥了。比如Python的rsa库,直接构造公钥对象:

import rsa # 十六进制模数n n_hex = "00b3d6..." n = int(n_hex, 16) e = 65537 pub_key = rsa.PublicKey(n, e) encrypted = rsa.encrypt(b"abc123", pub_key) import base64 print(base64.b64encode(encrypted).decode())

注意有个额外参数:rsa.encrypt默认使用PKCS#1填充,这跟JSEncrypt一致。如果你的密文跟浏览器对不上,开发者也需要考虑rsa库的随机填充机制——明明同一明文、同一公钥,每次加密结果不同,这不是你错了,而是随机填充带盐。所以验证时,重点看解密结果或长度,而不是"密文长得一样"。

4.2 两种常见公钥字符串的辨别

前端RSA公钥满天飞,但外观就两种:

类型特征还原方法
PEM格式有-----BEGIN PUBLIC KEY-----头尾,Base64多行crypto.createPublicKey或rsa.PublicKey.load_pkcs1_openssl_pem可直接用
DER/B64裸格式无头尾,可能是一串Base64或Hex先反转成DER Buffer,再用对应格式标识符解析

把这块讲清楚,是因为很多初学者卡在第一步:拿到一段公钥,直接扔给createPublicKey,报错说不认识。

报错不一定是你代码错了,而是公钥有"包装"。如果对方给你的是-----BEGIN RSA PUBLIC KEY-----,那其实是PKCS#1格式,跟上面SPKI格式不同,解析时也要区分。Node里用:

const keyObj = crypto.createPublicKey({ key: pemBuffer, format: 'pem', type: 'pkcs1' });

如果你不确定,一个轻量级万能方案是直接用第三方库node-forge:

const forge = require('node-forge'); const publicKey = forge.pki.publicKeyFromPem(pemString); const encrypted = publicKey.encrypt('abc123', 'RSAES-PKCS1-V1_5'); console.log(forge.util.encode64(encrypted));

在分析那些用了老式组件(如ASN.1处理逻辑)的站点时,node-forge对格式的容忍度较高。

4.3 补充补齐:处理"公钥+时间戳+密码"拼接加密

实际场景里,很少有站乖乖只加密一个password。越来越多的前端RSA,加密前会先拼上时间戳、随机数、固定盐,比如:

var raw = new Date().getTime() + "|" + pwd; var encrypted = encryptor.encrypt(raw);

这种你能直接找到原因:加了时间戳后,密文每次不同,服务器端解密后能验证时效性,也就变相防止重放攻击了。

当你分析时发现加密函数入参不再是明文,而是拼接串时,不要慌。从Hook里打印出来,看清拼法,在你自己重写加密逻辑时原样照拼即可。如果拼法太复杂,甚至有Base64、倒序、大小写变换,逐个对照看着还原就行,本质就是一个字符串处理函数链。

5. 新手最常见的坑,照着排查能省一晚上

这部分是全文最值钱的地方了。很多场景下你逆向不出来,不是你不懂,而是被几个"反直觉"的细节卡住。

5.1 密文对不上?先查填充模式

这是翻车率最高的一个坑。前端JSEncrypt加密结果默认是PKCS#1 v1.5填充;但到了后端Node或Python,如果你用的是默认crypto.publicEncrypt,在特定版本默认是RSA_PKCS1_OAEP_PADDING。这俩加密结果完全不一样,算法相同、填充不同,得到的是两个世界。

解决办法就一行:

const encrypted = crypto.publicEncrypt( { key: pubKey, padding: crypto.constants.RSA_PKCS1_PADDING }, buf );

在Python里对应:

encrypted = rsa.encrypt(text, pub_key) # rsa库默认PKCS#1 v1.5

如果用了OAEP,Python库可能是:

from cryptography.hazmat.primitives.asymmetric import padding encrypted = pub_key.encrypt(text, padding.OAEP( mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None ))

还有一点:OAEP的Hash算法也有SHA1和SHA256之分,前端JSEncrypt老版本默认SHA1,新版才加入SHA256支持。所以看到OAEP时,要先确认Hash类型。

5.2 公钥格式导致的解析报错

遇到ERR_OSSL_ASN1_INVALID_DATA或Python里ValueError: RSA key format is not supported,先别怀疑你的解析逻辑,去检查公钥字符串格式。

我处理过一个模拟项目,前端请求头里单独传递了一个公钥参数,打出来是一串带\n的Base64,但实际上它被URL编码过了,空格变成了%2B,加号变成了%2F,导致后端解析失败。解决方式是统一先解码URL、替换换行符、去空格再用。

编写一个公钥清洗函数很有必要:

function normalizePublicKey(rawKey) { return decodeURIComponent(rawKey) .replace(/\\n/g, '\n') .replace(/\r\n/g, '\n') .replace(/ +/g, ''); }

5.3 RSA加密长度限制

RSA不是万能的,它一次能加密的明文长度有上限。对1024位密钥,能加密的最大明文长度约117字节(取决于填充),超过就会报错。

但前端密码一般不会超过,所以你可能永远不知道有这回事。直到某天你去分析一个加密对象,对方把一整个JSON对象塞进了加密逻辑,这时明文长度可能超过限制,就会出问题。模拟项目X中,有一次前端用一个加密函数跑着没报错,是因为它内部自动分段加密了;而我的还原脚本没有分段,一跑就报"data too large for key size"。

如果对方确实用了分段,你要自己实现分块加密再拼接:

block_size = 117 # 1024位密钥 PKCS#1 v1.5 最多117字节 blocks = [data[i:i+block_size] for i in range(0, len(data), block_size)] cipher_blocks = [rsa.encrypt(b, pub_key) for b in blocks] ciphertext = b"".join(cipher_blocks)

5.4 Hook函数没生效的排查

最常见的是:你Hook了window.atob,但程序调用的atob根本不在window上,而是被包在模块作用域里。比如前端用webpack打包时,模块内部引用了var base64 = require("base-64"),那它不会走window.atob,你的Hook自然没反应。

破局思路:遇到这种,要想清楚加密库是打包进来的还是外部CDN的。如果是webpack打包进来的,直接去webpack的模块加载器里找它内部存的所有模块函数,找到对应加密模块后,可以直接调用内部模块导出的函数。

更暴力一点的思路是HookJSEncrypt.prototype.encrypt这个原型链方法,而不是底层Base64工具。

5.5 密文每次不同,不代表你错了

RSA加PKCS#1填充是随机化的。同一明文,每次加密结果永远不同(因为填充有随机字节)。这是校验时最大的干扰项——许多新手以为加密结果不同,就说明自己还原错了,其实恰恰相反,这正是加密正常的标志。

验证方法:把密文用私钥解回来。如果能解开且是原明文,就说明填充模式、密钥长度都对。手动写一个Node解密验证:

const crypto = require('crypto'); const fs = require('fs'); const privateKey = fs.readFileSync('private_key.pem', 'utf8'); // 本地测试私钥 const decrypted = crypto.privateDecrypt( { key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, Buffer.from(encryptedBase64, 'base64') ); console.log(decrypted.toString('utf8')); // 应该打印 abc123

如果你在分析一个真实站点,拿不到私钥怎么办?那你要验证密文正确性,就得走完整登录流程:拿着你还原的密文,模拟整个登录请求,只要服务器返回成功或跳转,说明加密逻辑对上了。

6. 入门者的三条进阶建议,少走弯路

最后分享几点个人体会,算不上什么大道理,但对刚接触JS逆向的朋友应该很有帮助。

第一,先学会跑通本地再上真实目标。现在网上有很多练习靶场和模拟项目,比如我自己搭的模拟项目X,专门设计了几十种加密玩法,从简单的RSA到登录流程完整防护。在这些靶场上练熟"定位→断点→还原→验证"四步法,比直接在真实站点上反复碰壁效率高得多。真实站点通常有反调试、混淆、动态Key等机制,不适合拿来练入门。

第二,多学一点密码学基础不会亏。很多人以为JS逆向就是会看代码、会Hook、会断点,但一遇到RSA就开始玄学。你要是搞明白了公钥加密、私钥签名、明文长度限制、填充机制,那对接下来的分析会有种"降维打击"的感觉。有时候看到前端加密函数,你甚至不用运行,光看库类型就知道它内部大概走了几道工序。包括以后你遇到AES、国密SM2/SM4,底层密码学知识是通用的。

第三,保持写验证脚本的习惯。我在还原RSA时,永远会有一对公私钥放在本地做对照实验。遇到某个加密逻辑分析不出来,就在本地起一个一样的环境,把log打全,再跟前端输出对照。跑顺了改成自动化测试脚本,每次改动代码都能回归一下。这不仅是逆向技巧,更是工程师的基本素养。

至于后续进阶,方向可以往这几个角度扩展:一是混淆JS的还原思路,比如ob混淆、控制流平坦化;二是动态密钥体系,服务端下发密钥、前端动态拼接;三是WebAssembly防护——它把逻辑编译成二进制,传统断点看不到明文。技术博弈永远在升级,但核心能力是相通的:看懂数据如何流转,看懂算法如何入参出参,然后在一个可控环境里把它复现出来。跑通了第一道关,后面的一切都会顺利很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询