Спецификация ECMAScript заставляет V8 раскрывать, запущен ли DevTools — и это нельзя пропатчить

Обложка: Спецификация ECMAScript заставляет V8 раскрывать, запущен ли DevTools — и это нельзя пропатчить

Вы запустили 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, выглядит так:

			let detected = false;
const e = new Error();
Object.defineProperty(e, "stack", {
  configurable: false,
  enumerable: false,
  get() { detected = true; return ""; },
});
console.debug(e);
return detected; // true, если Runtime domain активен
		

В нормальном браузере 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, не закрыт)

Второй вектор аккуратнее и глубже:

			let detected = false;
const trap = new Proxy({}, {
  ownKeys() { detected = true; return []; },
});
const obj = Object.create(trap);
console.groupEnd(obj);
return detected; // true, если Runtime domain активен
		

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]]) прямо требует вызвать ownKeys trap, если он определён. V8 вызывает его через Execution::Call — граница C++/JavaScript пересекается. detected = true.

Корень проблемы: три разумных решения создают дыру

Автор подчёркивает: ни одна отдельная строка кода не является ошибкой. Уязвимость — это пересечение трёх архитектурных решений:

  1. Безусловная генерация preview. Инспектор сериализует аргументы всех console.*-методов, даже тех, которые по спецификации аргументов не принимают.
  2. Неполное раскрытие Proxy. Проверка IsProxy() в двух местах смотрит только на сам объект, но не на его прототипы. Полная защита потребовала бы обхода всей цепочки прототипов при каждом вызове — дорогая операция для того, что сейчас является однострочной проверкой.
  3. Жадный сбор ключей при создании итератора. DebugPropertyIterator собирает все ключи заранее, включая из прототипов. Ленивый подход позволил бы вообще не трогать прототипы, если их ключи в итоге не нужны.
Паттерн — достичь управляемого пользователем trap через код инспектора, который защищает только непосредственный аргумент — вряд ли уникален для этих двух поверхностей.
SvebaАвтор исследования, svebaa.github.io

Что это значит для автоматизации браузеров

Оба сигнала срабатывают в любой среде, где активен 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.