SQL-инъекции

155

Во всех приведенных до сих пор примерах работы с базами данных в контексте ASP.NET использовались жестко закодированные значения. Это позволило сделать примеры простыми, прямолинейными и относительно безопасными. Это также означает, что они не слишком реалистичны и не могут продемонстрировать одну из наиболее серьезных опасностей, которая грозит веб-приложениям, взаимодействующим с базой данных — атаку внедрением SQL (SQL-инъекции).

Если говорить просто, внедрение SQL — это процесс передачи SQL-кода приложению таким способом, который не предусмотрен его разработчиком. Это может оказаться возможным из-за неудачного проектного решения и касается только тех приложений, которые используют прием построения SQL-строк для создания команд со значениями, поставляемыми пользователем.

Рассмотрим пример, показанный на рисунке ниже. В этом примере пользователь вводит идентификатор заказчика, a GridView отображает все строки для этого заказчика. В более реалистичном примере пользователь должен ввести некоторую аутентификационную информацию, такую как пароль. Или же идентификатор пользователя может быть основан на предыдущем экране регистрации, а текстовое поле обеспечит возможности пользователю указать дополнительные критерии наподобие диапазона дат или наименования товара в заказе:

Пользовательский запрос конфигурируется с оператором SQL

Здесь проблема связана с тем, каким образом выполняется команда. В данном примере оператор SQL строится динамически, с применением приема сборки строки. Значение из текстового поля txtID просто помещается в середину строки. Вот как выглядит этот код, включая разметку страницы:

<form id="form1" runat="server">
        <div>
            Введите ID заказчика: 
    <br />
            <asp:TextBox ID="txtID" runat="server"></asp:TextBox>
            <asp:Button ID="cmdGetRecords"
                runat="server" Text="Получить данные" OnClick="cmdGetRecords_Click"></asp:Button>
            <br />
            <br />
            <asp:GridView ID="GridView1"
                runat="server" Width="392px" Height="123px" Font-Names="Verdana" Font-Size="X-Small">
            </asp:GridView>
        </div>
</form>
protected void cmdGetRecords_Click(object sender, EventArgs e)
{
        string connectionString = WebConfigurationManager.ConnectionStrings["Northwind"].ConnectionString;
        SqlConnection con = new SqlConnection(connectionString);

        // Получить заказы по идентификатору заказчика
        string sql =
            "SELECT Orders.CustomerID, Orders.OrderID, COUNT(UnitPrice) AS Items, " +
            "SUM(UnitPrice * Quantity) AS Total FROM Orders " +
            "INNER JOIN [Order Details] " +
            "ON Orders.OrderID = [Order Details].OrderID " +
            "WHERE Orders.CustomerID = '" + txtID.Text + "' " +
            "GROUP BY Orders.OrderID, Orders.CustomerID";

        SqlCommand cmd = new SqlCommand(sql, con);

        con.Open();
        SqlDataReader reader = cmd.ExecuteReader();
        GridView1.DataSource = reader;
        GridView1.DataBind();
        reader.Close();
        con.Close();
}

Пользователь может попытаться подделать оператор SQL. Часто главной целью такой атаки является получение сообщения об ошибке. Если ошибка не обрабатывается правильно, и злоумышленнику передается низкоуровневая информация, она может быть использована для разработки более изощренной атаки. Например, представим, что произойдет, если пользователь введет в текстовом поле следующий текст:

ALFKI' OR '1'='1

Теперь посмотрим, какой оператор SQL получится в результате:

SELECT Orders.CustomerID, Orders.OrderID, COUNT(UnitPrice) AS Items,
	SUM(UnitPrice * Quantity) AS Total FROM Orders
	INNER JOIN [Order Details]
	ON Orders.OrderID = [Order Details].OrderID
	WHERE Orders.CustomerID = 'ALFKI' OR '1'='1'
	GROUP BY Orders.OrderID, Orders.CustomerID

Этот оператор вернет все записи о заказах. Даже если заказ не был выдан заказчиком ALFKI, все равно условие 1=1 истинно для всех строк. В результате вместо вывода специфической информации о текущем заказчике злоумышленнику передается вся информация:

SQL-инъекция

И если информация, показанная на экране, важна, как, например, номера карточек социального страхования, даты рождения или информация о кредитных картах, это может вылиться в серьезнейшую проблему! Фактически простые атаки внедрением SQL часто являются источником проблем для ведущих компаний, занятых электронной коммерцией. Часто уязвимость заключается не в текстовом поле, а в строке запроса (которая может быть использована для передачи таких данных базы, как уникальный идентификатор, со страницы списка на страницу формы).

Возможны и более сложные атаки. Например, злоумышленник может просто закомментировать остаток оператора SQL, добавив два тире (--). Эта атака специфична для SQL Server, но аналогичная атака возможна и в MySQL, если использовать символ #, и в Oracle, если задействовать точку с запятой (;). Злоумышленник может также применить пакетную команду, чтобы выполнить произвольный оператор SQL. При использовании поставщика данных SQL Server достаточно просто добавить точку с запятой, за которой передать дополнительную команду. Таким образом, можно, например, удалить содержимое другой таблицы, или даже вызвать системную хранимую процедуру SQL Server по имени xp_cmdshell, чтобы запустить произвольную программу из командной строки.

Вот что злоумышленник может ввести в текстовом поле, чтобы предпринять более изощренную атаку внедрением SQL, удалив все строки в таблице Customers:

ALFKI'; DELETE * FROM Customers--

Как же противостоять атаке внедрением SQL? Следует запомнить несколько хороших правил. Для начала неплохо бы использовать свойство TextBox.MaxLength, чтобы предотвратить чрезмерно длинный ввод, когда в этом нет необходимости. Это уменьшит вероятность того, что в поле ввода кто-то сможет вставить нежелательный большой фрагмент сценария.

В дополнение к этому можно использовать элементы управления проверкой достоверности ASP.NET, чтобы блокировать очевидно недопустимые данные (такие как текст, пробелы или специальные символы в числовых значениях). Более того, необходимо ограничить информацию, выдаваемую в сообщении об ошибке. Если вы перехватили исключение базы данных, то пользователю стоит выдать лишь самое общее сообщение вроде "Ошибка источника данных", а не выдавать полный текст свойства Exception.Message, которое может помочь обнаружить уязвимости системы.

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

string ID = txtID.Text.Replace("'", "''");

Конечно, проблем добавится, если вводимый текст действительно должен содержать апострофы. Кроме того, это не исключает полностью возможности атаки внедрением SQL. Замена апострофов предотвращает преждевременное закрытие строки злоумышленником. Однако при построении динамического оператора SQL, который включает числовые значения, для атаки внедрением SQL понадобится лишь одиночный пробел. Эта уязвимость часто (и опасно) игнорируется.

Еще одна хорошая идея — ограничить права учетной записи, от имени которой выполняется доступ к базе данных, чтобы она не имела прав доступа к другим базам данных или запуска расширенных системных хранимых процедур. Тем не менее, это не решает проблему внедрения SQL-сценариев, поскольку процесс, используемый для подключения к базе данных, почти всегда требует более широких привилегий, чем те, что выделяются отдельному пользователю. Ограничивая учетную запись, вы можете предотвратить атаки, связанные, например, с удалением таблиц, но не сможете предотвратить атаки, связанные с хищением информации.

Все описанные выше способы защиты являются дополнительными средствами, которые опытный злоумышленник может легко обойти. Лучший подход состоит в применении параметризованных команд или хранимых процедур, в которых предусмотрена собственная защита от атак внедрением SQL. Соответствующие приемы рассматриваются в последующих разделах.

Использование параметризованных команд

Параметризованная команда — это просто команда, которая использует символы-заполнители в тексте SQL. Заполнитель указывает место для динамически применяемых значений, которые затем пересылаются через коллекцию Parameters объекту Command. Например, следующий оператор SQL:

SELECT * FROM Customers WHERE CustomerID = 'ALFKI'

должен стать чем-то вроде:

SELECT * FROM Customers WHERE CustomerID = @CustID

Заполнители добавляются раздельно и автоматически кодируются.

Синтаксис параметризованных команд у различных поставщиков выглядит немного по-разному. В поставщике SQL Server предусматривается использованием именованных заполнителей (с уникальными именами). У поставщика OLE DB каждое жестко закодированное значение заменяется вопросительным знаком. В любом случае необходимо предоставить объект Parameter для каждого параметра, который вставляется в коллекцию Command.Parameters. При работе с поставщиком OLE DB следует убедиться, что параметры добавляются в том же порядке, в котором они появляются в строке SQL. Этого не требует поставщик данных SQL Server, поскольку соответствие между параметрами и заполнителями задается с помощью имен.

В следующем примере представлен вариант прежнего запроса, исключающий возможность атаки внедрением SQL:

protected void cmdGetRecords_Click(object sender, EventArgs e)
{
        // ...

        string sql =
            "SELECT Orders.CustomerID, Orders.OrderID, COUNT(UnitPrice) AS Items, " +
            "SUM(UnitPrice * Quantity) AS Total FROM Orders " +
            "INNER JOIN [Order Details] " +
            "ON Orders.OrderID = [Order Details].OrderID " +
            "WHERE Orders.CustomerID = @CustID" +
            "GROUP BY Orders.OrderID, Orders.CustomerID";

        SqlCommand cmd = new SqlCommand(sql, con);

        // Указать параметр
        cmd.Parameters.Add("@CustID", txtID.Text);

        // ...
}

Если вы предпримете попытку атаки внедрением SQL с этой исправленной версией страницы, то обнаружите, что она не вернет никаких записей. Причина в том, что ни одна позиция заказа не имеет значения идентификатора заказчика, равного текстовой строке «ALFKI' OR '1'='1». Это как раз то поведение, которое нужно.

Вызов хранимых процедур

Параметризованные команды — лишь небольшой шаг от команд в сторону полноценных хранимых процедур. Как известно, хранимая процедура представляет собой пакет из одного или более операторов SQL, сохраненный в базе данных.

Хранимые процедуры подобны функциям в том, что они являются хорошо инкапсулированными блоками логики, которые могут принимать данные (через входные параметры) и возвращать данные (через результирующие наборы и выходные параметры). Хранимые процедуры обладают множеством преимуществ:

В SQL Server предварительно компилируются все команды SQL, включая неподготовленные. Это значит, что вы используете преимущества компиляции независимо оттого, применяете хранимые процедуры или нет. Однако хранимые процедуры все равно имеют тенденцию повышать производительность, поскольку снижают вариантность SQL-операторов. Системам, использующим случайные операторы SQL, часто свойственно применение слегка отличающихся команд для решения похожих задач, что не позволяет серверу эффективно повторно использовать скомпилированные планы выполнения.

Рассмотрим пример SQL-кода, необходимого для создания хранимой процедуры для вставки отдельной записи в таблицу Employees. Этой хранимой процедуры изначально нет в базе данных Northwind, поэтому ее понадобится сначала добавить (с помощью такого инструмента, как SQL Server Management Studio):

CREATE PROCEDURE InsertEmployee
	@LastName varchar(20),
	@FirstName varchar(10),
	@EmployeeID int OUTPUT
AS

INSERT INTO Employees
	(LastName, FirstName, HireDate)
	VALUES (@LastName, @FirstName, GETDATE());

SET @EmployeeID = @@IDENTITY

Хранимая процедура принимает два параметра — фамилию и имя. Возвращает она идентификатор вновь созданной записи через выходной параметр EmployeeID, который извлекается после оператора INSERT с помощью функции @@IDENTITY. Это — один из примеров простых задач, которые хранимая процедура может дополнительно упростить. Без использования хранимой процедуры было бы довольно неудобно получить автоматически сгенерированное значение идентификатора только что вставленной новой записи.

Чтобы добавить эту хранимую процедуру к тестовой базе данных Northwind, нужно открыть SQL Server Management Studio (Программы --> Microsoft SQL Server --> SQL Server Management Studio) и выполнить следующие шаги:

  1. В окне Object Explorer настроить подключение к SQL Server

  2. В этом же окне раскрыть Northwind --> Programmability --> Stored Procedures:

    Хранимые процедуры базы данных Northwind
  3. Затем нужно щелкнуть правой кнопкой мыши по разделу Stored Procedures и выбрать New Stored Procedure. В окне редактора ввести код процедуры, указанный выше

  4. Выбрать меню Query --> Execute, чтобы построить и сохранить хранимую процедуру

Далее можно создать команду SqlCommand, которая послужит оболочкой для вызова хранимой процедуры. Эта команда принимает те же три параметра на входе и использует @@IDENTITY для получения и возврата идентификатора новой записи. Так же понадобится добавить параметры хранимой процедуры в коллекцию Command.Parameters. При этом необходимо указать точный тип данных и длину параметра, чтобы они соответствовали деталям в базе данных:

protected void Page_Load(object sender, EventArgs e)
{
        string connectionString = WebConfigurationManager.ConnectionStrings["Northwind"].ConnectionString;
        SqlConnection con = new SqlConnection(connectionString);

        // Создать команду для вызова хранимой процедуры InsertEmployee
        SqlCommand cmd = new SqlCommand("InsertEmployee", con);
        cmd.CommandType = CommandType.StoredProcedure;

        // Указать параметры
        cmd.Parameters.Add(new SqlParameter("@LastName", SqlDbType.NVarChar, 20));
        cmd.Parameters["@LastName"].Value = "Pupkin";      /* В данном случае устанавливаем простую строку,
                                                                хотя в реальном приложении значение будет 
                                                                извлекаться из формы */
        cmd.Parameters.Add(new SqlParameter("@FirstName", SqlDbType.NVarChar, 10));
        cmd.Parameters["@FirstName"].Value = "Vasilyi";

        // Последний параметр является выходным (output)
        cmd.Parameters.Add(new SqlParameter("@EmployeeID", SqlDbType.Int, 4));
        cmd.Parameters["@EmployeeID"].Direction = ParameterDirection.Output;

        con.Open();
        try
        {
            int numAff = cmd.ExecuteNonQuery();
            Label1.Text = String.Format("Добавлена <b>{0}</b> запись<br />", numAff);

            // Получить вновь сгенерированный идентификатор
            int empID = (int)cmd.Parameters["@EmployeeID"].Value;
            Label1.Text += "<br>Новому сотруднику присвоен ID: " + empID.ToString();
        }
        finally
        {
            con.Close();
        }
}

Обратите внимание, что последний параметр является выходным, что позволяет хранимой процедуре вернуть информацию в код. Хотя этот объект Parameter создается аналогично, вы должны удостовериться, что указали его как выходной параметр, установив значение его свойства Direction в Output. Предоставлять значение не нужно.

Этот код выведет следующий текст в метку Label1:

Выполнение хранимой процедуры

а в таблице Employees появится новая запись (обратите внимание, что я спроектировал хранимую процедуру для вставки строки содержащей всего два поля - FirstName и LastName, хотя в таблице столбцов больше, поэтому остальные поля заполняются значениями по умолчанию. Я это сделал, чтобы упростить пример, хотя в реальном приложении скорее всего придется определять более сложную хранимую процедуру):

Заполнение таблицы при выполнении безопасной хранимой процедуры

Удобным сокращением является метод AddWithValue() коллекции Parameters. Этот метод принимает имя параметра и значение, но никакой информации о типе. Вместо этого он выводит тип из приведенных данных. (Очевидно, что это работает с входными, но не выходными параметрами, поскольку для выходного параметра значение не указывается.) Если не нужно явно выбирать нестандартный тип данных, благодаря такому менее строгому подходу, можно упростить код:

cmd.Parameters.AddWithValue("@LastName", "Pupkin");
cmd.Parameters.AddWithValue("@FirstName", "Vasiliy");

Предполагая, что литеральное выражение "Pupkin" является строкой C# из 6 букв, здесь создается объект SqlParameter со значением Size, равным 6 (символов), и SqlDbType, установленным в NVarChar. База данных может при необходимости преобразовывать эти данные, если только вы не попытаетесь вставить их в поле меньшего размера или совершенного иного типа.

Существует одна ловушка — типы, допускающие null. Если нужно передать значение null хранимой процедуре, то использовать для этого null-ссылку C# нельзя, поскольку это означает неинициализированную ссылку, что является ошибочным условием. К сожалению, невозможно также использовать типы, допускающие null (такие как int?), потому что класс SqlParameter их не поддерживает. Чтобы обозначить null-значение поля, понадобится передать в качестве значения параметра .NET-константу DBNull.Value.

Пройди тесты
Лучший чат для C# программистов