Недавно мне пришлось разбираться с защитой от ботов, используемой на нескольких довольно популярных ресурсах.
На первый взгляд защита показалась обычной установкой куки через javascript, справиться с которой — дело 15-ти минут. В самом деле, после небольшого исследования стало понятно где что делается и какие параметры куда передаются, остается только переписать небольшую функцию с javascript на php и дело в шляпе.
Но все оказалось не так просто. И хотя в итоге защита была сломана, на это потребовалось далеко не 15 минут, и сам принцип защиты оказался для меня новым и довольно интересным.
Итак, обо всем по порядку.
Защита работает следующим образом.
Скрипт главной страницы сайта index.php ожидает куку, в которой одним из параметров будет указан хеш, вычисленный из IP-адреса посетителя.
Если кука не передается, то index.php перенаправляет посетителя на другую страницу, содержащую javascript код, который вычисляет необходимый параметр, записывает его в куку и возвращает нас обратно на главную страницу.
Чтобы обычный php-бот, выполняющий GET и POST запросы через CURL, смог проходить через такую защиту, нужно переписать вычисление хеша с javascript на php и затем дописывать в заголовок запроса нужную куку.
Теперь подробнее.
Запускаем Firefox, отключаем javascript и включаем Firebug.
Запрашиваем главную страницу index.php и смотрим заголовки запроса и ответа.
Запрос:
GET
Заголовки этого запроса не представляют для нас интереса.
А вот заголовки ответа:
Status: 302 Moved Temporarily
Connection keep-alive
Content-Type text/html
Date XXX GMT
Location
Server YTS/1.20.0
Transfer-Encoding chunked
После чего Firefox автоматически переходит на указанный в заголовке Location, получая следующий заголовок ответа:
Accept-Ranges bytes
Connection keep-alive
Content-Type text/html; charset=utf-8
Date XXX GMT
Last-Modified YYY GMT
Server YTS/1.20.0
Set-Cookie addr=1234:11.22.33.44; path=/
Transfer-Encoding chunked
Где 11.22.33.44 — мой IP-адрес, 1234 — какое-то число, логика вычисления которого неизвестна.
Сама страница содержит ссылку на js-код
Без js нас дальше не пустят.
После того как все запросы-ответы записаны, включаем javascript, очищаем cookie и делаем все заново.
Сейчас нас интересует то, что будет происходить после запроса страницы валидации.
На этот раз загружается главная страница сайта, и вот заголовок последнего запроса:
Accept text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Encoding gzip, deflate
Accept-Language ru-ru,ru;q=0.8,en-us;q=0.5,en;q=0.3
Connection keep-alive
Cookie addr=5678:11.22.33.44; urine=aabbccdd; v=1
Host example.com
Referer
User-Agent какой-то Firefox
Константа 1234 из прошлого ответа сервера в этот раз изменилась на 5678, IP-адрес остался тем же. Судя по всему это ID запроса, присваиваемый сервером и хранящийся в cookie. Ну что ж, его надо сохранить и просто записывать в куки в неизменном виде во время запросов.
А вот параметр urine=aabbccdd — это уже интересно. Раз он не приходил от сервера — значит он был получен у нас, и что-то подсказывает мне что это дело рук va.js.
Самое время посмотреть что там внутри. На первый взгляд полное болото, в которое лучше не влезать:
Но немного терпения, и после форматирования все выглядит читабельно и довольно понятно.
Есть две функции coo() и poo(), и код который пишет нужную нам куку и отправляет обратно на index.php.
Функция сoo() не представляет особого интереса, она получает значение указанного параметра из куки, и легко переписывается на php простым регулярным выражением.
А вот функция poo(), которая считает параметр urine:
Во время вызова ей передаются такие параметры:
a — это и есть уже готовое значение параметра urine (дальше оно только дополняется нулями если содержит меньше 8 символов).
addr — наш IP-адрес 11.22.33.44.
47 — константа.
Теперь все выглядит понятно.
php-бот, пробивающий эту защиту, должен работать по следующему алгоритму.
1. Делаем GET-запрос
И заодно включаем автоматический переход в случае редиректа:
В этом случае curl сам выполнит переход на новый location, и нам нет нужды программировать второй запрос. И мы получим заголовки обоих ответов, в первом заголовке будет Location, во втором — первая кука, содержащая ID запроса.
2. Парсим заголовки, получаем ID запроса и свой IP-адрес (если мы используем разные трюки то мы можем его сразу и не знать, а здесь его нам любезно подсказывают — очень удобно).
Считаем параметр urine, записываем в куку и отправляем новый GET-запрос на index.php. Защита пройдена.
Кука прописывается так:
Итак, остался последний штрих — вычисление urine.
Нужно просто переписать функцию poo() на php.
Для начала немного гуглим и пишем аналоги для пары js-функций и операторов, которых нет в php:
Теперь все готово, и можно переписать poo():
Сохраняем, запускаем и обламываемся — результаты js и php версий не совпадают.
В чем дело?
Добавляем код в js и php для вывода результата после каждой строки вычислений и смотрим в чем дело.
Оказывается простые арифметические операторы php в отличие от javascript плохо умеют работать с большими числами.
Например выражение
в javascript будет равно 22188624159636, а аналогичное в php
будет равно немного другому числу 22188624159600
Когда несколько подобных формул вычисляются подряд то ошибка накапливается, давая в итоге совсем другой результат. В некоторых выражениях php по умолчанию предполагает что результат является типом int и ограничивает максимальное значение до 4 млрд (на 32-х разрядных системах).
Похожие проблемы с большими числами есть и у Perl.
Для точных вычислений в php необходимо использовать функции библиотеки BC Math. Вместе с этим нужно добавить приведение к типу float.
В результате проб и ошибок получаем код, дающий те же результаты что и javascript. Но это требует дополнительных времени и усилий.
Код не самый оптимальный, для большей ясности вычисления выполняются по шагам.
И для функции zeroFill() добавляем в самое начало:
Мои боты свое дело сделали, а вы можете использовать описанную здесь защиту в своих целях. Если ее модифицировать, например динамически менять делающий вычисления код, то подобный взлом станет еще более трудной задачей. И если за вас никто не захочет взяться всерьез то этой защиты будет достаточно.
А вообще, лучшая защита от ботов — это капча. Даже самый хитрый javascript может быть выполнен ботами, использующими что-нибудь типа Perl-модуля Mechanize.
На первый взгляд защита показалась обычной установкой куки через javascript, справиться с которой — дело 15-ти минут. В самом деле, после небольшого исследования стало понятно где что делается и какие параметры куда передаются, остается только переписать небольшую функцию с javascript на php и дело в шляпе.
Но все оказалось не так просто. И хотя в итоге защита была сломана, на это потребовалось далеко не 15 минут, и сам принцип защиты оказался для меня новым и довольно интересным.
Итак, обо всем по порядку.
Поверхностный осмотр
Защита работает следующим образом.
Скрипт главной страницы сайта index.php ожидает куку, в которой одним из параметров будет указан хеш, вычисленный из IP-адреса посетителя.
Если кука не передается, то index.php перенаправляет посетителя на другую страницу, содержащую javascript код, который вычисляет необходимый параметр, записывает его в куку и возвращает нас обратно на главную страницу.
Чтобы обычный php-бот, выполняющий GET и POST запросы через CURL, смог проходить через такую защиту, нужно переписать вычисление хеша с javascript на php и затем дописывать в заголовок запроса нужную куку.
Вскрытие
Теперь подробнее.
Запускаем Firefox, отключаем javascript и включаем Firebug.
Запрашиваем главную страницу index.php и смотрим заголовки запроса и ответа.
Запрос:
GET
http://example.com
Заголовки этого запроса не представляют для нас интереса.
А вот заголовки ответа:
Status: 302 Moved Temporarily
Connection keep-alive
Content-Type text/html
Date XXX GMT
Location
http://example.com/govalidateyourself#98765:1234:11.22.33.44:/index.php
Server YTS/1.20.0
Transfer-Encoding chunked
После чего Firefox автоматически переходит на указанный в заголовке Location, получая следующий заголовок ответа:
Accept-Ranges bytes
Connection keep-alive
Content-Type text/html; charset=utf-8
Date XXX GMT
Last-Modified YYY GMT
Server YTS/1.20.0
Set-Cookie addr=1234:11.22.33.44; path=/
Transfer-Encoding chunked
Где 11.22.33.44 — мой IP-адрес, 1234 — какое-то число, логика вычисления которого неизвестна.
Сама страница содержит ссылку на js-код
http://example2.com/validator/va.jsи надпись «No javascript».
Без js нас дальше не пустят.
После того как все запросы-ответы записаны, включаем javascript, очищаем cookie и делаем все заново.
Сейчас нас интересует то, что будет происходить после запроса страницы валидации.
На этот раз загружается главная страница сайта, и вот заголовок последнего запроса:
Accept text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Encoding gzip, deflate
Accept-Language ru-ru,ru;q=0.8,en-us;q=0.5,en;q=0.3
Connection keep-alive
Cookie addr=5678:11.22.33.44; urine=aabbccdd; v=1
Host example.com
Referer
http://example.com/govalidateyourself
User-Agent какой-то Firefox
Константа 1234 из прошлого ответа сервера в этот раз изменилась на 5678, IP-адрес остался тем же. Судя по всему это ID запроса, присваиваемый сервером и хранящийся в cookie. Ну что ж, его надо сохранить и просто записывать в куки в неизменном виде во время запросов.
А вот параметр urine=aabbccdd — это уже интересно. Раз он не приходил от сервера — значит он был получен у нас, и что-то подсказывает мне что это дело рук va.js.
Самое время посмотреть что там внутри. На первый взгляд полное болото, в которое лучше не влезать:
if(document.cookie==""){document.write("Cookies error")}else{function poo(a,b){var c=a.length,d=b^c,e=0,f;while(c>=4){f=a.charCodeAt(e)&255|(a.charCodeAt(++e)&255)<<8|(a.charCodeAt(++e)&255)<<16|(a.charCodeAt(++e)&255)<<24;f=(f&65535)*1540483477+(((f>>>16)*1540483477&65535)<<16);f^=f>>>24;f=(f&65535)*1540483477+(((f>>>16)*1540483477&65535)<<16);d=(d&65535)*1540483477+(((d>>>16)*1540483477&65535)<<16)^f;c-=4;++e}switch(c){case 3:d^=(a.charCodeAt(e+2)&255)<<16;case 2:d^=(a.charCodeAt(e+1)&255)<<8;case 1:d^=a.charCodeAt(e)&255;d=(d&65535)*1540483477+(((d>>>16)*1540483477&65535)<<16)}d^=d>>>13;d=(d&65535)*1540483477+(((d>>>16)*1540483477&65535)<<16);d^=d>>>15;return d>>>0}function coo(a){var b=a+"=";var c=document.cookie.split(";");for(var d=0;d<c.length;d++){var e=c[d];while(e.charAt(0)==" ")e=e.substring(1,e.length);if(e.indexOf(b)==0)return e.substring(b.length,e.length)}return null}var dt=new Date,expiryTime=dt.setTime(dt.getTime()+1000e5);var dt2=new Date,expiryTime=dt2.setTime(dt2.getTime()+2e4);var addr=window.location.hash.split(":")[2];var a=poo(addr,47).toString(16);for(var i=0,z="";i<8-a.length;i++)z+="0";a=z+a;a=a.substring(6)+a.substring(4,6)+a.substring(2,4)+a.substring(0,2);var refurl=window.location.hash.split(":")[3];document.cookie="urine="+a+"; expires="+dt.toGMTString()+"; path=/";if(!coo("v")){document.cookie="v=1; expires="+dt2.toGMTString()+"; path=/";setTimeout("window.location = refurl",300)}else if(coo("v")<3){var c=coo("v");c++;document.cookie="v="+c+"; expires="+dt2.toGMTString()+"; path=/";setTimeout("window.location = refurl",300)}else if(coo("v")>=3){document.write("Too many redirects from: "+document.referrer)}}
Но немного терпения, и после форматирования все выглядит читабельно и довольно понятно.
Есть две функции coo() и poo(), и код который пишет нужную нам куку и отправляет обратно на index.php.
Функция сoo() не представляет особого интереса, она получает значение указанного параметра из куки, и легко переписывается на php простым регулярным выражением.
А вот функция poo(), которая считает параметр urine:
function poo( a, b ) { var c = a.length, d = b^c, e = 0, f; while( c >= 4 ) { f = a.charCodeAt( e ) & 255 | ( a.charCodeAt( ++e ) & 255 ) << 8 | ( a.charCodeAt( ++e ) & 255 ) << 16 | ( a.charCodeAt( ++e ) & 255 ) << 24; f = ( f & 65535 ) * 1540483477 + ( ( ( f >>> 16 ) * 1540483477 & 65535 ) << 16 ); f ^= f >>> 24; f = ( f & 65535 ) * 1540483477 + ( ( ( f >>> 16 ) * 1540483477 & 65535 ) << 16 ); d = ( d & 65535 ) * 1540483477 + ( ( ( d >>> 16 ) * 1540483477 & 65535 ) << 16 )^f; c -= 4; ++e } switch( c ) { case 3: d ^= ( a.charCodeAt( e + 2 ) & 255 ) << 16; case 2: d ^= ( a.charCodeAt( e + 1 ) & 255 ) << 8; case 1: d ^= a.charCodeAt( e ) & 255; d = ( d & 65535 ) * 1540483477 + ( ( ( d >>> 16 ) * 1540483477 & 65535 ) << 16 ) } d ^= d >>> 13; d = ( d & 65535 ) * 1540483477 + ( ( ( d >>> 16 ) * 1540483477 & 65535 ) << 16 ); d ^= d >>> 15; return d >>> 0 }
Во время вызова ей передаются такие параметры:
var a = poo( addr, 47 ).toString( 16 );
a — это и есть уже готовое значение параметра urine (дальше оно только дополняется нулями если содержит меньше 8 символов).
addr — наш IP-адрес 11.22.33.44.
47 — константа.
Теперь все выглядит понятно.
php-бот, пробивающий эту защиту, должен работать по следующему алгоритму.
1. Делаем GET-запрос
http://example.com/index.phpCтавим опцию получать заголовки ответа:
curl_setopt( $ch, CURLOPT_HEADER, 1 );
И заодно включаем автоматический переход в случае редиректа:
curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, 1 );
В этом случае curl сам выполнит переход на новый location, и нам нет нужды программировать второй запрос. И мы получим заголовки обоих ответов, в первом заголовке будет Location, во втором — первая кука, содержащая ID запроса.
2. Парсим заголовки, получаем ID запроса и свой IP-адрес (если мы используем разные трюки то мы можем его сразу и не знать, а здесь его нам любезно подсказывают — очень удобно).
Считаем параметр urine, записываем в куку и отправляем новый GET-запрос на index.php. Защита пройдена.
Кука прописывается так:
$headers = array( "Cookie: " . $cookie_str, // "addr=5678:11.22.33.44; urine=aabbccdd; v=1" /* другие заголовки по желанию/необходимости */ ); curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
Итак, остался последний штрих — вычисление urine.
Грабли
Нужно просто переписать функцию poo() на php.
Для начала немного гуглим и пишем аналоги для пары js-функций и операторов, которых нет в php:
// php js functions function charCodeAt( $str, $i ) { return ord( substr( $str, $i, 1 ) ); } // char at function charAt( $str, $i ) { return $str{ $i }; } //unsigned shift right (js >>>) function zeroFill( $a, $b ) { $z = hexdec( 80000000 ); if( $z & $a ) { $a = ( $a >> 1 ); $a &= ( ~ $z ); $a |= 0x40000000; $a = ( $a >> ( $b - 1 ) ); } else { $a = ( $a >> $b ); } return $a; }
Теперь все готово, и можно переписать poo():
// function poo( $a, $b ) { $c = strlen( $a ); $d = $b ^ $c; $e = 0; $f = ''; while( $c >= 4 ) { $f = charCodeAt( $a, $e ) & 255 | ( charCodeAt( $a, ++$e ) & 255 ) << 8 | ( charCodeAt( $a, ++$e ) & 255 ) << 16 | ( charCodeAt( $a, ++$e ) & 255 ) << 24; $f = ( $f & 65535 ) * 1540483477 + ( ( ( zeroFill( $f, 16 ) ) * 1540483477 & 65535 ) << 16 ); $f ^= zeroFill( $f, 24 ); $f = ( $f & 65535 ) * 1540483477 + ( ( ( zeroFill( $f, 16 ) ) * 1540483477 & 65535 ) << 16 ); $d = ( $d & 65535 ) * 1540483477 + ( ( ( zeroFill( $d, 16 ) ) * 1540483477 & 65535 ) << 16 )^$f; $c -= 4; ++$e; } switch( $c ) { case 3: $d ^= ( charCodeAt( $a, $e + 2 ) & 255 ) << 16; case 2: $d ^= ( charCodeAt( $a, $e + 1 ) & 255 ) << 8; case 1: $d ^= charCodeAt( $a, $e ) & 255; $d = ( $d & 65535 ) * 1540483477 + ( ( ( zeroFill( $d, 16 ) ) * 1540483477 & 65535 ) << 16 ); } $d ^= zeroFill( $d, 13 ); $d = ( $d & 65535 ) * 1540483477 + ( ( ( zeroFill( $d, 16 ) ) * 1540483477 & 65535 ) << 16 ); $d ^= zeroFill( $d, 15 ); return zeroFill( $d, 0 ); }
Сохраняем, запускаем и обламываемся — результаты js и php версий не совпадают.
В чем дело?
Добавляем код в js и php для вывода результата после каждой строки вычислений и смотрим в чем дело.
Оказывается простые арифметические операторы php в отличие от javascript плохо умеют работать с большими числами.
Например выражение
( 18220025198660 & 65535 ) * 1540483477 + ( ( ( 18220025198660 >>> 16 ) * 1540483477 & 65535 ) << 16 );
в javascript будет равно 22188624159636, а аналогичное в php
( 18220025198660 & 65535 ) * 1540483477 + ( ( ( zeroFill( 18220025198660, 16 ) ) * 1540483477 & 65535 ) << 16 )
будет равно немного другому числу 22188624159600
Когда несколько подобных формул вычисляются подряд то ошибка накапливается, давая в итоге совсем другой результат. В некоторых выражениях php по умолчанию предполагает что результат является типом int и ограничивает максимальное значение до 4 млрд (на 32-х разрядных системах).
Похожие проблемы с большими числами есть и у Perl.
Для точных вычислений в php необходимо использовать функции библиотеки BC Math. Вместе с этим нужно добавить приведение к типу float.
В результате проб и ошибок получаем код, дающий те же результаты что и javascript. Но это требует дополнительных времени и усилий.
Код не самый оптимальный, для большей ясности вычисления выполняются по шагам.
// function poo( $a, $b ) { $c = strlen( $a ); $d = $b ^ $c; $e = 0; $f = ''; while( $c >= 4 ) { $f = charCodeAt( $a, $e ) & 255 | ( charCodeAt( $a, ++$e ) & 255 ) << 8 | ( charCodeAt( $a, ++$e ) & 255 ) << 16 | ( charCodeAt( $a, ++$e ) & 255 ) << 24; $f = bcadd( bcmul( $f & 65535, 1540483477 ), ( floatval( ( bcmul( ( zeroFill( $f, 16 ) ), ( 1540483477 & 65535 ) ) ) ) << 16 ) ); $xx = zeroFill( $f, 24 ); $f = floatval( $f ) ^ floatval( $xx ); // $f = floatval( $f ); $f1 = bcmul( $f & 65535, 1540483477 ); $f2 = ( floatval( ( bcmul( ( zeroFill( $f, 16 ) ), ( 1540483477 & 65535 ) ) ) ) << 16 ); $f = bcadd( $f1, $f2 ); $d1 = bcmul( $d & 65535, 1540483477 ); $d2 = ( floatval( ( bcmul( ( zeroFill( $d, 16 ) ), ( 1540483477 & 65535 ) ) ) ) << 16 ); $d = bcadd( $d1, $d2 ); $d = floatval( $d ) ^ floatval( $f ); $c -= 4; ++$e; } switch( $c ) { case 3: $d = floatval( $d ) ^ ( ( charCodeAt( $a, $e + 2 ) & 255 ) << 16 ); case 2: $d = floatval( $d ) ^ ( ( charCodeAt( $a, $e + 1 ) & 255 ) << 8 ); case 1: $d = floatval( $d ) ^ ( charCodeAt( $a, $e ) & 255 ); $d1 = bcmul( $d & 65535, 1540483477 ); $d2 = ( floatval( ( bcmul( ( zeroFill( $d, 16 ) ), ( 1540483477 & 65535 ) ) ) ) << 16 ); $d = bcadd( $d1, $d2 ); } $d = floatval( $d ) ^ zeroFill( $d, 13 ); $d1 = bcmul( floatval( floatval( $d ) & 65535 ), 1540483477 ); $dd21 = zeroFill( $d, 16 ); $dd22 = floatval( bcmul( $dd21, 1540483477 & 65535 ) ); $dd23 = floatval( $dd22 << 16 ); $d2 = $dd23; $d = bcadd( $d1, $d2 ); $d = floatval( $d ) ^ zeroFill( $d, 15 ); if( $d < 0 ) { $res = bindec( decbin( ~0 ) ) - abs( $d ) + 1; } else { $res = $d; } return $res; }
И для функции zeroFill() добавляем в самое начало:
$a = floatval( $a );
Заключение
Мои боты свое дело сделали, а вы можете использовать описанную здесь защиту в своих целях. Если ее модифицировать, например динамически менять делающий вычисления код, то подобный взлом станет еще более трудной задачей. И если за вас никто не захочет взяться всерьез то этой защиты будет достаточно.
А вообще, лучшая защита от ботов — это капча. Даже самый хитрый javascript может быть выполнен ботами, использующими что-нибудь типа Perl-модуля Mechanize.
