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

Идентификация констант и смещений


"То, что для одного человека константа, для другого - переменная"

Алан Перлис "Афоризмы программирования"

Микропроцессоры серии 80x86 поддерживают операнды трех типов: регистр, непосредственное значение, непосредственный указатель. Тип операнда явно задается в специальном поле машинной инструкции, именуемом "mod", поэтому никаких проблем в идентификации типов операндов не возникает. Регистр – ну, все мы знаем, как выглядят регистры; указатель по общепринятому соглашению заключается в угловые скобки, а непосредственное значение записывается без них. Например:

MOV ECX, EAX;                   ß регистровый операнды

MOV ECX, 0x666;                                 ß левый операнд регистровый, правый – непосредственный

MOV [0x401020], EAX         ß левый операнд – указатель, правый – регистр

Кроме этого микропроцессоры серии 80x86 поддерживают два вида адресации памяти: непосредственную и косвенную. Тип адресации определяется типом указателя. Если операнд – непосредственный указатель, то и адресация непосредственна. Если же операнд-указатель – регистр, – такая адресация называется косвенной. Например:

MOV ECX,[0x401020] ß

непосредственная адресация

MOV ECX, [EAX]             ß

косвенная адресация

Для инициализации регистрового указателя разработчики микропроцессора ввели специальную команду – "LEA REG, [addr]" – вычисляющую значение адресного выражения addr и присваивающую его регистру REG. Например:

LEA EAX, [0x401020] ; регистру EAX



присваивается значение указателя 0x401020

MOV ECX, [EAX]             ; косвенная адресация – загрузка в ECX

двойного слова,

; расположенного по смещению 0x401020

Правый операнд команды LEA всегда представляет собой ближний (near) указатель. (Исключение составляют случаи использования LEA для сложения констант – подробнее об этом смотри в одноименном пункте). И все было бы хорошо…. да вот, оказывается, внутреннее представление ближнего указателя эквивалентно константе того же значения.
Отсюда – "LEA EAX, [0x401020]" равносильно "MOV EAX,0x401020". В силу определенных причин MOV значительно обогнал в популярности "LEA", практически вытеснив последнюю инструкцию из употребления.

Изгнание "LEA" породило фундаментальную проблему ассемблирования - "проблему OFFSETа". В общих чертах ее суть заключается в синтаксической неразличимости констант и смещений (ближних указателей). Конструкция "MOV EAX, 0x401020" может грузить в EAX и константу, равную 0x401020

(пример соответствующего Си-кода: a=0x401020), и указатель на ячейку памяти, расположенную по смещению 0x401020 (пример соответствующего Си-кода: a=&x). Согласитесь, a=0x401020 совсем не одно и тоже, что a=&x! А теперь представьте, что произойдет, если в заново ассемблированной программе переменная "x" в силу некоторых обстоятельств окажется расположена по иному смещению, а не 0x401020? Правильно, - программа рухнет, ибо указатель "a" по-прежнему указывает на ячейку памяти 0x401020, но здесь теперь "проживает" совсем другая переменная!

Почему переменная может изменить свое смещение? Основных причин тому две. Во-первых, язык ассемблера неоднозначен и допускает двоякую интерпретацию. Например, конструкции "ADD EAX, 0x66" соответствуют две машинные инструкции: "83 C0 66" и "05 66 00 00 00" длиной три и пять байт соответственно. Транслятор может выбрать любую из них и не факт, что ту же самую, которая была в исходной программе (до дизассемблирования). Неверно "угаданный" размер вызовет уплывание всех остальных инструкций, а вместе с ними и данных. Во-вторых, уплывание не замедлит вызвать модификация программы (разумеется, речь идет не о замене JZ

на JNZ, а настоящей адоптации или модернизации) и все указатели тут же "посыпаться".

Вернуть работоспособность программы помогает директива "offset". Если "MOV EAX, 0x401020" действительно



загружает в EAX

указатель, а не константу, по смещению 0x401020

следует создать метку, именуемую, скажем, "loc_401020", и "MOV EAX, 0x401020" заменить на "MOV EAX, offset loc_401020". Теперь указатель EAX связан не с фиксированным смещением, а с меткой!

А что произойдет, если предварить директивой offset

константу, ошибочно приняв ее за указатель? Программа откажет в работе или станет работать некорректно. Допустим, число 0x401020

выражало собой объем бассейна через одну трубу в который что-то втекает, а через другую – вытекает. Если заменить константу указателем, то объем бассейна станет равен… смещению метки в заново ассемблированной программе и все расчеты полетят к черту.



Рисунок 18  0х010 Типы операндов



Рисунок 19 0х011 Типы адресаций

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

Определение типа непосредственного операнда. Непосредственный операнд команды LEA

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

Непосредственные операнды команд MOV и PUSH

могут быть как константами, так и указателями. Чтобы определить тип непосредственного операнда, необходимо проанализировать: как используется его значение в программе. Если для косвенной адресации памяти – это указатель, в противном случае – константа.

Например, встретили мы в тексте программы команду "MOV EAX, 0x401020" (см. рис 19), - что это такое: константа или указатель? Ответ на вопрос дает строка "MOV ECX, [EAX]", подсказывающая, что значение "0x401020" используется для косвенной адресации памяти, следовательно, непосредственный операнд – ни что иное, как указатель.



Существует два типа указателей – указатели на данные и указатели на функцию. Указатели на данные используются для извлечения значения ячейки памяти и встречаются в арифметических командах и командах пересылки (например – MOV, ADD, SUB). Указатели на функцию используются в командах косвенного вызова и, реже, в командах косвенного перехода – CALL и JMP соответственно.

Рассмотрим следующий пример:

main()

{

static int a=0x777;

int *b = &a;

int c=b[0];

}

Листинг 123 Константы и указатели

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

main         proc near

var_8        = dword      ptr -8

var_4        = dword      ptr -4

push   ebp

mov    ebp, esp

sub    esp, 8

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

mov    [ebp+var_4], 410000h

; Загружаем в локальную переменную var_4 значение 0x410000

; Пока мы не можем определить его тип – константа это или указатель

mov    eax, [ebp+var_4]

; Загружаем содержимое локальной переменной var_4 в регистр EAX

mov    ecx, [eax]

; Загружаем в ECX содержимое ячейки памяти на которую указывает указатель EAX

; Ага! Значит, EAX все-таки указатель. Тогда локальная переменная var_4,

; откуда он был загружен, тоже указатель

; И непосредственный операнд 0x410000 – указатель, а не константа!

; Следовательно, чтобы сохранить работоспособность программы, создадим по

; смещению 0x410000 метку loc_410000, ячейку памяти, расположенную по этому

; адресу преобразует в двойное слово, и MOV

[ebp+var_4], 410000h заменим на:

; MOV [ebp+var_4], offset loc_410000

mov    [ebp+var_8], ecx

; Присваиваем локальной переменной var_8 значение *var_4 ([offset loc_41000])

mov    esp, ebp

pop    ebp

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

retn

main         endp

Листинг 124

Рассмотрим теперь пример с косвенным вызовом процедуры:

func(int a, int b)

{

return a+b;

};

main()

{

int (*zzz) (int a, int b) = func;

// Вызов функции происходит косвенно – по указателю zzz



zzz(0x666,0x777);

}

Листинг 125 Пример, демонстрирующий косвенный вызов процедуры

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

.text:0040100B main proc near           ; CODE XREF: start+AFp

.text:0040100B

.text:0040100B var_4       dword ptr -4

.text:0040100B

.text:0040100B             push    ebp

.text:0040100C             mov     ebp, esp

.text:0040100C             ; Открываем кадр стека

.text:0040100C

.text:0040100E             push    ecx

.text:0040100E             ; Выделяем память для локальной переменной var_4

.text:0040100E

.text:0040100F             mov     [ebp+var_4], 401000h

.text:0040100F             ; Присваиваем локальной переменной значение 0x401000

.text:0040100F             ; Пока еще мы не можем сказать – константа это или смещение

.text:0040100F

.text:00401016             push    777h

.text:00401016             ; Заносим значение 0x777 в стек. Константа это или указатель?

.text:00401016             ; Пока сказать невозможно – необходимо проанализировать

.text:00401016             ; вызываемую функцию

.text:00401016

.text:0040101B             push    666h

.text:0040101B             ; Заносим в стек непосредственное значение 0x666

.text:0040101B

.text:00401020             call    [ebp+var_4]

.text:00401020             ; Смотрите: косвенный вызов функции!

.text:00401020             ; Значит, переменная var_4 – указатель, раз так, то и

.text:00401020             ; присваиваемое ей непосредственное знаечние

.text:00401020             ; 0x401000 – тоже указатель!

.text:00401020             ; А по адресу 0x401000 расположена вызываемая функция!

.text:00401020             ; Окрестим ее каким-нибудь именем, например, MyFunc

и

.text:00401020             ; заменим mov

[ebp+var_4], 401000h на

.text:00401020             ; mov [ebp+var_4], offset MyFunc

.text:00401020             ; после чего можно будет смело модифицировать программу

.text:00401020             ; теперь-то она уже не "развалится"!



.text:00401020

.text:00401023             add     esp, 8

.text:00401023

.text:00401026             mov     esp, ebp

.text:00401028             pop     ebp

.text:00401028             ; Закрываем кадр стека

.text:00401028

.text:00401029                    retn

.text:00401029 main        endp

.text:00401000 MyFunc             proc near

.text:00401000 ; А вот и косвенно вызываемая функция MyFunc

.text:00401000 ; Исследуем ее, чтобы определить тип передаваемых ей

.text:00401000 ; непосредственных значений

.text:00401000

.text:00401000 arg_0              = dword ptr  8

.text:00401000 arg_4              = dword ptr  0Ch

.text:00401000 ; Ага, вот они, наши аргументы!

.text:00401000

.text:00401000             push    ebp

.text:00401001             mov     ebp, esp

.text:00401001             ; Открываем кадр стека

.text:00401001

.text:00401003             mov     eax, [ebp+arg_0]

.text:00401003             ; Загружаем в EAX

значение аргумента arg_0

.text:00401003

.text:00401006             add     eax, [ebp+arg_4]

.text:00401006             ; Складываем EAX

(arg_0) со значением аргумента arg_0

.text:00401006             ; Операция сложения намекает, что по крайней мере один из

.text:00401006             ; двух аргументов не указатель, т.к. сложение двух указателей

.text:00401006             ; бессмысленно (см. "Сложные случаи адресации")

.text:00401006

.text:00401009             pop     ebp

.text:00401009             ; Закрываем кадр стека

.text:00401009

.text:0040100A             retn

.text:0040100A             ; Выходим, возвращая в EAX

сумму двух аргументов

.text:0040100A             ; Как мы видим, ни здесь, ни в вызывающей функции,

.text:0040100A             ; непосредственные значения 0x666 и 0x777 не использовались

.text:0040100A             ; для адресации памяти – значит, это константы

.text:0040100A

.text:0040100A MyFunc             endp

.text:0040100A

Листинг 126

Сложные случаи адресации или математические операции с указателями. Си/Си++ и некоторые языки программирования допускают выполнение над указателями различных арифметических операций, чем серьезно затрудняют идентификацию типов непосредственных операндов.



В самом деле, если бы такие операции с указателями были запрещены, то любая математическая инструкция, манипулирующая с непосредственным операндом, однозначно указывала на его константный тип.

К счастью, даже в тех языках, где это разрешено, над указателями выполняется ограниченное число математических операций. Так, совершенно бессмысленно сложение двух указателей, а уж тем более умножение или деление их друг на друга. Вычитание – дело другое. Используя тот факт, что компилятор располагает функции в памяти согласно порядку их объявления в программе, можно вычислить размер функции, отнимая ее указатель от указателя на следующую функцию (см. рис. 20). Такой трюк встречается в упаковщиках (распаковщиках) исполняемых файлов, защитах с самомодифицирующимся кодом, но в прикладных программах используется редко.



Рисунок 20 0х012 Использования вычитания указателей для вычисления размера функции [структуры данных].

Сказанное выше относилось к случаям "указатель" + "указатель", между тем указатель может сочетаться и с константой. Причем, такое сочетание настолько популярно, что микропроцессоры серии 80x86 даже поддерживают для этого специальную адресацию – базовую. Пусть, к примеру, имеется указатель на массив и индекс некоторого элемента массива. Очевидно, чтобы получить значение этого элемента, необходимо сложить указатель с индексом, умноженным на размер элемента.

Вычитание константы из указателя встречается гораздо реже, - этому не только соответствует меньший круг задач, но и сами программисты избегают вычитания, поскольку оно нередко приводит к серьезным проблемам. Среди начинающих популярен следующий примем – если им требуется массив, начинающийся с единицы, они, объявив обычный массив, получают на него указатель и… уменьшают его на единицу! Элегантно, не правда ли? Нет, не правда, - подумайте, что произойдет, если указатель на массив будет равен нулю. Правильно, - "змея укусит" свой хвост, и указатель станет оч-чень большим положительным числом.


Вообще-то, под Windows 9x\ NT массив гарантированно не может быть размещен по нулевому смещению, но не стоит привыкать к трюкам, привязанным к одной платформе, и не работающим на других.

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

MOV EAX,0x...

MOV EBX,0x...

ADD EAX,EBX

MOV ECX,[EAX]

Летающий Слонопотам! Сумма двух непосредственных значений используется для косвенной адресации. Ну, положим, оба они указателями быть не могут, - исходя из самых общих соображений, – никак не должны. Наверняка одно из непосредственных значений – указатель на массив (структуру данных, объект), а другое – индекс в этом массиве. Для сохранения работоспособности программы указатель необходимо заменить смещением метки, а вот индекс оставить без изменений (ведь индекс – это константа).

Как же различить: что есть что? Увы, - нет универсального ответа, а в контексте приведенного выше примера – это и вовсе невозможно!

Рассмотрим следующий пример:

MyFunc(char *a, int i)

{

a[i]='\n';

a[i+1]=0;

}

main()

{

static char buff[]="Hello,Sailor!";

MyFunc(&buff[0], 5);

}

Листинг 127 Пример, демонстрирующий определение типов в комбинированных выражениях

Результат компиляции Microsoft Visual C++ должен выглядеть так:

main         proc near           ; CODE XREF: start+AFp

push   ebp

mov    ebp, esp

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

push   5

; Передаем функции MyFunc непосредственное значение 0x5

push   405030h

; Передаем функции MyFunc непосредственное значение 0x405030

call   MyFunc

add    esp, 8

; Вызываем MyFunc(0x405030, 0x5)

pop    ebp

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

retn

main         endp

MyFunc       proc near           ; CODE XREF: main+Ap



arg_0        = dword      ptr  8

arg_4        = dword      ptr  0Ch

push   ebp

mov    ebp, esp

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

mov    eax, [ebp+arg_0]

; Загружаем в EAX значение аргумента arg_0

; (arg_0 содержит непосредственное значение 0x405030)

add    eax, [ebp+arg_4]

; Складываем EAX со значением аргумента arg_4 (он содержит значение 0x5)

; Операция сложения указывает на то, что, по крайней мере, один из них

; константа, а другой – либо константа, либо указатель

mov    byte ptr [eax], 0Ah

; Ага! Сумма непосредственных значений используется для косвенной адресации

; памяти, значит, это константа и указатель. Но кто есть кто?

; Для ответа на этот вопрос нам необходимо понять смыл кода программы -

; чего же добивался программист сложением указателей?

; Предположим, что значение 0x5 – указатель. Логично?

; Да, вот не очень-то логично, - если это указатель, то указатель на что?

; Первые 64 килобайта адресного пространства Windows NT

заблокированы для

; "отлавливания" нулевых и неинициализированных указателей

; Ясно, что равным пяти указатель быть никак не может. Разве что программки

; использовал какой ни будь очень извращенный трюк.

; А если указатель – 0x401000? Выглядит правдоподобным легальным смещением...

; Кстати, что там у нас расположено? Секундочку...

; 00401000 db 'Hello,Sailor!',0

;

; Теперь все сходится – функции передан указатель на строку "Hello, Sailor!"

; (значение 0x401000) и индекс символа этой строки (значение 0x5),

; функция сложила указатель со строкой и записала в полученную ячейку символ \n

mov    ecx, [ebp+arg_0]

; В ECX заносится значение аргумента arg_0

; (как мы уже установили это – указатель)

add    ecx, [ebp+arg_4]

; Складываем arg_0 с arg_4 (как мы установили arg_4 – индекс)

mov    byte ptr [ecx+1], 0

; Сумма ECX используется для косвенной адресации памяти, точнее ковенно-базовой

; т.к. к сумме указателя и индекса прибавляется еще и единица и в эту ячейку



; памяти заносится ноль

; Наши выводы подтверждаются – функции передается указатель на строку и

; индекс первого "отсекаемого" символа строки

; Следовательно для сохранения работоспособности программы по смещению 0x401000

; необходимо создать метку "loc_s0", а PUSH 0x401000 в вызывающей функции

; заменить

на PUSH offset loc_s0

pop    ebp

retn

MyFunc       endp

Листинг 128

А теперь откомпилируем тот же самый пример компилятором Borland C++ 5.0 и сравним, чем он отличается от Microsoft Visual C++ (ниже для экономии места приведен код одной лишь функции MyFunc, функция main – практически идентична предыдущему примеру):

MyFunc       proc near           ; CODE XREF: _main+Dp

push   ebp

; Отрываем пустой кадр стека – нет локальных переменных

mov    byte ptr [eax+edx], 0Ah

; Ага, Borland C++ сразу сложил указатель с константой непосредственно в

; адресном выражении!

; Как определить какой из регистров константа, а какой указатель?

; Как и в предыдущем случае необходимо проанализировать их значение.

mov    byte ptr [eax+edx+1], 0

mov    ebp, esp

pop    ebp

; Закрытие кадра стека

retn

MyFunc       endp

Листинг 129

Порядок индексов и указателей. Открою маленький секрет – при сложении указателя с константой большинство компиляторов на первое место помещают указатель, а на второе – константу, каким бы ни было их расположение в исходной программе.

То есть, выражения "a[i]", "(a+i)[0]", "*(a+i)" и "*(i+a)" компилируются в один и тот же код! Даже если извратиться и написать так: "(0)[i+a]", компилятор все равно выдвинет 'a' на первое место. Что это – ослиная упрямость, игра случая или фича? Ответ до смешного прост – сложение указателя с константой дает указатель! Поэтому – результат вычислений всегда записывается в переменную типа "указатель".

Вернемся к последнему рассмотренному примеру, применив для анализа наше новое правило:



mov    eax, [ebp+arg_0]

; Загружаем в EAX значение аргумента arg_0

; (arg_0 содержит непосредственное значение 0x405030)

add    eax, [ebp+arg_4]

; Складываем EAX со значением аргумента arg_4 (он содержит значение 0x5)

; Операция сложения указывает на то, что, по крайней мере, один из них

; константа, а другой – либо константа, либо указатель

mov    byte ptr [eax], 0Ah

; Ага! Сумма непосредственных значений используется для косвенной адресации

; памяти, значит, это константа и указатель. Но кто из них кто?

; С большой степенью вероятности EAX – указатель, т.к. он стоит на первом

; месте, а var_4 – индекс, т.к. он стоит на втором

Листинг 130

Использование LEA для сложения констант. Инструкция LEA

широко используется компиляторами не только для инициализации указателей, но и сложения констант. Поскольку, внутренне представление констант и указателей идентично, результат сложения двух указателей идентичен сумме тождественных им констант. Т.е. "LEA EBX, [EBX+0x666] == ADD EBX, 0x666", однако по своим функциональным возможностям LEA значительно обгоняет ADD. Вот, например, "LEA ESI, [EAX*4+EBP-0x20]", - попробуйте то же самое "скормить" инструкции ADD!

Встретив в тексте программы команду LEA, не торопитесь навешивать на возвращенное ею значение ярлык "указатель", - с не меньшим успехом он может оказаться и константой! Если "подозреваемый" ни разу не используется в выражении косвенной адресации – никакой это не указатель, а самая настоящая константа!

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

1) В 32-разрядных Windows программах указатели могут принимать ограниченный диапазон значений. Доступный процессорам регион адресного пространства начинается со смещения 0x1.00.00

и простирается до смещения 0х80.00.00.00, а Windows 9x/Me и того меньше – от 0x40.00.00

до 0х80.00.00.00. Поэтому, все непосредственные значения, меньшие 0x1.00.00



и больше 0x80.00.00

представляют собой константы, а не указатели. Исключение составляет число ноль, обозначающее нулевой указатель. {>>> сноска некоторые защитные механизмы непосредственно обращаются к коду операционной системы, расположенному выше адреса 0x80.00.00}.

2) Если непосредственное значение смахивает на указатель – посмотрите, на что он указывает. Если по данному смещению находится пролог функции или осмысленная текстовая строка – скорее всего мы имеем дело с указателем, хотя может быть, это – всего лишь совпадение.

3) Загляните в таблицу перемещаемых элементов (см. "Шаг четвертый Знакомство с отладчиком :: Способ 0 Бряк на оригинальный пароль"). Если адрес "подследственного" непосредственного значения есть в таблице – это, несомненно, указатель. Беда в том, что большинство исполняемых файлов – неперемещаемы, и такой прием актуален лишь для исследования DLL (а DLL перемещаемы по определению).

К слову сказать, дизассемблер IDA Pro использует все три описанных способа для автоматического опознавания указателей. Подробнее об этом рассказывается в моей книге "Образ мышления – дизассемблер IDA" (глава "Настройки", стр. 408).

___Идентификация нулевых указателей. Нулевой указатель – это указатель, который ни на что не указывает. Чаще….  В языке Си/Си++ нулевые указатели выражаются константой 0, а в Паскале – ключевым словом nil, однако, внутреннее представление нулевого указателя не обязательно должно быть нулевым.

___Индекс – тоже указатель! Рассмотри

___16-разярднй код.

__не должно быть нераспознанных непосредсенных типов


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