在进行以下操作之前,请确保已完成之前文章中提到的 源码拉取及编译 部分。
如果已顺利完成相关配置,即可继续执行后续操作。
Canvas 指纹技术简介
Canvas 指纹 是一种基于浏览器的隐式用户标识技术,利用 HTML5 的 元素及其绘图功能生成设备或浏览器的唯一标识符。其核心原理是:不同设备、浏览器或操作系统在渲染相同的图形内容时,可能会产生细微的差异。这些差异可以被提取并转换为一个唯一的"指纹",从而用于识别用户的设备或浏览器。
Canvas 指纹的实现过程
以字节某音产品中的 Canvas 指纹生成代码为例,分析其实现过程:
javascript
function canvasFingerprint(e = 3735928559) {
var urlStr;
// 创建 Canvas 元素并设置尺寸
var canvasEl = document.createElement('canvas');
canvasEl.width = 100;
canvasEl.height = 100;
// 获取绘图上下文并绘制内容
var canvas = canvasEl.getContext('2d');
canvas.fillStyle = '#0078d4';
canvas.fillRect(0, 0, 100, 100);
canvas.font = '14px serif'; // 设置字体样式
canvas.fillText('龘ฑภ경', 2, 12); // 绘制多语言字符
canvas.shadowBlur = 2; // 添加阴影效果
canvas.showOffsetX = 1; // 自定义属性(非标准)
canvas.showColor = 'lime'; // 自定义属性(非标准)
canvas.arc(8, 8, 8, 0, 2); // 绘制圆弧
canvas.stroke(); // 描边
// 将 Canvas 内容转换为 Base64 数据 URL
urlStr = canvasEl.toDataURL();
// 基于数据 URL 计算哈希值
for (var i = 0; i < 32; i++) {
e = (65599 * e + urlStr.charCodeAt(e % urlStr.length)) >>> 0;
}
return e; // 返回最终的指纹值
}
// 调用函数生成指纹
canvasFingerprint();
实现步骤解析
-
创建 Canvas 元素: 使用 document.createElement('canvas') 创建一个 元素,并设置其宽度为 100 像素,高度为 100 像素。 这些尺寸经过精心设计,既能保证足够的复杂性,又能避免过高的计算开销。
-
绘制图形内容: 设置字体样式为 14px serif,并在画布上绘制包含多种语言字符的文本 '龘ฑภ경'。这种多语言字符组合增加了渲染结果的多样性。 添加阴影效果(shadowBlur)和绘制一个绿色圆弧(arc),进一步丰富图形内容。
-
提取渲染结果: 使用 canvasEl.toDataURL() 方法将画布内容转换为 Base64 编码的数据 URL。这个 URL 包含了当前设备对图形内容的渲染结果。
-
计算哈希值: 遍历数据 URL 的字符序列,通过一个自定义的哈希算法(基于乘法因子 65599 和字符 ASCII 值)计算出一个 32 位整数作为最终的指纹值。 这个哈希值具有高区分度,能够唯一标识当前设备或浏览器。
在src\third_party\blink\renderer\modules\canvas\canvas2d\canvas_2d_recorder_context.cc所在目录中,新建一个random_hex_color.h文件。文件内容如下
cpp
#ifndef RANDOM_COLOR_H
#define RANDOM_COLOR_H
#include <array>
#include <random>
inline const char* getRandomColorCode() {
const std::array<char, 17> hexChars = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', '\0'};
static std::array<char, 8> colorCode;
static std::mt19937 gen(42);
static std::uniform_int_distribution<> dis(0, 15);
colorCode[0] = '#';
for (int i = 1; i < 7; ++i) {
colorCode[i] = hexChars[dis(gen)];
}
colorCode[7] = '\0';
return colorCode.data();
}
#endif
在src\third_party\blink\renderer\modules\canvas\canvas2d\canvas_2d_recorder_context.cc目录中,找到底下这段函数的源码。
我们以修改 canvas 的 fillStyle 值为例进行说明。对于其他属性的随机化处理,可以参考同样的思路:找到对应的代码位置,并按照类似的方式调整逻辑。这里不再逐一演示,但方法思路是通用的。
如果在这个文件找不到这个代码,全局搜索一下canvas2d文件夹,不同版本的 chromium 源码结构会有些许差别,但功能是一致的
cpp
void Canvas2DRecorderContext::setFillStyle(v8::Isolate* isolate,
v8::Local<v8::Value> value,
ExceptionState& exception_state) {
ValidateStateStack();
CanvasRenderingContext2DState& state = GetState();
// This block is similar to that in setStrokeStyle(), see comments there for
// details on this.
if (value->IsString()) {
// v8::Local<v8::String> v8_string = value.As<v8::String>(); 这行注释
// 新增的
const char* color_code = getRandomColorCode();
// 新增的
v8::Local<v8::String> v8_string = v8::String::NewFromUtf8(isolate, color_code, v8::NewStringType::kNormal).ToLocalChecked();
UpdateIdentifiabilityStudyBeforeSettingStrokeOrFill(
v8_string, CanvasOps::kSetFillStyle);
if (state.IsUnparsedFillColor(v8_string)) {
return;
}
Color parsed_color = Color::kTransparent;
if (!ExtractColorFromV8StringAndUpdateCache(
isolate, v8_string, exception_state, parsed_color)) {
return;
}
if (state.FillStyle().IsEquivalentColor(parsed_color)) {
state.SetUnparsedFillColor(isolate, v8_string);
return;
}
state.SetFillColor(parsed_color);
state.ClearUnparsedFillColor();
state.ClearResolvedFilter();
return;
}
V8CanvasStyle v8_style;
if (!ExtractV8CanvasStyle(isolate, value, v8_style, exception_state)) {
return;
}
UpdateIdentifiabilityStudyBeforeSettingStrokeOrFill(v8_style,
CanvasOps::kSetFillStyle);
switch (v8_style.type) {
case V8CanvasStyleType::kCSSColorValue:
state.SetFillColor(v8_style.css_color_value);
break;
case V8CanvasStyleType::kGradient:
state.SetFillGradient(v8_style.gradient);
break;
case V8CanvasStyleType::kPattern:
if (!origin_tainted_by_content_ && !v8_style.pattern->OriginClean()) {
SetOriginTaintedByContent();
}
state.SetFillPattern(v8_style.pattern);
break;
case V8CanvasStyleType::kString: {
Color parsed_color = Color::kTransparent;
if (ParseColorOrCurrentColor(v8_style.string, parsed_color) ==
ColorParseResult::kParseFailed) {
return;
}
if (!state.FillStyle().IsEquivalentColor(parsed_color)) {
state.SetFillColor(parsed_color);
}
break;
}
}
state.ClearUnparsedFillColor();
state.ClearResolvedFilter();
}
新增头文件

先将v8::Localv8::String v8_string = value.Asv8::String();这行注释掉,然后新增底下两行代码。

修改完了之后保存,然后执行以下命令编译
sh
autoninja -C out/Default chrome
创建一个index.html文件,文件内容如下:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=0">
<title>canvas</title>
</head>
<body>
<canvas class="" id="myCanvas1" width="100" height="100"></canvas>
<canvas class="" id="myCanvas2" width="100" height="100"></canvas>
<script>
function canvasFingerprint (urlStr) {
var e = 3735928559
// 基于数据 URL 计算哈希值
for (var i = 0; i < 32; i++) {
e = (65599 * e + urlStr.charCodeAt(e % urlStr.length)) >>> 0;
}
return e; // 返回最终的指纹值
}
const canvas = document.getElementById('myCanvas1');
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'blue';
ctx.fillRect(0, 0, 100, 100);
const canvas2 = document.getElementById('myCanvas2');
const ctx2 = canvas2.getContext('2d');
ctx2.fillStyle = 'blue';
ctx2.fillRect(0, 0, 100, 100);
const urlStr1 = canvas.toDataURL();
const urlStr2 = canvas2.toDataURL();
// 调用函数生成指纹
console.log(canvasFingerprint(urlStr1));
console.log(canvasFingerprint(urlStr2));
</script>
</body>
</html>
在编译好的chromium中打开,就可以看到,每次刷新,他的颜色都不一样,已经被我们修改的源码给hook掉了。
特别说明
通过对上述随机化逻辑的分析和实现,可以发现一种简单而有效的检测方法: 对同一段 Canvas 逻辑代码多次执行,并对比每次生成的结果。如果所有结果完全一致,则说明浏览器环境未被篡改,处于正常状态;反之,如果结果存在不一致,则表明相关函数内容或者浏览器环境已被篡改。
关于这一问题的解决方案,我们将在下一节详细探讨。