Author: lightless wilson

0x00 起因

2017年4月,google chrome 突然宣布在 chrome59版本中,新增 headless 模式(https://developers.google.com/web/updates/2017/04/headless-chrome)。众多开发者听闻后纷纷表示太棒了,这也间接导致了 phantomjs 的主要维护人在 google group 上宣布不再维护 phantomjs 了,理由是『chrome 有了headless browser』。我也在第一时间了解了一下 chrome headless 相关的内容,无奈资料太少,一直没有去仔细研究,最近 wilson 师傅说要研究这个东西,于是和 wilson 师傅搞出来一份资料。

0x01 安装与启动

在官方给出的资料中写到,现有的 headless 模式并没有 Windows 版本。

Headless mode is available on Mac and Linux in Chrome 59. Windows support is coming soon!

而且也并未给出如何在 server 上运行的方式。如果你是在桌面环境,那就很简单:

chrome --headless --disable-gpu --remote-debugging-port=9222

在 mac 上的话你可能还需要这样:

alias chrome="/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome"
alias chrome-canary="/Applications/Google\ Chrome\ Canary.app/Contents/MacOS/Google\ Chrome\ Canary"
alias chromium="/Applications/Chromium.app/Contents/MacOS/Chromium"

如果你在 linux server 上,就需要借助其他的一些东西去跑了。我这里在 centos7上跑起来的:

yum install  \
    ipa-gothic-fonts \
    xorg-x11-fonts-100dpi \
    xorg-x11-fonts-75dpi \
    xorg-x11-utils \
    xorg-x11-fonts-cyrillic \
    xorg-x11-fonts-Type1 \
    xorg-x11-fonts-misc -y

yum install xorg-x11-server-Xvfb
echo "[google-chrome]" >> /etc/yum.repos.d/google-chrome.repo
echo "name=google-chrome" >> /etc/yum.repos.d/google-chrome.repo
echo "baseurl=http://dl.google.com/linux/chrome/rpm/stable/\$basearch" >> /etc/yum.repos.d/google-chrome.repo
echo "enabled=1" >> /etc/yum.repos.d/google-chrome.repo
echo "gpgcheck=1" >> /etc/yum.repos.d/google-chrome.repo
echo "gpgkey=https://dl.google.com/linux/linux_signing_key.pub" >> /etc/yum.repos.d/google-chrome.repo
yum -y install google-chrome-stable

xvfb-run --server-args='-screen 0, 1024x768x16' google-chrome --headless --disable-gpu -remote-debugging-port=9222

0x02 优雅的使用

官方给出了几个在 CLI 环境中使用的简单例子,但是这并不能满足我们要替换掉由 phantomjs 编写的爬虫的需求,所以我们从某处(似乎官方也有写,不记得了)发现了这个 npm 包:https://github.com/cyrus-and/chrome-remote-interface
在 github 上也给出了一段示例代码:

const CDP = require('chrome-remote-interface');
CDP((client) => {
    // extract domains
    const {Network, Page} = client;
    // setup handlers
    Network.requestWillBeSent((params) => {
        console.log(params.request.url);
    });
    Page.loadEventFired(() => {
        client.close();
    });
    // enable events then start!
    Promise.all([
        Network.enable(),
        Page.enable()
    ]).then(() => {
        return Page.navigate({url: 'https://github.com'});
    }).catch((err) => {
        console.error(err);
        client.close();
    });
}).on('error', (err) => {
    // cannot connect to the remote endpoint
    console.error(err);
});

这段代码用来请求 github,并返回请求结果。直接通过node main.js就可以运行了。

0x03 检测 XSS

我们的最终目的是用这个玩意替换掉我们的 phantomjs 爬虫以及 XSS 检测器,在 phantomjs 中,有类似 onAlert 之类事件,直接监听这个事件就可以检测到弹窗。一开始,我们的思路是把 alert 函数 hook 掉,类似这种代码:

let oldAlert = alert;
alert = (x) => {
    console.log("hook!");
    oldAlert(x);
}

并在Page.navigate之前将这段代码通过Runtime.evaluate注入到页面上,看起来无懈可击,但是毫无用处,最后的结果是,可以弹窗的链接,并没有打印出 hook(就算触发了也不会打印出来,这个console.log是在浏览器的环境里,不会打印到终端上,测试的时候用了另外一种变通的方法打印到了终端上,这里为了简单先不提)。而那种需要交互才弹窗的,就会显示 hook,很奇怪。

经过我和 wilson 师傅的分析,发现了问题所在。无论在Page.navigate之后还是之前注入代码,这段代码一定会在整个 DOM 渲染完成后执行的,这就导致了『XSS 的弹窗比我们的 hook还要早』的问题。人家窗都弹完了,hook 完就毫无用处。

后来经过仔细的阅读官方文档,发现了这个东西:

Page. javascriptDialogOpening
# Fired when a JavaScript initiated dialog (alert, confirm, prompt, or onbeforeunload) is about to open.

居然有这种事件,那就拿过来用吧

Page.javascriptDialogOpening((message, type) => {
      console.log(message);
})

这样一来弹窗事件就可以被捕获了。

0x04 最后的测试和修正

满心欢喜的拿了几个漏扫里的洞过来测试,都是反射型 XSS。发现了一个蛋疼的事情,chrome自身的xss auditor会阻止弹窗,自然也就不会触发javascriptDialogOpening事件了,搜了搜,发现可以在启动headless browser的时候,禁用掉xss auditor,使用--disable-xss-auditor选项即可。
附上最后调试出来的代码:

var fs = require("fs");

function dom_jump(dom_url,callback) {
  console.log("Start test: ", dom_url);
  var url_lists = [];
  const CDP = require("chrome-remote-interface");

  CDP(client => {

    const { Network, Page, DOM , Runtime, Log } = client;
    Network.requestWillBeSent(params => {
      url_lists.push(params.request.url);
    });

    Page.loadEventFired(() => {
      client.close();
      callback(url_lists);
    });

    Page.javascriptDialogOpening((message, type) => {
      console.log(message);
    })

    // enable events then start!
    Promise.all([
      Network.enable(), Page.enable(), 
      DOM.enable(), Runtime.enable(),
      Log.enable()
      ])
      .then(() => {
        Page.navigate({url: dom_url});
        // Page.reload(true);
      })
      .then(() => {
        var js = fs.readFileSync("payload.js");
        Runtime.evaluate({expression: js.toString()}).then(result => {
          console.log(result.result.value)
        });
      })
      .then(function() {
        Runtime.evaluate({expression: "document.getElementsByTagName('html')[0].innerHTML"}).then(result => {
          console.log("result: " + result.result.value);
        })
      });
  });
};

dom_jump(
  "http://demo.aisec.cn/demo/aisec/",
  function(url_lists){
  // console.log(url_lists);
});

其中的 payload.js 是检测 XSS 的相关内容,是 wilson 大表哥的,就不贴出来了。
最后我们修改了一下启动参数,让chrome headless可以当做远程服务器来跑,不用把代码和chrome 放到一起。

xvfb-run --server-args='-screen 0, 1024x768x16' google-chrome --headless --disable-gpu -remote-debugging-port=9222 -remote-debugging-address=0.0.0.0 --disable-xss-auditor

这样监听到0.0.0.0,连接的时候在 CDP 里指定IP和端口即可,以后还可以搞个小集群一起处理,加快速度和效率。