1 011 измерений текста: как форма регистрации доказывает, что браузер врёт про свою ОС
Один вызов, повторённый тысячу раз
Откройте профиль браузера, пройдите регистрацию на площадке, защищённой SDK от Castle, и посчитайте вызовы. За одну регистрацию мы насчитали 1 011 обращений к CanvasRenderingContext2D.measureText.
Не к document.fonts.check. Не к API перечисления шрифтов. Не к запросу разрешения queryLocalFonts. Тысяча измерений того, какой ширины получается строка текста.
Это число и есть вся статья. Дальше — только о том, что оно означает и почему это самый неудобный для подделки сигнал в браузере.
Перечисление научились закрывать. Измерение — нет.
Любой антидетект-браузер на рынке умеет управлять тем, какие семейства шрифтов видит страница. Отфильтруйте перечисляемый набор, закройте пару API — и наивный детектор («у вас есть Helvetica Neue и Menlo, значит вы на Mac») замолкает.
Поэтому детекторы перестали спрашивать, какие у вас шрифты, и начали спрашивать, какой они ширины.
Механика тут не хитрая. Так CSS подбирал шрифты всегда:
ctx.font = '72px "Some Family"'
ctx.measureText('mmmmmmmmmmlli WWWW 0123456789').width
Если Some Family есть — вы получаете метрики этого семейства. Если нет — текст рисуется запасным шрифтом, и вы получаете метрики запасного. Ни ошибки, ни исключения, ни undefined. «Не установлен» — это не состояние, которое можно спрятать, это конкретное число, и детектор его знает, потому что может получить его в любой момент, запросив семейство, которого нет ни у кого:
ctx.font = '72px "Nsjkdfh Qwerty 4471"'
Вот и вся базовая линия. Всё, что совпало с ней, — отсутствует.
Сравнение, которое всё решает
Теперь поставьте перед этим персону. Браузер заявляет Windows: navigator.platform отдаёт Win32, user-agent — Windows NT 10.0, client hints — Windows, строки GPU называют адаптер Direct3D.
На настоящей машине с Windows есть Segoe UI, Calibri и Cambria. Это разные гарнитуры с разными метриками, поэтому одна и та же строка даёт в них три разные ширины — и ни одна не равна базовой линии.
На машине, которая Windows не является, нет ни одного из трёх. Все три уходят в запасной шрифт. Все три измеряются как базовая линия. На практике это выглядит так, при 72px:
| Запрошенное семейство | Персона Windows на машине не под Windows | Машина, которая действительно Windows |
|---|---|---|
| Segoe UI | 1287.88 | одно значение |
| Calibri | 1287.88 | другое значение |
| Cambria | 1287.88 | третье значение |
| Семейство, которого нет ни у кого | 1287.88 | 1287.88, и больше ничто с ним не совпадает |
Левый столбец — противоречие, которому нет невинного объяснения. Нет на свете такой установки Windows, где Segoe UI измеряется ровно так же, как несуществующий шрифт. Одно сравнение, без машинного обучения и скоринга: профиль сказал «Windows», а движок отрисовки говорит обратное.
И поскольку это арифметика, а не эвристика, ей безразлично, насколько хорош остальной отпечаток. Мы прошли всю лестницу: отлежавшиеся профили, чистые резидентные выходы, несколько разных IP, живые руки на клавиатуре вместо автоматизации. Отказ везде. Приведите метрики в порядок — и тот же сценарий проходит.
На мобильных персонах всё хуже
Случай с Windows хотя бы ломается на трёх поимённо названных семействах. Мобильная персона на десктопной машине ломается на чём-то более фундаментальном.
В CSS есть три родовых семейства, которые браузер обязан во что-то разрешить: sans-serif, serif, monospace. На настоящем Android-устройстве это три разные гарнитуры, то есть три разные ширины. Это не артефакт фингерпринтинга, это просто свойство телефона.
Запустите Android-персону на десктопном хосте без набора шрифтов Android — и все три разрешатся в один и тот же запасной шрифт: одна ширина, повторённая трижды. Детектору даже не нужно знать, какие шрифты есть в Android. Достаточно знать, что serif и monospace на живом телефоне никогда не совпадают по ширине.
Это тот же провал, что и в случае с Windows, только уровнем ниже — и именно поэтому «какую ОС я заявляю» не может быть косметической настройкой.
Шрифты — не единственное, что читает Castle
Для полноты картины: шрифтовой сигнал привлёк наше внимание, но собирают не только его. В той же регистрации:
PointerEvent.pointerTypeпрочитан 4 853 разаEvent.timeStamp— 1 759 разMouseEvent.clientX/clientY— 1 279 разgetCoalescedEvents()вызван 688 разisTrustedпрочитан 639 раз
Это поведенческий профиль: каким устройством ввода вы являетесь, как указатель движется между двумя кликами, есть ли вообще промежуточные точки, которые порождает настоящая мышь. Драйверу, который телепортирует курсор от элемента к элементу и печатает с машинной скоростью, заполнить эти поля нечем.
Но про доказательства мы хотим сказать точно: в наших тестах поведенческая поверхность не была причиной отказов. Сессии, которые вёл живой человек, отклонялись так же, как автоматические, и те же профили проходили, как только метрики переставали противоречить друг другу. Поведение — это вклад в скоринг; шрифтовое противоречие было жёстким отказом.
Что из этого следует, если вы строите поверх браузера
Три вывода, и ни один из них не про конкретный SDK.
Заявление, которое вы не можете отрисовать, лучше не делать. Строки бесплатны — user-agent поставит кто угодно. Метрики не бесплатны: они берутся из настоящих шрифтовых файлов, которые действительно растрируются. Если ваш стек говорит «Windows», метрики шрифтов Windows должны быть на месте — для каждого семейства, которое есть на машине с Windows. Если их нет, вы не замаскировали машину, вы её пометили.
Вычитание — не сложение. Любой белый список, чёрный список и фильтр перечисления умеют только убирать шрифты. Это по-настоящему полезно: так семейства, характерные для хоста, не попадают в поле зрения. Но ни один фильтр никогда не создал шрифт, которого нет на диске. Если фильтр — ваш единственный инструмент, то заявляемая ОС обязана совпадать с той, на которой вы работаете.
Не проверяйте это через document.fonts.check. Он возвращает true практически на любое семейство, включая имена, выдуманные на ходу, потому что отвечает на вопрос «можно ли это отрисовать» — а отрисовать можно всё, через запасной шрифт. Это самый частый способ убедить себя, что со шрифтами всё хорошо, когда всё плохо. Измеряйте ширину или offsetWidth у элемента. Ничто другое не считается.
Проверьте свой браузер за тридцать секунд
Работает где угодно, без библиотек и разрешений:
const ctx = document.createElement('canvas').getContext('2d')
const sample = 'mmmmmmmmmmlli WWWW 0123456789'
const width = (family) => {
ctx.font = `72px "${family}"`
return ctx.measureText(sample).width
}
const baseline = width('Nsjkdfh Qwerty 4471') // семейства с таким именем нет ни у кого
const claimed = ['Segoe UI', 'Calibri', 'Cambria'].map(width)
console.log({ baseline, claimed })
// Персона Windows: все три обязаны отличаться от baseline и друг от друга.
// sans-serif / serif / monospace: три разных числа, всегда.
Если ваш браузер заявляет Windows и печатает одно и то же число четыре раза — именно это и увидела форма регистрации.
Где стоим мы
Наш движок решает это на уровне отрисовки, а не скриптом, который подменяет measureText постфактум, — по той же причине, по которой авторизация на прокси у нас живёт в сетевом слое, а не в расширении: всё, что страница может прочитать, она может сравнить с чем-то ещё, что она тоже может прочитать. Разбирать реализацию здесь мы не будем.
Скажем то, что полезно вам в любом случае: выбирайте ОС персоны осознанно, знайте, способен ли ваш браузер выдать метрики этой ОС на той машине, где вы его запускаете, и проверяйте это измерением, а не вопросом. В этом весь фильтр.
Попробуйте на бесплатном тарифе.
Неограниченное число локальных профилей, без карты. Проверьте результат детекторами сами.