Информационная безопасность / [Из песочницы] Защита от ботов, основанная на различии в работе с большими числами в JavaScript и PHP

в 13:51, , рубрики: javascript, php, боты, защита от ботов, спам, метки: , , , ,

Недавно мне пришлось разбираться с защитой от ботов, используемой на нескольких довольно популярных ресурсах.
На первый взгляд защита показалась обычной установкой куки через 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 GMTLocation

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.0Set-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-aliveCookie addr=5678:11.22.33.44; urine=aabbccdd; v=1
Host example.comReferer

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)<>>16)*1540483477&65535)<>>24;f=(f&65535)*1540483477+(((f>>>16)*1540483477&65535)<>>16)*1540483477&65535)<<16)^f;c-=4;++e}switch©{case 3:d^=(a.charCodeAt(e+2)&255)<<16;case 2:d^=(a.charCodeAt(e+1)&255)<>>16)*1540483477&65535)<>>13;d=(d&65535)*1540483477+(((d>>>16)*1540483477&65535)<>>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){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 ) <>> 16 ) * 1540483477 & 65535 ) <>> 24;

f = ( f & 65535 ) * 1540483477 + ( ( ( f >>> 16 ) * 1540483477 & 65535 ) <>> 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 ) <>> 16 ) * 1540483477 & 65535 ) <>> 13;

d = ( d & 65535 ) * 1540483477 + ( ( ( d >>> 16 ) * 1540483477 & 65535 ) <>> 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 ) * 1540483477 & 65535 ) << 16 );

в javascript будет равно 22188624159636, а аналогичное в php
( 18220025198660 & 65535 ) * 1540483477 + ( ( ( zeroFill( 18220025198660, 16 ) ) * 1540483477 & 65535 ) <= 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.


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js