Как антифрод-системы маркетплейсов вычисляют один и тот же браузер при смене IP через прокси: canvas, WebGL и шрифты
Содержание
- Отпечаток начинается раньше, чем вы думаете
- Canvas: почему картинка одинаковая на всех машинах
- WebGL: вендор GPU как паспорт
- Шрифты и метрики текста: невидимый почерк
- Полный список того, что летит в заголовки и JS
- Где прокси ломается: реальные кейсы
- Что делать: практический минимум
- Итог: IP — это 10% задачи
Отпечаток начинается раньше, чем вы думаете
Когда маркетплейс видит новый IP, он не радуется. Он настораживается. Потому что за последние десять лет антифрод научился смотреть не на адрес, а на то, как браузер себя ведёт. IP — это первое, что меняют. И первое, что перестало быть надёжным сигналом.
Логика простая. Пять аккаунтов с разных IP, но с одинаковым отпечатком браузера — это не пять человек. Это один человек с прокси-пулом. Антифрод-системы крупных маркетплейсов (Wildberries, Ozon, Amazon, eBay) собирают сотни параметров на каждом запросе и сравнивают их между сессиями. Совпадение по 15-20 параметрам при разных IP — почти гарантированный флаг.
Дальше разберём, что именно они смотрят. Не в общих чертах, а с конкретикой: какие API вызываются, какие байты сравниваются, где прокси не спасает.
Canvas: почему картинка одинаковая на всех машинах
Canvas fingerprinting работает так. Скрипт создаёт `
```javascript
const canvas = document.createElement('canvas');
canvas.width = 280;
canvas.height = 60;
const ctx = canvas.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillStyle = '#f60';
ctx.fillRect(100, 1, 62, 20);
ctx.fillStyle = '#069';
ctx.fillText('lexic-fp-test', 2, 15);
ctx.fillStyle = 'rgba(102, 204, 0, 0.7)';
ctx.fillText('lexic-fp-test', 4, 17);
const hash = canvas.toDataURL().slice(-64);
```
Что тут важно. Даже если вы подмените User-Agent, canvas останется прежним. Антифрод увидит: IP новый, UA новый, а хеш canvas — тот же, что у аккаунта, забаненного месяц назад. Связка мгновенная.
Обход существует, но он хрупкий. Шум в пикселях (canvas poisoning) добавляет случайные значения в RGB на каждом вызове. Проблема в том, что современные антифроды вызывают `toDataURL()` несколько раз подряд и сравнивают результаты. Если хеш «пляшет» — это тоже сигнал. Стабильный шум с фиксированным seed помогает, но его нужно синхронизировать с остальными параметрами отпечатка. Иначе получится зоопарк: canvas от одной машины, WebGL от другой.
WebGL: вендор GPU как паспорт
WebGL отдаёт ещё больше. Через `WEBGL_debug_renderer_info` скрипт читает `UNMASKED_VENDOR_WEBGL` и `UNMASKED_RENDERER_WEBGL`. Типичный ответ выглядит так: `Google Inc. (NVIDIA)` и `ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)`. Это фактически модель вашей видеокарты и версия драйвера.
```javascript
const gl = document.createElement('canvas').getContext('webgl');
const ext = gl.getExtension('WEBGL_debug_renderer_info');
const vendor = gl.getParameter(ext.UNMASKED_VENDOR_WEBGL);
const renderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL);
console.log(vendor, renderer);
```
Дальше антифрод сравнивает рендерер с платформой. Если UA говорит «Android на телефоне», а WebGL отдаёт дискретную NVIDIA — это несоответствие. Если UA говорит «Windows 10», а рендерер отдаёт `Apple M1` — тоже. Такие пары ловятся на раз-два.
Есть ещё один слой — параметры рендеринга. `MAX_TEXTURE_SIZE`, `MAX_VIEWPORT_DIMS`, `ALIASED_LINE_WIDTH_RANGE`. Они зависят от драйвера и GPU. Совокупность этих значений формирует стабильный хеш. Подменить его сложнее, чем UA: нужно эмулировать целый GPU на уровне JS-контекста.
Шрифты и метрики текста: невидимый почерк
Метрики шрифтов — недооценённый сигнал. Скрипт измеряет ширину и высоту отрендеренного текста в пикселях для десятка шрифтов и размеров. Разные версии Windows и разные наборы шрифтов дают разные значения. Например, ширина строки «mmmmmmmmmmlli» в Arial 72px на Windows 10 — 723.4px, на Windows 11 с обновлённым Arial — 723.9px. Мелочь, но она стабильна.
```javascript
function measure(font) {
const span = document.createElement('span');
span.style.cssText = `font:72px \${font};position:absolute;visibility:hidden`;
span.textContent = 'mmmmmmmmmmlli';
document.body.appendChild(span);
const w = span.offsetWidth;
span.remove();
return w;
}
const fp = ['Arial', 'Verdana', 'Times New Roman', 'Courier New']
.map(f => `\${f}:\${measure(f)}`).join('|');
```
Маркетплейсы добавляют к этому список установленных шрифтов через `document.fonts.check()`. Если у двух аккаунтов совпадает набор из 40 шрифтов и метрики до десятых пикселя — это одна машина. Смена IP тут ничего не меняет.
Полный список того, что летит в заголовки и JS
Антифрод собирает данные в два потока. Первый — HTTP-заголовки: `User-Agent`, `Accept-Language`, `Accept-Encoding`, порядок заголовков, `Sec-CH-UA` (Client Hints). Второй — JS-зонд, который выполняется на странице. Он читает `navigator`, `screen`, `Intl`, `AudioContext`, `WebRTC`.
Вот таблица реальных параметров, которые сравнивает типичная антифрод-система маркетплейса:
| Параметр | Источник | Стабильность | Легко подменить |
|---|---|---|---|
| Canvas hash | JS | Высокая | Нет |
| WebGL renderer | JS | Высокая | Частично |
| AudioContext hash | JS | Средняя | Нет |
| Список шрифтов | JS | Высокая | Нет |
| Часовой пояс | Intl | Высокая | Да |
| Разрешение экрана | screen | Средняя | Да |
| `navigator.hardwareConcurrency` | JS | Средняя | Да |
| Порядок HTTP-заголовков | Сеть | Высокая | Только на уровне клиента |
| TLS-отпечаток (JA3) | Сеть | Высокая | Только кастомный TLS-стек |
Последняя строка — самое больное. JA3/JA4 хеширует параметры TLS handshake: версию, набор шифров, расширения, порядок. Chrome и Firefox дают разные JA3. Прокси на уровне TCP этого не меняет — TLS-рукопожатие идёт от вашего браузера. Если вы используете `curl` с TLS-стеком OpenSSL, а UA подставляете от Chrome — антифрод увидит несоответствие за миллисекунды.
Где прокси ломается: реальные кейсы
Первый кейс. Сервер на nginx 1.24, настроен как reverse proxy для пула аккаунтов. Клиенты ходят через `proxy_pass` с разными исходящими IP. Проблема: все запросы приходят с одинаковым `Accept-Language: ru-RU,ru;q=0.9` и одинаковым порядком заголовков, потому что nginx пересобирает их по своему шаблону. Антифрод маркетплейса смотрел на порядок заголовков и на отсутствие `Sec-CH-UA-Mobile`. Пять аккаунтов с разных IP получили бан за неделю. Причина — не IP, а идентичный HTTP-профиль. Решение: проброс заголовков клиента без пересборки (`proxy_pass_request_headers on`, отключение `proxy_set_header` для всех полей, кроме `Host`), плюс разные профили браузеров на клиентах.
Второй кейс. Python-скрипт на `requests` с ротацией прокси из пула. 200 запросов в час, каждый с нового IP. Через два дня — блокировка всех аккаунтов. Причина: `requests` использует TLS-стек OpenSSL с фиксированным набором шифров. JA3-хеш одинаковый для всех запросов. Плюс `requests` не выполняет JS, поэтому canvas/WebGL отсутствуют — а их отсутствие на «обычном» браузерном трафике выглядит подозрительно. Решение: `curl_cffi` с имперсонацией Chrome, либо полноценный headless-браузер с профилями.
Третий кейс. Ферма виртуальных машин на одном гипервизоре. Каждая VM — с отдельным IP через прокси-пул. Антифрод заметил: `AudioContext` fingerprint совпадает у 30 аккаунтов. Причина — одинаковый виртуальный аудиодрайвер (эмуляция Intel HDA от гипервизора). Звуковой отпечаток зависит от драйвера и буферизации. На одинаковых VM он идентичен. Решение — раскидать по разным типам железа или использовать браузерные профили с шумом в AudioContext.
Что делать: практический минимум
Если задача — не ферма, а несколько аккаунтов с разных IP, начните с консистентности. Отпечаток должен быть цельным: UA от Chrome, canvas от той же машины, WebGL от того же GPU, часовой пояс от того же региона, что и IP. Прокси с IP в Германии и `Accept-Language: ru-RU` — красный флаг.
```bash
curl -x http://user:pass@proxy.example:8080 \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" \
-H "Accept-Language: de-DE,de;q=0.9" \
-H "Sec-CH-UA: \"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\"" \
--tlsv1.3 \
https://example.com/api/health
```
Флаг `--tlsv1.3` не решит проблему JA3, но хотя бы приведёт версию протокола к браузерной. Для реальной маскировки нужен клиент, который воспроизводит весь TLS-отпечаток. В экосистеме для этого есть `curl_cffi` (Python) и `tls-client`. Они эмулируют JA3 Chrome/Firefox/Safari на уровне рукопожатия.
Отдельно про IPv6. Многие антифроды хуже покрывают IPv6-диапазоны, потому что исторически их меньше. Прокси-сервисы вроде lexic.ml выдают IPv6-адреса с /64-подсетью — это даёт большой пул адресов для ротации без переиспользования. Но помните: смена IP внутри /64 не меняет отпечаток браузера. Canvas, WebGL и шрифты останутся теми же, и антифрод склеит аккаунты по ним, а не по адресу.
Итог: IP — это 10% задачи
Антифрод маркетплейсов давно сместил фокус с сетевого уровня на клиентский. Canvas, WebGL, шрифты, AudioContext, TLS-отпечаток — вот что сравнивается в первую очередь. Прокси решает задачу «разные IP», но не решает задачу «разные машины». Если отпечаток браузера одинаковый, смена IP только добавляет подозрений: зачем один человек меняет адрес десять раз в час.
Рабочая схема — консистентный профиль на каждый аккаунт. Отдельный браузерный профиль с уникальными canvas/WebGL/audio-параметрами, TLS-стек, соответствующий UA, язык и часовой пояс под регион IP. И тогда прокси — включая IPv6-пулы — работает как задумано: разносит трафик, не создавая новых связей между аккаунтами.