Урок 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.
call и ret: как работает вызов функции
Самое важное. Когда одна функция вызывает другую, процессору надо запомнить, куда вернуться после. Он хранит это на стеке.
- call f делает две вещи: кладёт на стек адрес возврата (адрес инструкции, идущей сразу за
call), а затем прыгает в функциюf. - ret делает обратное: снимает адрес возврата со стека в
rip— и выполнение продолжается там, откуда вызвали.
То есть адрес возврата (в этом курсе мы называем его saved RIP) физически лежит в памяти, на стеке. Запомните это — вокруг этого факта построена половина олимпиадного pwn.
Кадр стека
Когда функция начинает работу, она отводит себе кусок стека — свой кадр (stack frame). Почти каждая функция начинается с одинакового «пролога». Вот он у функции check из маленькой программы, которую мы в конце урока откроем в отладчике:
Пример разобран на бинарнике check_demo.
Первая строка, endbr64, — метка защиты, которую вставляет компилятор: процессор проверяет по ней, что прыжок пришёл в начало функции. На логику она не влияет, дальше её можно не замечать. Пролог — три следующие инструкции. Число в sub у каждой функции своё: столько байт ей нужно под локальные переменные.
После этого кадр выглядит так (сверху — старшие адреса):
- адрес возврата (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 |
Три вывода, которые стоит забрать с собой:
rspвернулся ровно туда, откуда начал (0x7fffffffe230). Сбалансированный кадр — норма: сколько отняли, столько и вернули. Если послеretстек «не сошёлся», значит с ним что-то делали намеренно.- Адрес возврата всё это время лежал в памяти по адресу
0x7fffffffe228— то есть поrbp+8(ведьrbp=0x7fffffffe220). Именно это соотношение вы примените в задании урока. 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 и переход. Умея читать это, вы за минуту находите, где программа принимает решение.
Куда вернётся функция
Программа остановлена внутри функции, сразу после её пролога. Регистры:
rsp = 0x7fffffffe200
rbp = 0x7fffffffe210
Дамп стека (x/4gx $rsp в gdb — 8-байтовые слова, значения уже собраны):
0x7fffffffe200: 0x4141414141414141 0x0000000000000005
0x7fffffffe210: 0x00007fffffffe230 0x000055555555518a
Вопрос: по какому адресу продолжится выполнение, когда функция дойдёт до ret?
Ответ — значение целиком, ровно как в дампе, в виде 0x....
Почему это сердце и реверса, и pwn
Два практических следствия, ради которых мы так подробно разбирали кадр:
- Реверс. Чтобы проследить, что происходит с введённой строкой, вы смотрите: она пришла в
rdi, легла в локальный буфер (rbp-0x20), дальше над буфером идут операции и сравнения. Кадр стека — это карта, по которой вы читаете функцию. - 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-задачах. Мы ничего не ломаем, но посчитать смещение вы теперь умеете, и в задании урока это пригодится.
Сколько байт до адреса возврата
Функция начинается так, и в буфер читается ввод пользователя:
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 на каждой остановке сам печатает регистры, стек и цепочку вызовов, и в них видно всё, о чём шла речь выше:
Пример разобран на бинарнике 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. Если после текста стек и регистры остались смутными — просто посмотрите его, после этого всё встанет на места.
А в следующем уроке мы начнём читать ассемблер: разберём основные инструкции и научимся понимать смысл коротких фрагментов кода.