Суббота, 26.07.2025, 02:47 Приветствую Вас Гость

On-line: Книги, учебники, статьи

Главная | Регистрация | Вход | RSS

Глава 5. Основы анализа кода программ(1)

Глава 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);
}

Вход на сайт
Поиск
Календарь
«  Июль 2025  »
ПнВтСрЧтПтСбВс
 123456
78910111213
14151617181920
21222324252627
28293031
Архив записей
Наш опрос
Как Вам удобнее??
Всего ответов: 341
Мини-чат
Друзья сайта
  • Официальный блог
  • Сообщество uCoz
  • FAQ по системе
  • Инструкции для uCoz
  • Статистика

    Онлайн всего: 1
    Гостей: 1
    Пользователей: 0