DOI: 10.15514/ISPRAS-2026-38(3)-20
Д.В. Кознов, ORCID: 0000-0003-2632-3193 <d.koznov@spbu.ru>
Д.А. Усачев, ORCID: 0009-0003-4863-6964 <usachev63@ro.ru>
Санкт-Петербургский государственный университет,
Россия, 199034, г. Санкт-Петербург, Университетская наб., д. 7–9.
Аннотация. В экосистеме тестирования ПО сетевых устройств крупной телекоммуникационной компании активно применяется фаззинг – подход к тестированию, где на вход тестируемой программе подаются случайные, неожиданные или некорректные входные данные. В связи с отсутствием в языке C динамических массивов как таковых для задач фаззинга C-функций оказывается полезной информация о массивах, которые принимаются на вход функциями. В данной работе предлагается специальный вид статического анализа для автоматического распознавания массивов, с которыми работают C-функции, а также определения их длины (аппроксимации). На основе предложенного метода был реализован предметно-ориентированный инструмент, нацеленный на конкретную кодовую базу компании, который при приемлемой производительности смог достичь значений метрик точности 79% и полноты 98% в распознавании массивов, при этом длина массива была правильно определена в 69% случаев. Интеграция нашего инструмента в экосистему тестирования компании позволила значительно улучшить качество фаззинга, увеличив метрику покрытия кодовой базы на 10%, а количество найденных ошибок – на 40%.
Ключевые слова: статический анализ; поиск динамических массивов; фаззинг; язык С; телекоммуникации.
Для цитирования: Кознов Д.В., Усачев Д.А. Статический анализатор для распознавания массивов в С-программах для задач фаззинга. Труды ИСП РАН, том 38, вып. 3, часть 2, 2026 г., стр. 33–48. DOI: 10.15514/ISPRAS-2026-38(3)-20.
Предметно-ориентированный анализ программного кода [1] подразумевает решение классических задач статического анализа, поиска клонов и других артефактов для одной кодовой базы, которая имеет значительные размеры (десятки миллионов строк кода). Такие кодовые базы требуют многочисленных средств поддержки для автоматического тестирования, рефакторинга, реинжиниринга, внедрения предметно-ориентированных языков (позволяющих поднять уровень абстракции для некоторой части кода с поддержкой его генерации и обратного проектирования) и так далее. Полученные алгоритмы и инструменты должны корректно работать не для всех возможных программ на выбранном языке программирования (С, Java и так далее), а только на данной кодовой базе, использующей некоторое подмножество стандартных языков и определённые шаблоны проектирования. Предметно-ориентированные инструменты по анализу программного кода в последнее время активно разрабатываются разными компаниями с использованием открытых (open source) инфраструктур, таких как Eclipse, LLVM, MS Visual Studio и других.
В данной работе рассмотрена задача статического определения факта использования С-функцией динамических массивов – то есть буферов памяти, размер которых неизвестен на момент компиляции программы, – а также задача по определению длины (аппроксимации) таких массивов. При этом в языке С синтаксически массивы отсутствуют, и часто процедуры получают их в виде нетипизированных указателей-параметров и далее работают с этим указателем как с массивом. В общем виде задача является неразрешимой, поэтому предлагается её решать на основе выделенных шаблонов кода, а также статически аппроксимировать сверху длину таких массивов. Следует отметить, что статическая информация о динамических массивах необходима для фаззинга в изоляции C-функций: для каждой такой функции перед фаззингом нужно создать соответствующий контекст, в котором, в частности, должен быть создан динамический массив нужной длины и передан в качестве параметра в эту функцию. Если сделать это неправильно, то фаззинг аварийно завершится, но вовсе не из-за ошибки в функции.
Данная задача возникла в процессе сопровождения кодовой базы крупной телекоммуникационной компании. Данное ПО имеет значительный функционал по обработке сетевых сообщений, которые оказываются буферами памяти, которые передаются по нетипизированным указателям в отдельные функции. При этом элементами таких буферов оказываются С-структуры, их поля могут содержать другие массивы, в ячейках которых, в свою очередь, могут содержаться другие структуры и так далее.
В рамках статьи предлагается метод статического анализа для автоматического выявления массивов, принимаемых на вход C-функциями, и определения их длин. Метод анализирует указатели для моделирования объектов, с которыми работает функция (в том числе массивов), а также потоки данных для определения индексов и длин этих массивов. Метод был реализован в виде программного инструмента, созданного на основе Clang / LLVM. Инструмент был интегрирован в экосистему тестирования компании, в результате чего была повышена эффективность фаззинга.
Статья организована следующим образом. В разделе 2 описывается предложенный метод. Раздел 3 посвящён описанию инструмента, реализующего разработанный метод. В разделе 4 представлены эксперименты. Раздел 5 описывает близкие исследования. Наконец, раздел 6 является заключением, содержащим выводы и направления дальнейших исследований.
На вход методу поступает исходный (анализируемый) С-код. Далее, для каждой функции строится сводка – её промежуточное представление, которое используется для анализа на следующих шагах. Затем отдельные сводки связываются и создаётся граф вызовов для всей программы. После этого выполняется восходящий анализ, в результате которого для каждой функции выдаётся следующее: использует ли она или нет массив переменной длины, в случае использования выдаётся оценка его длины. Схема метода представлена на рис. 1.

Рис. 1. Схема предложенного метода.
Рассмотрим подробнее каждый шаг метода.
Пусть является множеством всех функций анализируемого C-кода. Определим сводку отдельной функции следующим образом:
где:
– граф объектов языка С (object graph), используемых в теле функции (константы и литералы, переменные, динамически созданные объекты и пр.);
– граф потока управления для ;
– список параметров функции ;
– список глобальных переменных, используемых в ;
– инструкции обращения к массиву в функции ;
– множество ограничений над численными переменными в функции .
Рассмотрим эти составляющие сводки более детально.
Вершина в графе объектов обозначает множество объектов функции . Объектом, используемым в функции , может быть: константа или литерал (строковый или составной); переменная (локальная, глобальная, статическая); динамически созданный объект; элемент другого объекта (поле структуры/объединения, элемент массива, взятый адрес объекта); результат вызова функции или приведения типов; результат применения оператора (оператора присваивания, в том числе составного присваивания, например, +=, оператора инкремента/декремента, арифметического/логического оператора) и других конструкций. При этом разные объекты могут быть представлены одной вершиной в графе объектов. Например, если указатель p в одном случае указывает на переменную a, а в другом – на переменную b, то объекты a и b будут представлены одной вершиной, поскольку их нецелесообразно различать в графе объектов. Рёбра в графе объектов бывают двух видов: рёбра доступа и арифметические рёбра . Ребро доступа означает, что к объекту может быть осуществлён доступ путём применения операции к объекту . Мы рассматриваем виды операции , перечисленные в табл. 1.
Табл. 1. Виды операций на рёбрах доступа.
| Вид операции | Обозначение |
|---|---|
| Разыменование указателя p | p[0] |
| Доступ к полю field структуры (объединения) s | s.field |
| Доступ к элементу массива a по явному индексу i | a[i] |
| Доступ к элементу массива a по неизвестному индексу | a[?] |
| Смещение адреса addr на константу i | addr.offset(i) |
Таким образом, рёбра доступа (в особенности, связывающие указатель и объект-указуемое) моделируют расположение в памяти объектов, с которыми работает функция. Касаемо рёбер доступа выполняются следующие правила:
В качестве примера можно рассмотреть код в листинге 1. Граф объектов для этого примера изображён на рис. 2. Он состоит из семи пронумерованных вершин, каждая вершина на рисунке помечена своим типом. Вершина соответствует единственному параметру p функции field, который является указателем. Из ведёт одно ребро разыменования в вершину , которая представляет структуру Point и имеет три поля: x, y и d; для каждого из них имеется соответствующее ребро доступа. Поле d, являющееся указателем void* и представленное вершиной , имеет ребро доступа по указателю, ведущее в вершину конкретного типа Description. Наконец, имеется ещё одно ребро «структура – поле», ведущее в вершину типа int.
Арифметические рёбра в графе объектов связывают объекты целочисленного типа. Арифметическое ребро обозначает тот факт, что после выполнения инструкции с номером (про нумерацию см. далее) значения объектов и связаны соотношением , где является одной из унарных операций, представленных в табл. 2.
| typedef struct { int color; } Description; typedef struct { int x; int y; void *d; } Point; int foo(Point *p) { Description *d = (Description *)p->d; return d->color + p->x + p->y; } |
|---|
Листинг 1. Пример C-кода для иллюстрации графа объектов.

Рис. 2. Пример графа объектов.
Табл. 2. Виды операций на арифметических рёбрах.
| Вид операции | Соотношение |
|---|---|
| Копирование | |
| Сложение с константой | |
| Умножение на константу | |
| Деление на константу | , |
На уровне исходного кода определение функции представлено в виде дерева абстрактного синтаксиса (abstract syntax tree, AST). Оно состоит из набора операторов/выражений, которые также могут содержать в себе подвыражения. Как будет видно далее, для более точного анализа полезно учитывать порядок исполнения операторов. В данном случае представление в виде дерева оказалось неудобным, так как для произвольных двух вершин, отвечающих двум операторам, трудно понять, какой оператор должен исполняться раньше. Поэтому для функции создаётся другое промежуточное представление – граф потока управления (control flow graph) . Построение графа выполняется следующим образом. Рассматриваются все операторы/выражения в функции (в том числе и подвыражения), которые мы далее будем называть инструкциями. Метод разбивает инструкции на базовые блоки (basic block). Базовым блоком называется максимальная последовательность инструкций, удовлетворяющая следующим ограничениям: (i) поток управления функции может попасть в блок лишь войдя в первую инструкцию блока; (ii) инструкции в блоке выполняются последовательно без ветвлений: после первой обязательно выполняется вторая, после второй – третья и так далее до последней. Последняя инструкция в блоке называется терминатором и может выполнять выход из блока, переход в его начало, но не может выполнять переход в середину блока. Также вводятся два дополнительных базовых блока, не содержащих инструкций: блок (вход в функцию) и блок (выход из функции). Множество базовых блоков назначается множеством вершин графа потока управления . Все возможные переходы между базовыми блоками представлены множеством рёбер .
Для инструкций дополнительно вводится нумерация так, чтобы в каждом базовом блоке номера инструкций шли подряд. При этом номера инструкций в разных базовых блоках могут соотноситься произвольным образом, главное, чтобы они были различными.
Список параметров функции обозначим как . Параметр с номером имеет вид , где , а – имя параметра.
Множество содержит глобальные переменные, используемые функцией . Каждая такая переменная представляется в виде пары , где , а – имя переменной.
Для автоматического определения массивов и их индексов в сводку функции добавляются все инструкции обращения к массивам внутри . Каждая инструкция представляется в виде , что означает следующее: инструкция с номером осуществляет доступ к массиву по индексу , и результирующий объект (элемент массива) представлен вершиной . Индекс имеет вид , где – объект целочисленного типа, а и – это рациональные коэффициенты. Довольно часто индексом является целая константа () или вершина ( и ). Описанный общий вид индекса нужен для поддержки шаблонов кода, где длина массива нетривиально зависит от параметра функции.
Для того, чтобы автоматически аппроксимировать длину найденного в функции массива, необходимо выяснить, насколько большими могут быть индексы, которые используются при обращении к нему, то есть для заданной инструкции обращения к массиву необходимо найти численное ограничение сверху на индекс , которое выполняется перед инструкцией с номером . Для этого метод строит множество численных ограничений .
Каждое ограничение имеет вид и означает следующее: в начале исполнения базового блока выполняется соотношение , где – объект целочисленного типа, – знак сравнения, а , где – объект целочисленного типа, а и – рациональные коэффициенты. Ограничения выделяются из условий операторов ветвления, входящих в .
Для осуществления упрощённой версии межпроцедурного анализа, необходимой для решения нашей задачи, строится граф вызовов. Для функции через обозначим множество всех её точек вызова, то есть номеров соответствующих инструкций вызова функции (см. определение ). Вершинами графа вызовов являются все функции анализируемого C-кода, а каждое ребро имеет вид и означает, что функция в точке вызова может вызывать функцию . Отметим, что функция может вызывать функцию в нескольких местах, и поэтому рёбра графа вызовов различаются по точке вызова .
В предположении, что все вызовы в программе являются прямыми, то есть вызываемая функция известна во время компиляции, построение графа вызовов оказывается несложным. Сложность возникает в случае вызовов по указателю на функцию (так называемых непрямых вызовов). В таком случае функций может быть много. Для построения графа вызовов с учётом непрямых вызовов используется алгоритм, предложенный в гл. 19 [2].
Далее, на основе графа вызовов вершины в сводках вызываемой и вызывающей функций связываются между собой рёбрами возврата, которое задаётся следующим образом. Пусть имеется ребро графа вызовов . Тогда соответствующие ему рёбра возврата имеют вид , где , , и . Множество всех рёбер возврата обозначим через . Ребро возврата добавляется в в следующих случаях:
Каждую сводку можно представить в виде отдельной плоскости, содержащей вершины , связанные между собой горизонтальными рёбрами, то есть рёбрами доступа и арифметическими рёбрами. Рёбра возврата , в свою очередь, назовём вертикальными рёбрами. Для иллюстрации рассмотрим пример на листинге 2, состоящий из двух функций – foo и bar. Графы объектов этих функций вместе с рёбрами возврата изображены на рис. 3.
| int bar(int x, int y) { if (x < y) { return 1; } else { return 0; } } void foo(int *p) { int y = 2; int z = bar(*p, y); } |
|---|
Листинг 2. Пример C-кода для иллюстрации связывания сводок.

Рис. 3. Связывание сводок функций foo и bar рёбрами возврата.
Параметры x и y функции bar (вершины и ) связываются ребром возврата с выражением *p и переменной y (вершинами и ) соответственно. Кроме того, в функции bar есть две точки возврата, и соответствующие возвращаемые значения (вершины и ) связываются с переменной z.
На третьем шаге метода выполняется упрощённый межпроцедурный анализ.
Функции рассматриваются «снизу вверх», поэтому данный анализ называется восходящим. А именно, метод упорядочивает все функции анализируемого кода в последовательность так, что вызывающая функция всегда стоит раньше вызываемой. Затем каждая функция анализируется по очереди, начиная с последней. Не рассматриваются рекурсивные вызовы, поскольку они запрещены в целевой кодовой базе.
Для очередной функции выполняется анализ, который состоит из следующих шагов:
Результатом анализа функции является список из всех найденных массивов, а также оценки длины для каждого массива. Восходящий порядок гарантирует, что перед анализом функции её сводка дополнена информацией, «поднятой» из всех вызываемых функций (которые стоят в последовательности правее ).
Этот шаг является основным (и, фактически, единственным) случаем применения арифметических рёбер в методе и необходим для того, чтобы поддержать шаблоны кода, где длина массива не равна параметру функции , а выражается линейной функцией (например, или , где – параметр ). «Протягивание» на основе имеющейся информации в сводке добавляет новые инструкции обращения к массиву во множество , что позволяет найти больше массивов и правильно определить их длину. Шаг «протягивания» можно опустить, не нарушая метод: в таком случае метод не найдёт нужных массивов для вышеуказанных шаблонов кода, однако массивы будут найдены (а их длина – правильно определена) для всех остальных требуемых шаблонов.
«Протягивание» выполняется так. Пусть в сводке функции имеется инструкция обращения к массиву , где , а . Пусть также имеется арифметическое ребро , ведущее в вершину . Пусть также известно, что инструкция исполняется раньше, чем инструкция . Если все эти условия выполнены, то метод «протягивает» вдоль : он добавляет в множество новую инструкцию обращения к массиву , где . Фактически, метод создаёт альтернативное описание той же инструкции обращения к массиву. Если в инструкции индекс выражен как линейная функция от вершины , то в добавленной инструкции индекс выражен уже в терминах вершины .
Процесс «протягивания» может продолжаться до достижения «неподвижной точки», то есть такого состояния, когда никакое дальнейшее «протягивание» не создаёт новый элемент множества . В реальности данный процесс может оказаться долгим или даже не сойтись, поэтому в реализации процесс ограничивается во избежание длительного времени работы и большого потребления памяти.
Для проверки того, что инструкция исполняется раньше, чем инструкция (для произвольно взятых и ) рассматриваются соответствующие им базовые блоки и в графе потока управления . Проверяется, что базовый блок доминирует базовый блок , то есть любой путь от входного базового блока до базового блока проходит через блок . Если инструкции расположены в одном блоке (то есть ), то дополнительно проверяется, что они идут в правильном порядке, то есть . Проверка доминируемости осуществляется путём построения дерева доминаторов для графа потока управления (для этого использован алгоритм из [3]).
Наиболее сложным при анализе функции является поиск массивов в её сводке. Результатом этого поиска является множество , элементы которого имеют вид . Здесь вершина отвечает массиву, а представляет собой длину этого массива: , где , а и – это рациональные коэффициенты.
Множество строится следующим образом. Рассматривается каждая инструкция обращения к массиву , . Если индекс является константой, то есть , то в добавляется элемент , а поиск переходит к следующей инструкции обращения к массиву. Если (случай рассматривается симметрично), то метод ищет численное ограничение для вершины , которое выполняется перед инструкцией и имеет вид , где . Проверку выполнимости ограничения перед инструкцией мы обсудим ниже. Ограничения вида рассматриваются методом так же, как и ограничения . Если для инструкции обращения к массиву метод нашёл подходящее ограничение , то в добавляется элемент . В самом простом случае имеется инструкция обращения к массиву и выполняющееся перед инструкцией ограничение . Это означает, что в данной точке функции выполнено обращение к массиву по индексу, про который известно, что он меньше . Таким образом массив имеет длину .
Для того, чтобы выяснить, должно ли ограничение выполняться перед инструкцией , выполняется проверка того, что в графе потока управления базовый блок доминирует базовый блок инструкции . Данный способ является приближённым и не даёт гарантий того, что ограничение действительно выполняется в данной точке программы. Например, при исполнении базового блока значение численной переменной могло измениться до инструкции . Тем не менее данная проверка позволяет исключить нахождения большого количества ложноположительных динамических массивов.
Найденные на предыдущем шаге массивы и их длины выражены в терминах вершин графа объектов функции . Для того, чтобы сделать полученный результат пригодным для выдачи пользователю, следует вычислить имена соответствующих вершин. Каждое имя представляется в виде так называемого пути, который начинается с имени параметра функции или глобальной переменной, и опционально продолжается несколькими возможными операциями: разыменованием указателя; взятием поля; доступом к элементу массива по константному индексу; доступом к элементу массива по неизвестному индексу; смещением адреса. Данные операции соответствуют возможным операциям на рёбрах доступа. Таким образом, метод строит отображение , которое сопоставляет вершинам графа объектов (возможно, не всем) их имена.
Для определения имён метод выполняет обход графа объектов. Обход начинается с вершин, соответствующих параметрам функции и глобальным переменным; то есть это вершины из множеств и . Для них в добавляются их имена. Предположим, обход сейчас находится в вершине , которой сопоставлено имя , а также имеется ребро доступа , причём вершине пока не сопоставлено имя в отображении . Тогда метод добавляет имя для вершины : , после чего обход продолжается из вершины . Обход завершается, когда всем достижимым вершинам сопоставлены имена. Возможно, что процесс обхода может найти несколько путей до одной и той же вершины, в нашем случае мы выбираем любой.
Имея отображение , метод собирает и экспортирует найденные массивы в требуемый формат. Рассматривается каждый массив , , и если для обеих вершин есть имена, то добавляется окончательный результат: массив в функции , имеющий длину (где , и ).
До того, как завершить анализ функции , метод должен «поднять» необходимую информацию «наверх», в сводки вызывающих функций, чтобы далее было возможно качественно проанализировать оставшиеся функции . Рассмотрим функцию , вызывающую функцию в точке вызова – . Для осуществления «подъёма» информации в функцию метод сначала добавляет ряд горизонтальных и вертикальных рёбер следующим образом. Предположим, в сводке функции имеется горизонтальное ребро , а также есть вертикальное ребро . Тогда метод «поднимает» ребро , то есть в сводку добавляется новое горизонтальное ребро , которое ведёт в новую вершину . Добавление ребра не происходит, если из вершины уже ведёт ребро доступа с операцией . Затем метод добавляет новое вертикальное ребро , тем самым формируя «ячейку» из двух горизонтальных и двух вертикальных рёбер. Этот процесс продолжается до тех пор, пока не останется горизонтального ребра, которое можно поднять. Набор сформированных «ячеек», которые состоят из горизонтальных рёбер в сводках функций и и из вертикальных рёбер между этими двумя функциями, образует так называемую решётку. В свою очередь, вышеописанный шаг называется построением решётки.
Вместе с построением решётки метод «поднимает» используемые глобальные переменные из множества . Пусть в функции (или в вызванной функции) используется глобальная переменная , которая представлена в виде . Предположим, что в функции не используется переменная . Тогда метод добавляет в граф объектов новую вершину , которая соответствует переменной : . После этого метод добавляет новое вертикальное ребро во множество .
Пример построения решётки приведён на рис. 4 (соответствующий пример кода см. в листинге 3). Для данного C-кода метод строит две сводки – сводка , состоящая из одной вершины (параметр p), и сводка с тремя следующими вершинами: (параметр q), (выражение *q) и (выражение q->x). Также имеется одно ребро возврата , где – единственная точка вызова функции bar. При построении решётки в сводку добавляются две новые вершины , , два горизонтальных ребра , , а также два вертикальных ребра , . На рис. 4 добавленные вершины и рёбра изображены красным цветом.
| struct Pt { int x; int y; }; void bar(struct Pt *q) { q->x = 0; } void foo(struct Pt *p) { bar(p); } |
|---|
Листинг 3. Пример C-кода для иллюстрации построения решётки.

Рис. 4. Построение решётки.
«Подъём» горизонтальных рёбер нужен для того, чтобы учесть информацию о том, какие объекты используются в вызываемых функциях и как с ними происходит работа.
Наконец, метод завершает анализ функции «подъёмом» остальной информации в сводки всех функций, которые вызывают . Пусть функция вызывает функцию в точке вызова . Метод «поднимает» следующие сущности: арифметические рёбра , инструкции обращения к массиву и массивы .
Для примера рассмотрим процесс «подъёма» в сводку инструкции обращения к массиву , . Метод рассматривает три ребра возврата: , (если , то это ребро не рассматривается) и . Если какое-то из этих рёбер отсутствует, инструкция не «поднимается». Иначе в сводку добавляется новая инструкция обращения к массиву , где , а – это номер инструкции вызова в нумерации .
Остальная информация «поднимается» аналогично: для каждой вершины, входящей в состав сущности (арифметического ребра/массива) рассматривается ребро возврата, помеченное точкой вызова , и каждая вершина заменяется на конец своего ребра. Номер инструкции, если он есть, заменяется на номер . Таким образом, все инструкции в функции «схлопываются» в одну инструкцию в функции .
«Подняв» информацию во все вызывающие функции, за ненадобностью метод удаляет информацию о текущей функции для уменьшения потребления памяти, после чего метод переходит к анализу следующей функции . Восходящий анализ продолжается до тех пор, пока не будут проанализированы все функции.
Инструмент был реализован на основе компилятора Clang [4] (часть инфраструктуры LLVM). Этот популярный компилятор для языков семейства C с открытым исходным кодом реализован на C++ и разрабатывается open source сообществом более 20 лет. Clang обладает качественной документацией, хорошо проработанной архитектурой и высокой производительностью. С его помощью было создано множество инструментов разного назначения: линтеры (clang-tidy), форматтеры (clang-format), всевозможные сервисы для IDE, например, языковой сервер clangd, а также статические анализаторы общего назначения (Clang Static Analyzer [5]). Лицензия Clang позволяет на его основе создавать проприетарное ПО (в отличие, например, от GCC), что является одним из требований к данному решению. По этим причинам Clang был выбран для данной задачи. Для хранения результатов анализа была выбрана СУБД SQLite [6], так как она легко интегрируется в виде C-библиотеки и не требует развёртывания сервера базы данных. Таким образом, результаты анализа хранятся в одном файле, с которым в дальнейшем работает фаззер.
Архитектура инструмента изображена на рис. 5.

Рис. 5. Архитектура инструмента распознавания массивов.
На вход инструмент принимает файл compile_commands.json [7], в котором перечислены все входные C-файлы, а также опции компиляции. На выход инструмент записывает результат анализа в базу данных SQLite, которая в итоге для каждой функции содержит информацию о выявленных в ней массивах (если таковые имеются) и их длинах.
В начале работы инструмент осуществляет индексирование каждого C-файла. Индексирование начинается с построения дерева абстрактного синтаксиса (Clang AST [8]), которое строится в фронтенде Clang. С помощью рекурсивного обхода этого дерева наш инструмент строит индекс данного C-файла – его минималистичное представление, содержащее информацию об определённых в этом файле символах (функциях, типах, глобальных переменных и других языковых конструктах) и информацию, необходимую для метода, то есть сводки всех функций в данном файле.
После индексирования всех C-файлов построенные индексы объединяются в глобальный индекс программы. Имея множество сводок всех функций, инструмент связывает их и выполняет восходящий анализ в соответствии с предложенным методом. Завершив анализ очередной функции, инструмент записывает информацию о найденных массивах в базу данных, и после этого восходящий анализ переходит к рассмотрению следующей функции.
Для оценки эффективности инструмента были поставлены эксперименты, которые исследовали:
Эксперимент по производительности проводился на 8 модулях целевой кодовой базы (1200 файлов, что соответствует 860 тыс. строк кода1). Использовалось типовое облачное окружение с операционной системой EulerOS (Linux) на 8 ядрах (16 потоков), 32 ГБ оперативной памяти. Время работы оказалось приемлемым и составило 4,5 мин. при потреблении памяти 4,38 ГБ.
Во втором эксперименте была исследована эффективность фаззинга до и после внедрения нашего инструмента в экосистему тестирования кодовой базы. Оказалось, что покрытие кодовой базы фаззером при внедрении инструмента увеличилось на 10%, а количество найденных фаззером ошибок за 3 месяца эксплуатации инструмента увеличилось на 40% по сравнению с предыдущим аналогичным периодом до внедрения.
Последний эксперимент имел цель численно оценить точность нашего метода поиска массивов и проводился на открытом телекоммуникационном проекте DMM [9]. Проект DMM был выбран как пример открытого сетевого C-фреймворка, похожего по стилю на целевую кодовую базу. Был рассмотрен фрагмент проекта (119 функций, 18 тыс. строк кода), на котором были достигнуты значения точности (precision) 79% и полноты (recall) 98%. При этом длина массива была определена правильно в 69% случаев.
Ложноположительные результаты метод произвёл в следующих случаях: 1) когда подаваемый на вход указатель не использовался, а был перезаписан функцией; 2) когда один объект был распознан как массив из одного элемента; 3) когда C-структура была инициализирована функцией memset и метод распознал её как массив с типом элемента char. Наш метод можно улучшить, чтобы данные случаи обрабатывались правильно. Например, для первого случая можно представить исходное и новое значение указателя разными вершинами в графе объектов, используя SSA. Ложноотрицательных результатов оказалось немного, и большинство из них можно решить, поддержав дополнительные стандартные функции и системные вызовы, такие как send и recv.
Отметим, что применительно к задачам фаззинга метрика полноты нашего метода важнее, чем точность, поскольку при ложноположительном результате в контексте функции будет создан лишний массив (он не повлияет на фаззинг), а при ложноотрицательном нужный массив не будет создан, и фаззинг может аварийно завершиться, не покрыв значительную часть функции. Результаты третьего эксперимента доступны на GitHub [10].
Существует ряд подходов к анализу динамической памяти в языках программирования. Можно упомянуть про классические алгоритмы анализа потока управления, анализа потока данных и анализа указателей [2]. Также имеются подходы в рамках символьного исполнения программ, моделирующие динамическую память при статическом исполнении программ [11]. Однако задача управления динамической памятью в языке С является алгоритмически неразрешимой, поскольку по нетипизированному указателю в общем случае невозможно понять, куда именно он указывает, а из-за адресной арифметики, в свою очередь, трудно отследить происхождение фрагмента памяти, на которую он указывает. В частности, это влечёт неразрешимость задачи автоматической сборки мусора для языков С/С++ [12]. Тем не менее, все эти подходы, решая так или иначе задачу идентификации массивов (анализ указателей), оставляют в стороне задачу аппроксимации длины динамических массивов.
Важной задачей в статическом анализе C-программ является детектирование переполнений буфера. Существует ряд анализаторов, реализующих автоматические проверки такого рода. Clang Static Analyzer [5] – это инструмент статического анализа с открытым исходным кодом, который разрабатывается сообществом компилятора Clang [4] и предназначается для поиска ошибок в C/C++-программах. Он работает на уровне AST и использует метод символьного исполнения. Один из более сотни реализованных в инструменте детекторов находит потенциальный выход за границу массива. Данный детектор поддерживает массивы, длина которых известна статически, а наш метод также может выявлять массивы, длина которых хранится в переменной.
Коммерческий статический анализатор Svace [13-14] (создан в ИСП РАН) предназначен для внедрения процесса безопасной разработки в компаниях. Целью инструмента является предоставление универсальной единой инфраструктуры анализа, которая: обеспечивает масштабируемость на крупные приложения; поддерживает множество языков программирования (не только язык C), компиляторов, платформ; предоставляет единый пользовательский интерфейс и так далее. На базе межпроцедурного чувствительного к путям исполнения анализа в Svace реализованы качественные детекторы переполнения буфера, которые в том числе поддерживают динамически выделенные буферы с символьной длиной. В то время, как подход и реализованный движок анализа выглядит перспективным для решения задачи автоматического определения длин массивов, детекторы Svace неотделимы от всей крупной инфраструктуры статического анализа. По этой причине существующие фрагменты нецелесообразно повторно использовать и интегрировать в итоговое решение для фаззинга С-программ в телекоммуникациях.
Статический анализатор cooddy [15] разрабатывался в компании Huawei для внутреннего применения и имел детектор выхода за границу массива. Более того, он поддерживал пользовательские аннотации для функций, в особенности для тех, которые не определены внутри анализируемого проекта. Одной из аннотаций является аннотация буфера и его длины, с её помощью cooddy может искать больше случаев выхода за границу массива. Наш метод позволяет осуществить автоматическую генерацию аннотаций «буфер-длина», что потенциально может улучшить точность анализатора.
KLEEF [16] – это инструмент с открытым исходным кодом для символьного исполнения С/С++-программ, разрабатываемый R&D Toolchain Labs (исследовательская лаборатория им. П.Л. Чебышева). KLEEF выполняет две прикладные задачи: автоматическую генерацию качественного тестового покрытия и автоматическую верификацию трасс ошибок (найденных статическими анализаторами) для фильтрации ложных срабатываний. Важной составляющей любого движка символьного исполнения является символьная память, которая сопоставляет символам конкретные или символьные значения. Одной из особенностей KLEEF, которая отсутствовала в оригинальном проекте KLEE [17], является поддержка в символьной памяти сложных структур данных: связных списков, деревьев, и, в особенности, динамически созданных массивов (массивов с символьной длиной). Тем самым, инструмент KLEEF вполне применим для качественного автоматического распознавания динамических массивов с аппроксимацией длины. В сравнении с нашим методом KLEEF имеет две технические тонкости, которые усложняют его интеграцию в экосистему тестирования телекоммуникационного ПО. Во-первых, символьное исполнение требует больше вычислительных ресурсов, ведь для каждой функции в анализируемом C-коде необходимо пройти множество различных путей исполнения. Это создаёт риск неприемлемого времени работы для большой кодовой базы. Наш подход более простой и более легковесный. Во-вторых, движок KLEEF работает на уровне промежуточного представления LLVM [18], тем самым, исходный C-код необходимо прежде скомпилировать в это представление. Это создаёт дополнительный нетривиальный шаг в процедуре управления качеством нашей кодовой базы. Для нашего инструмента достаточно иметь исходный код и файл compile_commands.json.
В данной работе представлен специальный метод статического анализа для автоматического распознавания массивов, который состоит из:
Для тестирования кодовой базы был разработан инструмент на основе Clang, реализующий данный метод. Инструмент был интегрирован в систему фаззинга компании, что позволило увеличить метрику покрытия кодовой базы на 10%, а количество найденных ошибок – на 40%. На релевантном открытом проекте инструмент достиг точности 79% и полноты 98% в распознавании массивов, а длина была правильно определена в 69% случаев.
Приоритетным направлением дальнейшего развития инструмента представляется правильное распознавание массивов для более сложных шаблонов кода, встречающихся в целевой кодовой базе. Так, имеется запрос на поддержку следующих шаблонов:
Другим возможным улучшением является устранение ложноположительных результатов путём использования промежуточного представления SSA, в котором разные значения одного указателя будут представлены разными вершинами в графе объектов.
Дмитрий Владимирович КОЗНОВ – доктор технических наук, профессор кафедры системного программирования Санкт-Петербургского государственного университета, Сфера научных интересов: программная инженерия, модельно-ориентированная разработка программного обеспечения, программные данные, машинное обучение.
Данила Александрович УСАЧЕВ – бакалавр Санкт-Петербургского государственного университета, C++-разработчик. Сфера научных интересов: статический анализ кода, фаззинг.