百趣云 百趣云的博客

B站 wbi 签名:img_key 与 sub_key 混淆索引还原

wbi 的设计哲学:密钥动态化

B站 2023 年起大面积启用 wbi 签名,取代旧的 appkey+appsign 体系。它的设计思路和别家相反:算法完全公开(就是 md5),但密钥不在客户端写死,而是藏在接口返回的图片 URL 里,定期轮换。防护思路从"算法保密"转向"密钥时效",对逆向者反而友好了。

完整流程拆解

第一步:拿密钥原料。请求 https://api.bilibili.com/x/web-interface/nav(无需登录),返回:

{
  "data": {
    "wbi_img": {
      "img_url": "https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png",
      "sub_url": "https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png"
    }
  }
}

取两个 URL 的文件名去扩展名:img_key = 7cd084941338484aae1ad9425b84077csub_key = 4932caff0ff746eab6f01bf08b70ac45

第二步:混淆重排出真密钥。把 img_key + sub_key 拼接(64 字符),按一张固定的置换表重排,取前 32 位:

MIXIN_TABLE = [
    46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
    27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,
    37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22,
    25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52
]

def get_mixin_key(img_key, sub_key):
    raw = img_key + sub_key
    return ''.join(raw[i] for i in MIXIN_TABLE)[:32]

这张表写死在 B站前端 JS 里(搜 [46,47,18 能直接命中),自 wbi 上线以来没变过。

第三步:签名

import hashlib, time, urllib.parse

def wbi_sign(params: dict, mixin_key: str) -> dict:
    params = dict(params)
    params['wts'] = int(time.time())
    # 1. 按 key 字典序排序
    params = dict(sorted(params.items()))
    # 2. 参数值过滤 !'()* 五个字符(B站前端的特殊处理)
    filtered = {k: ''.join(c for c in str(v) if c not in "!'()*") for k, v in params.items()}
    # 3. urlencode 后拼密钥算 md5
    query = urllib.parse.urlencode(filtered)
    params['w_rid'] = hashlib.md5((query + mixin_key).encode()).hexdigest()
    return params

精髓:五个实测出来的坑

  1. 过滤字符这步不能省。B站前端在签名前会把参数值里的 !'()* 删掉,你不删,遇到含这些字符的参数(比如搜索关键词带括号)签名就对不上。这个细节藏在前端代码里,文档没有。
  2. urlencode 的编码方式要对。空格必须编码成 %20 而不是 +(Python 的 urlencode 默认用 quote_plus,要换 quote_via=urllib.parse.quote),中文按 UTF-8 百分号编码。编码方式不一致,含中文参数时 md5 必错。
  3. 密钥有有效期,别每次都拉。img_key/sub_key 会轮换(实测周期以天计),但每次都请求 nav 接口会留下扎眼的访问模式。正确做法:缓存 mixin_key,签名失败(返回 -352 风控码)时刷新重试一次。
  4. wts 与服务器时钟偏差容忍约 ±1 天,但建议本机校时,偏差大了返回签名错误。
  5. 部分接口还要 dm 系列参数。搜索等敏感接口除了 wbi 还校验 dm_img_listdm_img_strdm_cover_img_str 这几个——它们是 webgl/canvas 指纹的 base64 编码。可以传空值 dm_img_list=[],但传了就要传对,乱填比不填死得更快。

完整可用的封装

class WbiSigner:
    def __init__(self, session):
        self.session = session
        self.mixin_key = None
        self.key_time = 0

    def refresh_key(self):
        nav = self.session.get('https://api.bilibili.com/x/web-interface/nav').json()
        wbi = nav['data']['wbi_img']
        img_key = wbi['img_url'].rsplit('/', 1)[1].split('.')[0]
        sub_key = wbi['sub_url'].rsplit('/', 1)[1].split('.')[0]
        self.mixin_key = get_mixin_key(img_key, sub_key)
        self.key_time = time.time()

    def sign(self, params):
        if not self.mixin_key or time.time() - self.key_time > 3600 * 12:
            self.refresh_key()
        return wbi_sign(params, self.mixin_key)

总结

  1. wbi = 动态密钥(图片文件名)+ 固定置换表 + md5,算法零障碍
  2. 三个易错点:过滤 !'()*、空格编码 %20、key 排序
  3. 密钥缓存 12 小时,签名失败再刷新
  4. 敏感接口的 dm 指纹参数是第二道门,传空可过基础校验
By 百趣云 阅读量:1 On