суббота, 9 января 2010 г.

Flex, PHP: Безопасная передача данных с клиента на сервер

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

1. Подписывание данных секретным ключом.
Это дает гарантию, что данные не будут подменены на промежуточных узлах до сервера.
Технология следующая:

a. Генерируем секретный ключ, о котором знает только приложения клиента и сервера
b. К передаваемым данным на сервер добавляем еще один параметр равный хэшу данных с секретным ключом. Пример на Flex:
var key:String = "0000-0000-0000-0000-0000";
var sreq:String = "method=setBalance&addMoney=1000"; //сам запрос
//отсортируем параметры
//чтобы последовательность параметром и на клиенте и на сервере была одинакова. Это гарантирует схождение хэшей.
sreq = sreq.split("&").sort().join("&");
//добавим к параметрам время, чтобы нельзя было продублировать запрос 
var now:Date = new Date();
var sec = now.getTime().toString().substr(0, -3);
//конечный запрос к серверу с дополнительным параметром = хэшу запроса и секретного ключа
sreq = sreq + "&timestamp="+sec+"&sig="+MD5.hash(sreq+key);
c. Сервер принимает запрос и проверяет данные используя свой секретный ключ.
К сожалению нельзя использовать четкое совпадение времени клиента и сервера, т.к. оно может отличаться или сообщение может идти до нескольких секунд. Из-за этого на сервере допускается расхождение timestamp до 100 секунд. Серверный код на PHP:
$_TIME    = $_SERVER['REQUEST_TIME'];//время запроса
$_KEY     = "0000-0000-0000-0000-0000";//тотже ключ, что и на клиенте
$_TIMEOUT = 100; //допустимое отклонение времени
// Проверим валидность данных
//присланная подпись должна сойтись со сгенерированной на основе закрытого ключа 
$q = $_GET;
//отсортируем также как в flex
unset($q['timestamp'], $q['sig']);
ksort($q);
$req = "";
foreach($q as $k=>$v) {
  $req .= $k."=".$v."&";
}
$req = substr($req, 0, -1);

if($_GET['sig'] != md5($req.$_KEY)) {
  //подписи не сошлись. Данные были подменены.
  exit;
}
//Проверим расхождение времени
if($_GET['timestamp'] + $_TIMEOUT < $_TIME) {
  //Расхождение более чем на 100 секунд. Прерываем запрос
  exit;
}

К сожалению такой метод не гарантирует нам, что запрос не будет перехвачен на промежуточном узле и не продублирован N раз в течении этих 100 секунд. Для устранения этого служит следующий пункт:

2. Проверка запроса на дублирование.
Идею я позаимствовал из TCP/IP. Добавим к запросу еще один параметр = счетчику сообщений, а на сервере будем хранить значение последнего счетчика. На клиенте достаточно сгенерировать счетчик = текущее значение + 1, а на сервере принять, проверить что новое значение больше старого и обновить серверный счетчик. Это гарантирует нам, что если злоумышленник перехватит запрос и пошлет его заново, то сервер его отвергнет, т.к. значение будет меньше хранящегося на сервере.
Для реализации этого механизма на сервере нам уже понадобится БД, а на клиенте дополнительный запрос на получение счетчика.

Под катом можно увидеть полный код клиента и сервера:

Сторона клиента (Flex):
1. Класс реализующий передачу данных и их защиту:
package Lib {  
 import com.adobe.crypto.MD5;
 
 import flash.events.*;
 import flash.net.URLLoader;
 import flash.net.URLRequest;
 import flash.net.URLRequestHeader;
 import flash.net.URLRequestMethod;
 import flash.net.URLVariables;
  
 public class HttpRequest {
  
  private var header:URLRequestHeader;
  private var request:URLRequest;
  private var loader:URLLoader;
  
  public var uid:int = 1;
  private var key:String = "0000-0000-0000-0000-0000";
 
  public function HttpRequest(url: String) {
   loader = new URLLoader();
   header = new URLRequestHeader("pragma", "no-cache");
   request = new URLRequest(url);
  }
  
  public function getData(req: String, func, method:String = "get"):void {
   loader.addEventListener(Event.COMPLETE, func);
   var sreq:String = req + "&uid="+this.uid;
   sreq = sreq.split("&").sort().join("&"); //отсортируем параметры (чтобы хэши сошлись)
   var now:Date = new Date();
   var sec = now.getTime().toString().substr(0, -3);
   sreq = sreq + "×tamp="+sec+"&sig="+MD5.hash(sreq+this.key);
   request.data = new URLVariables(sreq);
   if(method == "post") {
    request.method = URLRequestMethod.POST;
   } else {
    request.method = URLRequestMethod.GET;
   }
   request.requestHeaders.push(header);
   loader.load(request);
  }
 }
}

2. Пример исопльзования:
public static function _get(req:String, func, method:String = "get", server:String = "http://server/listener.php"):void {
 var http:HttpRequest = new HttpRequest(server);
 http.getData(req, function complete(event:Event):void {
  var loader:URLLoader = URLLoader(event.target);
  //генерируем объект (подробнее)
  var xmlObj:XMLTree = new XMLTree(loader.data);
  var data:Object = xmlObj.getData();
  func(data.response);//вызываем колбэк с данными
 }, method);
}

Functions._get("method=user", function(data:Object) {
 Alert.show(data.toString());
});

Серверная сторона (PHP):
$_TIME    = $_SERVER['REQUEST_TIME'];
$_KEY     = "0000-0000-0000-0000-0000";
$_TIMEOUT = 100;
$_TIME_BEFORE_EVENT = 3600*1;
//db
$_SERV    = "localhost";
$_USR     = "root";
$_PASW    = "";
$_DB      = "bookie";

// 1. Проверим валидность данных
//присланная подпись должна сойтись с сгенерированной на основе закрытого ключа (в флэш аналогичная генерация)
//когда данные подтверждены, можно двигаться дальше
$q = $_GET;
//отсортируем также как в flex
unset($q['timestamp'], $q['sig']);
ksort($q);
$req = "";
foreach($q as $k=>$v) {
  $req .= $k."=".$v."&";
}
$req = substr($req, 0, -1);

if($_GET['sig'] != md5($req.$_KEY)) {
  XMLError(1, 'Erorr Sign');
}
//2. Проверим расхождение времени
if($_GET['timestamp'] + $_TIMEOUT < $_TIME) {
  XMLError(2, 'Request Expired');
}

//далее нужна БД
$link = mysql_connect($_SERV, $_USR, $_PASW);
if(!$link) {
  XMLError(5, 'DB Server Unaviable');
}
if(!mysql_select_db($_DB, $link)) {
  XMLError(6, 'DB Not Exist');
}

//основные методы
if($_GET['method'] == 'section') {
   checkRepeat($uid);
   XMLSelect('SELECT id, name, last_update, type_name, cnt_elem FROM section WHERE tid = '.intval($_GET['tid']).getFilter(), 'section');
} else {
  XMLError(4, 'Unknown Method');
}

function checkRepeat($uid) {
  //будем контролировать только запросы на обновление, на просмотр пох
  $id = mysql_result(mysql_query("SELECT lastid FROM `user` WHERE id = ".$uid), 0);

  //Проверим на повтор
  //пришедший счетчик должен быть больше текущего значения. Не важно на сколько, т.к. верность данных мы уже проверили на шаге 1.
  if($_GET['lid'] <= $id) {
    //вопрос: а если запрос долго идет и счетчик уже увеличился?
    XMLError(3, 'Dublicate request');
  }
  if(!mysql_query("UPDATE `user` SET lastid = lastid + 1 WHERE id = ".$uid)) echo mysql_error();
}

function XMLSelect($sql, $name = 'element', $list = true) {
  $result = mysql_query($sql);
  if (!$result) XMLError(7, mysql_error());
  
  header('Content-type: application/xml');
  $ret = '<?xml version="1.0" encoding="utf-8"?>'.
  '<response>';
  $keys = NULL;
  while ($row = mysql_fetch_assoc($result)) {
    $ret .= '<'.$name.'>';
    if(!$keys) {
      $keys = array_keys($row);
    }
    if($list) {
      foreach($keys as $key) {
        $ret .= '<'.$key.'>'.$row[$key].'</'.$key.'>';
      }
    } else {
      $ret .= $row[$keys[0]];
    }
    $ret .= '</'.$name.'>';
  }
  echo $ret.'</response>';
  exit;
}

function XMLUpdate($sql, $uid = NULL) {
  if(!empty($uid))
    checkRepeat($uid);
  $result = mysql_query($sql);
  if (!$result) XMLError(7, mysql_error());
  header('Content-type: application/xml');
  echo '<?xml version="1.0" encoding="utf-8"?>'.
       '<response>1</response>';
  exit;
}

function XMLError($code, $str = 'Unknown Error') {
  header('Content-type: application/xml');
  echo '<?xml version="1.0" encoding="utf-8"?>'.
       '<error>'.
       ' <error_code>'.$code.'</error_code>'.
       ' <error_msg>'.$str.'</error_msg>'.
       '</error>';
  exit;
}

Структура БД не имеет значения. Надеюсь общий смысл понятен.

27 комментариев:

  1. А если я открою код Flex-приложения 16-ричным редактором, а лучше специальным анализатором Flash-файлов, и найду секретный ключ?

    ОтветитьУдалить
  2. Справедливое замечание. Но это свойственно любому приложению (exe, swf, ...).
    Что-то у меня пока нет идей как это обойти...

    Хотя може делать чтото типа этого:
    1. Использовать первый запрос к апи в настройках приложения. Получить подписанные достоверные данные.
    2. Получить секретный ключ от сервера на основе этих данных.
    3. Дальше по предыдущей схеме.

    ОтветитьУдалить
  3. Это в случае, если приложение запущена во вконтакте. Иначе нужна какая то система авторизации на сайте, где будет запускаться flash приложение, чтобы получить достоверные подписанные данные о пользователе на первом этапе.

    ОтветитьУдалить
  4. Но всё равно этот секретный ключ перехватывается.
    Даже если пытаться защитить его каким-либо алгоритмом, swf декомпилится, алгоритм узнается, и ключ получаем всегда... А потом шлём сколько угодно запросов, со счетчиком равным 262435623.
    Не очень-то хорошо...

    Я не прав?

    ОтветитьУдалить
  5. Да уж, получается максимум что можно сделать - это усложнить взлом...
    А что, если генерировать уникальную подпись на сервере для каждого запроса, с привязкой к расположению приложения. Но это похоже тоже легко сломается, если подделать расположение приложения.

    ОтветитьУдалить
  6. А что если замешать хеш типа sha256($Username:$_SERVER['REMOTE_ADDR']) и проверять при каждом подключении (запросе) клиента ключ, хранящийся на сервере + использовать SSL? ;)

    ОтветитьУдалить
  7. В данном случае динамика обеспечена и даже в случае перехвата ключа... Сервер, проверяя ключ, по сути генерирует его снова
    что то вроде:
    function verifyKey($key)
    {
    $test_key = sha256($SESSION['Username'].":".$_SERVER['REMOTE_ADDR']);
    if($key == $test_key)
    //всё ok, наш клиент
    else
    //"что то не так"
    }
    и тем самым сравнивает источник (адрес источника) запроса с изначальным...
    конечно это довольно медленно - выстраивать хеши при каждом запросе, но помоему в этой схеме есть толк...
    Жду комментов

    ОтветитьУдалить
  8. $_SERVER['REMOTE_ADDR'] - это Ip адрес клиента?
    Т.е. опять же нужна серверная регистрация, чтобы собирать ip пользователей приложения. А если у пользователя динамический ip?
    Ктомуже злоумышленнику достаточно зарегаться на сервере (поснифать чужой трафик) и пользоваться этим ключем до скончания веков.

    Если же $_SERVER['REMOTE_ADDR'] - это адрес расположения приложения, то достаточно узнать через whois ip адрес приложения и в декомпилированном приложении подставить этот ip.

    ОтветитьУдалить
  9. :)
    $_SERVER['REMOTE_ADDR'] - это ip клиента, для которого в данный момент выполняется скрипт.

    Я беру пример где существует регистрация пользователей на сервере... А открытая она или закрытая - это уже другой вопрос)))

    Я так понял что вопрос ставился о невозможности подмены запроса))))



    Возьмём зарегистрированного раннее пользователя скажем "vasya" :) который в данный момент логинится на сервер с ip 92.252.203.22
    сервер проверяет user/pass в случае успешного логина выдаёт клиенту (Flex) ключ сгенерированный как "vasya:92.252.203.22"...
    Я ПОВТОРЯЮ ip юзера вовсе НЕ обязан храниться на сервере (в базе), а по поводу сниффа я не совсем понял... Ведь если запрос, помеченный ключём "vasya:92.252.203.22" придёт с ip скажем 92.252.210.24, то запрос будет отклонён :))))
    Повторяю ля хеша берётся текущий адрес ЗАЛОГИНЕНОГО пользователя! Имя пользователя в хеше используется только для уникальности хеша, например, если "92.252.203.22" это интернет-шлюз какой нибудь организации, за которой 200 компов.
    Способа "fuck the system" всего три:
    1. Подмена IP злоумышленником в то время когда клиент работает с сервером (когда ключ выдан и действителен), но для этого нужно засниффить каким то образом IP клиента, более того... Нужно вообще знать, что ключ строится на основе IP ;)
    2. Хак логина (например брутворс), но для предотвращения этого давно придумали CAPCHA.
    3. Хак базы... ну что тут можно сказать... DB сервер не должен торчать "в инет" :))
    В базе пароли должны быть зашифрованы алгоритмом, отличающимся от того, которым они шифруются при передаче от клиента к серверу ;)

    ОтветитьУдалить
  10. Сергей, спасибо! Очень похоже на правду. Особенно, если использовать сочетаний сессий на сервере и ip клиента. У меня остался только вопрос: на сколько сложно подделать ip адрес клиента? Если это трудноосуществимая операция, то защита выглядит очень надежно.

    ОтветитьУдалить
  11. В особо тяжелых случаях еще можно использовать одноразовые пароли etoken (http://ru.wikipedia.org/wiki/EToken) :) Для подтвержения пользователя.

    ОтветитьУдалить
  12. Теоретически существует возможность подменить адрес в заголовке TCP датаргаммы, но на пути от проги, которая это сделает есть ещё сетевая карта, которая формирует TCP аппаратно и вставляет в датаграммы установленный для неё (сетевухи) IP... Далее - провайдер.
    А вобще TCP - протокол, основанный на факте соединения (что подразумевает возможность передачи в оба конца) то есть если клиент обращается к серверу, то сервер на транспортном уровне (TCP) должен иметь возможность обратиться к клиенту, а так как в инет не могут торчать 2 кома с одним IP, то возможность подмены IP в TCP заголовке кажется весьма неправдоподобной :)
    А вообще надо спецификацию TCP почитать...
    Единственное. что может произойти, это случайность...
    Скажем, если вышеупомянутый Вася и злоумышленник в данный момент используют один пул провайдера (92.252.203.22) то есть внешний IP у них в данный момент один..., но в этом случае (поидее) возможность сниффа со стороны злоумышленника - исключена.
    Да и вообще снифф возможен только по схеме клиент-сниффер-сервер, либо если они в одной локалке (подключены к одному маршрутизатору, но тут всё зависит от конфигурации маршрутизатора)
    Я не знаю как с таким количеством факторов рассчитать возможность подмены IP адреса в TCP заголовке в количественном исчислении)))) но мне она кажется очень маловероятной.

    ОтветитьУдалить
  13. Этот комментарий был удален автором.

    ОтветитьУдалить
  14. "Скажем, если вышеупомянутый Вася и злоумышленник в данный момент используют один пул провайдера (92.252.203.22) то есть внешний IP у них в данный момент один..., но в этом случае (поидее) возможность сниффа со стороны злоумышленника - исключена.
    "

    Ну или если взять какую нибудь маленькую организацию, используюшую свой интернет шлюз (прокси сервер) с внешним (интернет) ip-адресом 92.252.203.22, то сисадмин в этой конторе может снифить траффик сколько ему вздумается, и впринципе может теоретически пользоваться ключом клиента при условии что знает как конкретный Flex клиент взаимодействует с сервером (например декомпиляция клиента), но тут уж ничего не сделаешь... это скорее человеческий фактор :) От этого никак не защититься...

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

    ОтветитьУдалить
  16. Да право не стоит, Алексей :)
    Это всё просто логика)))))
    Я уверен что это всё давным давно используется...

    ОтветитьУдалить
  17. Кстати по поводу сессий...
    Я просто сразу внимания не обратил :)
    В случае с сессиями по моему нет смысла в использовании хешированных ключей...
    Потому что по сути сессия занимается тем же самым, только на более низком уровне реализации (сервер).
    Этот как вы выразились "метод" - является скорее альтернативой сессиям...
    Я допёр до этого тогда, когда мне пришлось искать способ избавиться от использования сессий при работе Flex приложения (для AIR) с сервером. т.к принудительно обновлять сессию по таймеру в Flex приложении после бездействия клиента скажем в течении 10 минут - показалось как то криво..., а вечные сессии - форменное б*ядство :)

    ОтветитьУдалить
  18. К ключу лучше ешё прикрутить какое - нибудь рандомное значение, сгенерированное на сервере...

    ОтветитьУдалить
  19. Хотя в этом случае всё немного усложняется т.к это (рандомное) значение надо хранить где то на сервере(для последующей проверки входящего ключа)... либо действительно в сессии, либо в базе...

    ОтветитьУдалить
  20. Можно как в статье: счетчик запросов, тогда исключится возможность повторного запроса.

    ОтветитьУдалить
  21. Вот только вы забыли еще об одном виде хацкеров =)))

    Что если, злоумышленник и клиент - одно и то же лицо? Ну например, захотел он подкрутить себе рейтинг в игре или пару баксов добавить себе на счет?

    Что тогда? Есть ли способы защититься от декомпиляции?

    ОтветитьУдалить
  22. Ни один из способов не является приемлемым. Мутки с ип вам позволят привязать лишь текущую сессию - грубо говоря, приложение не запустят в 2 окна. Что бы защитить соединения одного пользователя от дрыгих - достаточно обычного ключа (ид уникального) пользователя - все это в мд5 и на сервер. Большинство проблем создают именно пользователи-злоумышленники которые пишут ботов, и пытаются найти уязвимости на сервере - для СВОЕГО персонажа в игре например.

    Защитить способов нет - но есть усложнить.

    ОтветитьУдалить
  23. Вот потому клиент и оперирует только с конечной информацией.
    На примере игры:
    *Пользователь выбрал куда бить - send
    *Сервак просчитал попадание, урон, остатки хп моба, записал в базу - показал это клиенту - load
    *Пользователь выбрал куда бить снова - send

    на стороне клиента ТОЛЬКО отображение информации, на стороне сервера - рассчеты, работа с данными.

    декомпиляция клиента игры в итоге не даст никаких возможностей накрутки.

    Cпасибо большое. Vobar.

    ОтветитьУдалить
  24. В статье обсуждается защита данных от подмены. Один из способо - подпись данных закрытым ключем.

    Также не всегда есть возможность обрабатывать такой поток данных в режиме реального времени на сервере.

    ОтветитьУдалить
  25. Столкнулся с такой темой.. Думал зашифровать. Но потом возникла проблема, что как бы не шифровал, пользователь всегда может продублировать запрос и допустим потратив игровые деньги вернуть заново. Потом пришла идея, тоже с использованием времени и даты. Только забота в том, что время отправки с компа пользователя попадает в заголовок пакета. Вот и сравнивать. Расшифровываем, берем время(в php не знаю есть такая функция или нет, заголовки смотреть запроса) если время отправки уже имело место быть (тут бд понадобится) баним за дублирование запроса. Что имеем в итоге? 1. Используя шифрование данных (des, rsa, aes и подобные) защищаемся от подделки запроса. 2. Ни легальный пользователь, ни нелегальный, ни даже программа, не знает о том, что помимо шифра, сервер еще проверит и время отправки. Здесь поясню, мы программно не присваиваем время и дату, это сделает ОС при отправке данных в инет. 3. Защита конечно не гарантирует подделки, но усложняет ее хорошенько. Ну и недостаток метода это если в одну и ту же секунду программа легально отправит два разных запроса, это нужно учесть. Не более одного запроса в секунду. 4. RSA алгоритм как раз и решает проблему шифрования и подделки запроса, а точнее его невозможности (ну только если у вас нет 100 тыс лет жизни, что б дождаться взлома ключа), но при этом не убирает проблему дублирования

    ОтветитьУдалить
  26. про дублирование в случае с базой данных это не проблема, перед тем как чтото туда вбивать или вычислять делается маленькая проверка на несколько полей, скажем логин + таймштамп (который он получит от сервера и с которым он сможет делать только 1 запрос после которого он получит новый штамп), и если есть несовпадение то запрос или просто убивается без предупреждения клиента или клиенту выдается нужное предупреждение + ко всему этому обновление штампа, задумка не сложная но довольно эффективная поскольку старые штампы в реальном времени убиваются тем самым и не бесконечны (поскольку обновляются при новом запросе)и можно к ним таймеры применять по необходимости...

    ОтветитьУдалить
  27. И все таки самая большая проблема - это защита от подмены данных когда злоумышленник и клиент - одно и то же лицо.
    1. дубли мы исключаем счетчиком + таймстамп
    2. для каждого запроса на сервере генерится рандомное значение, которое используется для шифрования запроса клиентом. Также на клиенте генерится рандом для ответов сервера.
    3. на клиенте и на сервере хранится секретный ключ, который также используется для подсчета контрольной суммы... вот защита этого ключа и есть проблема, потому как клиент у нас - swf_ка, и очень легко декомпилится. Думаю единственное решение - это обфускация кода. Только вот хорошие обфускаторы стоят хороших денег.
    также можно отправлять по ssl - но и тут есть методы подмены (((
    решения по полной защите нет.

    ОтветитьУдалить