>
>
>
OWASP, уязвимости и taint-анализ в PVS-…

Сергей Васильев
Статей: 96

OWASP, уязвимости и taint-анализ в PVS-Studio C#. Смешать, но не взбалтывать

Мы продолжаем развивать PVS-Studio как SAST решение. Одно из направлений, в котором ведутся работы, – улучшение покрытия OWASP. А какой же OWASP без taint-анализа? Вот и мы так подумали и решили прикрутить его к C# анализатору. О том, что получилось, а что не очень, ниже.

Примечание. Данная статья не ставит перед собой цель во всех подробностях раскрыть тематику SQL инъекций и работы с SQL из C#. Эти вопросы затрагиваются поверхностно и служат цели дополнения рассказа про taint-анализ.

Что за taint-анализ?

Упрощённо говоря, с помощью taint-анализа отслеживается распространение по программе данных, полученных из внешнего источника (taint source). Само по себе распространение "заражённых" данных опасности не несёт. Однако есть ключевые точки (taint sinks), при попадании в которые возможно возникновение различных уязвимостей: SQLI, XSS и т.п. Суть taint-анализа заключается в том, чтобы находить такие трассы распространения данных, по которым они могут без валидации попасть из taint source в taint sink.

Для реализации нам нужно будет учесть:

  • источники заражения (taint sources);
  • приёмники данных (taint sinks);
  • способы передачи заражённых данных;
  • сброс статуса заражения у данных.

Смешать, но не взбалтывать. ;)

Простенький пример:

var userName = Request.QueryString["userName"];
var rawCommand = "SELECT * FROM users WHERE name = '" + userName + "'";
var sqlCommand = new SqlCommand(rawCommand);
// Execution of the tainted command

В прямом смысле слова "классический" пример SQL инъекции – если будете гуглить, сразу наткнётесь на что-нибудь подобное. Проблема в том, что данные приходят от пользователя и сразу используются для формирования команды. Как следствие, злоумышленник волен подстроить данные таким образом, чтобы изменить логику SQL-запроса. Ожидаете на вход строку вида JohnSmith? Получите вместо этого входные данные вида Sergey'; DROP TABLE users; --. Распишитесь.

Taint анализ в PVS-Studio: общее

С++

Вообще первым анализатором в PVS-Studio, в котором была реализована концепция taint-анализа, был анализатор C и C++ кода. На taint-анализ опирается диагностическое правило V1010. Оно ищет различные кейсы, базирующиеся на одинаковом принципе: в программу извне поступают данные и без должной проверки приходят туда, куда не должны. Например, в функцию вызова командного интерпретатора – system. С помощью этого правила удалось найти несколько интересных кейсов, которые я описывал в отдельной статье (в ней даже пара видео есть). Ещё на C++ Russia 2018 я делал небольшой доклад, в том числе и на эту тему.

Примечание. Из упомянутой статьи особенно примечательным мне кажется случай, когда в коде исправляли CVE, но что-то пошло не так. В итоге код поправили, проблема никуда не ушла, и через какое-то время ей был присвоен новый CVE-идентификатор. Уже после этого код исправили как нужно. :)

C#

Вообще желание реализовать taint-анализ в C# анализаторе было у нас давно. Наверное, примерно с того самого времени, как он был имплементирован в C++ анализаторе. Мысли "сделать аналог V1010 в C# анализаторе" периодически озвучивались, однако руки всё как-то не доходили. Но, как я упоминал выше, одно из намеченных направлений развития C# анализатора в 2021 году – улучшение покрытия OWASP (в первую очередь – OWASP Top 10 2017, но и про ASVS забывать не хочется). Это стало отличным поводом для того, чтобы руки наконец дошли.

Первой диагностикой, которая будет использовать taint-анализ, решили сделать диагностику по поиску возможных SQL инъекций. Популярная тема, есть упоминание и в OWASP Top 10 2017 (A1:2017-Injection) и в OWASP ASVS – отличный кандидат. На том и порешили.

Taint анализ в PVS-Studio C#: как оно работает?

На базе data-flow анализа. В принципе, основная инфраструктура уже была готова. Что нужно было сделать, так это добавить информацию про источники, приёмники, передачу заражения и сброс заражённого статуса.

Параллельно мы дорабатывали анализатор в тех местах, которые находили: улучшили поддержку интерполированных строк в data-flow, обработку счётчиков циклов, убрали ряд false positives за счёт доработок общих механизмов. В общем, параллельно подкручивали анализатор то тут, то там.

Но вернёмся к taint-анализу.

Источники заражённых данных (taint sources)

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

Примеры источников:

  • HttpRequest.QueryString;
  • TextBox.Text;
  • Console.ReadLine;
  • и т.п.

Например, в следующем коде мы считаем переменную taintedVariable заражённой.

void Example()
{
  var taintedVariable = Console.ReadLine();
  TaintSink(taintedVariable);
}

В качестве ещё одного типа источников мы приняли параметры методов, доступные внешнему коду. Например, параметры public методов public классов:

public class Helper
{
  public void ExecuteCommand(String commandToExecute)
  {
    TaintSink(commandToExecute);
  }
}

В данном случае в методе ExecuteCommand мы считаем параметр commandToExecute заражённым. Суть такого подхода в следующем. Если метод доступен для внешней сборки, скорее всего, это API для взаимодействия с библиотекой. Альтернативный вариант – кто-то просто не заморачивается по поводу модификаторов области видимости. :)

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

Разработчик же самой библиотеки может рассчитывать на то, что на вход методу приходят уже проверенные данные. Значит, в повторной проверке необходимости нет – пришедшие данные можно использовать напрямую.

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

PVS-Studio не сможет понять, что стоит за вызовом библиотечного метода, если его исходный код недоступен, а сам метод непроаннотирован. Однако ситуации такие обнаруживать всё же хочется. Поэтому, как я писал выше, можно предупреждать самих разработчиков библиотеки о том, что данные из параметра публично доступного метода могут попасть в taint sink без обработки.

Рассмотрим какой-нибудь пример, иллюстрирующий сказанное.

public class DBHelper
{
  public void ProcessUserInfo(String userName)
  {
    ....
    var command = "SELECT * FROM Users WHERE userName = '" + userName + "'";
    ExecuteCommand(command);
    ....
  }

  private void ExecuteCommand(String rawCommand)
  {
    using (SqlConnection connection = new SqlConnection(_connectionString))
    {
      ....
      using (var sqlCommand = new SqlCommand(rawCommand, connection))
      {
        using (var reader = sqlCommand.ExecuteReader())
          ....
      }
    }
  }
}

Метод ProcessUserInfo доступен для использования внешнему коду (public метод в public классе). Параметр userName используется для формирования SQL запроса в строковом представлении (записывается в переменную command). Затем command в качестве аргумента передаётся в метод ExecuteCommand. Соответствующий параметр — rawCommand — используется для создания SQL команды (sqlCommand), которая затем исполняется. Выглядит небезопасно, но явного taint-источника здесь нет.

Предположим, что рассмотренный выше код — часть какой-то библиотеки. Назовём её SQLILib.dll. Эта библиотека используется в приложении (пусть будет SQLIApp.exe), где метод ProcessUserInfo используются следующим образом:

static void TestHelper(DBHelper helper)
{
  var userName = Request.Form["userName"];
  helper.ProcessUserInfo(userName);
}

Здесь данные, полученные от пользователя (Request.Form["userName"]), напрямую передаются в метод ProcessUserInfo. Так как ProcessUserInfo — метод из внешней библиотеки, так просто его код не посмотреть.

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

Да, можно предположить, что между пользователями библиотеки и её разработчиками есть некая договорённость о том, что в методы должны приходить уже проверенные данные. Например, это указано в документации к API. Но, когда речь идёт про безопасность, лучше перестраховаться.

К сожалению, при анализе кода SQLIApp.exe анализатор не сможет предупредить о возможной SQL инъекции, так как ничего не знает про метод ProcessUserInfo. С другой стороны, анализатор может выдать предупреждение при анализе кода самой библиотеки.

Выдавать ли предупреждения, когда источником заражённых данных считаются параметры — решать каждой диагностике, использующей taint-информацию. Для диагностики по поиску SQL инъекций мы решили выдавать предупреждения на низком уровне достоверности.

Примечание. Если вы не хотите видеть подобных предупреждений, то можете отключить их с помощью комментария вида //-V::5608:3 в .pvsconfig файле. Тогда предупреждения V5608 (SQLI) низкого уровня достоверности не попадут в лог. Более подробно про .pvsconfig файлы можно почитать в документации "Подавление ложных предупреждений" (раздел "Подавление ложных предупреждений с помощью файлов конфигурации диагностик (.pvsconfig)").

И наоборот, если вы считаете эти предупреждения крайне важными, то можете повысить их уровень достоверности до выского, используя //V_LEVEL_1::5608. Подробности приводятся в главе "Как задать свой уровень для конкретной диагностики" в разделе документации "Дополнительная настройка диагностик".

Приёмники заражённых данных (taint sinks)

Приёмники заражённых данных являются уникальными для каждого диагностического правила. Поэтому они скорее относятся к диагностикам, чем к общему механизму taint-анализа. Как мы обсуждали, суть приёмников в том, что в них не должны попадать заражённые данные. Если в приложении есть путь, когда данные из taint source попадают в taint sink — беда.

Например, в случае с SQL инъекциями, приёмником может служить конструктор класса SQLCommand или метод FromSqlRaw.

Пример:

var taintedStr = GetTaintedData();
var sqlCommand = new SqlCommand(taintedStr); // taint sink
....

Можно было бы подумать, что конструктор класса SqlCommand — это, скорее, передатчик, а приёмником выступает какой-нибудь из методов исполнения команды: SqlCommand.ExecuteSomehow. Однако кажется странным сначала создать заражённую команду, а потом пытаться её проверять. Проще и более очевидно проверить данные, на основе которых команда создаётся. Именно поэтому в нашем случае конструктор SqlCommand — sink, а не передатчик данных.

Таким же приёмником является свойство SqlCommand.CommandText. Пример опасного кода:

void ProcessUserInfo()
{
  using (SqlConnection connection = new SqlConnection(_connectionString))
  {
    ....
    String userName = Request.Form["userName"];

    using (var command = new SqlCommand()
    {
      Connection = connection,
      CommandText = "SELECT * FROM Users WHERE UserName = '" + userName + "'",
      CommandType = System.Data.CommandType.Text
    })
    {            
      using (var reader = command.ExecuteReader())
        ....
    }
  } 
}

Здесь создаётся экземпляр типа SqlCommand, однако заражённая строка не передаётся в качестве аргумента в конструктор, а используется для инициализации свойства CommandText.

Валидация заражённых данных

Естественно, что не все цепочки заражённых данных ведут от источников к приёмникам. Есть несколько причин прерывания:

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

Однако следует помнить, что, если валидация происходит только при определённых условиях, это может быть даже более опасным случаем из-за иллюзии безопасности.

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

String userName = Request.Form["userName"];    
using (var command = new SqlCommand()
{
  Connection = connection,
  CommandText = "SELECT * FROM Users WHERE UserName = @userName",
  CommandType = System.Data.CommandType.Text
})
{
  var userNameParam = new SqlParameter("@userName", userName);
  command.Parameters.Add(userNameParam);
            
  using (var reader = command.ExecuteReader())
    ....
}

В данном случае цепочка передачи taint-данных просто разорвётся при создании объекта типа SqlParameter. У анализатора нет информации о том, что он передаёт заражение или заражается. Как следствие, анализатор не будет считать переменную userNameParam заражённой. Исходное значение userName в создаваемую команду никак не попадает, поэтому предупреждение выдано не будет.

Распространение заражённых данных

Естественно, что данные не попадают сразу из taint source в taint sink. Теоретически могут, конечно, но это уже совсем фантастический код. :) Скорее всего, после проникновения в приложение заражённые данные сначала распространяются различными путями и лишь затем могут попасть в taint sink. Причём способы заражения могут быть достаточно вариативными. Простые присвоения переменных — это самый очевидный кейс.

Соответствующий пример, на самом деле, уже был продемонстрирован ранее:

void Example()
{
  var taintedVariable = Console.ReadLine();
  TaintSink(taintedVariable);
}

В этом фрагменте кода статус заражённого значения проставляется на вызов метода Console.ReadLine() и лишь затем передаётся переменной taintedVariable через операцию присвоения.

Заражения через переприсвоения аналогичны:

var taintedVariable = Console.ReadLine();
var taintedVariable2 = taintedVariable;

Дополнительно мы рассмотрели ряд более интересных кейсов передачи заражения. Например, в строках заражение вполне себе передаётся через конкатенацию:

var shipCity = Console.ReadLine();
var resStr 
  = "select * from OrdersTable where ShipCity = '" + shipCity + "'";

Анализируя операции конкатенации строк, мы проверяем, не является ли один из операндов заражённым. Если это так, выставляем соответствующий статус для всего выражения.

С таким же успехом данные могут передаваться через интерполированные строки:

var resStr = $"select * from UsersTable where Id = '{id}'";

Принцип аналогичен — мы анализируем интерполируемые элементы и, если хотя бы один из них заражён, выставляем статус заражения на всё выражение.

Ещё один способ передачи заражения — через вызов методов. Здесь тоже есть, где разгуляться. :)

Один из вариантов — транслирование заражения из аргументов в возвращаемое значение. Например:

var resStr = String.Join(separator, nonTaintedStr, taintedStr);

В результате выполнения этого кода при соединении строк заражение передастся от значения taintedStr в возвращаемое значение метода String.Join, а оттуда — в resStr.

Другой интересный кейс, с которым довелось столкнуться, — заражение объектов через аргументы метода, вызываемого у данного объекта. Типичный представитель подобных случаев — StringBuilder.

var sb = new StringBuilder();
sb.AppendLine(taintedStr);
var resStr = sb.ToString();

Изначально sb не является заражённым, но становится таковым в результате вызова метода AppendLine, если taintedStr содержит заражённые данные. Теперь, когда объект sb является инфицированным, он может сам передавать заражение дальше. В нашем случае заражается переменная resStr в результате вызова метода ToString, транслирующего статус заражённости из вызывающего объекта в возвращаемое значение.

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

Что не получилось?

Основное и самое обидное, с чем довелось столкнуться, — ограничения анализа значимых типов. Дело в том, что сейчас data-flow анализ в C# ограничен перечислениями и целочисленными типами, такими как int, byte, short и т.п. Из-за этого, как только в цепочке заражений встречается неизвестный значимый тип (какая-нибудь структура, например), она обрывается. Это большая точка для роста и доработок.

Вообще, так как это самая первая версия taint-анализа, уже есть некоторые идеи по доработкам и улучшениям. Будем допиливать со временем, в частности по мере разработки новых диагностических правил. Если столкнётесь с false positive срабатываниями или, наоборот, анализатор что-то не обнаружит, пожалуйста, напишите нам. Мы постараемся учесть эти случаи и поддержать их в будущем.

Работа taint-based диагностических правил

От общих механизмов переходим к их использованию. В принципе, здесь всё достаточно просто и общий алгоритм работы примерно одинаков.

  • При обходе дерева нужно обрабатывать те синтаксические конструкции, которые могут быть sink'ом данной диагностики.
  • При посещении соответствующей конструкции — проверять, является ли она sink'ом.
  • Если это наш sink, проверять, что приходящие в него данные имеют taint-статус и не имеют соответствующей метки валидации.
  • ...
  • PROFIT!

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

Первая диагностика на taint-анализе: поиск возможных SQLI

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

Не буду вдаваться в подробности, что такое SQL инъекция, какие виды бывают и т.д. В интернете достаточно информации на эту тему. Например, вот ссылочки на Википедию и на docs.microsoft.com. Обозначу только суть.

А суть очень проста и отлично ложится на базовую теорию taint-анализа, про которую мы рассуждали выше. Есть какой-то источник внешних данных (taint source). Данные в этом источнике поступают от пользователя, и он волен писать в них то, что ему вздумается. Эти данные приходят в приложение, перемещаются по нему и в результате попадают в SQL команду без проверки (sink). Если команда создаётся таким образом, что допустимыми являются любые данные от пользователя, он может скомпрометировать их так, чтобы выполнить произвольный запрос. Вот вам и инъекция.

Рассмотрим пример. Если вы уже гуглили запросы в духе "SQLI C#", то наверняка видели что-нибудь подобное:

private HttpRequest Request { get; set; }
void ProcessUserInfo()
{
  using (SqlConnection connection = new SqlConnection(_connectionString))
  {
    ....
    String userName = Request.Form["userName"];

    using (var command = new SqlCommand()
    {
      Connection = connection,
      CommandText = "SELECT * FROM Users WHERE UserName = '" + userName + "'",
      CommandType = System.Data.CommandType.Text
    })
    {            
      using (var reader = command.ExecuteReader())
        ....
    }
  } 
}

Проблема в том, что данные в userName поступают извне и подставляются в SQL команду без каких-либо проверок. Это чревато последствиями, если вместо адекватных данных в userName попадёт скомпрометированная команда. Например, если там будет строка ' OR '1'='1, то обработка данных запустится для всех элементов таблицы, а не одного пользователя.

Теперь разберём этот пример с точки зрения анализатора. Как он может обнаружить здесь угрозу возникновения SQLI?

В данном случае источником заражённых данных выступает свойство Request.Form (переменная Request имеет тип HttpRequest). Тип свойства FormNameValueCollection, и именно экземпляр этого типа мы считаем заражённым. У объекта вызывается индексатор, который ретранслирует заражённые данные на всё выражение (возвращаемое значение индексатора): Request.Form -> Request.Form["userName"]. Так как инициализатор переменной userName заражён, она также становится заражённой.

Анализируем вызов конструктора типа SqlCommand, точнее инициализацию свойств. Нас интересует свойство CommandText. В нашем случае CommandText — sink. То есть при записи в него заражённых данных должна срабатывать диагностика. Поэтому анализируем правую часть присваивания: "SELECT * FROM Users WHERE UserName = '" + userName + "'". Ага, конкатенация строк. Мы помним, что, если хотя бы один операнд имеет заражение, оно передаётся на всё выражение. Как мы помним, userName заражён. Так что в результате конкатенации становится заражённым и всё выражение целиком. Выходит, значение, записываемое в CommandText, заражено, а это как раз то, что мы хотели проверить.

Заключение

Taint анализ в описанном виде появился в релизе PVS-Studio 7.13. Там есть и диагностика по поиску возможных SQLI — V5608. Вы уже можете попробовать новую версию анализатора.

Безусловно, работы в этом направлении много. И как по улучшению общего механизма taint-анализа, так и по разработке диагностик, его использующих. Поэтому, если у вас есть какие-нибудь идеи по улучшению, смело пишите нам, возьмём себе на заметку.

Как обычно, приглашаю подписываться на мой Twitter-аккаунт. ;)