浅度剖析B站的新 -352 风控策略

2024-01-16 Technical Salty Fish 1条

某日,PipePipe开始加载不出up主投稿列表,并且报错-352 风控校验失败。开发者根据社区讨论进行了几次修复,但效果都没有持续太久。由于前段时间比较忙,遂暂停使用PipePipe,改用电脑浏览器进行B站冲浪。

电脑浏览器虽然能够加载出up主页面,但投稿列表加载前会跳出登录提示框。拒绝登录后投稿列表就不会加载了。根据bilibili-API-collect项目的讨论,特殊UAMozilla/5.0可以绕过B站的新风控策略,猜测是B站开发者特意留作debug用途。我没有仔细研究,直接用插件将访问B站使用的UA改为Mozilla/5.0,投稿列表就可以加载出来了。

然而好景不长,这周这个“后门”UA失效了。为了持续收看心爱的up主的最新投稿,我决定把这个新的风控机制彻底解决一下。

逆向工程是一门很复杂的学问,初学者通常很难找到方法上手,因此本篇会讲得详细一些,希望能够帮助到对web逆向工程有兴趣的新手。

开始!

在继续使用后门UAMozilla/5.0的情况下,访问up主主页(形如https://space.bilibili.com/8047632)时会在加载内容之前跳出登录提示框(二维码处的条纹图案是因为我的日用浏览器LibreWolf禁用WebGL)

2024-01-21T23:52:56.png

拒绝登录之后就会出现如下错误提示,并且整个页面空白。

2024-01-21T23:54:39.png

那么老规矩,掏出我们的开发者工具切到网络tab上,筛选出所有XHR请求(本意指通过XMLHttpRequest发出的请求,实际上fetch请求也算在内,可以认为包含所有对后端API的AJAX请求)逐条看过来。B站的API没有使用标准HTTP错误码,而是在返回的json中定义自己的错误码体系,所以我们得点进Response里才能看到哪里出了问题。首先是两条对/x/kv-frontend/namespace/data返回-304的请求。

2024-01-21T23:55:16.png

这两个请求的参数看起来没什么意思,返回的message似乎也无关紧要。我们继续往下看:

2024-01-21T23:55:24.png

这条请求有一些不对劲。错误代码和PipePipe抓取投稿列表的时候的报错一致,那么我们来仔细看看这个请求。众所周知,HTTP请求由类型、端点、参数和headers构成,其中最重要的headers包括cookies和user-agent。首先看参数:

2024-01-21T23:55:33.png

w_ridwts是B站特有的WBI签名,mid是up主的用户ID(这里拿官方举个例子),此外似乎就没有什么有趣的信息了。那么问题大概率出在headers上。此时可以选择控制变量法来观察到底是哪个header触发了风控。右键一条请求可以把它以各种格式导出,比如可以导出为cURL命令:

2024-01-21T23:55:50.png

导出的内容可以直接放在命令行执行:

2024-01-21T23:55:58.png

可以看到返回了一样的结果-352

如果我们打开一个什么配置都没有的阳春Firefox,访问同样的页面,投稿列表则是能够成功加载的。那么我们可以用同样的方法找到同样的API请求,并且对比headers的不同之处,通过逐渐修改header的值向能够成功的请求靠近,并找出具体触发风控的header项。

最后我发现问题在于User Agent(……)如果使用一个常见浏览器的User Agent值,比如Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/118.0或者Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36,就可以过风控:

投稿列表接口的新风控机制

所以研究了半天,居然是之前设置的后门UA起到了反作用。解决方案也很简单:用同样的方法把UA设置成某个常见值就完事了。解决完这个小问题之后我们来研究投稿列表为什么加载不出。改完UA之后打开up主页面是这样的:

2024-01-21T23:56:54.png

老规矩,不登录:

2024-01-21T23:57:06.png

还是一样的方法挖API请求,可以看见这条:

2024-01-21T23:57:22.png
2024-01-21T23:57:30.png

相比之下,一个阳春Firefox发出的这条请求会返回的正是投稿列表:
2024-01-21T23:58:09.png

我们一样对这两条请求进行对比。需要注意的是每次修改请求参数都必须重新生成WBI签名,否则100%会触发-403 访问权限不足(修改header则不需要)。直接在命令行操作太麻烦了,bilibili-API-collect介绍WBI签名的文档有提供计算签名的Python脚本,可以拿来用。

最后得出结论如下:

  • Cookies无关紧要,包括里面的bvuid3bvuid4以及bili_ticket等,忽略这些cookie也可以。
  • User Agent必须是常见值。多一个字符都不行。
  • dm_img_list参数必须有,值用空列表即可,但是不能省略。
  • dm_img_strdm_cover_img_str也必须有。这两个参数我们接下来介绍。

用来发出成功请求的最小Python示例是这样的:

import wbi # wbi.py内容修改自bilibili-API-collect的例子
import requests
import json
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36"}
def get_videos(mid):
    endpoint = "https://api.bilibili.com/x/space/wbi/arc/search"
    params = {
        "mid": mid,
        "dm_img_list": "[]",
        "dm_img_str": "V2ViR0wgMS",
        "dm_cover_img_str": "SW50ZWwoUikgSEQgR3JhcGhpY3NJbnRlbA",
    }
    params = wbi.sign(params)
    return json.loads(requests.get(endpoint, params, headers=headers).text)

WebGL指纹

现在我们来看dm_img_strdm_cover_img_str这两个参数。以上脚本使用的值是阳春Firefox发出的请求里截取出来的。应该不难想到这两串东西可能是base64编码,因此我们可以尝试base64解码:
2024-01-21T23:58:25.png

虽然报错invalid input,但是这个报错无关痛痒,我们还是得到了一些信息。这是因为base64编码的方式是将8bit的字符三个一组连成24个bit,对于每6个bit进行查表翻译(不足6bit的部分用0补全),最后用=补到4的整数倍长度。这个字符串长度为10,可见B站前端逻辑裁掉了最后两个字符。

另外一串dm_cover_img_str解码出来则是Intel(R) HD GraphicsIntel。看到这个格式我们应该就知道这是什么了。为了确认,我们可以再试试Chromium,解码得到的分别是WebGL 1.0 (OpenGL ES 2.0 Chromium)ANGLE (Intel, Mesa Intel(R) Graphics (ADL GT2), OpenGL 4.6)Google Inc. (Inte。很显然,这两个分别是WebGL的版本和渲染引擎的相关信息,可以用以下JS代码获取:

gl = document.createElement('canvas').getContext('webgl');
version = gl.getParameter(gl.VERSION)
ext = gl.getExtension('WEBGL_debug_renderer_info');
vendor = gl.getParameter(ext.UNMASKED_VENDOR_WEBGL);
renderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL);
console.log(`${version} | ${renderer} | ${vendor}`); 

Firefox输出:WebGL 1.0 | Intel(R) HD Graphics | Intel

Chromium输出:WebGL 1.0 (OpenGL ES 2.0 Chromium) | ANGLE (Intel, Mesa Intel(R) Graphics (ADL GT2), OpenGL 4.6) | Google Inc. (Intel)

而在禁用了WebGL的LibreWolf上,第二行就报错了,因为不存在WebGL context,gl的值为null。这时B站前端使用的dm_img_strdm_cover_img_str都为bm8gd2ViZ2,经base64解码为no webg,推测应该是no webgl

到这里,思路已经非常清晰了。我们可以推断B站做了这几件事:

  1. 尝试获取WebGL版本、vendor以及renderer。如果失败,使用缺省值no webgl
  2. 把renderer和vendor连起来,使用base64编码作为dm_cover_img_str
  3. 把版本号用base64编码作为dm_img_str
  4. 在请求search端点的时候带上这两个参数,以及空的dm_img_list
  5. 后端进行解码后,no webgl或者非常见值将触发风控返回-352

还记得B站对User Agent的风控策略吗?这里看来B站的工程师采用了相同的思路,即对一些常见浏览器的WebGL指纹予以白名单放行。

看源码

有了这个猜想,大致的应对方法就有了。在我们开始实现之前,最好能把相关JS源码挖出来确认一下。这时我们可以利用Dev Tools的Stack Trace功能。找到对应的请求,切到Stack Trace页面,就可以看到发起请求的函数调用栈:

2024-01-21T23:59:18.png

最上面的两条是浏览器内置函数的调用,因此我们可以直接点进第三条(t/<,一个名为t的函数内定义的匿名函数)。B站的前端代码经过压缩,对人类非常不友好。我们可以点击左下角的小按钮让LibreWolf(或者Firefox)帮我们prettify它。

2024-01-21T23:59:29.png

接着我们搜索获取WebGL相关信息必须用到的字符串,比如VERSION,就可以找到这里:

2024-01-22T00:03:27.png

这里定义了一个函数getUserWebglInfo(准确说这个函数叫value,但称其为getUserWebglInfo对人类更友好,下同)获取WebGL指纹信息,并存储在某个object中,推测之后请求search端点时会使用这里存储的信息。这个函数的内容基本和我们上面的猜想一致。由于这个文件又长又不好读,我不太想把整套请求逻辑都挖出来。找到此处已经足够我们精准反制这套风控策略。

应对方案

既然是因为我的LibreWolf禁用WebGL导致B站的JS获取不到WebGL指纹,那么我们可以通过JS伪造一个B站白名单的指纹来骗过风控机制。我想到了以下几种方法实现:

第一个方案是直接修改JS文件的内容,把这个函数的定义替换掉。这个方法在我之前的文章有使用过。缺点是比较笨重,而且适应性不强。这个JS文件的名字也没有stardust-video这样的强特征,不容易匹配。

第二个方案是通过注入一段JS代码修改这个函数存下来的信息。然而要怎么访问到这几个变量是个问题。实现这个方法需要对代码结构进行更深入的研究,我太懒了不想看。不过这个方法倒是非常适合做PoC。仔细观察可以发现,getUserWebglInfo只负责获取并保存指纹,而上面定义的另一个函数queryUserLog负责读取并返回之前保存下来的指纹。

至于queryUserLog为什么叫这个名字,我们暂且按下不表,好奇的读者可以继续往后看。我们可以在debugger中给queryUserLog打一个断点,然后刷新:

2024-01-22T00:03:59.png

return语句执行前,我们可以打开控制台(Pro Tip:按Esc键)把this.webglStrthis.webglVendorAndRenderer给改掉:

2024-01-22T00:04:40.png

然后就可以点击Resume按钮继续正常加载。不出意外的话,投稿视频列表就应该出现了。

2024-01-22T00:04:50.png

PoC已经有了,但是到底要怎么落实这个方案呢……由于我对JS的一些特性并不是非常了解,我想了很久也没有想出简单的实现方式。直到我突发奇想上网搜索能不能override掉canvas元素的getContext函数,结果还真的找到了

那么话不多说,直接上代码:

let fakeGL = {
  getParameter: function(param) {
    switch (param) {
      case this.VERSION:
        return "WebGL 1.0"
      case this.extension.UNMASKED_VENDOR_WEBGL:
        return "Intel"
      case this.extension.UNMASKED_RENDERER_WEBGL:
        return "Intel(R) HD Graphics"
      default:
        return null;
    }
  },
  extension: {
    UNMASKED_RENDERER_WEBGL: 1,
    UNMASKED_VENDOR_WEBGL: 2
  },
  VERSION: 0,
  getExtension: function(ext) {
    if (ext == "WEBGL_debug_renderer_info") {
      return this.extension;
    }
    else {
      return null;
    }
  },
}

HTMLCanvasElement.prototype.getContext = function (orig) {
  return function(type) {
    return type !== "webgl" ? orig.apply(this, arguments) : fakeGL
  }
}(HTMLCanvasElement.prototype.getContext)

我把这段代码加在了之前制作的用来应对1分钟自动暂停的Tampermonkey小脚本里。同样的脚本里实现的还有我逆向研究出来的免登录观看1080p视频的方法。这个方法我已经在去年7月左右给PipePipe提了issue。很遗憾,由于不知道什么原因,我为此事注册的CodeBerg账号大概是被管理员删了,所以那个issue也一并消失了,但作者已经采纳并实现了这个功能。

至此,我又可以愉快地用LibreWolf观看我最心爱的up主的视频了。

深挖一点点

但是我没有想通,为什么B站在base64编码后会截掉一些字符呢?为了那该死的好奇心,我决定深入探究一下背后的逻辑。我们略加思考可以猜测:在queryUserLog被调用时,顺着调用栈回溯,大概率可以找到对其返回值进行编码的代码。因此,不妨来看看这个t

2024-01-22T00:05:03.png

67012行是发起queryUserLog调用的代码,但是这里经过了混淆处理。没关系,虽然我们读代码比较难知道c(510)是个什么东西,但我们可以执行它:

2024-01-22T00:05:15.png

显然,U就是queryUserLog里的this。但是在我验证这个推断的时候突然发现U里除了两个关于WebGL指纹的信息,还存储了一个activityDetector,而其内容十分可疑:

2024-01-22T00:05:26.png

我们先把这个可疑的activityDetector放在一边,继续来研究B站有问题的base64。当我们看到下面的代码的时候,结合一些猜测和推断,一些疑惑会逐渐解开:

  1. 67013行的f来自l.obfuscate,其值为true,猜测这个变量没有什么实际作用。
  2. 67015行翻译为v["userLogStr"] = u["AHjCN"](F, v.userLog)
    67016行是v["webglStr"] = u.RzGqF(q, v["webglStr"])
    67017行则是v["webglVendorAndRenderer"] = u.XGGLO(q, v["webglVendorAndRenderer"])
  3. u.AHjCNu.RzGqFu.XGGLO只是用来混淆视听的,如图:
    2024-01-22T00:05:43.png
  4. q就是我们在找的编码函数:
    2024-01-22T00:05:55.png
  5. 还记得dm_img_list吗?它其实是userLogStr的马甲:
    2024-01-22T00:06:03.png

q的定义在这里:

q = function (t) {
  var e = N,
  n = {
    LztiW: function (t, e) {
      return t(e)
    },
    qmlRV: function (t, e) {
      return t - e
    }
  },
  r = (new TextEncoder) [e(552)](t) [e(537)],
  o = new Uint8Array(r),
  i = n[e(508)](btoa, String.fromCharCode[e(556)](null, o));
  return i[e(498)](0, n[e(507)](i[e(518)], 2))
};

和之前一样,e是一个混淆表,而n.LztiW是个啥用都没有的函数。我们可以还原出可读代码如下:

q = function (t) {
  var r = (new TextEncoder).encode(t).buffer,
  o = new Uint8Array(r),
  i = btoa(String.fromCharCode.apply(null, o));
  return i.substring(0, i.length-2))
};

可以看出其实就是调用了浏览器内置的btoa进行base64编码,然后把最后两个字符故意裁掉了。我的猜测是因为截掉最后两位可以保证没有=出现在请求参数的值里。

再深挖一点点

在前文中一直有一个东西被我们搁置着,那就是dm_img_list。现在我们来看看它是做什么的。这个看似人畜无害的东西真的只是一个空列表吗?当然不会这么简单!

我们已经知道,dm_img_list的来源是userLogStr,而userLogStrF(v.userLog)来的。此外我们还有以下观察:

  1. queryUserLog函数有一个参数t,而t中可能有一些时间戳相关的fieldpreTimestartTimeendTime
  2. queryUserLog中66077行处有对getLog函数的调用,而这个函数从activityDetector中读取信息。
  3. activityDetector里有一个activityEvents数组,值为["mousemove", "click"]

可以猜测,activityDetector是一个在后台不断收集用户鼠标指针活动的机制。收集到的事件信息会被存储在userLog中,然后被F序列化为一个字符串,附带在请求中发送给后端API。之所以我们看到的dm_img_list是空列表,是因为我们刚刚打开页面,还没有任何鼠标活动被记录。

为了验证这个猜想,我们可以在这里切换tab来AJAX刷新投稿列表,这样就会在有鼠标活动历史的情况下触发对search端点的请求。

2024-01-22T00:07:22.png

果然……

2024-01-22T00:07:31.png

真的,B站的开发大哥,你用wbi_imgimg_urlsub_url这种迷惑的方式隐藏WBI签名实时token也就算了,至少做了个.png结尾的样子装得像个图片链接;这img_list里充满timestamp和xyz也太不走心了吧?

禁止跟踪你大爷我

如果说WebGL版本、vendor和renderer只能算非常模糊的指纹特征,收集用户鼠标移动和点击行为则是非常恶心且下流的风控手段。我突然觉得每天我在B站冲浪的时候,陈叔叔都在猥琐地盯着我的一举一动:我什么时候把鼠标移到什么视频的封面上,看了几秒钟后又移走,最终点开了什么视频。作为不登录的白嫖用户,这件事对我可能没有那么要紧,但是对于正常使用网页端登录的用户呢?

我完全不能接受这种收集用户行为特征的做法,更何况带上这些记录之后,又出现了-352的问题,因此不管为了什么,都必须想个办法把这个参数定死在[]。最根本的方法是直接拦截对search端点的请求,手动覆盖掉这几个参数再发出去。需要注意的是WBI签名也必须重新生成,好在bilibili-API-collect项目提供了WBI签名的JS实现,我们稍加改动就可以拿来用(魔改出来的wbiSign函数实现不在此赘述)。

const origFetch = window.fetch;
window.fetch = async (...args) => {
  let url = new URL((args[0].startsWith("//") ? "https:" : "") + args[0]);
  if (url.host == "api.bilibili.com" && url.pathname == "/x/space/wbi/arc/search") {
    url.searchParams.set("dm_img_list", "[]");
    url.searchParams.set("dm_img_str", "V2ViR0wgMS");
    url.searchParams.set("dm_cover_img_str", "SW50ZWwoUikgSEQgR3JhcGhpY3NJbnRlbA");
    let paramsObj = Object.fromEntries(Array.from(url.searchParams))
    delete paramsObj.wts;
    delete paramsObj.w_rid;
    const { wts, w_rid } = wbiSign(paramsObj);
    url.searchParams.set("wts", wts);
    url.searchParams.set("w_rid", w_rid);
    args[0] = url.href;
  }
  const response = await origFetch(...args);
  return response;
};

MD5这里,我在Greasy Fork上找到一个实现,直接在脚本头上@require 即可使用。顺带着我把dm_img_strdm_cover_img_str也处理了一下,这样就不需要前面伪装WebGL指纹的代码了。

杂谈

逆向工程是一件有趣的事。通过一些技术手段,能够从一个特殊的视角去看到普通用户感知不到的现象。在分析代码的过程中,我似乎与写出这些代码的人建立了某种对话,透过代码去接近开发者的思维、洞察开发者的意图。

我不知道写出这些代码的人怎么看待自己写出的“作品”,甚至我也不知道自己应该怎么看待这篇博文和它会产生的实际影响。我必须承认风控策略存在的合理性,但我非常、非常不喜欢现行的风控思维,即通过过度侵略性的方式事无巨细地收集一切能收集到的信息,然后通过这些信息建立起的用户画像判断用户的风险等级。在本文的例子中,收集WebGL指纹尚且不算作具有侵略性,但是如你所见,对一个具有少量web知识储备的人(比如我)来说,这种风控形同虚设。从我决定解决这个风控带来的困扰到我写完这篇文章的初稿,一共只花了两天时间。因此,在以数据为根基的风控哲学指导下,风控对隐私权的僭越只会越来越变本加厉,比如这里的dm_img_list风控法。可以看出B站这次新推出的风控暂且没有做得很绝——使用空列表就能过,但这里一定有开发人员预留的操作空间,方便日后进一步收紧。

我对自己隐私的介意程度可能超过了99%的互联网用户,以至于我禁用了WebGL,常年通过一系列代理上网,还使用了很多反指纹手段让我的浏览器难以被精准识别。然而,这么做的后果就是我时不时会碰到各式的captcha,并且一些网页的浏览体验会大打折扣。很遗憾,在现行的大数据时代的“风控”机制眼中,重视隐私权的用户会被算法和黑产人士划上等号,因而享受到黑产级待遇。至于这些所谓的“风控”有多少程度是出于打击黑产,又有多少程度是披着风控的皮干着监视、跟踪、分析、控制用户的勾当,我也说不清——或许没有人说得清。

长期以来,B站(和许多其他互联网产品)开发人员和高级用户之间维持着一种微妙的平衡。我一样不知道B站采用这么简陋的风控手段有多少是出于开发者只想讨个生活混口饭吃,又有多少是出于做人留一线日后好想见(或许完全没有)。作为一家规模不小的科技企业,如果B站想,完全可以把整个站点都放到登录墙后,但无论如何感谢B站没有这么做,并且留了不少“漏洞”给我这样的用户。前段时间我阅读了B站官方发布的关于风控机制的一篇文章,在其中B站开发拼命给WBI签名这套风控系统贴金,将其宣传得无比高级。很好笑的是说不定在几年后,我也会成为这种文章的作者,吹捧着自己从心底里讨厌的内容。因为这就是生活,而生活一直都是一个充满妥协、充满不确定、充满将就和对付、一地鸡毛的东西。

到头来我也只是一个迷茫的普通人。诚实地讲,我也不知道有什么更好的方式来达到风控的效果。我处在B站用户的困境中,但能够理解B站运营者和开发人员的困境。正是因此,我不会将我逆向B站后写出的脚本公开出来。我讲解了思路,贴出了主要代码,并相信愿意仔细阅读我的博文的读者都是有他们的苦衷但本质善良的技术爱好者。如果你花了一些时间来研究,复现我的解决方案应该非常容易,并且如果你愿意与我交流技术,也可以在Telegram上找到我。

仅有一条评论
  1. l34xbiftamg l34xbiftamg

    不用这么麻烦。
    &dm_img_list=[{"x":748,"y":-1686,"z":0,"timestamp":716,"k":123,"type":0}]&dm_img_str=V2ViR0wgMS&dm_cover_img_str=QU5HTEUgKE5WSURJQSwgTlZJRElBIEdlRm9yY2UgR1RYIDk4MCBEaXJlY3QzRDExIHZzXzVfMCBwc181XzApLCBvciBzaW1pbGFyR29vZ2xlIEluYy4gKE5WSURJQS&dm_img_inter={"ds":[{"t":0,"c":"","p":[9,3,3],"s":[80,6232,2116]}],"wh":[4904,4883,48],"of":[212,424,212]}
    上面这一些参数是原来的请求没有的,随便打开一个b站请求,截取到上面的参数,追加到原来的url中,done!

    为什么要留个人信息啊?