Почему программы на Си больше нельзя сломать «по методичкам из нулевых»: разбираем пере...
Почему программы на Си больше нельзя сломать «по методичкам из нулевых»: разбираем переполнение буфера на ARM64 Когда новички приходят в тему бинарных уязвимостей, классический Buffer Overflow (переполнение буфера) часто преподносят как простую формулу: залил в строку 100 символов, перезаписал адрес возврата и получил шелл. Сегодня я решил развернуть лабораторный стенд в Parrot Security OS на архитектуре ARM64, написать уязвимый микросервис на Си и разобрать на реальных регистрах процессора, почему в современных системах всё устроено значительно глубже и надежнее. Делюсь выводами и наглядными логами из терминала. 1. В чем корень проблемы? В коде на Си классическая функция strcpy() не проверяет длину входящих данных. Если я выделяю под строку локальный массив размером 64 байта, а пользователь передает 80, лишние байты физически выходят за границы массива и начинают затирать соседние данные на стеке: сохраненный указатель фрейма и адрес возврата из функции. 2. Эксперимент №1: намеренно отключаем все щиты Чтобы увидеть механику без помех, я скомпилировал файл с отключенными защитами компилятора (-fno-stack-protector -z execstack -no-pie). Утилита checksec сразу подсветила мне беззащитный бинарник (первый скриншот): - No canary found - нет контроля целостности стека; - NX disabled - стек исполняемый; - No PIE - адреса функций зафиксированы и не меняются. Я рассчитал смещение и попытался направить поток выполнения на скрытую функцию по адресу 0x40070c. И вот тут сработала аппаратная специфика (второй скриншот): Процессор выбил ошибку SIGSEGV на странном адресе 0x0000ffff0040070c. Почему так произошло? В 64-битной архитектуре адрес функции содержит нулевые байты (\x00). Функция strcpy честно скопировала первые байты пейлоада, наткнулась на ноль и посчитала строку завершенной. В итоге младшая часть адреса перезаписалась, а старшая осталась от старого системного адреса libc (0xffff...). Программа упала в сегфолт, доказав: бинарные данные и строковые функции Си фундаментально несовместимы. 3. Эксперимент №2: компилируем по современным стандартам Дальше я пересобрал тот же самый исходник так, как собираются пакеты в современных дистрибутивах Linux: gcc -O2 -fstack-protector-strong -D_FORTIFY_SOURCE=2 -o target_hardened target.c Смотрим в checksec (третий скриншот): - Включена канарейка (Canary found); - Стек закрыт для исполнения кода (NX enabled); - Включена рандомизация адресов (PIE enabled); - Активирован динамический контроль функций (FORTIFY: Yes). Подаю ту же самую длинную строку - и программа мгновенно прерывает выполнение: *** buffer overflow detected ***: terminated (Aborted) Никакого неконтролируемого падения и никаких шансов добраться до управления регистрами. 4. Взгляд под капот: ассемблер AArch64 (четвертый скриншот) Самое интересное видно при дизассемблировании скомпилированного файла (objdump -d): Компилятор еще на этапе сборки вычислил, что размер буфера равен ровно 64 байтам. Он автоматически вырезал небезопасный вызов strcpy и заменил его на библиотечную проверку __strcpy_chk: - Строка mov x2, #0x40 - жестко заносит лимит буфера (64 в шестнадцатеричной системе) в служебный регистр; - Строка bl <__strcpy_chk@plt> - вызывает проверку границ до того, как байты попадут в память. Если длина ввода превышает 64 байта, программа не ждет перезаписи адресов, а немедленно завершает процесс через аварийный сигнал. Резюме для практики Этот разбор наглядно показывает, почему безопасность создается на стыке кода и компилятора: 1. Заменяйте небезопасные функции (strcpy, sprintf, gets) на строгие аналоги с контролем размера буфера (snprintf, strlcpy). 2. Никогда не отключайте базовые флаги защиты компилятора в продакшене. 3. Проверяйте результирующие бинарники через checksec и статические анализаторы кода.