Pull to refresh

Защита от ботов, основанная на различии в работе с большими числами в JavaScript и PHP

Reading time 8 min
Views 19K
Недавно мне пришлось разбираться с защитой от ботов, используемой на нескольких довольно популярных ресурсах.
На первый взгляд защита показалась обычной установкой куки через 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.php
Cтавим опцию получать заголовки ответа:

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.
Tags:
Hubs:
+41
Comments 44
Comments Comments 44

Articles