Фундаментальные основы хакерства

       

Идентификация регистровых и временных переменных


Ничто не постоянно так, как временное

Народная мудрость

Стремясь минимализировать количество обращений к памяти, оптимизирующие компиляторы размещают наиболее интенсивно используемые локальные переменные в регистрах общего назначения, только по необходимости сохраняя их в стеке (а в идеальном случае не сохраняя их вовсе).

Какие трудности для анализа это создает? Во-первых, вводит контекстную зависимость в код. Так, увидев в любой точке функции команду типа "MOV EAX,[EBP+var_10]", мы с уверенностью можем утверждать, что здесь в регистр EAX копируется содержимое переменной var_10. А что эта за переменная? Это можно легко узнать, пройдясь по телу функции на предмет поиска всех вхождений "var_10", - они-то и подскажут назначение переменной!

С регистровыми переменными этот номер не пройдет! Положим, нам встретилась инструкция "MOV EAX,ESI" и мы хотим отследить все обращения к регистровой переменной ESI. Как быть, ведь поиск подстроки "ESI" в теле функции ничего не даст, вернее, напротив, выдаст множество ложных срабатываний. Ведь один и тот же регистр (в нашем случае ESI) может использоваться (и используется) для временного хранения множества различных переменных! Поскольку, регистров общего назначения всего семь, да к тому же EBP

"закреплен" за указателем кадра стека, а EAX и EDX

– за возвращаемым значением функции, остается всего четыре регистра, пригодных для хранения локальных переменных. А в Си++ программах и того меньше – один из этих четырех идет под указатель на виртуальную таблицу, а другой – под указатель на экземпляр this. Плохи дела! С двумя регистрами особо не разгонишься, - в типичной функции локальных переменных – десятки! Вот компилятор и использует регистры как кэш, - только в исключительных случаях каждая локальная переменная сидит в "своем" регистре, чаще всего переменных хаотично скачут по регистрам, временами сохраняются в стеке, зачастую выталкиваясь совсем в другой регистр (не в тот, чье содержимое сохранялась).


Практически все распространенные дизассемблеры (в том числе и IDA) не в состоянии отслеживать "миграции" регистровых переменных и эту операцию приходится выполнять вручную. Определить содержимое интересующего регистра в произвольной точке программы достаточно просто, хотя и утомительно, - достаточно прогнать программу с начала функции до этой точки на "эмуляторе Pentium-а", работающего в голове, отслеживая все операции пересылки. Гораздо сложнее выяснить какое количество локальных переменных хранится в данном регистре. Когда большое количество переменных отображается на небольшое число регистров, однозначно восстановить отображение становится невозможно. Вот, например: программист объявляет переменную 'a', - компилятор помещает ее в регистр X. Затем, некоторое время спустя программист объявляет переменную 'b', - и, если переменная 'a' более не используется (что бывает довольно часто), компилятор может поместить в тот же самый регистр X переменную 'b', не заботясь о сохранении значения 'a' (а зачем его сохранять, если оно не нужно). В результате – мы "теряем" одну переменную. На первый взгляд здесь нет никаких проблем. Теряем, - ну и ладно! Теоретически это мог сделать и сам программист, - спрашивается: зачем он вводил 'b', когда для работы вполне достаточно одной 'a'? Если переменные 'a' и 'b' имеют один тип – то никаких проблем, действительно, не возникает, но в противном случае анализ программы будет чрезвычайно затруднен.

Перейдем к технике идентификации регистровых переменных. Во многих хакерских руководствах утверждается, что регистровая переменная отличается от остальных тем, что никогда не обращается к памяти вообще. Это неверно, регистровые переменные могут временно сохраняться в стеке командой PUSH

и восстанавливаться обратно – POP. Конечно, в некотором "высшем смысле" такая переменная перестает быть регистровой, но и не становится стековой. Чтобы не дробить типы переменных на множество классов, условимся считать, что (как утверждают другие хакерские руководства) – регистровая переменная, это переменная, содержащаяся в регистре общего назначения, возможно, сохраняемая в стеке, но всегда на вершине, а не в кадре стека.


Другими словами, регистровые переменные никогда не адресуются через EBP. Если переменная адресуется через EBP, следовательно, она "прописана" в кадре стека, и является стековой переменной. Правильно? Нет! Посмотрите, что произойдет, если регистровой переменной 'a' присвоить значение стековой переменной 'b'. Компилятор сгенерирует приблизительно следующий код "MOV REG, [EBP-xxx]", соответственно, присвоение стековой переменной значения регистровой будет выглядеть так: "MOV [EBP-xxx], REG". Но, несмотря на явное обращение к кадру стека, переменная REG

все же остается регистровой переменной. Рассмотрим следующий код:

...



MOV [EBP-0x4], 0x666

MOV ESI, [EBP-0x4]

MOV [EBP-0x8], ESI

MOV ESI, 0x777

SUB ESI, [EBP-0x8]

MOV [EBP-0xC], ESI

...

Листинг 114

Его можно интерпретировать двояко – то ли действительно существует некая регистровая переменная ESI (тогда исходный тест примера должен выглядеть как показано в листинге 115-а), то ли регистр ESI используется как временная переменная для пересылки данных (тогда исходный текст примера должен выглядеть как показано в листинге 1115-б):

int var_4=0x666;                  int var_4=0x666;

int var_8=var_4;                  register {>>> см. сноску}int ESI = var_4;

int vac_C=0x777 – var_8           int var_8=ESI;

ESI=0x777-var_8;

int var_C = ESI

а)                                б)

Листинг 115

Притом, что алгоритм обоих листингом абсолютно идентичен, левый из них заметно выигрывает в наглядности у правого. А главная цель дизассемблирования – отнюдь не воспроизведение подлинного исходного текста программы, а реконструирование ее алгоритма. Совершенно безразлично, что представляет собой ESI – регистровую или временную переменную. Главное – чтобы костюмчик сидел. Т.е. из нескольких вариантов интерпретации выбирайте самый наглядный!

Вот мы и подошли к понятию временных переменных, но, прежде чем заняться его изучением вплотную, завершим изучение регистровых переменных, исследованием следующего примера:



{>>> сноска | врезка В языках Си/Си++ существует ключевое слово "register" предназначенное для принудительного размещения переменных в регистрах. И все бы было хорошо, да подавляющее большинство компиляторов втихую игнорируют предписания программистов, размещая переменные там, где, по мнению компилятора, им будет "удобно". Разработчики компиляторов объясняют это тем, что компилятор лучше "знает" как построить наиболее эффективный код. Не надо, говорят они, пытаться помочь ему. Напрашивается следующая аналогия: пассажир говорит – мне надо в аэропорт, а таксист без возражений едет "куда удобнее".

Ну, не должна работа на компиляторе превращаться в войну с ним, ну никак не должна! Отказ разместить переменную в регистре вполне законен, но в таком случае компиляция должна быть прекращена с выдачей сообщения об ошибке, типа "убери register, а то компилить не буду!", или на худой конец – выводе предупреждения.}

main()

{

int a=0x666;

int b=0x777;

int c;

c=a+b;

printf("%x + %x = %x\n",a,b,c);

c=b-a;

printf("%x - %x = %x\n",a,b,c);

}

Листинг 116 Пример, демонстрирующий идентификацию регистровых переменных

Результат компиляции Borland C++ 5.x должен выглядеть приблизительно так:

; int __cdecl main(int argc,const char **argv,const char *envp)

_main        proc near           ; DATA XREF: DATA:00407044o

argc         = dword      ptr  8

argv         = dword      ptr  0Ch

envp         = dword      ptr  10h

; Обратите внимание – IDA не распознала ни одной стековой переменной,

; хотя они объявлялись в программе.

; Выходит, компилятор разместил их в регистрах

push   ebp

mov    ebp, esp

; Открываем кадр стека

push   ebx

push   esi

; Сохраняем регистры в стеке или выделяем память для стековых переменных?

; Поскольку, IDA не обнаружила ни одной стековой переменной, вероятнее всего,

; этот код сохраняет регистры

mov    ebx, 666h



; Смотрите: инициализируем регистр! Сравните это с примером 112, приведенным в

; главе "Идентификация локальных стековых переменных". Помните, там было:

; mov  [ebp+var_4], 666h

; Следовательно, можно заподозрить, что EBX

– это регистровая переменная

; Существование переменной доказывает тот факт, что если бы значение 0x666

; непосредственно передавалось функции т.е. так – printf("%x %x

%x\n", 0x666)

; Компилятор бы и поместил в код инструкцию "PUSH

0x666"

; А раз не так, следовательно: значение 0x666 передавалось через переменную

; Реконструируя исходный тест пишем:

; 1. int a=0x666

mov    esi, 777h

; Аналогично, ESI скорее всего представляет собой регистровую переменную

; 2. int b=0x777

lea    eax, [esi+ebx]

; Загружаем в EAX сумму ESI и EBX

; Нет, EAX – не указатель, это просто сложение такое хитрое

push   eax

; Передаем функции printf сумму регистровых переменных ESI

и EBX

; А вот, что такое EAX – уже интересно. Ее можно представить и самостоятельной

; переменной и непосредственной передачей суммы переменных a

и b

функции

; printf. Исходя из соображений удобочитаемости, выбираем последний вариант

; 3. printf (,,,,a+b)

push   esi

; Передаем функции printf регистровую переменную ESI, выше обозначенную нами

; как 'b'

; 3. printf(,,,b,a+b)

push   ebx

; Передаем функции printf регистровую переменную EBX, выше обозначенную как 'a'

; 3. printf(,,a,b,a+b)

push   offset aXXX  ; "%x + %x = %x"

; Передаем функции printf указатель на строку спецификаторов, судя по которой

; все три переменные имеют тип int

; 3. printf("%x + %x = %x", a, b, a + b)

call   _printf

add    esp, 10h

mov    eax, esi

; Копируем в EAX значение регистровой переменной ESI, обозначенную нами 'b'

; 4. int c=b

sub    eax, ebx

; Вычитаем от регистровой переменной EAX

('c') значение переменной EBX

('a')

; 5. c=c-a

push   eax

; Передаем функции printf разницу значений переменных EAX и EBX



; Ага! Мы видим, что от переменной 'c' можно отказаться, непосредственно

; передав функции printf разницу значений 'b' и 'a'. Вычеркиваем строку '5.'

; (совершаем откат), а вместо '4.' пишем следующее:

; 4. printf(,,,,b-a)

push   esi

; Передаем функции printf значение регистровой переменной ESI

('b')

; 4. printf(,,,b, b-a)

push   ebx

; Передаем функции printf значение регистровой переменной EBX

('a')

; 4. printf(,,a, b, b-a)

push   offset aXXX_0 ; "%x + %x = %x"

; Передаем функции printf указатель на строку спецификаторов, судя по которой

; все трое имеют тип int

; 4. printf("%x + %x = %x",a, b, b-a)

call   _printf

add    esp, 10h

xor    eax, eax

; Возвращаем в EAX нулевое значение

; return 0

pop    esi

pop    ebx

; Восстанавливаем регистры

pop    ebp

; Закрываем кадр стека

retn

; В итоге, реконструированный текст выглядит так:

; 1. int a=0x666

; 2. int b=0x777

; 3. printf("%x + %x = %x", a, b, a + b)

; 4. printf("%x + %x = %x", a, b, b - a)

;

; Сравнивая свой результат с оригинальным исходным текстом, с некоторой досадой

; обнаруживаем, что все-таки слегка ошиблись, выкинув переменную 'c'

; Однако эта ошибка отнюдь не загубила нашу работу, напротив, придала

; листингу более "причесанный" вид, облегчая его восприятие

; Впрочем, о вкусах не спорят, и если вы желаете точнее следовать ассемблерному

; коду, что ж, воля ваша – вводите еще и переменную 'c'. Это решение, кстати,

; имеет тот плюс, что не придется делать "отката" – переписывать уже

; реконструированные строки для удаления их них лишней переменной

_main        endp

Листинг 117

…когда же лебедь ушел от нас, мы его имя оставили себе, поскольку мы считали, что оно лебедю больше не понадобится

Алан Александр Милн.

"Дом в медвежьем углу"

(пер.Руднев, Т.Михайлова)

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


Для чего они нужны? Рассмотрим следующий пример: "int b=a". Если 'a' и 'b' – стековые переменные, то непосредственное присвоение невозможно, поскольку, в микропроцессорах серии 80x86 отсутствует адресация "память – память". Вот и приходится выполнять эту операцию в два этапа: "память à регистр" + "регистр à

память". Фактически компилятор генерирует следующий код:

register int tmp=a;          mov     eax, [ebp+var_4]

int  b=tmp;                                                     mov     [ebp+var_8], eax

где "tmp" – и есть временная переменная, создавая лишь на время выполнения операции "b=a", а затем уничтожаемая за ненадобностью.

Компиляторы (особенно оптимизирующие) всегда стремятся размещать временные переменные в регистрах, и только в крайних случаях заталкивают их в стек. Механизмы выделения памяти и способы чтения/записи временных переменных довольно разнообразны.

Сохранение переменных в стеке – обычная реакция компилятора на острый недостаток регистров. Целочисленные переменные чаще всего закидываются на вершину стека командой PUSH, а стягиваются оттуда командой POP. Встретив в тексте программы "тянитолкая" (инструкцию PUSH

в паре с соответствующей ей POP), сохраняющего содержимое инициализированного регистра, но не стековый аргумент функции (см. "Идентификация аргументов функции"), можно достаточно уверенно утверждать, что мы имеем дело с целочисленной временной переменной.

Выделение памяти под вещественные переменные и их инициализация в большинстве случаев происходят раздельно. Причина в том, что команды, позволяющей перебрасывать числа с вершины стека сопроцессора на вершину стека основного процессора, не существует и эту операцию приходится осуществлять вручную. Первым делом "приподнимается" регистр указатель вершины стека (обычно "SUB ESP, xxx"), затем в выделенные ячейки памяти записывается вещественное значение (обычно "FSTP [ESP]"), наконец, когда временная переменная становится не нужна, она удаляется из стека командой "ADD ESP, xxx" или подобной ей ("SUB, ESP, - xxx").



Подвинутые компиляторы (например, Microsoft Visual C++) умеют располагать временные переменные в аргументах, оставшихся на вершине стека после завершения последней вызванной функции. Разумеется, этот трюк применим исключительно к cdecl-, но не stdcall-функциям, ибо последние самостоятельно вычищают свои аргументы из стека (подробнее см. "Идентификация аргументов функций"). Мы уже сталкивались с таким приемом при исследовании механизма возврата значений функцией в главе "Идентификация значения, возвращаемого функцией".

Временные переменные размером свыше восьми байт (строки, массивы, структуры, объекты) практически всегда размешаются в стеке, заметно выделясь среди прочих типов своим механизмом инициализации – вместо традиционного MOV, здесь используется одна из команд циклической пересылки MOVSx, при необходимости предваренная префиксом повторения REP (Microsoft Visual C++, Borland C++), или несколько команд MOVSx

к ряду (WATCOM C).

Механизм выделения памяти под временные переменные практически идентичен механизму выделения памяти стековым локальным переменным, однако, никаких проблем идентификации не возникает. Во-первых, выделение памяти стековым переменным происходит сразу же после открытия кадра стека, а временным переменными – в любой точке функции. Во-вторых, временные переменные адресуются не через регистр указатель кадра стека, а через указатель вершины стека.

действие

методы







резервирование памяти

PUSH

SUB ESP, xxx

использовать стековые аргументы >>>#

освобождение памяти

POP

ADD ESP, xxx

запись переменной

PUSH

MOV [ESP+xxx],

MOVS

чтение переменной

POP

MOV , [ESP+xxx]

передача вызываемой функции

Таблица 15 Основные механизмы манипуляция со временными переменными

>>># Только в cdecl!

В каких же случаях компилятором создаются временные переменные? Вообще-то, это зависит от "нрава" самого компилятора (чужая душа – всегда потемки, а уж тем более – душа компилятора).


Однако можно выделить по крайней мере два случая, когда без создания временных переменных ну никак не обойтись: 1) при операциях присвоения, сложения, умножения; 2) в тех случаях, когда аргумент функции или член выражения – другая функция. Рассмотри оба случая подробнее.

::Создание временных переменных при пересылках данных и вычислении выражений. Как уже отмечалось выше, микропроцессоры серии 80x86 не поддерживают непосредственную пересылку данных из памяти в память, поэтому, присвоение одной переменной значения другой требует ввода временной регистровой переменной (при условии, что остальные переменные не регистровые).

Вычисление выражений (особенно сложных) так же требует временных переменных для хранения промежуточных результатов. Вот, например, сколько по-вашему требуется временных переменных для вычисления следующего выражения?

int a=0x1;int b=0x2;

int с= 1/((1-a) / (1-b));

Начнем со скобок, переписав их как: int tmp_d = 1; tmp_d=tmp_d-a; и int tmp_e=1; tmp_e=tmp_e-b; затем: int tmp_f = tmp_d / tmp_e; и наконец: tmp_j=1; c=tmp_j / tmp_f. Итого насчитываем…. раз, два, три, четыре, ага, четыре временных переменных. Не слишком ли много? Давайте попробуем записать это короче:

int tmp_d = 1;tmp_d=tmp_d-a;  // (1-a);

int tmp_e=1; tmp_e=tmp_e-b;   // (1-b);

tmp_d=tmp_d/tmp_e;         // (1-a) / (1-b);

tmp_e=1; tmp_e=tmp_e/tmp_d;

Как мы видим, вполне можно обойтись всего двумя временными переменными – совсем другое дело! А, что если бы выражение было чуточку посложнее? Скажем, присутствовало бы десять пар скобок вместо трех, - сколько бы тогда потребовалось временных переменных? Нет, не соблазняйтесь искушением сразу же заглянуть в ответ, - попробуйте сосчитать это сами! Уже сосчитали? Да что там считать – каким сложным выражение ни было – для его вычисления вполне достаточно всего двух временных переменных. А если раскрыть скобки, то можно ограничится и одной, однако, это потребует излишних вычислений. Этот вопрос во всех подробностях мы рассмотрим в главе "___Идентификация выражений", а сейчас посмотрим, что за код сгенерировал компилятор:



mov    [ebp+var_4], 1

mov    [ebp+var_8], 2

mov    [ebp+var_C], 3

; Инициализация локальных переменных

mov    eax, 1

; Вот вводится первая временная переменная

; В нее записывается непосредственное значение, т.к. команда, вычитания SUB,

; в силу архитектурных особенностей микропроцессоров серии 80x86 всегда

; записывает результат вычисления на место уменьшаемого и потому

; уменьшаемое не может быть непосредственным значением, вот и приходится

; вводить временную переменную

sub    eax, [ebp+var_4]

; tEAX

:= 1 – var_4

; в регистре EAX теперь хранится вычисленное значение (1-a)

mov    ecx, 1

; Вводится еще одна временная переменная, поскольку EAX

трогать нельзя –

; он занят

sub    ecx, [ebp+var_8]

; tECX

:= 1- var_8

; В регистре ECX теперь хранится вычисленное значение (1-b)

cdq

; Преобразуем двойное слово, лежащее в EAX

в четверное слово,

; помещаемое в EDX:EAX

; (машинная команда idiv всегда ожидает увидеть делимое именно в этих регистрах)

idiv   ecx

; Делим (1-a) на (1-b), помещая частое в tEAX

; Прежнее значение временной переменной при этом неизбежно затирается, однако,

; для дальнейших вычислений оно и не нужно

; Вот и пускай себе затирается – не беда!

mov    ecx, eax

; Копируем значение (1-a) / (1-b) в регистр ECX.

; Фактически, это новая временная переменная t2ECX, но в том же самом регистре

; (старое содержимое ECX нам так же уже не нужно)

; Индекс "2" после префикса "t" дан для того, чтобы показать, что t2ECX -

; вовсе не то же самое, что tECX, хотя обе эти временные переменные хранится

; в одном регистре

mov    eax, 1

; Заносим в EAX непосредственное значение 1

; Это еще одна временная переменная – t2EAX

cdq

; Обнуляем EDX

idiv   ecx

; Делим 1 на ((1-a) / (1-b))

; Частое помещается в EAX

mov    [ebp+var_10], eax

; c := 1 / ((1-a) / (1-b))

; Итак, для вычисления данного выражения потребовалось четыре временных



; переменных и всего два регистра общего назначения

Листинг 118

::Создание временных переменных для сохранения значения, возращенного функцией, и результатов вычисления выражений. Большинство языков высокого уровня (в том числе и Си/Си++) допускают подстановку функций и выражений в качестве непосредственных аргументов. Например: "myfunc(a+b, myfunc_2(c))" Прежде, чем вызвать myfunc, компилятор должен вычислить значение выражения "a+b". Это легко, но возникает вопрос – во что записать результат сложения? Посмотрим, как с этим справится компилятор:

mov    eax, [ebp+var_C]

; Создается временная переменная tEAX

и в нее копируется значение

; локальной переменной var_C

push   eax

; Временная переменная tEAX сохраняется в стеке, передавая функции myfunc

; в качестве аргумента значение локальной переменной var_C

; Хотя, локальная переменная var_C

в принципе могла бы быть непосредственно

; передана функции – PUSH [ebp+var_4] и никаких временных переменных!

call   myfunc

add    esp, 4

; Функция myfunc возвращает свое значение в регистре EAX

; Его можно рассматривать как своего рода еще одну временную переменную

push   eax

; Передаем функции myfunc_2 результат, возвращенный функцией myfunc

mov    ecx, [ebp+var_4]

; Копируем в ECX значение локальной переменной var_4

; ECX

– еще одна временная переменная

; Правда, не совсем понятно почему компилятор не использовал регистр EAX,

; ведь предыдущая временная переменная ушла из области видимости и,

; стало быть, занимаемый ею регистр EAX

освободился...

add    ecx, [ebp+var_8]

; ECX := var_4 + var_8

push   ecx

; Передаем функции myfunc_2 сумму двух локальных переменных

call   _myfunc_2

Листинг 119

Область видимости временных переменных. Временные переменные – это, в некотором роде, очень локальные переменные. Область их видимости в большинстве случаев ограничена несколькими строками кода, вне контекста которых временная переменная не имеет никакого смысла.По большому счету, временная переменная не имеет смысла вообще и только загромождает код. В самом деле, myfunc(a+b)

намного короче и понятнее, чем int tmp=a+b; myfunc(tmp). Поэтому, чтобы не засорять дизассемблерный листинг, стремитесь не употреблять в комментариях временные переменные, подставляя вместо них их фактические значения. Сами же временные переменные разумно предварять каким ни будь характерным префиксом, например, "tmp_" (или "t" если вы патологический любитель краткости). Например:

MOV EAX, [EBP+var_4]       ; // var_8 := var_4

; ^ tEAX := var_4

ADD EAX, [EBP+var_8],      ; ^ tEAX += var_8

PUSH EAX            ; // MyFunc(var_4+var_8)

CALL MyFunc

Листинг 120


Содержание раздела