Глава 5. Основы анализа кода программ
Большинство исполняемых модулей были написаны на языках высокого уровня и не содержат отладочной информации. Однако анализировать код программы и в этом случае приходится. Для того чтобы ускорить процесс анализа необходимо знать или, по крайней мере, иметь перед глазами некоторые стандартные ассемблерные структуры, соответствующие определенным структурам языков высокого уровня. Замечу, что речь, разумеется, будет идти о 32-битных приложениях.
I
Переменные и константы. Современные компиляторы довольно эффективно оптимизируют исходный код, поэтому не всегда просто разобраться, где какая переменная работает. В первую очередь это касается того, что для хранения части переменных, насколько это возможно, компилятор использует регистры. Если ему не хватит регистров, он начнет использовать память.
Для примера я взял простую консольную программу, написанную на Borland C++. В
текстовом варианте программа занимает полтора десятка строк, тогда как ЕХЕ-файл
имеет размер более 50 Кб. Впрочем, размер исполняемых файлов давно уже никого не
удивляет. Интересно другое: корректно справился с задачей, т.е. корректно выявил
точку входа - метку _main
, только один дизассемблер - IDA PRO.
Т.е., конечно, реально работающий участок программы дизассемблировали все, но
выявить, как происходит переход на участок, смог только упомянутый мной
дизассемблер. Приятно также и то, что аккуратно была распознана функция
_printf
. На Рис. 4.5.1 показан фрагмент дизассемблированной
программы, соответствующей основной процедуре main
. С другой
стороны, в данном случае нет никаких видимых возможностей быстрого поиска
данного фрагмента в отладчике. Осюда наглядно можно понять полезность
совместного использования отладчика и дизассемблера.
CODE 00401108 _main proc near ; DATA XREF: DATA:0040B044 CODE 00401108 CODE 00401108 argc = dword ptr 8 CODE 00401108 argv = dword ptr 0Ch CODE 00401108 envp = dword ptr 10h CODE 00401108 CODE 00401108 push ebp CODE 00401109 mov ebp, esp CODE 0040110B push ebx CODE 0040110C mov edx, offset unk_40D42C CODE 00401111 xor eax, eax CODE 00401113 CODE 00401113 loc_401113: ; CODE XREF: _main+22 CODE 00401113 mov ecx, 1Fh CODE 00401118 sub ecx, eax CODE 0040111A mov ebx, ds:off_40B074 CODE 00401120 mov cl, [ebx+ecx] CODE 00401123 mov [edx+eax], cl CODE 00401126 inc eax CODE 00401127 cmp eax, 21h CODE 0040112A jl short loc_401113 CODE 0040112C mov byte ptr [edx+20h], 0 CODE 00401130 push edx ; char CODE 00401131 push offset aS ;_va_args CODE 00401136 call _printf CODE 0040113B add esp, 8 CODE 0040113E pop ebx CODE 0040113F pop ebp CODE 00401140 retn CODE 00401140 _main endp
Рис. 4.5.1. Функция main консольного приложения. Дизассемблер с программы на Си, IDA PRO.
Попытаемся теперь понять, какая программа на Си была исходным источником данного фрагмента. Начнем с рассмотрения стандартных структур. Собственно, налицо только одна структура - цикл. Команда
CODE:0040112A jl short loc_401113и является ключевой в организации цикла. Команда же
inc eax
,
очевидно, инкрементирует параметр цикла. Т.о. в EAX
хранится некая
переменная, играющая роль параметра цикла. Назовем эту переменную
i
. Вышесказанное подтверждается и наличием команды xor
eax,eax
перед началом цикла. Команда эта, разумеется, эквивалентна просто
i=0
. Команда inc eax
означает просто i++
.
Попытаемся выявить еще переменные. Обратим внимание на команду CODE:0040110C mov edx, offset unk_40D42CКоманда весьма примечательна, т.к. в регистр
EDX
помещается
некий адрес, адрес чего-то. Проследим далее, как используется регистр
EDX
. Команда CODE:00401123 mov [edx+eax], clа также отсутствие в цикле других команд с регистром
EDX
убеждает нас, что EDX
играет роль указателя на строку, массив или
запись. Это нам предстоит сейчас выяснить. Тут примечательны две команды: CODE:0040112C mov byte ptr [edx+20h],0и
CODE:00401130 push edx ; char
Первая команда убеждает нас, что EDX
указывает на строку, т.к.
именно строка должна содержать в конце символ 0
. Вторая команда
является посылкой второго для функции printf
. Исходя из этого, а
также комментария IDA PRO (она раньше нас поняла, что это такое), заключаем, что
EDX
являет собой указатель на некую строку. Заметьте, что мы не
обратились к просмотру блока данных, что, несомненно, ускорило бы заключение.
Обозначим этот указатель как s1
. В этой связи выражение
[edx+eax]
можно трактовать как s1[i]
или как
*(s1+i)
. Рассмотрим теперь команду
CODE:0040111A mov ebx, ds:off_40B074
несомненно, означающую, что и регистр EBX
также представляет
собой указатель на строку (будем обозначать ее s2
), подтверждением
этому также служат последующие строки, означающие переброску символов из строки
s2
в строку s1
. Вот об этом следует поговорить
подробнее. Что означает последовательность команд
CODE:00401113 mov ecx, 1Fh CODE:00401118 sub ecx, eax
Значить она может только одно: на каждом шаге цикла в ECX
будут
находиться числа от 1FH
(31
) до 0
(последним значением регистра EAX
, то бишь переменной
i
, участвующим в команде sub ecx,eax
будет
1FH
). Поскольку в формировании содержимого регистра
ECX
участвует еще команда mov cx,1FH
, логично
предположить, что в регистре ECX
перед операцией перекачки символов
из одной строки в другую всегда будет находиться число 1FH-i
или
31-i
. Выражение [ebx+ecx]
тогда будет эквивалентно
s2[31-i]
или *(s2+31-i)
. В результате имеем, что
строки
CODE:00401120 mov cl, [ebx+ecx] CODE:00401123 mov [edx+eax], clпо сути, можно заменить на
s1[i]=s2[31-i]
. Думаю, что теперь
мы готовы рассмотреть целый фрагмент: CODE 00401111 xor eax, eax CODE 00401113 CODE 00401113 loc_401113: ; CODE XREF: _main+22 CODE 00401113 mov ecx, 1Fh CODE 00401118 sub ecx, eax CODE 0040111A mov ebx, ds:off_40B074 CODE 00401120 mov cl, [ebx+ecx] CODE 00401123 mov [edx+eax], cl CODE 00401126 inc eax CODE 00401127 cmp eax, 21h CODE 0040112A jl short loc_401113
Надеюсь, что не ошибусь, написав на языке Си следующий фрагмент:
i=0; do { s1[i]=s2[31-i] ; i++; } while(i>0x20)
Все бы хорошо, но не понятным остается наличие в цикле строки
CODE:0040111A mov ebx, ds:off_40B074
Почему бы ни поместить, ее, например, перед циклом. Ну что ж, оставим это на
совести компилятора. Что касается того, что цикл этот должен быть обязательно
циклом DO
, то это совсем не обязательно. В данном случае количество
шагов цикла задано явно, и можно сделать один условный переход в конце цикла.
Другими словами, приведенная выше структура могла быть результатом
оптимизационного преобразования цикла WHILE
.
Имеется и еще один вопрос: где хранятся строки s1
и
s2
? Здесь можно разобраться достаточно быстро.
main
является самой обычной процедурой, и если бы строки
являлись локальными переменными, то для них была бы зарезервирована область в
стеке процедуры путем вставки команды SUB ESP,N
(или ADD
ESP,-N
).
Таким образом, строковые переменные являются глобальными.
Интересно, что другие переменные, хотя они тоже локальные, хранятся в регистрах, в полном соответствии с принципом, что по мере возможности переменные следует хранить в регистрах. 56
Итак, окончательный результат наших изысканий может быть представлен на Рис. 4.5.2.
char s1[32]; char * s2="абвгдежзийклмнопрстуфхцчшщыьъэюя"; void main() { int i; i=0; do { s1[i]=s2[31-i]; i++; } while(i>32); s1[32]=0; printf("%s\n",s1); }
Рис. 4.5.2. Окончательный вариант программы на Си.
Ну, уж чтобы совсем покончить с рассматриваемым примером, отмечу, что
переменная s1
является неинициализированной, а переменная
s2
- инициализированной. Инициализированные переменные хранятся в
секции DATA
, неинициализированные - в BSS
(компилятор
Borland C++).
56 В старом Си для того, чтобы для хранения
переменных компилятор использовал регистры, переменные следует объявлять как
AUTO
.
II
В данном разделе мы рассмотрим некоторые классические структуры языка Си и их отображение в язык ассемблера.
1. Условные конструкции.
Неполная условная конструкция.
if (простое условие) { ... ... ... }если условие простое, то оно, разумеется, заменяется следующей возможной последовательностью
CMP EAX,1 JNZ L1 ... ... ... L1:
Полная условная конструкция.
if (простое условие) { ... ... ... } else { ... ... ... } CMP EAX,1 JNZ L1 ... ... ... JMP L2 L1: ... ... ... L2:
Вложенные условные конструкции.
Здесь все достаточно очевидно.
CMP EAX,1 JNZ L1 CMP EBX,2 JNZ L1 ... ... ... L1:
Что, конечно, равносильно одному составному условию, связанному союзом "И". Союз "ИЛИ", как известно, заменяется проверкой условий в блоке "ELSE".
2. Оператор switch или оператор выбора.
Оператор switch
весьма часто употребляется в функциях окон.
Хорошее знание его ассемблерной структуры поможет Вам легче отыскивать эти
функции в море ассемблерного кода.
switch(i) { case 1: ... ... ... break; case 3: ... ... ... break; case 5: ... ... ... break; }
А вот соответствующий данной структуре ассемблерный код.
DEC EAX JZ L1 SUB EAX,2 JZ L2 SUB EAX,2 JZ L3 JMP L4 L1: ... ... ... JMP L4 L2: ... ... ... JMP L4 L3: ... ... ... L4:
Структура, как видите, интересная. Такой подход позволяет наилучшим образом оптимизировать проверку большого количества условий. В действительности оператор выбора может кодироваться и другим способом. Вот еще один возможный вариант представления оператора выбора:
CMP EAX,10 JE L1 CMP EAX,5 JE L2 CMP EAX,11 JE L3 ...
3. Циклы.
С организацией цикла в языке Си мы уже познакомились в этой главе. Отмечу две наиболее часто практикуемые структуры.
1-я структура.
L1: ... ... ... CMP EAX,10 JL L1
2-я структура.
L2: CMP EAX,10 JA L1 ... ... ... JMP L2 L1:
Первая структура может быть несколько видоизменена и принимать следующий вид.
JMP L2 L1: ... ... ... CMP EAX,10 JL L1 L2:
Как по мановению волшебной палочки, в последней структуре цикл "ДО" превращается в цикл "ПОКА".
В своем рассмотрении я не упомянул цикл "FOR" - этого "монстра" языка Си, но, в принципе, это все тот же цикл "ПОКА".
4. Локальные переменные.
Чаще всего нам приходится работать с локальными переменными. С глобальными переменными мы уже встречались, они хранятся во вполне определенных секциях. Хороший дизассемблер их легко локализует. С локальными переменными часто разбираться гораздо сложнее. Особенно это касается записей или массивов. Хороший дизассемблер значительно упростит задачу. Рассмотрим, например, следующий фрагмент, взятый из IDA PRO.
CODE 00401108 _main proc near ; DATA XREF: DATA:0040B044 CODE 00401108 CODE 00401108 var_54 = dword ptr -54h CODE 00401108 var_28 = byte ptr -28h CODE 00401108 argc = dword ptr 8 CODE 00401108 argv = dword ptr 0Ch CODE 00401108 envp = dword ptr 10h CODE 00401108 CODE 00401108 push ebp CODE 00401109 mov ebp, esp CODE 0040110B add esp, 0FFFFFFACh CODE 0040110E push ebx CODE 0040110F xor ebx, ebx
Рис. 4.5.3. Пример задания двух локальных массивов. Взят из отладчика IDA PRO.
Взгляните внимательно на Рис. 4.5.3. Как видите, отладчик нам все прописал.
Две переменные var_54
и var_28
являются, несомненно,
массивами типа DWORD
. Причем если на первый отводится
28h
байт, т.е. 40 байт или 10 элементов массива, то на второй
54h-28h=2CH=44 или 11 элементов массива. И всего, следовательно, под локальные
переменные зарезервировано 84 байта. А что означает команда add
esp,0FFFFFFACH
? Но нас не обманешь! 0-0FFFFFFACH = 54h
, что
в десятичном исчислении и есть 84. В связи с массивами хотелось бы упомянуть,
что в Си изначально практиковалось два способа доступа к элементам массива:
посредством индексов и посредством указателей. Другими словами, можно было
написать: a[i]=10
и *(a+i)=10
. Причем вторая запись
оказывалась более эффективной (см. по этому поводу книгу [1], гл. 15.).
Разумеется, делать это можно и сейчас, но обе записи теперь в Borland C++ и
Microsoft C++ 6.0 приводят к совершенно одинаковым ассемблерным эквивалентам.
Что весьма отрадно - значит, компиляторы действительно развиваются. Кстати,
весьма поучительным было бы сравнение ассемблерного кода, производимого разными
компиляторами. Мы не будем этим заниматься, замечу только, что мое весьма
поверхностное сравнение компиляторов Borland C++ 5.0 и Visual C++ 6.0 привело к
выводу, что средства, разработанные фирмой Microsoft, несколько более эффективно
оптимизируют транслируемый код.
Но вернемся опять к фрагменту на Рис. 4.5.3. Посмотрим, как выглядит начало
функции main
в дизассемблере W32Dasm.
:00401108 55 push ebp :00401109 8ВЕС mov ebp, esp :0040110B 83C4AC add esp, FFFFFFAC :0040110E 53 push ebx :0040110F 33DB xor ebx, ebx
Рис. 4.5.4. Как выглядит фрагмент программы, представленный на Рис. 4.5.3 в окне дизассемблера W32Dasm.
Как видите, дизассемблер W32Dasm менее информативен, и из данного фрагмента можно заключить только, что для локальных переменных отведено 84 байта. Саму же структуру локальных переменных можно понять только путем анализа кода, который следует ниже.
5. Функции и процедуры.
Функции и процедуры идентифицируются достаточно просто. Структуру вызова и внутреннюю часть процедур Вы уже хорошо знаете. Остается только напомнить некоторые положения. Вызов процедуры:
PUSH par1 PUSH par2 PUSH par3 CALL 232343
Здесь все достаточно просто. Главное - распознать параметры и понять порядок
помещения их в стек. Надо также иметь в виду, что существует протокол передачи
параметров через регистры (см. главу 3.7). После вызова процедуры может стоять
команда очистки стека ADD ESP,N
.
Внутренняя часть процедуры также нами неоднократно разбиралась (см. Гл. 1.2, 3.7). Думаю, что она достаточно Вами узнаваема, и мы не будем здесь на этом останавливаться.
Не забывайте, что функции возвращают результат через регистр
EAX
. Это может помочь Вам быстро разобраться в назначении функции.
6. Оптимизация кода.
Выше мы пытались восстановить исходный текст программы по ассемблерному коду.
Насколько это удалось, можно выяснить, сравнив получившийся текст с исходным.
Следует заметить, что мы ошиблись: в исходном тексте стоит цикл
while
, а в нашем do
. Однако, если откомпилировать
получившуюся программу, она будет работать, так же как и исходная. Причина
отличия двух текстов программ (весьма простых, кстати) заключается в том, что
транслятор, кроме всего прочего, производит еще оптимизацию кода. Результат
оптимизации - принципиальная невозможность восстановить исходный текст по
исполняемому коду. Можно, однако, получить программный текст, правильно
описывающий алгоритм исполнения. Можно просто понять то, что делает данный код,
чем мы в данной главе и занимаемся.
Рассмотрим небольшую и весьма тривиальную программу на Си.
void main () { int i; char a[10]; char b[11]; for(i=0; i<10; i++) { a[i]='a'; *(b+i)='a'; printf("%c %c\n",a[i],b[i]); } ExitProcess(0); }