无头浏览器与浏览器特征
最近安全这块在做无头浏览器的检测,并且之前也有同学遇到过需要判断用户特征的唯一性问题,这里介绍一下
无头浏览器介绍
无头浏览器指的是没有图形用户界面的浏览器。
wiki: 无头浏览器在类似于流行网络浏览器的环境中提供对网页的自动控制,但是通过命令行界面或使用网络通信来执行。 它们对于测试网页特别有用,因为它们能够像浏览器一样呈现和理解超文本标记语言,包括页面布局、颜色、字体选择以及 JavaScript 和 AJAX 的执行等样式元素,这些元素在使用其他测试方法时通常是不可用的。
无头浏览器通常用来:
- Web 应用程序中的测试自动化。
- 拍摄网页截图
- 对 JavaScript 库运行自动化测试
- 收集网站数据
- 自动化网页交互
- 服务端自动渲染
目前常见的无头浏览器有如下几种
- Selenium
- puppeteer
- PhantomJS
PhantomJS 2018 年之后开始就不在维护了,所以目前不把这个项目作为使用无头浏览器的首选项目
无头浏览器架构
这里 puppeteer 官方提供的架构图,可以看到 puppeteer 的方案是通过 Chrome DevTool Protocol 作为通讯中间层和 Chrome 的无头模式进行交互的,其余部分和我们正常的 Chrome 浏览器大同小异。
- 其中 puppeteer 实现了同时控制多个浏览会话并可拥有多个页面(BroswerContext)。
- Page 至少包含 1 个主框架,可能还可以由 iframe 或者<frame>标签生成。
- Frame 至少有一个执行上下文,即框架的 JavaScript 被执行。一个框架可能有额外的 extension 执行上下文。
- Worker 只有单一的执行上下文

无头浏览器使用
puppeteer 由于是通过 node 启动,相对来说对与前端工程师更为友好,这里就不过多赘述了。Selenium 对于三方/测试同学来说更为常见,便于之后演示,我们用 Selenium,与 python 语言进行举例。
- python 版本
python2 与 python3 之间跨度比较大,我这里使用 python3 来举例。
python3 -v
Python 3.8.9 (default, Aug 21 2021, 15:53:23)
- 安装 Selenium
与 package.json 类似,python 里面是通过 requirements.txt 文件作为版本控制
然后我们执行
- demo 例子
这里 selenium 官方文档有一个比较基础的例子 我们进行下相关修改
import time
from selenium import webdriver
from selenium.webdriver.common.by import By
driver = webdriver.Chrome()
driver.get("https://www.selenium.dev/selenium/web/web-form.html")
title = driver.title
driver.implicitly_wait(0.5)
text_box = driver.find_element(by=By.NAME, value="my-text")
submit_button = driver.find_element(by=By.CSS_SELECTOR, value="button")
text_box.send_keys("Selenium")
submit_button.click()
# message = driver.find_element(by=By.ID, value="message")
# text = message.text
time.sleep(1000000)
执行: python3 main.py
我们简单分析下这段 python 代码,他做了如下 3 件事
- driver = webdriver.Chrome() init 一个 chrome 驱动
- driver.get("https://www.selenium.dev/selenium/web/web-form.html") 通过 driver 打开了一个页面
- 修改 title, 找到页面元素之后修改内容,执行 submit
执行结果如下
原来的界面是这个样子的
自动提交后的界面是这个样子的
可以看到自动执行了一些函数
三方与无头浏览器
- 无头浏览器的特征
或多或少大家都听过三方攻击。三方或者叫做黑产一般使用无头浏览器来抓取我们的数据,或者通过无头浏览器来实现自动化操作。但是如果三方单纯使用无头浏览器,是比较容易被检测出来相关的特征,除了大家熟悉的 ua 以外,还有这些数据
webdriver
在一般的浏览器里面,他是这个样子
而在无头浏览器里面,是这个样子
除此之外还有一些参数,这一些参数都是针对 navigator 相关内容进行判断
- 隐藏特征
有矛才有盾,针对无头浏览器的几个特征点,有相关的覆盖 js 被开发出来用来隐藏这些特征,目前大范围在使用的是 puppeteer 中的一个第三方维护的插件: puppeteer-extra-plugin-stealth
plugin
我们来读一下他的源码,首先看下他的源码结构
关键点我们看下,一个是 pkg.json, 一个是 index.js
pkg.json
{
"name": "puppeteer-extra-plugin-stealth",
"version": "2.11.2",
"description": "Stealth mode: Applies various techniques to make detection of headless puppeteer harder.",
"main": "index.js", // 入口文件
"typings": "index.d.ts",
"repository": "berstend/puppeteer-extra",
"homepage": "https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth#readme",
"author": "berstend",
"license": "MIT",
"scripts": {
"docs": "run-s docs-for-plugin postdocs-for-plugin docs-for-evasions postdocs-for-evasions types",
"docs-for-plugin": "documentation readme --quiet --shallow --github --markdown-theme transitivebs --readme-file readme.md --section API index.js",
"postdocs-for-plugin": "npx prettier --write readme.md",
"docs-for-evasions": "cd ./evasions && loop \"documentation readme --quiet --shallow --github --markdown-theme transitivebs --readme-file readme.md --section API index.js\"",
"postdocs-for-evasions": "cd ./evasions && loop \"npx prettier --write readme.md\"",
"lint": "eslint --ext .js .",
"test:js": "ava --concurrency 2 -v",
"test": "run-p test:js",
"test-ci": "run-s test:js",
"types": "npx --package typescript@3.7 tsc --emitDeclarationOnly --declaration --allowJs index.js"
},
"engines": {
"node": ">=8"
},
"keywords": [
"puppeteer",
"puppeteer-extra",
"puppeteer-extra-plugin",
"stealth",
"stealth-mode",
"detection-evasion",
"crawler",
"chrome",
"headless",
"pupeteer"
],
"ava": {
"files": ["!test/util.js", "!test/fixtures/sw.js"]
},
"devDependencies": {
"ava": "2.4.0",
"documentation-markdown-themes": "^12.1.5",
"fpcollect": "^1.0.4",
"fpscanner": "^0.1.5",
"loop": "^3.0.6",
"npm-run-all": "^4.1.5",
"puppeteer": "9"
},
"dependencies": {
"debug": "^4.1.1",
"puppeteer-extra-plugin": "^3.2.3",
"puppeteer-extra-plugin-user-preferences": "^2.4.1"
},
"peerDependencies": {
"playwright-extra": "*",
"puppeteer-extra": "*"
},
"peerDependenciesMeta": {
"puppeteer-extra": {
"optional": true
},
"playwright-extra": {
"optional": true
}
},
"gitHead": "babb041828cab50c525e0b9aab02d58f73416ef3"
}
我们看下 index.js
class StealthPlugin extends PuppeteerExtraPlugin {
constructor(opts = {}) {
super(opts);
}
get name() {
return "stealth";
}
get defaults() {
const availableEvasions = new Set([
"chrome.app", // chrome的私有属性
"chrome.csi",
"chrome.loadTimes",
"chrome.runtime",
"defaultArgs", // 默认参数
"iframe.contentWindow", // srcdoc相关参数
"media.codecs", // 媒体类型 * video/webm; codecs="vp8, vorbis" * video/mp4; codecs="avc1.42E01E" * audio/x-m4a; * audio/ogg; codecs="vorbis"
"navigator.hardwareConcurrency", // 处理器核数
"navigator.languages", // 语言
"navigator.permissions", // 权限
"navigator.plugins", // 默认插件
"navigator.webdriver",
"sourceurl", // CDPSession 和 chrome devtools通信的协议
"user-agent-override", // ua
"webgl.vendor", // webgl 的渲染器
"window.outerdimensions", // 屏幕尺寸
]);
return {
availableEvasions,
enabledEvasions: new Set([...availableEvasions]),
};
}
get dependencies() {
return new Set(
[...this.opts.enabledEvasions].map((e) => `${this.name}/evasions/${e}`)
);
}
get availableEvasions() {
return this.defaults.availableEvasions;
}
get enabledEvasions() {
return this.opts.enabledEvasions;
}
/**
* @private
*/
set enabledEvasions(evasions) {
this.opts.enabledEvasions = evasions;
}
async onBrowser(browser) {
if (browser && browser.setMaxListeners) {
browser.setMaxListeners(30);
}
}
}
const defaultExport = (opts) => new StealthPlugin(opts);
module.exports = defaultExport;
可以看到直接用了一个 set 来存放所有的修改代码,这个逻辑在 evasions 里面,接下来我把关键代码举例
// chrome.app
if (!window.chrome) {
Object.defineProperty(window, "chrome", {
writable: true,
enumerable: true,
configurable: false, // note!
value: {}, // We'll extend that later
});
}
if ("app" in window.chrome) {
return;
}
const makeError = {
ErrorInInvocation: (fn) => {
const err = new TypeError(`Error in invocation of app.${fn}()`);
return utils.stripErrorWithAnchor(err, `at ${fn} (eval at <anonymous>`);
},
};
const STATIC_DATA = JSON.parse(
`
{
"isInstalled": false,
"InstallState": {
"DISABLED": "disabled",
"INSTALLED": "installed",
"NOT_INSTALLED": "not_installed"
},
"RunningState": {
"CANNOT_RUN": "cannot_run",
"READY_TO_RUN": "ready_to_run",
"RUNNING": "running"
}
}
`.trim()
);
window.chrome.app = {
...STATIC_DATA,
get isInstalled() {
return false;
},
getDetails: function getDetails() {
if (arguments.length) {
throw makeError.ErrorInInvocation(`getDetails`);
}
return null;
},
getIsInstalled: function getDetails() {
if (arguments.length) {
throw makeError.ErrorInInvocation(`getIsInstalled`);
}
return false;
},
runningState: function getDetails() {
if (arguments.length) {
throw makeError.ErrorInInvocation(`runningState`);
}
return "cannot_run";
},
};
后面的代码判断参数是否有无就不列举了
// chrome.csi
const { timing } = window.performance;
window.chrome.csi = function () {
return {
onloadT: timing.domContentLoadedEventEnd,
startE: timing.navigationStart,
pageT: Date.now() - timing.navigationStart,
tran: 15, // Transition type or something
};
};
// chrome.loadTims
const ntEntryFallback = {
nextHopProtocol: "h2",
type: "other",
};
const protocolInfo = {
get connectionInfo() {
const ntEntry =
performance.getEntriesByType("navigation")[0] || ntEntryFallback;
return ntEntry.nextHopProtocol;
},
get npnNegotiatedProtocol() {
const ntEntry =
performance.getEntriesByType("navigation")[0] || ntEntryFallback;
return ["h2", "hq"].includes(ntEntry.nextHopProtocol)
? ntEntry.nextHopProtocol
: "unknown";
},
get navigationType() {
const ntEntry =
performance.getEntriesByType("navigation")[0] || ntEntryFallback;
return ntEntry.type;
},
get wasAlternateProtocolAvailable() {
return false;
},
get wasFetchedViaSpdy() {
const ntEntry =
performance.getEntriesByType("navigation")[0] || ntEntryFallback;
return ["h2", "hq"].includes(ntEntry.nextHopProtocol);
},
get wasNpnNegotiated() {
const ntEntry =
performance.getEntriesByType("navigation")[0] || ntEntryFallback;
return ["h2", "hq"].includes(ntEntry.nextHopProtocol);
},
};
const { timing } = window.performance;
function toFixed(num, fixed) {
var re = new RegExp("^-?\\d+(?:.\\d{0," + (fixed || -1) + "})?");
return num.toString().match(re)[0];
}
const timingInfo = {
get firstPaintAfterLoadTime() {
return 0;
},
get requestTime() {
return timing.navigationStart / 1000;
},
get startLoadTime() {
return timing.navigationStart / 1000;
},
get commitLoadTime() {
return timing.responseStart / 1000;
},
get finishDocumentLoadTime() {
return timing.domContentLoadedEventEnd / 1000;
},
get finishLoadTime() {
return timing.loadEventEnd / 1000;
},
get firstPaintTime() {
const fpEntry = performance.getEntriesByType("paint")[0] || {
startTime: timing.loadEventEnd / 1000,
};
return toFixed((fpEntry.startTime + performance.timeOrigin) / 1000, 3);
},
};
window.chrome.loadTimes = function () {
return {
...protocolInfo,
...timingInfo,
};
};
其他都是类似的,就不举例了,我们看下 webdriver
这里有点不太一样
async onPageCreated(page) {
await page.evaluateOnNewDocument(() => {
if (navigator.webdriver === false) {
} else if (navigator.webdriver === undefined) {
} else {
delete Object.getPrototypeOf(navigator).webdriver
}
})
}
async beforeLaunch(options) {
const idx = options.args.findIndex((arg) => arg.startsWith('--disable-blink-features='));
if (idx !== -1) {
const arg = options.args[idx];
options.args[idx] = `${arg},AutomationControlled`;
} else {
options.args.push('--disable-blink-features=AutomationControlled');
}
}
- 无头浏览器挂载 隐身 函数
通过这个网站下载到本地
https://raw.githubusercontent.com/requireCool/stealth.min.js/main/stealth.min.js
然后 python 脚本如下
import time
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
chrome_options = Options()
chrome_options.add_argument("--proxy-server=127.0.0.1:8888")
global browser
browser = webdriver.Chrome(chrome_options)
with open('stealth.min.js', 'r') as f:
js = f.read()
browser.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', { 'source': js })
browser.get("http://www.baidu.com")
time.sleep(1000000)
然后我们启动,会发现页面代理到了本地的代理工具("--proxy-server=127.0.0.1:8888")
然后我们看下 webdriver 是否注入成功
直接给弄成 undefined 的了
代理也代理上了
攻防
其实这一块是非常难写的,我这里给出结论,目前现在所有开源的识别无头浏览器的方案全部都无效
比如以下几个项目
https://infosimples.github.io/detect-headless/
https://bot.sannysoft.com/
https://github.com/antoinevastel/fpscanner/blob/master/src/fpScanner.js
这些都是 star 比较多的,全部都失去效果了
目前的主流做法都是需要借助算法,比如用户行为操作(鼠标,键盘事件等),然后配合 ua 等一些字段上报来计算
设备指纹
之前我们有同学需要判断唯一性,这里需要用到设备指纹
但是本质上无头浏览器的检测和设备指纹是一致的,都是检测浏览器的特征
这个库是比较有名的 https://github.com/fingerprintjs/fingerprintjs
具体原理是把各个部分当成组件分别实现,然后使用 loadBuiltinSources 方法获取所有组件,接着使用 componentsToCanonicalString 遍历组件的返回值,系列化拼接成字符串,最后用 x64hash128 方法把字符串转成一个独一无二的 hash 值。
这里我们先看下有哪些是浏览器特征
var components = [
{ key: "userAgent", getData: UserAgent }, //用户代理
{ key: "webdriver", getData: webdriver }, //网页内驱动软件
{ key: "language", getData: languageKey }, //语言种类
{ key: "colorDepth", getData: colorDepthKey }, //目标设备或缓冲器上的调色板的比特深度
{ key: "deviceMemory", getData: deviceMemoryKey }, //设备内存
{ key: "pixelRatio", getData: pixelRatioKey }, //设备像素比
{ key: "hardwareConcurrency", getData: hardwareConcurrencyKey }, //可用于运行在用户的计算机上的线程的逻辑处理器的数量。
{ key: "screenResolution", getData: screenResolutionKey }, //当前屏幕分辨率
{ key: "availableScreenResolution", getData: availableScreenResolutionKey }, //屏幕宽高(空白空间)
{ key: "timezoneOffset", getData: timezoneOffset }, //本地时间与 GMT 时间之间的时间差,以分钟为单位
{ key: "timezone", getData: timezone }, //时区
{ key: "sessionStorage", getData: sessionStorageKey }, //是否会话存储
{ key: "localStorage", getData: localStorageKey }, //是否具有本地存储
{ key: "indexedDb", getData: indexedDbKey }, //是否具有索引DB
{ key: "addBehavior", getData: addBehaviorKey }, //IE是否指定AddBehavior
{ key: "openDatabase", getData: openDatabaseKey }, //是否有打开的DB
{ key: "cpuClass", getData: cpuClassKey }, //浏览器系统的CPU等级
{ key: "platform", getData: platformKey }, //运行浏览器的操作系统和(或)硬件平台
{ key: "doNotTrack", getData: doNotTrackKey }, //do-not-track设置
{ key: "plugins", getData: pluginsComponent }, //浏览器的插件信息
{ key: "canvas", getData: canvasKey }, //使用 Canvas 绘图
{ key: "webgl", getData: webglKey }, //WebGL指纹信息
{ key: "webglVendorAndRenderer", getData: webglVendorAndRendererKey }, //具有大量熵的WebGL指纹的子集
{ key: "adBlock", getData: adBlockKey }, //是否安装AdBlock
{ key: "hasLiedLanguages", getData: hasLiedLanguagesKey }, //用户是否篡改了语言
{ key: "hasLiedResolution", getData: hasLiedResolutionKey }, //用户是否篡改了屏幕分辨率
{ key: "hasLiedOs", getData: hasLiedOsKey }, //用户是否篡改了操作系统
{ key: "hasLiedBrowser", getData: hasLiedBrowserKey }, //用户是否篡改了浏览器
{ key: "touchSupport", getData: touchSupportKey }, //触摸屏检测和能力
{ key: "fonts", getData: jsFontsKey, pauseBefore: true }, //使用JS/CSS检测到的字体列表
{ key: "fontsFlash", getData: flashFontsKey, pauseBefore: true }, //已安装的Flash字体列表
{ key: "audio", getData: audioKey }, //音频处理
{ key: "enumerateDevices", getData: enumerateDevicesKey }, //可用的多媒体输入和输出设备的信息。
];
我们看几个有意思的选项
canvas 指纹
通过 canvas 接口在页面上绘制一个隐藏的图像,在不同的系统,浏览器中最终的图像是存在像素级别的差别的,主要是因为操作系统各自使用了不同的设置和算法来进行抗锯齿和子像素渲染操作。即使相同的绘图操作,产生的图片数据的 CRC 检验也不相同。(CRC 是指使用 canvas.toDataURL 返回的 base64 数据中,最后一段 32 位的验证码)。
canvas 指纹并不少见,除此之外,还有使用音频指纹,甚至使用 WebGL 指纹和 WebGL 指纹的。 音频指纹跟 canvas 指纹的原理差不多,都是利用硬件或软件的差异,前者生成音频,后者生成图片,然后计算得到不同哈希值来作为标识。音频指纹的生成方式有两种:
- 生成音频信息流(三角波),对其进行 FFT 变换,计算 SHA 值作为指纹
- 生成音频信息流(正弦波),进行动态压缩处理,计算 MD5 值
这里 fingerprintjs 用的是方法 1,走快速傅立叶变换的方案,不多赘述了
我们看下 canvas 指纹的代码
export interface CanvasFingerprint {
winding: boolean
geometry: string
text: string
}
export const enum ImageStatus {
Unsupported = 'unsupported',
Skipped = 'skipped',
Unstable = 'unstable',
}
// 入口函数,判断是不是符合的内核
export default function getCanvasFingerprint(): Promise<CanvasFingerprint> {
return getUnstableCanvasFingerprint(doesBrowserPerformAntifingerprinting())
}
export async function getUnstableCanvasFingerprint(skipImages?: boolean): Promise<CanvasFingerprint> {
let winding = false
let geometry: string
let text: string
const [canvas, context] = makeCanvasContext() // 生成一个1*1的canvas
if (!isSupported(canvas, context)) { // 判断是不是有todataurl方法(转base64)
geometry = text = ImageStatus.Unsupported
} else {
winding = doesSupportWinding(context) // 这里是特性判断,可以先不用管,不影响结果
if (skipImages) {
geometry = text = ImageStatus.Skipped
} else {
;[geometry, text] = await renderImages(canvas, context) // image测试
}
}
return { winding, geometry, text }
}
function makeCanvasContext() {
const canvas = document.createElement('canvas')
canvas.width = 1
canvas.height = 1
return [canvas, canvas.getContext('2d')] as const
}
function isSupported(
canvas: HTMLCanvasElement,
context?: CanvasRenderingContext2D | null,
): context is CanvasRenderingContext2D {
return !!(context && canvas.toDataURL)
}
function doesSupportWinding(context: CanvasRenderingContext2D) {
context.rect(0, 0, 10, 10)
context.rect(2, 2, 6, 6)
return !context.isPointInPath(5, 5, 'evenodd')
}
async function renderImages(
canvas: HTMLCanvasElement,
context: CanvasRenderingContext2D,
): Promise<[geometry: string, text: string]> {
renderTextImage(canvas, context) // 渲染一些文字和emoji
await releaseEventLoop()
const textImage1 = canvasToString(canvas)
const textImage2 = canvasToString(canvas)
if (textImage1 !== textImage2) {
return [ImageStatus.Unstable, ImageStatus.Unstable] // 两次canvas的结果不稳定
}
renderGeometryImage(canvas, context) // 这里是画几何图形
await releaseEventLoop()
const geometryImage = canvasToString(canvas)
return [textImage1, geometryImage]
}
function renderTextImage(canvas: HTMLCanvasElement, context: CanvasRenderingContext2D) {
canvas.width = 240
canvas.height = 60
context.textBaseline = 'alphabetic'
context.fillStyle = '#f60'
context.fillRect(100, 1, 62, 20)
context.fillStyle = '#069'
context.font = '11pt "Times New Roman"'
const printedText = `Cwm fjordbank gly ${String.fromCharCode(55357, 56835) /* 😃 */}`
context.fillText(printedText, 2, 15)
context.fillStyle = 'rgba(102, 204, 0, 0.2)'
context.font = '18pt Arial'
context.fillText(printedText, 4, 45)
}
function renderGeometryImage(canvas: HTMLCanvasElement, context: CanvasRenderingContext2D) {
canvas.width = 122
canvas.height = 110
context.globalCompositeOperation = 'multiply'
for (const [color, x, y] of [
['#f2f', 40, 40],
['#2ff', 80, 40],
['#ff2', 60, 80],
] as const) {
context.fillStyle = color
context.beginPath()
context.arc(x, y, 40, 0, Math.PI * 2, true)
context.closePath()
context.fill()
}
context.fillStyle = '#f9c'
context.arc(60, 60, 60, 0, Math.PI * 2, true)
context.arc(60, 60, 20, 0, Math.PI * 2, true)
context.fill('evenodd')
}
function canvasToString(canvas: HTMLCanvasElement) {
return canvas.toDataURL()
}
function doesBrowserPerformAntifingerprinting() {
return isWebKit() && isWebKit616OrNewer() && isSafariWebKit()
}
通过系统字体
原理是判断浏览器/操作系统对字体的支持程度,代码如下
const testString = 'mmMwWLliI0O&1'
const textSize = '48px'
const baseFonts = ['monospace', 'sans-serif', 'serif'] as const
const fontList = [
'sans-serif-thin',
'ARNO PRO',
'Agency FB',
'Arabic Typesetting',
'Arial Unicode MS',
'AvantGarde Bk BT',
'BankGothic Md BT',
'Batang',
'Bitstream Vera Sans Mono',
'Calibri',
'Century',
'Century Gothic',
'Clarendon',
// 还有一大堆,为了展示长度就不列举了
] as const
export default function getFonts(): Promise<string[]> {
return withIframe(async (_, { document }) => {
const holder = document.body
holder.style.fontSize = textSize
const spansContainer = document.createElement('div')
spansContainer.style.setProperty('visibility', 'hidden', 'important') // 整个隐藏div
const defaultWidth: Partial<Record<string, number>> = {}
const defaultHeight: Partial<Record<string, number>> = {}
const createSpan = (fontFamily: string) => {
const span = document.createElement('span')
const { style } = span
style.position = 'absolute'
style.top = '0'
style.left = '0'
style.fontFamily = fontFamily
span.textContent = testString
spansContainer.appendChild(span)
return span
}
// 分别遍历baseFonts和fontList,生成span标签,并设置对应的fontFamily,注意fontList遍历的时候,需要同时遍历baseFonts,这样设置fontFamily的时候可以设置默认的baseFonts字体
const createSpanWithFonts = (fontToDetect: string, baseFont: string) => {
return createSpan(`'${fontToDetect}',${baseFont}`)
}
const initializeBaseFontsSpans = () => {
return baseFonts.map(createSpan)
}
const initializeFontsSpans = () => {
const spans: Record<string, HTMLSpanElement[]> = {}
for (const font of fontList) {
spans[font] = baseFonts.map((baseFont) => createSpanWithFonts(font, baseFont))
}
return spans
}
// 比较fontList和baseFonts的字体文案在不同的fontFamily下的宽高,如果不相等说明支持该字体,如果相等,说明系统不支持fontList的字体,使用了默认的baseFonts的字体
const isFontAvailable = (fontSpans: HTMLElement[]) => {
return baseFonts.some(
(baseFont, baseFontIndex) =>
fontSpans[baseFontIndex].offsetWidth !== defaultWidth[baseFont] ||
fontSpans[baseFontIndex].offsetHeight !== defaultHeight[baseFont],
)
}
const baseFontsSpans = initializeBaseFontsSpans()
const fontsSpans = initializeFontsSpans()
holder.appendChild(spansContainer)
await releaseEventLoop()
for (let index = 0; index < baseFonts.length; index++) {
defaultWidth[baseFonts[index]] = baseFontsSpans[index].offsetWidth
defaultHeight[baseFonts[index]] = baseFontsSpans[index].offsetHeight
}
// 对比
return fontList.filter((font) => isFontAvailable(fontsSpans[font]))
})
}
MurmurHash3
最后简单介绍一下 hash 函数,用的是 MurmurHash 一致性哈希算法
MurmurHash 是一种经过广泛测试且速度很快的非加密哈希函数。它有 Austin Appleby 于 2008 年创建,并存在多种变体,名字来自两个基本运算,即 multiply(乘法)和 rotate(旋转)(尽管该算法实际上使用 shift 和 xor 而不是 rotate)。最终产生 32 位或 128 位哈希,
https://blog.csdn.net/qq_44932835/article/details/122292320
mac 地址
mac 地址理论上是硬件地址,是唯一的,虽然还是会被人工修改
在浏览器环境下只有 ie 能够获取到,所以这里也不多阐述了
总结
本文介绍了无头浏览器的使用和浏览器的相关特征,希望以后同学们遇到有关的需求的时候能想的起来。
无头浏览器与浏览器特征
最近安全这块在做无头浏览器的检测,并且之前也有同学遇到过需要判断用户特征的唯一性问题,这里介绍一下
无头浏览器介绍
无头浏览器指的是没有图形用户界面的浏览器。
wiki: 无头浏览器在类似于流行网络浏览器的环境中提供对网页的自动控制,但是通过命令行界面或使用网络通信来执行。 它们对于测试网页特别有用,因为它们能够像浏览器一样呈现和理解超文本标记语言,包括页面布局、颜色、字体选择以及 JavaScript 和 AJAX 的执行等样式元素,这些元素在使用其他测试方法时通常是不可用的。
无头浏览器通常用来:
目前常见的无头浏览器有如下几种
PhantomJS 2018 年之后开始就不在维护了,所以目前不把这个项目作为使用无头浏览器的首选项目
无头浏览器架构
这里 puppeteer 官方提供的架构图,可以看到 puppeteer 的方案是通过 Chrome DevTool Protocol 作为通讯中间层和 Chrome 的无头模式进行交互的,其余部分和我们正常的 Chrome 浏览器大同小异。
无头浏览器使用
puppeteer 由于是通过 node 启动,相对来说对与前端工程师更为友好,这里就不过多赘述了。Selenium 对于三方/测试同学来说更为常见,便于之后演示,我们用 Selenium,与 python 语言进行举例。
python2 与 python3 之间跨度比较大,我这里使用 python3 来举例。
与 package.json 类似,python 里面是通过 requirements.txt 文件作为版本控制
然后我们执行
这里 selenium 官方文档有一个比较基础的例子 我们进行下相关修改
执行: python3 main.py
我们简单分析下这段 python 代码,他做了如下 3 件事
执行结果如下
原来的界面是这个样子的
自动提交后的界面是这个样子的
可以看到自动执行了一些函数
三方与无头浏览器
或多或少大家都听过三方攻击。三方或者叫做黑产一般使用无头浏览器来抓取我们的数据,或者通过无头浏览器来实现自动化操作。但是如果三方单纯使用无头浏览器,是比较容易被检测出来相关的特征,除了大家熟悉的 ua 以外,还有这些数据
webdriver
在一般的浏览器里面,他是这个样子
而在无头浏览器里面,是这个样子
除此之外还有一些参数,这一些参数都是针对 navigator 相关内容进行判断
有矛才有盾,针对无头浏览器的几个特征点,有相关的覆盖 js 被开发出来用来隐藏这些特征,目前大范围在使用的是 puppeteer 中的一个第三方维护的插件: puppeteer-extra-plugin-stealth
plugin
我们来读一下他的源码,首先看下他的源码结构
关键点我们看下,一个是 pkg.json, 一个是 index.js
pkg.json
{ "name": "puppeteer-extra-plugin-stealth", "version": "2.11.2", "description": "Stealth mode: Applies various techniques to make detection of headless puppeteer harder.", "main": "index.js", // 入口文件 "typings": "index.d.ts", "repository": "berstend/puppeteer-extra", "homepage": "https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth#readme", "author": "berstend", "license": "MIT", "scripts": { "docs": "run-s docs-for-plugin postdocs-for-plugin docs-for-evasions postdocs-for-evasions types", "docs-for-plugin": "documentation readme --quiet --shallow --github --markdown-theme transitivebs --readme-file readme.md --section API index.js", "postdocs-for-plugin": "npx prettier --write readme.md", "docs-for-evasions": "cd ./evasions && loop \"documentation readme --quiet --shallow --github --markdown-theme transitivebs --readme-file readme.md --section API index.js\"", "postdocs-for-evasions": "cd ./evasions && loop \"npx prettier --write readme.md\"", "lint": "eslint --ext .js .", "test:js": "ava --concurrency 2 -v", "test": "run-p test:js", "test-ci": "run-s test:js", "types": "npx --package typescript@3.7 tsc --emitDeclarationOnly --declaration --allowJs index.js" }, "engines": { "node": ">=8" }, "keywords": [ "puppeteer", "puppeteer-extra", "puppeteer-extra-plugin", "stealth", "stealth-mode", "detection-evasion", "crawler", "chrome", "headless", "pupeteer" ], "ava": { "files": ["!test/util.js", "!test/fixtures/sw.js"] }, "devDependencies": { "ava": "2.4.0", "documentation-markdown-themes": "^12.1.5", "fpcollect": "^1.0.4", "fpscanner": "^0.1.5", "loop": "^3.0.6", "npm-run-all": "^4.1.5", "puppeteer": "9" }, "dependencies": { "debug": "^4.1.1", "puppeteer-extra-plugin": "^3.2.3", "puppeteer-extra-plugin-user-preferences": "^2.4.1" }, "peerDependencies": { "playwright-extra": "*", "puppeteer-extra": "*" }, "peerDependenciesMeta": { "puppeteer-extra": { "optional": true }, "playwright-extra": { "optional": true } }, "gitHead": "babb041828cab50c525e0b9aab02d58f73416ef3" }我们看下 index.js
可以看到直接用了一个 set 来存放所有的修改代码,这个逻辑在 evasions 里面,接下来我把关键代码举例
后面的代码判断参数是否有无就不列举了
其他都是类似的,就不举例了,我们看下 webdriver
这里有点不太一样
通过这个网站下载到本地
https://raw.githubusercontent.com/requireCool/stealth.min.js/main/stealth.min.js
然后 python 脚本如下
然后我们启动,会发现页面代理到了本地的代理工具("--proxy-server=127.0.0.1:8888")
然后我们看下 webdriver 是否注入成功
直接给弄成 undefined 的了
代理也代理上了
攻防
其实这一块是非常难写的,我这里给出结论,目前现在所有开源的识别无头浏览器的方案全部都无效
比如以下几个项目
https://infosimples.github.io/detect-headless/
https://bot.sannysoft.com/
https://github.com/antoinevastel/fpscanner/blob/master/src/fpScanner.js
这些都是 star 比较多的,全部都失去效果了
目前的主流做法都是需要借助算法,比如用户行为操作(鼠标,键盘事件等),然后配合 ua 等一些字段上报来计算
设备指纹
之前我们有同学需要判断唯一性,这里需要用到设备指纹
但是本质上无头浏览器的检测和设备指纹是一致的,都是检测浏览器的特征
这个库是比较有名的 https://github.com/fingerprintjs/fingerprintjs
具体原理是把各个部分当成组件分别实现,然后使用 loadBuiltinSources 方法获取所有组件,接着使用 componentsToCanonicalString 遍历组件的返回值,系列化拼接成字符串,最后用 x64hash128 方法把字符串转成一个独一无二的 hash 值。
这里我们先看下有哪些是浏览器特征
我们看几个有意思的选项
canvas 指纹
通过 canvas 接口在页面上绘制一个隐藏的图像,在不同的系统,浏览器中最终的图像是存在像素级别的差别的,主要是因为操作系统各自使用了不同的设置和算法来进行抗锯齿和子像素渲染操作。即使相同的绘图操作,产生的图片数据的 CRC 检验也不相同。(CRC 是指使用 canvas.toDataURL 返回的 base64 数据中,最后一段 32 位的验证码)。
canvas 指纹并不少见,除此之外,还有使用音频指纹,甚至使用 WebGL 指纹和 WebGL 指纹的。 音频指纹跟 canvas 指纹的原理差不多,都是利用硬件或软件的差异,前者生成音频,后者生成图片,然后计算得到不同哈希值来作为标识。音频指纹的生成方式有两种:
这里 fingerprintjs 用的是方法 1,走快速傅立叶变换的方案,不多赘述了
我们看下 canvas 指纹的代码
通过系统字体
原理是判断浏览器/操作系统对字体的支持程度,代码如下
MurmurHash3
最后简单介绍一下 hash 函数,用的是 MurmurHash 一致性哈希算法
MurmurHash 是一种经过广泛测试且速度很快的非加密哈希函数。它有 Austin Appleby 于 2008 年创建,并存在多种变体,名字来自两个基本运算,即 multiply(乘法)和 rotate(旋转)(尽管该算法实际上使用 shift 和 xor 而不是 rotate)。最终产生 32 位或 128 位哈希,
https://blog.csdn.net/qq_44932835/article/details/122292320
mac 地址
mac 地址理论上是硬件地址,是唯一的,虽然还是会被人工修改
在浏览器环境下只有 ie 能够获取到,所以这里也不多阐述了
总结
本文介绍了无头浏览器的使用和浏览器的相关特征,希望以后同学们遇到有关的需求的时候能想的起来。