Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Я работаю в Red Hat над GCC, GNU Compiler Collection. Для следующего основного релиза GCC, GCC 10, я реализовывал новую опцию -fanalyzer: проход статического анализа для выявления различных проблем во время компиляции, а не во время исполнения.
Я думаю, что лучше выявлять проблемы как можно раньше по мере написания кода, используя компилятор, как часть цикла компиляции-редактирования-отладки, а не использовать статический анализ в качестве дополнительного инструмента «на стороне» (возможно, проприетарного). Поэтому, представляется целесообразным иметь встроенный в компилятор статический анализатор, который видит код в точности такой же, какой видит компилятор — ведь это и есть компилятор.
Этот вопрос, конечно, является огромной проблемой, которую нужно решить. Для этого релиза я сконцентрировался на типах проблем, замеченных в коде на Си, и, в частности, на ошибках двойного освобождения (double-free), но с целью последующего создания фреймворка, который мы сможем расширить в последующих релизах (когда мы сможем добавить больше проверок и поддержку языков, отличных от Си).
Надеюсь, что анализатор обеспечивает приличное количество дополнительной проверки, при этом не являясь слишком накладным. Я стремился к тому, чтобы -fanalyzer «всего лишь» удвоил время компиляции в качестве разумного компромисса между дополнительными проверками. У меня пока не получилось, как вы увидите ниже, но я работаю над этим.
Сейчас код находится в основной ветке GCC для GCC 10 и может быть опробован в Compiler Explorer, он же godbolt.org. Он хорошо работает для малых и средних примеров, но есть ошибки, которые означают, что он не готов к промышленному использованию. Я усердно работаю над исправлениями в надежде, что к моменту выхода GCC 10 (скорее всего, в апреле) эта возможность будет эффективно применима для C-кода.
Пути диагностики
Вот самый простой пример ошибки double-free:
#include <stdlib.h>
void test(void *ptr)
{
free(ptr);
free(ptr);
}
GCC 10 с -fanalyzer сообщает об этом следующим образом:
$ gcc -c -fanalyzer double-free-1.c
double-free-1.c: In function ‘test’:
double-free-1.c:6:3: warning: double-‘free’ of ‘ptr’ [CWE-415] [-Wanalyzer-double-free]
6 | free(ptr);
| ^~~~~~~~~
‘test’: events 1-2
|
| 5 | free(ptr);
| | ^~~~~~~~~
| | |
| | (1) first ‘free’ here
| 6 | free(ptr);
| | ~~~~~~~~~
| | |
| | (2) second ‘free’ here; first ‘free’ was at (1)
|
Этот лог показывает, что GCC выучил несколько новых трюков; во-первых, возможность диагностики иметь идентификаторы Common Weakness Enumeration (CWE). В этом примере диагностика double-free помечена тегом CWE-415. Надеемся, что этот тег сделает вывод более понятным, повысит точность и даст вам что-то простое для ввода в поисковых системах. Пока что только диагностика от -fanalyzer маркируется идентификаторами уязвимости CWE.
Если Вы используете GCC 10 с подходящим терминалом (например, свежий gnome-terminal), то CWE-идентификатор — это гиперссылка, ведущая к описанию проблемы. Говоря о гиперссылках, для многих релизов, когда GCC выдает предупреждение, он печатает опцию, регулирующую это предупреждение. Начиная с GCC 10, этот текст опции теперь является гиперссылкой на щелчок (опять же, предполагая достаточно развитый терминал), что должно привести вас к документации по этой опции (для любого предупреждения, а не только для тех, которые относятся к анализатору).
Теперь диагностика GCC может иметь связанную с ними цепочку событий, описывающую путь через код, который инициирует проблему. Учитывая отсутствие потока управления в приведенном выше примере, у него есть только два события, но вы можете увидеть, как второе событие относится к первому в его описании.
Приведем более полный пример. Вы видите проблему в следующем коде? (Подсказка: на этот раз это не двойное освобождение):
#include <setjmp.h>
#include <stdlib.h>
static jmp_buf env;
static void inner(void)
{
longjmp(env, 1);
}
static void middle(void)
{
void *ptr = malloc(1024);
inner();
free(ptr);
}
void outer(void)
{
int i;
i = setjmp(env);
if (i == 0)
middle();
}
Вот что сообщает GCC -fanalyzer, который показывает межпроцедурный поток управления с помощью ASCII-вывода:
$ gcc -c -fanalyzer longjmp-demo.c
longjmp-demo.c: In function ‘inner’:
longjmp-demo.c:8:3: warning: leak of ‘ptr’ [CWE-401] [-Wanalyzer-malloc-leak]
8 | longjmp(env, 1);
| ^~~~~~~~~~~~~~~
‘outer’: event 1
|
| 18 | void outer(void)
| | ^~~~~
| | |
| | (1) entry to ‘outer’
|
‘outer’: event 2
|
| 22 | i = setjmp(env);
| | ^~~~~~
| | |
| | (2) ‘setjmp’ called here
|
‘outer’: events 3-5
|
| 23 | if (i == 0)
| | ^
| | |
| | (3) following ‘true’ branch (when ‘i == 0’)...
| 24 | middle();
| | ~~~~~~~~
| | |
| | (4) ...to here
| | (5) calling ‘middle’ from ‘outer’
|
+--> ‘middle’: events 6-8
|
| 11 | static void middle(void)
| | ^~~~~~
| | |
| | (6) entry to ‘middle’
| 12 | {
| 13 | void *ptr = malloc(1024);
| | ~~~~~~~~~~~~
| | |
| | (7) allocated here
| 14 | inner();
| | ~~~~~~~
| | |
| | (8) calling ‘inner’ from ‘middle’
|
+--> ‘inner’: events 9-11
|
| 6 | static void inner(void)
| | ^~~~~
| | |
| | (9) entry to ‘inner’
| 7 | {
| 8 | longjmp(env, 1);
| | ~~~~~~~~~~~~~~~
| | |
| | (10) ‘ptr’ leaks here; was allocated at (7)
| | (11) rewinding from ‘longjmp’ in ‘inner’...
|
<-------------+
|
‘outer’: event 12
|
| 22 | i = setjmp(env);
| | ^~~~~~
| | |
| | (12) ...to ‘setjmp’ in ‘outer’ (saved at (2))
|
Вышеизложенное довольно многословно, хотя, возможно, так это и должно быть для того, чтобы передать, что происходит, учитывая использование setjmp и longjmp. Я надеюсь, что описание достаточно понятно: происходит утечка памяти, когда вызов longjmp разворачивает стек обратно в outer мимо точки очистки в middle, не вызывая очистки.
Если вам не нравится ASCII-вывод, показанный выше, вы можете просматривать события как отдельную диагностику «ноты» при помощи -fdiagnostics-path-format=separate-events:
$ gcc -c -fanalyzer -fdiagnostics-path-format=separate-events longjmp-demo.c
longjmp-demo.c: In function ‘inner’:
longjmp-demo.c:8:3: warning: leak of ‘ptr’ [CWE-401] [-Wanalyzer-malloc-leak]
8 | longjmp(env, 1);
| ^~~~~~~~~~~~~~~
longjmp-demo.c:18:6: note: (1) entry to ‘outer’
18 | void outer(void)
| ^~~~~
In file included from longjmp-demo.c:1:
longjmp-demo.c:22:7: note: (2) ‘setjmp’ called here
22 | i = setjmp(env);
| ^~~~~~
longjmp-demo.c:23:6: note: (3) following ‘true’ branch (when ‘i == 0’)...
23 | if (i == 0)
| ^
longjmp-demo.c:24:5: note: (4) ...to here
24 | middle();
| ^~~~~~~~
longjmp-demo.c:24:5: note: (5) calling ‘middle’ from ‘outer’
longjmp-demo.c:11:13: note: (6) entry to ‘middle’
11 | static void middle(void)
| ^~~~~~
longjmp-demo.c:13:15: note: (7) allocated here
13 | void *ptr = malloc(1024);
| ^~~~~~~~~~~~
longjmp-demo.c:14:3: note: (8) calling ‘inner’ from ‘middle’
14 | inner();
| ^~~~~~~
longjmp-demo.c:6:13: note: (9) entry to ‘inner’
6 | static void inner(void)
| ^~~~~
longjmp-demo.c:8:3: note: (10) ‘ptr’ leaks here; was allocated at (7)
8 | longjmp(env, 1);
| ^~~~~~~~~~~~~~~
longjmp-demo.c:8:3: note: (11) rewinding from ‘longjmp’ in ‘inner’...
In file included from longjmp-demo.c:1:
longjmp-demo.c:22:7: note: (12) ...to ‘setjmp’ in ‘outer’ (saved at (2))
22 | i = setjmp(env);
| ^~~~~~
или вообще выключить их с помощью -fdiagnostics-path-format=none. Есть также формат вывода JSON.
Все новые диагностики имеют название вида -Wanalyzer-SOMETHING: Мы уже видели -Wanalyzer-double-free и -Wanalyzer-malloc-leak выше. Все эти диагностики включаются, когда включен -fanalyzer, но их можно выборочно отключить с помощью вариантов -Wno-analyzer-SOMETHING (например, с помощью прагм).
Какие новые предупреждения будут?
Наряду с детектированием double-free, проводятся проверки на утечки malloc и fopen:
#include <stdio.h>
#include <stdlib.h>
void test(const char *filename)
{
FILE *f = fopen(filename, "r");
void *p = malloc(1024);
/* do stuff */
}
$ gcc -c -fanalyzer leak.c
leak.c: In function ‘test’:
leak.c:9:1: warning: leak of ‘p’ [CWE-401] [-Wanalyzer-malloc-leak]
9 | }
| ^
‘test’: events 1-2
|
| 7 | void *p = malloc(1024);
| | ^~~~~~~~~~~~
| | |
| | (1) allocated here
| 8 | /* do stuff */
| 9 | }
| | ~
| | |
| | (2) ‘p’ leaks here; was allocated at (1)
|
leak.c:9:1: warning: leak of FILE ‘f’ [CWE-775] [-Wanalyzer-file-leak]
9 | }
| ^
‘test’: events 1-2
|
| 6 | FILE *f = fopen(filename, "r");
| | ^~~~~~~~~~~~~~~~~~~~
| | |
| | (1) opened here
|......
| 9 | }
| | ~
| | |
| | (2) ‘f’ leaks here; was opened at (1)
|
Контроль использования памяти после ее освобождения:
#include <stdlib.h>
struct link { struct link *next; };
int free_a_list_badly(struct link *n)
{
while (n) {
free(n);
n = n->next;
}
}
$ gcc -c -fanalyzer use-after-free.c
use-after-free.c: In function ‘free_a_list_badly’:
use-after-free.c:9:7: warning: use after ‘free’ of ‘n’ [CWE-416] [-Wanalyzer-use-after-free]
9 | n = n->next;
| ~~^~~~~~~~~
‘free_a_list_badly’: events 1-4
|
| 7 | while (n) {
| | ^
| | |
| | (1) following ‘true’ branch (when ‘n’ is non-NULL)...
| 8 | free(n);
| | ~~~~~~~
| | |
| | (2) ...to here
| | (3) freed here
| 9 | n = n->next;
| | ~~~~~~~~~~~
| | |
| | (4) use after ‘free’ of ‘n’; freed at (3)
|
Контроль освобождения указателя не на кучу (heap):
#include <stdlib.h>
void test(int n)
{
int buf[10];
int *ptr;
if (n < 10)
ptr = buf;
else
ptr = (int *)malloc(sizeof (int) * n);
/* do stuff. */
/* oops; this free should be conditionalized. */
free(ptr);
}
$ gcc -c -fanalyzer heap-vs-stack.c
heap-vs-stack.c: In function ‘test’:
heap-vs-stack.c:16:3: warning: ‘free’ of ‘ptr’ which points to memory not on the heap [CWE-590] [-Wanalyzer-free-of-non-heap]
16 | free(ptr);
| ^~~~~~~~~
‘test’: events 1-4
|
| 8 | if (n < 10)
| | ^
| | |
| | (1) following ‘true’ branch (when ‘n <= 9’)...
| 9 | ptr = buf;
| | ~~~~~~~~~
| | |
| | (2) ...to here
| | (3) pointer is from here
|......
| 16 | free(ptr);
| | ~~~~~~~~~
| | |
| | (4) call to ‘free’ here
|
Контроль использования функции, которая, как известно, небезопасна для использования внутри обработчика signal:
#include <stdio.h>
#include <signal.h>
extern void body_of_program(void);
void custom_logger(const char *msg)
{
fprintf(stderr, "LOG: %s", msg);
}
static void handler(int signum)
{
custom_logger("got signal");
}
int main(int argc, const char *argv)
{
custom_logger("started");
signal(SIGINT, handler);
body_of_program();
custom_logger("stopped");
return 0;
}
$ gcc -c -fanalyzer signal.c
signal.c: In function ‘custom_logger’:
signal.c:8:3: warning: call to ‘fprintf’ from within signal handler [CWE-479] [-Wanalyzer-unsafe-call-within-signal-handler]
8 | fprintf(stderr, "LOG: %s", msg);
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
‘main’: events 1-2
|
| 16 | int main(int argc, const char *argv)
| | ^~~~
| | |
| | (1) entry to ‘main’
|......
| 20 | signal(SIGINT, handler);
| | ~~~~~~~~~~~~~~~~~~~~~~~
| | |
| | (2) registering ‘handler’ as signal handler
|
event 3
|
|cc1:
| (3): later on, when the signal is delivered to the process
|
+--> ‘handler’: events 4-5
|
| 11 | static void handler(int signum)
| | ^~~~~~~
| | |
| | (4) entry to ‘handler’
| 12 | {
| 13 | custom_logger("got signal");
| | ~~~~~~~~~~~~~~~~~~~~~~~~~~~
| | |
| | (5) calling ‘custom_logger’ from ‘handler’
|
+--> ‘custom_logger’: events 6-7
|
| 6 | void custom_logger(const char *msg)
| | ^~~~~~~~~~~~~
| | |
| | (6) entry to ‘custom_logger’
| 7 | {
| 8 | fprintf(stderr, "LOG: %s", msg);
| | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| | |
| | (7) call to ‘fprintf’ from within signal handler
|
Наряду и с другими предупреждениями.
Что остаётся сделать?
В существующем виде проверка хорошо работает на малых и средних примерах, но есть две проблемные области, с которыми я сталкиваюсь при масштабировании до реального кода на Си.
Во-первых, в моем коде контроля состояний есть ошибки. Внутри чекера есть классы для абстрактного описания состояния программы. Чекер исследует программу, строя направленный граф пар (точка, состояние) с логикой упрощения состояния и слияния состояний в точках соединения потока управления.
Теоретически, если состояние становится слишком сложным, проверяющий должен перейти в наименее определенное состояние, но при таком подходе возникают ошибки, приводящие к взрыву числа состояний в заданной точке, что затем приводит к тому, что проверяющий работает медленно, в конце концов, достигая предела безопасности, и не исследует программу полностью. Чтобы исправить это, я переписал кишки кода управления состоянием. Надеюсь, что на следующей неделе перепишу master.
Далее, даже если мы полностью исследуем программу, пути через код, генерируемый анализатором -fanalyzer, иногда бывают абсурдно многословны. Самое худшее, что я видел, это путь из 110 событий для использования неинициализированных данных, сообщаемых при компиляции самого GCC. Я думаю, что это ложноположительное срабатывание, и очевидно, что неразумно ожидать от пользователей, что они пройдут через что-то подобное.
Анализатор пытается найти кратчайший возможный путь через граф (точка, состояние), генерирует из него цепочку событий, а затем пытается упростить эту цепочку. Фактически, он применяет серию peephole оптимизаций к цепочке событий, чтобы получить минимальную цепочку, которая демонстрирует проблему.
Недавно я реализовал способ фильтрации несущественных ребер потока управления из пути, который должен помочь, и работаю над аналогичным патчем для устранения избыточных межпроцедурных ребер.
В качестве конкретного примера я попробовал анализатор на реальной ошибке (пусть и пятнадцатилетней давности) -CVE-2005-1689, уязвимость double-free в krb5 1.4.1. Он корректно идентифицирует ошибку без ложных срабатываний, но на данный момент на выходе stderr 170 строк. Вместо того, чтобы показывать вывод в строке здесь, вы можете посмотреть его по этой ссылке.
Первоначально это было 1187 строк. Я исправлял различные ошибки и реализовывал больше упрощений, чтобы довести его до 170 строк. Частично проблема в том, что free выполняется с помощью макроса krb5_xfree, а код печати пути показывает, как каждый макрос расширяется каждый раз, когда происходит событие внутри макроса. Возможно, в выводе следует показывать расширение макроса только один раз за диагностику. Также первые несколько событий в каждой диагностике — это межпроцедурная логика, которая на самом деле неактуальна для пользователя (я работаю над исправлениями этого). С этими изменениями вывод должен быть значительно короче.
Может быть, лучший интерфейс мог бы выдавать отдельный HTML-файл, по одному на предупреждение, и выдавать «заметку» с указанием места расположения дополнительной информации?
Я хочу дать конечному пользователю достаточно информации о предупреждении, но не перегружая его. Есть ли лучшие способы представить это? Дайте мне знать в комментариях.
Как опробовать
GCC 10 появится в Fedora 32, которая должна выйти через пару месяцев.
Для простых примеров кода можно поиграться с новым GCC онлайн на godbolt.org (выберите gcc «trunk» и добавьте -fanalyzer в опции компилятора).
Удачи!
Далее добавлено переводчиком из форумов.
Dlang, комментарии Уолтера Брайта на Hacker News
Это соответствует продвижению [моей] идеи сделать для D по умолчанию @ safe, и реализовать систему Владения/Заимствования @ live.
Либо бы вскочим на этот автобус, либо он нас переедет.
Double-free's можно отслеживать, выполняя анализ потока данных (DFA) в функции. Именно так D делает это в своей зарождающейся реализации системы владения/заимствования. Это можно сделать и без DFA, получив только 90% правильных результатов и имея множество ложных срабатываний.
В прошлом я использовал много статических чекеров, и процент ложных срабатываний был достаточно высок, чтобы отказаться от их использования. Вот почему D использует DFA, чтобы дать 100% положительных сигналов при 0% ложных срабатываний (Прим.пер. здесь имеется в виду, что все обнаруженные утечки — 100% утечки, а не то, что отлавливаются 100% всех возможных). Я знал, что это будет возможно, потому что компиляторы использовали DFA при проходе оптимизации.
Чтобы отслеживание заработало, нельзя просто отслеживать события для функции, называемой «free». В конце концов, обычное дело — писать свои собственные аллокаторы памяти, и компилятор не будет знать, что это такое. Следовательно, должен быть какой-то механизм, чтобы сообщить компилятору, когда параметр функции типа указатель «потребляется» вызываемой функцией, и когда он просто «одолжен» ей (отсюда и номенклатура системы «Владелец/заемщик»).
Одна из трудностей, которую можно преодолеть с помощью D, заключается в том, что существует несколько сложных семантических конструкций, которые необходимо разбить на их компонентные операции с указателями. Я заметил, что Rust упростил эту проблему, упростив язык :-).
Но как только это сделано, оно работает, и работает удовлетворительно хорошо.
Обратите внимание, что ничто из этого не является критикой того, что делает GCC 10, потому что в статье не хватает подробностей, чтобы сделать обоснованные выводы. Но я рассматриваю это как часть общей тенденции, что люди устали от ошибок безопасности памяти в языках программирования, и очень приятно видеть прогресс на всех фронтах.
Комментарий Тимона Гера для понимания D из форума обсуждения на Dlang.org
@ live не является системой владения/заимствования, хотя она действительно основывается на концепциях, связанных с владением и заимствованием.
Система собственности/заимствования навязывает семантику собственности в коде @ safe, @ live — нет. Это только линтер для @ system и @ trusted кода без гарантий безопасности.