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

ff19f7e7a24e5d3a6b8ea098f2c965a5

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

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

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

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

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

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

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

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.
    Этот флаг активирует дополнительные предупреждения компилятора, такие как предупреждение о неиспользуемых аргументах функций, предупреждение о несоответствии типов указателей, предупреждение о сравнении разных типов и так далее.

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

gcc -Wall -Wextra main.c

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

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

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

#include 

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 

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).

Никакой дополнительной диагностической информации.

Просто сам факт наличия ошибки.

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

gcc -Wall -Wextra -Werror main.c -ftrapv

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

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

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

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

-g — флаг, подключающий отладочную информацию.

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

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

clang -g -fsanitize=undefined main.c 

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

Используем компилятор clang. Флаги идентичны флагам gcc.

Пример

Есть вот такой код

#include 
#include 

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 

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).
Три нижние строки содержат системные вызовы, которые нам не интересны. Стоит сразу обратить внимание на строку #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 максимальное количество цифр, которое можно ввести — 10. Если вы введете 12, то старшая часть числа (в двоичной системе счисления) откинется.

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

(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

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

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

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

Вопрос — если мы не смогли считать данные за один раз, значит ли что оставшиеся данные будут храниться в буфере stdin?

Ответ — да.

Вопрос — значит ли, что мы может вызвать функцию scanf() дважды, чтобы она забрала из буфера оставшиеся данные?

Ответ — нет. Второй scanf() заставит дописать данные в буфер. Это затрет старый набор данных, который хранился в буфере ввода.

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

Есть два способа:

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

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

© Habrahabr.ru