-
-
[原创]凤凰云智登录密码 RSA 加密 JS 逆向实战(大整数 RSA + 阿里 IDaaS + execjs 补环境)
-
发表于: 2天前 360
-
凤凰云智登录密码 RSA 加密 JS 逆向实战(大整数 RSA + 阿里 IDaaS + execjs 补环境)
本文是「JS 逆向实战 30 例」系列第 11 篇,项目源码:JS 逆向实战 30 例 · 开源仓库 (
Project11)
一、目标与背景
凤凰云智(lark.yuekeyun.com)是一套企业办公平台,它的登录走的是阿里 IDaaS(lark-acl.alibaba.com)。抓包会发现,账号只以明文 loginId 出现,而 password 是一长串十六进制——这就是这篇文章要还原的核心:登录密码的 RSA 加密。除了密码,请求体里还有 captchaVerifyParam(阿里无痕验证的票据)和 sceneId,前者需要靠打码/指纹方式自取,后者是站点级公开常量。
这是一个很经典的「JS 加密 + 只还原单个加密函数」的场景,难点不在算法本身(就是标准 RSA),而在于:
RSA 是大整数实现,密钥是页面里写死的三套公钥(按环境区分);
站点 JS 是一整个 webpack bundle,必须补出
window/localStorage/GLOBAL_CONFIG才能跑;加密前对明文做了
encodeURIComponent,这个编码细节很容易漏。站点:
8b0K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6D9j5i4u0C8i4K6u0W2P5i4g2W2K9$3g2&6N6h3&6Q4x3X3g2U0L8$3#2Q4x3V1j5`.目标接口:
117K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6D9j5i4u0C8i4K6u0V1j5h3y4D9i4K6u0W2j5h3I4A6j5X3q4T1j5g2)9J5k6h3y4G2L8g2)9J5c8X3W2V1M7q4)9J5c8X3I4G2k6$3W2F1b7h3y4U0L8%4g2F1N6p5q4F1k6q4m8%4k6l9`.`.(POST)需还原字段:
password(RSA)、captchaVerifyParam、sceneId难度:⭐⭐⭐(webpack 补环境 + 大整数 RSA)
| 字段 | 含义 |
|---|---|
loginId |
账号明文 |
password |
RSA 加密后的十六进制串(本文重点) |
captchaVerifyParam |
阿里验证码的验证票据,需自行获取 |
sceneId |
验证码场景 ID(站点公开常量) |
二、抓包分析
打开 DevTools 的 Network,在登录页输入账号密码、点登录,筛 loginAccountAndPwd,就能看到这个 POST:
POST 07aK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6D9j5i4u0C8i4K6u0V1j5h3y4D9i4K6u0W2j5h3I4A6j5X3q4T1j5g2)9J5k6h3y4G2L8g2)9J5c8X3W2V1M7q4)9J5c8X3I4G2k6$3W2F1b7h3y4U0L8%4g2F1N6p5q4F1k6q4m8%4k6q4)9K6c8X3q4U0j5$3g2K6M7#2)9#2k6Y4c8G2K9$3g2F1i4K6y4p5
content-type: application/json;charset=UTF-8
origin: a43K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6D9j5i4u0C8i4K6u0W2P5i4g2W2K9$3g2&6N6h3&6Q4x3X3g2U0L8$3@1`.
referer: a5cK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6D9j5i4u0C8i4K6u0W2P5i4g2W2K9$3g2&6N6h3&6Q4x3X3g2U0L8$3#2Q4x3V1j5`.
gray-lease-code: null
gray-user-id: null
{"loginId":"你的账号","password":"你的密码(密文)","captchaVerifyParam":"你的captchaVerifyParam","sceneId":"1fz224se"}
一眼能判断:loginId 是明文,password 不是 MD5(MD5 是 32 位固定长度),而是一长串十六进制、长度随明文变化——这是 RSA 的典型特征。captchaVerifyParam 是阿里验证码 SDK 生成的,属于「环境绑定」产物,不在本次还原范围内,先留占位。
【图1:DevTools 里抓到的 loginAccountAndPwd 请求,圈出 password —— 建议截图】
我把请求头里的 gray-lease-code / gray-user-id 一起抄进 Python,服务端对这几个头有校验。
三、定位加密入口
站点只有一个大的 bundle(本项目的 js/yuekeyun.js,约 82KB)。定位加密函数最快的办法是全局搜关键字:搜 RSA、encryptedString、password。
搜 encryptedString 会落到一个 webpack 模块 757026,它导出「把明文转成密文」的函数:
// 取自 js/yuekeyun.js 模块 757026 —— 密码加密入口
757026: (e, t, n) => {
"use strict";
var r = n(385784).default;
var a = r(n(954746)) // 大整数 RSA 实现
, o = r(n(624362)) // daily 环境公钥
, i = r(n(896378)) // pre 环境公钥
, s = r(n(949284)); // prod 环境公钥
const l = {
daily: o.default, zlg_daily: o.default,
pre: i.default, zlg_pre: i.default,
prod: s.default, zlg_prod: s.default
};
a.default.setMaxDigits(130);
const u = new a.default.RSAKeyPair(
"10001", "", // 指数 e
localStorage.publicKey || l[window.GLOBAL_CONFIG.env] // 模数 n(按环境取)
);
t.default = e => a.default.encryptedString(u, encodeURIComponent(e)) // ★ 先编码再加密
}
几个关键点立刻清晰了:
- 公钥不是从接口拿的,而是按
window.GLOBAL_CONFIG.env在页面里选死的(daily / pre / prod 三套,都是十六进制模数,624362/896378/949284三个模块的e.exports就是那串长十六进制)。本项目GLOBAL_CONFIG.env = "prod"。 - 指数
e = "10001"(即 65537),d为空(只有公钥,无需私钥)。 setMaxDigits(130)决定了分包大小,必须调用,否则大整数运算会崩。- 最容易被忽略的是
encodeURIComponent(e)——先 URL 编码、再加密。
再往上一层,模块 954746(encryptedString 的宿主)就是一份经典的「大整数 + Barrett 约减」RSA:
// 取自 js/yuekeyun.js —— 大整数 RSA 的出口
e.exports = {
RSAKeyPair: function (e, t, n) {
this.e = C(e), this.d = C(t), this.m = C(n),
this.chunkSize = 2 * w(this.m), this.radix = 16, this.barrett = new G(this.m)
},
setMaxDigits: d,
encryptedString: function (e, t) { // e=RSAKey, t=明文
for (var n = new Array, r = t.length, a = 0; a < r;) n[a] = t.charCodeAt(a), a++;
for (; n.length % e.chunkSize != 0;) n[a++] = 0; // 补零到 chunkSize 整数倍
var o, i, s, l = n.length, u = "";
for (a = 0; a < l; a += e.chunkSize) {
for (s = new p, o = 0, i = a; i < a + e.chunkSize; ++o)
s.digits[o] = n[i++], s.digits[o] += n[i++] << 8;
var c = e.barrett.powMod(s, e.e); // 分组幂模
u += (16 == e.radix ? E(c) : v(c, e.radix)) + " ";
}
return u.substring(0, u.length - 1)
}
}
这就是一份可独立复现的算法:把明文按 chunkSize 分组、每组当大整数做 m^e mod n、转十六进制拼接。
【图2:在 t.default = e => ...encryptedString(...) 一行打断点,看调用栈 —— 建议截图】
四、JS 还原与补环境
由于打包代码一启动就引用 window / localStorage / window.GLOBAL_CONFIG,直接用 Node 跑会报「window is not defined」。所以在文件顶部先补环境(这也是仓库的真实做法):
// 取自 js/yuekeyun.js —— 顶部补环境
var BB;
window = global
self = window
localStorage = {
"__ETAG__CNA__ID__": "20260728_fpbvIgnmvHEBASQIgmayfsy0",
"tfstk__": "...", // 站点埋点字段,照抄,非隐私
"isg__": "...",
"syfhs": "1346664329",
"ETLCD": "false",
"auyst": "1785243896"
}
localStorage.publicKey = undefined // 关键:默认 undefined,走 GLOBAL_CONFIG.env 分支
window.GLOBAL_CONFIG = {
"assets": "//g.iyunzhi.com/s/v/alipic-lark/larkportal-front/5.0.23/",
"envType": "PROD",
"appType": "YZ",
"appName": "云智",
"env": "prod", // ★ 决定用哪套公钥
"version": "5.0.23"
}
补完环境后,bundle 能正常初始化,末尾就是原作者留下的调用示范:
// 取自 js/yuekeyun.js —— 尾部调用
NN = { "loginId": "dssdfasg aoijsdf;ngs", "password": "gbaeoijg;qjg36" } // 占位/示例值
DD = BB("757026")
const password = (0, DD.default)(NN.password) // ← 拿到 RSA 密文
console.log(password)
TT = "C0Hd0zExy5"
NXNX = "1.0" === "3.0" ? t : window.btoa(JSON.stringify({
certifyId: t,
sceneId: this.config.SceneId,
isSign: !0,
securityToken: this.config.securityToken
}))
运行 node yuekeyun.js,控制台就会打印出 NN.password 对应的密文。BB("757026") 是 webpack 的模块加载器,等价于 require。
说明:仓库里
NN.loginId/NN.password是演示占位值,不是真实凭据;真实账号密码属于隐私,按仓库约定统一用你的xxx占位。
五、Python 实现
Python 侧比较简单:密码密文和验证票据作为输入(先用 node 或本条 JS 算出密文,再填进去),然后带齐请求头 POST。
# 取自 py/yuekeyun.py
import requests
import json
headers = {
"accept": "application/json, text/plain, */*",
"content-type": "application/json;charset=UTF-8",
"gray-lease-code": "null",
"gray-user-id": "null",
"origin": "4b3K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6D9j5i4u0C8i4K6u0W2P5i4g2W2K9$3g2&6N6h3&6Q4x3X3g2U0L8$3@1`.",
"referer": "e5bK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6D9j5i4u0C8i4K6u0W2P5i4g2W2K9$3g2&6N6h3&6Q4x3X3g2U0L8$3#2Q4x3V1j5`.",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
}
url = "a2aK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6D9j5i4u0C8i4K6u0V1j5h3y4D9i4K6u0W2j5h3I4A6j5X3q4T1j5g2)9J5k6h3y4G2L8g2)9J5c8X3W2V1M7q4)9J5c8X3I4G2k6$3W2F1b7h3y4U0L8%4g2F1N6p5q4F1k6q4m8%4k6l9`.`."
params = {"access_token": ""}
data = {
"loginId": "你的账号",
"password": "你的密码(密文)", # ← RSA 密文
"captchaVerifyParam": "你的captchaVerifyParam",
"sceneId": "1fz224se"
}
data = json.dumps(data, separators=(',', ':')) # ★ 紧凑 JSON,不能带空格
response = requests.post(url, headers=headers, params=params, data=data)
print(response.text)
print(response)
也可以把它接到 execjs 里,让 Python 直接调 JS 出密文(需要注意 yuekeyun.js 末尾那段 console.log(password) 会污染 stdout,最好改成一个只返回值的入口函数):
import execjs
Js = execjs.compile(open("js/yuekeyun.js", encoding="utf-8").read())
# 前提:把文件尾部改成导出函数,例如 function get_pwd(p){ return BB("757026").default(p) }
# pwd = Js.call("get_pwd", "你的明文密码")
六、运行结果
node yuekeyun.js 跑通后,终端会给出一长串十六进制密文(NN.password 的 RSA 结果),长度明显大于明文。把这段密文填进 yuekeyun.py 的 password,带上有效的 captchaVerifyParam,服务端就不再返回「密码错误」,而是正常下发登录流程:
{"success":true,"content":{"data":{...},"...":"..."}} // 或成功后的跳转态数据
<Response [200]>
若验证码票据过期/无效,会看到类似验证失败的返回(阿里侧错误码)。
【图3:终端打印 RSA 密文 + loginAccountAndPwd 返回 200 —— 建议截图】
七、踩坑与注意
- 先编码、后加密:
encryptedString(u, encodeURIComponent(e)),明文里的中文、+、@等会被 URL 编码后再进 RSA,漏掉这一步结果一定不对。 setMaxDigits(130)不能省:它初始化大整数位宽,不调用直接RSAKeyPair会异常。- 公钥按环境选:
l[window.GLOBAL_CONFIG.env]用的是当前环境的公钥;自己补环境时env写错(比如写成pre)会导致密文换了模数,服务端直接判定密码错误。仓库里 prod 对应949284那段长十六进制。 captchaVerifyParam是环境绑定产物:它不是纯算法,跟浏览器指纹、行为、时间戳都相关,纯requests很难稳定复现,需要打码平台或专门的验证码逆向。仓库里同样以你的captchaVerifyParam占位。execjs的 stdout 污染:yuekeyun.js尾部自带console.log(password),用execjs直调时该输出会混进 node stdout。稳妥做法是把它改写成导出函数,只在页面返回字符串。- JSON 必须紧凑:
json.dumps(..., separators=(',', ':')),阿里 IDaaS 对 body 原文(哪怕空格)可能参与校验。 - 占位符:仓库里
loginId/password/captchaVerifyParam均为占位,直接运行必失败,请替换为你自己的。
八、总结
凤凰云智这类站点把「密钥 + RSA 实现」整体打进 webpack,逆向的关键不是重写 RSA,而是补出 window / localStorage / GLOBAL_CONFIG 让 bundle 跑起来,再拿到那个封装好的加密函数。记住 RSA 密文两个特征:十六进制、长度固定且远大于明文;再记住「先 encodeURIComponent 再加密」这个最容易翻车的细节。
免责声明:本文仅供网络安全技术学习交流,请勿用于任何商业或非法用途。
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
赞赏
- [原创]荔枝网 X-ITOUCHTV 头签名 WASM 逆向实战(webpack + wasm-bindgen + sig.json 中转) 393
- [原创]scrape.center 靶场 WASM 签名 JS 逆向实战(WebAssembly.encrypt + Node 子进程 + Python) 423
- [原创]国家医保服务平台国密 SM2/SM4 签名 JS 逆向实战(x-tif 头 + 补环境 + execjs) 432
- [原创]凤凰云智登录密码 RSA 加密 JS 逆向实战(大整数 RSA + 阿里 IDaaS + execjs 补环境) 359
- [原创]山西省政府采购电子卖场 nsssjss/sign 请求头签名逆向实战(JS 逆向 / RSA / 嵌套哈希 / execjs) 451