
Я работаю в 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 кода без гарантий безопасности.
