背景
由于很多headless瀏覽器的webgl信息比較明顯正卧,如果源站嘗試采集webgl參數(shù)會(huì)暴露自動(dòng)化工具的特征春宣,所以黑產(chǎn)攻擊中需要去欺騙webgl的信息上報(bào)既峡。
目前在github上可以找到一個(gè)spoof webgl的項(xiàng)目预茄,star數(shù)并不多隔显,但其思路應(yīng)該是比較主流的hook webgl相關(guān)接口的方式崎溃。本文主要對該工具的使用和源碼進(jìn)行分析蜻直。
這份代碼并不完美,甚至能找到幾處bug,但不妨礙我們學(xué)習(xí)其思想概而;github地址:https://github.com/siejqa/spoofHeadless
背景知識(shí)簡單介紹
Webgl和參數(shù)采集
簡單來說webgl就是瀏覽器給前端js代碼調(diào)用的渲染繪圖API呼巷,該API可以在在html canvas元素中使用,可以調(diào)用到硬件進(jìn)行加速赎瑰,所以webgl的參數(shù)通常與硬件強(qiáng)相關(guān)王悍。更具體的介紹和教程可以參考:https://www.w3cschool.cn/webgl/i4gf1oh1.html
具體采集webgl的參數(shù)時(shí),需要首先先獲取canvas下的webgl Context餐曼,使用getContext接口压储。而采集具體參數(shù)是使用getParameter函數(shù)完成,getParameter接受一個(gè)整數(shù)源譬,每個(gè)整數(shù)對應(yīng)一個(gè)屬性;以獲取GPU型號(hào)為例:
// 獲取webgl context
var gl = document.createElement("canvas").getContext("webgl")
// 采集GPU render:編號(hào)為37446
gl.getExtension("WEBGL_debug_renderer_info")["UNMASKED_RENDERER_WEBGL"]
gl.getParameter(37446)
完整的getParameter常量表可以參考:https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/Constants
Webdriver
webdriver本質(zhì)上是瀏覽器根據(jù)w3c實(shí)現(xiàn)的一套操作瀏覽器的接口踩娘,而每個(gè)瀏覽器都有一個(gè)特定的 WebDriver 實(shí)現(xiàn)刮刑,如chrome webdriver:https://chromedriver.chromium.org/downloads
而目前比較廣義的定義(或者說黑產(chǎn)使用的方式)翘紊,通常是指puppeteer/selenium這類中捆,集成了多種瀏覽器泄伪,并提供高級(jí)api供上層應(yīng)用調(diào)用的自動(dòng)化工具津函;可以直接使用python(selenium)和nodejs(puppeteer)來編寫腳本魂那,完成webdriver的控制涯雅,從而完成瀏覽器上的自動(dòng)化操作划乖。相關(guān)資料可以自行搜索學(xué)習(xí)仰美。
SpoofWebGL使用方法
此處介紹如何在selenium使用SpoofWebGL工具,當(dāng)然該工具簡單改造后可以在所有的webdriver上使用。
- 將項(xiàng)目clone下來之后壤圃,使用可以看到src文件夾下有兩個(gè)文件效床,其中manifest.json是extension的配置文件,injected是源碼卤妒。
- 之后用zip命令將src文件夾打包:zip -rj extension.zip src/
- 將zip后綴名改成.crx(chrome extension的后綴名) :mv extension.zip extension.crx
- 編寫webdriver腳本如下(注意要先安裝好selenium和chrome webdriver)士复,去觀察我們的webgl參數(shù)讀取情況(注意原項(xiàng)目中使用的是firefox的webdriver,所以腳本要做修改):
from selenium import webdriver
opt = webdriver.ChromeOptions()
extension_path = './extension.crx'
opt.add_extension(extension_path)
driver = webdriver.Chrome(options=opt)
# Check what data is spoofed
driver.get('https://browserleaks.com/webgl')
可以看到這個(gè)vendor和render已經(jīng)不太正常了;
- 作為對比,注釋掉options直接啟動(dòng)捻脖,會(huì)顯示本機(jī)的真實(shí)GPU:
from selenium import webdriver
# opt = webdriver.ChromeOptions()
#
# extension_path = './extension.crx'
# opt.add_extension(extension_path)
driver = webdriver.Chrome()
# Check what data is spoofed
driver.get('https://browserleaks.com/webgl')
注:此處是使用瀏覽器界面模式打開的抛寝,實(shí)際上如果是啟動(dòng)headless模式晶府,該renderer會(huì)和本機(jī)的有差別剂习,這也是為什么要使用spoof webgl的原因
源碼分析
總結(jié)來說拂封,該extension是將webgl相關(guān)的接口全部進(jìn)行了hook萧恕,本質(zhì)技術(shù)難度上并不大吆视,且可以很容易進(jìn)行定制化拙寡。下面開始對hook方法進(jìn)行分析
webdriver相關(guān)繞過
開始的第一部分跟webgl檢測關(guān)系不大,主要是用defineProperty方法對navigator下一些字段進(jìn)行了hook淮摔,繞過webdriver相關(guān)的一些檢測厕隧;主要是設(shè)置上瀏覽器語言,以及將Navigator.webdriver置為false:
Object.defineProperty(navigator, 'languages', {
get: function () {
var availableLanguages = Array('en', 'pl', 'cs', 'ru', 'fr', 'fr-fr', 'lb', 'no')
return ['en-US', get_random_item(availableLanguages)];
},
});
// fake webdriver property (headless has it as true)
Object.defineProperty(navigator, 'webdriver', {
get: () => false,
});
WebGL Hook
根據(jù)上文中webgl調(diào)用示例可知調(diào)用webgl接口采集參數(shù)主要分為三步:
- 使用getContext獲取webgl Context
- 使用context.getExtension獲取webgl拓展的編號(hào)
- 使用context.getParameter獲取具體參數(shù)的值
對應(yīng)步驟我們查看該腳本的hook方法:
HTMLCanvasElement.getContext Hook
要hook該方法拴曲,我們需要先定義一個(gè)類店溢,如下:
function WebGLRenderingContext(canvas) {
this.canvas = canvas;
this.drawingBufferWidth = canvas.width;
this.drawingBufferHeight = canvas.height;
};
之后將WebGLRenderingContext中的基本屬性和方法進(jìn)行初始化叁熔,即對Object.prototype.attribute進(jìn)行賦值一個(gè)空函數(shù)。注意床牧,基礎(chǔ)屬性本質(zhì)上都是一些編號(hào)荣回,如上文中的例子一樣,他是用來傳入getParameter做入?yún)⒌摹?/p>
// 原webgl Context中的基本方法集合
var functions = [
'viewport',
'vertexAttribPointer',
'vertexAttrib4fv',
'vertexAttrib4f',
'vertexAttrib3fv',
...
]
// 原webgl Context中的基本屬性集合戈咳,這里挑選一些經(jīng)常被收集的作為例子
var enumerates = {
...
'VERSION': 7938,
...
'UNMASKED_VENDOR_WEBGL': 37445,
'UNMASKED_RENDERER_WEBGL': 37446,
...
'DEPTH_BITS': 3414,
'GREEN_BITS': 3411,
'BLUE_BITS': 3412,
...
'STENCIL_BITS': 3415,
...
'MAX_VERTEX_UNIFORM_VECTORS': 36347,
'MAX_VERTEX_TEXTURE_IMAGE_UNITS': 35660,
'MAX_VERTEX_ATTRIBS': 34921,
'MAX_VARYING_VECTORS': 36348,
'MAX_TEXTURE_SIZE': 3379,
'MAX_TEXTURE_IMAGE_UNITS': 34930,
'MAX_RENDERBUFFER_SIZE': 34024,
'MAX_FRAGMENT_UNIFORM_VECTORS': 36349,
'MAX_CUBE_MAP_TEXTURE_SIZE': 34076,
'MAX_COMBINED_TEXTURE_IMAGE_UNITS': 35661,
...
};
// 將原本的函數(shù)全部替換成空函數(shù)
functions.forEach(function (func) {
WebGLRenderingContext.prototype[func] = function () {
return {};
};
});
Object.keys(enumerates).forEach(function (key) {
WebGLRenderingContext.prototype[key] = enumerates[key];
});
實(shí)際上原腳本之后馬上對context.getExtension完成了賦值心软,那此處其實(shí)順序不影響執(zhí)行結(jié)果,所以我們留在下一節(jié)描述著蛙。
進(jìn)入hook的代碼删铃,實(shí)際上document.createElement("canvas").getContext("webgl")調(diào)用到的是HTMLCanvasElement.getContext方法,所以對該方法進(jìn)行Hook:
try {
const getContext = HTMLCanvasElement.prototype.getContext;
// 利用重定義HTMLCanvasElement.prototype.getContext完成Hook册踩,是常見的hook方法
HTMLCanvasElement.prototype.getContext = function () {
// 獲取第一個(gè)入?yún)⒂窘悖ǔ?webgl"效拭,'webgl-experimental'等
var name = arguments[0];
console.log("HTMLCanvasElement app requested extension: " + name);
console.log(JSON.stringify(arguments, null, 4));
if (name == 'webgl' || name == 'webgl-experimental' || name == 'experimental-webgl' || name == 'moz-webgl') {
// 最終返回了上文中自定義的類WebGLRenderingContext暂吉,完成hook
var y = new WebGLRenderingContext(this);
console.log("WEBGL " + y);
console.log(JSON.stringify(y, null, 4));
return y;
}
// 其他的webgl類型不支持胖秒,返回原始數(shù)據(jù)
if (name == 'webgl2' || name == 'experimental-webgl2' || name == 'fake-webgl') {
console.log("WEBGL2")
return null;
}
var ext = getContext.apply(this, arguments);
console.log("HTMLCanvasElement extension " + name + " " + (ext ? "found" : "not found"));
console.log(ext);
return ext;
}
} catch (e) { }
context.getExtension定義
實(shí)際上很簡單,只需要get對應(yīng)屬性時(shí)返回指定編號(hào)即可慕的,此處以上文中的"WEBGL_debug_renderer_info"為例子:
var extensions = {
// ratified
...
'WEBGL_debug_renderer_info': {
'UNMASKED_VENDOR_WEBGL': 37445,
'UNMASKED_RENDERER_WEBGL': 37446
},
...
}
WebGLRenderingContext.prototype.getExtension = function (ext) {
console.log("WebGLRenderingContext.getExtension" + ext);
return extensions[ext];
};
注意此處有一些特例是"WEBGL_lose_context"和
"WEBGL_draw_buffers", 他們的屬性內(nèi)部包含方法阎肝,需要定義一下:
function loseContext () {
}
function restoreContext () {
}
function drawBuffersWEBGL () {
}
var extensions = {
// ratified
...
'WEBGL_lose_context': {
loseContext,
restoreContext
},
...
'WEBGL_draw_buffers': {
'MAX_DRAW_BUFFERS_WEBGL': 34852,
'MAX_COLOR_ATTACHMENTS_WEBGL': 36063,
...
drawBuffersWEBGL
},
}
context.getParameter 定義,完成取值的Hook
代碼可以拆解如下:
- 定義部分肮街,拿到getParameter的參數(shù):
try {
const getParameter = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function () {
var name = arguments[0];
console.log("WebGLRenderingContext - getParameter: " + name);
...
} catch (a) { }
- Hook UNMASKED_VENDOR_WEBGL 和UNMASKED_RENDERER_WEBGL 參數(shù)风题,從一個(gè)備選列表中隨機(jī)返回一個(gè)vendor/renderer,可以很好的防止收集信息結(jié)果過度集中嫉父,也可以很方便的進(jìn)行拓展:
function get_random_item(list) {
return list[Math.floor((Math.random() * list.length))];
}
WebGLRenderingContext.prototype.getParameter = function () {
...
// UNMASKED_VENDOR_WEBGL
if (name == 37445) {
var options = ['Intel Open Source Technology Center', 'X.Org', 'Vendor Google Inc.'];
return get_random_item(options);
} else if (name == 37446) {
// UNMASKED_RENDERER_WEBGL
var options = ['Mesa DRI Intel(R) Ivybridge Mobile', 'AMD KAVERI (DRM 2.43.0 / 4.4.0-119-generic, LLVM 5.0.0)', 'Renderer Google SwiftShader', 'AMD ARUBA (DRM 2.43.0 / 4.4.0-119-generic, LLVM 5.0.0)', 'Mesa DRI Intel(R) HD Graphics 630 (Kaby Lake GT2)', 'Gallium 0.4 on AMD KAVERI (DRM 2.43.0 / 4.4.0-83-generic, LLVM 3.8.0)'];
return get_random_item(options);
}
...
}
- Hook 一些基礎(chǔ)屬性, 如RENDERER / VENDOR / SHADING_LANGUAGE_VERSION /
VERSION
WebGLRenderingContext.prototype.getParameter = function () {
...
else if (name == 7937 || name == 7936) {
// RENDERER // VENDOR
return 'Mozilla';
} else if (name == 35724) {
// SHADING_LANGUAGE_VERSION
return 'WebGL GLSL ES 1.0';
} else if (name == 7937 || name == 7938) {
// VERSION
return 'WebGL 1.0';
}
...
}
- Hook ALIASED_LINE_WIDTH_RANGE / ALIASED_POINT_SIZE_RANGE, 會(huì)返回一個(gè)float array沛硅,size為2;這里代碼有點(diǎn)小問題绕辖,不影響功能摇肌,name == 7937是VERSION,不過在上面已經(jīng)判斷過了仪际,不會(huì)進(jìn)到這個(gè)分支:
WebGLRenderingContext.prototype.getParameter = function () {
...
else if (name == 7937 || name == 33901 || name == 33902) {
// ALIASED_LINE_WIDTH_RANGE // ALIASED_POINT_SIZE_RANGE
var option = new Float32Array([1, 8192]);
return option;
}
...
}
- 針對一些webgl位寬信息進(jìn)行Hook围小,返回隨機(jī)值[2, 4, 8, 16]中1個(gè),具體參數(shù)見注釋:
WebGLRenderingContext.prototype.getParameter = function () {
...
else if (name == 3413 || name == 3412 || name == 3411 || name == 3410 || name == 34852) {
// ALPHA_BITS // BLUE_BITS // GREEN_BITS // RED_BITS // MAX_DRAW_BUFFERS_WEBGL
return get_random_item([2, 4, 8, 16]);
}
...
}
- 針對一些位寬信息進(jìn)行Hook树碱,返回固定值肯适,參數(shù)見注釋
WebGLRenderingContext.prototype.getParameter = function () {
...
else if (name == 3415)
// STENCIL_BITS
return 0;
} else if (name == 3414) {
// DEPTH_BITS
return 24;
}
...
}
- 接下來是該腳本bug的地方,Hook出現(xiàn)問題成榜,如果使用該腳本不加修改框舔,很容易通過此bug識(shí)別;原因主要在于以下hook的三個(gè)參數(shù)值理論上是返回一個(gè)整數(shù)赎婚,但不知為何作者這里使用了get_random_items, 但沒有給第二個(gè)參數(shù)雨饺,所以n會(huì)為undefined,導(dǎo)致固定返回一個(gè)Array:undefined惑淳;修復(fù)也很簡單额港,換成get_random_item即可。源代碼如下:
function get_random_items(list, n) {
var result = new Array(n),
len = list.length,
taken = new Array(len);
if (n > len)
n = len
while (n--) {
var x = Math.floor(Math.random() * len);
result[n] = list[x in taken ? taken[x] : x];
// 比較巧妙的取隨機(jī)多個(gè)值的方式歧焦,留一個(gè)array標(biāo)記如果下次再取到其下標(biāo)會(huì)從目前未取成的最后一個(gè)元素
taken[x] = --len in taken ? taken[len] : len;
}
return result;
}
WebGLRenderingContext.prototype.getParameter = function () {
...
else if (name == 34047 || name == 34921) {
// MAX_TEXTURE_MAX_ANISOTROPY_EXT // MAX_VERTEX_ATTRIBS
return get_random_items([2, 4, 8, 16]);
} else if (name == 35661) {
// MAX_COMBINED_TEXTURE_IMAGE_UNITS
return get_random_items([128, 192, 256]);
}
...
}
- 對一些其他的MAX相關(guān)屬性進(jìn)行Hook移斩,返回隨機(jī)值,具體屬性見注釋
WebGLRenderingContext.prototype.getParameter = function () {
...
} else if (name == 34076 || name == 34024 || name == 3379) {
// MAX_CUBE_MAP_TEXTURE_SIZE // MAX_RENDERBUFFER_SIZE
return get_random_item([16384, 32768]) ;
} else if (name == 36349 || name == 36347) {
// MAX_FRAGMENT_UNIFORM_VECTORS // MAX_VERTEX_UNIFORM_VECTORS
return get_random_item([4096, 8192]);
} else if (name == 34930 || name == 36348 || name == 35660) {
// MAX_TEXTURE_IMAGE_UNITS // MAX_VARYING_VECTORS // MAX_VERTEX_TEXTURE_IMAGE_UNITS
return get_random_item([16, 32, 64]);
}
...
}
- 對MAX_VIEWPORT_DIMS進(jìn)行Hook绢馍,會(huì)返回一個(gè)長度為2且兩個(gè)值相等的Int32Array向瓷,同樣此處隨機(jī)取值:
WebGLRenderingContext.prototype.getParameter = function () {
...
else if (name == 3386) {
// MAX_VIEWPORT_DIMS
var value = get_random_item([8192, 16384, 32768])
var options = new Int32Array([value, value]);
return options;
}
...
}
- 最后,剩下的參數(shù)統(tǒng)一隨機(jī)從[0, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096]隨機(jī)取值返回(此處還有個(gè)冗余分支STENCIL_BITS舰涌,上面已經(jīng)判斷過了猖任,屬于冗余代碼)
WebGLRenderingContext.prototype.getParameter = function () {
...
else {
console.log("Retuning random value for: " + name);
return get_random_item([0, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096]);
}
...
}
- 最后的迷惑操作:理論上此處已經(jīng)涵蓋了所有的case返回,但是最后還多了個(gè)跑不到的分支:
WebGLRenderingContext.prototype.getParameter = function () {
...
var ext = getParameter.apply(this, arguments);
console.log("WebGLRenderingContext extension " + name + " " + (ext ? "found" : "not found"));
console.log(JSON.stringify(ext, null, 4));
return ext;
}
說實(shí)話我猜測此處他是想模擬一些參數(shù)瓷耙,他們在getParameter之前必須先調(diào)用getExtension方法后才可以獲取朱躺,但是此處加在最后屬實(shí)看不懂刁赖,個(gè)人理解應(yīng)該放在這個(gè)大if...else...前面;有時(shí)間我可以好好修復(fù)一下這個(gè)項(xiàng)目????
其他的一些被Hook的方法
- getSupportedExtension:比較簡單长搀,隨機(jī)從extensions中間選擇隨機(jī)個(gè)keys并返回宇弛,出現(xiàn)異常則將所有的keys都返回。
// extensions的keys可以參見getExtension部分
const getSupportedExtensions = WebGLRenderingContext.prototype.getSupportedExtensions;
WebGLRenderingContext.prototype.getSupportedExtensions = function () {
try {
console.log("WebGLRenderingContext.getSupportedExtensions")
var availableExtensions = Object.keys(extensions);
console.log(availableExtensions);
var itemsToGet = Math.floor(Math.random() * (availableExtensions.length - 6) + 5);
console.log(itemsToGet);
var selectedExtensions = get_random_items(availableExtensions, itemsToGet);
console.log(selectedExtensions);
return selectedExtensions;
} catch (a) {
console.log(a)
return Object.keys(extensions);
}
}
- 針對一些headless瀏覽器有可能會(huì)出現(xiàn)canvas的一些屬性異常(broken會(huì)為0)源请,如canvas的width和height枪芒,以及offset,進(jìn)行Hook谁尸,還是使用defineProperty重寫get方法對屬性進(jìn)行hook:
// in case of broken image return random height/width
var size = 0;
['height', 'width'].forEach(property => {
const imageDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, property);
Object.defineProperty(HTMLImageElement.prototype, property, {
imageDescriptor,
get: function () {
// 如果canvas破損舅踪,則返回隨機(jī)size
if (this.complete && this.naturalHeight == 0) {
if (!size) {
// 返回隨機(jī)的長/寬
size = Math.floor(Math.random() * (30 - 10 + 1)) + 10;
}
return size;
}
// 未破損則返回正常size
return imageDescriptor.get.apply(this);
},
});
});
// hairline feature (headless can't render it normally)
const imageDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight');
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
...imageDescriptor,
get: function () {
if (this.id == 'modernizr') {
return 1;
}
return imageDescriptor.get.apply(this);
},
});
插件執(zhí)行
方法比較簡單,將整個(gè)大函數(shù)作為字符串良蛮,最后在html document中新建一個(gè)script tag硫朦,script.textContent賦值為字符串即可:
var scriptCode = '(' + function () {
...
function WebGLRenderingContext(canvas) {
...
};
...
WebGLRenderingContext.prototype.getExtension = function (ext) {
...
};
...
WebGLRenderingContext.prototype.getParameter = function () {
...
}
...
} + ')();'; // 轉(zhuǎn)成字符串,可直接執(zhí)行
// 新建script節(jié)點(diǎn)插入document中背镇,即自動(dòng)執(zhí)行
var script = document.createElement('script');
script.textContent = scriptCode;
(document.head || document.documentElement).appendChild(script);
// 最后move掉代碼即可
script.remove();