Ваш код принимает данные извне? Поздравляем, вы вступили на минное поле! Любой непроверенный ввод от пользователя может привести к уязвимости, и найти все "растяжки" вручную в большом проекте почти невозможно. Но есть "сапёр" — статический анализатор. Инструмент нашего "сапёра" — 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 в каждой точке выполнения программы.
При объявлении и инициализации переменой присваивается значение
0;При получении индекса из консоли его значение становится любым и ограничивается только фантазией пользователя и диапазоном типа, т.е.
[INT_MIN; INT_MAX];После операции побитового "И" значение
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.