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

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

celsoft 7 октября 2016 Блог 8 683
Вне всяких сомнений, 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

Комментарии

  1. rullan (Клиенты)

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

Информация

Комментирование публикаций доступно только пользователям имеющим действующую лицензию на скрипт. Если вы уже приобретали скрипт, то вам необходимо зайти на сайт под своим клиентским аккаунтом.

Календарь

«    Сентябрь 2020    »
ПнВтСрЧтПтСбВс
 123456
78910111213
14151617181920
21222324252627
282930 

Опрос на сайте

Совершаете ли вы покупки в интернет?