/ / Доверяй, но проверяй: защита от SQL-инъекций
октябрь 07 2016

Доверяй, но проверяй: защита от SQL-инъекций

Вне всяких сомнений, SQL-инъекции являются одним из самых распространенных способов взлома сайта. Едва ли не первое, что пытается провернуть взломщик – тестирование популярных инъекций. В этом небольшом посте мы вкратце рассмотрим историю вопроса, методы борьбы с инъекциями, а также напишем небольшой PHP-класс обертку для PDOStatement для безопасного подключения и взаимодействия с MySQL-сервером (MySQL в данном случае приводится лишь по причине наибольшей распространенности, при желании все нижеследующее может быть адаптировано и на другие СУБД).

Суть вопроса:

Что, в сущности, из себя представляет инъекция? Начинающий php-разработчик, только приступивший к изучению MySQL, вероятнее всего, будет конструировать запросы примерно следующим образом:

$query = "SELECT name FROM mytable WHERE id=" . $_GET['id'];
В данном случае, PHP запрашивает у MySQL значение name соответствующее id, полученному через GET. Вероятно, id был введен из формы или возник в результате перехода пользователя по ссылке вида http://somedomain.com/?id=5. Сконструированный таким образом запрос обычно отправляется на сервер с использованием популярной php-библиотеки mysqli.

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

5; SELECT * FROM mytable;
или еще страшнее:

5; DROP TABLE mytable;

В результате, MySQL-сервер сначала выполнит наш запрос, а следом за ним – запрос злоумышленника. Таким образом, взломщик, в зависимости от ситуации (и прав MySQL юзера, который используется PHP) сделать почти все что угодно: от получения доступа к конфиденциальной информации до полного контроля над вашей базой данных.

Как же разработчику защитить свой сайт от инъекций?

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

$query = "SELECT name FROM mytable WHERE id=" . intval($_GET['id']);
Или:

if( intval ( $_GET['id'] ) ) {
	$query = "SELECT name FROM mytable WHERE id=" . intval($_GET['id']);
} else {
	exit('айайай!');
}
В данном случае, полученная из GET-а информация перед отправкой проверяется на числовое значение. Любое значение, отличное от числового, введенное злоумышленником не будет отправлено на MySQL-сервер. Для валидации более сложных значений можно использовать регулярные выражения.
Другим вариантом примитивной защиты является использование нативной функции mysqli_real string_escape() для предотвращения проникновения «корварных» запросов. Главным минусом подобного подхода, является его крайняя неавтоматизированность: нам приходится проверять пользовательское значение каждый раз когда мы делаем запрос. Можно, конечно, использовать готовый SQL-билдер со встроенной защитой от SQL-инъекций или написать свой. Однако, в данном посте мы предлагаем несколько иной подход, основанный на использовании PDO.

Использование PDOStatement

Начиная с версии 5.1 в PHP доступен встроенный класс PDO (PHP Data Objects). Данный класс содержит богатый набор методов для работы с широким спектром баз данных. Несмотря на почтенный возраст данного инструмента, многие разработчики им пренебрегает, предпочитая «по-старинке» пользоваться библиотекой mysqli, в том числе и мы в DLE, но у нас есть ряд важный причин, DLE это старый скрипт, который появился задолго до PDO, и у нас есть обязательства по совместимости, как со старыми версиями скрипта, так и по максимальному упрощению процесса обновления, для тех, кто пользуется сторонними модулями. Плюс мы очень тщательно подходим к вопросам фильтрации входящих данных. Но вы в отличие от нас не такие старые «динозавры», поэтому главная мысль, которую мы хотим донести до вас заключается в следующем: прямое использования mysqli без каких-либо оберток – прямой путь к SQL-инъекциям, поэтому пишите код сразу безопасным. У разработчиков, по сути, есть только три выхода:

  • использовать ручную проверку пользовательских данных
  • написать собственную библиотеку (или взять готовую) на основе mysqli с защитой от SQL-инъекций
  • использовать PDO
Именно о последнем способе и пойдет разговор. В PDO имеется специальный набор методов, называемый PDOStatement. Его сущность заключается в том, что сам запрос и значения столбцов или параметров запроса передаются на MySQL-сервер отдельно. Таким образом, мы получаем встроенную защиту от инъекций на все случаи жизни (ну или почти на все). В силу того, что PDOStatement – довольно таки громоздкая штука (с точки зрения строчек кода), удобнее всего пользоваться ей через самописную обертку, написанием которой мы сейчас и займемся. Дабы сразу приучать к хорошему, результаты своей деятельности мы оформим в виде класса.

Наш класс будет содержать всего 4 метода:

  • метод для соединения с базой
  • метод для проверки наличия соединения
  • метод для отправки безопасного запроса через PDOStatement
  • метод для разрыва соединения с базой
Итак приступим. Для работы с базой нам понадобятся 6 свойств:

class myClass {

	private $host = "localhost";
	private $dbname = "dbname";
	private $user = "username";
	private $pass = "userpassword";
	private $charset = "utf8";
	
	private $pdo;
}
Первые 5 содержат хост, имя базы, логин, пароль и кодировку и не нуждаются в пояснении. В 6-е свойство же пригодится нам для хранения объекта pdo. Первым делом следует, конечно, написать метод для соединения:

	public function mysql_connect() {
		$dsn = "mysql:host=" . $this->host . ";dbname=" . $this->dbname . ";charset=" . $this->charset;
		$connopt = array(
    		PDO::ATTR_ERRMODE  => PDO::ERRMODE_EXCEPTION,
    		PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
		);
		$this->pdo = new PDO($dsn, $this->user, $this->pass, $connopt);
	}
Первая строчка содержит набор параметров для соединения с сервером. Массив $connopt задает различные режимы работы с PDO. Эти режимы могут быть использованы для тонкой отладки ошибок и специфических ситуаций. Мы не будем вдаваться в подробности используемых опций в данном руководстве. Все интересующиеся могут обратиться к более подробной документации по PHP. Здесь же отметим, что в последней строчке мы создаем объект для работы с PDO, передавая конструктору заданные параметры, и пишем этот объект в наше свойство $pdo.

Итак, с базой мы соединились. Напишем же метод для проверки наличия соединения. С этой целью мы будем использовать метод getAttribute(PDO::ATTR_CONNECTION_STATUS). В случае нормального соединения он возвращает строчку "hostname via TCP/IP", где hostname – имя нашего хоста.

	public function mysql_get_status() {
		if(is_null($this->pdo)) {
			return false; 
		} elseif ($this->pdo->getAttribute(PDO::ATTR_CONNECTION_STATUS) === $this->host . " via TCP/IP") {
			return true;
		} else {
			return false;
		}
	}
Если свойство pdo пусто – значит соединение не создано (или было разрушено). Следующим этапом мы проверяем значение возвращаемое getAttribute и сравниваем его с нормальным. Если оно отличается – выкидываем в false. Если все прошло гладко – возвращаем true. Если нет – false.

Теперь, собственно, самое интересное. Напишем метод для отправки безопасного запроса к базе.
Наш метод будет иметь три аргумента:

  • тело самого запроса
  • ассоциативный массив с набором значений
  • бинарный аргумент, определяющий требуется ли вернуть данные из базы. Для выполнение SELECT’ов данный аргумент будет равен true, а для INSERT’ов\UPDATE’ов – false.
Итак, наш метод:

	public function mysql_query($query, $placeholders = null, $select = true) {
		if($this->mysql_get_status()) {
			$stmt = $this->pdo->prepare($query);
			if(!is_null($placeholders)) {
				$stmt->execute($placeholders);
			} else {
				$stmt->execute();
			}
			if ($select) {
				$arr = $stmt->fetchAll();
				return $arr;
			}
		} else {
			return false; 
		}
	}
Давайте разберемся, что же здесь происходит. Первое условие необходимо для того, чтобы пытаться выполнять запрос только при наличии соединения с базой данных. Во втором условии, мы проверяем наличие массива значений (плейсхолдеров). Если массив не указан – значит мы выполняем запрос «как есть». Если же массив плейсхолдеров имеет место быть, мы передаем его методу execute() в качестве аргумента. Методы prepare() и execute() – и есть то ради чего все создавалось. Как вы могли заметить, при работе с базой через PDO тело запроса и его значения передаются PDO отдельно друг от друга. При этом сам запрос пишется в следующем виде:

"SELECT name FROM mytable WHERE id = :user_id"
Где :user_id – название ключа в массиве, передаваемом в execute(). То есть в явном виде отправка запроса с использованием PDOStatement выглядит примерно так:

$smtp = $pdo->prepare("SELECT name FROM mytable WHERE id = :user_id");
$stmt-execute( array ('user_id' => '5' ) );
Для разрушения соединения достаточно лишь присвоить null объекту pdo. Поэтому, метод для ликвидации соединения будет самым коротким:

	public function mysql_destroy() {
		$this->pdo = null;
	}
Итак, соберем наш класс воедино:

class myClass {
	private $host = "localhost";
	private $dbname = "dbname";
	private $user = "username";
	private $pass = "userpassword";
	private $charset = "utf8";
	
	private $pdo;

	public function mysql_connect() {
		$dsn = "mysql:host=" . $this->host . ";dbname=" . $this->dbname . ";charset=" . $this->charset;
		$connopt = array(
    		PDO::ATTR_ERRMODE  => PDO::ERRMODE_EXCEPTION,
    		PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
		);
		$this->pdo = new PDO($dsn, $this->user, $this->pass, $connopt);
	}
	public function mysql_get_status() {
		if(is_null($this->pdo)) {
			return false; 
		} elseif ($this->pdo->getAttribute(PDO::ATTR_CONNECTION_STATUS) === $this -> host . " via TCP/IP") {
			return true;
		} else {
			return false;
		}
	}
	public function mysql_query($query, $placeholders = null, $select = true) {
		if($this->mysql_get_status()) {
			$stmt = $this->pdo->prepare($query);
			if(!is_null($placeholders)) {
				$stmt->execute($placeholders);
			} else {
				$stmt->execute();
			}
			if ($select) {
				$arr = $stmt->fetchAll();
				return $arr;
			}
		} else {
			return false; 
		}
	}

	public function mysql_destroy() {
		$this->pdo = null;
	}
}
Пример отправки безопасного запроса:
//устанавливаем соединение
$obj = new myClass; 
$obj->mysql_connect(); 

//SELECT
$data = $obj->mysql_query("SELECT name FROM mytable WHERE id = :user_id AND email=:email;", array('user_id' => '5', 'email' => 'somemail@mail.com' ), TRUE); 
print_r( $data ); 

//INSERT 
$obj->mysql_query("INSERT INTO mytable (‘name’, ‘email’) VALUES (:name, :email);", array( 'name' => 'Иван', 'email' => 'somemail@mail.com' ), FALSE); 

//разрываем соединение
$obj->mysql_destroy();
На этом пока все. Удачи вам и хорошего настроения. Подписывайтесь на нашу страницу в социальной сети "Вконтаке" https://vk.com/dlepage

Комментарии

rullan

rullan

7 октября 2016 21:32 Клиенты
0
Спасибо за информацию!

Информация

Посетители, находящиеся в группе Гости, не могут оставлять комментарии к данной публикации.
Календарь
«    Январь 2017    »
ПнВтСрЧтПтСбВс
 1
2345678
9101112131415
16171819202122
23242526272829
3031 
Опрос на сайте
Совершаете ли вы покупки в интернет?

Популярные новости
Архив новостей
Январь 2017 (1)
Декабрь 2016 (3)
Ноябрь 2016 (3)
Октябрь 2016 (2)
Сентябрь 2016 (3)
Июль 2016 (1)