Почему программы на Си больше нельзя сломать «по методичкам из нулевых»: разбираем переполнение буфера на 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 и статические анализаторы кода.
Просмотры: 17