Спецификация ECMAScript заставляет V8 раскрывать, запущен ли DevTools — и это нельзя пропатчить
Новости TprogerВы запустили Puppeteer или Playwright, чтобы автоматизировать браузер. Ваш скрипт аккуратно маскируется: правильный User-Agent, реальные размеры окна, никаких следов headless-режима. Но есть один вектор, который вы не можете закрыть — и именно он сдаёт вас с потрохами.
Исследователь под ником Sveba опубликовал разбор двух способов детектировать headless-браузеры через Chrome DevTools Protocol (CDP). Оба метода работают в одну строку JavaScript, не требуют никаких разрешений и срабатывают синхронно — без замеров тайминга. Второй способ по состоянию на март 2026 года не закрыт и, по заявлению автора, не может быть закрыт без изменения поведения ECMAScript.
Ключевые выводы:
— Оба сигнала срабатывают, когда активен CDP Runtime domain — то есть одновременно при открытых DevTools и при работе Puppeteer/Playwright
— Сигнал 1 (май 2025): кастомный getter на stack у объекта Error — частично закрыт патчем V8, но обходится через прототипы
— Сигнал 2 (март 2026): Proxy в прототипе объекта + console.groupEnd() — не закрыт, корень проблемы в спецификации ECMAScript
— Детектирование синхронное, без тайминга, без permissions — один вызов console.*
— Runtime.enable одинаково активируется и DevTools UI, и автоматизацией
Что такое CDP и почему он нас выдаёт
Chrome DevTools Protocol (CDP) — это API, через который DevTools общается с браузером. Когда вы открываете инструменты разработчика, браузер активирует домен Runtime внутри V8. То же самое происходит, когда Puppeteer или Playwright вызывают Runtime.enable — с точки зрения V8 это неотличимо.
В режиме Runtime.enable инспектор подписывается на все вызовы console.* и начинает сериализовывать аргументы для отображения в панели DevTools. Именно эта сериализация и создаёт side effects, которые становятся детектируемыми.
Сигнал 1: кастомный getter на stack (май 2025, частично закрыт)
Первый вектор, который широко использовался среди вендоров bot-detection, выглядит так:
В нормальном браузере console.debug(e) просто логирует объект. Никто не читает .stack, getter не вызывается, detected остаётся false.
При активном Runtime.enable инспектор перехватывает вызов и прогоняет аргумент через функцию descriptionForError() в v8/src/inspector/value-mirror.cc. Эта функция обращается к свойствам name, stack и message через object->Get() — а C++ API V8 при обращении к свойству честно вызывает getter, если тот определён. Getter срабатывает. detected становится true.
Патч V8 и его неполнота
В мае 2025 года в V8 приземлились два коммита (7 и 9 мая), которые ввели обёртку getErrorProperty(). Перед чтением свойства она проверяет ScriptId getter'а: если у него есть реальный ScriptId (то есть getter написан на JavaScript, а не является нативным C++-аксессором) — читать отказывается.
Проблема в том, что патч срабатывает только если GetOwnPropertyDescriptor на объекте возвращает дескриптор. Если нет — функция идёт по альтернативной ветке и вызывает object->Get() напрямую, до всякой проверки ScriptId. Getter, определённый не как собственное свойство экземпляра, а через прототип — обходит патч полностью.
Сигнал 2: Proxy в прототипе + console.groupEnd (март 2026, не закрыт)
Второй вектор аккуратнее и глубже:
obj — это не Proxy. Это обычный объект, у которого прототипом является Proxy. typeof obj === "object". Именно эта деталь позволяет обойти защиту.
Четыре слоя C++, которые приводят к срабатыванию trap
Автор прошёлся по всей цепочке выполнения в debug-сборке V8 и задокументировал каждый переход:
- Слой A — инспектор генерирует preview безусловно. Функция
V8ConsoleMessage::wrapArgumentsвызывается для каждого аргумента любогоconsole.*-метода с флагомgeneratePreview = true.console.groupEndпо спецификации не принимает аргументов — но JavaScript позволяет передавать аргументы любой функции, и V8 их исправно обрабатывает. - Слой B — проверка Proxy работает только на поверхности. В
buildObjectPreviewInternalесть цикл, который снимает обёртку Proxy черезwhile(value->IsProxy()). Посколькуobjсам не является Proxy, цикл не выполняется ни разу. Инспектор считает объект «пассивными данными» и передаёт его дальше. - Слой C — DebugPropertyIterator обходит цепочку прототипов заранее. При создании итератора через
DebugPropertyIterator::Create()он сразу накапливает ключи — не лениво по запросу, а при конструировании. Итератор проходит по всей цепочке прототипов: объект без свойств → переход к прототипу (нашему Proxy) → запрос ключей. - Слой D — спецификация ECMAScript требует вызова trap. Когда
KeyAccumulator::GetKeysнаконец добирается до Proxy, выбора нет: спецификация (§10.5.11 [[OwnPropertyKeys]]) прямо требует вызватьownKeystrap, если он определён. V8 вызывает его черезExecution::Call— граница C++/JavaScript пересекается.detected = true.
Корень проблемы: три разумных решения создают дыру
Автор подчёркивает: ни одна отдельная строка кода не является ошибкой. Уязвимость — это пересечение трёх архитектурных решений:
- Безусловная генерация preview. Инспектор сериализует аргументы всех
console.*-методов, даже тех, которые по спецификации аргументов не принимают. - Неполное раскрытие Proxy. Проверка
IsProxy()в двух местах смотрит только на сам объект, но не на его прототипы. Полная защита потребовала бы обхода всей цепочки прототипов при каждом вызове — дорогая операция для того, что сейчас является однострочной проверкой. - Жадный сбор ключей при создании итератора.
DebugPropertyIteratorсобирает все ключи заранее, включая из прототипов. Ленивый подход позволил бы вообще не трогать прототипы, если их ключи в итоге не нужны.
Паттерн — достичь управляемого пользователем trap через код инспектора, который защищает только непосредственный аргумент — вряд ли уникален для этих двух поверхностей.
Что это значит для автоматизации браузеров
Оба сигнала срабатывают в любой среде, где активен Runtime.enable: при открытых DevTools, при использовании Puppeteer, Playwright или любого другого инструмента, работающего через CDP. Среды без Runtime.enable — не детектируются.
Детектирование синхронное — никаких таймингов, никаких разрешений, никаких browser extensions. Один вызов console.groupEnd() с правильно подготовленным объектом даёт однозначный ответ.
Закрыть второй сигнал на стороне V8 сложно: любое решение либо потребует дорогого обхода цепочки прототипов, либо изменит поведение инспектора при работе с Proxy. Спецификация ECMAScript не оставляет пространства для манёвра — trap должен вызываться.
Частые вопросы
Что такое CDP Runtime domain и зачем его активируют?
Chrome DevTools Protocol (CDP) — это API для взаимодействия с внутренностями браузера. Домен Runtime отвечает за исполнение JavaScript и отображение объектов. DevTools активирует его, чтобы показывать переменные в консоли. Puppeteer и Playwright активируют его автоматически для управления страницей и выполнения скриптов.
Можно ли заблокировать детектирование на стороне Puppeteer/Playwright?
Для первого сигнала есть частичные обходы (не определять getter как собственное свойство Error, использовать прототипы). Для второго сигнала — нет. Вызов ownKeys trap при обращении к ключам Proxy является требованием спецификации ECMAScript и не может быть пропущен движком без нарушения совместимости.
Это касается только headless Chrome?
Нет. Сигналы срабатывают всякий раз, когда активен Runtime.enable — то есть в том числе при открытых DevTools в обычном браузере. Разница в том, что bot-detection интересует именно автоматизация, а не пользователи с открытой консолью.
Был ли сигнал 2 раскрыт вендорам bot-detection?
Статья опубликована в личном блоге исследователя. Отдельного responsible disclosure в Chromium не упоминается. Автор ссылается на пост castle.io, который задокументировал первый сигнал и майский патч 2025 года — именно он послужил вдохновением для этого исследования.
Выводы
Исследование наглядно показывает, как три независимых, разумно выглядящих инженерных решения образуют детектируемый side effect. V8 закрыл первый вектор патчем в мае 2025 года — но неполно. Второй вектор остаётся открытым и, по мнению автора, не может быть устранён без изменений либо в архитектуре инспектора, либо в поведении ECMAScript.
Если вы занимаетесь разработкой инструментов автоматизации или browser fingerprinting — полный разбор с исходниками V8 стоит прочитать целиком. Обсуждение развернулось в r/ReverseEngineering.