Как и многие подобные истории, эта началась с ночного парсинга, который к утру превратился в тыкву.
Я запустил скрапер вечером, он собрал пару тысяч страниц, я ушёл спать. Утром в логах сплошные 403 и капчи, а в базе за ночь легло страниц двести. Ок, думаю, прокси спалили. Поменял пул на резидентный, накинул puppeteer-stealth, сверху ещё пачку заплаток с гитхаба. Проработало ровно до следующего вечера. Потом опять тыква.
Так продолжалось полгода. В какой-то момент мне надоело в сотый раз докидывать заплатку, и я решил разобраться, что конкретно меня палит. Не прокси. Не поведение. Сам браузер.
Осмотрим пациента
Любой стелс устроен одинаково: поверх Chrome вешается слой JavaScript, который переписывает то, на что смотрит антибот. navigator, canvas, WebGL и ещё десятки поверхностей. И вот тут первая засада.
Берём подменённую функцию и просим показать её исходник через toString(). Настоящая функция браузера отвечает [native code]. А моя заплатка честно показывает мой же JavaScript. Спалился на первой строчке.
Ладно, стелс это тоже патчит. Но детектор не дурак и лезет глубже.
Сверяет главный фрейм с воркером и iframe. Заплатка живёт в одном контексте, а тот же объект, вытащенный из другого, её не видит.
Ловит утечку Runtime.enable, по ней сразу понятно, что браузером кто-то рулит по CDP.
Смотрит на форму TLS-хендшейка (JA3/JA4) и сверяет её с тем, что обещает User-Agent. Заявляешь Chrome на Windows, а рукопожатие выдаёт питоновский клиент.
Перерисовывает один и тот же холст дважды и смотрит, стабилен ли отпечаток, или заплатка подмешивает случайный шум, которого у настоящего железа нет.
Где-нибудь да разъедется. И так будет всегда, потому что я крашу браузер сверху, а не меняю его изнутри. Это не «кривая заплатка», это тупик подхода: маскировка и то, что её просвечивает, написаны на одном языке. Тот, кто читает страницу, всегда на уровень ниже того, кто патчит её из той же страницы.
Вскрытие
Раз проблема в слое поверх браузера, надо убрать слой и лезть в сам браузер. Я взял исходники Chromium и пошёл править фингерпринт прямо в C++, в Blink, V8 и BoringSSL.
Идея простая. Каждое значение, которое читает детектор, должно быть не подменено сверху, а просто другим внутри, ровно как оно было бы на чужой машине. Тогда никакого слоя нет. Подменённый геттер это настоящий C++ геттер, поэтому toString() в любом реалме честно отдаёт [native code]. Сверка «фрейм против воркера» ничего не находит, потому что находить нечего: значение одно и то же везде, оно вкомпилировано в движок. Браузер, который изучает сам себя, видит обычный Chrome. Потому что это и есть обычный Chrome, просто с другими числами внутри.
Что заработало, а что нет
Сразу оговорю, чего не заявляю, потому что именно на этом обычно и врут. Это про фингерпринт браузера, а не про поведение сессии. Серверный антифрод конкретной площадки, поведенческий анализ, скорость и траектория мышки, тут я вам не помощник. Fortress чинит браузер, а не то, как вы им управляете.
А что заявляю, можно проверить руками, не веря мне на слово. Я прогнал сборку по публичным
CreepJS: 0% headless, 0% stealth.
Sannysoft: всё зелёное.
BrowserScan: пишет «Normal».
Живой Cloudflare Turnstile: проходит без клика мышкой.
Всё это воспроизводится одним скриптом tools/gauntlet.py из репозитория, так что можно прогнать самому и убедиться. Персона при этом собирается когерентно: платформа, GPU, таймзона, язык, набор голосов, раскладка клавиатуры и форма TLS съезжаются в один правдоподобный Windows-девайс, а не в ме
По коду менять нечего
Движок поднимает сырой CDP на порту 9222, без утечки Runtime.enable. Я цепляю к нему свой ж остальной код работает как работал. Меняется одна строчка, адрес подключения.
Патчей тридцать четыре, каждый маленький и на одну задачу, читается за минуту. Всё открыто единому слову, соберите движок сами и проверьте, что внутри ровно то, что заявлено.
github.com/tiliondev/fortress



