-
-
[原创]36Kr 登录接口 sign 签名 + 密码 RSA 加密逆向实战(JS 逆向 / MD5 签名 / JSEncrypt / execjs)
-
发表于: 1小时前 52
-
[原创]36Kr 登录接口 sign 签名 + 密码 RSA 加密逆向实战(JS 逆向 / MD5 签名 / JSEncrypt / execjs)
36Kr 登录接口 sign 签名 + 密码 RSA 加密逆向实战(JS 逆向 / MD5 签名 / JSEncrypt / execjs)
本文是「JS 逆向实战 30 例」系列第 9 篇,项目源码:JS 逆向实战 30 例 · 开源仓库 (
Project9)
一、目标与背景
36Kr 的手机号密码登录,我想还原它的登录协议。
站点:
814K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1j5K6y4X3E0J5i4K6u0W2j5$3!0E0i4K6u0r3目标接口:
13fK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4j5i4c8W2N6$3q4&6i4K6u0W2x3K6k6C8M7W2)9J5k6h3y4G2L8g2)9J5c8X3q4H3K9g2)9J5c8X3#2#2M7#2)9J5c8X3I4G2k6$3W2F1i4K6u0r3j5Y4W2y4L8$3u0A6L8r3g2b7j5i4y4K6N6$3!0J5k6l9`.`.需还原字段:
sign、nonce、timestamp、mobileNo、password难度:⭐⭐⭐⭐(四星。手机号 / 密码各自 RSA 加密,再加一层 MD5 签名,且签名依赖首页下发的动态
nonce)
这个项目有意思的地方在于:签名里混入了一个来自首页的随机串 nonce,也就是 window.__GATEWAY_SIGN__。只扣 JS 还不够,必须先请求一次首页把这个值取回来,才能算出正确的 sign。这在网关型 API(gateway)里非常常见。
二、抓包分析
打开 597K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1j5K6y4X3E0J5i4K6u0W2j5$3!0E0i4K6u0r3,登录框输入手机号密码点登录,F12 → Network,抓 byMobilePassword 这个请求。
先看 Request URL 的 Query:
POST d96K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4j5i4c8W2N6$3q4&6i4K6u0W2x3K6k6C8M7W2)9J5k6h3y4G2L8g2)9J5c8X3q4H3K9g2)9J5c8X3#2#2M7#2)9J5c8X3I4G2k6$3W2F1i4K6u0r3j5Y4W2y4L8$3u0A6L8r3g2b7j5i4y4K6N6$3!0J5k6q4)9K6c8Y4y4A6k6$3&6Q4x3@1c8Q4x3U0k6D9N6q4)9K6b7U0x3J5i4@1f1@1i4@1u0p5i4K6S2p5i4@1f1#2i4K6S2p5i4K6R3I4i4@1f1#2i4K6R3#2i4@1q4p5i4@1f1^5i4@1u0r3i4K6W2n7i4@1f1#2i4K6R3^5i4@1t1$3i4K6t1$3k6%4c8Q4x3@1t1`.
sign 挂在 query 上。
再看 Payload(请求体),是一个 JSON:
{
"nonce": "<base64 串>",
"krtoken": "",
"partner_id": "web",
"timestamp": 1785212736314,
"param": {
"countryCode": "86",
"mobileNo": "<base64 密文>",
"password": "<base64 密文>"
}
}
规律非常清晰:
mobileNo/password都是 Base64 密文(RSA 加密后 Base64);nonce是一个 Base64 串;timestamp是毫秒时间戳;sign是 query 上的 MD5 串。
【图1:byMobilePassword 请求的 Query 里的 sign 与 Body 里的加密 param —— 建议截图】
还有一个关键线索:登录前页面里会有 window.__GATEWAY_SIGN__="...",它就是这里的 nonce。
三、定位加密入口
登录是前端 JS 发出的,所以定位思路:
XHR 断点:在 Sources → XHR/Fetch Breakpoints 里加
byMobilePassword,触发登录,断下来后看调用栈;往上层回溯:找到封装请求的函数后,往上看它怎么组装
param和sign;同时全局搜
setPublicKey、JSEncrypt、encrypt、MD5、sign。
最终抠出来的核心函数 get_arg(来自真实文件 js/36kr_2.js):
// 取自仓库真实 js:Project9/36kr/js/36kr_2.js
function get_arg(GATEWAY_SIGN){
var i = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCeiLxP4ZavN8qhI+x+whAiFpGWpY9y1AHSQC86qEMBVnmqC8vdZAfxxuQWeQaeMWG07lXhXegTjZ5wn9pHnjg15wbjRGSTfwuZxSFW6sS3GYlrg40ckqAagzIjkE+5OLPsdjVYQyhLfKxj/79oOfjl/lV3rQnk/SSczHW0PEyUbQIDAQAB"
, a = function() {
var e = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : "";
if (true) {
var t = BBB(491)
, r = new t.JSEncrypt;
r.setPublicKey(i);
var a = r.encrypt(e);
return a
}
}
countryCode = "86"
mobileNo = a("你的手机号")
password = a("你的密码")
var timestamp = Date.now();
var OO = GATEWAY_SIGN
Arg = '{"krtoken":"","partner_id":"web","timestamp":' + timestamp.toString() + ',"param":{"countryCode":"86","mobileNo":"' + mobileNo + '","password":"' + password + '"}}' + OO
var sign = md5(Arg)
li = {}
li["countryCode"] = countryCode
li["mobileNo"] = mobileNo
li['password'] = password
li["timestamp"] = timestamp
li["nonce"] = OO
li["sign"] = sign
return li
}
拆解一下:
i是站点写死的 RSA 公钥;a(...)是一个内部函数:用BBB(491)拿到 webpack 里的JSEncrypt模块,setPublicKey(i)后encrypt(),对手机号 / 密码分别加密(注意 RSA 加密是每次都带随机 padding 的,所以同样的手机号每次密文也不同);md5是文件顶部定义的cryto_js.MD5(text).toString();sign = md5(JSON串 + GATEWAY_SIGN)—— 这就是签名精髓:把登录 body 的 JSON 原样字符串再拼上首页下发的nonce,一起 MD5。
文件顶部很简单,直接 require('crypto-js'):
// 取自仓库真实 js:Project9/36kr/js/36kr_2.js(顶部)
window = global
self = window
var cryto_js = require('crypto-js');
function md5(text) { // md5加密
return cryto_js.MD5(text).toString()
}
【图2:XHR 断点命中后回溯到 get_arg 的调用栈 —— 建议截图】
四、JS 还原与补环境
这一篇的还原重点是两段环境的配合:
1. 先拿 nonce(GATEWAY_SIGN)
nonce 不从 JS 里算,而是从首页 HTML 里抠。仓库里 py/arg.py 专门干这件事:
# 取自仓库真实 py:Project9/36kr/py/arg.py
import requests
def get_arg()->str:
headers = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Accept-Language": "zh-CN,zh;q=0.9",
"Referer": "48bK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1j5K6y4X3E0J5i4K6u0W2j5$3!0E0i4K6u0r3i4K6t1$3M7i4g2G2N6q4)9K6b7R3`.`.,
"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",
"sec-ch-ua": "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\""
}
cookies = {
"_c_WBKFRo": "你的_c_WBKFRo",
"_nb_ioWEgULi": "",
"SERVERID": "你的SERVERID",
"_wafchallengeid": "你的_wafchallengeid",
"_waftokenid": "你的_waftokenid"
}
url = "15eK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1j5K6y4X3E0J5i4K6u0W2j5$3!0E0i4K6u0r3i4K6t1$3M7i4g2G2N6q4)9K6b7R3`.`.
response = requests.get(url, headers=headers, cookies=cookies).text
res = response.split('<script async>window.__GATEWAY_SIGN__="')[1].split('"')[0]
return res
做法就是 GET 首页,然后从 <script async>window.__GATEWAY_SIGN__="..." 里切出那串 Base64。注意 nonce 和 Cookie 是配套的,_c_WBKFRo 这类 WAF Cookie(占位符 你的_c_WBKFRo)如果失效,首页可能拿不到这个值。
2. 算 mobileNo / password / sign
把上一步的 nonce 作为 GATEWAY_SIGN 参数喂给 36kr_2.js 的 get_arg:
补环境只做了
window = global; self = window;JSEncrypt从 webpack 模块BBB(491)里取,所以我把整个 webpack 运行时保留,没有把那段!function(e){...}删掉;md5用crypto-js,所以要npm i crypto-js。
关于 RSA 加密,几个必须记住的点:
用
setPublicKey+encrypt,不是encryptLong(这里手机号密码都不长);每次
encrypt结果不同(PKCS#1 v1.5 随机填充),所以sign里的密文和实际发出去的密文必须来自同一次调用,不能分开算两次。
五、Python 实现
主脚本 py/36kr.py 把“取 nonce → 生成签名参数 → 发登录请求”串起来:
# 取自仓库真实 py:Project9/36kr/py/36kr.py
import requests
import json
from arg import get_arg
import os
import execjs
GATEWAY_SIGN = get_arg()
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
JsPath = os.path.join(BASE_DIR, '..', 'js', '36kr_2.js')
with open(JsPath, encoding="utf-8") as f:
Js_code = f.read()
Js = execjs.compile(Js_code)
arg = Js.call(get_arg, GATEWAY_SIGN)
headers = {
"Content-Type": "application/json",
"Origin": "fbdK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1j5K6y4X3E0J5i4K6u0W2j5$3!0E0i4K6t1$3M7i4g2G2N6q4)9K6b7R3`.`.,
"Referer": "be0K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1j5K6y4X3E0J5i4K6u0W2j5$3!0E0i4K6u0r3i4K6t1$3M7i4g2G2N6q4)9K6b7R3`.`.,
"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",
# ...其余 sec-ch-ua / dnt 等略...
}
cookies = {
"sensorsdata2015jssdkcross": "你的sensorsdata",
"_c_WBKFRo": "你的_c_WBKFRo",
"_nb_ioWEgULi": ""
}
url = "e1bK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4j5i4c8W2N6$3q4&6i4K6u0W2x3K6k6C8M7W2)9J5k6h3y4G2L8g2)9J5c8X3q4H3K9g2)9J5c8X3#2#2M7#2)9J5c8X3I4G2k6$3W2F1i4K6u0r3j5Y4W2y4L8$3u0A6L8r3g2b7j5i4y4K6N6$3!0J5k6q4)9J5y4Y4q4#2L8%4c8Q4x3@1t1`.
params = {
"sign": arg["sign"]
}
data = {
"nonce": arg["nonce"],
"krtoken": "",
"partner_id": "web",
"timestamp": arg["timestamp"],
"param": {
"countryCode": arg["countryCode"],
"mobileNo": arg["mobileNo"],
"password": arg["password"]
}
}
data = json.dumps(data, separators=(',', ':'))
response = requests.post(url, headers=headers, cookies=cookies, params=params, data=data)
print(response.text)
print(response)
结构非常干净:get_arg()(network)拿 nonce → Js.call(...) 拿签名参数 → sign 放 query、其余放 body。
⚠️ 小提醒:这里
Js.call(get_arg, GATEWAY_SIGN)的第一个参数严格来说应该是函数名字符串("get_arg")。仓库里直接把 Python 的get_arg函数对象传了进去(因为from arg import get_arg同名),属于一个容易踩的写法坑,实际使用时建议写成Js.call("get_arg", GATEWAY_SIGN),语义更明确。
六、运行结果
执行 python 36kr.py,终端会打印登录接口的返回。如果账号密码正确、签名与 nonce 匹配,会返回类似:
{"code":0,"data":{"token":"...","userId":...},"msg":"success"}如果 sign 不对,会返回签名校验失败的提示——这正好可以用来验证我们的签名算法是否还原正确。
【图3:终端打印登录接口返回 —— 建议截图】
七、踩坑与注意
nonce和 Cookie 是配套的。GATEWAY_SIGN来自首页,首页又依赖 WAF Cookie(_c_WBKFRo等占位符)。换一套 Cookie 后要重新请求首页拿新nonce,否则签名必错。sign里的密文必须和发出去的密文一致。 RSA 每次加密都带随机填充,如果你为了打印调试而对密码调了两次a("你的密码"),两次密文不同,签出来的sign就对不上。sign拼的是 body 的紧凑 JSON。Arg里字段顺序是krtoken → partner_id → timestamp → param,顺序和空格都影响 MD5,必须和最终发出的 JSON 完全一致。Python 侧用了json.dumps(..., separators=(',', ':'))来保证紧凑格式,但字段顺序由 dict 决定,要保证和 JS 里拼的一样。JSEncrypt从 webpack 模块取。 别把BBB(491)那段 webpack 运行时删掉,否则找不到模块。self = window也要补。 有些打包代码会访问self,Node 里没有,补上更稳。crypto-js依赖。 顶部require('crypto-js')需要npm i crypto-js。账号密码脱敏。 仓库里
mobileNo = a("你的手机号")/password = a("你的密码")都是占位符,跑之前换成自己的可用测试账号。
八、总结
网关型 API 的签名常常混入“首页下发的随机 nonce”,所以还原 = “取 nonce(Python)+ 算签名(JS)”两段配合。
RSA 加密结果每次不同,签名与密文必须取自同一次调用,否则前后端校验必然失败。
签名的拼串顺序和 JSON 紧凑格式是硬约束,Python 复现 body 时字段顺序要和 JS 完全对齐。
免责声明:本文仅供网络安全技术学习交流,请勿用于任何商业或非法用途。
[招生]科锐逆向工程师培训(2026年11月13日实地,远程教学同时开班, 第57期)
赞赏
- [原创]山西省政府采购电子卖场 nsssjss/sign 请求头签名逆向实战(JS 逆向 / RSA / 嵌套哈希 / execjs) 0
- [原创]36Kr 登录接口 sign 签名 + 密码 RSA 加密逆向实战(JS 逆向 / MD5 签名 / JSEncrypt / execjs) 0
- [原创]五矿电商 ec.minmetals 请求 param 参数 RSA 长加密逆向实战(JS 逆向 / JSEncrypt / 补环境 / subprocess node) 251
- [原创]中国观鸟记录中心 birdreport 请求 RSA 加密 + 响应 AES 解密逆向实战(JS 逆向 / JSEncrypt / 补环境 / execjs) 277
- [原创][求助]数位观察 swguancha 响应 AES 解密逆向实战(JS 逆向 / 补环境 / execjs) 446