Идентификация констант и смещений
"То, что для одного человека константа, для другого - переменная"
Алан Перлис "Афоризмы программирования"
Микропроцессоры серии 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-разярднй код.
__не должно быть нераспознанных непосредсенных типов