背景
由于很多headless浏览器的webgl信息比较明显,如果源站尝试采集webgl参数会暴露自动化工具的特征,所以黑产攻击中需要去欺骗webgl的信息上报。
目前在github上可以找到一个spoof webgl的项目,star数并不多,但其思路应该是比较主流的hook webgl相关接口的方式。本文主要对该工具的使用和源码进行分析。
这份代码并不完美,甚至能找到几处bug,但不妨碍我们学习其思想;github地址:https://github.com/siejqa/spoofHeadless
背景知识简单介绍
Webgl和参数采集
简单来说webgl就是浏览器给前端js代码调用的渲染绘图API,该API可以在在html canvas元素中使用,可以调用到硬件进行加速,所以webgl的参数通常与硬件强相关。更具体的介绍和教程可以参考:https://www.w3cschool.cn/webgl/i4gf1oh1.html
具体采集webgl的参数时,需要首先先获取canvas下的webgl Context,使用getContext接口。而采集具体参数是使用getParameter函数完成,getParameter接受一个整数,每个整数对应一个属性;以获取GPU型号为例:
1
2
3
4
5
|
// 获取webgl context
var gl = document.createElement("canvas").getContext("webgl")
// 采集GPU render:编号为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本质上是浏览器根据w3c实现的一套操作浏览器的接口,而每个浏览器都有一个特定的 WebDriver 实现,如chrome webdriver:https://chromedriver.chromium.org/downloads
而目前比较广义的定义(或者说黑产使用的方式),通常是指puppeteer/selenium这类,集成了多种浏览器,并提供高级api供上层应用调用的自动化工具;可以直接使用python(selenium)和nodejs(puppeteer)来编写脚本,完成webdriver的控制,从而完成浏览器上的自动化操作。相关资料可以自行搜索学习。
SpoofWebGL使用方法
此处介绍如何在selenium使用SpoofWebGL工具,当然该工具简单改造后可以在所有的webdriver上使用。
- 将项目clone下来之后,使用可以看到src文件夹下有两个文件,其中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参数读取情况(注意原项目中使用的是firefox的webdriver,所以脚本要做修改):
1
2
3
4
5
6
7
8
9
10
|
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')
|

可以看到这个vendor和render已经不太正常了;
- 作为对比,注释掉options直接启动,会显示本机的真实GPU:
1
2
3
4
5
6
7
8
9
10
|
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')
|

注:此处是使用浏览器界面模式打开的,实际上如果是启动headless模式,该renderer会和本机的有差别,这也是为什么要使用spoof webgl的原因
源码分析
总结来说,该extension是将webgl相关的接口全部进行了hook,本质技术难度上并不大,且可以很容易进行定制化。下面开始对hook方法进行分析
webdriver相关绕过
开始的第一部分跟webgl检测关系不大,主要是用defineProperty方法对navigator下一些字段进行了hook,绕过webdriver相关的一些检测;主要是设置上浏览器语言,以及将Navigator.webdriver置为false:
1
2
3
4
5
6
7
8
9
10
|
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
根据上文中webgl调用示例可知调用webgl接口采集参数主要分为三步:
- 使用getContext获取webgl Context
- 使用context.getExtension获取webgl拓展的编号
- 使用context.getParameter获取具体参数的值
对应步骤我们查看该脚本的hook方法:
HTMLCanvasElement.getContext Hook
要hook该方法,我们需要先定义一个类,如下:
1
2
3
4
5
|
function WebGLRenderingContext(canvas) {
this.canvas = canvas;
this.drawingBufferWidth = canvas.width;
this.drawingBufferHeight = canvas.height;
};
|
之后将WebGLRenderingContext中的基本属性和方法进行初始化,即对Object.prototype.attribute进行赋值一个空函数。注意,基础属性本质上都是一些编号,如上文中的例子一样,他是用来传入getParameter做入参的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
// 原webgl Context中的基本方法集合
var functions = [
'viewport',
'vertexAttribPointer',
'vertexAttrib4fv',
'vertexAttrib4f',
'vertexAttrib3fv',
...
]
// 原webgl Context中的基本属性集合,这里挑选一些经常被收集的作为例子
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,
...
};
// 将原本的函数全部替换成空函数
functions.forEach(function (func) {
WebGLRenderingContext.prototype[func] = function () {
return {};
};
});
Object.keys(enumerates).forEach(function (key) {
WebGLRenderingContext.prototype[key] = enumerates[key];
});
|
实际上原脚本之后马上对context.getExtension完成了赋值,那此处其实顺序不影响执行结果,所以我们留在下一节描述。
进入hook的代码,实际上document.createElement(“canvas”).getContext("webgl")调用到的是HTMLCanvasElement.getContext方法,所以对该方法进行Hook:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
try {
const getContext = HTMLCanvasElement.prototype.getContext;
// 利用重定义HTMLCanvasElement.prototype.getContext完成Hook,是常见的hook方法
HTMLCanvasElement.prototype.getContext = function () {
// 获取第一个入参,通常为"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类型不支持,返回原始数据
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定义
实际上很简单,只需要get对应属性时返回指定编号即可,此处以上文中的"WEBGL_debug_renderer_info"为例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
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”, 他们的属性内部包含方法,需要定义一下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
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
代码可以拆解如下:
1
2
3
4
5
6
7
|
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 参数,从一个备选列表中随机返回一个vendor/renderer,可以很好的防止收集信息结果过度集中,也可以很方便的进行拓展:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
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 一些基础属性, 如RENDERER / VENDOR / SHADING_LANGUAGE_VERSION /
VERSION
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
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, 会返回一个float array,size为2;这里代码有点小问题,不影响功能,name == 7937是VERSION,不过在上面已经判断过了,不会进到这个分支:
1
2
3
4
5
6
7
8
9
|
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位宽信息进行Hook,返回随机值[2, 4, 8, 16]中1个,具体参数见注释:
1
2
3
4
5
6
7
8
|
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]);
}
...
}
|
- 针对一些位宽信息进行Hook,返回固定值,参数见注释
1
2
3
4
5
6
7
8
9
10
11
|
WebGLRenderingContext.prototype.getParameter = function () {
...
else if (name == 3415)
// STENCIL_BITS
return 0;
} else if (name == 3414) {
// DEPTH_BITS
return 24;
}
...
}
|
- 接下来是该脚本bug的地方,Hook出现问题,如果使用该脚本不加修改,很容易通过此bug识别;原因主要在于以下hook的三个参数值理论上是返回一个整数,但不知为何作者这里使用了get_random_items, 但没有给第二个参数,所以n会为undefined,导致固定返回一个Array:undefined;修复也很简单,换成get_random_item即可。源代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
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];
// 比较巧妙的取随机多个值的方式,留一个array标记如果下次再取到其下标会从目前未取成的最后一个元素
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相关属性进行Hook,返回随机值,具体属性见注释
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
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进行Hook,会返回一个长度为2且两个值相等的Int32Array,同样此处随机取值:
1
2
3
4
5
6
7
8
9
10
|
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;
}
...
}
|
- 最后,剩下的参数统一随机从[0, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096]随机取值返回(此处还有个冗余分支STENCIL_BITS,上面已经判断过了,属于冗余代码)
1
2
3
4
5
6
7
8
|
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]);
}
...
}
|
- 最后的迷惑操作:理论上此处已经涵盖了所有的case返回,但是最后还多了个跑不到的分支:
1
2
3
4
5
6
7
8
|
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;
}
|
说实话我猜测此处他是想模拟一些参数,他们在getParameter之前必须先调用getExtension方法后才可以获取,但是此处加在最后属实看不懂,个人理解应该放在这个大if…else…前面;有时间我可以好好修复一下这个项目😂😂
其他的一些被Hook的方法
- getSupportedExtension:比较简单,随机从extensions中间选择随机个keys并返回,出现异常则将所有的keys都返回。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
// 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浏览器有可能会出现canvas的一些属性异常(broken会为0),如canvas的width和height,以及offset,进行Hook,还是使用defineProperty重写get方法对属性进行hook:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
// 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破损,则返回随机size
if (this.complete && this.naturalHeight == 0) {
if (!size) {
// 返回随机的长/宽
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);
},
});
|
插件执行
方法比较简单,将整个大函数作为字符串,最后在html document中新建一个script tag,script.textContent赋值为字符串即可:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
var scriptCode = '(' + function () {
...
function WebGLRenderingContext(canvas) {
...
};
...
WebGLRenderingContext.prototype.getExtension = function (ext) {
...
};
...
WebGLRenderingContext.prototype.getParameter = function () {
...
}
...
} + ')();'; // 转成字符串,可直接执行
// 新建script节点插入document中,即自动执行
var script = document.createElement('script');
script.textContent = scriptCode;
(document.head || document.documentElement).appendChild(script);
// 最后move掉代码即可
script.remove();
|