Содержание статьи
- Идентификация локальных стековых переменных
- Адресация локальных переменных
- Детали технической реализации
- Идентификация механизма выделения памяти
- Идентификация регистровых и временных переменных
- Временные переменные
- Создание временных переменных для сохранения значения, возвращенного функцией, и результатов вычисления выражений
- Область видимости временных переменных
- Идентификация глобальных переменных
- Техника восстановления перекрестных ссылок
- Косвенная адресация глобальных переменных
- Заключение
«Тип переменной» в рассматриваемом нами случае не имеет отношения к типу данных, имеется в виду совокупность способов работы с переменными со стороны программиста и компилятора, период жизни переменных и места их обитания. В идеальном случае для восстановления алгоритма исследуемой программы неплохо выяснить тип данных переменных, но это уже вопрос соотношения времени, трудозатрат и выгоды от полученной информации.
Фундаментальные основы хакерства
Пятнадцать лет назад эпический труд Криса Касперски «Фундаментальные основы хакерства» был настольной книгой каждого начинающего исследователя в области компьютерной безопасности. Однако время идет, и знания, опубликованные Крисом, теряют актуальность. Редакторы «Хакера» попытались обновить этот объемный труд и перенести его из времен Windows 2000 и Visual Studio 6.0 во времена Windows 10 и Visual Studio 2019.
Ссылки на другие статьи из этого цикла ищи на странице автора.
Идентификация локальных стековых переменных
Локальные переменные размещаются в стеке (его также называют автоматической памятью), а удаляет их оттуда вызываемая функция, когда она завершится. Рассмотрим подробнее, как это происходит. Сначала в стек затягиваются аргументы, передаваемые функции (если они есть), а сверху на них кладется адрес возврата, помещаемый туда инструкцией CALL
, которая вызывает эту функцию. Получив управление, функция открывает кадр стека — сохраняет прежнее значение регистра RBP
и устанавливает его равным регистру RSP
(регистр — указатель вершины стека). «Выше» (то есть в более младших адресах) RBP
находится свободная область стека, «ниже» — служебные данные (сохраненный RBP
, адрес возврата) и аргументы.
Сохранность области стека, расположенной выше указателя вершины стека (регистра RSP
), не гарантирована от затирания и искажения. Ее беспрепятственно могут использовать, например, обработчики аппаратных прерываний, вызываемые в непредсказуемом месте в непредсказуемое время. Да и использование стека самой функцией (для сохранения регистров или передачи аргументов) приведет к его искажению. Какой выход? Принудительно переместить указатель вершины стека вверх, тем самым заняв данную область стека. Сохранность памяти, находящейся «ниже» RSP
, гарантируется (имеется в виду гарантируется от непреднамеренных искажений). Очередной вызов инструкции PUSH
занесет данные на вершину стека, не затирая локальные переменные.
По окончании работы функция обязана вернуть RSP
на прежнее место, иначе инструкция RET
снимет со стека отнюдь не адрес возврата, а вообще невесть что (значение самой «верхней» локальной переменной) и передаст управление «в космос»...
На левой картинке показано состояние стека на момент вызова функции. Она открывает кадр стека, сохраняя прежнее значение регистра RBP
, и устанавливает его равным RSP
. На правой картинке изображено резервирование 0x14
байт стековой памяти под локальные переменные. Резервирование достигается перемещением регистра RSP
«вверх» — в область младших адресов. Фактически локальные переменные размещаются в стеке так, как будто бы они были туда помещены командой PUSH
. При завершении работы функция увеличивает значение регистра RSP
, возвращая его на прежнюю позицию и освобождая тем самым память, занятую локальными переменными. Затем она стягивает со стека и восстанавливает значение RBP
, закрывая тем самым кадр стека.
Адресация локальных переменных
Адресация локальных переменных очень похожа на адресацию стековых аргументов, только аргументы располагаются «ниже» RBP
, а локальные переменные — «выше». Другими словами, аргументы имеют положительные смещения относительно RBP
, а локальные переменные — отрицательные, поэтому их очень легко отличить друг от друга. Например, [
— аргумент, а [
— локальная переменная.
Регистр — указатель кадра стека служит как бы барьером: по одну сторону от него аргументы функции, по другую — локальные переменные.
Теперь понятно, почему при открытии кадра стека значение RSP
копируется в RBP
, иначе адресация локальных переменных и аргументов значительно усложнилась бы, а разработчики компиляторов (как это ни странно) тоже люди и не хотят без нужды осложнять себе жизнь. Впрочем, оптимизирующие компиляторы умеют адресовать локальные переменные и аргументы непосредственно через RSP
, освобождая регистр RBP
для более важных целей.
Детали технической реализации
Существует множество вариантов реализации выделения и освобождения памяти под локальные переменные. Казалось бы, чем плохо очевидное SUB
на входе и ADD
на выходе? А вот некоторые компиляторы в стремлении отличаться ото всех остальных резервируют память не уменьшением, а увеличением RSP
... Да, на отрицательное число, которое по умолчанию большинством дизассемблеров отображается как очень большое положительное. Оптимизирующие компиляторы при отводе небольшого количества памяти изменяют SUB
на PUSH
, что на несколько байтов короче. Последнее создает очевидные проблемы идентификации: попробуй разберись, то ли перед нами сохранение регистров в стеке, то ли передача аргументов, то ли резервирование памяти для локальных переменных (подробнее об этом см. раздел «Идентификация механизма выделения памяти»).
Алгоритм освобождения памяти также неоднозначен. Помимо увеличения регистра указателя вершины стека инструкцией ADD
(или в особо извращенных компиляторах увеличения его на отрицательное число), часто встречается конструкция MOV
. Мы ведь помним, что при открытии кадра стека RSP
копировался в RBP
, а сам RBP
в процессе исполнения функции не изменялся. Наконец, память может быть освобождена инструкцией POP
, выталкивающей локальные переменные одну за другой в какой‑нибудь ненужный регистр (понятное дело, такой способ оправдывает себя лишь на небольшом количестве локальных переменных).
Идентификация механизма выделения памяти
Выделение памяти инструкциями SUB
и ADD
непротиворечиво и всегда интерпретируется однозначно. Если же оно выполняется командой PUSH
, а освобождение — POP
, эта конструкция становится неотличима от простого освобождения/сохранения регистров в стеке. Ситуация серьезно осложняется тем, что в функции присутствуют и настоящие команды сохранения регистров, сливаясь с командами выделения памяти. Как узнать, сколько байтов резервируется для локальных переменных и резервируются ли они вообще (может, в функции локальных переменных и нет вовсе)?
Ответить на этот вопрос позволяет поиск обращений к ячейкам памяти, лежащих «выше» регистра RBP
, то есть с отрицательными относительными смещениями. Рассмотрим два примера.
В левом из них никакого обращения к локальным переменным не происходит вообще, а в правом наличествует конструкция MOV [
, копирующая значение 0x666
в локальную переменную var_4
. А раз есть локальная переменная, для нее кем‑то должна быть выделена память. Поскольку инструкций SUB
и ADD
в теле функций не наблюдается, подозрение падает на PUSH
, так как сохраненное содержимое регистра RCX
располагается в стеке на четыре байта «выше» RBP
. В данном случае подозревается лишь одна команда — PUSH
, поскольку PUSH
на роль «резерватора» не тянет. Но как быть, если подозреваемых несколько?
Определить количество выделенной памяти можно по смещению самой «высокой» локальной переменной, которую удается обнаружить в теле функции. То есть, отыскав все выражения типа [
, выберем наибольшее смещение «xxx» — в общем случае оно равно количеству байтов выделенной под локальные переменные памяти. В частностях же встречаются объявленные, но не используемые локальные переменные. Им выделяется память (хотя оптимизирующие компиляторы просто выкидывают такие переменные за ненадобностью), но ни одного обращения к ним не происходит, и описанный выше алгоритм подсчета объема резервируемой памяти дает заниженный результат. Впрочем, эта ошибка никак не сказывается на результатах анализа программы.
Инициализация локальных переменных
Существует два способа инициализации локальных переменных: присвоение необходимого значения инструкцией MOV
(например, MOV [
) и непосредственное заталкивание значения в стек инструкцией PUSH
(например, PUSH
). Последнее позволяет выгодно комбинировать выделение памяти под локальные переменные с их инициализацией (разумеется, только в том случае, если этих переменных немного).
Популярные компиляторы в подавляющем большинстве случаев выполняют операцию инициализации с помощью MOV
, а PUSH
более характерен для ассемблерных извращений, которые встречаются, например, в защитах, имеющих задачу сбить хакеров с толку. Ну, если такой прием и смутит хакера, то только начинающего.
Размещение массивов и структур
Массивы и структуры размещаются в стеке последовательно в смежных ячейках памяти, при этом меньший индекс массива (элемент структуры) лежит по меньшему адресу, но — внимание — адресуется большим модулем смещения относительно регистра указателя кадра стека. Это не покажется удивительным, если вспомнить, что локальные переменные адресуются отрицательными смещениями, следовательно, [
> [
.
Путаницу усиливает и то обстоятельство, что, давая локальным переменным имена, IDA опускает знак минус. Поэтому из двух имен, скажем var_4
и var_10
, по меньшему адресу лежит то, чей индекс больше! Если var_4
и var_10
— это два конца массива, то с непривычки возникает непроизвольное желание поместить var_4
в голову, а var_10
в хвост массива, хотя на самом деле все наоборот!
Выравнивание в стеке
В некоторых случаях элементы структуры, массива и даже просто отдельные переменные требуется располагать по кратным адресам. Но ведь значение указателя вершины заранее не определено и неизвестно компилятору. Как же он, не зная фактического значения указателя, сможет выполнить это требование? Да очень просто — возьмет и откинет младшие биты RSP
!
Легко доказать, что, если младший бит равен нулю, число четное. Чтобы быть уверенным, что значение указателя вершины стека делится на два без остатка, достаточно лишь сбросить его младший бит. Сбросив два бита, мы получим значение, заведомо кратное четырем, три — восьми и так далее.
Сбрасывает биты в подавляющем большинстве случаев инструкция AND
. Например, AND
делает RSP
кратным шестнадцати. Как было получено это значение? Переводим 0xFFFFFFF0
в двоичный вид, получаем 11111111
. Видишь четыре нуля на конце? Значит, четыре младших бита любого числа будут маскированы и оно разделится без остатка на 24 = 16.
Хотя с локальными переменными мы уже неоднократно встречались при изучении прошлых примеров, не помешает сделать это еще один раз:
local_vars_identified
#include <stdio.h>#include <stdlib.h>int MyFunc(int a, int b) { int c; // Локальная переменная типа int char x[50]; // Массив (демонстрирует схему размещения массивов в памяти) c = a + b; // Заносим в c сумму аргументов a и b _itoa_s(c, &x[0], sizeof(x), 0x10); // Переводим сумму a и b в строку printf("%x == %s == ", c, &x[0]); // Выводим строку на экран return c;}int main() { // Объявляем локальные переменные a и b для того, чтобы // продемонстрировать механизм их инициализации компилятором. // Такие извращения понадобились для того, чтобы запретить // оптимизирующему компилятору помещать локальную переменную // в регистр (см. «Идентификация регистровых переменных») int a = 0x666; int b = 0x777; int c[1]; // Так как функции printf передается указатель на с, // а указатель на регистр быть передан не может, // компилятор вынужден оставить переменную в памяти c[0] = MyFunc(a, b); printf("%x\n", &c[0]); return 0;}
Результат выполнения программы local_vars_identified показан на картинке ниже.
Результат компиляции компилятором Microsoft Visual C++ 2019 в режиме реального времени с отключенной оптимизацией должен выглядеть так:
int MyFunc(int, int) proc near
Value = dword ptr -58h
DstBuf = byte ptr -50h
var_18 = qword ptr -18h
Локальные переменные располагаются по отрицательному смещению относительно RBP
, а аргументы функции — по положительному. Выше мы обсуждали, что большинство компиляторов (в том числе Visual C++) оптимизируют код, освобождая регистр RBP
, являющийся указателем кадра стека, чтобы он исполнял роль дополнительного регистра общего назначения. При этом функцию RBP
начинает выполнять RSP
(указатель на вершину стека). Заметь также: чем выше расположена переменная, тем больше модуль ее смещения.
arg_0 = dword ptr 8arg_8 = dword ptr 10h; Инициализируем аргументы в памяти, загружая их значения из регистров mov [rsp+arg_8], edx; О том, что это аргументы, а не нечто иное, говорит их положительное смещение относительно регистра RBP mov [rsp+arg_0], ecx; Уменьшаем значение RSP на 0x78, резервируя 0x78 байт под локальные переменные sub rsp, 78h mov rax, cs:__security_cookie xor rax, rsp mov [rsp+78h+var_18], rax; Вновь копируем аргументы в регистры для удобства последующих операций mov eax, [rsp+78h+arg_8] mov ecx, [rsp+78h+arg_0]; Складываем значения аргументов add ecx, eax mov eax, ecx
А вот и первая локальная переменная! На то, что это именно она и есть, указывает ее отрицательное смещение относительно регистра RBP
. Почему отрицательное? А посмотри, как IDA определила Value
. По моему личному мнению, было бы намного нагляднее, если бы отрицательные смещения локальных переменных подчеркивались более явно.
Продолжение доступно только участникам
Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
Вариант 2. Открой один материал
Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.
Я уже участник «Xakep.ru»