Boss直聘 zp_stoken 完整逆向分析:从抓包到本地计算
▎ 本文仅供学习研究,了解反爬机制原理,请勿用于违法用途。
前言
做爬虫的同学应该都遇到过 Boss直聘的反爬,随便请求一个接口就返回:
{ "code": 37, "message": "请求异常", "zpData": { "seed": "YoNJFI7rB9A7cD8HJo4ORVUTaSPf+t2i...", "ts": 1722000000000, "name": "000d2cfa" } }
code=37 意味着你需要计算出一个 zp_stoken 放进 Cookie,才能继续请求。
网上的解决方案大多是:
- 用 Selenium/Playwright 保持浏览器 → 太重
- RPC 调用浏览器 JS → 依赖浏览器进程
- 直接买现成服务 → 花钱且不稳定
本文带你从零分析这个参数的生成逻辑,最终实现纯本地计算,无需浏览器。
一、抓包定位
打开 Chrome DevTools,Network 面板过滤 security-js,触发一次 code=37 后你会看到:
GET www.zhipin.com/web/common/…
这个 JS 文件就是加密逻辑的核心,文件名(000d2cfa)随时会变,每次 code=37 响应里的 name 字段会告诉你最新的文件名。
下载这个 JS,先看一眼结构——混淆得很厉害,但关键是找到入口函数:
// 混淆后的样子 (function(_0x1a2b3c) { // ...几千行混淆代码 })()
在 Console 里执行:
// 找到 ABC 构造函数 (new window.ABC).z(seed, ts)
有返回值,说明入口找到了。
二、算法分析
zp_stoken 的格式固定为:
b073f + base64(加密数据)
整个生成过程分三部分:
2.1 P 部分(Token 编码)
把 seed(服务器下发)、盐值 b073、时间戳按特定规则编码成字节数组:
def get_P(token, salt, ts, start, end, obj): list_ = [] list_.append(len(salt)) list_.extend(list(salt.encode())) list_.append(len(token)) list_.extend(list(token.encode())) # 时间戳用 varint 编码 timestamp_val = ts // 1000 func_6565(timestamp_val, list_) # LCG 随机数 random_cal = deal_random_s(start, end, obj) func_6565(random_cal, list_) # 奇偶索引分别加 i 或 2i result_list = [] for i in range(len(list_)): if i % 2 == 0: result_list.append(list_[i] + i) else: result_list.append(list_[i] + 2 * i) return result_list
2.2 V 部分(环境指纹)
这部分模拟浏览器环境指纹,包含:
- UA 哈希:对 UserAgent 字符串做类 CRC 计算,取低 16 位
- 屏幕参数:固定值 [0, 0, 0, 0, 1536, 824, 1536, 824, 1536, 864, 0, 8, 0, 0]
- 魔改 SHA1:对 UA 数据做签名,用的是改过参数的 SHA1 变体
关键的 UA 哈希函数:
def deal_UserAgent(ua: str): index_list = [] init_operator_val = unsigned_to_signed(unsigned_right_shift(-1, 8)) init = -1 for i in range(256): # 预计算 CRC 查找表 init_val = None val = unsigned_right_shift(i, 1) for j in range(7): if i & 1 == 0: i = val val = unsigned_right_shift(i, 1) else: if init_val is None: init_val = unsigned_to_signed(3988292384) ^ val flag = init_val & 1 if flag == 0: init_val = unsigned_right_shift(init_val, 1) else: init_val = unsigned_to_signed(3988292384) ^ unsigned_right_shift(init_val, 1) if init_val is None: init_val = 0 index_list.append(init_val) # 对 UA 字符串做 CRC 运算 for char in ua: list_index = (init ^ ord(char)) & 255 init = init_operator_val ^ index_list[list_index] init_operator_val = unsigned_right_shift(init, 8) return unsigned_right_shift(unsigned_to_signed(init ^ -1), 0) & 65535
2.3 随机数生成(LCG)
Boss直聘用的是线性同余生成器(LCG),初始种子基于时间戳:
def get_first_s(): ts = int(time.time() * 1000) return math.floor(ts / 1000) + 9138
def deal_random_s(start, end, obj): w = 1664525 * obj['s'] g = w + 1013904223 k = g % 4294967296 obj['s'] = k S = obj['s'] & 2147483647 E = S / 2147483647 filed = end - start res = math.floor(filed * E) + start return res
三、最终编码
把 P 和 V 拼接后,经过 XOR 混淆 + 变换 + UTF-8 样编码:
def get_J(list_: list, encode_list: list): """XOR 混淆""" c = [] M_len = len(list_) for i in range(M_len): U_index = i % len(encode_list) val = (list_[i] + 3) ^ encode_list[U_index] c.append(val) return c
def get_ll(U): """字节变换:XOR → 翻转 → XOR""" len_ = len(U) for i in range(len_): U[i] = U[i] ^ 3 U.reverse() for j in range(len_): U[j] = U[j] ^ 7 for k in range(len_): U[k] = U[k] ^ 7 for i in range(len_): U[i] = U[i] ^ 3 return U
最终输出:
def main(token_, ts, user_agent): xklm = {'s': get_first_s()} salt = 'b073' sha1Salt = 'zzzp' updateVal = '14669' EnvList = [55, 55, 55, 55, 55, 53, 53, 53, 53, 53, 48, 48, 55, 55, 55, 53, 53, 53, 53, 53, 53, 53, 53, 53, 53] screenArgs = [0, 0, 0, 0, 1536, 824, 1536, 824, 1536, 864, 0, 8, 0, 0] getPropertyDescriptorList = [129, 24, 52, 129, 42, 52, 129, 122, 52, 114, 52, 73, 52]
P = get_P(token_, salt, int(ts), 0, 100, xklm)
encode_res_list = []
array_concat(encode_res_list, P)
V = get_V(EnvList, user_agent, 'www.zhipin.com', '',
sha1Salt, screenArgs, getPropertyDescriptorList, updateVal, xklm)
array_concat(encode_res_list, V)
G = get_G(xklm)
A = get_A()
Z = G + A
X = str(Z)
Y_list = []
func_6565(Z, Y_list)
J = get_J(encode_res_list, list(X.encode()))
Y_list.extend(J)
ll = get_ll(Y_list)
pl = get_pl(ll)
base64_val = binascii.b2a_base64(bytes(pl)).decode()
return 'b073f' + base64_val
四、实际使用
处理 code=37 响应的完整流程:
import requests
session = requests.Session()
def fetch_with_stoken(url, params, cookies): headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0", "Accept": "application/json, text/plain, /", "Referer": "www.zhipin.com/", } resp = session.get(url, params=params, headers=headers, cookies=cookies, timeout=20) data = resp.json()
if data.get("code") == 37:
zp = data["zpData"]
seed, ts, name = zp["seed"], zp["ts"], zp["name"]
# 用 iv8 加载官方 JS 计算(最稳定)
token = compute_stoken_iv8(seed, ts, name)
cookies["__zp_stoken__"] = token
cookies["bst"] = token
# 重试
resp = session.get(url, params=params, headers=headers,
cookies=cookies, timeout=20)
data = resp.json()
return data
五、三种方案对比
方案 │ 稳定性 │ 依赖 │ 推荐度
iv8 本地执行 │ ★★★★★ │ pip install iv8 │ ⭐⭐⭐ 首选
纯 Python 实现 │ ★★★☆☆ │ 无 │ ⭐⭐ 备用(算法更新会失效)
RPC 调用浏览器 │ ★★★★☆ │ 需要浏览器进程 │ ⭐ 重量级
iv8 方案最推荐:直接加载官方 JS 在本地沙箱执行,算法更新后自动适配,无需逆向维护。
六、注意事项
- EnvList 和 updateVal 会更新:纯 Python 方案需要定期抓包校准这些常量
- wt2 Cookie 必须有效:code=37 之外还有 code=32(账号风控),需要多账号轮换
- 请求频率控制:翻页间隔建议 15-30 秒,不要暴力请求
总结
│ 参数 │ 说明
│ 格式 │ b073f + base64
│ 触发 │ 服务器下发 code=37 + seed/ts/name
│ 核心算法 │ LCG 随机数 + 魔改 SHA1 + CRC UA哈希 + XOR 混淆
│ 最佳实践 │ iv8 加载官方 JS 本地计算
完整代码已开源:github.com/Yang-hua6/boss-
如果本文对你有帮助,欢迎点赞收藏,也欢迎给 GitHub 仓库点个 ⭐