Pull to refresh

Безопасное программирование на Си. Часть 1

Level of difficultyEasy
Reading time9 min
Views14K

Введение

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

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

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

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

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

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

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

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 за конструктивную критику и предложения по улучшению статьи.

Tags:
Hubs:
Total votes 18: ↑12 and ↓6+14
Comments26

Articles