Раздел 32 · Системное программирование: Zig, ассемблер, Verilog

Переполнение буфера и защиты от него

senior~55 мин

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

Переполнение буфера и защиты от него

В прошлом уроке адресная арифметика аккуратно попадала в нужный элемент массива. Сегодня та же арифметика уходит за его конец. У массива в машине нет ни длины рядом, ни забора по краю: если запись идёт по индексу, который никто не проверил, байты просто ложатся в соседние слова кадра. А над буфером на стеке лежит адрес возврата. Затереть его значит увести управление в чужое место. Это классическая тема главы 3.10: как работает переполнение буфера, почему оно десятилетиями кормило атаки, и чем его в итоге остановили. Всё покажем на своём бинарнике в песочнице.

Цели урока

  • Понять, что запись за конец массива на стеке перетекает в соседние слова кадра: локальные, сохранённый %rbp, адрес возврата.
  • Снять реальный дамп кадра своей функции и измерить расстояние от буфера до адреса возврата.
  • Объяснить, почему в стандартной библиотеке Zig нет функции вроде gets, и какую подпись вынужденно принимает безопасное чтение строки.
  • Увидеть, как ReleaseSafe вставляет проверку границы и превращает переполнение в панику, а ReleaseFast её снимает, и картина становится как в C.
  • Разобрать механику подмены адреса возврата на своей же программе, без готового эксплойта под чужие цели.
  • Прочитать защиты: канарейку на стеке, ASLR, неисполняемую память NX, и понять, почему nop-салазки как исторический приём закрываются связкой защит.
  • Снять стековую трассу через std.debug.dumpCurrentStackTrace и прочитать по ней цепочку вызовов.

Идея: запись за конец массива перетекает в кадр

Массив на стеке это просто несколько байт подряд, выделенных в стековом кадре функции. Рядом, в том же кадре, лежат другие локальные переменные, сохранённые регистры и, чуть выше, адрес возврата, который положила инструкция call. Пока запись идёт по индексу от нуля до длины массива, всё аккуратно. Но массив не хранит свою длину рядом с собой, о чём был урок про массивы и выравнивание: есть только адрес начала и уговор о размере элемента. Если цикл пишет дальше, чем помещается, процессор не возражает. Он кладёт байты по формуле base + i * size и для i за границей, затирая то, что там оказалось.

А оказывается там самое ценное. Стек растёт вниз, к младшим адресам, а запись в массив идёт вверх, к старшим. Значит, переполняя буфер, мы ползём ровно в сторону сохранённого %rbp и адреса возврата. Сначала гибнут соседние локальные, потом база кадра, потом адрес возврата. Как только затёрт он, функция на выходе прыгнет не туда, откуда её позвали, а туда, что мы записали. В этом вся суть атаки, которую книга разбирает в главе 3.10.

Кадр echo: что лежит над буфером

Чтобы это не осталось словами, снимем дамп настоящего кадра. Встроенная функция @frameAddress() возвращает значение %rbp внутри текущего кадра, а &buf даёт адрес буфера. По соглашению о вызовах адрес возврата лежит по %rbp + 8, сразу над сохранённым %rbp. Вычтем одно из другого и узнаем, сколько байт отделяет начало буфера от адреса возврата.

// frame.zig
const std = @import("std");

var sink: u8 = 0;

const Frame = struct { buf: usize, ret_slot: usize, distance: usize };

fn echo(src: []const u8, out: *Frame) void {
    var buf: [8]u8 = undefined;
    const rbp = @frameAddress(); // значение %rbp внутри кадра echo
    out.buf = @intFromPtr(&buf);
    out.ret_slot = rbp + 8; // адрес возврата лежит по %rbp + 8
    out.distance = (rbp + 8) - @intFromPtr(&buf);
    const dst: [*]u8 = &buf; // many-pointer: запись без проверки границ
    for (src, 0..) |c, i| dst[i] = c;
    sink = buf[0];
}

pub fn main() void {
    var f: Frame = undefined;
    echo("hi", &f);
    std.debug.print("&buf = 0x{x}\nret slot = 0x{x} (rbp+8)\nbuf -> ret = {d} байт\n", .{ f.buf, f.ret_slot, f.distance });
}

Соберём под Linux x86-64 и запустим в песочнице:

$ zig build-exe frame.zig -O ReleaseFast -fno-omit-frame-pointer -target x86_64-linux-gnu
$ ./frame
&buf = 0x7ffffffffc10
ret slot = 0x7ffffffffcb8 (rbp+8)
buf -> ret = 168 байт

Прочитай числа. Буфер на восемь байт, а до адреса возврата от его начала целых 168 байт. Откуда столько, если буфер такой маленький? Между буфером и адресом возврата компилятор разложил другие локальные, временные значения и выравнивание кадра. Точное расстояние зависит от сборки: у тебя выйдет другое число, но оно стабильно между запусками одной и той же программы. А вот сами адреса скачут от запуска к запуску:

$ ./frame
&buf = 0x7ffffffffb30
ret slot = 0x7ffffffffbd8 (rbp+8)
buf -> ret = 168 байт

Адреса другие, расстояние то же. Скачут они из-за ASLR, к которому мы вернёмся в разделе про защиты. Запомни главное: над буфером, на фиксированном расстоянии, лежит адрес возврата, и запись за конец буфера доберётся до него, если её никто не остановит. Виджет в конце урока рисует этот кадр в учебной «тесной» раскладке, где буфер и адрес возврата стоят вплотную. В реальном кадре, как видишь, между ними гораздо больше байт, но смысл тот же.

Почему в Zig нет gets

Классическая пара из книги это функции gets и echo на C. gets(buf) читает строку со стандартного ввода в буфер и не знает ни слова о его размере: сколько байт придёт, столько и запишет, хоть тысячу в буфер на восемь. Именно поэтому gets давно исключили даже из стандарта C: безопасно пользоваться ей нельзя в принципе, размер буфера ей просто негде взять.

В стандартной библиотеке Zig функции gets нет и быть не может. Чтение в Zig всегда идёт в срез, а срез носит длину с собой, поэтому у любого чтения есть граница, дальше которой оно не пишет. Сравни две подписи. Вот как выглядела бы опасная функция, если её перевести на Zig буквально:

// так написать нельзя: откуда функции знать, где конец buf?
fn echo(buf: [*]u8) void // many-pointer, длины нет

А вот подпись, к которой Zig подталкивает на самом деле: буфер приходит срезом, с длиной, и функция обязана вернуть ошибку, если строка не помещается.

fn goodEcho(src: []const u8, buf: []u8) error{TooLong}![]u8

Разница в типе buf. Голый [*]u8 это просто адрес, писать по нему можно сколько угодно. Срез []u8 это адрес плюс длина, и buf.len всегда под рукой, чтобы проверить границу до записи. Язык не запрещает выстрелить себе в ногу через [*]u8, как мы сделали в дампе кадра выше, но стандартная библиотека так не делает нигде, и тебя тянет за собой. Ровно эту безопасную goodEcho ты напишешь в практике урока.

ReleaseSafe ловит переполнение

Теперь главная линия урока, ради которой мы на Zig, а не на C. Возьмём переполнение через индексацию массива, не через голый указатель:

// oob.zig
const std = @import("std");

var sink: u8 = 0;

fn echo(src: []const u8) void {
    var buf: [8]u8 = undefined;
    for (src, 0..) |c, i| buf[i] = c; // индексируем массив, не [*]u8
    sink = buf[0];
}

pub fn main() void {
    echo("этой строки слишком много для восьми байт");
}

Соберём в ReleaseSafe и запустим:

$ zig build-exe oob.zig -O ReleaseSafe -target x86_64-linux-gnu
$ ./oob
thread 1 panic: index out of bounds: index 8, len 8
echo.zig:7:30: 0x… in echo (oob)
start.zig:698:59: 0x… in callMain (oob)
start.zig:190:5: 0x… in _start (oob)

Переполнения не случилось. На девятом байте, когда i дошло до 8 при длине массива 8, ReleaseSafe поймал выход за границу и остановил программу с паникой, напечатав индекс, длину и стековую трассу. Ни один байт за буфером не был затёрт: проверка стоит до записи. В этом и состоит защита: атака не проваливается на полпути, она не начинается вовсе.

Откуда взялась проверка, видно в ассемблере. Возьмём одну индексную запись в отдельной функции, чтобы оптимизатор не свернул цикл, и снимем её в обоих режимах:

// store.zig
export fn put(buf: *[8]u8, i: usize, c: u8) void {
    buf[i] = c;
}
$ zig build-obj store.zig -O ReleaseSafe -target x86_64-linux-gnu -femit-bin=store.o
$ objdump -d store.o
put:
    cmpq    $0x7, %rsi           # i > 7 ?
    ja      .Loob                # да: индекс за границей [8]u8, паника
    movb    %dl, (%rdi,%rsi)     # buf[i] = c
    retq
.Loob:
    movq    %rsi, %rdi
    movl    $0x8, %esi
    callq   outOfBounds          # паника: индекс i, длина 8

Перед самой записью стоят две инструкции: cmpq $0x7, %rsi сравнивает индекс с 7, а ja (jump if above, беззнаковый переход) уводит в панику, если i больше. Сравнение беззнаковое, поэтому и настоящий выход i >= 8, и отрицательный индекс, который в usize выглядит огромным числом, ловятся одним переходом, ровно как мы видели для проверки границ массива в уроке про массивы. Сама запись movb %dl, (%rdi,%rsi) это уже знакомая масштабированная адресация, она не изменилась.

ReleaseFast снимает проверку

Тот же put в ReleaseFast:

$ zig build-obj store.zig -O ReleaseFast -target x86_64-linux-gnu -femit-bin=store.o
$ objdump -d store.o
put:
    pushq   %rbp
    movq    %rsp, %rbp
    movb    %dl, (%rdi,%rsi)     # buf[i] = c, без всякой проверки
    popq    %rbp
    retq

Проверки нет. Осталась одна запись по вычисленному адресу, и i может быть любым: девять, сто, миллион. Цена безопасности в ReleaseSafe это ровно те два cmp и ja, что мы видели выше, и непройденный переход в горячем коде почти ничего не стоит предсказателю ветвлений. Но в ReleaseFast их нет, и картина становится точно такой же, как в C: запись за конец массива никто не ловит, байты уходят в соседние слова кадра. Тот же oob.zig, собранный в ReleaseFast, не паникует, а тихо затирает стек и ведёт себя непредсказуемо, вплоть до аварийного завершения где-то дальше.

Вот почему выбор режима сборки это выбор модели безопасности. ReleaseSafe держит проверки границ, индексов, переполнения целых и отдаёт за это немного скорости. ReleaseFast снимает их ради последних процентов производительности и возвращает тебя в мир, где за каждую границу отвечаешь ты сам. Для кода, который читает недоверенный ввод, ReleaseSafe это разумная настройка по умолчанию. Та же граница между безопасным и ручным есть и в других языках: в Rust индексация проверяется по умолчанию, а голый доступ к памяти без проверок надо явно затребовать в блоке unsafe, о чём был урок про unsafe в Rust.

Подмена адреса возврата

Покажем теперь саму механику угона управления, на своей же программе и прицельно, без слепого перебора. Мы уже знаем из дампа кадра, что адрес возврата лежит по %rbp + 8. Запишем туда адрес функции, которую никто не звал, и посмотрим, куда уйдёт ret.

// hijack.zig
const std = @import("std");

fn win() void {
    std.debug.print(">>> перехват: управление ушло в win(), которую никто не звал\n", .{});
    std.process.exit(0);
}

noinline fn vuln() void {
    var buf: [8]u8 = undefined;
    // адрес возврата лежит по %rbp + 8, прицельно затираем его адресом win
    const ret_slot: *usize = @ptrFromInt(@frameAddress() + 8);
    ret_slot.* = @intFromPtr(&win);
    std.mem.doNotOptimizeAway(&buf);
}

pub fn main() void {
    vuln();
    std.debug.print("нормальный возврат: сюда мы не попадём\n", .{});
}
$ zig build-exe hijack.zig -O ReleaseFast -fno-omit-frame-pointer -target x86_64-linux-gnu
$ ./hijack
>>> перехват: управление ушло в win(), которую никто не звал

Читай результат внимательно. Функция vuln не звала win. Она лишь записала адрес win в ту ячейку стека, где лежал адрес возврата. Когда дошло до ret, процессор снял с вершины стека это значение и прыгнул по нему, то есть в win. Строка «нормальный возврат» из main не напечаталась вовсе: управление туда уже не вернулось. Это и есть угон потока управления через подмену адреса возврата, сведённый к самой сути.

В настоящей атаке адрес возврата затирают не прицельной записью, а переполнением буфера: злоумышленник подбирает длину и содержимое ввода так, чтобы нужные байты легли точно в ячейку адреса возврата. Мы этот шаг не делаем: нам важна механика, а не рабочий эксплойт под внешнюю цель. Куда он направляет управление, вопрос отдельный. Исторически в буфер клали и сам код, который хотели выполнить, и прыгали в него; этот код называли шеллкодом. Сегодня, как увидишь ниже, связка защит делает этот прямой путь нерабочим.

Кадры переменного размера и %rbp

До сих пор компилятор знал размер кадра на этапе компиляции и выделял его одним sub $N, %rsp в прологе. Но бывает, что размер локального массива известен только в рантайме. Тогда кадр приходится растить динамически, и тут на сцену выходит регистр %rbp как база кадра.

Идея простая. Если размер кадра фиксирован, к локальным можно обращаться прямо от %rsp: вершина стека не двигается внутри функции, смещения постоянны. Но как только внутри функции делается sub на переменную величину, %rsp перестаёт быть надёжной точкой отсчёта: он зависит от размера, которого компилятор не знал. Поэтому в прологе сохраняют старую вершину в %rbp через push %rbp и mov %rsp, %rbp, а потом весь доступ к аргументам и сохранённым значениям ведут от %rbp с постоянными смещениями: %rbp + 8 это адрес возврата, (%rbp) сохранённый %rbp вызывающего, а локальные лежат по отрицательным смещениям. Сам %rsp при этом волен уехать вниз на любую, вычисленную в рантайме величину. В эпилоге mov %rbp, %rsp разом возвращает вершину на место, pop %rbp восстанавливает базу вызывающего, и ret уходит по адресу, который всё это время спокойно лежал по известному смещению от %rbp.

Вот зачем в дампе кадра мы брали @frameAddress() и прибавляли 8: это работает именно потому, что %rbp указывает на фиксированную точку кадра, а адрес возврата стоит от неё на известном расстоянии. Кадры переменного размера это единственный случай, где %rbp действительно необходим; в простом коде компилятор часто обходится без него, о чём была заметка про пролог и эпилог.

Защиты: канарейка, ASLR, NX

Если бы атака оставалась такой же простой, как в восьмидесятые, писать надёжные программы было бы невозможно. Но с тех пор накопился набор защит, и каждая закрывает свой кусок. Разберём три главные.

Канарейка на стеке

Канарейка это страж, которого кладут между буфером и адресом возврата. В прологе функции компилятор берёт секретное случайное число и пишет его на стек перед адресом возврата; в эпилоге, прямо перед ret, сравнивает сохранённое значение с эталоном. Переполнение буфера, ползущее к адресу возврата, неизбежно проходит через канарейку и затирает её. Значения перестают совпадать, и программа падает, не дойдя до ret.

В Zig канарейку включает флаг -fstack-protector. Соберём функцию с массивом на стеке и снимем её ассемблер:

// ssp.zig
const std = @import("std");
export fn vuln(src: [*:0]const u8) void {
    var buf: [64]u8 = undefined;
    var i: usize = 0;
    while (src[i] != 0) : (i += 1) buf[i] = src[i]; // копия без учёта размера buf
    buf[i] = 0;
    _ = std.c.printf("%s\n", &buf);
}
$ zig build-obj ssp.zig -O ReleaseFast -fstack-protector -lc -target x86_64-linux-gnu -femit-bin=ssp.o
$ objdump -d ssp.o
vuln:
    pushq   %rbp
    movq    %rsp, %rbp
    subq    $0x50, %rsp
    movq    %fs:0x28, %rax       # берём секретную канарейку из TLS
    movq    %rax, -0x8(%rbp)     # кладём её прямо под адрес возврата
    ...                          # тело: копирование src в buf по смещению -0x48(%rbp)
    movq    %fs:0x28, %rax       # в эпилоге снова берём эталон
    cmpq    -0x8(%rbp), %rax     # сравниваем с тем, что на стеке
    jne     .Lsmashed            # не совпало: стек затёрт
    addq    $0x50, %rsp
    popq    %rbp
    retq
.Lsmashed:
    callq   __stack_chk_fail     # аварийное завершение, до ret не доходим

Канарейка живёт в потокоблокальной памяти по адресу %fs:0x28, буфер buf компилятор положил по -0x48(%rbp), а саму канарейку по -0x8(%rbp), то есть между буфером и сохранённым %rbp с адресом возврата. Переполнение buf на пути к адресу возврата обязано пройти через -0x8(%rbp) и затрёт канарейку. В эпилоге cmpq это ловит, и jne уводит в __stack_chk_fail, который завершает процесс. Проверим, что так и происходит, на длинном вводе:

$ zig build-exe ssp.zig ...   # с main, читающим строку из окружения
$ LINE=$(python3 -c "print('A'*200)") ./ssp
Illegal instruction
$ echo $?
132

Процесс упал с кодом 132 (сигнал прерывания от __stack_chk_fail), не напечатав ничего после. Важное слово: не вернувшись. Адрес возврата, может, и затёрт, но до ret дело не дошло, управление по подменённому адресу не ушло. Канарейка не мешает переполнению случиться, она мешает им воспользоваться.

ASLR: адреса каждый раз новые

Чтобы прыгнуть в нужное место, атакующему надо знать адрес этого места: адрес своего кода в буфере или адрес функции, в которую он метит. ASLR выбивает у него эту опору. При каждом запуске операционная система раскладывает стек, кучу и библиотеки по новым, случайным адресам. Мы это уже видели в дампе кадра: &buf был то 0x7ffffffffc10, то 0x7ffffffffb30. Жёстко вписанный в эксплойт адрес в следующем запуске указывает в пустоту, и прыжок приводит к аварии, а не к захвату.

ASLR не чинит сам баг: переполнение по-прежнему случается. Он отнимает у атаки знание, без которого прицельный прыжок превращается в угадывание одного адреса из миллиардов.

NX: стек не исполняется

Исторический трюк с шеллкодом клал исполняемый код в буфер на стеке и прыгал туда. Его закрывает бит NX, он же запрет исполнения. Страницы стека и кучи помечаются как неисполняемые, и процессор отказывается выполнять инструкции оттуда. Прыжок в буфер на стеке теперь упирается в аппаратный запрет: байты, которые атакующий туда положил, прочитать как код нельзя, процесс падает сегфолтом.

nop-салазки и почему связка закрывает путь

Раз по ASLR адрес буфера точно неизвестен, атакующий брал запас: заполнял начало буфера длинной цепочкой инструкций nop, которые ничего не делают, а в конце ставил шеллкод. Такую цепочку называют nop-салазками: если прыжок попадал в любое место дорожки, управление «съезжало» по ней до шеллкода, как по горке. Прицеливаться можно было грубо, лишь бы попасть в дорожку.

Но салазки это приём под конкретный сценарий: исполняемый код на стеке плюс грубое прицеливание. NX убирает из него главное, саму возможность исполнить что-либо со стека, и прыжок в салазки теперь даёт не разгон по nop-ам, а сегфолт на первой же инструкции. Вот почему защиты работают не поодиночке, а связкой: канарейка ловит переполнение до ret, ASLR отнимает знание адресов, NX запрещает исполнять стек. Классический прямой путь требует, чтобы все три отсутствовали сразу.

Виджет ниже собирает это в одну картину. Тяни ползунок длины ввода и смотри, как байты заливают кадр снизу вверх, к старшим адресам. Переключатели включают канарейку и NX, а выпадающий список задаёт, куда метит подменённый адрес возврата. Следи за итоговым вердиктом: при какой длине гибнут локальные, когда затирается адрес возврата, как канарейка обрывает атаку раньше, и чем заканчивается прыжок в стек при включённом и выключенном NX.

Раскладка в виджете учебная, «тесная»: буфер, канарейка, сохранённый %rbp и адрес возврата стоят вплотную, по восемь и шестнадцать байт. В реальном кадре, как мы мерили выше, между буфером и адресом возврата гораздо больше байт и выравнивания, но порядок слоёв и логика та же.

Стековая трасса: кто кого позвал

Когда ReleaseSafe ловит выход за границу, он печатает не только строку паники, но и стековую трассу, цепочку вызовов, приведшую к месту падения. Ту же трассу можно снять руками в любой точке через std.debug.dumpCurrentStackTrace:

// trace.zig
const std = @import("std");
fn inner() void {
    std.debug.dumpCurrentStackTrace(.{});
}
fn outer() void {
    inner();
}
pub fn main() void {
    outer();
}
$ zig build-exe trace.zig -O Debug -target x86_64-linux-gnu
$ ./trace
trace.zig:3:36: 0x… in inner (trace.zig)
trace.zig:5:5: 0x… in outer (trace.zig)
trace.zig:7:5: 0x… in main (trace.zig)
start.zig:698:59: 0x… in callMain (std.zig)
start.zig:190:5: 0x… in _start (std.zig)

Трасса читается сверху вниз от места вызова к корню: inner позвала dumpCurrentStackTrace, inner позвал outer, outer позвал main, а main запустил рантайм через _start. Восстанавливает эту цепочку тот же механизм кадров, что мы разбирали: каждый кадр хранит адрес возврата, по которому находится вызывающий, а от него следующий, и так до дна стека. Переполнение, затирающее адреса возврата, ломает и эту цепочку: трасса после угнанного кадра показывает мусор, что само по себе признак затёртого стека.

Практика

Пришло время написать безопасную версию echo, ту самую goodEcho, подпись которой мы разобрали выше. Функция читает одну строку из среза src в буфер buf: копирует байты до первого '\n' или до конца src, перевод строки в результат не включает, и возвращает срез buf ровно с прочитанными байтами. Главное правило одно: ни при каком вводе функция не пишет за пределы buf. Если строка не помещается, верни error.TooLong и не трогай ни одного байта за границей. Проверяй место до записи, а не после: буфер на n байт вмещает строку длиной n, но не n плюс один. Тесты прогонят короткую строку, строку впритык по длине буфера, строку на один байт длиннее, остановку на '\n', пустой ввод и очень длинный ввод в маленький буфер, причём буфер обрамлён сторожевыми байтами, которые обязаны остаться нетронутыми.

Упражнения

Итоги

  • У массива на стеке нет забора по краю: запись по непроверенному индексу ложится в соседние слова кадра по формуле base + i * size, как для любого другого индекса.
  • Стек растёт вниз, а запись в массив идёт вверх, поэтому переполнение ползёт в сторону сохранённого %rbp и адреса возврата. Затёртый адрес возврата уводит ret в чужое место.
  • Дамп кадра через @frameAddress() и &buf показывает реальное расстояние от буфера до адреса возврата: оно стабильно между запусками, а сами адреса скачут из-за ASLR.
  • В стандартной библиотеке Zig нет gets: чтение идёт в срез, который носит длину с собой, поэтому у любого чтения есть граница.
  • ReleaseSafe вставляет перед индексной записью cmp и ja и превращает выход за границу в панику до того, как что-либо затёрто. ReleaseFast эти проверки снимает, и картина становится как в C.
  • Подмена адреса возврата сводится к записи адреса в ячейку %rbp + 8: на ret процессор прыгает по тому, что там лежит.
  • Кадры переменного размера требуют %rbp как фиксированной базы: %rsp уезжает на вычисленную в рантайме величину, а доступ к адресу возврата и аргументам идёт от %rbp с постоянными смещениями.
  • Защиты работают связкой: канарейка (%fs:0x28) ловит переполнение до ret, ASLR отнимает знание адресов, NX запрещает исполнять стек. nop-салазки как исторический приём закрываются именно NX.
  • std.debug.dumpCurrentStackTrace печатает цепочку вызовов, восстановленную по адресам возврата в кадрах; затёртый стек ломает и эту цепочку.

Дальше

Ты увидел, как адресная арифметика за концом массива превращается в угон управления, и чем его останавливают. Это был последний урок про то, как читать чужой машинный код и его данные. Дальше в разделе целочисленный мир уступает место числам с плавающей точкой: у них свой файл регистров, свои инструкции и своя логика сравнения, где NaN ломает привычный порядок. А за скалярными операциями откроются векторные: один регистр, который держит восемь чисел сразу. Следующий урок про числа с плавающей точкой и SIMD снимает настоящий ассемблер float-кода и показывает, как @Vector в Zig занимает всю ширину машины.

домашка

Домашка