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 搜 encryptpassword 这些关键词。但这是签名验证,不是加密——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帮忙拆解逻辑就行,不用硬啃。

三、打断点确认走哪条路

代码里有两处调了 osign: 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)正好就是 0x674523010xEFCDAB890x98BADCFE0x103254760xC3D2E1F0——跟代码里的一模一样。看到这五个数,可以 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 排查和合法安全测试。请勿将其用于非法入侵、未授权数据抓取或其他违反法律法规的行为。