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
@@ -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
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
Экран и 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"]))),
|
||||||
|
}
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user