Назад в Центр знаний
Отпечатки

Плагины-патчи браузера: что меняют и чего не достигают

Публичный список обходных скриптов плагина для Puppeteer, три поверхности, которых патч страницы не достигает, и fixture, который показывает, применился ли патч.

BotBrowser Team

Документация

Нужна структурированная документация по теме Отпечатки?

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

Плагин такого рода это набор патчей на JavaScript страницы, и форма этой доставки определяет и то, что он может изменить, и то, что не может. Популярный puppeteer-extra-plugin-stealth поставляется как каталог обходных скриптов, каждый из которых это небольшой скрипт evaluateOnNewDocument, переписывающий одно свойство navigator, один член пространства имён chrome или один геттер WebGL до того, как выполнится собственный код страницы. Это реальный и поддающийся проверке механизм, и у него есть граница: патч, который выполняется внутри страницы, можно прочитать изнутри страницы, а три поверхности вообще не проходят через JavaScript страницы. Публичный исходный код плагина перечисляет, что именно он патчит и каких трёх поверхностей не достигает, а fixture читает значение, дескриптор и toString рядом друг с другом, поэтому видно, применился ли патч на самом деле. Варианты для Puppeteer и Playwright это отдельные сторонние проекты, каждый со своим графиком выпуска; ни один из них не рекомендуется. Долю прохождений локальное изменение обещать не может.

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

TL;DR

  • Обходные скрипты плагина это скрипты страницы, внедряемые до собственного кода документа, а не конфигурация браузера. Каждый переписывает значение, видимое из JavaScript, и страница может прочитать результат обратно.
  • Публичный каталог обходных скриптов содержит 19 записей: chrome.app, chrome.csi, chrome.loadTimes, chrome.runtime, defaultArgs, iframe.contentWindow, media.codecs, navigator.hardwareConcurrency, navigator.languages, navigator.permissions, navigator.plugins, navigator.vendor, navigator.webdriver, sourceurl, user-agent-override, webgl.vendor, window.outerdimensions, плюс общие каталоги _template и _utils.
  • navigator.webdriver обрабатывается дважды: слой аргументов добавляет --disable-blink-features=AutomationControlled, а скрипт страницы удаляет свойство прототипа в старых сборках Chromium, где оно ещё присутствует.
  • Пропатченное и непропатченное значение неразличимы через console.log. Именно чтение Object.getOwnPropertyDescriptor и Function.prototype.toString отделяет перезаписанное свойство от нативного.
  • Три поверхности никогда не доходят до JavaScript страницы: отпечатки сетевого уровня, рукопожатие TLS и параметры HTTP/2; собственный процесс браузера и его командная строка; и всё, что сервер решает по соединению, а не по документу.

Схема в слоях показывает патчи JavaScript страницы, достигающие трёх поверхностей внутри документа, тогда как три другие поверхности лежат ниже границы JavaScript: сетевой уровень и TLS, процесс браузера с командной строкой и решение на стороне сервера.

Содержание

Что на самом деле содержит публичный список обходных скриптов

Исходный код плагина это каталог с именем evasions. Прочитать его достаточно, чтобы знать, к чему плагин прикасается, потому что каждый подкаталог это один именованный обходной скрипт со своим index.js.

Обходной скриптЗатрагиваемая поверхностьЧто меняет скрипт
navigator.webdriverФлаг автоматизацииУдаляет свойство прототипа в старых сборках; добавляет аргумент запуска в новых
navigator.vendorИдентичность navigatorПерезаписывает строку вендора
navigator.pluginsПеречисление плагиновЗаменяет содержимое массива плагинов
navigator.languagesСписок языковПерезаписывает сообщаемый массив языков
navigator.permissionsЗапрос разрешенийОборачивает результат запроса
navigator.hardwareConcurrencyЧисло ядерПерезаписывает сообщаемое целое число
webgl.vendorПараметр WebGLОборачивает getParameter для строк вендора и рендерера
chrome.app, chrome.csi, chrome.loadTimes, chrome.runtimeПространство имён chromeДобавляет или перезаписывает члены, доступные странице
media.codecsПоддержка кодековПерезаписывает ответ о кодеках
iframe.contentWindowИдентичность между фреймамиВыравнивает значения, сообщаемые окном iframe
window.outerdimensionsГеометрия окнаСообщает внешний размер, который был бы у обычного окна
user-agent-overrideЗаголовок и значение user agentЗадаёт заголовок и строку, видимую странице
sourceurlURL источника скриптаСнимает маркер sourceURL, который Puppeteer добавляет своим внедрённым скриптам
defaultArgsАргументы запускаОтбрасывает умолчания Puppeteer, по которым браузер проще опознать

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

Способ доставки и есть ограничивающее условие

evaluateOnNewDocument выполняет функцию до любого скрипта страницы. Именно эта синхронизация делает патч невидимым для наивного чтения: к моменту, когда выполняется код страницы, у свойства уже стоит перезаписанное значение. Но патч не меняет браузер; он меняет то, что видит документ после того, как браузер выдал значение.

Эта разница объясняет целый класс отказов. Сервер, который принимает решение по рукопожатию TLS, никогда не выполняет скрипт страницы, поэтому никакое количество evaluateOnNewDocument до него не доходит. Клиент CDP, который смотрит командную строку цели, читает аргументы процесса, а не документ, поэтому перезапись уровня страницы там тоже ничего не даёт.

Два семейства плагинов не имеют общего кода

puppeteer-extra и playwright-extra это отдельные пакеты одного репозитория, у каждого свой интерфейс плагина. Плагин, написанный под один из них, не загружается в другом без прослойки, и две экосистемы обновляют свои точки внедрения по разным графикам. Для сравнения это важно: список обходных скриптов, прочитанный в пакете Puppeteer, описывает то, что этот пакет патчит на пути запуска Puppeteer, а вариант для Playwright это отдельный артефакт со своим списком.

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

Что на самом деле делает обходной скрипт webdriver

У обходного скрипта navigator.webdriver две половины, и читать обе поучительно. Хук beforeLaunch меняет аргументы запуска: если --disable-blink-features= уже присутствует, он дописывает AutomationControlled, иначе добавляет флаг целиком. Затем хук onPageCreated выполняет скрипт страницы, который проверяет текущее значение и удаляет Object.getPrototypeOf(navigator).webdriver только в тех сборках, где свойство ещё присутствует и истинно.

Значит, плагин честен насчёт собственной границы: в сборке Chromium, где флаг уже даёт false или undefined, скрипт страницы не делает вообще ничего. Это полезная деталь устройства для любого, кто читает утверждение о плагине, потому что она показывает: даже авторы плагина считают аргумент запуска основным исправлением, а скрипт страницы патчем совместимости.

Чего не достигает патч на JavaScript страницы

Три группы поверхностей лежат ниже документа или рядом с ним, и ни один скрипт evaluateOnNewDocument не может их изменить.

Сетевой уровень и TLS

Клиентское приветствие TLS, порядок наборов шифров, расширения, список протоколов ALPN, кадр SETTINGS HTTP/2 и порядок заголовков формируются сетевым стеком браузера до того, как существует хоть один документ. Сервер, который снимает отпечаток соединения, видит форму, к которой скрипт страницы никогда не имел доступа. Добавление или удаление свойства у navigator не меняет ни одного байта этого рукопожатия.

То же рассуждение охватывает поведение DNS и повторное использование соединения. Скрипт уровня страницы не может изменить, какой резолвер ответил, было ли соединение переиспользовано и сколько заняло TCP-рукопожатие, потому что эти события завершаются до первой строки кода страницы.

Процесс браузера и командная строка

Дерево процессов, путь к исполняемому файлу, аргументы в /proc/<pid>/cmdline в Linux и отношение родитель-потомок между лаунчером и его браузером наблюдаемы снаружи документа. Скрипт страницы может перезаписать то, что документ сообщает о себе, но он не может перезаписать вектор аргументов, который запустил процесс.

Именно поэтому плагин меняет args в beforeLaunch, а не пытается убрать флаги из вида одной страницы. Флаги видны всякому, кто может прочитать процесс, и собственный комментарий плагина про информационную панель Chrome признаёт, что у аргумента есть видимое последствие.

Решение на стороне сервера

Последняя группа вообще не отпечаток; это модель, которая потребляет показания. Сервис проверки может объединить форму сети, заголовки запроса, тайминги, историю аккаунта и значения, сообщаемые страницей, в одну оценку, и ни один из этих входов не проходит через скрипт страницы как значение, которое можно править. Даже идеальный патч внутри документа оставляет оценку поставщику. Какие именно чтения выбирает конкретный продукт, перечислено в что детектор действительно читает, и двум продуктам не обязательно выбирать один и тот же набор.

Это самая глубокая граница, и именно она делает выражение «недетектируемый браузер» утверждением о модели, а не свойством браузера.

Fixture, который читает значение, дескриптор и toString

Fixture, который читает только navigator.webdriver, не может отличить пропатченное значение от нативного, потому что оба возвращают логическое значение. Чтение трёх вещей рядом друг с другом их разделяет.

Три чтения

Первое это само значение: navigator.webdriver, или navigator.vendor, или результат WebGLRenderingContext.getParameter. Именно это читает наивный детектор.

Второе это дескриптор свойства: Object.getOwnPropertyDescriptor(Object.getPrototypeOf(navigator), 'webdriver'). У нативного аксессора есть функция get, в исходном тексте которой назван встроенный геттер. Удалённое свойство возвращает undefined; переопределённое возвращает дескриптор, чей get указывает на обёртку, и она может нести или не нести те же флаги, что и нативный аксессор.

Третье это Function.prototype.toString для этого геттера. Исходный текст нативного аксессора несёт собственную подпись и заканчивается на [native code]; в сборке Chromium геттер Navigator.prototype.webdriver читается как function get webdriver() { [native code] }. Обёртка, определённая в JavaScript, показывает собственное тело. В каталоге _utils самого плагина есть помощник withUtils, который патчит toString ровно по этой причине, и это само по себе доказывает, что чтение работает. Что ещё видно при отбеленном toString, разобрано отдельно в материале ложь нативной подмены и белый список toString, где перечислены проверки, переживающие перезапись исходного текста.

ЧтениеОтвет нативного кодаОтвет пропатченного кодаЧего оно не доказывает
Чтение значенияЛогическое значение, строка вендора, строка рендерераПерезаписанное значениеКакое из них его выдало
getOwnPropertyDescriptorНативный аксессор с get и setundefined или переопределённый аксессорСогласована ли страница в остальном
Function.prototype.toString[native code]Собственный исходный текст обёрткиУбедительна ли обёртка

Почему fixture нужен на своей странице

Чтение должно выполняться на документе, которым управляет оператор, потому что цель в том, чтобы сравнить известную конфигурацию с известным документом, а не проверять чужой сайт. Локального URL data: или статического файла на диске достаточно: скрипт fixture читает три значения, печатает их как JSON и сохраняет результат рядом с записью о прогоне.

Сравнение после этого тривиально. Если значение отличается от того, что сборка браузера сообщила бы нативно, дескриптор показывает, было ли свойство удалено или заменено, а toString показывает, объявляет ли обёртка себя. Патч, который оставляет строку [native code] там, где живёт обёртка, это патч, пропатчивший заодно toString, и это ровно тот случай, ради которого существует помощник _utils плагина.

Почему fixture нуждается в чистой базе сравнения

Сравнение работает только тогда, когда fixture есть с чем сравнить. Три значения нужно прочитать на той же сборке при выключенном плагине и сохранить эту базу рядом с пропатченным прогоном. Без неё строка [native code] ничего не доказывает, потому что в записи нет того, как выглядел непропатченный аксессор именно в этой сборке.

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

Чтение патча, который выдаёт себя за нативный

Интересный случай это обёртка, у которой toString тоже пропатчен и возвращает [native code]. Это не делает чтение бесполезным; это меняет то, что чтение доказывает. Дескриптор, чей get не является геттером движка, всё равно отличается от непатченного эталона, снятого на той же сборке, а обёртка, которая заявляет [native code], тогда как исходный текст, прочитанный в другом realm, отличается от непатченного эталона, противоречит сама себе. Сравнение идентичности между realm для этого не годится: геттер свежего iframe это другой объект функции даже в браузере без единого патча, поэтому такое сравнение сообщает о различии в обоих случаях.

Поэтому fixture стоит записывать и флаги дескриптора: configurable, enumerable, writable и наличие set. Нативный navigator.webdriver это аксессор только для чтения с устойчивым набором флагов, но флаги стоит записывать, не делая их критерием: обходной скрипт, удаляющий свойство, не оставляет дескриптора вовсе, а помощник replaceProperty в плагине раскрывает существующий дескриптор и подменяет только get, поэтому заменённый геттер сохраняет нативные флаги. Такая запись не требует, чтобы страница была честна насчёт toString.

Запись того, что увидел fixture

Три чтения вместе с идентификатором сборки браузера, версией внедрённого скрипта и URL документа стоит хранить в одном объекте JSON на прогон. Два прогона с одинаковой конфигурацией должны дать одинаковые чтения; разница между ними означает, что внедрение не применилось в одном из прогонов, а это дефект конфигурации, а не результат детектора.

Что fixture не может решить

Fixture описывает документ. Он не описывает соединение, процесс или модель. Безупречное чтение пропатченного navigator.webdriver на своей странице ничего не говорит о том, как поставщик оценит сессию, и этот разрыв и есть причина, по которой запись каждого прогона должна называть измеренную поверхность.

Эта граница должна оставаться в самой записи. Поле с меткой surface: document честно; то же чтение с меткой verdict: pass уже нет, потому что fixture никогда не запускал модель поставщика.

FAQ

Меняет ли патч уровня страницы сетевой отпечаток браузера?

Нет. Его обходные скрипты это скрипты страницы, внедряемые до кода документа, плюс изменение аргумента запуска. Байты клиентского приветствия TLS, кадр SETTINGS HTTP/2 и порядок заголовков формируются сетевым стеком до того, как существует хоть один документ, поэтому скрипт страницы до них не доходит.

Достаточно ли пропатченного navigator.webdriver, чтобы пройти детектор?

Нет. Детектор может прочитать значение, дескриптор свойства, toString, аргументы запуска или форму сети, и модель поставщика решает, что именно он читает. Обходной скрипт затрагивает одно свойство прототипа двумя путями кода, тогда как флаги дескриптора, исходный текст, прочитанный в другом realm, и форма соединения остаются тем, что дал запуск.

Как отличить пропатченное свойство от нативного?

На своей странице читаются три вещи: значение, Object.getOwnPropertyDescriptor на прототипе и Function.prototype.toString для get из дескриптора. Флаги дескриптора записываются для прогона, а затем исходный текст читается внутри realm iframe его собственным Function.prototype.toString и сравнивается с непатченным эталоном, снятым на той же сборке. Обёртка с пропатченным toString всё равно отличается от эталона, если сравнить записанный исходный текст.

Практический вывод

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

Следующий шаг это один прогон fixture на своей странице. Значения, дескриптор и toString для navigator.webdriver выводятся как JSON рядом со сборкой браузера и версией внедрённого скрипта, и прогон повторяется ещё один раз. Если оба прогона совпадают, патч применился согласованно; если различаются, исследовать нужно внедрение, а не детектор. Смежные материалы: что на самом деле читает детектор разбирает список поверхностей со стороны детектора, а реестр детекторов BotD показывает, какие проверки запускает один детектор с открытым исходным кодом.

Источники

#Плагин Браузера#Puppeteer-Extra#Navigator.webdriver#Function toString#Дескриптор

Эта статья входит в руководство: Что такое отпечаток браузера?

Выполните ту же проверку в BotBrowser

Загрузите профиль, сохраните внутри браузера те сигналы, о которых только что прочитали, и подтвердите результат на своей машине до раскатки.