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:
protokeyandClaude Opus 5.5 committed 2026-10-13 21:02:21 +04:00
1 parent 4c8394eb4c
commit 9dc2c0b619
6 files changed
+176 -19

No files matched your search

+1 -1
View File
@@ -40,7 +40,7 @@ Client → Caddy (TLS) → api (FastAPI) ──task──▶ RabbitMQ ──▶
reCAPTCHA, hCaptcha) are solved via the client's 2captcha key. reCAPTCHA, hCaptcha) are solved via the client's 2captcha key.
- **Proxies**: not managed by the service — the client may supply their own proxy per request. Browser locale and - **Proxies**: not managed by the service — the client may supply their own proxy per request. Browser locale and
timezone are derived from the proxy's exit IP. timezone are derived from the proxy's exit IP.
- **Sessions**: `session_id` cookie → persistent Chrome profile on disk, removed after `PROFILE_TTL_DAYS` (7) unused days. A fingerprint (screen, DPR) is generated on - **Sessions**: `session_id` cookie → persistent Chrome profile on disk, removed after `PROFILE_TTL_DAYS` (7) unused days. A fingerprint (screen, DPR, canvas noise seed) is generated on
the first request of a session; cookies passed in a request replace the profile's cookies. the first request of a session; cookies passed in a request replace the profile's cookies.
- **Browser identity is not configurable**: user agent and window size are the real browser's own — a spoofed UA - **Browser identity is not configurable**: user agent and window size are the real browser's own — a spoofed UA
mismatches `userAgentData` and Cloudflare blocks it. Clients take `user_agent` from the response. mismatches `userAgentData` and Cloudflare blocks it. Clients take `user_agent` from the response.
+12 -4
View File
@@ -138,11 +138,19 @@ Cookie: session_id=<id> # необязательно, см. «Сесси
(`extra/user_data_dir/<session_id>`): cookies, localStorage и отпечаток сохраняются, повторно проходить Cloudflare (`extra/user_data_dir/<session_id>`): cookies, localStorage и отпечаток сохраняются, повторно проходить Cloudflare
обычно не нужно. Без cookie или с некорректным значением (не UUID) запрос получает новый профиль. обычно не нужно. Без cookie или с некорректным значением (не UUID) запрос получает новый профиль.
Сессии изолированы друг от друга: у каждой свой профиль Chrome — cookies, localStorage, кэш.
Отпечаток профиля генерируется при первом запросе сессии и дальше не меняется (`robotex_session.json` в каталоге Отпечаток профиля генерируется при первом запросе сессии и дальше не меняется (`robotex_session.json` в каталоге
профиля): размер экрана и `devicePixelRatio` десктопного Chrome на Linux из реального распределения (browserforge). профиля):
Применяется нативной эмуляцией Chrome, без JS-подмены (она ломала прохождение Cloudflare); DPR влияет и на хеш
canvas. Ограничение: canvas, WebGL и `hardwareConcurrency` у всех профилей одинаковые, поэтому примерно половина * размер экрана и `devicePixelRatio` десктопного Chrome на Linux из реального распределения (browserforge) —
профилей совпадает по отпечатку. нативной эмуляцией Chrome;
* шум canvas: свой seed у каждого профиля, поэтому хеш canvas у профилей разный, а внутри профиля стабильный.
Меняется младший бит у редких непрозрачных пикселей при чтении canvas (`toDataURL`, `toBlob`, `getImageData`);
пустой canvas не меняется.
Остальное — WebGL, `hardwareConcurrency`, шрифты, UA — не подменяется: это родные значения Chrome, за маскировку
автоматизации отвечает Patchright. Полная JS-подмена отпечатка ломала прохождение Cloudflare.
User-Agent и размер окна запросом не задаются — это родные значения браузера (почему — [Usage.md](Usage.md#что-задаёт-сервис-а-не-клиент)). User-Agent и размер окна запросом не задаются — это родные значения браузера (почему — [Usage.md](Usage.md#что-задаёт-сервис-а-не-клиент)).
Поле `user_agent` в запросе даёт `422`. Поле `user_agent` в запросе даёт `422`.
+71
View File
@@ -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
View File
@@ -1,9 +1,11 @@
""" """
Отпечаток профиля сессии (рядом с профилем Chrome): экран и devicePixelRatio. Генерируется один раз Отпечаток профиля сессии (рядом с профилем Chrome): экран, devicePixelRatio и seed шума canvas.
при первом запросе сессии и дальше не меняется — реальный человек не меняет монитор между визитами. Генерируется один раз при первом запросе сессии и дальше не меняется — реальный человек не меняет
монитор между визитами.
Применяется только нативной эмуляцией Chrome, без JS-инъекций: полная подмена через browserforge Экран и DPR применяются нативной эмуляцией Chrome. JS — только минимальный шум canvas (js/canvas_noise.js,
InjectFunction ломала прохождение Cloudflare Turnstile (51a801f). #89): полная подмена через browserforge InjectFunction ломала прохождение Cloudflare Turnstile (51a801f).
Остальное (WebGL, hardwareConcurrency, шрифты) — родное, как его отдаёт Chrome через Patchright.
User-Agent не подменяется: строка UA, отличная от реального Chrome, расходится с User-Agent не подменяется: строка UA, отличная от реального Chrome, расходится с
navigator.userAgentData и движком, и Cloudflare такой браузер не пропускает (замер в #87). UA — всегда navigator.userAgentData и движком, и Cloudflare такой браузер не пропускает (замер в #87). UA — всегда
@@ -11,6 +13,7 @@ navigator.userAgentData и движком, и Cloudflare такой браузе
""" """
import json import json
import logging import logging
import random
from pathlib import Path from pathlib import Path
from browserforge.fingerprints import FingerprintGenerator, Screen from browserforge.fingerprints import FingerprintGenerator, Screen
@@ -20,6 +23,11 @@ log = logging.getLogger(__name__)
SETTINGS_FILE = "robotex_session.json" SETTINGS_FILE = "robotex_session.json"
# окно браузера под Xvfb — 1920x1080, монитор физически не может быть меньше окна # окно браузера под Xvfb — 1920x1080, монитор физически не может быть меньше окна
MIN_SCREEN = (1920, 1080) 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: def generate_fingerprint(attempts: int = 10) -> dict:
@@ -42,7 +50,7 @@ def generate_fingerprint(attempts: int = 10) -> dict:
else: else:
log.warning("browserforge failed %d times, using default screen", attempts) 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: 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: def load_or_create(user_data_dir: Path) -> dict:
"""Настройки профиля: сохранённый отпечаток или новый при первом запросе сессии.""" """Настройки профиля: сохранённый отпечаток или новый при первом запросе сессии."""
settings = load(user_data_dir) settings = load(user_data_dir)
if "fingerprint" in settings: if "fingerprint" in settings and "canvas_seed" in settings["fingerprint"]:
return settings 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)) (user_data_dir / SETTINGS_FILE).write_text(json.dumps(settings, ensure_ascii=False))
return settings return settings
@@ -65,4 +77,7 @@ def load_or_create(user_data_dir: Path) -> dict:
def browser_kwargs(settings: dict) -> dict: def browser_kwargs(settings: dict) -> dict:
"""kwargs AsyncStealthySession под отпечаток профиля.""" """kwargs AsyncStealthySession под отпечаток профиля."""
fp = settings["fingerprint"] 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"]))),
}
+40 -2
View File
@@ -35,6 +35,23 @@ FP_JS = """async () => {
}) + '</pre>'; }) + '</pre>';
}""" }"""
# пустой canvas не зашумлён, подменённые функции выглядят нативными
NOISE_JS = """() => {
const blank = document.createElement('canvas');
blank.width = 50; blank.height = 50;
const empty = document.createElement('canvas');
empty.width = 50; empty.height = 50;
const data = blank.getContext('2d').getImageData(0, 0, 50, 50).data;
return {
blank_same: blank.toDataURL() === empty.toDataURL(),
blank_zero: data.every(v => v === 0),
native: [HTMLCanvasElement.prototype.toDataURL, HTMLCanvasElement.prototype.toBlob,
CanvasRenderingContext2D.prototype.getImageData, Function.prototype.toString]
.every(f => /\\{ \\[native code\\] \\}$/.test(Function.prototype.toString.call(f)) && /native code/.test(String(f))),
lengths: [HTMLCanvasElement.prototype.toDataURL.length, CanvasRenderingContext2D.prototype.getImageData.length],
};
}"""
@pytest.fixture @pytest.fixture
def env(monkeypatch, tmp_path): def env(monkeypatch, tmp_path):
@@ -92,7 +109,8 @@ def test_cookies_replace_profile_cookies(client):
def test_fingerprint_applied_and_stable(client, monkeypatch): def test_fingerprint_applied_and_stable(client, monkeypatch):
async def page_fp(page): async def page_fp(page):
await page.evaluate(FP_JS) # в основном мире страницы, как скрипты сайта (по умолчанию Patchright выполняет в изолированном)
await page.evaluate(FP_JS, isolated_context=False)
return json.loads(await page.inner_html("#fp")) return json.loads(await page.inner_html("#fp"))
results = {} results = {}
@@ -132,7 +150,27 @@ def test_fingerprint_applied_and_stable(client, monkeypatch):
assert all(m == measured[0] for m in measured), "fingerprint changed within a profile" assert all(m == measured[0] for m in measured), "fingerprint changed within a profile"
fps[d] = measured[0] fps[d] = measured[0]
assert len(fps) == 4 assert len(fps) == 4
print("distinct fingerprints:", len({json.dumps(f, sort_keys=True) for f in fps.values()}), "of", len(fps)) # шум canvas (#89): у каждого профиля свой хеш canvas, значит и отпечаток целиком
assert len({f["canvas"] for f in fps.values()}) == 4
assert len({json.dumps(f, sort_keys=True) for f in fps.values()}) == 4
def test_canvas_noise_is_invisible(client, monkeypatch):
"""Шум не трогает пустой canvas, а подменённые функции неотличимы от нативных по toString и length."""
seen = {}
original = windows.make_page_action
def capture(payload):
action = original(payload)
async def wrapped(page):
seen.update(await page.evaluate(NOISE_JS, isolated_context=False))
return await action(page)
return wrapped
monkeypatch.setattr(windows, "make_page_action", capture)
assert solve(client, url="https://example.com")["status"] == "ok"
assert seen == {"blank_same": True, "blank_zero": True, "native": True, "lengths": [0, 4]}
def test_ua_in_response_is_native(client): def test_ua_in_response_is_native(client):
+29 -4
View File
@@ -11,6 +11,7 @@ def test_fingerprint_generated_once(tmp_path):
screen = first["fingerprint"]["screen"] screen = first["fingerprint"]["screen"]
assert screen["width"] >= profile.MIN_SCREEN[0] and screen["height"] >= profile.MIN_SCREEN[1] assert screen["width"] >= profile.MIN_SCREEN[0] and screen["height"] >= profile.MIN_SCREEN[1]
assert first["fingerprint"]["device_scale_factor"] >= 1 assert first["fingerprint"]["device_scale_factor"] >= 1
assert first["fingerprint"]["canvas_seed"] > 0
assert "user_agent" not in first assert "user_agent" not in first
@@ -38,9 +39,33 @@ def test_generate_falls_back_when_browserforge_fails(monkeypatch):
raise ValueError("Cannot generate headers") raise ValueError("Cannot generate headers")
monkeypatch.setattr(profile, "FingerprintGenerator", Failing) monkeypatch.setattr(profile, "FingerprintGenerator", Failing)
assert profile.generate_fingerprint(attempts=2) == {"screen": {"width": 1920, "height": 1080}, "device_scale_factor": 1} fp = profile.generate_fingerprint(attempts=2)
assert (fp["screen"], fp["device_scale_factor"]) == ({"width": 1920, "height": 1080}, 1)
assert fp["canvas_seed"] > 0
def test_browser_kwargs_do_not_touch_user_agent(): def test_browser_kwargs():
kwargs = profile.browser_kwargs({"fingerprint": {"screen": {"width": 2560, "height": 1440}, "device_scale_factor": 1.25}}) kwargs = profile.browser_kwargs({"fingerprint": {
assert kwargs == {"additional_args": {"screen": {"width": 2560, "height": 1440}, "device_scale_factor": 1.25}} "screen": {"width": 2560, "height": 1440}, "device_scale_factor": 1.25, "canvas_seed": 12345,
}})
assert kwargs["additional_args"] == {"screen": {"width": 2560, "height": 1440}, "device_scale_factor": 1.25}
assert "useragent" not in kwargs
script = kwargs["init_script_content"]
assert "const SEED = 12345 | 0;" in script and "__SEED__" not in script
def test_profile_before_canvas_noise_gets_seed_and_keeps_screen(tmp_path):
old = {"fingerprint": {"screen": {"width": 2560, "height": 1440}, "device_scale_factor": 2}}
(tmp_path / profile.SETTINGS_FILE).write_text(json.dumps(old))
fp = profile.load_or_create(tmp_path)["fingerprint"]
assert (fp["screen"], fp["device_scale_factor"]) == ({"width": 2560, "height": 1440}, 2)
assert fp["canvas_seed"] > 0
assert profile.load_or_create(tmp_path)["fingerprint"] == fp
def test_profiles_get_different_canvas_seeds(tmp_path):
seeds = set()
for i in range(20):
(tmp_path / str(i)).mkdir()
seeds.add(profile.load_or_create(tmp_path / str(i))["fingerprint"]["canvas_seed"])
assert len(seeds) == 20