0x00 常规测试

0x00 Chrome Headless User Agent

Chrome无头版本的UA中含有HeadlessChrome字样,示例:

1
Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/60.0.3112.50 Safari/537.36

0x01 WebDriver

基于Selenium WebDriver驱动的无头浏览器会在js中生成一个window.navigator.webdriver对象,正常的浏览器中是没有这个对象的。

0x02 WebDriver Advanced

在浏览器中执行如下js:

1
navigator.webdriver = false

在正常的浏览器中,执行上述js后再次访问navigator.webdriver的值,其值为false,但在无头浏览器中,其值仍然为true

0x03 Chrome Test

在Chrome无头模式的js环境中访问window.chrome的值为undefined

0x04 Permissions

因为无头浏览器是默认拒绝所有使用js代码申请的权限,但又不会修改权限申请队列中的申请状态。所以我们执行如下代码想用户申请通知栏权限:

1
const permissionStatus = await navigator.permissions.query({ name: 'notifications' });

在无头浏览器中,权限的申请会被立刻拒绝(Notification.permission值为denied),但是权限的申请状态又不会发生变化(访问permissionStatus.state的值依旧为prompt,表示用户还未操作是否给予权限),前后是相矛盾的。

0x05 Plugins Length

正常的浏览器都会有插件,所以访问navigator.plugins返回的列表一定不为空,而无头浏览器一般是没有浏览器插件的。

BTW:

即便你新安装的浏览器,你的浏览器也是默认带一个PDF插件的,用于在浏览器标签页中打开PDF文件。

0x06 Plugins is of type PluginArray

在正常的浏览器中,使用js访问navigator.plugins返回的对象类型为PluginArray,而不是一个普通的数组。在无头浏览器中则不是。

0x07 Languages

无头浏览器是没有语言设置的,不信你可以在无头浏览器中访问navigator.languages,返回值一般为undefined或者一个空列表。而在正常的浏览器中,返回值为当前浏览器的语言,如['en-US', 'en']

0x08 WebGL Vendor

Chrome无头版的WebGL Vendor是Brian Paul,正常浏览器一般为Intel Open Source Technology Center或者Google Inc

0x09 WebGL Render

Chrome无头版的WebGL Render是Mesa OffScreen,正常浏览器中显示的一般和你电脑显卡的型号有关。就比如我的电脑采用的是Intel的集成显卡,显示的WebGL Render的值就为:ANGLE (Intel(R) UHD Graphics 630 Direct3D11 vs_5_0 ps_5_0)

0x0A Retina/HiDPI Hairline Feature

在页面中插入一个DIV:

1
<div id="modernizr"></div>

然后在CSS文件中,为其配置如下样式:

1
2
3
#modernizr {
border: .5px solid transparent;
}

当我们在js中访问这个divoffsetHeight属性值的时候,返回值如果不为1,那就是无头浏览器。

这种识别方式有很强的局限性,仅局限于使用High DPI显示器在正常的浏览器中渲染的网页这个offsetHeight属性才会返回1。而在无头浏览器中,不管是不是High DPI的显示器,都会返回其它值。

0x0B Broken Image Dimensions

在HTML中通过img标签加载一个失效的图片链接,然后再通过js去访问这个img标签所指向图片的宽度和高度,在无头浏览器中,返回的宽度值和高度值均为0。在正常的浏览器中,返回的数值和你当前浏览器窗口的大小和缩放比例有关,是个非0值。

1
2
3
4
5
6
7
8
9
10
11
var body = document.getElementsByTagName("body")[0];
// 动态添加一张失效的图片
var image = document.createElement("img");
image.src = "http://fake-image-address"; // 失效的图片链接
image.setAttribute("id", "fakeimage");
body.appendChild(image);
image.onerror = function(){
if(image.width == 0 && image.height == 0) {
console.log("Chrome headless detected");
}
}

0x01 浏览器指纹测试

0x00 PHANTOM_UA

使用PhantomJS渲染的网页,UA会默认被加上一个PhantomJS字样。

0x01 PHANTOM_PROPERTIES

在正常的浏览器中执行window.callPhantomwindow._phantomwindow.phantom,返回的值均为undefined,而使用PhantomJS渲染的网页,三个值均不为undefined

0x02 PHANTOM_ETSL

在正常的浏览器中,执行如下js语句返回的值为33,而在PhantomJS渲染的网页中,值为37:

1
eval.toString().length

0x03 PHANTOM_LANGUAGE

在PhantomJS的环境下使用js访问navigator.languages的值为undefined

0x04 PHANTOM_WEBSOCKET

故意创建一个错误的WebSocket对象:

1
2
new WebSocket('itsgonnafail');  // 错误的WebSocket对象
new WebSocket("ws://localhost:9998"); // 正确的WebSocket,做对比

在正常的浏览器中加载错误的WebSocket对象抛出的异常为DOMException而在PhantomJS渲染的环境下抛出的异常为SyntaxError

0x05 MQ_SCREEN

在无头浏览器中执行如下js返回值为false,正常浏览器则为true

1
window.matchMedia('(min-width: ' + (window.innerWidth - 1) + 'px)').matches

0x06 PHANTOM_OVERFLOW

故意执行一段会导致Stack Overflow的js代码,并记录下抛出的异常以及递归的深度信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
let depth = 0;  // 记录实际走过的递归深度
function iWillBetrayYouWithMyLongName() {
try {
depth++;
iWillBetrayYouWithMyLongName();
} catch (e) {
errorMessage = e.message;
errorName = e.name;
errorStacklength = e.stack.toString().length;
}
}

iWillBetrayYouWithMyLongName();

在正常的浏览器中,栈溢出抛出的异常为RangeError: Maximum call stack size exceeded,且我们透过depth记录的递归深度值要远远大于通过catch抛出的异常中的栈长度的。如果这个大小关系反过来了,那就极有可能是透过PhantomJS渲染的。

0x07 PHANTOM_WINDOW_HEIGHT

在JS中使用screen.availWidthscreen.availHeight获取显示浏览器的屏幕的可用宽度和可用高度,使用screen.widthscreen.height获取显示浏览器的屏幕的宽度和高度。正常情况下,可用宽度是小于等于屏幕宽度的,可用高度也是小于等于屏幕高度的。而在PhantomJS渲染的环境下,这个关系可能就会反过来。

0x08 HEADCHR_IFRAME

Chrome的无头版本是不会加载使用srcdoc指定HTML代码的iframe的,例如:

1
<iframe srcdoc="blank page"></iframe>

而正常的浏览器是会加载的,所以我们可以通过判断其有没有被正常加载而判断出当前是否处在无头浏览器渲染的模式下,示例代码:

1
2
3
4
5
6
7
8
9
const iframe = document.createElement('iframe');
iframe.srcdoc = 'blank page';
document.body.appendChild(iframe);

const result = typeof iframe.contentWindow.chrome;
iframe.remove();
if (result === "undefined") {
console.log("Chrome headless detected");
}

0x09 CHR_DEBUG_TOOLS

在正常的浏览器中,用户按下F12键,或者网页内右键菜单点击检查,将会打开浏览器的控制台工具。无头模式下的浏览器则是没有这个控制台工具的。

0x0A SELENIUM_DRIVER

Selenium驱动的无头浏览器,在js中访问如下的这一些对象,值都不为undefined。在正常的浏览器中,访问如下这些对象,返回均为undefined

1
2
3
4
5
6
7
8
9
10
11
12
13
window._Selenium_IDE_Recorder
window.callSelenium
window._selenium
document.__webdriver_script_fn
document.__driver_evaluate
document.__webdriver_evaluate
document.__selenium_evaluate
document.__fxdriver_evaluate
document.__driver_unwrapped
document.__webdriver_unwrapped
document.__selenium_unwrapped
document.__fxdriver_unwrapped
document.__webdriver_script_func

0x0B CHR_BATTERY

在正常的浏览器中,执行js:

1
window.navigator.getBattery()

可以返回当前电脑的电池电量信息,而在无头浏览器中返回的Promise会一直处于pending状态。

0x0C CHR_MEMORY

在正常的浏览器中,执行js:

1
navigator.deviceMemory

会返回当前电脑以GB为单位的设备内存,在无头浏览器中则返回0。

0x0D TRANSPARENT_PIXEL

用Canvas动态加载一幅仅有1个像素点的纯白色2D png图像,然后再通过canvasCtx.getImageData(0, 0, 1, 1)复制这个像素点的像素值,如果返回结果的第四个通道的值为0,也就是透明的话,说明为正常的浏览器。

0x0E VIDEO_CODECS

如果是使用基于原始Chromium项目的无头浏览器,而不是Google Chrome浏览器的无头版本的话,可以通过判断浏览器是否可以正常解码MP4(有没有H264解码器)来判断是不是无头浏览器。因为正常用户使用的浏览器都是会有MP4解码器的,播放视频毕竟是大多用户使用浏览器的一个常规功能嘛。

1
2
3
4
const videoElt = document.createElement("video");
if (videoElt.canPlayType('video/mp4; codecs="avc1.42E01E"') !== 'probably') {
console.log("Chrome headless detected");
}

TL;DR

Chromium项目为什么没有H264等一众音视频解码器?

因为版权原因,Chromium项目作为一个开源项目,其仅仅是一个浏览器项目,是没有集成各种有版权的音视频解码器的。而Google Chrome浏览器是自带各种音视频解码器的,所以如果使用的是Google Chrome浏览器的无头模式,这种方法就会失效。

0x0F AUDIO_CODECS

与上面一个原理相同,Chromium项目也是没有任何音频解码器的,我们可以在页面创建一个音频元素,然后判断其是否可以正常播放相关音频来判断是否为无头浏览器:

1
2
3
4
5
const audioElt = document.createElement("audio");
// mpeg为MP3格式音频
if (audioElt.canPlayType('audio/mpeg;') !== 'probably') {
console.log("Chrome headless detected");
}

0x10 TOUCH_SCREEN

因为我们经常需要模拟手机访问某个网站,也就是配置一个手机UA来进行抓取。如果检测到当前的UA为手机端UA并且navigator.maxTouchPoints返回值为0或undefined的话,那极有可能是一个无头浏览器。

因为现在的手机多半都是触摸屏手机,执行js脚本navigator.maxTouchPoints得到的是当前设备多点触控所能容许的最大的触控点的个数。触摸屏手机下,这个值肯定是至少为1的。

0x11 MEDIA_DEVICES

一般的电脑都是含有多媒体设备的,这里说的多媒体设备特指:摄像头(video input)、麦克风(audio input)和扬声器(audio output)。一般的电脑,上述三者中少说也含有一个。但是运行无头浏览器的服务器中,多半是没有如上的多媒体设备。我们可以在js中执行如下方法,来获取运行当前浏览器的电脑中所连接的多媒体设备的列表:

1
navigator.mediaDevices.enumerateDevices()

如果返回的列表为空的话,那么当前网页的渲染环境就极有可能是一个无头浏览器。

0x12 SEQUENTUM

在正常的浏览器中执行如下js返回值应该为false,在Selenium驱动的无头浏览器中则相反:

1
window.external && window.external.toString && window.external.toString().indexOf('Sequentum') > -1

0x13 ACCELERATION_METER_USED

如果检测到当前使用移动端UA访问此网页时,网页可以在js中监听加速度计的变化情况,如果没有加速度计的变化数据,那极有可能是使用电脑模拟的。因为如果是个正常人在用手机滑网页的时候,加速度计的数据一定会有微弱的抖动变化的。

1
2
3
4
5
6
window.addEventListener("devicemotion",function(event){
var acceleration=event.accelerationIncludingGravity;
var x=acceleration.x; //x方向的加速度
var y=acceleration.y; //y方向的加速度
var z=acceleration.z; //z方向的加速度
},false);

BTW

网页内监听加速度计的变化情况,主要用于开发“摇一摇”功能、监听手机的倾斜情况以及完成一些体感游戏等。

0x14 NIGHTMARE_PROPERTIES

使用Nightmare JS渲染的网页,在js中访问window.__nightmare返回值不为undefined