Итак, все мы уже компилировали программы раньше, но знаете ли вы, как ваш компьютер разделил и сохранил различные части программы? Наберитесь терпения, поначалу меня это ошеломило. Давайте вскочим.

Скомпилированная программа разбивается на пять сегментов: текст, данные, bss, куча и стек.

В текстовом сегменте находятся инструкции машинного языка программы. Когда программа начинает выполняться, RIP (регистр, указывающий на выполняющуюся в данный момент инструкцию) устанавливается на первую инструкцию машинного языка в текстовом сегменте. Затем процессор следует циклу выполнения по мере выполнения инструкций:

  1. Читает инструкцию, на которую указывает RIP
  2. Добавляет длину инструкции в байтах к RIP
  3. Выполняет инструкцию, которая была прочитана на шаге 1.
  4. Возвращается к шагу 1

Вы не можете писать в текстовый сегмент памяти. Любая попытка сделать это приведет к уничтожению программы. Сегмент текстовой памяти имеет фиксированный размер.

Раздел данных используется для хранения инициализированных глобальных и статических переменных.

Раздел bss используется для хранения неинициализированных глобальных и статических переменных.

Оба эти раздела памяти доступны для записи, хотя их размер фиксирован. Глобальные и статические переменные могут сохраняться независимо от контекста функции, поскольку они хранятся в своих собственных сегментах памяти.

Раздел кучи памяти находится под непосредственным контролем программистов. В C программисты могут использовать функцию malloc() для динамического выделения памяти в куче. Куча не имеет фиксированного размера и может увеличиваться или уменьшаться. Рост кучи «движется вниз к более высоким адресам памяти» (Hacking: The Art of Exploitation, Jon Erickson). Давайте проиллюстрируем это на примере программы, которая последовательно выделяет память в куче:

Запустим программу, чтобы увидеть адреса памяти:

Первый напечатанный адрес — 0x21c5010, а второй — под ним (визуально) 0x21c51b0 (более высокий адрес).

Стек памяти используется для хранения локальных переменных функций и контекста во время вызовов функций. Стек не имеет фиксированного размера. Когда функция вызывается, «эта функция будет иметь свой собственный набор передаваемых переменных, а код функции будет находиться в другом месте памяти в текстовом сегменте. Поскольку контекст и RIP должны изменяться при вызове функции, стек используется для запоминания всех переданных переменных, местоположения, в которое RIP должен вернуться после завершения функции, и всех локальных переменных, используемых этой функцией». (Эриксон). Эта информация хранится в кадре стека. Стек (стек памяти) состоит из множества различных кадров стека. В отличие от кучи, стек растет вверх (визуально) в сторону младших адресов памяти.

Поскольку и стек, и куча являются динамическими, то есть их размер меняется в зависимости от того, сколько памяти использует программист, имеет смысл, что они растут в противоположных направлениях друг к другу. Это «минимизирует неиспользуемое пространство, позволяя увеличить стек, если куча мала, и наоборот» (Эриксон).

Регистр RSP содержит адрес конца стека. Важно отметить, что регистр RSP неявно управляется несколькими инструкциями ЦП: PUSH, POP, CALL RET и т. д. Вот почему мы не видим ассемблерных инструкций, сбрасывающих значение регистра RSP после инструкции CALL.

Когда функция вызывается, во фрейм стека помещаются несколько вещей: регистр RBP (также известный как указатель фрейма (FP)), который «используется для ссылки на локальные переменные функции в текущем фрейме стека, […] параметры для функция, ее локальные переменные и два указателя, которые необходимы, чтобы вернуть все как было: указатель сохраненного кадра (SFP) и адрес возврата. SFP используется для восстановления предыдущего значения EBP, а адрес возврата используется для восстановления RIP до следующей инструкции, найденной после вызова функции» (Эриксон). Регистр RBP обрабатывается только явно.

Давайте проиллюстрируем, что происходит в стеке, когда мы запускаем простую функцию:

Разберем функцию main():

Вы можете видеть, что каждый из параметров для test_function() помещается в стек. Когда выполняется инструкция CALL, «адрес возврата помещается в стек, и процесс выполнения переходит к началу функции test_function() по адресу 0x40055d» (Эриксон). Обратным адресом в этом случае будет инструкция, следующая за инструкцией CALL. После возврата test_function() RIP укажет на 0x4005be.

Разберем test_function():

Обратите внимание, что RBP помещается в стек. Это называется сохраненным указателем кадра (SFP) и позже используется для восстановления RBP до предыдущего кадра. Затем RSP копируется в RBP для установки нового указателя кадра. Это имеет смысл, так как RBP используется для ссылки на локальные функциональные переменные в текущем кадре стека. Также обратите внимание на явное манипулирование RBP. Наконец, мы видим, что из значения RSP вычитается 40, это для экономии памяти под локальные переменные buffer и flag.

Мы можем наблюдать, как меняется стек, используя GDB. Сначала добавим несколько точек останова:

Давайте перейдем к следующей точке останова:

Вы можете видеть, что регистры RSP, RBP и RIP переместились вниз по адресному пространству к более низким адресам. Мы также видим, что разница между RSP и RBP составляет 40, что имеет смысл из-за инструкции «SUB RSP, 0X40», которую мы видели в разборке test_functions.

В итоге кадр стека выглядит так:

После завершения test_function() кадр стека извлекается из стека, а RIP устанавливается на адрес возврата, чтобы программа могла продолжить выполнение. RBP также получает значение сохраненного указателя кадра, чтобы он мог ссылаться на локальные переменные в предыдущем кадре стека. Этот цикл создания и завершения кадров стека продолжается до тех пор, пока программа не завершит выполнение.