Введение

В цикле данных статей будет описываться подход к безопасному программированию на Си, будут представлены минимальные сведения об инструментах проверки кода и будут приведены примеры типовых ошибок.

Основная аудитория - студенты первых курсов технических ВУЗов.

Работа с целыми числами

Компилируем правильно

Как делаем обычно

Компиляция программы это процесс получения из исходного кода исполняемого файла.

Пример типовой команды компиляции:

gcc main.c

Где main.c ваш файлик с исходным кодом.

Если вы выполнили эту команду и компилятор ничего Вам не написал, то с небольшой вероятностью банальных ошибок вы избежали. В целом это хорошая история, значит Ваша программа смогла собраться в исполняемый файл с именем по умолчанию a.out.

Но бывают ситуации, когда компилятор во время компиляции может вывести сообщение.

Данное сообщение может быть двух видов:

  1. Warning (предупреждения) - компилятор предупреждает Вас, что вы скорее всего делаете что-то неправильно (небезопасно), но скомпилирует.
    Настоятельно НЕ рекомендуется их игнорировать на первых этапах программирования;

  2. Error (ошибка) (подсвечивается красным) - Вы где-то сильно ошиблись, причем так, что это не дает скомпилировать программу. Скорее всего ошибка кроется в синтаксисе языка.
    Как правило при ошибке компилятор в явном виде укажет участок кода, в котором находится ошибка. Тут придется все исправлять, иначе исполняемый файл Вы не получите.

Как нужно делать правильно

Во-первых, компилятор ВАШ ДРУГ. В нем есть встроенный синтаксический и (немного) статический анализаторы кода. Т.е. на этапе компиляции он анализирует исходный код. Если уже на данном этапе была получено предупреждение, то на это стоит обратить внимание.

Но стандартные паттерны определения небезопасных конструкций достаточно ограничены.
Чтобы расширить их добавьте два флага при компиляции

  1. -Wall (short for "all warnings") включает все стандартные предупреждения компилятора. Этот флаг полезен, чтобы быть уверенным, что все потенциально проблемные участки кода будут выявлены.
    Например, флаг -Wall может предупредить о неиспользуемых переменных, неинициализированных переменных, некорректных вызовах функций и так далее.

  2. -Wextra (short for "extra warnings") включает дополнительные предупреждения, кроме тех, которые включает флаг -Wall.
    Этот флаг активирует дополнительные предупреждения компилятора, такие как предупреждение о неиспользуемых аргументах функций, предупреждение о несоответствии типов указателей, предупреждение о сравнении разных типов и так далее.

  3. -Wpedantic (short for "pedantic warnings") включает предупреждения связанные с соблюдением стандарта ISO C. Он проверяет форматирование и использование нежелательных раширений этого стандарта.
    Данный флаг не влияет на безопасность, но упомянуть о нём стоило.

Т.е. Ваша команда компиляции начинает выглядеть вот так:

gcc -Wall -Wextra main.c

Это приводит к большему выводу ошибок и предупреждений, что хорошо - вы УЖЕ нашли ошибку и можете её исправить, а не попали на неё во время сдачи кода и судорожно пытаетесь исправить.

Пример предупреждения

Пусть у нас есть код простейшей программы:

#include <stdio.h>

int main() {
    int a = 5;
    printf("%f", a);
    return 0;
}

В данном кусочке кода намеренно допущена ошибка, связанная с типом данных в идентификаторе формата при вызове printf().
При обычной компиляции командой:

gcc 1.c

Компилятор не выдаст никаких ошибок и предупреждений.
Но стоит добавить пару флагов:

gcc -Wall -Wextra 1.c

Как вывод компилятора меняется:

1.c: In function 'main':
1.c:5:14: warning: format '%f' expects argument of type 'double', but argument 2 has type 'int' [-Wformat=]
    5 |     printf("%f", a);
      |             ~^   ~
      |              |   |
      |              |   int
      |              double
      |             %d

Наглядно видно, что компилятор на пятой строке файла 1.с нашел конструкцию, которая с большой долей вероятности некорректна. Но в тоже время, исполняемый файл был успешно скомпилирован.

Пример ошибки

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

#include <stdio.h>

int main() {
    int a = 5;
    printf("%f", a)
    return 0;
}

Вывод компилятора даже при обычной компиляции подсказывает нам, что на пятой строке ожидался символ точки с запятой.

1.c: In function 'main':
1.c:5:20: error: expected ';' before 'return'
    5 |     printf("%d", a)
      |                    ^
      |                    ;
    6 |     return 0;
      |     ~~~~~~

Что добавить до идеала

Выше было указано, что нужно исправлять даже предупреждения, поскольку код не такой сложный.
Сделаем это на уровне флага - добавим флаг -Werror. Этот флаг заставляет компилятор воспринимать все предупреждения как ошибки.
Т.е. даже с предупреждением код не скомпилируется.
Итоговая команда компиляции кода:

gcc -Wall -Wextra -Werror main.c

Числа, цифры и операции с ними

В ходе первых лабораторных работ студенты зачастую оперируют только целыми числами.
Основные ошибки при работе с ними - переполнение типа данных и деление на ноль.

Про переполнение разрядной сетки

Пусть у нас имеется знаковое число размером 1 байт (8 бит).

Тогда представление числа 127 будет выглядеть так:

0111 1111

0 - старший бит и он отвечает за знак.
0 - положительное, 1 - отрицательное.

Если сделать 127 + 1, то получится не 128, а -128.

Почему?

Потому что старший бит отвечает за знак, остальные за само число.:

0111 1111
0000 0001
---------
1000 0000

Как определить переполнение

Использовать флаг компилятора -ftrapv.

Этот флаг встраивает специальные ассемблерные инструкции в Ваш код, которые проверяют числовые типы данных после арифметических операций.

Если что-то пошло не так, то система завершит Вашу программу с надписью "Abort" (определит переполнение типа данных и не дожидаясь проблем пошлет сигнал SIGABRT).

Пример срабатывания -ftrapv

Имеем следующий код:

#include <stdio.h>
#include <limits.h>

int main() {
    int a = INT_MAX;
    int b = a * a;
    printf("%d", b);
    return 0;
}

Компилируем:

gcc -ftrapv main.c

В итоге, при запуске вывод будет следующим:

bash: Job 1, './a.out' terminated by signal SIGABRT (Abort)

При перехвате сигнала SIGABRT (Abort) будет создан core файл, содержащий информацию о дампе процесса.
Данный файл можно передать в отладчик и получить необходимую информацию об ошибке.

Но, в явном виде никакой дополнительной диагностической информации выведено не будет. Просто сам факт наличия ошибки.

Как отладить?

Гораздо удобнее использовать санитайзер кода UBSAN.
Undefined Behavior Sanitizer - санитайзер, который определяет неопределенное поведение, например переполнение и деление на ноль. Это инструмент, который встраивается в исполняемый файл на этапе компиляции и при обнаружении ошибки прерывает выполнение программы, а также выдает отладочную информацию.

Важно помнить, что санитайзеры работают в динамике, т.е. чтобы среагировать на ошибку, вы должны выполнить уязвимый участок кода.

Как его использовать?
На этапе компиляции необходимо добавить флаги:

-g - флаг, подключающий отладочную информацию. При срабатывании санитайзера, благодаря данному флагу, в отладочной информации отобразится номер строки исходного кода, на которую сработал санитайзер. Без данного флага вместо номера строки с небезопасной конструкцией будет отображаться её адрес в памяти.

-fsanitize=undefined - флаг санитайзера

Итого, команда компиляции:

clang -g -fsanitize=undefined main.c 

С -ftrapv смешивать не стоит.

Для более комфортной работы сменим компилятор на clang. Остальные флаги идентичны флагам gcc.

Пример

Есть следующий код:

#include <limits.h>
#include <stdio.h>

int main() {
    int max_value = INT_MAX;
    int reuslt = max_value * max_value;
    printf("%i", max_value);
    return 0;
}

Компилируем и запускаем:

gcc int_overflow.c
./a.out

Получаем число без ошибок. Компилятор не выдал никаких ошибок при компиляции, работа программы завершилась без ошибок. Т.е. о некорректной работе разработанной программы вы узнаете только по контексту во время эксплуатации.

А если так:

clang -g -fsanitize=undefined int_overflow.c
./a.out

Вот вывод:

int_overflow.c:6:28: runtime error: signed integer overflow: 2147483647 * 2147483647 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior int_overflow.c:6:28 in 

int_overflow.c:6:28 - на шестой строке файла int_overflow.c произошло переполнение типа данных int.

Далее описание того, на каких значениях пошли проблемы.

На второй строке вывода - типизация ошибки. В нашем случае это "неопределенное поведение".

Деление на ноль

Как определить? Также с помощью санитайзера UBSAN.

Пример кода:

#include <stdio.h>

int main() {
    int a = 5;
    int b = 0;
    int result = a / b;  // деление на ноль
    printf("result: %d\n", result);
    return 0;
}

Команда компиляции:

clang -g -fsanitize=undefined main.c -o bin_ubsan

С помощью флага -o мы указали имя исполняемого файла, который появится после компиляции bin_ubsan.
Запускаем исполняемый файл и поучаем примерно такой вывод:

div_zero.c:6:20: runtime error: division by zero
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior div_zero.c:6:20 in
UndefinedBehaviorSanitizer:DEADLYSIGNAL
==30184==ERROR: UndefinedBehaviorSanitizer: FPE on unknown address 0x55defd77936b (pc 0x55defd77936b bp 0x7ffe5c5ff170 sp 0x7ffe5c5ff150 T30184)
    #0 0x55defd77936b in main /home/users/klavishnik/2023/sanitizers-examples/ubsan/div_zero/div_zero.c:6:20
    #1 0x7f7fed8f720b in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    #2 0x7f7fed8f72bb in __libc_start_main@GLIBC_2.2.5 csu/../csu/libc-start.c:381:3
    #3 0x55defd74a310 in _start csu/../sysdeps/x86_64/start.S:115

UndefinedBehaviorSanitizer can not provide additional info.
SUMMARY: UndefinedBehaviorSanitizer: FPE /home/users/klavishnik/2023/sanitizers-examples/ubsan/div_zero/div_zero.c:6:20 in main
==30184==ABORTING

Не стоит пугаться такого ёмкого вывода.
На что стоит сразу обратить внимание, так это на саму первую строку.
Там дан тип ошибки "division by zero" и на какой строке какой файла эта операция произошла (div_zero.c:6:).

Пожалуй, на этом этапе стоит ввести термин трассы выполнения программы.
По ней может отследить место, на котором произошла ошибка. Она содержит в себе адреса (строки в исходном коде или адреса в исполняемых файлах) вызова функций. Последовательность вызовов и сформирует трассу работы вашей программы. Если вы "поймаете" ошибку, то последний вызов трассы будет указывать на проблемный участок кода.

В данном примере трасса состоит из 4 строк и начинается она снизу (т.е. с строки #3).
Три нижние строки содержат системные вызовы и внутренние функции из glibc, которые нам не интересны. Стоит сразу обратить внимание на строку #0, поскольку именно там начинается работа с собственными файлами.

#0 0x55defd77936b in main /home/users/klavishnik/2023/sanitizers-examples/ubsan/div_zero/div_zero.c:6:20

Здесь присутствуют подробные сведения об ошибке: выведен путь до файла, который содержит ошибку, а также указан номер строки, которая вызвала срабатывание санитайзера.

Переполнение на вводе

У многих возникает вопрос, как отслеживать переполнение на вводе.
Например, когда с помощью scanf() пытаемся записать в int число, большее чем MAX_INT.
Начнем с того, что "переполнение на вводе" это вообще некорректный термин.
Переполнение типа данных может возникнуть только при каких-то манипуляциях над переменными (например, инкрементация числового типа).
Такие переполнения можно ловить с помощью санитайзера UBSAN или флага компиляции -ftrapv.

Как появляется?

При вводе числа большего, чем диапазон выбранного типа данных (например, int), оно просто откинет лишние биты числа.
Например, целочисленный тип данных int на x86_64 размером 32 бита. Если ввести, например, 33 битное число, старшая часть откинется.

Пример. Есть число:

(dec) 123 456 789 123
(bin) 0001 1100 1011 1110 1001 1001 0001 1010 1000 0011

Это число занимает 5 байт, в int влезет всего 4.
Т.е. старшие 8 бит откидываются и в переменную у нас запишется число:

(bin) 1011 1110 1001 1001 0001 1010 1000 0011
(dec) 1 050 221 187

Как видим, здесь даже не изменился знак числа.
Т.е. отследить это будет крайне сложно.

А как scanf данные получает?

В системе есть несколько потоков - поток ввода (stdin), вывода (stdout) и ошибок (stderr).
Поток ввода берет данные со стандартного устройства ввода (по умолчанию это клавиатура) и хранит его в буфере из которого уже scanf() примет данные.

В таком случае встают два вопроса:

  1. А что, если мы не смогли считать данные за один раз, значит ли что оставшиеся данные будут храниться в буфере stdin?
    Ответ - да.

  2. Значит ли это, что мы может вызвать функцию scanf() дважды, чтобы она забрала из буфера оставшиеся данные?
    Ответ - нет. Второй scanf() заставит дописать данные в буфер ввода, что затрёт старый набор данных, который хранился в нём.

А как избежать потери данных?

Существуют два способа:

  1. Объявите переменную бОльшего диапазона данных, через которую будете контролировать данные.
    Например, для int создайте вторую переменную с типом данных long int и записывайте ввод в неё.
    Далее, через обычный if контролируйте введённое число. Если оно больше диапазона int'a - выкидывайте ошибку. Меньше - продолжайте работу.
    Этот способ не убережет Вас от ситуации, когда будет введено число, превышающее диапазон long int.

  2. Использовать спецификатор ввода.
    Конструкция scanf("%5i",&input); считает только 5 символов.
    Не забудете написать предупреждение для пользователя.

Релиз - отдельно, тестирование - отдельно

Важно помнить, что добавление специальных флагов компиляции (-ftrapv) или санитайзеров увеличивает размер скомпилированного файла и замедляет скорость его работы из-за выполнения проверок.

Поэтому необходимо делать отдельно Debug и Release сборки.

  1. Debug-сборка выполняется с включением специальных флагов компиляции / санитайзеров. На ней же выполняются все тесты и проверки.

  2. Release-сборка компилируется без доп.проверок, и, как правило, включает флаги компиляции, направленные на оптимизацию кода. Она же и передается конечному пользователю для дальнейшего использования.

UPD: Статья была обновлена. Спасибо всем, кто указал на ошибки.
Отдельная благодарность @berez за конструктивную критику и предложения по улучшению статьи.

Комментарии (24)


  1. SalazarMAX
    18.10.2023 11:42
    +4

    Безопасный код должен начинаться с указания void вместо списка аргументов для функций, которые не принимают аргументов, а в ваших примерах этого не наблюдается.


    1. SuperTEHb
      18.10.2023 11:42
      -1

      В одном случае функция может не принимать аргументов, а во втором не может принимать аргументов. Это же разные вещи.


      1. includedlibrary
        18.10.2023 11:42
        +5

        Вообще, если не указать void, значит функция может принимать сколько угодно параметров.

        Этот пример компилируется:

        #include <stdio.h>
        
        static void print_hello() {
            puts("Hello World");
        }
        
        int main(void) {
            print_hello();
            print_hello("lol", "kek");
        }

        clang выводит предупреждение:

        test.c:9:29: warning: too many arguments in call to 'print_hello'
            print_hello("lol", "kek");
            ~~~~~~~~~~~             ^
        1 warning generated.

        До gcc пока не добрался, но, по-моему, он не выводит предупреждение в таких случаях.

        Если же добавить void, пример не скомпилируется:

        test.c:9:17: error: too many arguments to function call, expected 0, have 2
            print_hello("lol", "kek");
            ~~~~~~~~~~~ ^~~~~~~~~~~~
        test.c:3:13: note: 'print_hello' declared here
        static void print_hello(void) {
                    ^
        1 error generated.


      1. includedlibrary
        18.10.2023 11:42

        Я добрался до gcc, он и правда не выводит предупреждение, если не указать void вместо списка аргументов, причём даже с флагами -Wall и -Wextra. У меня, если что, gcc версии 13.2.1


        1. shovdmi
          18.10.2023 11:42

          /* объявление функции без аргументов */
          int no_args();
          /* тоже объявление функции без аргументов */
          int no_args(void);

          Первый вариант означает, что тип и кол-во аргументов не определены (можно писать no_args(1,2,3)), второй вариант означает, что аргументов у функции нет. Первый вариант можно отнести к быдлокоду, он ещё жив, полагаю, для совместимости...

          https://habr.com/ru/companies/badoo/articles/503140/comments/#comment_21653930


    1. equeim
      18.10.2023 11:42

      Или можно компилировать с `-std=c2x` :)


  1. monsterooovich
    18.10.2023 11:42

    А ещё можно юзать умные указатели в сишке вот с этой библиотекой.

    https://github.com/Snaipe/libcsptr

    Правда там нужен компилятор с поддержкой определённых GNU расширений (по сути поддержка деструкторов).


    1. Vladislav_Dudnikov
      18.10.2023 11:42

      Кроме gcc ещё clang вроде их поддерживает.


      1. funny_falcon
        18.10.2023 11:42

        Даже TinyCC поддерживает. Правда, только в транке, и только в качестве синтаксического сахара (т.е. на unwind выполнятся не будут).


  1. kozlyuk
    18.10.2023 11:42
    +11

    Старший бит отвечает за знак, остальные за само число.

    И тут студент получает контрольный вопрос в голову: почему 1 0000000₂ — это (-128), а не (-0).


    1. LAutour
      18.10.2023 11:42
      +2

      Если учили, что такое числа в ПК, ОК, и ДК - то можно догадаться.


  1. dmitryrf
    18.10.2023 11:42
    +1

    Можно ещё вот тут про флаги компилятора почитать https://interrupt.memfault.com/blog/best-and-worst-gcc-clang-compiler-flags


  1. DraugerVan
    18.10.2023 11:42
    +4

    Ошибка:

    0 - это старший байт и он отвечает за знак.

    Не байт, а бит.


  1. stdlib_h
    18.10.2023 11:42

    Undefined Santizer - Санитайзер, который...

    Слово Behavior "выпало":

    UndefinedBehaviorSanitizer, a fast undefined behavior detector

    https://gcc.gnu.org/onlinedocs/gcc-13.2.0/gcc/Instrumentation-Options.html#index-fsanitize_003dundefined

    Кстати, в первый раз увидел в тексте документации GCC ссылку на llvm.org.


    1. Alytona
      18.10.2023 11:42

      У Вас scanf написан как sacnf.
      "А как sacnf данные получает?"


  1. domix32
    18.10.2023 11:42
    +2

    Безопасное программирование на Си

    Самый безопасный способ заниматься безопасным программированием на Си - не писать на Си. Отсутвие честных типов делает задачу фактически невыполнимой. Если не следовать какому-нибудь MISRA-гайдлайну и не торчать постоянно в статическом анализаторе - безопасный код на Си будет являться тавтологией.


    1. includedlibrary
      18.10.2023 11:42

      Не писать на си иногда не вариант, я вот понял, что всякую низкоуровневую фигню ни на плюсах, ни на расте я писать не желаю. Можно, кстати, упороться и писать доказуемо корректные программы на си. А тавтология - это использование одного и того же слова в предложении, разве нет?


      1. domix32
        18.10.2023 11:42

        попробуйте zig или odin. у первого говорят даже интероперабельность с си максимально приятная.

        Доказуемо корректные программы тоже не совсем про безопасность - вполне можно написать доказуемо корректный эксплойт.

        Да, с тавтологией это я глупость сморозил. Там какое-то другое слово должно было быть.


        1. Vladislav_Dudnikov
          18.10.2023 11:42
          +1

          Zig показался прикольным, с перспективами, как мне кажется. Есть ещё c2lang - похож на Си, но с методами и некоторыми дополнительными приятными фишками (типа моделей, публичные/приватные функции), недавно компилятор для c2lang даже переписали на сам c2lang, у него полная совместимость с Си и портировать либы можно очень быстро (вроде его даже можно компилировать в Си).

          А про тавтологию, может "оксюморон" имели ввиду?


        1. includedlibrary
          18.10.2023 11:42
          +1

          попробуйте zig или odin

          Пока zig не выйдет в релиз, смотреть на него особо не хочется. В последний раз я столкнулся с тем, что мне приходилось вместо документации читать исходники, потому что в документации нужной мне информации не было. А вот на odin надо будет посмотреть


  1. Kosorukiy_Shiva
    18.10.2023 11:42

    Спасибо за статью, привет с МГТУ СМ, а будет ли в будущем статья на тему форматирования вывода scanf и printf , а также указатели?


    1. Klavishnik001 Автор
      18.10.2023 11:42

      Здравствуйте.
      В будущем планировалась статья, посвященная расширению подхода к выявлению ошибок в том числе при работе с памятью, а именно: вывод компилятора -> (описано в данной статье) -> стат.анализ средствами clang -> динамический анализ средствами ASan, MSan \ Valgrind -> сбор покрытия.
      Также планирую рассмотреть типовые ошибки при работе с массивами и памятью.
      Возможно будет переход к фаззинг тестированию

      Про форматированной ввод/вывод планов не было, этому посвящен отдельный абзац статьи. А что именно Вам интересно?

      P.s. Статья писалась для студентов МИФИ, рад, что студентов МГТУ она тоже заинтересовала)


    1. includedlibrary
      18.10.2023 11:42

      scanf вообще лучше не использовать. А какие могут быит проблемы с printf, если сейчас компиляторы выводят предупреждения о несоответствии заявленного формата вывода и переданных аргументов?


      1. Klavishnik001 Автор
        18.10.2023 11:42

        Чем Вас не устраивает scanf для ввода целых чисел? Тем более в лабораторных работах первого курса?