木匣子

Web/Game/Programming/Life etc.

如何劫持网页上的 Vue 实例

最近老婆在家休产假,偶尔会在网上看看电视剧。但是我们常去的那个网站除了烦人的微信二维码观影限制,还加了片头广告等。二维码观影限制的实现在「使用 WebpackJsonp 与 jQuery 进行代码注入」一文中提到过:强制在影片播放前显示一个微信公众号的二维码以及一个观影码,然后向后台不停轮询公众号平台是否收到观影码来决定何时开始播放影片。

前文中我介绍了如何借助早期版本的 Webpack 以及 jQuery 来实现前端劫持。不过前端是一个快速发展的行业,这些方法很快就过时了,所以要有新的思路来实现原来的需求。

站方升级时引入了国产的 umi 框架,数据和模块都是在初始页面加载后动态载入的。而且这次他们不再将 jQuery 暴露到全局,所以之前的方法不管用了。此外他们也移除了 production 中的 sourcemaps,给阅读源码增加了难度。

不过幸运的是他们的播放器组件仍然使用 Vue 框架编写。使用 Vue 意味着组件的接口完全普被暴露在全局中,可以通过 DOM 的 __vue__ 变量访问到 DOM 绑定的 Vue 实例,以及实例中的所有成员变量和方法。

方法如下:在浏览器检查元素,找到目标后 DOM 会被标记上 == $0

<div id="vjs-qr-code"> == $0
    <!-- ... -->
</div>

这时候可以在控制台直接通过 $0 访问这个 DOM,如果它有 __vue__ 字段,恭喜你,找到一个 vue 实例,并且可以使用它提供的所有接口:

> $0.__vue__
< a {_uid: 28, _isVue: true, $options: {…}, _renderProxy: Proxy, _self: a, …}
$attrs: (...)
$children: []
$el: div#vjs-qr-code
$vnode: Pa {tag: "vue-component-27-qr-code", data: {…}, children: undefined, text: undefined, elm: div#vjs-qr-code, …}
ajaxOnOff: (...)
coverAndProhibitPlay: (...)
coverState: (...)
currentTime: (...)
displayState: (...)
expirationFormatTime: (...)
expirationTime: (...)
expirationTimer: (...)
followedSuccess: (...)
initState: (...)
openExpirationTimer: ƒ ()
processingJudgment: ƒ ()
requestCode: ƒ ()
requestCodeTimer: (...)
requestSubscribe: ƒ ()
requestSubscribeTimer: (...)
requestTimeInterval: (...)
reset: ƒ ()
shareSuccess: (...)
shareUrl: (...)
sharing: (...)
success: ƒ ()
type: (...)
wxSubscribe: (...)
wxSubscribeCode: (...)
...

看到这些字段和方法,是不是感觉目标近在咫尺了呀!不过在还没有略读源码的情况下,我们也很难知道它们的具体作用,只能黑盒使用。不过既然看到了如此完整的方法名,可见源码没有被深度混淆,还是可以一读的。

从网络面板找到几个 javascript 文件一一下载到本地,放到 IDE 中。格式化后根据上面的一些方法名,很快可以确定组件的源码片段。

在源码中可以找到几个被轻度混淆但很重要的方法:

{
    processingJudgment: function(e) {
        var t = this;
        if (!this.coverState) {
            var n = !0;
            (this.followedSuccess || this.shareSuccess) && (n = !1), null ===
            this.wxSubscribeCode && (this.wxSubscribeCode = this.wxSubscribe.code);
            var r = parseInt(this.wxSubscribe.time);
            if (n && !this.type && this.wxSubscribeCode && r >= 0 && e >= r) {
                if (0 === r && this.initState.end) return;
                0 !== r || this.initState.start ||
                this.$emit('update:initState', {start: !0}), this.$set(this, 'type',
                    'subscription'), this.$emit('update:coverAndProhibitPlay',
                    !0), this.$set(this, 'expirationTime', (new Date).getTime() + 1e3 *
                    this.wxSubscribe.timeout), this.requestCode(this.wxSubscribe.timeout -
                    10), this.openExpirationTimer(), this.requestSubscribe(function() {
                    t.$emit('update:coverAndProhibitPlay', !1), t.initState.end ||
                    t.$emit('update:initState', {start: !0, end: !0, skipOtherOptions: !0});
                }), n = !1;
            }
            this.$nextTick(function() {
                t.initState.start || t.$emit('update:initState', {start: !0, end: !0});
            });
        }
    },
    // ...
}

以上方法是一个预判函数,决定了是否显示或跳过二维码。要想利用预判逻辑,需要在预判之前修改数据。对这个片头二维码来说比较不容易利用。而广告显示的逻辑就可以从这里下手。

不过这个函数还提供了一个重要信息,就是 requestSubscribe(callback) 里的回调,表示的是确证二维码验证后要做的事:清理该组件并放行。若我们利用这里面的代码来构造相应逻辑,就可以去掉这个二维码了。仔细看一下它的实现:

{
    requestSubscribe: function(e) {
        var t = this;
        if (this.options.wxSubscribeFn) {
            var n = this.cancelSource = (new Date).getTime();
            this.options.wxSubscribeFn({
                code: 0, success: function(r) {
                    if (t.cancelSource !== n) return console.warn(
                        '\u83b7\u53d6\u5173\u6ce8\u516c\u4f17\u53f7\u8bf7\u6c42\u88ab\u4e0b\u4e00\u4e2a\u53d6\u6d88\uff01');
                    var i = r.code, o = r.wxCode;
                    t.ajaxOnOff && 0 === i && !o &&
                    (t.type = null, t.followedSuccess = !0, t.ajaxOnOff = !1, e && e());
                }, error: function(e) {
                    console.error('\u83b7\u53d6wx\u5173\u6ce8\u5931\u8d25!', e);
                },
            }), this.ajaxOnOff && (clearTimeout(
                this.requestSubscribeTimer), this.requestSubscribeTimer = setTimeout(
                this.requestSubscribe.bind(this, e), this.requestTimeInterval));
        }
    },
    // ...
}

从这个函数可以看到,判定订阅成功后还修改了一些状态。另外还注意到 beforeDestroy 清理了一些计时器:

{
    beforeDestroy: function() {
        clearTimeout(this.requestCodeTimer), clearTimeout(
            this.requestSubscribeTimer), clearTimeout(this.expirationTimer), clearTimeout(
            this.teseTimer);
    },
    // ...
}

综上我们可以构造出这样一段代码,直接干掉这个二维码并播放影片:

(function() {
    let dom = document.querySelector('#vjs-qr-code');
    let vue = dom && dom.__vue__;
    if (!vue) {
        console.warn('qr vue is not detected.');
        return;
    }

    /* request success */
    vue.followedSuccess = true;
    vue.shareSuccess = true;
    vue.ajaxOnOff = true;
    vue.type = null;
    
    vue.$emit('update:coverAndProhibitPlay', false);
    vue.initState.end || vue.$emit('update:initState', {
        start: true,
        end: true,
        skipOtherOptions: true,
    });

    /* before destroy */
    clearTimeout(vue.requestCodeTimer);
    clearTimeout(vue.requestSubscribeTimer);
    clearTimeout(vue.expirationTimer);
    clearTimeout(vue.teseTimer);
})();

打开影片页面,然后将上述代码复制到控制台运行,二维码消失了,影片开始播放!确认可行后,同理我们可以制作去除广告的脚本。但总不能每次运行都去控制台跑代码吧?而且如果在 Xbox 的 Edge 浏览器上看片,没法访问控制台怎么办?

于是我写了这个 kantv-helper 项目,将这些脚本制成 userscript ,可以通过浏览器的 tampermonkey 扩展安装,在特定页面自动加载执行,并设置了自动更新。

而对于 Xbox ,则采用收藏夹注入脚本的方式,将脚本添加到收藏夹,然后每次看片的时候手动点击执行该脚本即可。详情请参考(如此简陋的)项目页面:https://mutoo.im/kantv-helper/

那么问题来了,如果你是站方,你有哪些方法来见招拆招?期待未来的更新。