企查查Web接口签名逆向实战
企查查 Web 接口签名逆向实战
抓包时会发现:企查查搜索、翻页请求除了常规请求头之外,还带着一对很奇怪的头——键是一串 20 位的十六进制串,值是一长串 128 位的十六进制串,而且这个键名每次接口都不一样,是连键名都算出来的。更关键的是:用相同关键词、翻相同页码,这个值是固定的。说明它不是时间戳类随机数,而是由请求内容决定的确定性签名。本文记录把它完整还原出来的全过程。
一、先看现象:这串密文是什么算法
翻页和点击搜索框都会触发请求,除请求载荷外携带的请求头基本一致。逐个对比后发现:整个请求里只有一个加密参数,键和值都被加密。
进一步观察:相同关键词、相同页码下,加密参数的值是相同的。这说明它是确定性签名——只要输入确定,输出就确定,这给了我们复现的依据。

测量密文长度:键 20 位 hex、值 128 位 hex,由同一个签名函数一次产出(键 20 位 + 值 128 位)。
128 hex = 64 字节 = 512 bit,对应 SHA-512 家族(MD5=32 hex、SHA-256=64 hex、SHA-512=128 hex)。
初步判断:这是 HMAC-SHA512——一个明文、一个密钥,产出 128 位签名。先有这个方向,后面找代码就有靶子。

二、hook 请求头:把加密函数逼出来
加密参数最终一定要写进请求头发出去,而浏览器里设置请求头只有 setRequestHeader 这一条物理路径,躲不掉。hook 它,谁写了请求头,调用栈就指向谁。hook 脚本如下:
(function () {
const oOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (m, u) {
this.__url = String(u);
return oOpen.apply(this, arguments);
};
const oSRH = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.setRequestHeader = function (k, v) {
if (this.__url && this.__url.includes('/api/')) {
console.trace('[HEADER]', this.__url.split('?')[0], '|', k, '=', v);
}
return oSRH.call(this, k, v);
};
console.log('%c钩子已装,搜索一次触发请求', 'color:#0a0');
})();
加密头必经 setRequestHeader,所以 hook 请求头就能定位到加密参数所在的调用栈。


三、顺着调用栈往上挖
读栈的核心:签名发生在"生成"和"写入"之间。从写入点往上找第一帧"有加密逻辑"的代码——从栈顶往下逐个看函数体,哪个有加密特征,哪个就是签名函数。
先进入函数 S 打个断点,继续运行几次观察。

观察发现 Object.prototype.hasOwnProperty.call(t, i) && n.call(null, t[i], i, t) 只是在遍历,并不参与加密,取消断点继续往下找。
接着进入 Promise.then 里的 f.request。为什么要进这里?现代 SPA(Vue/React)里直接全局搜索 x-sign 或 SHA512,往往会因为 Webpack 打包混淆和 Promise 异步流程而碰壁。Promise.then 是微任务,不进 f.request 就不知道请求是哪段业务代码发起的,也穿不透混淆。
进入 f.request 后,观察右侧 Scope 面板里的 config 对象(即请求配置 e),发现此时请求头里还没有加密参数——说明加密逻辑在 f.request 内部或其后的异步链路(axios 拦截器、transformRequest 或网络适配器)里。

断点打在 o = o.then(l.shift(), l.shift()) 这一行:o 是当前 Promise;两次 l.shift() 分别取出成功回调和失败回调挂到 o 上。l 数组里存的就是一连串异步处理函数——请求拦截器、transformRequest 回调或业务逻辑。
最直接的办法:给 l 里每个函数都打上断点,刷新触发请求,看最终断在哪。

打桩后先取消 Promise 断点,方便刷新触发请求。现在触发请求——

断点落在函数 m,正是一直在找的请求拦截器。它做了四件事:
1) e.headers["X-Requested-With"] = "XMLHttpRequest" —— 传统 Ajax 标志,后端靠它判断请求是否由浏览器发起,很多网站第一道风控就是拦没有这个头的请求。
2) e.headers["x-function-name"] = encodeURIComponent(...) —— 业务模块标识,服务端据此区分你在调哪个接口,做业务级风控。
3) e.headers["x-pid"] = window.pid —— 每次刷新页面 window.pid 都会重新赋值,服务端会校验它是否与会话匹配,不匹配直接 403。
4) v(e)(核心)—— 这才是真正的签名函数。在 v 上断点,重新触发请求。
函数 v 就在 m 上方,看到 e.headers[i] = r 基本能确定:i 是加密参数的键,r 是值。

再次触发请求进入 v——

果然 i 就是键、r 就是值。
四、还原签名函数 v
把函数 v 整个扒下来:
v = function(e) {
var t = ("/api/" + e.url.replace(e.baseURL, "")).replace("//", "/")
, n = f().stringify(e.params || {});
n && (t += (-1 === t.indexOf("?") ? "?" : f().options.delimiter || "&") + n);
var i = function() {
var e = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : {}
, t = (arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : "/").toLowerCase()
, n = JSON.stringify(e).toLowerCase();
return o(t + n, h(t)).toLowerCase().substr(8, 20)
}(t = t.toLowerCase(), e.data)
, r = function() {
var e = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : {}
, t = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : ""
, n = (arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : "/").toLowerCase()
, i = JSON.stringify(e).toLowerCase();
return o(n + "pathString" + i + t, h(n))
}(t, e.data, (0,
d.A)());
e.headers[i] = r
}
这段压缩代码结构清晰后只做五步:拼路径 → 归一化 → 算键名 → 算键值 → 写回头。
为了看清传入参数,在 e.headers[i] = r 之前打印 e:

e.url: "search/searchRecommendation"
e.baseURL: "https://www.qcc.com/api"
e.params: {searchKey: '网络', type: 1}
e.data: undefined(因为是 GET 请求,没有 body 数据) 后续要爬取的数据接口是post请求 补上body即可
e.headers: 现有的请求头对象(包含 X-Requested-With 等)
核心执行步骤:
步骤1 拼路径:url 拼上固定前缀 /api/,用 qs 序列化 params 接到末尾,形成完整 API 路径。
步骤2 归一化:整条路径转小写;请求体 JSON.stringify 后也转小写(无 body 时视为 {})。
步骤3 算键名:把"小写路径 + 小写 body"喂给哈希函数 o,密钥用 h,结果转小写后从第 8 位截 20 个字符,作为请求头键名。
步骤4 算键值:把"小写路径 + 固定串 pathString + 小写 body + 随机数 d.A()"喂给同一个 o 和 h,得到完整 128 位签名。
步骤5 写头:e.headers[i] = r,把动态键值对塞进请求头随请求发出。
五、拆三个零件:o、h、d.A()
哈希入口 o 长这样:
var i = n(949788)
, r = n.n(i)
, o = function(e, t) {
return r()(e, t).toString()
}这是核心哈希算法的入口。e 是待签名的明文字符串,t 是密钥(盐)。它调用底层加密库 r(),传入明文和密钥,输出标准的哈希字符串(大概率是 HMAC-MD5 或 HMAC-SHA1)。同时找到949788的函数:
点进 Webpack 模块 949788:
949788: function(t, e, n) {
var r, o, i;
"undefined" != typeof globalThis ? globalThis : "undefined" != typeof self && self,
o = [n(515823)],
r = function(a) {
var s = n(630501);
a = s(a),
function(s, u) {
"object" === (0,
a.default)(e) ? t.exports = e = u(n(538933), n(293776), n(585580), n(373257)) : (o = [n(538933), n(293776), n(585580), n(373257)],
void 0 === (i = "function" == typeof (r = u) ? r.apply(e, o) : r) || (t.exports = i))
}(0, function(t) {
return t.HmacSHA512
})
}
,
void 0 === (i = "function" == typeof r ? r.apply(e, o) : r) || (t.exports = i)
}
关键一行 return t.HmacSHA512。所以结论很干净:o(明文, 密钥) ≡ CryptoJS.HmacSHA512(明文, 密钥).toString(),用密钥 t 对明文 e 做 HMAC-SHA512,输出标准 128 位十六进制串。
密钥派生函数 h:
var l = 20
, u = c
, h = function() {
for (var e = (arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : "/").toLowerCase(), t = e + e, n = "", i = 0; i < t.length; ++i) {
var r = t[i].charCodeAt() % l;
n += u[r]
}
return n
}
查表数组 c:
var c = {};
a().extend(c, { 0: "W" });
a().extend(c, { 1: "l" });
a().extend(c, { 2: "k" });
// ... 中间略 ...
a().extend(c, { 19: "8" });
c = ["W", "l", "k", "B", "Q", "g", "f", "i", "i", "r", "v", "6", "A", "K", "N", "k", "4", "L", "1", "8"]
密钥按路径动态生成:路径小写后重复两遍,逐字符取 charCode % 20 查上表拼接。路径不同,密钥就不同。
最后一个零件 d.A():
__webpack_exports__.A = function() { var list = ["w", "i", "n", "d", "o", "w", ".", "t", "i", "d"]; return eval(list.join("")) }
它把 ["w","i","n","d","o","w",".","t","i","d"] join 后 eval,本质就是 return window.tid,从全局对象上取 tid 作为防重放随机因子(nonce)。
顺带区分两个容易混的全局量:window.pid 放在请求头 x-pid,每次刷新都变;window.tid 参与签名,会话内不变。tid 不变恰恰说明它是会话级标识——服务端在会话里存着它,验签时才能取出来比对。
六、一个必须提醒的坑:变量遮蔽
读压缩代码最容易翻车的点:外层和函数内部的 t、e、i 根本不是同一个变量。比如函数 r 内部:
var t = arguments[2] || ""; // 内部的 t 其实是 tid,不是路径!
var n = arguments[0].toLowerCase(); // 内部的 n 才是路径
var i = JSON.stringify(e).toLowerCase(); // 内部的 i 才是 body
所以读压缩代码的铁律是:别看变量名猜含义,看 arguments[i] 的实参映射。一旦搞反,签名公式就全错。理顺后,函数 r 的真实身份是:
r = function(路径参数, 请求体参数) { // arguments[0]=路径, [1]=body, [2]=tid
var bodyObj = 请求体参数 || {} // 原: e
, tidVal = arguments[2] || "" // 原: t ← 这里才是 tid
, pathLow = 路径参数.toLowerCase() // 原: n ← 这里才是路径
, dataLow = JSON.stringify(bodyObj).toLowerCase(); // 原: i
return HMAC512(pathLow + "pathString" + dataLow + tidVal, deriveKey(pathLow))
}(拼好的路径, e.data, window.tid)
而函数 i(算键名)的内部变量映射又和 r 完全相反——内部 t 是路径,内部 n 是 body。两个 IIFE 理顺后,最终公式就是:
键名 = HMAC-SHA512(小写路径 + 小写body, 密钥).hex 取 [8:28]
键值 = HMAC-SHA512(小写路径 + "pathString" + 小写body + tid, 密钥).hex(完整128位)
密钥 = 由小写路径经 h() 派生
七、算法定稿
把整条链路浓缩一下:
① 路径:t = "/api/" + url 去掉 baseURL 后补的部分,有 params 就接 "?" + qs.stringify(params),整体 toLowerCase()
② 密钥:h(t) = 对 (t+t) 逐字符 CODES[charCodeAt % 20] 拼接
③ 键:HMAC-SHA512(key, t + JSON.stringify(data||{}).toLowerCase()).hex[8:28]
④ 值:HMAC-SHA512(key, t + "pathString" + JSON.stringify(data||{}).toLowerCase() + window.tid).hex
密钥就是 h(n),前面已经分析过——传入的 n 是路径,所以密钥很容易构造。现在只剩找 tid,它一般挂在前端全局变量上:

刷新页面、搜索其他关键词观察:pid 随机变化,它就是请求头里的 x-pid;tid 不变,是会话级标识。所以发请求前先取好 pid 和 tid。
八、Python 复现
算法全部明确后,就可以用 Python 独立复现了:
import re
import sys
import json
import hmac
import hashlib
import requests
from urllib.parse import quote
UA = ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36')
# ★★ 登录后从浏览器复制的 Cookie 整段粘在下面(F12 → Network → 请求头 Cookie 那一行)★★
COOKIE_RAW = """qcc_did=fee18a33-260a-40b7-9d0e-3b75462d6a12; UM_distinctid=19fd71995bd26-0a2b81285102d2-1e462c69-3e8000-19fd71995beea1; _c_WBKFRo=6hrs2AxzZpStp36ZXfBStx90ZlmfVoLOgmSiKkso; QCCSESSID=0d508b60a66404b8c2e5e2f34c; acw_tc=76b20f6b17892161894563114eb4b2530e6791f7419ce638f473984addda15; CNZZDATA1254842228=338090597-1786020206-https%253A%252F%252Fwww.qcc.com%252F%7C1789216195
"""
# ======================== ① 基础工具 ========================
def parse_cookie(raw: str) -> dict:
"""'a=1; b=2' → {'a': '1', 'b': '2'}"""
return {k.strip(): v.strip()
for k, v in (p.split('=', 1) for p in raw.split(';') if '=' in p)}
def load_tid_pid(sess: requests.Session, keyword: str = '') -> tuple:
"""首页优先提取 window.tid / window.pid(tid 会话级、全站一致)。
返回 (tid, pid);失败抛异常并说明原因。"""
for url in ['https://www.qcc.com/'] + (
[f'https://www.qcc.com/web/search?key={quote(keyword)}'] if keyword else []):
html = sess.get(url, timeout=20).text
if 'aliyun_waf' in html or 'acw_sc__v2' in html:
print(f'[!] {url} 被 WAF 拦截,换下一个入口')
continue
m_tid = re.search(r'window\.tid\s*=\s*["\']([a-f0-9]+)', html)
if not m_tid:
print(f'[!] {url} 未见 window.tid(HTML {len(html)} 字符),换下一个入口')
continue
m_pid = re.search(r'window\.pid\s*=\s*["\']([a-f0-9]+)', html)
tid, pid = m_tid.group(1), (m_pid.group(1) if m_pid else None)
print(f'[+] 提取入口: {url}')
print(f'[+] tid = {tid}')
print(f'[+] pid = {pid}')
return tid, pid
raise RuntimeError('未取到 tid:请先在浏览器过 WAF / 登录,并更新 COOKIE_RAW')
# ======================== ③ 签名算法 ========================
CODES = ["W", "l", "k", "B", "Q", "g", "f", "i", "i", "r",
"v", "6", "A", "K", "N", "k", "4", "L", "1", "8"]
def derive_key(path: str) -> str:
"""密钥:路径小写重复两遍,逐字符 ord % 20 查 CODES 表"""
return ''.join(CODES[ord(c) % 20] for c in path.lower() * 2)
def sign_post(path: str, body: dict, tid: str) -> tuple:
"""返回 (动态请求头名, 动态请求头值)
键 = HMAC512(路径小写 + body小写, key).hex[8:28]
值 = HMAC512(路径小写 + 'pathString' + body小写 + tid, key).hex
"""
t = path.lower()
ds = json.dumps(body, separators=(',', ':'), ensure_ascii=False).lower()
key = derive_key(t).encode()
name = hmac.new(key, (t + ds).encode(), hashlib.sha512).hexdigest()[8:28]
value = hmac.new(key, (t + 'pathString' + ds + tid).encode(), hashlib.sha512).hexdigest()
return name, value
# ======================== ④⑤ 发请求 ========================
def search(sess: requests.Session, keyword: str, page: int,
tid: str, pid: str, size: int = 20) -> dict:
"""搜索/翻页:每次重算签名"""
body = {'searchKey': keyword, 'pageIndex': page, 'pageSize': size}
name, value = sign_post('/api/search/searchMulti', body, tid)
headers = {
'accept': 'application/json, text/plain, */*',
'accept-language': 'zh-CN,zh;q=0.9',
'content-type': 'application/json',
'origin': 'https://www.qcc.com',
'referer': f'https://www.qcc.com/web/search?key={quote(keyword)}&p={page}',
'x-requested-with': 'XMLHttpRequest',
name: value, # ★ 动态头名(每页不同)
}
if pid:
headers['x-pid'] = pid
payload = json.dumps(body, separators=(',', ':'), ensure_ascii=False)
r = sess.post('https://www.qcc.com/api/search/searchMulti',
headers=headers, data=payload.encode('utf-8'), timeout=20)
if 'json' not in r.headers.get('content-type', ''):
print(f'[!] 非 JSON 响应: HTTP {r.status_code} | {r.text[:200]}')
return {}
j = r.json()
if j.get('status') == 409 or r.status_code == 209:
print(f'[!] 需要登录: {j.get("message")} → 更新 COOKIE_RAW')
return j
# ======================== 自测(不联网)========================
def selftest() -> bool:
name, value = sign_post('/api/search/searchMulti',
{'searchKey': '科技', 'pageIndex': 2, 'pageSize': 20},
'54abcc89d5a947f93d6f5bac3e5e71db')
ok1 = name == 'bfca01659a2ffbdf651b'
ok2 = value == ('ad36c5b3ce8bb6aadaac2964aa1cdedeef059d394f8e567113d5baa03c853c0'
'3534e66474a7046870dd0e02f1a570e5df932761df230d1eed4eca11fa34a80f4')
print('键:', name, '|', '✅' if ok1 else '❌')
print('值:', value[:40] + '...', '|', '✅' if ok2 else '❌')
print('自测:', '通过 ✅' if ok1 and ok2 else '失败 ❌')
return ok1 and ok2
# ======================== 主流程 ========================
def main():
args = [a for a in sys.argv[1:] if not a.startswith('--')]
keyword = args[0] if args else '科技'
rng = args[1] if len(args) > 1 else '1'
p1, p2 = (int(x) for x in rng.split('-')) if '-' in rng else (int(rng), int(rng))
# 会话
sess = requests.Session()
sess.headers.update({'user-agent': UA})
ck = parse_cookie(COOKIE_RAW)
if ck:
sess.cookies.update(ck)
print(f'[+] 使用手动 cookie({len(ck)} 项)')
else:
print('[i] 未提供 cookie,未登录会被拒绝')
tid, pid = load_tid_pid(sess, keyword)
for page in range(p1, p2 + 1):
print(f'\n===== 第 {page} 页 =====')
j = search(sess, keyword, page, tid, pid)
items = j.get('Result') or []
print('Status:', j.get('Status'), '| 条数:', len(items))
for it in items:
nm = re.sub(r'<.*?>', '', it.get('Name', ''))
print(f" - {nm} | {it.get('OperName')} | {it.get('RegistCapi')} | {it.get('KeyNo')}")
if __name__ == '__main__':
if '--selftest' in sys.argv:
sys.exit(0 if selftest() else 1)
main()
注意事项:由于博客排版格式限制,有部分代码缩进需要使用者自行更正。

运行后成功拿到搜索数据,说明加密参数构造正确。
小结:整个逆向的关键路径只有三步——hook setRequestHeader 拿到写入点和调用栈;在 Promise 拦截器链里用打桩断点定位到签名函数 v;最后靠 arguments 实参映射拆穿变量遮蔽,把 HMAC-SHA512 公式还原出来。这类动态请求头是中大型站点常见的风控手段,但只要前端算得出,就一定能被复现。
使用提示:本文仅用于学习研究前端加密与爬虫原理,实际使用请遵守目标站点服务条款与 robots 协议,控制请求频率,不要对站点造成压力。
再次声明:本文所有内容仅面向合法合规的技术交流与学习研究,不允许任何主体将本文内容用于商业项目、非法爬虫活动等任何未经授权的场景。否则,一切责任由使用者自行承担。