Ваш код принимает данные извне? Поздравляем, вы вступили на минное поле! Любой непроверенный ввод от пользователя может привести к уязвимости, и найти все "растяжки" вручную в большом проекте почти невозможно. Но есть "сапёр" — статический анализатор. Инструмент нашего "сапёра" — taint-анализ (aka анализ помеченных данных). Он позволяет обнаружить "грязные" данные, дошедшие до опасных мест без проверки. Сегодня мы расскажем о том, как он работает.

Да кто такой этот ваш taint-анализ?!

Вспомните свою разработческую молодость. Наверняка сразу после "Hello, World" вы могли написать что-то вроде такого:

int main()
{
  char name[256];
  printf("Stand up. There you go. "
         "You were dreaming. What's your name?\n");
  scanf("%255s", name);
  printf(name);

  return 0;
}

Давайте мы пока закроем глаза на возможное переполнение буфера name при пользовательском вводе — это всего лишь наша вторая программа. Будем честны, этого количества символов хватит всем. Ну... почти всем. :) Однако в этой программе мы также сталкиваемся и с другой проблемой: в строке name содержатся помеченные данные. К чему может привести передача такой строки в функцию printf, мы рассказывали в отдельной статье.

"Такие косяки допускают только студенты", — скажете вы. Спешу раздосадовать: мы находили подобные ошибки вовсе не в курсовых работах.

Чтобы находить их на этапе написания кода, можно использовать статические анализаторы с применением технологии taint-анализа. Давайте же заглянем под капот C и C++ анализатора PVS-Studio и подробнее рассмотрим его устройство.

Да, у нас была стратегия

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

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

int getElement(int index)
{
  int arr[2] {0, 1};
  return arr[index];
}

int main()
{
  int index = 0;                         // index = 0
  std::cin >> index;                     // index = [INT_MIN; INT_MAX]
  index &= 1;                            // index = [0; 1]
  int element = getElement(index);
  return element;
}

В примере отмечено, какое виртуальное значение имеет переменная index в каждой точке выполнения программы.

  1. При объявлении и инициализации переменой присваивается значение 0;

  2. При получении индекса из консоли его значение становится любым и ограничивается только фантазией пользователя и диапазоном типа, т.е. [INT_MIN; INT_MAX];

  3. После операции побитового "И" значение index принадлежит диапазону [0; 1].

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

Но что делать, если нам неизвестно виртуальное значение переменной? Например, это параметр функции. Давайте удалим функцию main из прошлого примера:

int getElement(int index)
{
  int arr[2] {0, 1};
  return arr[index]; // index is unknown
}

Вот перед нами просто функция, возвращающая значение массива по индексу. Во время анализа тела функции без межпроцедурной информации значение параметра index анализатору неизвестно.

Может возникнуть закономерный вопрос: "Разве index = [INT_MIN; INT_MAX] и index is unknown не одно и то же?"

Нет, это не одно и то же. Пожалуй, тут нужно отступить и пояснить один важный момент.

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

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

Taint-анализ стоит особняком посреди двух стратегий. Несмотря на то, что в теории это часть sound стратегии, его применение ограничено жизненным циклом потенциально помеченных данных, лежащем в узком коридоре на пути из источника к стоку, а потому и побочные эффекты в виде ложных срабатываний здесь не так сильно выражены.

Не будем глубоко вдаваться в описание теории taint-анализа и статического анализа, в целом. В статьях про Java анализатор мы уже касались этой темы и даже подробно её рассматривали.

Заглянем под капот

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

Примечание. Помимо общеизвестных источников потенциально помеченных данных, таких как std::cin и scanf, в PVS-Studio есть возможность добавления пользовательских источников с помощью аннотаций в формате JSON.

Например, так пользователь может добавить свой собственный источник потенциально помеченных данных, проаннотировав его как taint_source:

std::string ReadStrFromStream(std::istream &input, std::string &str)
{
  ....
  input >> str; 
  return str;
  ....
}

"annotations": [
  ....
  {
    "type": "function",
    "name": "ReadStrFromStream",
    "params": [
      {
        "type": "std::istream &input"
      },
      {
        "type": "std::string &str",
        "attributes": [ "taint_source" ] // <=
      }
    ],
    "returns": { "attributes": [ "taint_source" ] }
  }
  ....
]

Подробнее про аннотации в формате JSON можно прочитать в нашей документации.

Полученные из источников данные считаются помеченными и отмечаются соответствующим тегом. При операциях с такими данными их статус может распространяться (taint propagation).

Например, при сложении достоверных данных с помеченными результат становится помеченным.

int taintedData;
scanf("%d", &taintedData);

int res = 5 + taintedData; // res now is tainted data too
....

// The same thing happens when using 
// tainted data in a loop condition
int taintedData;
scanf("%d", &taintedData);

for (int i = 0; i < taintedData; i++)
{
  //The value of i is in range [0; taintedData]
  //as a top bound of the range is tainted
  //the variable derived taint status too
}

Идём дальше и подходим к концу жизненного цикла помеченных данных — к приёмникам.

Приёмники потенциально помеченных данных (taint sink, сток) — это функции или операции, чувствительные к достоверности аргументов/операндов. Такими стоками, например, являются функции, выделяющие память, операция деления на ноль и оператор индексирования.

template <typename T, size_t size>
const auto &getElement(const std::array<T, size> &arr, size_t index)
{
  return arr[index]; // taint-sink operation
}

int main()
{
  size_t index;
  scanf("%zu", &index);

  std::array arr { 1, 2, 3, 4, 5 };
  return getElement(arr, index);
}

А с помощью аннотации taint_sink можно определить пользовательскую функцию-сток:

{
  ....
  "annotations": [
    {
      {
        "type": "function",
        "name": "DoSomethingWithData",
        "params": [ { "type": "std::string &str",
                      "attributes": [ "taint_sink" ] }] // <=
      }
    }
  ]
}

Казалось бы, при чём здесь контракты?

В С++26 в язык будут добавлены контракты. Да, они предназначены для обнаружения ошибок на этапе выполнения, но и статическому анализу они будут весьма полезны. Дело в том, что очень многие функции уже сейчас обладают неявными контрактами. Например, операция деления предполагает, что делитель не может быть равен нулю.

С помощью нового синтаксиса можно явно определить контракт функции (Function contract specifiers). Такие условия описываются в объявлении функции и доступны даже без её тела, что даёт возможность статическому анализатору сопоставлять предикаты с виртуальными значениями аргументов и выявлять ошибки.

Функция-сток, по сути, обладает контрактом, накладывая его на аргументы. Например, так будет выглядеть функция getElement с добавлением контрактов:

template <typename T, size_t size>
const auto &getElement(const std::array<T, size> &arr, size_t index)
  pre(index < std::size(arr))
{
  return arr[index];
}

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

Разобравшись с условиями, при которых анализатор генерирует предупреждения при взаимодействии со стоками, перейдём к вопросу верификации данных. Очевидно, что для исправления ошибок при использовании помеченных данных их нужно каким-то способом проверить. Но как определить, что данные прошли верификацию и перестали быть потенциально помеченными? В случае с пользовательскими аннотациями это элементарно: данные, прошедшие через функцию, определённую пользователе�� как санитайзер, считаются достоверными. Но что, если пользовательских аннотаций нет? Неужели только и остается, что ориентироваться на "validate" и "check" в именах функций?

Верификация

Как мы определили выше, помеченными считаются данные, полученные извне и не прошедшие достаточной проверки.

И тут можно задаться вопросом: "А что значит "достаточная проверка"?"

Это хороший вопрос. Верификация является самой интересной частью taint-анализа. Именно этот этап описывают правила проверки помеченных данных.

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

  • регулярные выражения. Строки можно проверять, сопоставляя с паттернами регулярных выражений;

  • экранирование. Замена управляющих последовательностей в потенциально помеченном тексте предотвращает выполнение вредоносного кода. Такой метод применяется при работе с SQL-инъекциями;

  • приведение типов. Способ проверки, применяемый к числовым данным. Включает в себя проверку диапазона допустимых значений, точности значений;

  • белый список. Сопоставление введённых данных с белым списком допустимых вариантов значений.

Но как удостовериться, что проверка будет исчерпывающей? Рассмотрим пример:

int getElement(int array[5], int index)
{
  scanf("%d", &index);

  if (index >= 0)
  {
    return array[index];
  }
  
  return 0;
}

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

Предупреждение PVS-Studio: V557 Array underrun/overrun is possible. The 'index' index is from potentially tainted source.

Попробуем выполнить более точную проверку:

int getElement(int array[5], int index)
{
  scanf("%d", &index);

  if (index >= 0 && index < 6)
  {
    return array[index];
  }
  
  return 0;
}

На этот раз мы проверили обе границы диапазона индекса, введённого пользователем, но проверили неправильно, поэтому использование его в operator[] всё равно может привести к выходу за границу массива. Это все ещё ошибка, вызванная помеченными данными? Или уже нет?

Предупреждение PVS-Studio: V557 Array overrun is possible. The value of 'index' index could reach 5.

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

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

А что вы думаете об этом примере? Когда помеченные данные перестают быть таковыми?

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

Помимо ориентированности taint-анализа на поиск потенциальных уязвимостей, связанных с высокоуровневым кодом (SQL-инъекции, XXE, XEE и т.д.), существует также необходимость применять его и на гораздо более низком уровне. Например, в операциях индексирования, деления на ноль, выделении памяти и т.д. Поэтому taint-анализ востребован даже в условиях отсутствия пользовательских аннотаций.

В такой ситуации можно рассчитывать на оставшиеся два метода верификации: приведение типов и сопоставление с "белым" списком допустимых значений. Например, можно считать достоверными переменные, проверенные функциями equal или contains.

Как мы уже успели выяснить, проверку обеих границ диапазона для знаковых чисел и верхней границы для беззнаковых PVS-Studio считает верификацией. Например, внутри блока if при таком условии мы получим достоверный диапазон значений переменной:

int array[16] { };
int index;
scanf("%d", &index);

if (index > 0 && index < 16)
{
  // index = [1; 15] inside the if statement
  return array[index];
}

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

int array[16] { };
int index;
scanf("%d", &index);

int trustedValue1 = index & 0xf; // trustedValue1 = [0; 15]
array[trustedValue1] = 0;

int trustedValue2 = index % 16;  // trustedValue2 = [0; 15]
array[trustedValue2] = 0;

Пока PVS-Studio не поддерживает аннотаций для функций-санитайзеров, однако в ближайшее время планируется реализовать этот функционал.

Ну и в заключение

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

Для увеличения эффективности пользователь может применять аннотации в формате JSON, добавляя свои собственные источники и стоки помеченных данных. В любом случае, PVS-Studio может предложить базовый taint-анализ даже в отсутствие пользовательских аннотаций. А с приходом контрактов в C++26 качество анализа вырастет ещё сильнее.

Если вы хотите попробовать анализ помеченных данных самостоятельно, приглашаем вас воспользоваться бесплатной 30-дневной версией анализатора PVS-Studio.

Комментарии (0)