Здесь мы обсудим, как решения SAST находят бреши в безопасности. Я расскажу вам о разных и дополняющих друг друга подходах к обнаружению потенциальных уязвимостей, объясню, зачем нужен каждый из них и как превратить теорию в практику.

SAST (статическое тестирование безопасности приложений) используется для поиска дефектов безопасности без запуска приложения. В то время как традиционный статический анализ — это способ обнаружения ошибок, SAST фокусируется на обнаружении потенциальных уязвимостей.

Как выглядит SAST для нас? Берем исходники, отдаем анализатору и получаем отчет со списком возможных дефектов безопасности.

Итак, основная цель этой статьи — ответить на вопрос, как именно инструменты SAST ищут потенциальные уязвимости.

Типы используемой информации

Решения SAST не анализируют исходный код в простом текстовом представлении: это неудобно, неэффективно и часто недостаточно. Поэтому анализаторы работают с промежуточными представлениями кода и несколькими типами информации. В сочетании они обеспечивают наиболее полное представление приложения.

Синтаксическая информация

Анализаторы работают с промежуточным представлением кода. Наиболее распространенными являются синтаксические деревья (абстрактное синтаксическое дерево или дерево синтаксического анализа).

Давайте посмотрим на шаблон ошибки:

operand#1 <operator> operand#1

Дело в том, что слева и справа от оператора используется один и тот же операнд. Такой код может содержать ошибку, например, при использовании операции сравнения.

a == a

Однако приведенный выше случай является особым, и существует множество вариантов:

  • один или оба операнда могут быть заключены в скобки;
  • оператор может быть не только '==', но и '!=', '||' и т.д.
  • операнды могут быть доступом к элементам, вызовами функций и т. д., а не идентификаторами.

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

Давайте посмотрим на выражение: a == (a). Его синтаксическое дерево может выглядеть так:

С такими деревьями проще работать: есть информация о структуре, легко извлекать операнды и операторы из выражений. Нужно ли опускать скобки? Без проблем. Просто спуститесь по дереву.

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

Семантическая информация

Вот пример:

if (lhsVar == rhsVar)
{ .... }

Если lhsVar и rhsVar являются переменными типа double, в коде могут возникнуть проблемы. Например, если и lhsVar, и rhsVar точно равны 0,5, это сравнение является истинным. Однако если одно значение равно 0,5, а другое — 0,4999999999999, то проверка дает false. Тогда возникает вопрос: какого поведения ожидает разработчик? Если он предполагает, что разница находится в пределах погрешности, сравнение следует переписать.

Предположим, мы хотели бы поймать такие случаи. Но вот проблема: то же самое сравнение будет абсолютно корректным, если типы lhsVar и rhsVar целочисленные.

Представим: при проверке кода анализатор встречает следующее выражение:

if (lhsVar == rhsVar)
{ .... }

Вопрос: должны ли мы выносить предупреждение в этом случае или нет? Вы можете посмотреть на дерево и увидеть, что операнды — это идентификаторы, а инфиксная операция — это сравнение. Однако мы не можем решить, опасен этот случай или нет, поскольку нам неизвестны типы переменных lhsVar и rhsVar.

В этом случае на помощь приходит семантическая информация. Используя семантику, вы можете получить данные узла дерева:

  • какой тип (в терминах языка программирования) имеет соответствующее выражение узла;
  • какая сущность представлена ​​узлом: локальная переменная, параметр, поле и т. д.;
  • ...

В приведенном выше примере нам нужна информация о типах переменных lhsVar и rhsVar. Все, что вам нужно сделать, это получить эту информацию, используя семантическую модель. Если тип переменной реальный — выдать предупреждение.

Аннотации функций

Синтаксиса и семантики иногда недостаточно. Посмотрите на пример:

IEnumerable<int> seq = null;
var list = Enumerable.ToList(seq);
....

Метод ToList объявлен во внешней библиотеке, анализатор не имеет доступа к исходному коду. Существует переменная seq со значением null, которое передается методу ToList. Это безопасная операция или нет?

Давайте воспользуемся информацией о синтаксисе. Вы можете понять, где литерал, где идентификатор, а где вызов метода. Но безопасно ли вызывать метод? Это неясно.

Попробуем семантику. Вы можете понять, что seq — это локальная переменная, и даже найти ее значение. Что мы можем узнать о Enumerable.ToList? Например, тип возвращаемого значения и тип параметра. Безопасно ли передавать ему null? Это неясно.

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

Аннотацией метода ToList в коде анализатора может быть следующее:

Annotation("System.Collections.Generic",
           nameof(Enumerable),
           nameof(Enumerable.ToList),
           AddReturn(ReturnFlags.NotNull), 
           AddArg(ArgFlags.NotNull));

Основная информация, которую содержит данная аннотация:

  • полное имя метода (включая имя типа и пространство имен). При наличии перегрузок может потребоваться дополнительная информация о параметрах;
  • ограничения на возвращаемое значение. ReturnFlags.NotNull сообщает, что возвращаемое значение не будет null;
  • ограничения на входные значения. ArgFlags.NotNull указывает анализатору, что единственный аргумент метода не должен иметь значение null.

Вернемся к исходному примеру:

IEnumerable<int> seq = null;
var list = Enumerable.ToList(seq);
....

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

Виды анализа

Теперь у нас есть обзор информации, используемой для анализа. Итак, давайте обсудим виды анализа.

Анализ на основе шаблонов

Иногда эти регулярные ошибки на самом деле являются недостатками безопасности. Посмотрите на этот пример уязвимости.

iOS: CVE-2014-1266

Информация об уязвимости:

  • CVE-ID: CVE-2014-1266
  • CWE-ID: CWE-20: Неправильная проверка ввода
  • Запись ПНВ
  • Описание: Функция SSLVerifySignedServerKeyExchange в libsecurity_ssl/lib/sslKeyExchange.c в функции «Безопасный транспорт» в компоненте «Безопасность данных» в Apple iOS 6.x до 6.1.6 и 7.x до 7.0.6, Apple TV 6.x до версии 6.0.2, а Apple OS X 10.9.x до версии 10.9.2 не проверяет подпись в сообщении обмена ключами сервера TLS, что позволяет злоумышленникам-посредникам подделывать серверы SSL с помощью (1) использования произвольного закрытый ключ для этапа подписания или (2) пропуск этапа подписания.

Код:

....
if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0)
  goto fail;
  goto fail;
if ((err = SSLHashSHA1.final(&hashCtx, &hashOut)) != 0)
  goto fail;
....

На первый взгляд может показаться, что все в порядке. На самом деле второй goto является безусловным. Поэтому проверка вызовом метода SSLHashSHA1.final никогда не выполнялась.

Код должен быть отформатирован следующим образом:

....
if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0)
  goto fail;
goto fail;
if ((err = SSLHashSHA1.final(&hashCtx, &hashOut)) != 0)
  goto fail;
....

Как статический анализатор может отловить такой дефект?

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

Возьмем упрощенный код с тем же смыслом:

{
  if (condition)
    goto fail;
    goto fail;
  ....
}

Его синтаксическое дерево может выглядеть так:

Блок – это набор операторов. Из синтаксического дерева также ясно, что:

  • первый оператор goto относится к оператору if, а второй относится непосредственно к блоку;
  • оператор ExpressionStatement находится между GotoStatement и LabeledStatement;
  • goto, относящийся к блоку, выполняется без условия, но перед ExpressionStatement нет метки. Это означает, что ExpressionStatement в этом случае недоступен.

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

Еще один способ отловить дефект — проверить, соответствует ли форматирование кода логике выполнения.

Упрощенный алгоритм будет следующим:

  • Проверьте отступ перед ответвлением then оператора if.
  • Возьмем следующий оператор после if.
  • Если оператор находится на следующей строке после ветки then и у них одинаковый отступ — выдать предупреждение.

Алгоритмы упрощены для ясности и не включают крайние случаи. Диагностические правила обычно более сложны и содержат больше исключений для случаев, когда предупреждение не требуется.

Анализ потока данных

Вот пример:

if (ptr || ptr->foo())
{ .... }

Разработчики испортили логику кода, перепутав операторы '&&' и '||'. Таким образом, если ptr является нулевым указателем, он разыменовывается.

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

if (ptr)
{ .... }
// 50 lines of code
....
auto test = ptr->foo();

Здесь указатель ptr проверяется на NULL, а затем разыменовывается без проверки, это выглядит подозрительно.

Примечание. Я использую NULL в тексте для обозначения значения нулевого указателя, а не как макрос C.

Уловить подобный случай в закономерностях будет сложно. Необходимо выдать предупреждение для примера кода выше, но не для фрагмента кода ниже, так как ptr не является нулевым указателем в момент разыменования:

if (ptr)
{ .... }
// 50 lines of code
....
if (ptr)
{
  auto test = ptr->foo();
  ....
}

В результате мы пришли к выводу, что было бы неплохо отслеживать значения переменных. Это было бы полезно для приведенных выше примеров, поскольку отслеживание оценивает значение указателя ptr в определенном месте приложения. Если указатель разыменован в NULL — выдается предупреждение, иначе — нет.

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

Анализ потока данных полезен для различных типов данных. Взгляните на эти примеры:

  • логическое значение: true или false;
  • целое число: диапазоны значений;
  • указатели/ссылки: нулевое состояние.

Давайте снова обсудим пример с указателями. Разыменование нулевого указателя является дефектом безопасности — CWE-476: Разыменование нулевого указателя.

if (ptr)
{ .... }
// 50 lines of code
....
auto test = ptr->foo();

Прежде всего, анализатор обнаруживает, что ptr проверяется на NULL. Проверка накладывает ограничения на значение ptr: ptr не является нулевым указателем в ветви then инструкции if. Поскольку анализатор это знает, он не выдаст предупреждение на следующий код:

if (ptr)
{ 
  ptr->foo();
}

Но каково значение ptr вне оператора if?

if (ptr)
{ .... }
// ptr - ???

// 50 lines of code
....
auto test = ptr->foo();

В общем, неизвестно. Однако анализатор может учитывать тот факт, что ptr уже был проверен на NULL. Таким образом, разработчик объявляет контракт, согласно которому ptr может принять NULLзначение. Этот факт можно сохранить.

В результате, когда анализатор встречает выражение auto test = ptr-›foo(), он может проверить условие:

  • при разыменовании точное значение ptr неизвестно.
  • ptr в приведенном выше коде проверяется на NULL.

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

Теперь давайте посмотрим, как анализ потока данных обрабатывает целочисленные типы. Посмотрите на код, содержащий дефект безопасности CWE-570: Expression is Always False в качестве примера.

void DataFlowTest(int x) 
{ 
  if (x > 10) 
  {
    var y = x - 10;
    if (y < 0)
      ....
    if (y <= 1)
      ....
  }
}

Начнем с самого начала. Взгляните на объявление метода:

void DataFlowTest(int x) 
{ .... }

В локальном контексте (анализ внутри одного метода) у анализатора нет информации о том, какое может быть значение x. Однако тип параметра определен — int. Это помогает ограничить диапазон возможных значений: [-2 147 483 648; 2 147 483 647] (при условии, что мы считаем int размером 4 байта).

Тогда в коде есть условие:

if (x > 10)
{ .... }

Если анализатор проверяет ветвь then оператора if, он накладывает дополнительные ограничения на диапазон. Значение x находится в диапазоне [11; 2 147 483 647] в разделе тогда.

Затем объявляется и инициализируется переменная y:

var y = x - 10;

Поскольку анализатору известны пределы значений x, он может вычислить возможное значение y. Для этого из граничных значений вычитается 10. Таким образом, значение y находится в диапазоне [1; 2 147 483 637].

Далее — оператор if:

if (y < 0)
  ....

Анализатору известно, что в данный момент выполнения значение переменной y находится в диапазоне [1; 2 147 483 637]. Оказывается, значение y всегда больше нуля, а выражение y ‹ 0 всегда ложно.

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

Ютнеф: CVE-2017-6298

Информация об уязвимости:

Посмотрим на фрагмент кода:

....
TNEF->subject.data = calloc(size, sizeof(BYTE));          
TNEF->subject.size = vl->size; 
memcpy(TNEF->subject.data, vl->data, vl->size);
....

Мы рассмотрим, откуда берется уязвимость:

  • Функция calloc выделяет блок памяти и инициализирует его нулями. Если память не была выделена, calloc возвращает нулевой указатель.
  • Потенциальный нулевой указатель записывается в поле TNEF-›subject.data.
  • Поле TNEF-›subject.data используется в качестве первого аргумента функции memcpy. Если первый аргумент memcpy является нулевым указателем, возникает неопределенное поведение. Как мы знаем, TNEF-›subject.data может быть нулевым указателем.

И аннотации, и анализ потока данных будут полезны для выявления этой проблемы.

Аннотации:

  • calloc может возвращать нулевой указатель;
  • первый аргумент memcpy не должен быть нулевым указателем (кстати, второй тоже не может).

Анализ потока данных отслеживает:

  • запись потенциально нулевого указателя из возвращенного значения calloc в TNEF-›subject.data;
  • перемещение значения как части поля TNEF-›subject.data;
  • получение потенциально нулевого указателя в первый аргумент функции memcpy из поля TNEF-›subject.data.

На картинке выше показано, как анализатор отслеживает значения выражений, чтобы найти разыменование потенциально нулевого указателя.

Анализ порчи

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

Рассмотрим пример кода, уязвимого для SQL-инъекций:

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())
    { /* Data processing */ }
  }
}

В данном случае нас интересует следующее:

  • данные поступают от пользователя и записываются в переменную userName;
  • userName вставляется в запрос, а затем запрос записывается в свойство CommandText;
  • созданная команда SQL выполняется.

Предположим, что строка _SergVasiliev_ приходит как userName от пользователя. Тогда сгенерированный запрос будет выглядеть так:

SELECT * FROM Users WHERE UserName = '_SergVasiliev_'

Начальная логика та же — из базы данных извлекаются данные для пользователя _SergVasiliev_.

Теперь предположим, что пользователь отправляет следующую строку: ' OR '1'='1. После подстановки в шаблон запроса запрос будет выглядеть так:

SELECT * FROM Users WHERE UserName = '' OR '1'='1'

Злоумышленнику удалось изменить логику запроса. Поскольку часть выражения всегда истинна, запрос возвращает данные обо всех пользователях.

Кстати, отсюда и пошёл мем про машины со странными номерами.

Давайте еще раз посмотрим на пример уязвимого кода:

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())
    { /* Data processing */ }
  }
}

Анализатору неизвестно точное значение, которое будет записано в userName. Значение может быть либо безопасным _SergVasiliev_, либо опасным ' ИЛИ ​​'1'='1. Код также не накладывает ограничений на строку.

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

Taint анализ имеет дело с маршрутами передачи данных. Анализатор отслеживает, откуда поступают данные, как они распределяются и куда уходят.

Taint-анализ используется для обнаружения различных типов инъекций и тех недостатков безопасности, которые возникают из-за недостаточной проверки пользовательского ввода.

В примере с SQL-инъекцией taint-анализ может построить маршрут передачи данных, который поможет найти брешь в безопасности:

Вот пример реальной уязвимости, которую можно обнаружить с помощью taint-анализа.

BlogEngine.NET: CVE-2018-14485

Информация об уязвимости:

Давайте просто кратко рассмотрим уязвимость от BlogEngine.NET, более подробный обзор занял бы как минимум статью. Кстати, на эту тему есть статья, которую можно прочитать здесь.

BlogEngine.NET — это платформа для ведения блогов, написанная на C#. Выяснилось, что некоторые обработчики уязвимы для атаки XXE (XML eXternal Entity). Злоумышленник может украсть данные с машины, на которой развернут блог. Для этого необходимо отправить специально сконфигурированный XML-файл на определенный URL-адрес.

Уязвимость XXE состоит из двух компонентов:

  • небезопасно настроенный парсер;
  • данные от злоумышленника, которые обрабатывает этот парсер.

Только опасный парсер можно отследить и выдать предупреждение вне зависимости от того, какие данные он обрабатывает. Этот подход имеет некоторые плюсы и минусы:

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

Предположим, мы наконец решили отслеживать данные пользователей. В этом случае также пригодится анализ taint.

Но позвольте мне вернуть вас в XXE. CVE-2018-14485 от BlogEngine.NET можно поймать следующим образом:

Анализатор начинает отслеживать передачу данных с HTTP-запроса и видит, как данные передаются между переменными и методами. Также он отслеживает перемещение по программе опасного экземпляра парсера (запроса типа XmlDocument).

Данные объединяются в вызове request.LoadXml(xml) — парсер с опасной конфигурацией обрабатывает пользовательские данные.

Теорию XXE и подробное описание этой уязвимости я собрал в следующей статье: 'Уязвимости, связанные с обработкой XML-файлов: XXE в C#-приложениях в теории и на практике.

Заключение

Мы обсудили некоторые варианты поиска уязвимостей в исходном коде приложения, а также их сильные и слабые стороны. Основная цель этой статьи — объяснить, как инструменты SAST ищут уязвимости. Однако в заключение я хочу напомнить, почему они ищут уязвимости.

1. 2022 год (еще до его окончания) уже превзошел 2021 год по количеству обнаруженных дефектов безопасности. Вот почему безопасность должна быть проблемой.

2. Чем раньше будет найдена уязвимость, тем проще и дешевле ее исправить. Инструменты SAST снижают финансовые и репутационные риски, поскольку помогают находить и исправлять ошибки как можно раньше. Более подробно я освещал эту тему здесь: SAST в Secure SDLC: 3 причины интегрировать его в конвейер DevSecOps.