← Все статьи

1 011 измерений текста: как форма регистрации доказывает, что браузер врёт про свою ОС

6 мин чтения фингерпринтингдетектшрифтыантибот

Один вызов, повторённый тысячу раз

Откройте профиль браузера, пройдите регистрацию на площадке, защищённой 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

Для полноты картины: шрифтовой сигнал привлёк наше внимание, но собирают не только его. В той же регистрации:

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

Но про доказательства мы хотим сказать точно: в наших тестах поведенческая поверхность не была причиной отказов. Сессии, которые вёл живой человек, отклонялись так же, как автоматические, и те же профили проходили, как только метрики переставали противоречить друг другу. Поведение — это вклад в скоринг; шрифтовое противоречие было жёстким отказом.

Что из этого следует, если вы строите поверх браузера

Три вывода, и ни один из них не про конкретный 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 постфактум, — по той же причине, по которой авторизация на прокси у нас живёт в сетевом слое, а не в расширении: всё, что страница может прочитать, она может сравнить с чем-то ещё, что она тоже может прочитать. Разбирать реализацию здесь мы не будем.

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

Попробуйте на бесплатном тарифе.

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