что такое стек процесса
Что такое стек
И почему так страшен стек-оверфлоу.
Постепенно осваиваем способы организации и хранения данных. Уже было про деревья, попробуем про стеки. Это для тех, кто хочет в будущем серьёзно работать в ИТ: одна из фундаментальных концепций, которая влияет на качество вашего кода, но не касается какого-то конкретного языка программирования.
👉 Стек — это одна из структур данных. Структура данных — это то, как хранятся данные: например, связанные списки, деревья, очереди, множества, хеш-таблицы, карты и даже кучи (heap).
Как устроен стек
Стек хранит последовательность данных. Связаны данные так: каждый элемент указывает на тот, который нужно использовать следующим. Это линейная связь — данные идут друг за другом и нужно брать их по очереди. Из середины стека брать нельзя.
👉 Главный принцип работы стека — данные, которые попали в стек недавно, используются первыми. Чем раньше попал — тем позже используется. После использования элемент стека исчезает, и верхним становится следующий элемент.
Классический способ объяснения принципов стека звучит так: представьте, что вы моете посуду и складываете одинаковые чистые тарелки стопкой друг на друга. Каждая новая тарелка — это элемент стека, а вы просто добавляете их по одной в стек.
Когда кому-то понадобится тарелка, он не будет брать её снизу или из середины — он возьмёт первую сверху, потом следующую и так далее.
🤔 Есть структура данных, похожая на стек, — называется очередь, или queue. Если в стеке кто последний пришёл, того первым заберут, то в очереди наоборот: кто раньше пришёл, тот раньше ушёл. Можно представить очередь в магазине: кто раньше её занял, тот первый дошёл до кассы. Очередь — это тоже линейный набор данных, но обрабатывается по-другому.
Стек вызовов
В программировании есть два вида стека — стек вызовов и стек данных.
Когда в программе есть подпрограммы — процедуры и функции, — то компьютеру нужно помнить, где он прервался в основном коде, чтобы выполнить подпрограмму. После выполнения он должен вернуться обратно и продолжить выполнять основной код. При этом если подпрограмма возвращает какие-то данные, то их тоже нужно запомнить и передать в основной код.
Чтобы это реализовать, компьютер использует стек вызовов — специальную область памяти, где хранит данные о точках перехода между фрагментами кода.
Допустим, у нас есть программа, внутри которой есть три функции, причём одна из них внутри вызывает другую. Нарисуем, чтобы было понятнее:
Программа запускается, потом идёт вызов синей функции. Она выполняется, и программа продолжает с того места, где остановилась. Потом выполняется зелёная функция, которая вызывает красную. Пока красная не закончит работу, все остальные ждут. Как только красная закончилась — продолжается зелёная, а после её окончания программа продолжает свою работу с того же места.
А вот как стек помогает это реализовать на практике:
Программа дошла до синей функции, сохранила точку, куда ей вернуться после того, как закончится функция, и если функция вернёт какие-то данные, то программа тоже их получит. Когда синяя функция закончится и программа получит верхний элемент стека, он автоматически исчезнет. Стек снова пустой.
С зелёной функцией всё то же самое — в стек заносится точка возврата, и программа начинает выполнять зелёную функцию. Но внутри неё мы вызываем красную, и вот что происходит:
При вызове красной функции в стек помещается новый элемент с информацией о данных, точке возврата и указанием на следующий элемент. Это значит, что когда красная функция закончит работу, то компьютер возьмёт из стека адрес возврата и вернёт управление снова зелёной функции, а красный элемент исчезнет. Когда и зелёная закончит работу, то компьютер из стека возьмёт новый адрес возврата и продолжит работу со старого места.
Переполнение стека
Почти всегда стек вызовов хранится в оперативной памяти и имеет определённый размер. Если у вас будет много вложенных вызовов или рекурсия с очень большой глубиной вложенности, то может случиться такая ситуация:
Переполнение — это плохо: данные могут залезать в чужую область памяти и записывать себя вместо прежних данных. Это может привести к сбою в работе других программ или самого компьютера. Ещё таким образом можно внедрить в оперативную память вредоносный код: если программа плохо работает со стеком, можно специально вызвать переполнение и записать в память что-нибудь вредоносное.
Стек данных
Стек данных очень похож на стек вызовов: по сути, это одна большая переменная, похожая на список или массив. Его чаще всего используют для работы с другими сложными типами данных: например, быстрого обхода деревьев, поиска всех возможных маршрутов по графу, — и для анализа разветвлённых однотипных данных.
Стек данных работает по такому же принципу, как и стек вызовов — элемент, который добавили последним, должен использоваться первым.
Что дальше
А дальше поговорим про тип данных под названием «куча». Да, такой есть, и с ним тоже можно эффективно работать. Стей тюнед.
Организация памяти процесса
Управление памятью – центральный аспект в работе операционных систем. Он оказывает основополагающее влияние на сферу программирования и системного администрирования. В нескольких последующих постах я коснусь вопросов, связанных с работой памяти. Упор будет сделан на практические аспекты, однако и детали внутреннего устройства игнорировать не будем. Рассматриваемые концепции являются достаточно общими, но проиллюстрированы в основном на примере Linux и Windows, выполняющихся на x86-32 компьютере. Первый пост описывает организацию памяти пользовательских процессов.
Каждый процесс в многозадачной ОС выполняется в собственной “песочнице”. Эта песочница представляет собой виртуальное адресное пространство, которое в 32-битном защищенном режиме всегда имеет размер равный 4 гигабайтам. Соответствие между виртуальным пространством и физической памятью описывается с помощью таблицы страниц (page table). Ядро создает и заполняет таблицы, а процессор обращается к ним при необходимости осуществить трансляцию адреса. Каждый процесс работает со своим набором таблиц. Есть один важный момент — концепция виртуальной адресации распространяется на все выполняемое ПО, включая и само ядро. По этой причине для него резервируется часть виртуального адресного пространства (т.н. kernel space).
Это конечно не значит, что ядро занимает все это пространство, просто данный диапазон адресов может быть использован для мэппирования любой части физического адресного пространства по выбору ядра. Страницы памяти, соответствующие kernel space, помечены в таблицах страниц как доступные исключительно для привилегированного кода (кольцо 2 или более привилегированное). При попытке обращения к этим страницам из user mode кода генерируется page fault. В случае с Linux, kernel space всегда присутствует в памяти процесса, и разные процессы мэппируют kernel space в одну и ту же область физической памяти. Таким образом, код и данные ядра всегда доступны при необходимости обработать прерывание или системный вызов. В противоположность, оперативная память, замэппированная в user mode space, меняется при каждом переключении контекста.
Синим цветом на рисунке отмечены области виртуального адресного пространства, которым в соответствие поставлены участки физической памяти; белым цветом — еще не использованные области. Как видно, Firefox использовал большую часть своего виртуального адресного пространства. Все мы знаем о легендарной прожорливости этой программы в отношении оперативной памяти. Синие полосы на рисунке — это сегменты памяти программы, такие как куча (heap), стек и так далее. Обратите внимание, что в данном случае под сегментами мы подразумеваем просто непрерывные адресные диапазоны. Это не те сегменты, о которых мы говорим при описании сегментации в Intel процессорах. Так или иначе, вот стандартная схема организации памяти процесса в Linux:
Давным давно, когда компьютерная техника находилась в совсем еще младенческом возрасте, начальные виртуальные адреса сегментов были совершенно одинаковыми почти для всех процессов, выполняемых машиной. Из-за этого значительно упрощалось удаленное эксплуатирование уязвимостей. Эксплойту часто необходимо обращаться к памяти по абсолютным адресам, например по некоторому адресу в стеке, по адресу библиотечной функции, и тому подобное. Хакер, рассчитывающий осуществить удаленную атаку, должен выбирать адреса для обращения в слепую в расчете на то, что размещение сегментов программы в памяти на разных машинах будет идентичным. И когда оно действительно идентичное, случается, что людей хакают. По этой причине, приобрел популярность механизм рандомизации расположения сегментов в адресном пространстве процесса. Linux рандомизирует расположение стека, сегмента для memory mapping, и кучи – их стартовый адрес вычисляется путем добавления смещения. К сожалению, 32-битное пространство не очень-то большое, и эффективность рандомизации в известной степени нивелируется.
В верхней части user mode space расположен стековый сегмент. Большинство языков программирования используют его для хранения локальных переменных и аргументов, переданных в функцию. Вызов функции или метода приводит к помещению в стек т.н. стекового фрейма. Когда функция возвращает управление, стековый фрейм уничтожается. Стек устроен достаточно просто — данные обрабатываются в соответствии с принципом «последним пришёл — первым обслужен» (LIFO). По этой причине, для отслеживания содержания стека не нужно сложных управляющих структур – достаточно всего лишь указателя на верхушку стека. Добавление данных в стек и их удаление – быстрая и четко определенная операция. Более того, многократное использование одних и тех же областей стекового сегмента приводит к тому, что они, как правило, находятся в кеше процессора, что еще более ускоряет доступ. Каждый тред в рамках процесса работает с собственным стеком.
Возможна ситуация, когда пространство, отведенное под стековый сегмент, не может вместить в себя добавляемые данные. В результате, будет сгенерирован page fault, который в Linux обрабатывается функцией expand_stack(). Она, в свою очередь, вызовет другую функцию — acct_stack_growth(), которая отвечает за проверку возможности увеличить стековый сегмент. Если размер стекового сегмента меньше значения константы RLIMIT_STACK (обычно 8 МБ), то он наращивается, и программа продолжает выполняться как ни в чем не бывало. Это стандартный механизм, посредством которого размер стекового сегмента увеличивается в соответствии с потребностями. Однако, если достигнут максимально разрещённый размер стекового сегмента, то происходит переполнение стека (stack overflow), и программе посылается сигнал Segmentation Fault. Стековый сегмент может увеличиваться при необходимости, но никогда не уменьшается, даже если сама стековая структура, содержащаяся в нем, становиться меньше. Подобно федеральному бюджету, стековый сегмент может только расти.
Динамическое наращивание стека – единственная ситуация, когда обращение к «немэппированной» области памяти, может быть расценено как валидная операция. Любое другое обращение приводит к генерации page fault, за которым следует Segmentation Fault. Некоторые используемые области помечены как read-only, и обращение к ним также приводит к Segmentation Fault.
Под стеком располагается сегмент для memory mapping. Ядро использует этот сегмент для мэппирования (отображания в память) содержимого файлов. Любое приложение может воспользоваться данным функционалом посредством системного вызовома mmap() (ссылка на описание реализации вызова mmap) или CreateFileMapping() / MapViewOfFile() в Windows. Отображение файлов в память – удобный и высокопроизводительный метод файлового ввода / вывода, и он используется, например, для загрузки динамических библиотек. Существует возможность осуществить анонимное отображение в память (anonymous memory mapping), в результате чего получим область, в которую не отображен никакой файл, и которая вместо этого используется для размещения разного рода данных, с которыми работает программа. Если в Linux запросить выделение большого блока памяти с помощью malloc(), то вместо того, чтобы выделить память в куче, стандартная библиотека C задействует механизм анонимного отображения. Слово «большой», в данном случае, означает величину в байтах большую, чем значение константы MMAP_THRESHOLD. По умолчанию, это величина равна 128 кБ, и может контролироваться через вызов mallopt().
Кстати о куче. Она идет следующей в нашем описании адресного пространства процесса. Подобно стеку, куча используется для выделения памяти во время выполнения программы. В отличие от стека, память, выделенная в куче, сохранится после того, как функция, вызвавшая выделение этой памяти, завершится. Большинство языков предоставляют средства управления памятью в куче. Таким образом, ядро и среда выполнения языка совместно осуществляют динамическое выделение дополнительной памяти. В языке C, интерфейсом для работы с кучей является семейство функций malloc(), в то время как в языках с поддержкой garbage collection, вроде C#, основной интерфейс – это оператор new.
Если текущий размер кучи позволяет выделить запрошенный объем памяти, то выделение может быть осуществлено средствами одной лишь среды выполнения, без привлечения ядра. В противном случае, функция malloc() задействует системный вызов brk() для необходимого увеличения кучи (ссылка на описание реализации вызова brk). Управление памятью в куче – нетривиальная задача, для решения которой используются сложные алгоритмы. Данные алгоритмы стремятся достичь высокой скорости и эффективности в условиях непредсказуемых и хаотичных пэттернов выделения памяти в наших программах. Время, затрачиваемое на каждый запрос по выделению памяти в куче, может разительно отличаться. Для решения данной проблемы, системы реального времени используют специализированные аллокаторы памяти. Куча также подвержена фрагментированию, что, к примеру, изображено на рисунке:
Наконец, мы добрались до сегментов, расположенных в нижней части адресного пространства процесса: BSS, сегмент данных (data segment) и сегмент кода (text segment). BSS и data сегмент хранят данные, соответствующий static переменным в исходном коде на C. Разница в том, что в BSS хранятся данные, соответствующие неинициализированным переменным, чьи значения явно не указаны в исходном коде (в действительности, там хранятся объекты, при создании которых в декларации переменной либо явно указано нулевое значение, либо значение изначально не указано, и в линкуемых файлах нет таких же common символов, с ненулевым значением. – прим. перевод.). Для сегмента BSS используется анонимное отображение в память, т.е. никакой файл в этот сегмент не мэппируется. Если в исходном файле на C использовать int cntActiveUsers, то место под соответствующий объект будет выделено в BSS.
В отличии от BSS, data cегмент хранит объекты, которым в исходном коде соответствуют декларации static переменных, инициализированных ненулевым значением. Этот сегмент памяти не является анонимным — в него мэппируется часть образа программы. Таким образом, если мы используем static int cntWorkerBees = 10, то место под соответствующий объект будет выделено в data сегменте, и оно будет хранить значение 10. Хотя в data сегмент отображается файл, это т.н. «приватный мэппинг» (private memory mapping). Это значит, что изменения данных в этом сегменте не повлияют на содержание соответствующего файла. Так и должно быть, иначе присвоения значений глобальным переменным привели бы к изменению содержания файла, хранящегося на диске. В данном случае это совсем не нужно!
Мы можем посмотреть, как используются области памяти процесса, прочитав содержимое файла /proc/pid_of_process/maps. Обратите внимание, что содержимое самого сегмента может состоять из различных областей. Например, каждой мэппируемой в memory mapping сегмент динамической библиотеке отводится своя область, и в ней можно выделить области для BSS и data сегментов библиотеки. В следующем посте поясним, что конкретно подразумевается под словом “область”. Учтите, что иногда люди говорят “data сегмент”, подразумевая под этим data + BSS + heap.
Можно использовать утилиты nm и objdump для просмотра содержимого бинарных исполняемых образов: символов, их адресов, сегментов и т.д. Наконец, то, что описано в этом посте – это так называемая “гибкая” организация памяти процесса (flexible memory layout), которая вот уже несколько лет используется в Linux по умолчанию. Данная схема предполагает, что у нас определено значение константы RLIMIT_STACK. Когда это не так, Linux использует т.н. классическую организации, которая изображена на рисунке:
Ну вот и все. На этом наш разговор об организации памяти процесса завершен. В следующем посте рассмотрим как ядро отслеживает размеры описанных областей памяти. Также коснемся вопроса мэппирования, какое отношение к этому имеет чтение и запись файлов, и что означают цифры, описывающие использование памяти.
О стеке простыми словами — для студентов и просто начинающих
Привет, я студент второго курса технического университета. После пропуска нескольких пар программирования по состоянию здоровья, я столкнулся с непониманием таких тем, как «Стек» и «Очередь». Путем проб и ошибок, спустя несколько дней, до меня наконец дошло, что это такое и с чем это едят. Чтобы у вас понимание не заняло столько времени, в данной статье я расскажу о том что такое «Стек», каким образом и на каких примерах я понял что это такое. Если вам понравится, я напишу вторую часть, которая будет затрагивать уже такое понятие, как «Очередь»
Теория
На Википедии определение стека звучит так:
Стек (англ. stack — стопка; читается стэк) — абстрактный тип данных, представляющий собой список элементов, организованных по принципу LIFO (англ. last in — first out, «последним пришёл — первым вышел»).
Поэтому первое, на чем бы я хотел заострить внимание, это представление стека в виде вещей из жизни. Первой на ум мне пришла интерпретация в виде стопки книг, где верхняя книга — это вершина.
На самом деле стек можно представить в виде стопки любых предметов будь то стопка листов, тетрадей, рубашек и тому подобное, но пример с книгами я думаю будет самым оптимальным.
Итак, из чего же состоит стек.
Стек состоит из ячеек(в примере — это книги), которые представлены в виде структуры, содержащей какие-либо данные и указатель типа данной структуры на следующий элемент.
Сложно? Не беда, давайте разбираться.
На данной картинке схематично изображен стек. Блок вида «Данные/*next» и есть наша ячейка. *next, как мы видим, указывает на следующий элемент, другими словами указатель *next хранит адрес следующей ячейки. Указатель *TOP указывает на вершину стек, то есть хранит её адрес.
С теорией закончили, перейдем к практике.
Практика
Для начала нам нужно создать структуру, которая будет являться нашей «ячейкой»
Новичкам возможно будет не понятно, зачем наш указатель — типа comp, точнее сказать указатель типа структуры comp. Объясню, для того чтобы указатель *next мог хранить структуру comp, ей нужно обозначить тип этой структуры. Другими словами указать, что будет хранить указатель.
После того как у нас задана «Ячейка», перейдем к созданию функций.
Функции
Функция создания «Стека»/добавления элемента в «Стек»
При добавлении элемента у нас возникнет две ситуации:
Разберем чуть чуть по-подробнее.
Во-первых, почему функция принимает **top, то есть указатель на указатель, для того чтобы вам было наиболее понятно, я оставлю рассмотрение этого вопроса на потом. Во-вторых, по-подробнее поговорим о q->next = *top и о том, что же означает ->.
-> означает то, что грубо говоря, мы заходим в нашу структуру и достаем оттуда элемент этой структуры. В строчке q->next = *top мы из нашей ячейки достаем указатель на следующий элемент *next и заменяем его на указатель, который указывает на вершину стека *top. Другими словами мы проводим связь, от нового элемента к вершине стека. Тут ничего сложного, все как с книгами. Новую книгу мы кладем ровно на вершину стопки, то есть проводим связь от новой книги к вершине стопки книг. После этого новая книга автоматически становится вершиной, так как стек не стопка книг, нам нужно указать, что новый элемент — вершина, для этого пишется: *top = q;.
Функция удаления элемента из «Стека» по данным
Данная функция будет удалять элемент из стека, если число Data ячейки(q->Data) будет равна числу, которое мы сами обозначим.
Здесь могут быть такие варианты:
Для лучшего понимания удаления элемента проведем аналогии с уже привычной стопкой книг. Если нам нужно убрать книгу сверху, мы её убираем, а книга под ней становится верхней. Тут то же самое, только в начале мы должны определить, что следующий элемент станет вершиной *top = q->next; и только потом удалить элемент free(q);
Если книга, которую нужно убрать находится между двумя книгами или между книгой и столом, предыдущая книга ляжет на следующую или на стол. Как мы уже поняли, книга у нас-это ячейка, а стол получается это NULL, то есть следующего элемента нет. Получается так же как с книгами, мы обозначаем, что предыдущая ячейка будет связана с последующей prev->next = q->next;, стоит отметить что prev->next может равняться как ячейке, так и нулю, в случае если q->next = NULL, то есть ячейки нет(книга ляжет на стол), после этого мы очищаем ячейку free(q).
Так же стоит отметить, что если не провести данную связь, участок ячеек, который лежит после удаленной ячейки станет недоступным, так как потеряется та самая связь, которая соединяет одну ячейку с другой и данный участок просто затеряется в памяти
Функция вывода данных стека на экран
Самая простая функция:
Здесь я думаю все понятно, хочу сказать лишь то, что q нужно воспринимать как бегунок, он бегает по всем ячейкам от вершины, куда мы его установили вначале: *q = top;, до последнего элемента.
Главная функция
Хорошо, основные функции по работе со стеком мы записали, вызываем.
Посмотрим код:
Вернемся к тому, почему же в функцию мы передавали указатель на указатель вершины. Дело в том, что если бы мы ввели в функцию только указатель на вершину, то «Стек» создавался и изменялся только внутри функции, в главной функции вершина бы как была, так и оставалась NULL. Передавая указатель на указатель мы изменяем вершину *top в главной функции. Получается если функция изменяет стек, нужно передавать в нее вершину указателем на указатель, так у нас было в функции s_push,s_delete_key. В функции s_print «Стек» не должен изменяться, поэтому мы передаем просто указатель на вершину.
Вместо цифр 1,2,3,4,5 можно так-же использовать переменные типа int.
Заключение
Полный код программы:
Так как в стек элементы постоянно добавляются на вершину, выводиться элементы будут в обратном порядке
В заключение хотелось бы поблагодарить за уделенное моей статье время, я очень надеюсь что данный материал помог некоторым начинающим программистам понять, что такое «Стек», как им пользоваться и в дальнейшем у них больше не возникнет проблем. Пишите в комментариях свое мнение, а так же о том, как мне улучшить свои статьи в будущем. Спасибо за внимание.
Обзор 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! В следующей статье мы разберем, как применить знания, полученные в предыдущих статьях блога, чтобы выполнить обратную разработку на практике.