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


0831_TaintAnalysis_Cs_ru/image1.png


Мы продолжаем развивать 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 без обработки.


0831_TaintAnalysis_Cs_ru/image2.png


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


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 диагностических правил


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


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

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


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


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


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


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


0831_TaintAnalysis_Cs_ru/image3.png


Рассмотрим пример. Если вы уже гуглили запросы в духе "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-аккаунт. ;)


Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Sergey Vasiliev. OWASP, Vulnerabilities, and Taint Analysis in PVS-Studio for C#. Stir, but Don't Shake.




К сожалению, не доступен сервер mySQL