逆向!去除B站不登录1分钟自动暂停限制

2023-05-24 Technical Salty Fish 11条
(For English readers) This post is my notes for reverse engineering the Bilibili frontend to remove the newly added 1-minute auto pause when playing a video without logging in.

最近在用PipePipe(原AnimePipe)刷B站的时候偶然发现音乐区某翻唱up主,遂沉迷其中。在实验室工(mo)作(yu)的时候总想放此up主的歌作为背景音乐,但是B站2023年新加了一个非常讨厌的限制:如果不登录,视频开播之后一分钟左右会自动暂停并跳出登录提示窗。我无法忍受时不时切换回B站点继续,于是打算尝试浅浅逆向一下B站前端,找到破解这个限制的方法。

我的web基础不是很扎实,让各位看官老爷见笑了。

前辈研究

首先我们查一下相关的研究,可以找到这个脚本:B站(bilibili)自动续播因未登录而暂停的视频和对应的博文:关于B站(bilibili)对未登录用户视频观看进行暂停和弹窗的分析与简单解决方案。里面提到:

既然很可能是网页加载时引入的JS实现的,于是通过edge禁止部分JS加载的debug模式,排查出所属的文件。经验证,该功能存在于来自s1.hdslb.comstardust-video.****的js文件。当禁止该js加载时,不再出现自动暂停和弹窗的情况。然而,虽然视频可以继续播放,弹幕也没有问题,但是评论区、头像、及右侧推送区的加载出现了异常。这也是可以预见的,很显然是一个压缩后的综合性JS文件,并不可能单独实现这一功能。也因此,禁止该文件的引入虽然能解决所述的问题,但仍然影响着用户的正常使用和体验。
于是想到换一下思路,如通过重写同名函数覆盖该功能。遗憾的是该文件是经过压缩混淆的生产环境文件,内容太多太杂,实现的功能较多,难以定位(例如,搜索setTimeout和setInterval可以得到成百上千的结果)。尝试过简单的反混淆工具,也未能得到易于解读的效果。

原来博主的解决思路是写一个脚本检测暂停然后自动续播,但是我作为完美主义患者不能接受这种治标不治本的workaround。博主提到的setTimeout函数为我们提供了初始的逆向思路,我们就从这里入手。

开始摸索

和延时相关的功能最后总要落到两个函数上:setTimeoutsetInterval。我个人感觉setTimeout会更接近这里的场景,所以我们先研究它。我最开始的思路是打出来B站对这个函数的全部调用,然后寻找定时和一分钟接近的。想要列出所有对setTimeout的调用,最直接的方法就是override掉这个函数,在每次调用的时候嵌入一个console.log。简单搓一段代码:

window.oldSetTimeout = window.setTimeout;

window.setTimeout = function(callback, delay, ...args) {
    console.log("Set timeout " + delay);
    return window.oldSetTimeout(function() {
        console.log("Hit timeout " + delay);
        callback(...args);
    }, delay);
};

这样我们就可以获知什么时候setTimeout被调用了,延迟设置了多少,什么时候触发了回调函数。我们要想个办法让这段代码在所有其它脚本之前运行,这样才能保证B站的脚本会运行我们的这个版本。我用了个插件叫JS Injector

细化思路

我注入了这段js之后刷新页面,果不其然console炸了……数不清的log刷满了屏幕。显然我们需要一些更具体的思路。我第一个想到的是按照延迟时长过滤,只看延迟大于等于50000的调用(留一点margin),这样下来输出少了很多,剩下的delay值有这么几个:50000,85000,100000. 问题是没有一个足够接近一分钟,暂停事件发生的时候console也没跳出来Hit timeout的提示。

那还能说明什么呢,说明这个思路不太对呗。我想到的另外一种可能就是B站通过反复调用setTimeout来修改某些内部状态值,并且在达到一定条件的时候触发暂停。虽然这种情况更应该用setInterval,但是鉴于我之前看到了大量500、1000之类的小delay不断刷屏,这个猜想也是有合理性的。

我通过在特定的delay值出现的时候直接return进行了排查,发现在禁用500毫秒delay的时候出现了值得注意的现象:视频播放之后不到10秒就会卡住,并且console报错WebSocket连接中断。有趣的是自动暂停并且弹登录窗的事情也没有发生。这让我怀疑500毫秒delay具有某种心跳的作用,不断刷新一个和后端的WebSocket连接来获取视频流,同时这个心跳也起到计时暂停的作用。这种瞎猜虽然有那么一点道理,但并没有什么用,小delay出现得太多了,排查起来很麻烦。

不过观察暂停事件发生时间附近console的输出给我带来了一些线索。这些输出大概长这样:

2023-05-24T08:00:47.png

miniLogin.umd.min.js显然是管登录弹窗的脚本,而它第一次出现在console log里就是这里。结合我对500毫秒delay的怀疑,我继续推断是在这一次碰到500毫秒延迟到期的时候前段判断到了应该暂停弹窗的时间。这里一个401毫秒的延迟引起了我的注意,因为这个数字非常独特,只出现过这一次。

深挖线索

这个401毫秒的延迟会不会就是突破点呢?为了搞清楚在周围究竟发生了什么,我决定在这个地方打一个call stack出来看一看。代码很简单:

if (delay == 401) console.trace();

然后这个stack trace大概长这个样子:

2023-05-24T08:01:03.png

jinkela是……金坷垃?B站开发好调皮

大概可以看到,第一次调用stardust-video里的函数是c,然后它又去调用了po,最后来到openQuickLogin。结合禁用stardust-video的时候不会发生暂停事件的观察,可以合理猜测负责暂停的代码就在这么几个函数调用中间。我们看看这里的代码:(用Firefox直接打开会看到缩成一团的压缩版脚本,可以点击左下角图标为{}的按钮来prettify)

function mo(t, e, n, r) {
    var i,
    o = !1,
    a = 0;
    function s() {
        i && clearTimeout(i)
    }
    function c() {
        for (var c = arguments.length, l = new Array(c), u = 0; u < c; u++) l[u] = arguments[u];
        var f = this,
        d = Date.now() - a;
        function p() {
          a = Date.now(),
          n.apply(f, l)
        }
        function h() {
          i = void 0
        }
        o || (r && !i && p(), s(), void 0 === r && d > t ? p() : !0 !== e && (i = setTimeout(r ? h : p, void 0 === r ? t - d : t)))
    }
    return 'boolean' != typeof e && (r = n, n = e, e = void 0),
    c.cancel = function () {
        s(),
        o = !0
    },
    c
}

浏览器停在了o || (r && !i ...这一行。显然这个代码是混淆过的,里面有不少故意为了增加阅读难度而搞出来的迷惑操作,比如!0,直接看不容易看懂。于是我把这段代码贴给ChatGPT,并且提了这样一个要求:

2023-05-24T08:01:22.png

出人意料的是,ChatGPT不仅还原了这些迷惑操作,还推断出了混淆之前的变量名,而且看起来很合理。

function mo(delay, immediate, callback, debounce) {
  var timeoutId,
    canceled = false,
    lastExecutionTime = 0;

  function clearTimeout() {
    if (timeoutId) {
      clearTimeout(timeoutId);
    }
  }

  function executeCallback() {
    var args = Array.from(arguments);
    var context = this;
    var timeSinceLastExecution = Date.now() - lastExecutionTime;

    function scheduleNextExecution() {
      lastExecutionTime = Date.now();
      callback.apply(context, args);
    }

    function clearTimer() {
      timeoutId = undefined;
    }

    if (!canceled) {
      if (debounce && !timeoutId) {
        scheduleNextExecution();
      }

      clearTimeout();

      if (debounce === undefined && timeSinceLastExecution > delay) {
        scheduleNextExecution();
      } else if (immediate !== true) {
        timeoutId = setTimeout(debounce ? clearTimer : scheduleNextExecution, debounce === undefined ? delay - timeSinceLastExecution : delay);
      }
    }
  }

  if (typeof immediate !== "boolean") {
    debounce = callback;
    callback = immediate;
    immediate = undefined;
  }

  executeCallback.cancel = function () {
    clearTimeout();
    canceled = true;
  };

  return executeCallback;
}

柳暗花明

虽然这个函数形迹可疑,我们还是没有确凿的证据证明它就是罪魁祸首。逆向的时候只看代码是非常痛苦的,如果结合观察运行时行为有一些事情会变得简单很多,于是我决定打个断点跟踪一下。在几次step in后,我来到了一个函数loginVersionEveryPlayInterval

2023-05-24T08:01:34.png

这个名字和我的猜想高度重合,让我们看看这个函数怎么写的:

loginVersionEveryPlayInternval: function (t) {
    var e = this,
        n = 0,
        r = 0,
        i = 0,
        o = mo(1500, !0, (function () {
            if (!e.userInfo.isLogin && + i != + e.currentCid) {
                var o = e.player.getMediaInfo().absolutePlayTime;
                o - n >= t && (i = e.currentCid, e.player.pause(), e.openQuickLogin(), n = o),
                    0 !== o && (r = o)
            }
        }));
    this.player.on(nano.EventType.Player_TimeUpdate, o),
        this.player.on(nano.EventType.Player_LoadStart, (function () {
        var t,
            o;
        null !== (t = e.videoData) && void 0 !== t && null !== (o = t.rights) && void 0 !== o && o.is_stein_gate && + i == + e.currentCid ? n -= r : n = 0,
            r = 0
    }))
},

e.player.pause(), e.openQuickLogin()把这段代码的意图写在了脸上……我们可以看到o,也就是mo函数的返回值c(ChatGPT还原:executeCallback)被注册成了Player_TimeUpdate事件的一个回调函数. ChatGPT的判断正确。Player_TimeUpdate我没有继续深挖,但是顾名思义应该是一个会被周期性调用的函数。而mo被调用的时候,n(ChatGPT还原:callback)被赋值为这里调用了e.player.pause()的那个匿名函数。因此,c调用p的时候,p就会去调用这个匿名函数检测是否到了应该暂停的时间。另一方面,如果我们在这里打印t,会发现它的值为60,正好是暂停发生的一分钟时长,也印证了这段代码就是我们要寻找的关键。

采取行动

mo里其余的代码看起来像是非常扭曲的控制延迟的逻辑,推测应该是故意为了混淆视听而写成这样。既然核心逻辑已经明确,我们就可以开始着手干掉这个讨厌的暂停了。方法有很多,我这里采取的方法是让mo函数什么都不做,直接返回。实现也非常简单粗暴:在mo函数的开头加一句return;

为了测试这样做的效果,我把整个stardust-video脚本的代码粘贴进了JS Injector。(注:虽然Chromium可以直接编辑js,但这个文件太大,编辑后浏览器会卡死。)然后在mo函数里添加了return语句,最后在Dev Tools里屏蔽了原来的stardust-video脚本。这个测试成功了,直到整个视频播放完都没有出现暂停现象。

但是用JS Injector修改脚本的方法还是笨重了一些,而且适应性不强。为了让解决方案更轻量化,我决定搓个TamperMonkey脚本。我并没有开发TamperMonkey脚本的经验,js水平也仅仅停留在初学者,所以就直接从网上抄了一小段代码魔改了一下:

function addScript(text) {
    text = text.replace("function mo(t,e,n,r){", "function mo(t,e,n,r){return;");
    var newScript = document.createElement('script');
    newScript.type = "text/javascript";
    newScript.textContent = text;
    var body = document.getElementsByTagName('body')[0];
    body.appendChild(newScript);
}

window.addEventListener('beforescriptexecute', function(e) {
    var src = e.target.src;
    if (src.search(/stardust-video\.[\w\d]+\.js/) != -1) {
        e.preventDefault();
        e.stopPropagation();
        var xmlHttp = new XMLHttpRequest();
        xmlHttp.onreadystatechange = function() {
            if (xmlHttp.readyState == 4 && xmlHttp.status == 200) {
                addScript(xmlHttp.responseText);
            }
        }
        xmlHttp.open("GET", src, true);
        xmlHttp.send(null);
    }
});

脚本的实现思路是通过监听beforescriptexecute事件拦截stardust-video脚本的加载,并且重新获取脚本内容,经过添加return处理后插入到页面中。这应该不是最优雅的实现方法,如果有更好的方法请评论告诉我。经过实测,这个脚本有效,并且没有带来任何可见的副作用。

太好了,问题解决了,可以听歌去了。


斩草除根

结果就在本篇博文发布之后不久,某日我在听歌的时候突然又被暂停了。经简单调查发现mo被改了个名字,变成了go。推测是B站更新小版本的时候重新混淆过。好消息是loginVersionEveryPlayInternval(对没错,有个拼写错误)的名字并没有变。因此我稍微修改了一下脚本,把这个函数的名字给改掉了。这样不管以后怎么更新,只要官方不去动这个函数,我们的脚本都会有效。

已有 11 条评论
  1. 当Bilibili出现登录模态Modal时, 在HTML里面可以找到Div (bili-mini-mask) ClassName名称, 用Chrome DevTools打开網路, 点击搜尋, 输入 (bili-mini-mask) 就能找到stardust-video.js, 复制后格式化, 再用SublimeText搜索一下就能找到checkHasLoginDialog, 进一步搜索checkHasLoginDialog就能找到openLoginDialogCheck, loginVersionWechat, loginVersionBackBlock, loginVersionEveryPlayInternval, 两分钟就能找到根源, 很简单的, 对函式上下文调用修補一下就好了

  2. 在Google檢索<<不登录Bilibili查看评论区的方法>>时, 偶然发现你这篇和那篇CSDN不相关的文章

  3. 今天花了点时间用Tamper-monkey写了个解決Bili-bili手机网页端牛皮癣的小脚本 (问题: 1. 安装下载App Dialog, 2. 点击复制垃圾文本, 3. 点击全屏自动下载安装包, 4. 观看更多视频需要安装App的问题), 1.简单JSPath点击, 2.exec-Command替换为空闭包, 3. 窗口Player-Agent替换为空闭包, 4. 通过循环DOM 取得Vue options propsData bvid, 克隆替换DOM树解决事件侦听器无法移除的问题 (onclick location设置combined bvid URL), 然后又想再研究研究评论区只有两条内容的问题, Chrome DevTools打开網路, 点击搜尋字串, 发现API拉取了全部的评论, 只是js本地设置了limit, 研究了好一会后来解决了, 也是通过Vue user is-Login 设置Boolean true, 然后再init-Comment就行了, 完美解决免登录评论区的问题

  4. 最后一条留言, 我把刚才提到的去处Bili-bili牛皮癣小脚本上传到Pastebin上了, 导入Tamper-Monkey 就能用 (((((另外我测试时候发现, 这个脚本顺带着也解决了你提到的不登录自动暂停的问题)))))) 有需要的话任何人都可以下载, pastebin点com/apWLNVMP

  5. 小三 小三

    好用,射射兄弟

  6. xzz2022 xzz2022

    试了下,貌似又失效了? 添加类名"bili-mini-mask"的div,或者给此类名添加display:none属性,可以限制弹窗,但是还是会暂停视频要手动点一次播放,后续就不会再触发; 遗憾没找到触发暂停的代码,不然就完美了

  7. xzz2022 xzz2022

    重新尝试了下,取巧使用覆写settimeout,找到源代码延迟时间,就等于能取消源代码主动触发的事件执行,可以实现不再暂停和免登录1080P, 我的代码

    1. ZZ ZZ

      请问可以分享一下代码吗?

      1. xzz2022 xzz2022

        github搜xzz2021/beautifyPage, 代码还不太完善,核心替换在inject.js文件里,业务逻辑在bilibili/index.vue下,触发暂停事件还没找到,自己加了个1分钟的定时器,会自动点击继续播放

        1. titanium40 titanium40

          xzz2022: 链接http://xzz2022.top:2023/share/srT9spq2似乎已经断裂,无法下载文件(美国)。请考虑更新。

  8. Carlo Carlo

    使用Tampermonkey插件,然后写个脚本,使用新特性MutationObserver
    使用着还可以,看这篇文章
    https://blog.csdn.net/weixin_43331420/article/details/129659049