Статический анализ в GCC 10

от автора

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

ссылка на оригинал статьи https://habr.com/ru/post/496278/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *