Урок 3. Стек и адрес возврата: на чём держатся и реверс, и pwn

В прошлом уроке появился регистр rsp, и мы отложили его «на потом». Потом настало. Стек — самая важная структура памяти для реверса: через неё работают вызовы функций, в ней лежат локальные переменные, и именно она — главная арена всей бинарной эксплуатации (курса Pwn). Разберём её так, чтобы вы читали её в отладчике без запинки.

Что такое стек

Стек — это область памяти, которую программа использует как черновик для вызовов функций. Устроен он по принципу LIFO (last in, first out) — «последним положил, первым взял», как стопка тарелок: кладёшь сверху, берёшь сверху.

Одна неожиданная деталь: в x86-64 стек растёт вниз, к младшим адресам. Когда на стек что-то кладут, адрес вершины уменьшается. Регистр rsp (stack pointer) всегда показывает на текущую вершину — то есть на самый младший занятый адрес.

Работают с вершиной две инструкции:

  • push X — «положить»: rsp уменьшается на 8, и по новому адресу записывается X.
  • pop X — «снять»: значение с вершины читается в X, и rsp увеличивается на 8.

push и pop в 64-битном режиме всегда двигают rsp на 8 байт, и адрес возврата тоже занимает 8. Локальные переменные внутри кадра бывают любого размера — char занимает один байт, int четыре, — а сам кадр компилятор выделяет одной инструкцией sub rsp, N и подбирает N так, чтобы перед каждым call значение rsp делилось на 16.

Инструкция push уменьшает rsp на 8. Значит, стек в x86-64 растёт… · 4 б.

call и ret: как работает вызов функции

Самое важное. Когда одна функция вызывает другую, процессору надо запомнить, куда вернуться после. Он хранит это на стеке.

  • call f делает две вещи: кладёт на стек адрес возврата (адрес инструкции, идущей сразу за call), а затем прыгает в функцию f.
  • ret делает обратное: снимает адрес возврата со стека в rip — и выполнение продолжается там, откуда вызвали.

То есть адрес возврата (в этом курсе мы называем его saved RIP) физически лежит в памяти, на стеке. Запомните это — вокруг этого факта построена половина олимпиадного pwn.

Что делает инструкция call прямо перед тем, как прыгнуть в функцию? · 4 б.

Кадр стека

Когда функция начинает работу, она отводит себе кусок стека — свой кадр (stack frame). Почти каждая функция начинается с одинакового «пролога». Вот он у функции check из маленькой программы, которую мы в конце урока откроем в отладчике:

Терминал: objdump функции check из check_demo; подписаны push rbp — сохранить rbp вызвавшей функции, mov rbp,rsp — rbp отмечает начало кадра, sub rsp,0x18 — место под локальные переменные, leave и ret — эпилог; endbr64 — метка защиты

Пример разобран на бинарнике check_demo.

Первая строка, endbr64, — метка защиты, которую вставляет компилятор: процессор проверяет по ней, что прыжок пришёл в начало функции. На логику она не влияет, дальше её можно не замечать. Пролог — три следующие инструкции. Число в sub у каждой функции своё: столько байт ей нужно под локальные переменные.

После этого кадр выглядит так (сверху — старшие адреса):

Кадр стека: адрес возврата и сохранённый rbp сверху, локальные переменные и буфер снизу; rsp и rbp; System V ABI

  • адрес возврата (saved RIP) — по адресу rbp+8, его положил call;
  • сохранённый rbp вызвавшей функции — по адресу rbp;
  • локальные переменные и буферы — ниже, между rbp и rsp (адреса rbp-8, rbp-0x10, …).

В конце функция сворачивает кадр «эпилогом»: leave (это то же самое, что mov rsp, rbp; pop rbp) и ret.

Зачем два указателя? rsp во время работы функции постоянно скачет (каждый push/pop его двигает), а rbp стоит на месте весь вызов. Поэтому к локальным переменным удобно обращаться относительно неподвижного rbp (rbp-8, rbp-0x10). Правда, с оптимизацией -O2 компилятор часто убирает rbp из роли «якоря» и адресует всё от rsp — тогда в декомпиляции вы увидите обращения к rsp+смещение. Не пугайтесь, идея та же.

Пошагово: один вызов на конкретных числах

Пока всё это звучит абстрактно. Давайте проследим один вызов по шагам — с настоящими адресами. Пусть перед вызовом rsp = 0x7fffffffe230, а rip = 0x401140, и по этому адресу стоит call check. Инструкция call занимает 5 байт, значит следующая за ней — 0x401145. Это и есть будущий адрес возврата. Сама check начинается с 0x401136.

Следите за двумя числами — rsp и rip:

Что выполняется Что происходит rsp после rip после
(до вызова) — 0x7fffffffe230 0x401140
call check на стек лёг адрес возврата 0x401145, прыжок в check 0x7fffffffe228 0x401136
push rbp сохранили rbp вызвавшего 0x7fffffffe220 …
mov rbp, rsp rbp = 0x7fffffffe220 — кадр закреплён 0x7fffffffe220 …
sub rsp, 0x10 отвели 16 байт под локальные 0x7fffffffe210 …
(тело функции) работа с rbp-8, rbp-0x10 0x7fffffffe210 …
leave rsp = rbp, затем pop rbp 0x7fffffffe228 …
ret снял 0x401145 со стека в rip 0x7fffffffe230 0x401145

Три вывода, которые стоит забрать с собой:

  1. rsp вернулся ровно туда, откуда начал (0x7fffffffe230). Сбалансированный кадр — норма: сколько отняли, столько и вернули. Если после ret стек «не сошёлся», значит с ним что-то делали намеренно.
  2. Адрес возврата всё это время лежал в памяти по адресу 0x7fffffffe228 — то есть по rbp+8 (ведь rbp = 0x7fffffffe220). Именно это соотношение вы примените в задании урока.
  3. ret не «помнит» ничего сам — он просто берёт то, что лежит на вершине стека, и прыгает туда. Если к моменту ret в этой ячейке окажется чужое число, процессор послушно прыгнет по нему. Вот почему переполнение буфера так опасно: никакой «проверки правильности» адреса возврата не существует.

Соглашение о вызовах: где искать аргументы

Как функция получает аргументы? В Linux x86-64 действует соглашение System V: первые шесть целочисленных/указательных аргументов передаются в регистрах, по строгому порядку:

№ аргумента 1 2 3 4 5 6 7-й и далее
регистр rdi rsi rdx rcx r8 r9 на стеке

Возвращаемое значение функции кладётся в rax.

Для реверса это золотое правило. Если вы видите в коде check(char *s), то указатель s придёт в функцию через rdi. Если перед call check стоит mov edi, ... / lea rdi, ... — вот он, первый аргумент. А результат проверки («подошёл пароль или нет») вернётся в rax, и по нему тут же будет test/cmp и переход. Умея читать это, вы за минуту находите, где программа принимает решение.

Функция объявлена как check(char *s). В каком регистре окажется указатель s при входе в неё (Linux x86-64)? · 4 б.
R2

Куда вернётся функция

12 б.

Программа остановлена внутри функции, сразу после её пролога. Регистры:

rsp = 0x7fffffffe200
rbp = 0x7fffffffe210

Дамп стека (x/4gx $rsp в gdb — 8-байтовые слова, значения уже собраны):

0x7fffffffe200: 0x4141414141414141  0x0000000000000005
0x7fffffffe210: 0x00007fffffffe230  0x000055555555518a

Вопрос: по какому адресу продолжится выполнение, когда функция дойдёт до ret?

Ответ — значение целиком, ровно как в дампе, в виде 0x....

Почему это сердце и реверса, и pwn

Два практических следствия, ради которых мы так подробно разбирали кадр:

  1. Реверс. Чтобы проследить, что происходит с введённой строкой, вы смотрите: она пришла в rdi, легла в локальный буфер (rbp-0x20), дальше над буфером идут операции и сравнения. Кадр стека — это карта, по которой вы читаете функцию.
  2. Pwn (забегая вперёд). Адрес возврата лежит на стеке, чуть «выше» локального буфера. Если в буфер записать больше данных, чем он вмещает, запись пойдёт в сторону старших адресов и затрёт saved RIP. Тогда после ret процессор прыгнет туда, куда подставил атакующий. Это и есть переполнение буфера на стеке — механизм задач вроде «Портал в Край» · региональный этап, 2025–2026, 9–11 классы и «Двойной редстоуновый замок» · заключительный этап, 2025–2026, 9 класс. Мы пока ничего не эксплуатируем, но вы уже должны видеть, почему место saved RIP на стеке — самое лакомое.

Считаем смещение: сколько байт до адреса возврата

«Переполнение затирает адрес возврата» — звучит расплывчато. На деле это простая арифметика, и её стоит проделать один раз руками.

Пусть в функции есть буфер на 32 байта, и компилятор разместил его по адресу rbp-0x20. Разложим кадр по ячейкам, от буфера вверх:

Смещение от начала буфера Что там лежит Адрес
0 … 31 сам буфер buf[32] rbp-0x20 … rbp-0x1
32 … 39 сохранённый rbp (8 байт) rbp
40 … 47 адрес возврата (saved RIP) rbp+8

Считаем: от начала буфера до сохранённого rbp — ровно 0x20 = 32 байта. Сам rbp занимает ещё 8. Значит, адрес возврата начинается на 32 + 8 = 40-м байте от начала буфера.

Что это означает практически: если программа позволит записать в buf больше 32 байт, то байты с 33-го по 40-й лягут поверх сохранённого rbp, а байты с 41-го по 48-й — поверх адреса возврата. Первые 40 байт в атаке обычно просто «мусор-заполнитель», а дальше подставляется нужный адрес.

Отсюда общая формула для такого кадра:

смещение до адреса возврата = размер_буфера + 8

Восьмёрка — это сохранённый rbp, который лежит между буфером и адресом возврата. Если компилятор кадр не создавал (оптимизация убрала rbp), восьмёрки не будет — поэтому смещение всегда проверяют по конкретному коду, а не берут из головы.

Именно это число — «сколько байт до адреса возврата» — первым делом ищут в pwn-задачах. Мы ничего не ломаем, но посчитать смещение вы теперь умеете, и в задании урока это пригодится.

Локальный буфер на стеке переполнили, и запись пошла в сторону старших адресов. Что из этого может быть затёрто и привести к перехвату выполнения после ret? · 4 б.
R10

Сколько байт до адреса возврата

12 б.

Функция начинается так, и в буфер читается ввод пользователя:

push rbp
mov  rbp, rsp
sub  rsp, 0x50
...
lea  rax, [rbp-0x40]     ; адрес буфера для ввода
mov  rdi, rax
call gets

Буфер начинается по адресу rbp-0x40. Кадр обычный: сохранённый rbp лежит по rbp, адрес возврата — по rbp+8.

Сколько байт нужно записать в буфер, чтобы следующие записанные байты легли ровно на адрес возврата?

Ответ — десятичное число.

В отладчике

Всё это осязаемо в gdb (с pwndbg):

  • x/8gx $rsp — показать 8 «слов» с вершины стека; среди них вы найдёте сохранённый rbp и адрес возврата;
  • bt (backtrace) — отладчик проходит по цепочке сохранённых rbp/адресов возврата и показывает, кто кого вызвал;
  • info registers rsp rbp rip — посмотреть указатели.

Вот как это выглядит в настоящем отладчике. Маленькая программа: main вызывает check(2, 3), а мы остановились внутри check сразу после пролога (push rbp, mov rbp, rsp, sub rsp, 0x18). pwndbg на каждой остановке сам печатает регистры, стек и цепочку вызовов, и в них видно всё, о чём шла речь выше:

Экран pwndbg внутри check: RDI = 2, RSI = 3; в панели STACK строка rbp указывает на сохранённый rbp, строка +008 — адрес возврата 0x40117b (main+27); BACKTRACE: check вызвана из main+27

Пример разобран на бинарнике check_demo — тот же, что в начале урока.

  • RDI = 2, RSI = 3 — аргументы, как велит System V;
  • в панели STACK строка с меткой rbp — сохранённый rbp вызвавшей функции, а строка ниже, +008, — адрес возврата 0x40117b (main+27): ровно rbp+8;
  • BACKTRACE подтверждает, что check вызвана из main+27.

Адреса здесь настоящие, поэтому они отличаются от учебных чисел в таблице выше, но устройство кадра то же самое.

Именно на этом строится задание урока: по дампу стека определить, куда вернётся функция.

Что запомнить

  • Стек растёт вниз; rsp — вершина; push кладёт (rsp-=8), pop снимает (rsp+=8).
  • call кладёт на стек адрес возврата и прыгает в функцию; ret снимает его в rip.
  • В кадре: адрес возврата — по rbp+8, сохранённый rbp — по rbp, локальные переменные — ниже.
  • System V: аргументы 1–6 — в rdi, rsi, rdx, rcx, r8, r9; возврат — в rax.
  • Переполнение локального буфера может затереть saved RIP — фундамент pwn.

Посмотри вживую

Всё это гораздо понятнее в движении, чем в тексте. Ниже — видео-разбор: на простой программе (a = …, b = …, a + b, вывод результата) показано, как значения попадают в регистры, как при вызове меняется стек и что в это время лежит в памяти — вживую в отладчиках gdb и edb. Если после текста стек и регистры остались смутными — просто посмотрите его, после этого всё встанет на места.

А в следующем уроке мы начнём читать ассемблер: разберём основные инструкции и научимся понимать смысл коротких фрагментов кода.