AntiDetect
Возможности Технология Цены Для кого
Документация Блог FAQ
Язык
EnglishItalianoDeutschEspañolРусский
Скачать

Десять способов, которыми DevTools Protocol вас выдаёт

Любой популярный стек автоматизации управляет Chrome через DevTools Protocol. CDP делали для отладки, а не для маскировки — и он оставляет следы в десятке мест. Вот где именно и почему заплатка на JavaScript обычно только вредит.

CDP никогда и не задумывался тихим

Puppeteer, Playwright, Selenium 4 и любая «стелс»-обёртка поверх них стоят на одном фундаменте — Chrome DevTools Protocol. Это тот же канал, который браузер открывает по нажатию F12. Chrome предоставляет его, чтобы отладчик мог осматривать страницу и управлять ею, и проектировался он ровно с одной «нецелью» — скрывать факт подключения отладчика.

Отсюда и растёт проблема. Перечисленные ниже сигналы — не баги Puppeteer. Почти все они — корректное поведение CDP, просто такое, какого никогда не бывает у браузера под управлением человека. Поставщику систем обнаружения не нужно ничего ломать, чтобы их найти; достаточно прочитать значения, которые платформа отдаёт даром.

Дальше — список по состоянию на 2026 год, примерно в порядке того, насколько дёшево проверить каждый пункт.

navigator.webdriver и почему правка в JS хуже

Самый старый сигнал. navigator.webdriver возвращает true всякий раз, когда Chrome запущен с включённой автоматизацией. Каждое руководство советует его переопределить:

Object.defineProperty(navigator, 'webdriver', { get: () => false })

Это не исправление. Это второй, более громкий сигнал. В обычном браузере webdriver — это аксессор на Navigator.prototype, а не собственное свойство экземпляра. После приведённого фрагмента истинны три вещи, которые не истинны больше нигде:

Скрипты обнаружения обходят цепочку toString уже годами. Браузер, возвращающий честное true, — это браузер с автоматизацией. Браузер, возвращающий поддельное false, — это браузер с автоматизацией, который ещё и пытается это скрыть, а для большинства риск-движков это хуже.

Единственный вариант, выдерживающий проверку, — тот, где false возвращает сам геттер на C++: нет собственного свойства, нет стрелочной функции и ничего лишнего в цепочке прототипов.

Runtime.enable: утечка, переписавшая всем стек

Чтобы выполнить JavaScript на странице, большинство библиотек вызывают Runtime.enable. У одной этой команды есть побочные эффекты, доходящие до страницы: она начинает слать уведомления Runtime.executionContextCreated и меняет способ сериализации аргументов консоли.

Классический эксплойт умещается в твит. Создайте объект с геттером, передайте его в console.debug и посмотрите, сработает ли геттер. При подключённом отладчике и вызванном Runtime.enable протокол сериализует объект для клиента, и геттер выполняется. Без отладчика к нему никто не притрагивается.

let leaked = false;
const probe = { get id() { leaked = true; return 1; } };
console.debug(probe);
// leaked === true  →  something is listening on CDP

Именно это подтолкнуло экосистему к пропатченным форкам — rebrowser-puppeteer и подобным, — которые обходятся без Runtime.enable и используют изолированные миры или addBinding. Эти патчи работают, и их стоит применять. Но они закрывают ровно одну дыру из десяти.

Три изъяна синтетического ввода

Когда автоматизация двигает мышь, она не двигает мышь. Она вызывает Input.dispatchMouseEvent, и Chrome собирает объект события, почти — но не совсем — такой же, какой рождает настоящий конвейер ввода. Три отличия элементарно наблюдаются со страницы.

screenX и screenY неверны

Это баг Chromium 40280325. CDP выставляет screenX = clientX и screenY = clientY — координаты вьюпорта там, где должны быть экранные. При настоящем клике они различаются на положение окна плюс интерфейс браузера: полосу вкладок, адресную строку, закладки. В развёрнутом окне это примерно 90-130 пикселей по вертикали.

Поэтому у настоящего клика у верхнего края страницы screenY около 150. Клик через CDP в том же месте сообщает screenY = 20. Cloudflare Turnstile считает низкий screenY ботовским кликом — и справедливо: человек не может кликнуть выше интерфейса собственного браузера.

UIEvent.detail равен нулю

У настоящего клика event.detail хранит счётчик нажатий — 1 для одиночного, 2 для двойного. События клика, отправленные через CDP, приходят с click_count = 0, поэтому detail равен 0. Не существует пользовательского жеста, дающего клик с detail, равным нулю. Turnstile проверяет это внутри своего iframe с проверкой.

getCoalescedEvents() пуст

Современные браузеры группируют частые перемещения указателя и отдают исходные отсчёты через PointerEvent.getCoalescedEvents(). Настоящий pointermove всегда несёт хотя бы одно сгруппированное событие — самого себя. Отправленный через CDP pointermove несёт пустой массив, потому что аппаратного отсчёта, который можно было бы сгруппировать, попросту не было.

Ничего из этого нельзя починить из JavaScript. Объект события собирается в C++ раньше, чем его увидит любой скрипт страницы, а свойства доступны только для чтения.

Окно браузера в разрезе, показывающее смещение между clientY и screenY
Баг Chromium 40280325. Настоящий клик несёт в экранной координате интерфейс браузера; клик через CDP сообщает вместо этого координату вьюпорта. Turnstile читает разницу.

Строки, которые автоматизация оставляет на виду

Два семейства строковых литералов оказываются там, где страница может их прочитать.

Маркеры ChromeDriver. ChromeDriver отслеживает результаты собственных вызовов, объявляя свойства документа с префиксами cdc_, $cdc_ или $chrome_. Они не скрыты. Трёхстрочный цикл по Object.getOwnPropertyNames(document) находит их, и скрипты обнаружения ищут именно это с 2018 года.

Исходные URL Puppeteer. Puppeteer дописывает //# sourceURL=pptr:... к скриптам, которые выполняет, и создаёт изолированный мир с именем __puppeteer_utility_world__. Оба всплывают в Error.stack, если исключение пересекает границу:

try { null.f() } catch (e) { /* e.stack may contain "pptr:" */ }

Это самые простые для устранения сигналы — и те, что чаще всего остаются на месте, потому что появляются лишь при условиях, которые разработчик редко проверяет: при неперехваченной ошибке внутри выполняемой функции.

Worker: контекст, который никто не закрывает

Именно этот пробел подводит самые аккуратные в остальном сборки.

У Web Worker, Service Worker и Shared Worker есть собственный глобальный контекст со своим navigator. Этот объект создаёт движок в потоке worker из настоящих значений браузера. Переопределения CDP на уровне страницы — Emulation.setUserAgentOverride, внедрённые скрипты, Page.addScriptToEvaluateOnNewDocument — до него не достают.

В результате браузер утверждает одно в главном потоке и другое внутри worker. Скрипту обнаружения достаточно спросить дважды:

// main thread says 8; worker says 24
new Worker(URL.createObjectURL(new Blob([
  'postMessage([navigator.hardwareConcurrency, navigator.deviceMemory, navigator.platform])'
], { type: 'text/javascript' })));

Любое расхождение между ними — окончательный вывод. Настоящие браузеры его дать не могут, потому что оба контекста читают одни и те же исходные значения. Через этот путь утекают, в частности, hardwareConcurrency, deviceMemory, platform, полный список брендов из userAgentData и — через OffscreenCanvas — вендор и рендерер WebGL.

Полноценно закрыть это снаружи невозможно. Значения должны приходить из движка, а значит, navigator в worker обязан строиться из того же профиля, что и у окна.

Мелочи, которые всё равно складываются

Ещё три пункта, каждый дёшево проверяется и каждый сам по себе способен завершить сессию.

Внешние размеры при закреплённых DevTools

Разница между outerWidth и innerWidth — это интерфейс браузера: примерно 16 пикселей по горизонтали и 85-90 по вертикали в десктопном Chrome. Закрепите DevTools — и этот зазор вырастает на сотни пикселей. Окно, сообщающее outerHeight - innerHeight = 400, — это окно с открытым отладчиком.

Квота хранилища

navigator.storage.estimate() возвращает квоту, выведенную из свободного места на диске. Это уже сам по себе сильный аппаратный сигнал, и вдобавок он выдаёт контейнеры и режим инкогнито, которые сообщают характерные круглые числа, далёкие от любого реального диска.

Отсутствующие API для заявленной платформы

Если ваш user agent сообщает Chrome 147 под Windows, страница может проверить, какие API у Chrome 147 под Windows есть на самом деле. В headless-сборках исторически не было chrome.runtime, полного механизма разрешений Notification и части кодеков, имеющихся в headful-сборках. Заявлять платформу, которую вы не можете подкрепить, хуже, чем не заявлять ничего.

Почему исправлять нужно в исходниках

Посмотрите на список — и проступает закономерность. Почти каждый сигнал рождается ниже JavaScript:

Контент-скрипт выполняется после всего этого. Он может лишь перезаписать уже решённое, и каждая перезапись оставляет описанные выше следы: собственные свойства там, где место прототипам, стрелочные функции там, где место нативному коду, расхождения между контекстами.

Это честный аргумент в пользу изменённой сборки Chromium — и стоит назвать и её пределы. Правка движка решает слой ниже JavaScript. Она не решает отпечатки TLS и HTTP/2, которые лежат вообще ниже браузера, и ничего не делает с поведением: сессия, кликающая с нечеловеческой регулярностью, будет помечена, какими бы чистыми ни были её свойства.

Проверка собственной сборки

Не верьте на слово никакому поставщику, включая нас. Проверки открытые, и прогнать их можно за день:

Прогоните их на том, чем пользуетесь сегодня, прежде чем что-то менять. Инструмент, проходящий девять таких проверок и заваливающий десятую, не находится в девяти десятых пути: одного однозначного расхождения достаточно.

Исправлено в движке, а не в контент-скрипте

P8 поставляет сборку Chromium, где всё это ведёт себя так же, как в обычном браузере, — потому что изменён сам код на C++, а не обёртка над ним.

Начать бесплатно
Все статьи Далее: браузерный фингерпринтинг