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