JS逆向入门第二课:财联社逆向实战——从签名验证到算法还原(保姆级教程)
第一课结尾留了个预告:下一篇用财联社实战,把 XHR/Fetch 断点 + Call Stack 追溯完整走一遍。说到做到,今天就来。这篇拿财联社(cls.cn)的「电报」页面开刀,走一遍从「发现签名」到「还原算法」再到「用 Python 调通」的全流程。学完这课,下次遇到签名,你知道从哪下手。还没看过第一课?先回去看,再回来。
一、先抓到 API,再试探它
打开 https://www.cls.cn/telegraph,这个页面是财联社的核心功能——24小时实时快讯,新闻一条接一条地滚动刷新。F12 切到 Network 面板,过滤 Fetch/XHR,会看到有请求刷出来,找一个发往 /api/cache 的、name 参数是 telegraph 的那个,点开 Response——一堆新闻数据,好,找到接口了。
右键 → Copy → Copy as cURL,终端里跑一下:
curl 'https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9&sign=6c73b056a64891cdc257dcf1914464ad'
能通,有数据。这时候新手小白的反应通常是:好,拿这个 curl 写个 Python 脚本定时跑,收工。
但过几分钟再跑一次——同样的 URL,同样的 sign,返回 50101 了。sign 是有时效性的,过一段时间就失效,服务器不认了。这时候才意识到不能直接用,得搞明白这个 sign 是怎么算出来的,才能动态生成新的。
删掉 sign 再跑一下确认:
curl 'https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9'
返回 {"errno":50101,"msg":"小财正在加载中..."}——确认了,sign 是必须的。接下来就是要找它怎么算出来的。
如果按第一课的路子,这时候应该去 Sources 搜 encrypt、password 这些关键词。但这是签名验证,不是加密——sign 这个词更可能藏在代码里。试试搜 sign:,结果果然出来了。
二、搜索 sign:,找到可疑代码
切到 Sources 面板,Ctrl+Shift+F 全局搜索 sign:。搜索结果里,_app-55a1d161e52e0f66.js 这个文件有多处匹配,拉到最下面,倒数两行特别显眼:
sign:o({...n})
sign:o("")
一个传对象,一个传空字符串。在搜索结果中点倒数第二行,跳转到代码区,只看到这一小段:
sign: o({
...n
})
} : {
sign: o("")
};
光看这一小块还不太确定这是什么。往上滑,发现这其实是一个模块,开头写着 85073: (e, t, r) => {,再往上滑看到完整的逻辑:
let o = r(36498) // ← 这个 o 是签名函数
// 固定参数注入
n.os = "web",
n.sv = "8.7.9",
n.app = n.app || 0 === n.app ? n.app : "CailianpressWeb";
// 参数排序
let s = (e, t) => e.toString().toUpperCase() > t.toString().toUpperCase() ? 1
: e.toString().toUpperCase() === t.toString().toUpperCase() ? 0 : -1;
// 生成 sign
sign: o({...n})
可以看到做了三件事:注入固定参数 → 排序 → 调 o 函数算签名。压缩后的代码看着有点晕?没关系,逆向过程中遇到看不懂的压缩代码,直接复制给AI,让AI帮忙拆解逻辑就行,不用硬啃。
三、打断点确认走哪条路
代码里有两处调了 o:sign: o({...n}) 和 sign: o("")。前者传参数字典,后者传空字符串,不知道实际走哪个。
两个都打上断点。然后在 XHR/fetch Breakpoints 点 + 号,输入 /api/cache,回车,刷新页面。
请求触发,代码啪地停在 o({...n}) 上,o("") 完全没动静。确定了,走的是 o({...n})。那 o("") 呢?兜底用的,当参数对象 n 不存在时给个空 sign 保底,正常情况根本不会走。
四、悬停跳转,找到签名函数本体
鼠标悬停在变量 o 上,出现一个蓝色带下划线的链接。点它,代码跳转到函数定义:
e.exports = function(e) {
let t = e && s(a(e).map(t => (function e(t, r) {
let n = typeof r, i = null;
return null === r || (/string|number|boolean/.test(n)
? i = "".concat(o(t), "=").concat(o(r))
: Array.isArray(r)
? i = r.length
? s(r.map((r, n) => e("".concat(t, "[").concat(n, "]"), r)))
: o("".concat(t, "[]"))
: "object" === n && (i = s(a(r).map(n =>
e("".concat(t, "[").concat(n, "]"), r[n]))
))
), i
})(t, e[t])));
return i(t = n.sync(t))
}
追进去,代码是压缩后的,直接看有点绕。这时候可以复制这段代码扔给AI,让它帮忙拆解逻辑。问一句"这段JS代码在做什么",AI会告诉你它的核心就三步:序列化(把 {app: "CailianpressWeb", os: "web"} 转成 app=CailianpressWeb&os=web)、排序(key 按字母顺序排整齐)、哈希(调 n.sync(t) 做哈希得到 sign)。问题现在就卡在 n.sync(t)——它用的什么哈希算法?
五、断点 + 悬停,一路追到哈希算法
鼠标悬停 n.sync(t),第一次是 null——因为还没执行到这个位置,变量还没赋值。在 n.sync(t) 那一行打个断点,点上面的蓝色箭头放掉上一个断点,继续执行,代码停住了,这次再悬停:
function c(e) { return o.digest(e) }
有函数了!点击蓝色链接跳转进去,看到 o.digest(e),再悬停跳转:
e.prototype.digest = function(e) {
return o(this.rawDigest(e).buffer)
}
此刻 Scope 面板里的 e 就是序列化后的字符串——比如 "app=CailianpressWeb&os=web&sv=8.7.9"。继续追,在 return 行打上断点,放行前一个断点,然后悬停 this.rawDigest,跳转:
e.prototype.rawDigest = function(e) {
var t = e.byteLength || e.length || e.size || 0;
this._initState(this._heap, this._padMaxChunkLen);
var r = 0, n = this._maxChunkLen;
for (r = 0; t > r + n; r += n)
this._coreCall(e, r, n, t, !1);
return this._coreCall(e, r, t - r, t, !0),
f(this._heap, this._padMaxChunkLen)
}
先执行 _initState 初始化,然后在循环里调 _coreCall 算哈希。悬停 this._initState,跳转进去:
e.prototype._initState = function(e, t) {
this._offset = 0;
var r = new Int32Array(e, t + 320, 5);
r[0] = 0x67452301,
r[1] = -0x10325477, // 无符号 = 0xEFCDAB89
r[2] = -0x67452302, // 无符号 = 0x98BADCFE
r[3] = 0x10325476,
r[4] = -0x3c2d1e10 // 无符号 = 0xC3D2E1F0
}
这五个数就是铁证 🔍 每种哈希算法都有自己固定的一套初始向量,相当于它的"身份证"。MD5 是 4 个常量,SHA-256 是 8 个,而 SHA-1 标准定义的五个初始值(H0~H4)正好就是 0x67452301、0xEFCDAB89、0x98BADCFE、0x10325476、0xC3D2E1F0——跟代码里的一模一样。看到这五个数,可以 100% 确定底层用的是 SHA-1。那 _coreCall 就是 SHA-1 的核心轮函数了。
SHA-1 是一种哈希算法(也叫摘要算法),输入任意字符串,输出固定 40 位的十六进制串。特点是不可逆、同样输入永远得到同样输出。对于逆向来说,不需要搞懂里面的数学原理,知道它是公开标准、Python 的 hashlib.sha1() 就能复现就行了。来龙去脉理清楚:参数排序 → 序列化 → SHA-1 → 外层再处理 → 最终 32 位 sign。
六、Python 验证
在刚才的追踪中,n.sync 调的是 SHA-1——但我们最初看到的 sign 是 32 位的。SHA-1 输出是 40 位。说明外面还包了一层处理。
用刚还原的算法验证一下:
import hashlib
def generate_sign(params: dict) -> str:
keys = sorted(params.keys())
raw = '&'.join([f"{k}={params[k]}" for k in keys])
sha1 = hashlib.sha1(raw.encode()).hexdigest()
# 外层还有一层 MD5
return hashlib.md5(sha1.encode()).hexdigest()
# 验证接口1
params1 = {"app": "CailianpressWeb", "name": "telegraph", "os": "web", "sv": "8.7.9"}
sign1 = generate_sign(params1)
print(sign1) # 6c73b056a64891cdc257dcf1914464ad ?
# 验证接口2
params2 = {"app": "CailianpressWeb", "lastTime": "1783429674", "name": "telegraphList", "os": "web", "sv": "8.7.9"}
sign2 = generate_sign(params2)
print(sign2) # 99475d71077718c605bd491e2c4c22d0 ?
跑一下,两个 sign 全部对上,跟浏览器里的一模一样。🎉
接口1: 6c73b056a64891cdc257dcf1914464ad ✅
接口2: 99475d71077718c605bd491e2c4c22d0 ✅
最终的签名算法就是:参数按 key 排序 → 序列化成 key=value&key=value → SHA-1 → MD5 → sign
七、完整代码实现
import hashlib
import requests
def generate_sign(params: dict) -> str:
keys = sorted(params.keys())
raw = '&'.join([f"{k}={params[k]}" for k in keys])
sha1 = hashlib.sha1(raw.encode()).hexdigest()
return hashlib.md5(sha1.encode()).hexdigest()
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
"Referer": "https://www.cls.cn/"
}
params1 = {"app": "CailianpressWeb", "name": "telegraph", "os": "web", "sv": "8.7.9"}
params1["sign"] = generate_sign(params1)
resp1 = requests.get("https://www.cls.cn/api/cache", params=params1, headers=headers)
data1 = resp1.json()
if data1.get("errno") == 0:
print(f"✅ 成功!共 {len(data1['data']['roll_data'])} 条电报")
for item in data1['data']['roll_data'][:3]:
print(f" · {(item.get('brief') or item.get('content', ''))[:60]}...")
params2 = {"app": "CailianpressWeb", "lastTime": "1783429674", "name": "telegraphList", "os": "web", "sv": "8.7.9"}
params2["sign"] = generate_sign(params2)
resp2 = requests.get("https://www.cls.cn/api/cache", params=params2, headers=headers)
data2 = resp2.json()
if data2.get("errno") == 0:
print(f"✅ 成功!共 {len(data2['data']['roll_data'])} 条新闻")
for item in data2['data']['roll_data'][:3]:
print(f" · {item.get('title') or '(快讯)'}: {(item.get('brief') or item.get('content', ''))[:60]}...")
运行结果:
✅ 成功!共 20 条电报
· 财联社7月7日电,闪迪跌幅扩大至10%,总市值报2315亿美元。...
· 财联社7月7日电,欧盟官方数据显示,截至6月30日,2025/26年度...
· 财联社7月7日电,欧洲债券跌势延续,德国两年期国债收益率上升...
✅ 成功!共 20 条新闻
· 美股光通信板块盘初集体下跌 Coherent跌近6%: 【美股光通信板块...
· (快讯): 财联社7月7日电,美股碳化硅龙头Wolfspeed盘初走低...
· (快讯): 财联社7月7日电,美银首次覆盖SpaceX,给予"买入"评级...
Node.js 版本也一样:
const crypto = require('crypto');
function generateSign(params) {
const keys = Object.keys(params).sort();
const raw = keys.map(k => `${k}=${params[k]}`).join('&');
const sha1 = crypto.createHash('sha1').update(raw).digest('hex');
return crypto.createHash('md5').update(sha1).digest('hex');
}
const params = {
app: 'CailianpressWeb', name: 'telegraph',
os: 'web', sv: '8.7.9'
};
params.sign = generateSign(params);
console.log(params.sign);
// 6c73b056a64891cdc257dcf1914464ad
🎉 到这里,第二课的核心内容就完了。你不仅还原了财联社的签名算法,更关键的是掌握了 一套通用的签名逆向方法论:
- 发现签名 → 搜索关键词定位可疑代码
- 多处调用的函数,打断点确认走哪个分支
- 压缩代码里悬停蓝色链接一路跳转
- 悬停是 null 就打断点,放行后再悬停
- 一路追到哈希算法的初始化常量,一锤定音
这一套思路,任何有签名验证的网站都适用。
下一篇预告:《JS逆向入门第三课:JS Hook 原理与实战》——当网站把加密逻辑藏得特别深,或者代码动态生成导致你搜都搜不到的时候,Hook 是你最后的武器。
⚠️ 免责声明:本文介绍的技术是前端开发和安全研究的通用技能,广泛应用于性能分析、Bug 排查和合法安全测试。请勿将其用于非法入侵、未授权数据抓取或其他违反法律法规的行为。