перевод статьи Detecting PhantomJS Based Visitors
неплохое обсуждение статьи на Hacker News
Статья старая, помидорами не кидайтесь — лучше делитесь опытом в комментариях.
В наши дни во многих инцидентах по безопасности используется автоматизация (со стороны злоумышленников). Web-scraping, повторное использование паролей, click-fraud — все это совершается злоумышленниками в попытках (зачастую успешных) замаскироваться под обычного пользователя, то есть по сути выглядеть для сервера как броузер обычного пользователя. Как владелец сайта, вы наверно хотите быть уверены в том что обслуживаете людей а не бездушные железки, а как поставщик сервиса вы наверно хотите еще и доступ дать к своему контенту через api, а не через тяжелый и глючный web-интерфейс.
Предположим что у вас уже есть простенькая проверка для cUrl и ему подобных посетителей, и она достаточно эффективна. Следующим шагом ожидаемо будет поставить проверку на то что ваши клиенты настоящие и пользуются настоящим броузером, с тупым и глючным UI, а не боты на поделках типа PhantomJS или SlimerJS.
В этой статье мы рассмотрим пару приемов для определения фантомных ботов. Я рассматриваю только фантом, так как он более популярен, но многие моменты могут быть использованы и для SlimerJS и ему подобных.
Важно!!! Рассматриваемые методы применимы к обоим веткам фантома (1.x и 2.x) если явно не оговорено иное.
Для начала: можно ли определить фантома даже не отвечая ему (то есть исключительно по его http запросу)?
HTTP стек
Вы, должно быть, знаете что фантом построен на QT фрейворке. Так вот, Qt реализует HTTP стек несколько иначе, чем другие современные броузеры.
Для начала давайте взглянем на простенький http запрос Хрома:
GET / HTTP/1.1
Host: localhost:1337
Connection: keep-alive
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36
Accept-Encoding: gzip, deflate, sdch
Accept-Language: en-US,en;q=0.8,ru;q=0.6
А теперь этот же запрос в фантоме:
GET / HTTP/1.1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit/534.34 (KHTML, like Gecko) PhantomJS/1.9.8 Safari/534.34
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Connection: Keep-Alive
Accept-Encoding: gzip
Accept-Language: en-US,*
Host: localhost:1337
Обратите внимание, хедеры фантома отличаются от хрома (как и от большинства современных броузеров):
- Host хедер идет последним (у хрома первым)
- значение хидера Connection (обратите внимание на регистр)
- Accept-Encoding у фантома только gzip
- User-Agent содержит “PhantomJS”
Проверка на различие этих хедеров на стороне сервера может помочь определить фантомный заход.
Но насколько безопасно доверять такой проверке? Если злоумышленник использует прокси для перезаписи этих хедеров то в общем то ему не составит труда мимикрировать под нормальный броузер.
Похоже что такое решение проблемы не тянет на серебряную пулю. Ок, давайте взглянем, что мы можем сделать на клиенте используя фантомные особенности JavaScript окружения.
Проверка User-Agent на клиенте
мы можем не верить полученному в запросе User-Agent, но что насчет значения в клиенте?
if (/PhantomJS/.test(window.navigator.userAgent)) {
console.log("PhantomJS environment detected.");
}
К сожалению это так же легко поменять как хедер в запросе, так что этого явно не достаточно.
Плагины
navigator.plugins содержит массив плагинов установленных в броузере. Обычно он содержит что то вроде Flash, ActiveX, поддержку для java апплетов или Default Browser Helper который указывает на то что этот броузер — дефолтный в OS X. Наши исследования показывают что большинство установок «с нуля» общераспространенных броузеров содержат хотя бы один дефолтный плагин — даже на мобилах.
Этим и отличается PhantomJs — он не ставит никаких плагинов и более того — не дает никаких возможностей их установить ( PhantomJS API)
Следующая проверка может быть вполне полезной:
if (!(navigator.plugins instanceof PluginArray) || navigator.plugins.length == 0) {
console.log("PhantomJS environment detected.");
} else {
console.log("PhantomJS environment not detected.");
}
С другой стороны — крайне просто подменить массив navigator.plugins исполняя js код ДО подгрузки страницы (как здесь)
Также никакого труда не составляет создать кастомную сборку с настоящими установленными плагинами. Это гораздо легче чем кажется потому что QT, на котором построен фантом, предоставляет возможности для подключения npapi плагинов.
Timing
Другой интересный момент — это то как PhantomJS рубит JavaScript диалоги:
var start = Date.now();
alert('Press OK');
var elapse = Date.now() - start;
if (elapse < 15) {
console.log("PhantomJS environment detected. #1");
} else {
console.log("PhantomJS environment not detected.");
}
После нескольких проверок можно предположить что если диалог закрывается меньше чем за 15 милисекунд, то скорее всего броузер не контролируется человеком. Но использование этой техники предполагает некоторый негатив со стороны реальных пользователей, которые вынуждены будут закрывать непонятные окошки. (на самом деле этот момент можно обойти, привязавшись к каким либо действиям пользователя, например предлагая что либо при наведении на какой либо элемент — тот момент когда пользователь говорит «нет, спасибо». Тоже немного навязчиво, но по крайней мере хоть какой то смысл в происходящем с точки зрения пользователя — прим. перевод.)
Глобалы
PhantomJS 1.x предоставляет два вида глобалов:
if (window.callPhantom || window._phantom) {
console.log("PhantomJS environment detected.");
} else {
console.log("PhantomJS environment not detected.");
}
Но это часть экспериментальной технологии, так что все еще может поменяться.
Фишки JavaScript движка
PhantomJS 1.x и 2.x используют не самые свежие версии WebKit, что подразумевает отсутствие новых модных плюшек, внедренных уже в последние версии броузеров. Это автоматически распространяется и на JS движок, то есть некоторые свойства и методы ведут себя иначе или вообще отсутствуют в PhantomJS (тут правда непонятно чем это все отличается от просто старого броузера — прим. перев.)
Один из таких методов — Function.prototype.bind, отсутствующий в PhantomJS 1.x и старше. Следующий пример проверяет — есть ли bind у прототипа функции и если есть — то точно ли он нативный а не зашимленный.
(function () {
if (!Function.prototype.bind) {
console.log("PhantomJS environment detected. #1");
return;
}
if (Function.prototype.bind.toString().replace(/bind/g, 'Error') != Error.toString()) {
console.log("PhantomJS environment detected. #2");
return;
}
if (Function.prototype.toString.toString().replace(/toString/g, 'Error') != Error.toString()) {
console.log("PhantomJS environment detected. #3");
return;
}
console.log("PhantomJS environment not detected.");
})();
</script>
Если вам этот код кажется слегка непонятным, можно взглянуть на небольшое объяснение в деталях здесь (видео)
Stack Traces
Ошибки которые генерит JavaScript код обработанные PhantomJS через команду evaluate содержат уникальный стек по которому можно определить «безголовый» броузер.
Предположим что PhantomJS вызывает обработку в следующем коде:
var err;
try {
null[0]();
} catch (e) {
err = e;
}
if (indexOfString(err.stack, 'phantomjs') > -1) {
console.log("PhantomJS environment detected.");
} else {
console.log("PhantomJS environment is not detected.");
}
Обратите внимание — здесь у нас кастомная indexOfString() функция, (реализацию мы оставили за скобками предполагая что у читателя не вызовет никаких затруднений реализовать ее) так как нативная String.prototype.indexOf может быть подменена PhantomJS (пользовательским скриптом) и возвращать отрицательный результат. (что в общем то тоже нетрудно проверить — прим. перевод.)
Так, а как теперь PhantomJS заставить исполнить этот код? Одна из техник — переписать наиболее часто используемые DOM функции которые с большой вероятностью будут вызваны. Например код ниже переписывает document.querySelectorAll что бы перехватить stack trace броузера:
var html = document.querySelectorAll('html');
var oldQSA = document.querySelectorAll;
Document.prototype.querySelectorAll = Element.prototype.querySelectorAll = function () {
var err;
try {
null[0]();
} catch (e) {
err = e;
}
if (indexOfString(err.stack, 'phantomjs') > -1) {
return html;
} else {
return oldQSA.apply(this, arguments);
}
};
Итого
В этой статье мы рассмотрели 7 разных техник определения PhantomJS как на сервере так и на клиенте. Комбинируя результаты проверки с обратной связью (например замеряя скорость рендеринга или ломая сессионную куку) можно в принципе устроить сложностей PhantomJS посетителям. Но держите в голове что эти техники не являются строгими и безошибочными (по факту на кастомных сборках ни одна не работает — прим. перевод.) и продвинутый противник может прорвать оборону. Для углубления в тему мы рекомендуем к просмотру нашу презентацию (видео, слайды). Есть также GitHub репа с примерами и возможными путями обхода проверок.
Спасибо за внимание и удачной охоты!
Автор: gonzazoid