本文本涉及的版本为 Webpack 2.x-3.x,写作时 Webpack 已经发布 4.x 半年左右。

0x00 Webpack

Webpack 是当下最流行的 Web 打包工具,允许前端开者发在项目中进行模块化开发,并将代码打包成一个代码包(bundle),在浏览器上加载使用。
但随着引入的第三库越来越多,代码包会越来越大,影响页面加载时间。于是 Webpack 提供了一个分包功能,可以将共享的库拆分(code-spliting)到不同的包中,使得浏览器可以并行加载不同的包,加快页面打开的速度。

0x01 jQuery

jQuery 是一个老牌的前端组件,主要用于提供方便的 Dom 操作和 Ajax 请求。由于在前端项目中大量使用,所以在模块化开发中,常常将它作为全局组件使用。为了必避在每个模块中导入它:import $ from 'jquery';。Webpack 提供了一个叫 webpack.ProvidePlugin 的插件,可以在配置中将 jQuery 全局化,在其它模块中直接使用而无需 import

new webpack.ProvidePlugin({
  $: 'jquery',
});

0x02 webpackJsonp

如果不使用 Webpack 的分包功能,包中的模块全部存在 Webpack 闭包里的 installedModules = {} 对象中,只能通过闭包中的 __webpack_require__(moduleId) 来引用。在这种情况下 jQuery 将与前端逻辑放在一起,并且受到 Webpack 的闭包保护,而无法在网页上的其它代码里引用包中的 jQuery 实例。除非你在代码中强行把 jQuery 导入到 window 命名空间[1]

但是如果启用了 Webpack 的分包功能,Webpack 生成一个 Manifest.js 并向 Window 对象注入一个 webpackJsonp(chunkIds, moreModules, executeModules) 接口用于多个分包之间共享数据。

但是如果仔细阅读这个接口的代码,会发现第三个参数有特别的作用,可以指定 moduleId 并返回该 Module,相当于可以在外部直接 require 包内的模块。

window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
    ...
    if(executeModules) {
        for(i=0; i < executeModules.length; i++) {
            result = __webpack_require__(__webpack_require__.s = executeModules[i]);
        }
    }
    return result;
}

0x03 $.ajax()

在 jQuery 中,前端开发者可以用 $.ajax()/$.getJSON()/$.post() 等接口与服务端通讯。后两者其实是 $.ajax() 的封装。从 jQuery 的文档中可以看到 $.ajax() 的一般用法:

$.ajax({
    url: '/path/to/api-endpoint',
    data: {
        /* request data */
    },
    method: 'GET',
    success: (data) => {
        /* handle response data */
    }
}).done((data) => {
    /* another way to handle response data */
});

以上代码向 /path/to/api-endpoint 请求数据,并带上 data 指定的参数,最后在 success 里处理返回结果。

0x04 $.ajaxPrefilter()

如果有个 API 返回了 {"successful": false},要是我们能拦截返回结果并修改它,我们就可以构造一些数据来影响前端逻辑。而 jQuery 确实提供了一个接口让我们有机会在数据被处理前将它改掉:$.ajaxPrefilter()

$.ajaxPrefilter((options) => {
    const oldSuccess = options.success;
    options.success = (data) => {
        console.log('original', data);
        data.successful = true;
        console.log('injected', data);
        oldSuccess && oldSuccess(data);
    };
});

以上代码向 jQuery 注册了一个 ajax pre-filter,之后的所有 ajax 都被会它预处理。它在将原来的 success 回调进行封装,并修改了服务端返回的结果,从而影响了前端原有的逻辑。除此之外,ajaxPrefilter 还可以用来修改所有 $.ajax() 的 options 对象,包括 data 等。

ajaxPrefilter 这类的拦截器本意是让前端开发者有一些统一的地方对 API 通讯进行一些封装,但是如果被脚本小子利用就另当别论了。

0x05 Case

影响前端逻辑看起来似乎没什么用,但是却很方便来用绕过一些限制,实现一些 Web 自动化,甚至采集用户数据等等。

例如某电视剧网站,最近为了收割微信用户,强制用户观影前需要关注公众号,将观影码发送至公众号才可以进入观看影片。但其接口设计有问题,只是通过 jQuery 不断轮询 API,检查服务端是否收到观影码,如果收到就放行。

  1. 打开 Console 观察,发现不能直接访问 jQuery 对象,但网站使用 Webpack 打包并分包处理;
  2. 通过下载 Manifest.js 可以发现 webpackJsonp(chunkIds, moreModules, executeModules) 接口;
  3. 通过下载 Vendor.js 可以找到混淆过的 jQuery 的 moduleId ;
  4. 通过 let jq = webpackJsonp([],[],[jqModuleId]) 获取包内 jQuery ;
  5. 通过 ajaxPrefilter 注入修改 API 返回的结果;
  6. 下一次轮询后成功进入观影。

0x06 受影响版本

由于 Webpack 的代码是开源的,跟踪提交记录可以看到 webpackJsonp(chunkIds, moreModules, executeModules)2.0.0-beta 版被引入,并在 4.0.0-alpha.0 被移除。所以如果网站使用 Webpack 分包,最好升级到 Webpack 4.x 以上的版本。

另外尽可能不要将 jQuery 暴露到 window 全局空间,可以防范类似 ajaxPrefilter 的修改。

0x07 备注

有个有趣的细节是,如果在 webpack.ProvidePlugin 中添加 {'window.$': 'jquery'},即使在模块中“不小心”写了 window.$ = $ 或者 window['$'] = $,Webpack 会将其修改为 __webpack_provided_window_dot_ = $,从而防止 $ 被暴露到全局空间。


  1. window.$ = $; ↩︎