Что такое стек организации памяти
Разбираемся с управлением памятью в современных языках программирования
Привет, Хабр! Представляю вашему вниманию перевод статьи «Demystifying memory management in modern programming languages» за авторством Deepu K Sasidharan.
В данной серии статей мне бы хотелось развеять завесу мистики над управлением памятью в программном обеспечении (далее по тексту — ПО) и подробно рассмотреть возможности, предоставляемые современными языками программирования. Надеюсь, что мои статьи помогут читателю заглянуть под капот этих языков и узнать для себя нечто новое.
Углублённое изучение концептов управления памятью позволяет писать более эффективное ПО, потому как стиль и практики кодирования оказывают большое влияние на принципы выделения памяти для нужд программы.
Часть 1: Введение в управление памятью
Управление памятью — это целый набор механизмов, которые позволяют контролировать доступ программы к оперативной памяти компьютера. Данная тема является очень важной при разработке ПО и, при этом, вызывает затруднения или же вовсе остаётся черным ящиком для многих программистов.
Для чего используется оперативная память?
Когда программа выполняется в операционный системе компьютера, она нуждается в доступе к оперативной памяти (RAM) для того, чтобы:
Стек используется для статичного выделения памяти. Он организован по принципу «последним пришёл — первым вышел» (LIFO). Можно представить стек как стопку книг — разрешено взаимодействовать только с самой верхней книгой: прочитать её или положить на неё новую.
Использование стека в JavaScript. Объекты хранятся в куче и доступны по ссылкам, которые хранятся в стеке. Тут можно посмотреть в видеоформате
Куча используется для динамического выделения памяти, однако, в отличие от стека, данные в куче первым делом требуется найти с помощью «оглавления». Можно представить, что куча это такая большая многоуровневая библиотека, в которой, следуя определённым инструкциям, можно найти необходимую книгу.
Почему эффективное управление памятью важно?
В отличие от жёстких дисков, оперативная память весьма ограниченна (хотя и жёсткие диски, безусловно, тоже не безграничны). Если программа потребляет память не высвобождая её, то, в конечном итоге, она поглотит все доступные резервы и попытается выйти за пределы памяти. Тогда она просто упадет сама, или, что ещё драматичнее, обрушит операционную систему. Следовательно, весьма нежелательно относиться легкомысленно к манипуляциям с памятью при разработке ПО.
Различные подходы
Современные языки программирования стараются максимально упростить работу с памятью и снять с разработчиков часть головной боли. И хотя некоторые почтенные языки всё ещё требуют ручного управления, большинство всё же предоставляет более изящные автоматические подходы. Порой в языке используется сразу несколько подходов к управлению памятью, а иногда разработчику даже доступен выбор какой из вариантов будет эффективнее конкретно для его задач (хороший пример — C++). Перейдём к краткому обзору различных подходов.
Ручное управление памятью
Язык не предоставляет механизмов для автоматического управления памятью. Выделение и освобождение памяти для создаваемых объектов остаётся полностью на совести разработчика. Пример такого языка — C. Он предоставляет ряд методов (malloc, realloc, calloc и free) для управления памятью — разработчик должен использовать их для выделения и освобождения памяти в своей программе. Этот подход требует большой аккуратности и внимательности. Так же он является в особенности сложным для новичков.
Сборщик мусора
Сборка мусора — это процесс автоматического управления памятью в куче, который заключается в поиске неиспользующихся участков памяти, которые ранее были заняты под нужды программы. Это один из наиболее популярных вариантов механизма для управления памятью в современных языках программирования. Подпрограмма сборки мусора обычно запускается в заранее определённые интервалы времени и бывает, что её запуск совпадает с ресурсозатратными процессами, в результате чего происходит задержка в работе приложения. JVM (Java/Scala/Groovy/Kotlin), JavaScript, Python, C#, Golang, OCaml и Ruby — вот примеры популярных языков, в которых используется сборщик мусора.
Получение ресурса есть инициализация (RAII)
RAII — это программная идиома в ООП, смысл которой заключается в том, что выделяемая для объекта область памяти строго привязывается к его времени существования. Память выделяется в конструкторе и освобождается в деструкторе. Данный подход был впервые реализован в C++, а так же используется в Ada и Rust.
Автоматический подсчёт ссылок (ARC)
Данный подход весьма похож на сборку мусора с подсчётом ссылок, однако, вместо запуска процесса подсчёта в определённые интервалы времени, инструкции выделения и освобождения памяти вставляются на этапе компиляции прямо в байт-код. Когда же счётчик ссылок достигает нуля, память освобождается как часть нормального потока выполнения программы.
Автоматический подсчёт ссылок всё так же не позволяет обрабатывать циклические ссылки и требует от разработчика использования специальных ключевых слов для дополнительной обработки таких ситуаций. ARC является одной из особенностей транслятора Clang, поэтому присутствует в языках Objective-C и Swift. Так же автоматический подсчет ссылок доступен для использования в Rust и новых стандартах C++ при помощи умных указателей.
Владение
Это сочетание RAII с концепцией владения, когда каждое значение в памяти должно иметь только одну переменную-владельца. Когда владелец уходит из области выполнения, память сразу же освобождается. Можно сказать, что это примерно как подсчёт ссылок на этапе компиляции. Данный подход используется в Rust и при этом я не смог найти ни одного другого языка, который бы использовал подобный механизм.
В данной статье были рассмотрены основные концепции в сфере управления памятью. Каждый язык программирования использует собственные реализации этих подходов и оптимизированные для различных задач алгоритмы. В следующих частях, мы подробнее рассмотрим решения для управления памятью в популярных языках.
Читайте так же другие части серии:
Ссылки
Вы можете подписаться на автора статьи в Twitter и на LinkedIn.
Основные принципы программирования: стек и куча
Мы используем всё более продвинутые языки программирования, которые позволяют нам писать меньше кода и получать отличные результаты. За это приходится платить. Поскольку мы всё реже занимаемся низкоуровневыми вещами, нормальным становится то, что многие из нас не вполне понимают, что такое стек и куча, как на самом деле происходит компиляция, в чём разница между статической и динамической типизацией, и т.д. Я не говорю, что все программисты не знают об этих понятиях — я лишь считаю, что порой стоит возвращаться к таким олдскульным вещам.
Сегодня мы поговорим лишь об одной теме: стек и куча. И стек, и куча относятся к различным местоположениям, где происходит управление памятью, но стратегия этого управления кардинально отличается.
Стек — это область оперативной памяти, которая создаётся для каждого потока. Он работает в порядке LIFO (Last In, First Out), то есть последний добавленный в стек кусок памяти будет первым в очереди на вывод из стека. Каждый раз, когда функция объявляет новую переменную, она добавляется в стек, а когда эта переменная пропадает из области видимости (например, когда функция заканчивается), она автоматически удаляется из стека. Когда стековая переменная освобождается, эта область памяти становится доступной для других стековых переменных.
Из-за такой природы стека управление памятью оказывается весьма логичным и простым для выполнения на ЦП; это приводит к высокой скорости, в особенности потому, что время цикла обновления байта стека очень мало, т.е. этот байт скорее всего привязан к кэшу процессора. Тем не менее, у такой строгой формы управления есть и недостатки. Размер стека — это фиксированная величина, и превышение лимита выделенной на стеке памяти приведёт к переполнению стека. Размер задаётся при создании потока, и у каждой переменной есть максимальный размер, зависящий от типа данных. Это позволяет ограничивать размер некоторых переменных (например, целочисленных), и вынуждает заранее объявлять размер более сложных типов данных (например, массивов), поскольку стек не позволит им изменить его. Кроме того, переменные, расположенные на стеке, всегда являются локальными.
В итоге стек позволяет управлять памятью наиболее эффективным образом — но если вам нужно использовать динамические структуры данных или глобальные переменные, то стоит обратить внимание на кучу.
Куча — это хранилище памяти, также расположенное в ОЗУ, которое допускает динамическое выделение памяти и не работает по принципу стека: это просто склад для ваших переменных. Когда вы выделяете в куче участок памяти для хранения переменной, к ней можно обратиться не только в потоке, но и во всем приложении. Именно так определяются глобальные переменные. По завершении приложения все выделенные участки памяти освобождаются. Размер кучи задаётся при запуске приложения, но, в отличие от стека, он ограничен лишь физически, и это позволяет создавать динамические переменные.
27–29 декабря, Онлайн, Беcплатно
Вы взаимодействуете с кучей посредством ссылок, обычно называемых указателями — это переменные, чьи значения являются адресами других переменных. Создавая указатель, вы указываете на местоположение памяти в куче, что задаёт начальное значение переменной и говорит программе, где получить доступ к этому значению. Из-за динамической природы кучи ЦП не принимает участия в контроле над ней; в языках без сборщика мусора (C, C++) разработчику нужно вручную освобождать участки памяти, которые больше не нужны. Если этого не делать, могут возникнуть утечки и фрагментация памяти, что существенно замедлит работу кучи.
В сравнении со стеком, куча работает медленнее, поскольку переменные разбросаны по памяти, а не сидят на верхушке стека. Некорректное управление памятью в куче приводит к замедлению её работы; тем не менее, это не уменьшает её важности — если вам нужно работать с динамическими или глобальными переменными, пользуйтесь кучей.
Заключение
Вот вы и познакомились с понятиями стека и кучи. Вкратце, стек — это очень быстрое хранилище памяти, работающее по принципу LIFO и управляемое процессором. Но эти преимущества приводят к ограниченному размеру стека и специальному способу получения значений. Для того, чтобы избежать этих ограничений, можно пользоваться кучей — она позволяет создавать динамические и глобальные переменные — но управлять памятью должен либо сборщик мусора, либо сам программист, да и работает куча медленнее.
Обзор Stack Memory
Стековая память — это раздел памяти, используемый функциями c целью хранения таких данных, как локальные переменные и параметры, которые будут использоваться вредоносным ПО для осуществления своей злонамеренной деятельности на взломанном устройстве.
Эта статья является третьей в серии из четырех частей, посвященных x64dbg:
Часть 3. Обзор Stack Memory (мы здесь)
Часть 4. Анализ вредоносного ПО с помощью x64dbg
Что такое стековая память?
Стековую память часто называют LIFO (last in, first out; «последним пришёл — первым вышел»). Представьте ее как стопку строительных кирпичей, уложенных друг на друга: вы не можете взять кирпич из середины, так как стопка упадет, поэтому сначала нужно брать самый верхний кирпич. Так работает стек.
В предыдущей статье мы объяснили, что такое регистры в x64dbg, а также разобрали некоторые основные ассемблерные инструкции. Эта информация необходима для понимания работы стека. Когда в стек добавляются новые данные, вредоносная программа использует команду PUSH. Чтобы удалить элемент из стека, вирус использует команду POP. Данные также могут быть выведены из стека в регистр.
Регистр «ESP» используется для обозначения следующего элемента в стеке и называется «указателем стека».
EBP (он же «указатель кадра») служит неизменной точкой отсчета для данных в стеке. Этот регистр позволяет программе определить, насколько далеко от этой точки находится элемент стека. Так, если переменная находится на расстоянии двух «строительных блоков», то это [EBP+8], так как каждый «блок» в стеке равен 4 байтам.
Каждая функция в программе будет генерировать собственный стековый кадр для ссылки на свои переменные и параметры с использованием этого метода.
Архитектура стековой памяти
Следующая схема поможет проиллюстрировать, как стек формируется подобно набору строительных блоков:
Низкие адреса памяти расположены вверху, а высокие адреса — внизу.
Каждая функция генеририрует собственный стековый кадр, поэтому стековый кадр в приведенном выше примере может накладываться на другой кадр, который используется для другой функции.
EBP, как упоминалось ранее, хранится в качестве неизменной точки отсчета в стеке — это делается путем перемещения значения ESP (указателя стека) в EBP. Мы делаем это, поскольку ESP будет меняться, так как он всегда указывает на вершину стека. Хранение его в EBP дает нам неизменную точку отсчета в стеке, и теперь функция может ссылаться на свои переменные и параметры в стеке из этого места.
В этом примере переданные в функцию параметры хранятся в [EBP]+8, [EBP]+12 и [EBP]+16. Поэтому, когда мы видим [EBP]+8, это расстояние в стеке от EBP.
Переменные будут сохраняться после начала выполнения функции, поэтому они будут располагаться выше по стеку, но в более низком адресном пространстве, поэтому в данном примере они отображаются как [EBP]-4.
Пример стековой памяти
Чтобы проиллюстрировать информацию выше, приведем пример простой программы на языке C, которая вызывает функцию addFunc, складывает два числа (1+4) и выводит результат на экран.
Если сосредоточиться на коде функции addFunc, то в качестве аргументов передаются два параметра (a и b) и локальная переменная c, в которой хранится результат. После компиляции программы ее можно загрузить в x64dbg. Ниже показано, как будет выглядеть ассемблерный код для этой программы:
Первые три строки — это так называемый пролог функции, именно здесь в стеке создается пространство для функции.
push ebp сохраняет ESP (предыдущий указатель стекового кадра), чтобы к нему можно было вернуться в конце функции. Стековый кадр используется для хранения локальных переменных, и каждая функция будет иметь собственный стековый кадр в памяти.
mov ebp, esp перемещает текущую позицию стека в EBP, которая является основанием стека. Теперь у нас есть точка отсчета, которая позволяет ссылаться на наши локальные переменные, хранящиеся в стеке. Значение EBP больше никогда не меняется.
sub esp, 10 увеличивает стек на 16 байт (10 в шестнадцатеричном формате), чтобы выделить место в стеке для любых переменных, на которые нам нужно ссылаться.
Ниже показано, как будет выглядеть стек для этой программы. Каждый используемый фрагмент данных располагается друг над другом на участке памяти в соответствии с приведенной ранее схемой.
EBP-10
EBP-C
EBP-8
EBP-4 (int c)
EBP = Помещается в стек в начале функции. Это начало нашего стекового кадра.
EBP+4 = Адрес возврата к предыдущей функции
EBP+8 = Параметр 1 (int a)
EBP+C = Параметр 2 (int b)
Данный пример иллюстрирует, что в этом стеке выделено место для четырех локальных переменных, однако у нас есть только одна переменная — int c.
mov edx,dword ptr ss:[ebp+8]: здесь мы перемещаем int a (со значением 1) в регистр EDX.
Важной частью здесь является [ebp+8]. Квадратные скобки означают, что вы напрямую обращаетесь к памяти в этом месте. Это ссылка на место в памяти, которое находится на 8 байт выше в стеке, чем то, что находится в EBP.
Ранее мы упоминали, что параметры, перемещаемые в функцию, всегда находятся в более высоком адресном пространстве, которое расположено ниже по стеку. Наши параметры int a и int b были переданы в функцию до создания стекового кадра, поэтому они находятся в ebp+8 и ebp+c.
mov eax,dword ptr ss:[ebp+C]: то же самое, что и выше, но теперь мы ссылаемся на ebp+C, то есть int b (значение 4), и перемещаем его в регистр EAX.
Команда add eax, edx добавляет и сохраняет результат в EAX.
mov dword ptr ss:[ebp-4],eax: здесь мы перемещаем результат, хранящийся в EAX, в локальную переменную int c.
Локальная переменная «c» определяется внутри функции, поэтому она находится в более низком адресе памяти, чем верхняя часть стека. Таким образом, поскольку она находится внутри стекового кадра и имеет размер 4 байта, мы можем просто использовать часть пространства, ранее выделенного для переменных, вычитая 10 из esp, и в этом случае воспользоваться EBP-4.
mov eax,dword ptr ss:[ebp-4]: большинство функций возвращают значение, хранящееся в EAX, поэтому если выше возвращаемое значение находилось в EAX и мы переместили его в переменную «c», то здесь оно просто помещается обратно в EAX, готовое к возврату.
Leave: это маска для операции, которая перемещает EBP обратно в ESP и выводит его из стека, т. е. подготавливает стековый кадр функции, которая вызвала эту функцию.
Команда ret перенаправляет к обратному адресу для возвращения к вызываемой функции, у которой есть хорошо сохранившийся стековый кадр, потому что мы запомнили его в начале этой функции.
Практический пример: стековая память и x64dbg
В предыдущей статье было показано, как распаковать вредоносные программы с помощью x64dbg. Теперь мы рассмотрим некоторые функции, используемые вредоносным ПО, и то, как используется стек.
Сначала откройте распакованную вредоносную программу в x64dbg; в данном примере программа называется просто 267_unpacked.bin.
Перейдите к точке входа вредоносной программы, выбрав Debug («Отладка»), а затем Run («Выполнить»).
Сейчас мы находимся в точке входа вредоносной программы, и я выделил два окна, которые содержат информацию о стековой памяти:
В первом окне показаны параметры, которые были перенесены в стек. Мы знаем, что это параметры, а не переменные, так как это esp+, а не esp-, как объяснялось ранее.
Второе окно — это собственно стековая память:
Первый столбец — это список адресов в стековой памяти. Как упоминалось ранее, высокие адреса находятся внизу, а низкие — вверху.
Второй столбец содержит данные, которые были помещены в стек, а синие скобки представляют отдельные стековые кадры. Помните, что каждая функция будет иметь собственный стековый кадр для хранения своих параметров.
В третьем столбце содержится информация о том, что он автоматически заполняется утилитой x64dbg. В этом примере мы видим адреса, куда x64dbg вернется после выполнения функции.
На изображении ниже первой командой, на которую указывает EIP, является push ebp; текущее значение в EBP, которое выделено на изображении ниже, — 0038FDE8.
Посмотрев на окно стека, мы выделили этот адрес, который является текущим базовым указателем стекового кадра.
При нажатии кнопки Step over («Обойти») EBP помещается в стек, и после завершения этой функции вредоносная программа может вернуться к этому адресу.
Теперь нам нужно переместить наш текущий указатель стека в ESP — это адрес 0038FDDC, выделенный ниже.
Выполнение этой команды перемещает ESP в регистр EBP, который выделен ниже.
Затем вредоносная программа освобождает место в стеке путем вычитания 420 из ESP. Она использует вычитание для создания области в нижнем адресном пространстве, которое находится выше по стеку (на следующем изображении показано нижнее адресное пространство над текущим стековым кадром).
Затем выполнение команды sub esp, 420 обновляет стек.
Обратите внимание, что теперь мы находимся в более низком адресном пространстве, которое находится выше по стеку, а ESP обновлен для отображения нового местоположения на вершине стека.
Это общая схема, которую вы увидите при запуске функций вредоносных программ и с которой вам предстоит познакомиться.
Далее идут три инструкции push, которые перемещают значения трех регистров в стек. Как и ожидалось, пошаговое выполнение данных инструкций обновляет стек и окно параметров:
На следующем этапе рассмотрим одну из функций, написанных хакером, и разберемся, что выполняет функция и как задействуется стек.
На изображении ниже мышь наведена на функцию 267_unpacked.101AEC9, при выполнении которой в x64dbg появляется всплывающее окно с предварительным просмотром данной функции. Это позволяет пользователю увидеть часть ассемблерного кода вызываемой функции. В этом всплывающем окне мы видим, что большое количество строк перемещается в переменные, и мы знаем, что это переменные, благодаря синтаксису ebp-. Такие строки представляют собой обфусцированные API вызовов Windows, которые будут использоваться вредоносным ПО для выполнения различных действий, таких как создание процессов и файлов на диске.
Перейдя к этой функции, мы можем более детально рассмотреть, что происходит, а также увидеть, как стек используется в x64dbg.
В правом нижнем углу выделен созданный стековый кадр, и снова, как и предполагалось, у нас есть пролог функции.
Вход в эту функцию теперь обновляет ESP, который является адресом 0038F9AC в стековой памяти, содержащей адрес возврата к функции main, а также создает пространство в стеке путем вычитания 630 из ESP. Инструкции, начинающиеся с mov, перемещают имена хешированных функций в свои собственные переменные.
Прокручивая вниз ассемблерный код, мы доходим до конца функции и видим несколько вызовов функций, которые используются для деобфускации хешей, только что перенесенных в переменные.
Выделенные команды — это так называемый «эпилог функции», который очищает стек после завершения функции. Мы переходим к этим инструкциям, выбрав интересующую нас инструкцию, «add esp, C», а затем выбрав «Debug» на панели инструментов и «Run until selection».
Это обновляет EIP согласно инструкции, которую мы выделили, а также показывает стек перед очисткой.
В прологе функции для создания пространства в стеке вредоносная программа должна была произвести вычитание из ESP, чтобы иметь возможность выделить место в стеке, которое находилось в нижнем адресном пространстве. Теперь нам нужно удалить выделенное пространство, поэтому, выполнив команду add esp, C, мы добавляем шестнадцатеричное значение «C» в стек, чтобы переместиться вниз в более высокое адресное пространство.
На изображении ниже показан обновленный стек после выполнения команды add esp, C.
Следующая команда — mov esp, ebp, которая переместит значение в EBP в ESP. Наш текущий EBP — 0042F3EC. Прокручивая вниз данные в окне стека, мы видим, что этот адрес содержит наш старый ESP, который является указателем стека.
Выполнение этой команды очищает стек.
Команда pop ebp открывает адрес 00E0CDA8, который хранился в верхней части стека, и перемещает его в EBP.
Это означает, что при выполнении следующей инструкции ret мы вернемся к адресу 00E0CDA8.
На изображении выше показано, что мы вернулись к «основной» функции вредоносного кода и находимся по адресу 00E0CDA8, сразу после функции, которую мы только что проанализировали в x64dbg.
Теперь вы готовы приступить к реверс-инжинирингу вредоносного ПО с помощью x64dbg! В следующей статье мы разберем, как применить знания, полученные в предыдущих статьях блога, чтобы выполнить обратную разработку на практике.