feature: шум canvas с seed профиля — отпечатки профилей различаются (#89)
Минимальный init-script (src/api/js/canvas_noise.js): младший бит у ~1/64 непрозрачных пикселей при toDataURL/toBlob/getImageData, детерминирован от seed профиля. Пустой canvas не меняется, подменённые функции неотличимы по toString и length. Старые профили получают seed, экран сохраняется. Проверено: 4 профиля — 4 разных хеша canvas; Cloudflare 6/6 на новых профилях. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4c8394eb4c
commit
9dc2c0b619
6 files changed
+176
-19
No files matched your search
@@ -0,0 +1,71 @@
|
||||
// Шум canvas профиля (#89): младший бит одного канала у ~1/64 непрозрачных пикселей.
|
||||
// Детерминирован от seed профиля и номера пикселя: внутри профиля хеш canvas стабилен,
|
||||
// между профилями — разный. Пустые (прозрачные) пиксели не меняются, иначе пустой canvas
|
||||
// перестал бы быть пустым. Подменённые функции выглядят нативными для toString.
|
||||
(() => {
|
||||
const SEED = __SEED__ | 0;
|
||||
const MAX_PIXELS = 4096 * 4096;
|
||||
const fnToString = Function.prototype.toString;
|
||||
const nativeSource = new WeakMap();
|
||||
|
||||
const noise = (data) => {
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i + 3] === 0) continue;
|
||||
let x = Math.imul((i >>> 2) ^ SEED, 0x9e3779b1);
|
||||
x ^= x >>> 15;
|
||||
x = Math.imul(x, 0x85ebca77);
|
||||
x ^= x >>> 13;
|
||||
if ((x & 63) === 0) {
|
||||
const c = i + ((x >>> 6) % 3);
|
||||
data[c] ^= 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const replace = (proto, name, make) => {
|
||||
const desc = Object.getOwnPropertyDescriptor(proto, name);
|
||||
if (!desc || typeof desc.value !== 'function') return null;
|
||||
const orig = desc.value;
|
||||
const impl = make(orig);
|
||||
const fn = { [name](...args) { return impl.call(this, args); } }[name];
|
||||
Object.defineProperty(fn, 'length', { value: orig.length });
|
||||
nativeSource.set(fn, fnToString.call(orig));
|
||||
Object.defineProperty(proto, name, { ...desc, value: fn });
|
||||
return orig;
|
||||
};
|
||||
|
||||
const toStringDesc = Object.getOwnPropertyDescriptor(Function.prototype, 'toString');
|
||||
const toString = { toString() { return nativeSource.has(this) ? nativeSource.get(this) : fnToString.call(this); } }.toString;
|
||||
nativeSource.set(toString, fnToString.call(fnToString));
|
||||
Object.defineProperty(Function.prototype, 'toString', { ...toStringDesc, value: toString });
|
||||
|
||||
const ctxProto = CanvasRenderingContext2D.prototype;
|
||||
const origGetImageData = replace(ctxProto, 'getImageData', (orig) => function (args) {
|
||||
const img = orig.apply(this, args);
|
||||
noise(img.data);
|
||||
return img;
|
||||
});
|
||||
|
||||
// toDataURL/toBlob: шумим копию, исходный canvas страницы не трогаем
|
||||
const noisyCopy = (canvas) => {
|
||||
const w = canvas.width, h = canvas.height;
|
||||
if (!w || !h || w * h > MAX_PIXELS) return canvas;
|
||||
const copy = document.createElement('canvas');
|
||||
copy.width = w;
|
||||
copy.height = h;
|
||||
const ctx = copy.getContext('2d');
|
||||
ctx.drawImage(canvas, 0, 0);
|
||||
const img = origGetImageData.call(ctx, 0, 0, w, h);
|
||||
noise(img.data);
|
||||
ctx.putImageData(img, 0, 0);
|
||||
return copy;
|
||||
};
|
||||
|
||||
const canvasProto = HTMLCanvasElement.prototype;
|
||||
replace(canvasProto, 'toDataURL', (orig) => function (args) {
|
||||
return orig.apply(noisyCopy(this), args);
|
||||
});
|
||||
replace(canvasProto, 'toBlob', (orig) => function (args) {
|
||||
return orig.apply(noisyCopy(this), args);
|
||||
});
|
||||
})();
|
||||
+23
-8
@@ -1,9 +1,11 @@
|
||||
"""
|
||||
Отпечаток профиля сессии (рядом с профилем Chrome): экран и devicePixelRatio. Генерируется один раз
|
||||
при первом запросе сессии и дальше не меняется — реальный человек не меняет монитор между визитами.
|
||||
Отпечаток профиля сессии (рядом с профилем Chrome): экран, devicePixelRatio и seed шума canvas.
|
||||
Генерируется один раз при первом запросе сессии и дальше не меняется — реальный человек не меняет
|
||||
монитор между визитами.
|
||||
|
||||
Применяется только нативной эмуляцией Chrome, без JS-инъекций: полная подмена через browserforge
|
||||
InjectFunction ломала прохождение Cloudflare Turnstile (51a801f).
|
||||
Экран и DPR применяются нативной эмуляцией Chrome. JS — только минимальный шум canvas (js/canvas_noise.js,
|
||||
#89): полная подмена через browserforge InjectFunction ломала прохождение Cloudflare Turnstile (51a801f).
|
||||
Остальное (WebGL, hardwareConcurrency, шрифты) — родное, как его отдаёт Chrome через Patchright.
|
||||
|
||||
User-Agent не подменяется: строка UA, отличная от реального Chrome, расходится с
|
||||
navigator.userAgentData и движком, и Cloudflare такой браузер не пропускает (замер в #87). UA — всегда
|
||||
@@ -11,6 +13,7 @@ navigator.userAgentData и движком, и Cloudflare такой браузе
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
from browserforge.fingerprints import FingerprintGenerator, Screen
|
||||
@@ -20,6 +23,11 @@ log = logging.getLogger(__name__)
|
||||
SETTINGS_FILE = "robotex_session.json"
|
||||
# окно браузера под Xvfb — 1920x1080, монитор физически не может быть меньше окна
|
||||
MIN_SCREEN = (1920, 1080)
|
||||
CANVAS_NOISE_JS = (Path(__file__).parent / "js" / "canvas_noise.js").read_text()
|
||||
|
||||
|
||||
def new_canvas_seed() -> int:
|
||||
return random.randint(1, 2**31 - 1)
|
||||
|
||||
|
||||
def generate_fingerprint(attempts: int = 10) -> dict:
|
||||
@@ -42,7 +50,7 @@ def generate_fingerprint(attempts: int = 10) -> dict:
|
||||
else:
|
||||
log.warning("browserforge failed %d times, using default screen", attempts)
|
||||
|
||||
return {"screen": screen, "device_scale_factor": dpr}
|
||||
return {"screen": screen, "device_scale_factor": dpr, "canvas_seed": new_canvas_seed()}
|
||||
|
||||
|
||||
def load(user_data_dir: Path) -> dict:
|
||||
@@ -55,9 +63,13 @@ def load(user_data_dir: Path) -> dict:
|
||||
def load_or_create(user_data_dir: Path) -> dict:
|
||||
"""Настройки профиля: сохранённый отпечаток или новый при первом запросе сессии."""
|
||||
settings = load(user_data_dir)
|
||||
if "fingerprint" in settings:
|
||||
if "fingerprint" in settings and "canvas_seed" in settings["fingerprint"]:
|
||||
return settings
|
||||
settings = {"fingerprint": generate_fingerprint()}
|
||||
if "fingerprint" in settings:
|
||||
# профиль до #89: экран сохраняем, добавляем только seed шума
|
||||
settings = {"fingerprint": settings["fingerprint"] | {"canvas_seed": new_canvas_seed()}}
|
||||
else:
|
||||
settings = {"fingerprint": generate_fingerprint()}
|
||||
(user_data_dir / SETTINGS_FILE).write_text(json.dumps(settings, ensure_ascii=False))
|
||||
return settings
|
||||
|
||||
@@ -65,4 +77,7 @@ def load_or_create(user_data_dir: Path) -> dict:
|
||||
def browser_kwargs(settings: dict) -> dict:
|
||||
"""kwargs AsyncStealthySession под отпечаток профиля."""
|
||||
fp = settings["fingerprint"]
|
||||
return {"additional_args": {"screen": fp["screen"], "device_scale_factor": fp["device_scale_factor"]}}
|
||||
return {
|
||||
"additional_args": {"screen": fp["screen"], "device_scale_factor": fp["device_scale_factor"]},
|
||||
"init_script_content": CANVAS_NOISE_JS.replace("__SEED__", str(int(fp["canvas_seed"]))),
|
||||
}
|
||||
Reference in new issue
Block a user