Идентификация литералов и строк
Уже давно
Утихло поле боя,
Но сорок тысяч
Воинов Китая
Погибли здесь,
Пожертвовав собою...
Ду Фо "Оплакиваю поражение при Чэньтао"
Казалось бы, что может быть сложного в идентификации строк? Если то, на что ссылается указатель (см. "Идентификация указателейконстант и смещений") выглядит как строка, - это и есть строка! Более того, в подавляющем большинстве случаев строки обнаруживаются и идентифицируются тривиальным просмотром дампа программы (при условии, конечно, что они не зашифрованы, но шифровка – тема отдельного разговора). Так-то, оно так, да не все столь просто!
Задача "номер один" – автоматизированное выявление строк в программе, - ведь не пролистывать же мегабайтовые дампы вручную? Существует множество алгоритмов идентификации строк. Самый простой (но не самый надежный) основан на двух следующих тезисах:
1) строка состоит из ограниченного ассортимента символов. В грубом приближении это – цифры, буквы алфавита (включая проблел), знаки препинания и служебные символы наподобие табуляции или возврата каретки;
2) строка должна состоять по крайней мере из нескольких символов.
Условимся считать минимальную длину строки равной N байтам, тогда для автоматического выявления всех строк достаточно отыскать все последовательности из N и более "строковых" символов. Весь вопрос в том, чему должна быть равна N, и какие символы включать в "строковые".
Если N мало, порядка трех-четырех байт, то мы получим очень большое количество ложных срабатываний. Напротив, когда N велико, порядка шести-восьми байт, число ложных срабатываний близко к нулю и ими можно пренебречь, но все короткие строки, например "OK", "YES", "NO" окажутся нераспознаны! Другая проблема – помимо знакоцифровых символов в строках встречаются и элементы псевдографики (особенно часты они в консольных приложениях), и всякие там "мордашки", "стрелки", "карапузики" – словом почти вся таблица ASCII.
Чем же тогда строка отличается от случайной последовательности байт? Частотный анализ бессилен – ему для нормальной работы требуется как минимум сотня байт текста, а мы говорим о строках из двух-трех символов!
Зайдем с другого конца – если в программе есть строка, значит, на нее кто-нибудь да ссылается. А раз так – можно поискать среди непосредственных значений указатель на распознанную строку. И, если он будет найден, шансы на то, что это действительно именно строка, а не случайная последовательность байт резко возрастают. Все просто, не так ли?
Просто, да не совсем! Рассмотрим следующим пример:
BEGIN
WriteLn('Hello, Sailor!');
END.
Листинг 131
Откомпилирует его любым подходящим Pascal-компилятором (например, Delphi или Free Pascal) и, загрузив откомпилированный файл в дизассемблер, пройдемся вдоль сегмента данных. Вскоре на глаза попадется следующее:
.data:00404040 unk_404040 db 0Eh ;
.data:00404041 db 48h ; H
.data:00404042 db 65h ; e
.data:00404043 db 6Ch ; l
.data:00404044 db 6Ch ; l
.data:00404045 db 6Fh ; o
.data:00404046 db 2Ch ; ,
.data:00404047 db 20h ;
.data:00404048 db 53h ; S
.data:00404049 db 61h ; a
.data:0040404A db 69h ; i
.data:0040404B db 6Ch ; l
.data:0040404C db 6Fh ; o
.data:0040404D db 72h ; r
.data:0040404E db 21h ; !
.data:0040404F db 0 ;
.data:00404050 word_404050 dw 1332h
Листинг 132
Вот она, искомая строка! (В том, что это строка – у нас никаких сомнений нет). Попробуем найти: кто на нее ссылается? В IDA Pro для этого следует нажать <ALT-I> и в поле поиска ввести смещение начала строки – "0x404041"…
Как это "ничего не найдено – Search Failed"? А что же тогда передается функции WriteLn? Может быть, это глюк IDA? Просматриваем дизассемблерный текст вручную – результат вновь нулевой.
Причина нашей неудачи в том, что в начале Pascal- строк идет байт, содержащий длину этой строки. Действительно, в дампе по смещению 0x404040
находится значение 0xE
(четырнадцать в десятичной системе исчисления). А сколько символов строке "Hello, Sailor!"? Считаем: один, два, три… четырнадцать! Вновь нажимаем <ALT-I> и ищем непосредственный операнд, равный 0x404040. И, в самом деле, находим:
.text:00401033 push 404040h
.text:00401038 push [ebp+var_4]
.text:0040103B push 0
.text:0040103D call FPC_WRITE_TEXT_SHORTSTR
.text:00401042 push [ebp+var_4]
.text:00401045 call FPC_WRITELN_END
.text:0040104A push offset loc_40102A
.text:0040104F call FPC_IOCHECK
.text:00401054 call FPC_DO_EXIT
.text:00401059 leave
.text:0040105A retn
Листинг 133
Отказывается, мало идентифицировать строку – еще, как минимум, требуется определить ее границы.
Наиболее популярны следующие типы строк: Си-строки, завершающиеся нулем; DOS-строки, завершающиеся символом "$"; Pascal-строки, предваряемые одним-, двух- или четырехбайтным полем, содержащим длину строки. Рассмотрим каждый из этих типов подробнее:
::Си-строки, так же именуемые ASCIIZ-строками (от Zero – нуль на конце) – весьма распространенный тип строк, широко использующийся в операционных системах семейств Windows и UNIX. Символ "\0" (не путать с "0") имеет специальное предназначение и трактуется по-особому – как завершитель строки. Длина ASCIIZ-строк практически ничем не ограничена – ну разве что размером адресного пространства, выделенного процессу или протяженностью сегмента. Соответственно, в Windows 9x\NT максимальный размер ASCIIZ-строки лишь немногим менее 2 гигабайт, а в Windows 3.1 и MS-DOS – около 64 килобайт. Фактическая длина ASCIIZ-строк лишь на байт длиннее исходной ASCII-строки.
Несмотря на перечисленные выше достоинства, Си-строкам присущи и некоторые недостатки. Во-первых, ASCIIZ-строка не может содержать нулевых байт, и поэтому, она не пригодна для обработки бинарных данных. Во-вторых, операции копирования, сравнения и контакции Си-строк сопряжены со значительными накладными расходами – современным процессорам не выгодно работать с отдельными байтами, – им желательно иметь дело с двойными словами. Но, увы, длина ASCIIZ-строк наперед неизвестна и ее приходится вычислять "на лету", проверяя каждый байт на символ завершения. Правда, разработчики некоторых компиляторов идут на хитрость – они завершают строку семью
нулями, - что позволяет работать с двойными словами, а это на порядок быстрее. Почему семью, а не четырьмя? Ведь в двойном слове байтов четыре! Да, верно, четыре, но подумайте, что произойдет, если последний значимый символ строки придется на первый байт двойного слова? Верно, его конец заполнят три нулевых байта, но двойное слово из-за вмешательства первого символа уже не будет равно нулю! Вот поэтому, следующему двойному слову надо предоставить еще четыре нулевых байта, тогда оно гарантировано будет равно нулю. Впрочем, семь служебных байт на каждую строку – это уже перебор!
::DOS-строки. В MS-DOS функция вывода строки воспринимает знак '$' как символ завершения, поэтому в программистских кулуарах такие строки называют "DOS-строками". Термин не совсем корректен – все остальные функции MS-DOS работают исключительно с ASCIIZ-строками! Причина выбора столь странного выбора символа-разделителя восходит к тем древнейшим временам, когда никакого графического интерфейса еще и в помине не существовало, а консольный терминал считался весьма продвинутой системой взаимодействия с пользователем. Клавиша <Enter> не могла служить завершителем строки, т.к. под час приходилось вводить в программу несколько строк сразу. Комбинации <Ctrl-Z>, или <Alt-000> так же не годились – на многих клавиатурах тех лет отсутствовали такие регистры! С другой стороны, компьютеры использовались главным образом для инженерных, а не бухгалтерских расчетов, и символ "бакса" был самым мало употребляемым символом – вот и решили использовать его для сигнализации о завершении пользователем ввода и как символ-завершитель строки. (Да, символ завершитель вводился пользователем, а не добавлялся программой, как это происходит с ASCIIZ-строками).
В настоящее время DOS- строки практически вышли из употребления и читатель вряд ли с ними столкнется…
::Pascal-строки. Pascal-строки не имеют завершающего символа, - вместо этого они предваряются специальным полем, содержащим длину этой строки. Достоинства этого подхода: – возможность хранения любых символов в строке (в том числе и нулевых байт!) и высокая скорость обработки строковых переменных. Вместо постоянной проверки каждого байта на завершающий символ, происходит лишь одно обращение к памяти – загрузка длины строки. Ну, а раз длина строки известна, можно работать не с байтами, а двойными словами – "родным" типом данных 32-разрядных процессоров. Весь вопрос в том – сколько байт отвести под поле размера. Один? Что ж, экономно, но тогда максимальная длина строки будет ограничена 255 символами, что во многих случаях оказывается явно недостаточно! Этот тип строк используют практически все Pascal-компиляторы (например, Borland Turbo Pascal, Free Pascal), поэтому-то такие строки и называют "Pascal-строками" или, если более точно, "короткими Pascal-строками".
::Delphi-строки. Осознавая очевидную смехотворность ограничения длины Pascal-строк 255 символами, разработчики Delphi расширили поле размера до двух байт, увеличив, тем самым максимально возможную длину до 65.535 символов. Хотя, такой тип строк поддерживают и другие компиляторы (тот же Free Pascal к примеру), в силу сложившейся традиции их принято именовать Delphi-строками или "Pascal-строками с двухбайтным полем размера – двухбайтными Pascal-строками".
Ограничение в шестьдесят с гаком килобайт и "ограничением" язык назвать не поворачивается. Большинство строк имеют гораздо меньшую длину, а для обработки больших массивов данных (текстовых файлов, к примеру) если куча (динамическая память) и ряд специализированных функций. Накладные же расходы (два служебных байта на каждую строковую переменную) не столь велики, чтобы их брать в расчет. Словом, Delphi-строки, сочетая в себе лучше стороны Си- и Pascal-строк (практически неограниченную длину и высокую скорость обработки соответственно), представляются самым удобным и практичным типом.
::Wide-Pascal строки. "Широкие" Pascal- строки отводят на поле размера аж четыре байта, "ограничивая" максимально возможную длину 4.294.967.295 символами или 4 гигабайтами, что даже больше того количества памяти, которое Windows NT\9x выделяют в "личное пользование" прикладному процессу! Однако за эту роскошь приходится дорого платить, отдавая каждой строке четыре "лишние" байта, три из которых в большинстве случаев будут попросту пустовать. Накладные расходы на коротких строках становятся весьма велики, поэтому, тип Wide-Pascal практически не используется.
::Комбинированные типы. Некоторые компиляторы используют комбинированный Си+Pascal тип, что позволяет им с одной стороны, достичь высокой скорости обработки строк и хранить в строках любые символы, а с другой – обеспечить совместимость с огромным количеством Си-библиотек, "заточенных" под ASCIIZ-строки. Каждая комбинированная строка принудительно завершается нулем, но этот нуль в саму строку не входит и штатные библиотеки (операторы) языка работают с ней как с Pascal-строкой. При вызове же функций Си-библиотек, компилятор передает им указатель не на истинное начало строки, а на первый символ строки.
__::Другие завершающие символы.
Рисунок 21 0х014 Осиновые типы строк
::Определение типа строк. По внешнему виду строки определить ее тип весьма затруднительно. Наличие завершающего нуля в конце строки еще не повод считать ее ASCIIZ-строкой (Pascal-компиляторы в конец строк частенько дописывают один или несколько нулей для выравнивания данных по кратным адресам), а совпадение предшествующего строке байта с ее длинной может действительно быть лишь случайным совпадением.
Грубо тип строки определяется по роду компилятора (Си или Pascal), а точно – по алгоритму обработки этой строки (т.е. анализом манипулирующего с ней кода). Рассмотрим следующий пример:
VAR
s0, s1 : String;
BEGIN
s0 :='Hello, Sailor!';
s1 :='Hello, World!';
IF s0=s1 THEN WriteLN('OK') ELSE Writeln('Woozl');
END.
Листинг 134 Пример, демонстрирующий идентификацию типа строк
Откомпилировав его компилятором Free Pascal, заглянем в сегмент данных. Там мы найдем следующую строку:
.data:00404050 aHelloWorld db 0Dh,'Hello, World!',0 ; DATA XREF: _main+2B^o
Не правда ли, она очень похожа на ASCIIZ-строку? Кому не известен используемый компилятор, тому и на ум не придет, что 0xD – это поле длины, а не символ переноса! Чтобы проверить нашу гипотезу на счет типа, перейдем по перекрестной ссылке, любезно обнаруженной IDA Pro, или самостоятельно найдем в дизассемблированном тексте непосредственный операнд 0x404050
(смещение строки).
push offset _S1 ; Передаем указатель на строку-приемник
push offset aHelloWorld ;"\rHello, World!" Передаем указатель на строку-источник
push 0FFh ; Макс. длина строки
call FPC_SHORTSTR_COPY
Так-с, указатель на строку передается функции FPC_SHORTSTR_COPY. Из прилагаемой к Free Pascal документации можно узнать, что эта функция работает с короткими Pascal - строками, стало быть, байт 0xD
никакой не символ переноса, а длина строки. А чтобы мы делали, если бы у нас отсутствовала документация на Free Pascal? (В самом же деле, невозможно раздобыть все-все-все компиляторы!). Кстати, штатная поставка IDA Pro, вплоть до версии 4.17 включительно, не содержит сигнатур FPP-библиотек и их приходится создавать самостоятельно.
В тех случаях, когда строковая функция неопознана или отсутствует ее описание, путь один – исследовать код на предмет выяснения алгоритма его работы. Ну что, засучим рукава и приступим?
FPC_SHORTSTR_COPY proc near ; CODE XREF: sub_401018+21p
arg_0 = dword ptr 8 ; Макс. длина строки
arg_4 = dword ptr 0Ch ; Исходная строка
arg_8 = dword ptr 10h ; Целевая строка
push ebp
mov ebp, esp
; Открываем кадр стека
push eax
push ecx
; Сохраняем регистры
cld
; Сбрасываем флаг направления
; т.е. заставляем команды LODS, STOS, MOVS
инкрементировать регистр-указатель
mov edi, [ebp+arg_8]
; Загружаем в регистр EDI значение аргумента arg_8 (смещение целевого буфера)
mov esi, [ebp+arg_4]
; Загружаем в регистр ESI значение аргумента arg_4 (смещение исходной строки)
xor eax, eax
; Обнуляем регистр EAX
mov ecx, [ebp+arg_0]
; Загружаем в ECX значение аргумента arg_0 (макс. допустимая длина строки)
lodsb
; Загружаем в AL первый байт исходной строки, на которую указывает регистр ESI
; и увеличиваем ESI на единицу
cmp eax, ecx
; Сравниваем первый символ строки с макс. возможной длиной строки
; Уже ясно, что первой символ строки – длина, однако, притворимся, что мы
; не знаем назначения аргумента arg_0, и продолжим анализ
jbe short loc_401168
; if (ESI[0] <= arg_0) goto loc_401168
mov eax, ecx
; Копируем в EAX значение ECX
loc_401168: ; CODE XREF: sub_401150+14j
stosb
; Записываем первый байт исходной строки в целевой буфер
; и увеличиваем EDI на единицу
cmp eax, 7
; Сравниваем длину строки с константой 0x7
jl short loc_401183
; Длина строки меньше семи байт?
; Тогда и копируем ее побайтно!
mov ecx, edi
; Загружаем в ECX значение указателя на целевой буфер, увеличенный на единицу
; (его увеличила команда STOSB при записи байта)
neg ecx
; Дополняем ECX до нуля, NEG(0xFFFF) = 1;
; ECX :=1
and ecx, 3
; Оставляем в ECX три младший бита, остальные – сбрасываем
; ECX :=1
sub eax, ecx
; Отнимаем от EAX (содержит первый байт строки) "кастрированный" ECX
repe movsb
; Копируем ECX байт из исходной строки в целевой буфер, передвигая ESI
и EDI
; В нашем случае мы копируем 1 байт
mov ecx, eax
; Теперь ECX содержит значение первого байта строки, уменьшенное на единицу
and eax, 3
; Оставляем в EAX три младший бита, остальные – сбрасываем
shr ecx, 2
; Циклическим сдвигом, делим ECX на четыре (22=4)
repe movsd
; Копируем ECX двойных байтов из ESI в EDI
; Теперь становится ясно, что ECX
– содержит длину строки, а, поскольку,
; в ECX загружается значение первого байта строки, можно с полной уверенностью
; сказать, что первый байт строки (причем именно, байт, а не слово) содержит
; длину этой строки
; Таким образом, это – короткая Pascal - строка
;
loc_401183: ; CODE XREF: sub_401150+1Cj
mov ecx, eax
; Если длина строки менее семи байт, то EAX
содержит длину строки для ее
; побайтного копирования (см. условный переход jbe short loc_401168)
; В противном случае EAX содержит остаток "хвоста" строки, который не смог
; заполнить собой последнее двойное слово
; В общем, так или иначе, в ECX загружается количество байт для копирования
repe movsb
; Копируем ECX байт из ESI в EDI
pop ecx
pop eax
; Восстанавливаем регистры
leave
; Закрываем кадр стека
retn 0Ch
FPC_SHORTSTR_COPY endp
Листинг 135
А теперь познакомимся с Си-строками, для чего нам пригодится следующий пример:
#include <stdio.h>
#include <string.h>
main()
{
char s0[]="Hello, World!";
char s1[]="Hello, Sailor!";
if (strcmp(&s0[0],&s1[0])) printf("Woozl\n"); else printf("OK\n");
}
Листинг 136
Откомпилируем его любым подходящим Си-компилятором, например, Borland C++ 5.0 (внимание – Microsoft Visual C++ для этой цели не подходит, см. "Turbo-инициализация строковых переменных"), и поищем наши строки в сегменте данных.
Долго искать не приходится – вот они:
DATA:00407074 aHelloWorld db 'Hello, World!',0 ; DATA XREF: _main+16^o
DATA:00407082 aHelloSailor db 'Hello, Sailor!',0 ; DATA XREF: _main+22^o
DATA:00407091 aWoozl db 'Woozl',0Ah,0 ; DATA XREF: _main+4F^o
DATA:00407098 aOk db 'OK',0Ah,0 ; DATA XREF: _main+5C^o
Обратите внимание: строки следуют вплотную друг к другу – каждая из них завершается символом нуля, и значение первого байта строки не совпадает с ее длиной. Несомненно, перед нами ASCIIZ-строки, однако, не мешает лишний раз убедиться в этом, тщательно проанализировав манипулирующий с ними код:
_main proc near ; DATA XREF: DATA:00407044o
var_20 = byte ptr -20h
var_10 = byte ptr -10h
push ebp
mov ebp, esp
; Открываем кадр стека
add esp, 0FFFFFFE0h
; Резервируем место для локальных переменных
mov ecx, 3
; Заносим в регистр ECX значение 0x3
lea eax, [ebp+var_10]
; Загружаем в EAX указатель на локальный буфер var_10
lea edx, [ebp+var_20]
; Загружаем в EDX указатель на локальный буфер var_20
push esi
; Сохраняем регистр ESI
; Именно сохраняем, а не передаем функции, т.к. ESI
еще не был инициализирован!
push edi
; Сохраняем регистр EDI
lea edi, [ebp+var_10]
; Загружаем в EDI указатель на локальный буфер var_10
mov esi, offset aHelloWorld ; "Hello, World!"
; IDA
распознала в непосредственном операнде смещение строки "Hello,World!"
; А если бы и не распознала – это бы сделали мы сами, основываясь на том, что:
; 1) непосредственный операнд совпадает со смещением строки
; 2) следующая команда неявно использует ESI
для косвенной адресации памяти,
; следовательно, в ESI загружается указатель
repe movsd
; Копируем ECX двойных слов из ESI в EDI
; Чему равно ECX? Оно равно 0x3
; Для перевода из двойных слов в байты умножаем 0x3 на 0x4 и получаем 0xC,
; что на байт короче копируемой строки "Hello,World!", на которую указывает ESI
movsw
; Копируем последний байт строки "Hello, World!" вместе с завершающим нулем
lea edi, [ebp+var_20]
; Загружаем в регистр EDI указатель на локальный буфер var_20
mov esi, offset aHelloSailor ; "Hello, Sailor!"
; Загружаем в регистр ESI указатель на строку "Hello, Sailor!"
mov ecx, 3
; Загружаем в ECX количество полных двойных слов в строке "Hello, Sailor!"
repe movsd
; Копируем 0x3 двойных слова
movsw
; Копируем слово
movsb
; Копируем последний завершающий байт
; // Функция сравнения строк
loc_4010AD: ; CODE XREF: _main+4Bj
mov cl, [eax]
; Загружаем в CL содержимое очередного байта строки "Hello, World!"
cmp cl, [edx]
; CL
равен содержимому очередного байта строки "Hello, Sailor!"?
jnz short loc_4010C9
; Если символы обоих строк не равны, переходим к метке loc_4010C9
test cl, cl
jz short loc_4010D8
; Регистр CL равен нулю? (В строке встретился нулевой символ?)
; если так, то прыгаем на loc_4010D8
; Теперь мы можем безошибочно определить тип строки –
; во-первых, первый байт строки содержит первый символ строки,
; а не хранит ее длину,
; во-вторых, каждый байт строки проверяется на завершающий нулевой символ
; Значит, это ASCIIZ-строки!
mov cl, [eax+1]
; Загружаем в CL следующий символ строки "Hello, World!"
cmp cl, [edx+1]
; Сравниваем его со следующим символом "Hello, Sailor!"
jnz short loc_4010C9
; Если символы не равны – закончить сравнение
add eax, 2
; Переместить указатель строки "Hello, World!" на два символа вперед
add edx, 2
; Переместить указатель строки "Hello, Sailor!" на два символа вперед
test cl, cl
jnz short loc_4010AD
; Повторять сравнение пока не будет достигнут символ-завершитель строки
loc_4010C9: ; CODE XREF: _main+35j _main+41j
jz short loc_4010D8
; см. "Идентификация if – then - else"
; // Вывод строки "Woozl"
push offset aWoozl ; format
call _printf
pop ecx
jmp short loc_4010E3
loc_4010D8: ; CODE XREF: _main+39j _main+4Dj
; // Вывод строки "OK"
push offset aOk ; format
call _printf
pop ecx
loc_4010E3: ; CODE XREF: _main+5Aj
xor eax, eax
; Функция возвращает ноль
pop edi
pop esi
; Восстанавливаем регистры
mov esp, ebp
pop ebp
; Закрываем кадр стека
retn
_main endp
Листинг 137
___строки одного типа
Turbo- инициализация строковых переменных. Не всегда, однако, различить строки так просто. Чтобы убедиться в этом, достаточно откомпилировать предыдущий пример компилятором Microsoft Visual C++, и заглянуть в полученный файл любым подходящим дизассемблером, скажем IDA Pro.
Так, переходим в секцию данных, прокручиваем ее вниз то тех пор, пока не устанет рука (а когда устанет – кирпич на Page Down!) и… Woozl! – никаких следов присутствия строк "Hello, Sailor!" и "Hello, World!". Зато обращает на себя внимание какая-то странная гряда двойных слов – смотрите:
.data:00406030 dword_406030 dd 6C6C6548h ; DATA XREF: main+6^r
.data:00406034 dword_406034 dd 57202C6Fh ; DATA XREF: main +E^r
.data:00406038 dword_406038 dd 646C726Fh ; DATA XREF: main +17^r
.data:0040603C word_40603C dw 21h ; DATA XREF: main +20^r
.data:0040603E align 4
.data:00406040 dword_406040 dd 6C6C6548h ; DATA XREF: main +2A^r
.data:00406044 dword_406044 dd 53202C6Fh ; DATA XREF: main +33^r
.data:00406048 dword_406048 dd 6F6C6961h ; DATA XREF: main +3C^r
.data:0040604C word_40604C dw 2172h ; DATA XREF: main +44^r
.data:0040604E byte_40604E db 0 ; DATA XREF: main +4F^r
Чтобы это значило? Это не указатели – они никуда не указывают, это не переменные типа int – мы не объявляли таких в программе.
Жмем <F4> для перехода в hex-режим и что мы видим? Вот они наши строки, вот они родимые:
.data:00406030 48 65 6C 6C 6F 2C 20 57-6F 72 6C 64 21 00 00 00 "Hello, World!..."
.data:00406040 48 65 6C 6C 6F 2C 20 53-61 69 6C 6F 72 21 00 00 "Hello, Sailor!.."
.data:00406050 57 6F 6F 7A 6C 0A 00 00-4F 4B 0A 00 00 00 00 00 "Woozl0..OK0....."
Хм, почему же тогда IDA Pro их посчитала двойными словами? Ответить на вопрос поможет анализ манипулирующего со строкой кода, но прежде чем приступить к его исследованию, превратим эти двойные слова в нормальную ASCIIZ - строку. (<U> для преобразования двойных слов в цепочку бестиповых байт и <A> для преобразования ее в строку). Затем подведем курсор к первой перекрестной ссылке и, нажмем <Enter>:
main proc near ; CODE XREF: start+AFp
var_20 = byte ptr -20h
var_1C = dword ptr -1Ch
var_18 = dword ptr -18h
var_14 = word ptr -14h
var_12 = byte ptr -12h
var_10 = byte ptr -10h
var_C = dword ptr -0Ch
var_8 = dword ptr -8
var_4 = word ptr -4
; Откуда взялось столько локальных переменных?!
push ebp
mov ebp, esp
; Открываем кадр стека
sub esp, 20h
; Резервируем память для локальных переменных
mov eax, dword ptr aHelloWorld ; "Hello, World!"
; Загружаем в EAX... нет, не указатель на строку "Hello, World!", а
; четыре первых байта этой строки! Теперь понятно, почему ошиблась IDA Pro
; и оригинальный код (до преобразования строки в строку) выглядел так:
; mov eax, dword_406030
; Не правда ли, не очень наглядно? И если бы, мы изучали не свою, а чужую
; программу, этот трюк дизассемблера ввел бы нас в заблуждение!
mov dword ptr [ebp+var_10], eax
; Копируем четыре первых байта строки в локальную переменную var_10
mov ecx, dword ptr aHelloWorld+4
; Загружаем байты с четвертого по восьмой строки "Hello, World!" в ECX
mov [ebp+var_C], ecx
; Копируем их в локальную переменную var_C. Но мы-то уже знаем, что это
; никакая не переменная var_C, а часть строкового буфера
mov edx, dword ptr aHelloWorld+8
; Загружаем байты с восьмого по двенадцатый строки "Hello, World!" в EDX
mov [ebp+var_8], edx
; Копируем их в локальную переменную var_8, точнее – в строковой буфер
mov ax, word ptr aHelloWorld+0Ch
; Загружаем оставшийся двух-байтовый хвост строки в AX
mov [ebp+var_4], ax
; Записываем его в локальную переменную var_4
; Итак, строка копируется по частям в следующие локальные переменные:
; int var_10; int var_0C; int var_8; short int var_4
; следовательно, на самом деле есть только одна локальная переменная –
; char var_10[14]
mov ecx, dword ptr aHelloSailor ; "Hello, Sailor!"
; Проделываем ту же самую операцию копирования над строкой "Hello, Sailor!"
mov dword ptr [ebp+var_20], ecx
mov edx, dword ptr aHelloSailor+4
mov [ebp+var_1C], edx
mov eax, dword ptr aHelloSailor+8
mov [ebp+var_18], eax
mov cx, word ptr aHelloSailor+0Ch
mov [ebp+var_14], cx
mov dl, byte_40604E
mov [ebp+var_12], dl
; Копируем строку "Hello, Sailor!" в локальную переменную char var_20[14]
lea eax, [ebp+var_20]
; Загружаем в регистр EAX указатель на локальную переменную var_20
; которая (как мы помним) содержит строку "Hello, Sailor!"
push eax ; const char *
; Передаем ее функции strcmp
; Из этого можно заключить, что var_20 – действительно хранит строку,
; а не значение типа int
lea ecx, [ebp+var_10]
; Загружаем в регистр ECX указатель на локальную переменную var_10,
; хранящую строку "Hello, World!"
push ecx ; const char *
; Передаем ее функции srtcmp
call _strcmp
add esp, 8
; strcmp("Hello, World!", "Hello, Sailor!")
test eax, eax
jz short loc_40107B
; Строки равны?
; // Вывод на экран строки "Woozl"
push offset aWoozl ; "Woozl\n"
call _printf
add esp, 4
jmp short loc_401088
; // Вывод на экран строки "OK"
loc_40107B: ; CODE XREF: sub_401000+6Aj
push offset aOk ; "OK\n"
call _printf
add esp, 4
loc_401088: ; CODE XREF: sub_401000+79j
mov esp, ebp
pop ebp
; Закрываем кадр стека
retn
main endp
Листинг 138
___о поддержке строк IDA
___"\r\n\a\v\b\t\x1B"
" !\"#$%&'()*+,-./0123456789:;<=>?"
"@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_"
"`abcdefghijklmnopqrstuvwxyz{|}~"
"АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ"
"абвгдежзийклмноп---¦+¦¦¬¬¦¦¬---¬"
"L+T+-+¦¦Lг¦T¦=+¦¦TTLL-г++----¦¦-"
"рстуфхцчшщъыьэюя";
___обработка строк операторами и функцими
___строки фиксированной длины
___паскль пихает строки в сегмента кода
Идентификация локальных стековых переменных
…общая масса бактерий гораздо больше, чем наша с вами суммарная масса. Бактерии - основа жизни на земле…
А.П. Капица
Локальные переменные размещаются в стеке
(так же называемым автоматической памятью) и удаляются оттуда вызываемой функцией по ее завершению. Рассмотрим подробнее: как это происходит. Сначала в стек затягиваются аргументы, передаваемые функции (если они есть), а сверху на них кладется адрес возврата, помещаемый туда инструкцией CALL вызывающей эту функцию. Получив управление, функция открывает кадр стека – сохраняет прежнее значение регистра EBP и устанавливает его равным регистру ESP (регистр указатель вершины стека). "Выше" (т.е. в более младших адресах) EBP находится свободная область стека, ниже – служебные данные (сохраненный EBP, адрес возврата) и аргументы.
Сохранность области стека, расположенная выше указателя вершины стека (регистра ESP), не гарантируется от затирания и искажения. Ее беспрепятственно могут использовать, например, обработчики аппаратных прерываний, вызываемые в непредсказуемом месте в непредсказуемое время. Да и использование стека самой функцией (для сохранения ль регистров или передачи аргументов) приведет к его искажению. Какой из этой ситуации выход? – принудительно переместить указатель вершины стека вверх, тем самым "занимая" данную область стека. Сохранность память, находящейся "ниже" ESP гарантируется (имеется ввиду – гарантируется от непреднамеренных искажений), - очередной вызов инструкции PUSH занесет данные на вершину стека, не затирая локальные переменные.
По окончании же своей работы, функция обязана вернуть ESP на прежнее место, иначе функция RET снимет со стека отнюдь не адрес возврата, а вообще не весь что (значение самой "верхней" локальной переменной) и передаст управление "в космос"…
Рисунок 15 0х00E Механизм размещения локальных переменных в стеке. На левой картинке показано состояние стека на момент вызова функции.
Детали технической реализации. Существует множество вариаций реализации выделения и освобождения памяти под локальные переменные. Казалось бы, чем плохо очевидное SUB ESP,xxx на входе и ADD ESP, xxx
на выходе? А вот Borland C++ (и некоторые другие компиляторы) в стремлении отличиться ото всех остальных резервируют память не уменьшением, а увеличением ESP… да, на отрицательное число (которое по умолчанию большинством дизассемблеров отображается как очень большое положительное). Оптимизирующие компиляторы при отводе небольшого количества памяти заменяют SUB
на PUSH reg, что на несколько байт короче. Последнее создает очевидные проблемы идентификации – попробуй, разберись, то ли перед нами сохранение регистров в стеке, то ли передача аргументов, то ли резервирование памяти для локальных переменных (подробнее см. "идентификация механизма выделения памяти").
Алгоритм освобождения памяти так же неоднозначен. Помимо увеличения регистра указателя вершины стека инструкцией ADD ESP, xxx
(или в особо извращенных компиляторах его увеличения на отрицательное число), часто встречается конструкция "MOV ESP, EBP". (Мы ведь помним, что при открытии кадра стека ESP копировался в EBP, а сам EBP в процессе исполнения функции не изменялся). Наконец, память может быть освобождена инструкцией POP, выталкивающей локальные переменные одну за другой в какой ни будь ненужный регистр (понятное дело, такой способ оправдывает себя лишь на небольшом количестве локальных переменных).
Действие |
Варианты реализации |
||
Резервирование памяти |
SUB ESP, xxx |
ADD ESP,–xxx |
PUSH reg |
Освобождение памяти |
ADD ESP, xxx |
SUB ESP,–xxx |
POP reg |
MOV ESP, EBP |
Идентификация механизма выделения памяти. Выделение памяти инструкциями SUB и ADD
непротиворечиво и всегда интерпретируется однозначно. Если же выделение памяти осуществляется командой PUSH, а освобождение – POP, эта конструкция становится неотличима от простого освобождения/сохранения регистров в стеке.
Ситуация серьезно осложняется тем, что в функции присутствуют и "настоящие" команды сохранения регистров, сливаясь с командами выделения памяти. Как узнать: сколько байт резервируется для локальных переменных, и резервируются ли они вообще (может, в функции локальных переменных и нет вовсе)?
Ответить на этот вопрос позволяет поиск обращений к ячейкам памяти, лежащих "выше" регистра EBP, т.е. с отрицательными относительными смещениями. Рассмотрим два примера, приведенные на листинге 110.
PUSH EBP PUSH EBP
PUSH ECX PUSH ECX
xxx xxx
xxx MOV [EBP-4],0x666
xxx xxx
POP ECX POP ECX
POP EBP POP EBP
RET RET
Листинг 110
В левом из них никакого обращения к локальным переменным не происходит вообще, а в правом наличествует конструкция "MOV [EBP-4],0x666", копирующая значение 0x666 в локальную переменную var_4. А раз есть локальная переменная, для нее кем-то должна быть выделена память. Поскольку, инструкций SUB ESP, xxx
и ADD ESP, – xxx в теле функций не наблюдается – "подозрение" падает на PUSH ECX, т.к. сохраненное содержимое регистра ECX располагается в стеке на четыре байта "выше" EBP. В данном случае "подозревается" лишь одна команда – PUSH ECX, поскольку PUSH EBP на роль "резерватора" не тянет, но как быть, если "подозреваемых" несколько?
Определить количество выделенной памяти можно по смещению самой "высокой" локальной переменной, которую удается обнаружить в теле функции. То есть, отыскав все выражения типа [EBP-xxx] выберем наибольшее смещение "xxx" – в общем случае оно равно количеству байт выделенной под локальные переменные памяти. В частностях же встречаются объявленные, но не используемые локальные переменные. Им выделяется память (хотя оптимизирующие компиляторы просто выкидывают такие переменные за ненадобностью), но ни одного обращения к ним не происходит, и описанный выше алгоритм подсчета объема резервируемой памяти дает заниженный результат.
Впрочем, эта ошибка никак не сказывается на результатах анализа программы.
Инициализация локальных переменных. Существует два способа инициализации локальных переменных: присвоение необходимого значение инструкцией MOV (например, "MOV [EBP-04], 0x666") и непосредственное заталкивания значения в стек инструкцией PUSH
( например, PUSH 0x777). Последнее позволяет выгодно комбинировать выделение памяти под локальные переменные с их инициализацией (разумеется, только в том случае, если этих переменных немного).
Популярные компиляторы в подавляющем большинстве случаев выполняют операцию инициализации с помощью MOV, а PUSH
более характер для ассемблерных извращений, встречающихся, например, в защитах в попытке сбить с толку хакера. Ну, если такой примем и собьет хакера, то только начинающего.
Размещение массивов и структур. Массивы и структуры размещаются в стеке последовательно в смежных ячейках памяти, при этом меньший индекс массива (элемент структуры) лежит по меньшему адресу, но, - внимание, - адресуется большим модулем смещения относительно регистра указателя кадра стека. Это не покажется удивительными, если вспомнить, что локальные переменные адресуются отрицательными смещениями, следовательно, [EBP-0x4] > [EBP-0x10].
Путаницу усиливает то обстоятельство, что, давая локальными переменным имена, IDA опускает знак минус. Поэтому, из двух имен, скажем, var_4 и var_10, по меньшему адресу лежит то, чей индекс больше! Если var_4 и var_10 – это два конца массива, то с непривычки возникает непроизвольное желание поместить var_4 в голову, а var_10 в "хвост" массива, хотя на самом деле все наоборот!
Выравнивание в стеке. В некоторых случаях элементы структуры, массива и даже просто отдельные переменные требуется располагать по кратным адресам. Но ведь значение указателя вершины заранее не определено и неизвестно компилятору. Как же он, не зная фактического значения указателя, сможет выполнить это требование? Да очень просто – возьмет и откинет младшие биты ESP!
Легко доказать, если младший бит равен нулю, число – четное. Чтобы быть уверенным, что значение указателя вершины стека делится на два без остатка, достаточно лишь сбросить его младший бит. Сбросив два бита, мы получим значение заведомо кратное четырем, три – восьми и т.д.
Сброс битов в подавляющем большинстве случаев осуществляется инструкцией AND. Например, "AND ESP, FFFFFFF0" дает ESP кратным шестнадцати. Как было получено это значение? Переводим "0xFFFFFFF0" в двоичный вид, получаем – "11111111 11111111 11111111 11110000". Видите четыре нуля на конце? Значит, четыре младших бита любого числа будут маскированы, и оно разделиться без остатка на 24 = 16.
___Как IDA идентифицирует локальные переменные.
Хотя с локальными переменными мы уже неоднократно встречались при изучении прошлых примеров, не помешает это сделать это еще один раз:
#include <stdio.h>
#include <stdlib.h>
int MyFunc(int a, int b)
{
int c; // Локальная переменная типа int
char x[50] // Массив (демонстрирует схему размещения массивов в памяти_
c=a+b; // Заносим в 'c' сумму аргументов 'a
и 'b'
ltoa(c,&x[0],0x10) ; // Переводим сумму 'a' и 'b' в строку
printf("%x == %s == ",c,&x[0]); // Выводим строку на экран
return c;
}
main()
{
int a=0x666; // Объявляем локальные переменные 'a' и 'b' для того, чтобы
int b=0x777; // продемонстрировать механизм их иницилизации компилятором
int c[1]; // Такие извращения понадобовились для того, чтобы запретит
// отимизирующему компилятору помещать локальную переменную
// в регистр (см. "Идентификация регистровых переменных")
// Т.к. функции printf
передается указатель на 'c', а
// указатель на регистр быть передан не может, компилятор
// вынужен оставить переменную в памяти
c[0]=MyFunc(a,b);
printf("%x\n",&c[0]);
return 0;
}
Листинг 111 Демонстрация идентификации локальных переменных
Результат компиляции компилятора Microsoft Visual C++6.0 с настройками по умолчанию должен выглядеть так:
MyFunc proc near ; CODE XREF: main+1Cp
var_38 = byte ptr -38h
var_4 = dword ptr –4
; Локальные переменные располагаются по отрицательному смещению относительно EBP,
; а аргументы функции – по положительному.
; Заметьте также, чем "выше" расположена переменная, тем больше модуль ее смещения
arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch
push ebp
mov ebp, esp
; Открываем кадр стека
sub esp, 38h
; Уменьшаем значение ESP на 0x38, резервируя 0x38 байт под локальные переменные
mov eax, [ebp+arg_0]
; загружаем а EAX значение аргумента arg_0
; О том, что это аргумент, а не нечто иное, говорит его положительное
; смещение относительно регистра EBP
add eax, [ebp+arg_4]
; складываем EAX со значением аргумента arg_0
mov [ebp+var_4], eax
; А вот и первая локальная переменная!
; На то, что это именно локальная переменная, указывает ее отрицательное
; смещение относительно регистра EBP. Почему отрицательное? А посмотрите,
; как IDA определила "var_4"
; По моему личному мнению, было бы намного нагляднее если бы отрицательные
; смещения локальных переменных подчеркивались более явно.
push 10h ; int
; Передаем функции ltoa значение 0x10 (тип системы исчисления)
lea ecx, [ebp+var_38]
; Загружаем в ECX указатель на локальную переменную var_38
; Что это за переменная? Прокрутим экран дизассемблера немного вверх,
; там где содержится описание локальных переменных, распознанных IDA
; var_38 = byte ptr -38h
; var_4 = dword ptr –4
;
; Ближайшая нижняя переменная имеет смещение –4, а var_38, соответственно, -38
; Вычитая из первого последнее получаем размер var_38
; Он, как нетрудно подсчитать, будет равен 0x34
; С другой стороны, известно, что функция ltoa
ожидает указатель на char*
; Таким образом, в комментарии к var_38 можно записать "char s[0x34]"
; Это делается так: в меню "Edit" открываем подменю "Functions", а в нем –
; пункт "Stack variables" или нажимаем "горячую" комбинацию <Ctrl-K>
; Открывается окно с перечнем всех распознанных локальных переменных.
; Подводим курсор к "var_34" и нажимаем <;> для ввода повторяемого комментария
; и пишем нечто вроде "char s[0x34]". Теперь <Ctrl-Enter> для завершения ввода
; и <Esc> для закрытия окна локальных переменных.
; Все! Теперь возле всех обращений к var_34 появляется введенный нами
; комментарий
;
push ecx ; char *
; Передаем функции ltoa указатель на локальный буфер var_38
mov edx, [ebp+var_4]
; Загружаем в EDX значение локальной переменной var_4
push edx ; __int32
; Передаем значение локальной переменной var_38 функции ltoa
; На основании прототипа этой функции IDA
уже определила тип переменной – int
; Вновь нажмем <Ctrl-K> и прокомментируем var_4
call __ltoa
add esp, 0Ch
; Переводим содержимое var_4 в шестнадцатеричную систему исчисления,
; записанную в строковой форме, возвращая ответ в локальном буфере var_38
lea eax, [ebp+var_38] ; char s[0x34]
; Загружаем в EAX указатель на локальный буфер var_34
push eax
; Передаем указатель на var_34 функции printf для вывода содержимого на экран
mov ecx, [ebp+var_4]
; Копируем в ECX значение локальной переменной var_4
push ecx
; Передаем функции printf значение локальной переменной var_4
push offset aXS ; "%x == %s == "
call _printf
add esp, 0Ch
mov eax, [ebp+var_4]
; Возвращаем в EAX значение локальной переменной var_4
mov esp, ebp
; Освобождаем память, занятую локальными переменными
pop ebp
; Восстанавливаем прежнее значение EBP
retn
MyFunc endp
main proc near ; CODE XREF: start+AFp
var_C = dword ptr -0Ch
var_8 = dword ptr -8
var_4 = dword ptr –4
push ebp
mov ebp, esp
; Открываем кадр стека
sub esp, 0Ch
; Резервируем 0xC байт памяти для локальных переменных
mov [ebp+var_4], 666h
; Инициализируем локальную переменную var_4, присваивая ей значение 0x666
mov [ebp+var_8], 777h
; Инициализируем локальную переменную var_8, присваивая ей значение 0x777
; Смотрите: локальные переменные расположены в памяти в обратном порядке
; их обращения к ним! Не объявления, а именно обращения!
; Вообще-то, порядок расположения не всегда бывает именно таким, - это
; зависит от компилятора, поэтому, полагаться на него никогда не стоит!
mov eax, [ebp+var_8]
; Копируем в регистр EAX значение локальной переменной var_8
push eax
; Передаем функции MyFunc значение локальной переменной var_8
mov ecx, [ebp+var_4]
; Копируем в ECX значение локальной переменной var_4
push ecx
; Передаем MyFunc значение локальной переменной var_4
call MyFunc
add esp, 8
; Вызываем MyFunc
mov [ebp+var_C], eax
; Копируем возращенное функцией значение в локальную переменную var_C
lea edx, [ebp+var_C]
; Загружаем в EDX указатель на локальную переменную var_C
push edx
; Передаем функции printf указатель на локальную переменную var_C
push offset asc_406040 ; "%x\n"
call _printf
add esp, 8
xor eax, eax
; Возвращаем нуль
mov esp, ebp
; Освобожаем память, занятую локальными переменными
pop ebp
; Закрываем кадр стека
retn
main endp
Листинг 112
Не очень сложно, правда? Что ж, тогда рассмотрим результат компиляции этого примера компилятором Borland C++ 5.0 – это будет немного труднее!
MyFunc proc near ; CODE XREF: _main+14p
var_34 = byte ptr -34h
; Смотрите, - только одна локальная переменная! А ведь мы объявляли целых три...
; Куда же они подевались?! Это хитрый компилятор поместил их в регистры, а не стек
; для более быстрого к ним обращения
; (подробнее см. "Идентификация регистровых и временных переменных")
push ebp
mov ebp, esp
; Открываем кадр стека
add esp, 0FFFFFFCC
; Резервируем... нажимаем <-> в IDA, превращая число в знаковое, получаем "–34"
; Резервируем 0x34 байта под локальные переменные
; Обратите внимание: на этот раз выделение памяти осуществляется не SUB, а ADD!
push ebx
; Сохраняем EBX в стеке или выделяем память локальным переменным?
; Поскольку память уже выделена инструкцией ADD, то в данном случае
; команда PUSH действительно сохраняет регистр в стеке
lea ebx, [edx+eax]
; А этим хитрым сложением мы получаем сумму EDX
и EAX
; Поскольку, EAX и EDX не инициализировались явно, очевидно, через них
; были переданы аргументы (см. "Идентификация аргументов функций")
push 10h
; Передаем функции ltoa выбранную систему исчисления
lea eax, [ebp+var_34]
; Загружаем в EAX указатель на локальный буфер var_34
push eax
; Передаем функции ltoa указатель на буфер для записи результата
push ebx
; Передаем сумму (не указатель!) двух аргументов функции MyFunc
call _ltoa
add esp, 0Ch
lea edx, [ebp+var_34]
; Загружаем в EDX указатель на локальный буфер var_34
push edx
; Передаем функции printf указатель на локальный буфер var_34, содержащий
; результат преобразования суммы аргументов MyFunc
в строку
push ebx
; Передаем сумму аргументов функции MyFunc
push offset aXS ; format
call _printf
add esp, 0Ch
mov eax, ebx
; Возвращаем сумму аргументов в EAX
pop ebx
; Выталкиваем EBX из стека, восстанавливая его прежнее значение
mov esp, ebp
; Освобождаем память, занятную локальными переменными
pop ebp
; Закрываем кадр стека
retn
MyFunc endp
; int __cdecl main(int argc,const char **argv,const char *envp)
_main proc near ; DATA XREF: DATA:00407044o
var_4 = dword ptr –4
; IDA
распознала по крайней мере одну локальную переменную –
; возьмем это себе на заметку.
argc = dword ptr 8
argv = dword ptr 0Ch
envp = dword ptr 10h
push ebp
mov ebp, esp
; Открываем кадр стека
push ecx
push ebx
push esi
; Сохраняем регистры в стеке
mov esi, 777h
; Помещаем в регистр ESI значение 0x777
mov ebx, 666h
; Помещаем в регистр EBX значение 0x666
mov edx, esi
mov eax, ebx
; Передаем функции MyFunc аргументы через регистры
call MyFunc
; Вызываем MyFunc
mov [ebp+var_4], eax
; Копируем результат, возвращенный функцией MyFunc
в локальную переменную var_4
; Стоп! Какую такую локальную переменную?! А кто под нее выделял память?!
; Не иначе – как из одна команд PUSH. Только вот какая?
; Смотрим на смещение переменной – она лежит на четыре байта выше EBP, а эта
; область памяти занята содержимым регистра, сохраненного первым PUSH,
; следующим за открытием кадра стека.
; (Соответственно, второй PUSH кладет значение регистра по смещению –8 и т.д.)
; А первой была команда PUSH ECX, - следовательно, это не никакое не сохранение
; регистра в стеке, а резервирование памяти под локальную переменную
; Поскольку, обращений к локальным переменным var_8 и var_C не наблюдается,
; команды PUSH EBX и PUSH ESI, по-видимому, действительно сохраняют регистры
lea ecx, [ebp+var_4]
; Загружаем в ECX указатель на локальную переменную var_4
push ecx
; Передаем указатель на var_4 функции printf
push offset asc_407081 ; format
call _printf
add esp, 8
xor eax, eax
; Возвращаем в EAX нуль
pop esi
pop ebx
; Восстанавливаем значения регистров ESI
и EBX
pop ecx
; Освобождаем память, выделенную локальной переменной var_4
pop ebp
; Закрываем кадр стека
retn
_main endp
Листинг 113
__дописать модификация локальной переменной из другого потока
FPO - Frame Pointer Omission Традиционно для адресации локальных переменных используется регистр EBP. Учитывая, что регистров общего назначения всего семь, "насовсем" отдавать один из них локальным переменным очень не хочется. Нельзя найти какое-нибудь другое, более элегантное решение?
Хорошенько подумав, мы придем к выводу, что отдельный регистр для адресации локальных переменных вообще не нужен, - достаточно (не без ухищрений, правда) одного лишь ESP – указателя стека.
Единственная проблема – плавающий кадр стека. Пусть после выделения памяти под локальные переменные ESP указывает на вершину выделенного региона. Тогда, переменная buff
(см. рис 17) окажется расположена по адресу ESP+0xC. Но стоит занести что-нибудь в стек (аргумент вызываемой функции или регистр на временное сохранение), как кадр "уползет" и buff окажется расположен уже не по ESP+0xC, а – ESP+0x10!
Рисунок 17 0х004 Адресация локальных переменных через регистр ESP приводит к образованию плавающего кадра стека
Современные компиляторы умеют адресовать локальные переменные через ESP, динамически отслеживая его значение (правда, при условии, что в теле функции нет хитрых ассемблерных вставок, изменяющих значение ESP непредсказуемым образом).
Это чрезвычайно затрудняет изучение кода, поскольку теперь невозможно, ткнув пальцем в произвольное место кода, определить к какой именно локальной переменной происходит обращение, - приходится "прочесывать" всю функцию целиком, внимательно следя за значением ESP (и нередко впадая при этом в грубые ошибки, пускающие всю работу насмарку).
К счастью, дизассемблер IDA умеет обращаться с такими переменными, но хакер тем и отличается от простого смертного, что никогда всецело не полагается на автоматику, а сам
стремиться понять, как это работает!
Рассмотрим наш старый добрый simple.c, откомпилировав его с ключом "/O2" – оптимизация по скорости. Тогда компилятор будет стремиться использовать все регистры и адресовать локальные переменные через ESP, что нам и надо.
>cl sample.c /O2
00401000: 83 EC 64 sub esp,64h
Выделяем память для локальных переменных. Обратите внимание – теперь уже нет команд PUSH EBP\MOV EBP,ESP!
00401003: A0 00 69 40 00 mov al,[00406900] ; mov al,0
00401008: 53 push ebx
00401009: 55 push ebp
0040100A: 56 push esi
0040100B: 57 push edi
Сохраняем регистры
0040100C: 88 44 24 10 mov byte ptr [esp+10h],al
Заносим в локальную переменную [ESP+0x10] (назовем ее buff) значение ноль
00401010: B9 18 00 00 00 mov ecx,18h
00401015: 33 C0 xor eax,eax
00401017: 8D 7C 24 11 lea edi,[esp+11h]
Устанавливаем EDI на локальную переменную [ESP+0x11] (неинициализированный хвост buff)
0040101B: 68 60 60 40 00 push 406060h ; "Enter password"
Заносим в стек смещение строки "Enter password". Внимание! Регистр ESP теперь уползает на 4 байта "вверх"
00401020: F3 AB rep stos dword ptr [edi]
00401022: 66 AB stos word ptr [edi]
00401024: 33 ED xor ebp,ebp
00401026: AA stos byte ptr [edi]
Обнуляем буфер
00401027: E8 F4 01 00 00 call 00401220
Вывод строки "Enter password" на экран. Внимание!
Аргументы все еще не вытолкнуты из стека!
0040102C: 68 70 60 40 00 push 406070h
Заносим в стек смещение указателя на указатель stdin. Внимание! ESP еще уползает на четыре байта вверх.
00401031: 8D 4C 24 18 lea ecx,[esp+18h]
Загружаем в ECX указатель на переменную [ESP+0x18]. Еще один буфер? Да как бы не так! Это уже знакомая нам переменная [ESP+0x10], но "сменившая облик" за счет изменения ESP. Если из 0x18
вычесть 8 байт на которые уполз ESP – получим 0x10, - т.е. нашу старую знакомую – [ESP+0x10]!
Крохотную процедуру из десятка строк "проштудировать" несложно, но вот на программе в миллион строк можно и лапти скинуть! Или… воспользоваться IDA. Посмотрите на результат ее работы:
.text:00401000 main proc near ; CODE XREF: start+AFvp
.text:00401000
.text:00401000 var_64 = byte ptr -64h
.text:00401000 var_63 = byte ptr -63h
IDA обнаружила две локальные переменные, расположенные относительно кадра стека по смещениям 63 и 64, оттого и названных соответственно: var_64 и var_63.
.text:00401000 sub esp, 64h
.text:00401003 mov al, byte_0_406900
.text:00401008 push ebx
.text:00401009 push ebp
.text:0040100A push esi
.text:0040100B push edi
.text:0040100C mov [esp+74h+var_64], al
IDA автоматически подставляет имя локальной переменной к ее смещению в кадре стека
.text:00401010 mov ecx, 18h
.text:00401015 xor eax, eax
.text:00401017 lea edi, [esp+74h+var_63]
Конечно, IDA не смогла распознать инициализацию первого байта буфера и ошибочно приняла его за отдельную переменную, – но это не ее вина, а компилятора! Разобраться – сколько переменных тут в действительности может только человек!
.text:0040101B push offset aEnterPassword ; "Enter password:"
.text:00401020 repe stosd
.text:00401022 stosw
.text:00401024 xor ebp, ebp
.text:00401026 stosb
.text:00401027 call sub_0_401220
.text:0040102C push offset off_0_406070
.text:00401031 lea ecx, [esp+7Ch+var_64]
Обратите внимание – IDA правильно распознала обращение к нашей переменной, хотя ее смещение – 0x7C – отличается от 0x74!
Идентификация математических операторов
"…если вы обессилены, то не удивительно, что вся ваша жизнь -- не развлечение. У вас… так много вычислений, расчетов, которые необходимо сделать в вашей жизни, что она просто не может быть развлечением."
Ошо "Пустая Лодка"
Беседы по высказываниям Чжуан Цзы
Идентификация оператора "+". В общем случае оператор "+" транслируется либо в машинную инструкцию ADD, "перемалывающую" целочисленные операнды, либо в инструкцию FADDx, обрабатывающую вещественные значения. Оптимизирующие компиляторы могут заменять "ADD xxx, 1" более компактной командой "INC xxx", а конструкцию "c = a + b + const" транслировать в машинную инструкцию "LEA c, [a + b + const]". Такой трюк позволяет одним махом складывать несколько переменных, возвратив полученную сумму в любом регистре общего назначения, – не обязательно в левом слагаемом как это требует мнемоника команды ADD. Однако, "LEA" не может быть непосредственно декомпилирована в оператор "+", поскольку она используется не только для оптимизированного сложения (что, в общем-то, побочный продукт ее деятельности), но и по своему непосредственному назначению – вычислению эффективного смещения. (подробнее об этом см. "Идентификация констант и смещений", "Идентификация типов"). Рассмотрим следующий пример:
main()
{
int a, b,c;
c = a + b;
printf("%x\n",c);
c=c+1;
printf("%x\n",c);
}
Листинг 204 Демонстрация оператора "+"
Результат его компиляции компилятором Microsoft Visual C++ 6.0 с настройками по умолчанию должен выглядеть так:
main proc near ; CODE XREF: start+AFp
var_c = dword ptr -0Ch
var_b = dword ptr -8
var_a = dword ptr -4
push ebp
mov ebp, esp
; Открываем кадр стека
sub esp, 0Ch
; Резервируем память для локальных переменных
mov eax, [ebp+var_a]
; Загружаем в EAX значение переменной var_a
add eax, [ebp+var_b]
; Складываем EAX со значением переменной var_b
и записываем результат в EAX
mov [ebp+var_c], eax
; Копируем сумму var_a
и var_b в переменную var_c, следовательно:
; var_c = var_a + var_b
mov ecx, [ebp+var_c]
push ecx
push offset asc_406030 ; "%x\n"
call _printf
add esp, 8
; printf("%x\n", var_c)
mov edx, [ebp+var_c]
; Загружаем в EDX значение переменной var_c
add edx, 1
; Складываем EDX со значением 0х1, записывая результат в EDX
mov [ebp+var_c], edx
; Обновляем var_c
; var_c = var_c +1
mov eax, [ebp+var_c]
push eax
push offset asc_406034 ; "%x\n"
call _printf
add esp, 8
; printf("%\n",var_c)
mov esp, ebp
pop ebp
; Закрываем кадр стека
retn
main endp
Листинг 205
А теперь посмотрим, как будет выглядеть тот же самый пример, скомпилированный с ключом "/Ox" (максимальная оптимизация):
main proc near ; CODE XREF: start+AFp
push ecx
; Резервируем место для одной локальной переменной
; (компилятор посчитал, что три переменные можно ужать в одну и это дейст. так)
mov eax, [esp+0]
; Загружаем в EAX значение переменной var_a
mov ecx, [esp+0]
; Загружаем в EAX значение переменной var_b
; (т.к .переменная не инициализирована загружать можно откуда угодно)
push esi
; Сохраняем регистр ESI в стеке
lea esi, [ecx+eax]
; Используем LEA для быстрого сложения ECX и EAX с последующей записью суммы
; в регистр ESI
; "Быстрое сложение" следует понимать не в смысле, что команда LEA выполняется
; быстрее чем ADD, - количество тактов той и другой одинаково, но LEA
; позволяет избавиться от создания временной переменной для сохранения
; промежуточного результата сложения, сразу направляя результат в ESI
; Таким образом, эта команда декомпилируется как
; reg_ESI = var_a + var_b
push esi
push offset asc_406030 ; "%x\n"
call _printf
; printf("%x\n", reg_ESI)
inc esi
; Увеличиваем ESI на единицу
; reg_ESI = reg_ESI + 1
push esi
push offset asc_406034 ; "%x\n"
call _printf
add esp, 10h
; printf("%x\n", reg_ESI)
pop esi
pop ecx
retn
main endp
Листинг 206
Остальные компиляторы (Borland C++, WATCOM C) генерируют приблизительно идентичный код, поэтому, приводить результаты бессмысленно – никаких новых "изюминок" они в себе не несут.
Идентификация оператора "–". В общем случае оператор "– " транслируется либо в машинную инструкцию SUB
(если операнды – целочисленные значения), либо в инструкцию FSUBx (если операнды – вещественные значения). Оптимизирующие компиляторы могут заменять "SUB xxx, 1" более компактной командой "DEC xxx", а конструкцию "SUB a, const" транслировать в "ADD a, -const", которая ничуть не компактнее и ни сколь не быстрей (и та, и другая укладываться в один так), однако, хозяин (компилятор) – барин. Покажем это на следующем примере:
main()
{
int a,b,c;
c = a - b;
printf("%x\n",c);
c = c - 10;
printf("%x\n",c);
}
Листинг 207 Демонстрация идентификации оператора "-"
Не оптимизированный вариант будет выглядеть приблизительно так:
main proc near ; CODE XREF: start+AFp
var_c = dword ptr -0Ch
var_b = dword ptr -8
var_a = dword ptr -4
push ebp
mov ebp, esp
; Открываем кадр стека
sub esp, 0Ch
; Резервируем память под локальные переменные
mov eax, [ebp+var_a]
; Загружаем в EAX значение переменной var_a
sub eax, [ebp+var_b]
; Вычитаем из var_a
значением переменной var_b, записывая результат в EAX
mov [ebp+var_c], eax
; Записываем в var_c
разность var_a и var_b
; var_c = var_a – var_b
mov ecx, [ebp+var_c]
push ecx
push offset asc_406030 ; "%x\n"
call _printf
add esp, 8
; printf("%x\n", var_c)
mov edx, [ebp+var_c]
; Загружаем в EDX значение переменной var_c
sub edx, 0Ah
; Вычитаем из var_c
значение 0xA, записывая результат в EDX
mov [ebp+var_c], edx
; Обновляем var_c
; var_c = var_c – 0xA
mov eax, [ebp+var_c]
push eax
push offset asc_406034 ; "%x\n"
call _printf
add esp, 8
; printf("%x\n",var_c)
mov esp, ebp
pop ebp
; Закрываем кадр стека
retn
main endp
Листинг 208
А теперь рассмотрим оптимизированный вариант того же примера:
main proc near ; CODE XREF: start+AFp
push ecx
; Резервируем место для локальной переменной var_a
mov eax, [esp+var_a]
; Загружаем в EAX значение локальной переменной var_a
push esi
; Резервируем место для локальной переменной var_b
mov esi, [esp+var_b]
; Загружаем в ESI значение переменной var_b
sub esi, eax
; Вычитаем из var_a
значение var_b, записывая результат в ESI
push esi
push offset asc_406030 ; "%x\n"
call _printf
; printf("%x\n", var_a – var_b)
add esi, 0FFFFFFF6h
; Добавляем
к ESI (разности var_a и
var_b) значение 0хFFFFFFF6
; Поскольку, 0xFFFFFFF6 == -0xA, данная строка кода выглядит так:
; ESI = (var_a – var_b) + (– 0xA) = (var_a – var_b) – 0xA
push esi
push offset asc_406034 ; "%x\n"
call _printf
add esp, 10h
; printf("%x\n", var_a – var_b – 0xA)
pop esi
pop ecx
; Закрываем кадр стека
retn
main endp
Листинг 209
Остальные компиляторы (Borland, WATCOM) генерируют практически идентичный код, поэтому здесь не рассматриваются.
Идентификация оператора "/". В общем случае оператор "/" транслируется либо в машинную инструкцию "DIV" (беззнаковое целочисленное деление), либо в "IDIV" (целочисленное деление со знаком), либо в "FDIVx" (вещественное деление).
Если делитель кратен степени двойки, то "DIV" заменяется на более быстродействующую инструкцию битового сдвига вправо "SHR a, N", где a – делимое, а N – показатель степени с основанием два.
Несколько сложнее происходит быстрое деление знаковых чисел. Совершенно недостаточно выполнить арифметический сдвиг вправо (команда арифметического сдвига вправо SAR
заполняет старшие биты с учетом знака числа), ведь если модуль делимого меньше модуля делителя, то арифметический сдвиг вправо сбросит все значащие биты в "битовую корзину", в результате чего получиться 0xFFFFFFFF, т.е. –1, в то время как правильный ответ – ноль. Вообще же, деление знаковых чисел арифметическим сдвигом вправо дает округление в большую сторону, что совсем не входит в наши планы. Для округления знаковых чисел в меньшую сторону необходимо перед выполнением сдвига добавить к делимому число , где N
– количество битов, на которые сдвигается число при делении. Легко видеть, что это приводит к увеличению всех сдвигаемых битов на единицу и переносу в старший разряд, если хотя бы один из них не равен нулю.
Следует отметить: деление очень медленная операция, гораздо более медленная чем умножение (выполнение DIV
может занять свыше 40 тактов, в то время как MUL обычно укладываться в 4), поэтому, продвинутые оптимизирующие компиляторы заменяют деление умножением. Существует множество формул подобных преобразований, вот, например, она (самая популярная из них):
, где N – разрядность числа. Выходит, грань между умножением и делением очень тока, а их идентификация довольно сложна. Рассмотрим следующий пример:
main()
{
int a;
printf("%x %x\n",a / 32, a / 10);
}
Листинг 210 Идентификация оператора "/"
Результат его компиляции компилятором Microsoft Visual C++ с настройками по умолчанию должен выглядеть так:
main proc near ; CODE XREF: start+AFp
var_a = dword ptr -4
push ebp
mov ebp, esp
; Открываем кадр стека
push ecx
; Резервируем память для локальной переменной
mov eax, [ebp+var_a]
; Копируем в EAX значение переменной var_a
cdq
; Расширяем EAX до четверного слова EDX:EAX
mov ecx, 0Ah
; Заносим в ECX значение 0xA
idiv ecx
; Делим (учитывая знак) EDX:EAX
на 0xA, занося частное в EAX
; EAX = var_a / 0xA
push eax
; Передаем результат вычислений функции printf
mov eax, [ebp+var_a]
; Загружаем в EAX значение var_a
cdq
; Расширяем EAX до четверного слова EDX:EAX
and edx, 1Fh
; Выделяем пять младших бит EDX
add eax, edx
; Складываем знак числа для выполнения округления отрицательных значений
; в меньшую сторону
sar eax, 5
; Арифметический сдвиг вправо на 5 позиций
; эквивалентен делению числа на 25 = 32
; Таким образом, последние четыре инструкции расшифровываются как:
; EAX = var_a / 32
; Обратите внимание: даже при выключенном режиме оптимизации компилятор
; оптимизировал деление
push eax
push offset aXX ; "%x %x\n"
call _printf
add esp, 0Ch
; printf("%x %x\n", var_a / 0xA, var_a / 32)
mov esp, ebp
pop ebp
; Закрываем кадр стека
retn
main endp
Листинг 211
А теперь, засучив рукава и глотнув пустырника (или валерьянки) рассмотрим оптимизированный вариант того же примера:
main proc near ; CODE XREF: start+AFp
push ecx
; Резервируем память для локальной переменной var_a
mov ecx, [esp+var_a]
; Загружаем в ECX значение переменной var_a
mov eax, 66666667h
; Так, что это за зверское число?!
; В исходном коде ничего подобного и близко не было!
imul ecx
; Умножаем это зверское число на переменную var_a
; Обратите внимание: именно умножаем, а не делим.
; Однако притворимся на время, что у нас нет исходного кода примера, потому
; ничего странного в операции умножения мы не видим
sar edx, 2
; Выполняем арифметический сдвиг всех битов EDX
на две позиции вправо, что
; в первом приближении эквивалентно его делению на 4
; Однако ведь в EDX находятся старшее двойное слово результата умножения!
; Поэтому, три предыдущих команды фактически расшифровываются так:
; EDX = (66666667h * var_a) >> (32 + 2) = (66666667h * var_a) / 0x400000000
;
; Понюхайте эту строчку – не пахнет ли паленым? Как так не пахнет?! Смотрите:
; (66666667h * var_a) / 0x400000000 = var_a * 66666667h / 0x400000000 =
; = var_a * 0,10000000003492459654808044433594
; Заменяя по всем правилам математики умножение на деление и одновременно
; выполняя округление до меньшего целого получаем:
; var_a * 0,1000000000 = var_a * (1/0,1000000000) = var_a/10
;
; Согласитесь, от такого преобразования код стал намного понятнее!
; Как можно распознать такую ситуацию в чужой программе, исходный текст которой
; неизвестен? Да очень просто – если встречается умножение, а следом за ним
; сдвиг вправо, обозначающий деление, то каждый нормальный математик сочтет
; своим долгом такую конструкцию сократить, по методике показанной выше!
mov eax, edx
; Копируем полученное частное в EAX
shr eax, 1Fh
; Сдвигаем на 31 позицию вправо
add edx, eax
; Складываем: EDX = EDX + (EDX >> 31)
; Чтобы это значило? Нетрудно понять, что после сдвига EDX
на 31 бит вправо
; в нем останется лишь знаковый бит числа
; Тогда – если число отрицательно, мы добавляем к результату деления один,
; округляя его в меньшую сторону. Таким образом, весь этот хитрый код
; обозначает ни что иное как тривиальную операцию знакового деления:
; EDX = var_a / 10
; Не слишком ли много кода для одного лишь деления? Конечно, программа
; здорово "распухает", зато весь этот код выполняется всего лишь за 9 тактов,
; в то время как в не оптимизированном варианте аж за 28!
; /* Измерения проводились на процессоре CLERION
с ядром P6, на других
; процессорах количество тактов может отличается */
; Т.е. оптимизация дала более чем трехкратный выигрыш, браво Microsoft!
mov eax, ecx
; Вспомним: что находится в ECX? Ох, уж эта наша дырявая память, более дырявая
; чем дуршлаг без дна… Прокручиваем экран дизассемблера вверх. Ага, в ECX
; последний раз разгружалось значение переменной var_a
push edx
; Передаем функции printf результат деления var_a
на 10
cdq
; Расширяем EAX (var_a) до четверного слова EDX:EAX
and edx, 1Fh
; Выбираем младшие 5 бит регистра EDX, содержащие знак var_a
add eax, edx
; Округляем до меньшего
sar eax, 5
; Арифметический сдвиг на 5 эквивалентен делению var_a на 32
push eax
push offset aXX ; "%x %x\n"
call _printf
add esp, 10h
; printf("%x %x\n", var_a / 10, var_a / 32)
retn
main endp
Листинг 212
Ну, а другие компиляторы, насколько они продвинуты в плане оптимизации? Увы, ни Borland, ни WATCOM не умеют заменять деление более быстрым умножением для чисел отличных от степени двойки. В подтверждении тому рассмотрим результат компиляции того же примера компилятором Borland C++:
_main proc near ; DATA XREF: DATA:00407044o
push ebp
mov ebp, esp
; Открываем кадр стека
push ebx
; Сохраняем EBX
mov eax, ecx
; Копируем в EAX содержимое неинициализированной регистровой переменной ECX
mov ebx, 0Ah
; Заносим в EBX значение 0xA
cdq
; Расширяем EAX до четверного слова EDX:EAX
idiv ebx
; Делим ECX на 0xA (долго делим – тактов 20, а то и больше)
push eax
; Передаем полученное значение функции printf
test ecx, ecx
jns short loc_401092
; Если делимое не отрицательно, то переход на loc_401092
add ecx, 1Fh
; Если делимое положительно, то добавляем к нему 0x1F для округления
loc_401092: ; CODE XREF: _main+11j
sar ecx, 5
; Сдвигом на пять позиций вправо делим число на 32
push ecx
push offset aXX ; "%x %x\n"
call _printf
add esp, 0Ch
; printf("%x %x\n", var_a / 10, var_a / 32)
xor eax, eax
; Возвращаем ноль
pop ebx
pop ebp
; Закрываем кадр стека
retn
_main endp
Листинг 213
Идентификация оператора "%". Специальной инструкции для вычисления остатка в наборе команд микропроцессоров серии 80x86 нет, - вместо этого остаток вместе с частным возвращается инструкциями деления DIV, IDIV
и FDIVx
(см. идентификация оператора "/").
Если делитель представляет собой степень двойки (2N = b), а делимое беззнаковое число, то остаток будет равен N младшим битам делимого числа. Если же делимое – знаковое, необходимо установить все биты, кроме первых N равными знаковому биту для сохранения знака числа. Причем, если N первых битов равно нулю, все биты результата должны быть сброшены независимо от значения знакового бита.
Таким образом, если делимое – беззнаковое число, то выражение a % 2N
транслируется в конструкцию: "AND a, N", в противном случае трансляция становится неоднозначна – компилятор может вставлять явную проверку на равенство нулю с ветвлением, а может использовать хитрые математические алгоритмы, самый популярный из которых выглядит так: DEC x\ OR x, -N\ INC x. Весь фокус в том, что если первые N бит числа x равны нулю, то все биты результата кроме старшего, знакового бита, будут гарантированно равны одному, а OR x, -N
принудительно установит в единицу и старший бит, т.е. получится значение, равное, –1. А INC –1 даст ноль! Напротив, если хотя бы один из N младших битов равен одному, заема из старших битов не происходит и INC x
возвращает значению первоначальный результат.
Продвинутые оптимизирующие компиляторы могут путем сложных преобразований заменять деление на ряд других, более быстродействующих операций. К сожалению, алгоритмов для быстрого вычисления остатка для всех делителей не существует и делитель должен быть кратен , где k и t – некоторые целые числа.
Тогда остаток можно вычислить по следующей формуле:
Да, эта формула очень сложна и идентификация оптимизированного оператора "%" может быть весьма и весьма непростой, особенно учитывая патологическую любовь оптимизаторов к изменению порядка команд.
Рассмотрим следующий пример:
main()
{
int a;
printf("%x %x\n",a % 16, a % 10);
}
Листинг 214 Идентификация оператора "%"
Результат его компиляции компилятором Microsoft Visual C++ с настройками по умолчанию должен выглядеть так:
main proc near ; CODE XREF: start+AFp
var_4 = dword ptr -4
push ebp
mov ebp, esp
; Открываем кадр стека
push ecx
; Резервируем память для локальной переменной
mov eax, [ebp+var_a]
; Заносим в EAX значение переменной var_a
cdq
; Расширяем EAX до четвертного слова EDX:EAX
mov ecx, 0Ah
; Заносим в ECX значение 0xA
idiv ecx
; Делим EDX:EAX
(var_a) на ECX (0xA)
push edx
; Передаем остаток от деления var_a на 0xA функции printf
mov edx, [ebp+var_a]
; Заносим в EDX значение переменной var_a
and edx, 8000000Fh
; "Вырезаем" знаковый бит и четыре младших бита числа
; в четырех младших битах содержится остаток от деления EDX
на 16
jns short loc_401020
; Если число не отрицательно, то прыгаем на loc_401020
dec edx
or edx, 0FFFFFFF0h
inc edx
; Последовательность сия, как говорилось выше характера для быстрого
; расчета отставка знакового числа
; Следовательно, последние шесть инструкций расшифровываются как:
; EDX = var_a % 16
loc_401020: ; CODE XREF: main+19j
push edx
push offset aXX ; "%x %x\n"
call _printf
add esp, 0Ch
; printf("%x %x\n",var_a % 0xA, var_a % 16)
mov esp, ebp
pop ebp
; Закрываем кадр стека
retn
main endp
Листинг 215
Любопытно, что оптимизация не влияет на алгоритм вычисления остатка.
Увы, ни Microsoft Visual C++, ни остальные известные мне компиляторы не умеют вычислять остаток умножением.
Идентификация оператора "*".
В общем случае оператор "*" транслируется либо в машинную инструкцию "MUL" (беззнаковое целочисленное умножение), либо в "IMUL" (целочисленное умножение со знаком), либо в "FMULx" (вещественное умножение). Если один из множителей кратен степени двойки, то "MUL" ("IMUL") обычно заменяется командой битового сдвига влево "SHL" или инструкцией "LEA", способной умножать содержимое регистров на 2, 4 и 8. Обе последних команды выполняются за один такт, в то время как MUL
требует в зависимости от модели процессора от двух до девяти тактов. К тому же LEA
за тот же такт успевает сложить результат умножение с содержимым регистра общего назначения и/или константой в придачу. Это позволяет умножать на 3, 5 и 9 просто добавляя к умножаемому регистру его значение. Ну, разве это не сказка? Правда, у LEA есть один недочет – она может вызывать остановку AGI, в конечном счете "съедающую" весь выигрыш в быстродействии на нет.
Рассмотрим следующий пример:
main()
{
int a;
printf("%x %x %x\n",a * 16, a * 4 + 5, a * 13);
}
Листинг 216 Идентификация оператора "*"
Результат его компиляции компилятором Microsoft Visual C++ с настройками по умолчанию должен выглядеть так:
main proc near ; CODE XREF: start+AFp
var_a = dword ptr -4
push ebp
mov ebp, esp
; Открываем кадр стека
push ecx
; Резервируем место для локальной переменной var_a
mov eax, [ebp+var_a]
; Загружаем в EAX значение переменной var_a
imul eax, 0Dh
; Умножаем var_a
на 0xD, записывая результат в EAX
push eax
; Передаем функции printf произведение var_a * 0xD
mov ecx, [ebp+var_a]
; Загружаем в ECX значение var_a
lea edx, ds:5[ecx*4]
; Умножаем ECX на 4 и добавляем к полученному результату 5, записывая его в EDX
; И все это выполняется за один такт!
push edx
; Передаем функции printf результат var_a * 4 + 5
mov eax, [ebp+var_a]
; Загружаем в EAX значение переменной var_a
shl eax, 4
; Умножаем var_a на
16
push eax
; Передаем функции printf произведение var_a * 16
push offset aXXX ; "%x %x %x\n"
call _printf
add esp, 10h
; printf("%x %x %x\n", var_a * 16, var_a * 4 + 5, var_a * 0xD)
mov esp, ebp
pop ebp
; Закрываем кадр стека
retn
main endp
Листинг 217
За вычетом вызова функции printf и загрузки переменной var_a из памяти на все про все требуется лишь три
такта процессора. А что будет, если скомпилировать этот пример с ключиком "/Ox"? А будет вот что:
main proc near ; CODE XREF: start+AFp
push ecx
; Выделяем память для локальной переменной var_a
mov eax, [esp+var_a]
; Загружаем в EAX значение переменной var_a
lea ecx, [eax+eax*2]
; ECX = var_a * 2 + var_a = var_a * 3
lea edx, [eax+ecx*4]
; EDX = (var_a * 3)* 4 + var_a = var_a * 13!
; Вот так компилятор ухитрился умножить var_a на 13,
; причем всего за один (!) такт. Да, обе инструкции LEA
прекрасно спариваются
; на Pentium MMX и Pentium Pro!
lea ecx, ds:5[eax*4]
; ECX = EAX*4 + 5
push edx
push ecx
; Передаем
функции printf var_a * 13 и var_a * 4 +5
shl eax, 4
; Умножаем var_a на
16
push eax
push offset aXXX ; "%x %x %x\n"
call _printf
add esp, 14h
; printf("%x %x %x\n", var_a * 16, var_a * 4 + 5, var_a * 13)
retn
main endp
Листинг 218
Этот код, правда, все же не быстрее предыдущего, не оптимизированного, и укладывается в те же три такта, но в других случаях выигрыш может оказаться вполне ощутимым.
Другие компиляторы так же используют LEA для быстрого умножения чисел. Вот, к примеру, Borland поступает так:
_main proc near ; DATA XREF: DATA:00407044o
lea edx, [eax+eax*2]
; EDX = var_a*3
mov ecx, eax
; Загружаем в ECX неинициализированную регистровую переменную var_a
shl ecx, 2
; ECX = var_a * 4
push ebp
; Сохраняем EBP
add ecx, 5
; Добавляем к var_a
* 4 значение 5
; Borland
не использует LEA
для сложения. А жаль…
lea edx, [eax+edx*4]
; EDX = var_a + (var_a *3) *4 = var_a * 13
; А вот в этом Borland и MS единодушны :-)
mov ebp, esp
; Открываем кадр стека
; Да, да… вот так посреди функции и открываем…
; Выше, кстати, "потерянная" команда push EBP
push edx
; Передаем printf произведение var_a
* 13
shl eax, 4
; Умножаем ((var_a
*4) + 5) на 16
; Что такое?! Да, это глюк компилятора, посчитавшего: раз переменная var_a
; неинициализирована, то ее можно и не загружать…
push ecx
push eax
push offset aXXX ; "%x %x %x\n"
call printf
add esp, 10h
xor eax, eax
pop ebp
retn
_main endp
Листинг 219
Хотя "визуально" Borland генерирует более "тупой" код, его выполнение укладывается в те же три такта процессора. Другое дело WATCOM, показывающий удручающе отсталый результат на фоне двух предыдущих компиляторов:
main proc near
push ebx
; Сохраняем EBX в стеке
mov eax, ebx
; Загружаем в EAX значение неинициализированной регистровой переменной var_a
shl eax, 2
; EAX = var_a * 4
sub eax, ebx
; EAX = var_a * 4 – var_a = var_a * 3
; Вот каков WATCOM! Сначала умножает "с запасом", а потом лишнее отнимает!
shl eax, 2
; EAX = var_a * 3 * 4 = var_a * 12
add eax, ebx
; EAX = var_a * 12 + var_a = var_a * 13
; Вот так, да? Четыре инструкции, в то время как "ненавистный" многим
; Microsoft Visual C++ вполне обходится и двумя!
push eax
; Передаем printf значение var_a
* 13
mov eax, ebx
; Загружаем в EAX значение неинициализированной регистровой переменной var_a
shl eax, 2
; EAX = var_a * 4
add eax, 5
; EAX = var_a * 4 + 5
; Ага! Пользоваться LEA WATCOM то же не умеет!
push eax
; Передаем printf значение var_a * 4 + 5
shl ebx, 4
; EBX = var_a * 16
push ebx
; Передаем printf значение var_a * 16
push offset aXXX ; "%x %x %x\n"
call printf_
add esp, 10h
; printf("%x %x %x\n",var_a * 16, var_a * 4 + 5, var_a*13)
pop ebx
retn
main_ endp
Листинг 220
В результате, код, сгенерированный компилятором WATCOM требует шести тактов, т.е. вдвое больше, чем у конкурентов.
::Комплексные операторы. Язык Си\Си++ выгодно отличается от большинства своих конкурентов поддержкой комплексных операторов: x= (где x – любой элементарный оператор), ++ и – –.
Комплексные операторы семейства "a x= b" транслируются в "a = a x b" и они идентифицируются так же, как и элементарные операторы (см. "элементарные операторы").
Операторы "++" и "––": в префиксной форме они выражаются в тривиальные конструкции "a = a +1" и "a = a – 1" не представляющие для нас никакого интереса, но вот постфиксная форма – дело другое.
__обращение к разным частям одной переменной
Идентификация new и delete
…нет ничего случайного. Самые свободные ассоциации являются самыми надежными"
тезис классического психоанализа
Операторы new
и delete транслируются компилятором в вызовы библиотечных функций, которые могут быть распознаны точно так, как и обычные библиотечные функции (см. "Идентификация библиотечных функций"). Автоматически распознавать библиотечные функции умеет, в частности, IDA Pro, снимая эту заботу с плеч пользователя. Однако IDA Pro есть не у всех, и далеко не всегда в нужный момент находится под рукой, да к тому же не все библиотечные функции она знает, а из тех, что знает не всегда узнает new
и delete… Словом, причин для их ручной идентификации существует предостаточно…
Реализация new
и delete может быть любой, но Windows-компиляторы в большинстве своем редко реализуют функции работы с кучей самостоятельно, - зачем это, ведь намного проще обратиться к услугам операционной системы. Однако наивно ожидать вместо new вызов HeapAlloc, а вместо delete – HeapFree. Нет, компилятор не так прост! Разве он может отказать себе в удовольствии "вырезания матрешек"? Оператор new транслируется в функцию new, вызывающую для выделения памяти malloc, malloc же в свою очередь обращается к heap_alloc (или ее подобию – в зависимости от реализации библиотеки работы с памятью – см. "подходы к реализацию кучи"), – своеобразной "обертке" одноименной Win32 API-процедуры. Картина с освобождением памяти – аналогична.
Углубляться в дебри вложенных вызовов – слишком утомительно. Нельзя ли new и delete идентифицировать как-нибудь иначе, с меньшими трудозатратами и без большой головной боли? Разумеется, можно! Давайте вспомним все, что мы знаем о new.
- new принимает единственный аргумент – количество байт выделяемой памяти, причем этот аргумент в подавляющем большинстве случаев вычисляется еще на стадии компиляции, т.е. является константой;
- если объект не содержит ни данных, ни виртуальных функций, его размер равен единице (минимальный блок памяти, выделяемый только для того, чтобы было на что указывать указателю this); отсюда – будет очень много вызовов типа PUSH 01\CALL xxx, - где xxx и есть адрес new! Вообще же, типичный размер объектов составляет менее сотни байт… - ищите часто вызываемую функцию, с аргументом-константой меньшей ста байт;
- функция new – одна из самых "популярных" библиотечных функций, - ищите функцию с "толпой" перекрестных ссылок;
- самое характерное: new возвращает указать this, а this очень легко идентифицировать даже при беглом просмотре кода (см. "Идентификация this");
- возвращенный new результат всегда проверяется на равенство нулю, и если он действительно равен нулю, конструктор (если он есть – см. "Идентификация конструктора и деструктора") не вызывается;
"Родимых пятен" у new более чем достаточно для быстрой и надежной идентификации, - тратить время на анализ ее кода совершенно ни к чему! Единственное, о чем следует помнить: new используется не только для создания новых экземпляров объектов, но и для выделения памяти под массивы (структуры) и изредка – одиночные переменные (типа int *x = new int, - что вообще маразм, но… некоторые так делают). К счастью, отличить два этих способа очень просто – ни у массивов, ни у структур, ни у одиночных переменных нет указателя this!
Давайте, для закрепления всего вышесказанного рассмотрим фрагмент кода, сгенерированного компилятором WATCOM (IDA PRO не распознает его "родную" new):
main_ proc near ; CODE XREF: __CMain+40p
push 10h
call __CHK
push ebx
push edx
mov eax, 4
call W?$nwn_ui_pnv
; это, как мы узнаем позднее, функция new. IDA
вообще-то распознала ее имя, но,
; чтобы узнать в этой "абракадабре" оператор выделения памяти – надо быть
; провидцем!
; Пока же обратим внимание, что она принимает один аргумент-константу
; очень небольшую по значению т.е. заведомо не являющуюся смещением
; (см. "Идентификация констант и смещений")
; Передача аргумента через регистр ни о чем не говорит – Watcom
так поступает
; со многими библиотечными функциями, напротив, другие компиляторы всегда
; заталкивают аргумент в стек...
mov edx, eax
test eax, eax
; Проверка результата, возвращенного функцией, на нулевое значение
; (что характерно для new)
jz short loc_41002A
mov dword ptr [eax], offset BASE_VTBL
; Ага, функция возвратила указатель и по нему записывается указатель на
; виртуальную таблицу (или по крайней мере – массив функций)
; EAX
уже очень похож на this, но, чтобы окончательно убедиться в этом,
; требуется дополнительные признаки…
loc_41002A: ; CODE XREF: main_+1Aj
mov ebx, [edx]
mov eax, edx
call dword ptr [ebx]
; Вот теперь можно не сомневаться, что EAX
– указатель this, а этот код –
; и есть вызов виртуальной функции!
; Следовательно, функция W?$nwm_ui_pnv
и есть new
;(а кто бы еще мог возвратить this?)
Листинг 54
Сложнее идентифицировать delete. Каких либо характерных признаков эта функция не имеет. Да, она принимает единственный аргумент – указатель на освобождаемый регион памяти, причем, в подавляющем большинстве случаев этот указатель – this. Но, помимо нее, this принимают десятки, если не сотни других функций! Правда, между ними существует одно тонкое различие – delete
в большинстве случаев принимает указатель this через стек, а остальные функции – через регистр. К сожалению, некоторые компиляторы, (тот же WATCOM – не к ночи он будет упомянут) передают многим библиотечным функциям аргументы через регистры, скрывая тем самым все различия! Еще, delete
ничего не возвращает, но мало ли функций поступают точно так же? Единственная зацепка – вызов delete
следует за вызовом деструктора (если он есть), но, ввиду того, что конструктор как раз и идентифицируется как функция, предшествующая delete, образуется замкнутый круг!
Ничего не остается, как анализировать ее содержимое – delete рано или поздно вызывает HeapFree (хотя тут возможны и варианты, так Borland содержит библиотеки, работающие с кучей на низком уровне и освобождающие память вызовом VirtualFree). К счастью, IDA Pro в большинстве случаев опознает delete
и самостоятельно напрягаться не приходится.
::подходы к реализации кучи.
В некоторых, между прочим достаточно многих, руководствах по программированию на Си++ (например, Джефри Рихтер "Windows для профессионалов") встречаются призывы всегда выделять память именно new, а не malloc, поскольку, new опирается на эффективные средства управления памятью самой операционной системы, а malloc реализует собственный (и достаточно тормозной) менеджер кучи. Все это грубые натяжки! Стандарт вообще ничего не говорит о реализации кучи, и какая функция окажется эффективнее наперед неизвестно. Все зависит от конкретных библиотек конкретного компилятора.
Рассмотрим, как происходит управление памятью в штатных библиотеках трех популярных компиляторов: Microsoft Visual C++, Borland C++ и Watcom C++.
В Microsoft Visual C++
и malloc, и new представляют собой переходники к одной и той же функции __nh_malloc, поэтому, можно с одинаковым успехом пользоваться и той, и другой. Сама же __nh_malloc вызывает __heap_alloc, в свою очередь вызывающую API функцию Windows HeapAlloc.
(Стоит отметить, что в __heap_alloc есть "хук" – возможность вызвать собственный менеджер куч, если по каким-то причинам системный будет недоступен, впрочем, в Microsoft Visual C++ 6.0 от хука осталась одна лишь обертка, а собственный менеджер куч был исключен).
Все не так в Borland C++! Во-первых, этот зверь напрямую работает с виртуальной памятью Windows, реализуя собственный менеджер кучи, основанный на функциях VirtualAlloc/VirtualFree. Профилировка показывает, что он серьезно проигрывает в производительности Windows 2000 (другие системы не проверял), не говоря уже о том, что помещение лишнего кода в программу увеличивает ее размер. Второе: new вызывает функцию malloc, причем, вызывает не напрямую, а через несколько слоев "оберточного" кода! Поэтому, вопреки всем рекомендациям, под Borland C++ вызов malloc
эффективнее, чем new!
Товарищ Watcom
(во всяком случае, его одиннадцатая версия – последняя, до которой мне удалось дотянуться) реализует new
и malloc практически идентичным образом, - обе они ссылаются на _nmalloc, - очень "толстую" обертку от LocalAlloc. Да, да – 16-разрядной функции Windows, самой являющейся переходником к HeapAlloc!
Таким образом, Джефри Рихтер лопухнулся по полной программе – ни в одном из популярных компиляторов new не быстрее malloc, а вот наоборот – таки да. Уж не знаю, какой он такой редкоземельный компилятор имел ввиду (точнее, не сам компилятор, а библиотеки, поставляемые вместе с ним, но это не суть важно), или, скорее всего, просто писал не думавши. Отсюда мораль – все умозаключения, прежде чем переносить на бумагу, необходимо тщательно проверять.
Идентификация объектов, структур и массивов
Для целого поколения Эйнштейн был глашатаем передовой науки, пророком разума и мира. А сам он в глубине своей кроткой и невозмутимой души без всякой горечи оставался скептиком… Он хотел затеряться и как бы раствориться в окружающем его мире, а оказался одним из самых разрекламированных людей нашего века, и его лицо, вдохновенное и отрешенное от всех грехов мира, стало таким же широко известным, как фотография какой-нибудь кинозвезды.
Чарлз Перси Сноу «ЭЙНШТЕЙН»
Внутренне представление объектов очень похоже на представление структур в языке Си (по большому счету, объекты и есть структуры), поэтому, рассмотрим их идентификацию в одной главе.
Структуры очень популярны среди программистов – позволяя объединить под одной крышей родственные данные, они делают листинг программы более наглядным, упрощая его понимание. Соответственно, идентификация структур при дизассемблировании облегчает анализ кода. К великому сожалению исследователей, структуры как таковые существует только в исходном тексте программы и практически полностью "перемалываются" при ее компиляции, становясь неотличимыми от обычных, никак не связанных друг с другом переменных.
Рассмотрим следующий пример:
#include <stdio.h>
#include <string.h>
struct zzz
{
char s0[16];
int a;
float f;
};
func(struct zzz y)
// Понятное дело, передачи структуры по значению лучше избегать,
// но здесь это сделано умышленно для демонстрации скрытого создания
// локальной переменной
{
printf("%s %x
%f\n",&y.s0[0], y.a, y.f);
}
main()
{
struct zzz y;
strcpy(&y.s0[0],"Hello,Sailor!");
y.a=0x666;
y.f=6.6;
func(y);
}
Листинг 45 Пример, демонстрирующий уничтожение структур на стадии компиляции
Результат его компиляции в общем случае должен выглядеть так:
main proc near ; CODE XREF: start+AFp
var_18 = byte ptr -18h
var_8 = dword ptr -8
var_4 = dword ptr -4
; члены структуры неотличимы от обычных локальных переменных
push ebp
mov ebp, esp
sub esp, 18h
; резервирование места в стеке для структуры
push esi
push edi
push offset aHelloSailor ; "Hello,Sailor!"
lea eax, [ebp+var_18]
; Указатель на локальную переменную var_18
; следующая за ней переменная расположена по смещению 8
; следовательно, 0x18-0x8=0x10 – шестнадцать байт – именно столько
; занимает var_18, что намекает на то, что она – строка
; (см. "Идентификация литералов и строк")
push eax
call strcpy
; копирование строки из сегмента данных в локальную переменную-член структуры
add esp, 8
mov [ebp+var_8], 666h
; занесение в переменную типа DWORD
значения 0x666
mov [ebp+var_4], 40D33333h
; а это значение в формате float равно 6.6
; (см. "Идентификация аргументов функций")
sub esp, 18h
; резервируем место для скрытой локальной переменной, которая используется
; компилятором для передачи функции экземпляра структуры по значению
; (см. "Идентификация локальных переменных – регистровых и временныех
переменныех")
mov ecx, 6
; будет скопировано 6 двойных слов, т.е. 24 байта
; 16 – на строку и по четыре на float
и int
lea esi, [ebp+var_18]
; получаем указатель на копируемую структуру
mov edi, esp
; получаем указатель на только что созданную скрытую локальную переменную
repe movsd
; копируем!
call func
; вызываем функцию
; передачи указателя на скрытую локальную переменную не происходит – она
; и так находится на верху стека.
add esp, 18h
pop edi
pop esi
mov esp, ebp
pop ebp
retn
main endp
Листинг 46
А теперь заменим структуру последовательным объявлением тех же самых переменных:
main()
{
char s0[16];
int a;
float f;
strcpy(&s0[0],"Hello,Sailor!");
a=0x666;
f=6.6;
}
Листинг 47 Пример, демонстрирующий сходство структур с обычными локальными переменными
И сравним результат компиляции с предыдущим:
main proc near ; CODE XREF: start+AFp
var_18 = dword ptr -18h
var_14 = byte ptr -14h
var_4 = dword ptr -4
; Ага, кажется есть какое-то различие! Действительно, локальные переменные помещены
; в стек не в том порядке, в котором они были объявлены в программе, а как это
; захотелось компилятору. Напротив, члены структуры обязательно должны помещаться
; в порядке их объявления.
; Но, поскольку, при дизассемблировании оригинальный порядок следования переменных
; не известен, определить "правильно" ли они расположены или нет, увы,
; не представляется возможным
push ebp
mov ebp, esp
sub esp, 18h
; резервируем 0x18 байт стека (как и предыдущем примере)
push offset aHelloSailor ; "Hello,Sailor!"
lea eax, [ebp+var_14]
push eax
call strcpy
add esp, 8
mov [ebp+var_4], 666h
mov [ebp+var_18], 40D33333h
; смотрите: код аккуратно совпадает байт в байт! Следовательно, невозможно
; автоматически отличить структуру от простого скопища локальных переменных
mov esp, ebp
pop ebp
retn
main endp
func proc near ; CODE XREF: main+36p
var_8 = qword ptr -8
arg_0 = byte ptr 8
arg_10 = dword ptr 18h
arg_14 = dword ptr 1Ch
; смотрите: хотя функции передается только один аргумент – экземпляр структуры –
; в дизассемблерном тексте он не отличим от последовательной засылки в стек
; нескольких локальных переменных! Поэтому, восстановить подлинный прототип
; функции невозможно!
push ebp
mov ebp, esp
fld [ebp+arg_14]
; загрузить в стек FPU вещественное целое, находящееся по смещению
; 0x14 относительно указателя eax
sub esp, 8
; зарезервировать 8 байт пол локал. перемен.
fstp [esp+8+var_8]
; перепихнуть считанное вещественное значение в локальную переменную
mov eax, [ebp+arg_10]
push eax
; прочитать только что "перепихнутую" вещественную переменную
; и затолкать ее в стек
lea ecx, [ebp+arg_0]
; получить указатель на первый аргумент
push ecx
push offset aSXF ; "%s %x %f\n"
call printf
add esp, 14h
pop ebp
retn
func endp
Листинг 48
Выходит, отличить структуру от обычных переменных невозможно? Неужто исследователю придется самостоятельно распознавать "родство" данных и связывать их "брачными узами", порой ошибаясь и неточно воспроизводя исходный текст программы?
Как сказать… И да, и нет одновременно. "Да": экземпляр структуры, использующийся в той же единице трансляции в которой он был объявлен, "развертывается" еще на стадии компиляции в самостоятельные переменные, обращение к которым происходит индивидуально по их фактическим адресам (возможно относительным). "Нет", – если в области видимости находится один лишь указатель на экземпляр структуры. – Тогда обращение ко всем членам структуры происходит через указатель на этот экземпляр структуры (т.к. структура не присутствует в области видимости, например, передается другой функции по ссылке, вычислить фактические адреса ее членов на стадии компиляции невозможно).
Постойте, но ведь точно так происходит обращение и к элементам массива, – базовый указатель указывает на начало массива, к нему добавляется смещение искомого элемента относительно начала массива (индекс элемента, умноженный на его размер), – результат вычислений и будет фактическим указателем на искомый элемент!
Единственное фундаментальное отличие массивов от структур состоит в том, что массивы гомогенны
(т.е. состоят из элементов одинакового типа), а структуры могут быть как гомогенными, таки гетерогенными
(состоящими из элементов различных типов). Таким образом, задача идентификации структур и массивов сводится: во-первых, к выделению ячеек памяти, адресуемых через общий для всех них базовый указатель, и, во-вторых, определению типа этих переменных ___(см.
идентификация типов данных). Если удается выделить более одного типа – скорее всего перед нами структура, в противном случае это с равным успехом может быть и структурой, и массивом, - тут уж приходится смотреть по обстоятельствам и самой программе.
С другой стороны, если программисту вздумается подсчитать зависимость выпитого пива от дня недели, он может выделить для учета либо массив day[7], либо завести структуру struct week{int Monday; int Tuesday;….}. И в том, и в другом случае сгенерированный компилятором код будет одинаков, да не только код, но и смысл! В этом контексте структура неотличима от массива и физически, и логически, - выбор той или иной конструкции – дело вкуса.
Так же возьмите себе на заметку, что массивы, как правило, длинны, а обращение к их элементам часто сопровождается различными математическими операциями, совершаемыми над указателем. Далее – обработка элементов массива как правило осуществляется в цикле, а члены структуры по обыкновению "разбираются" индивидуально (хотя некоторые программисты позволяют себе вольность обращаться со структурой как с массивом). Еще неприятнее, что Си/Си++ допускают (если не сказать провоцируют) явное преобразование типов и… ой, а ведь в этом случае, при дизассемблировании не удастся установить: имеем ли мы дело с объединенными под одну крышу разнотипными данными (т.е. структуру), или же это массив, c "ручным" преобразованием типа своих элементов. Хотя, строго говоря, после подобных преобразований массив превращается в самую настоящую структуру! (Массив по определению гомогенен, и данные разных типов хранить не может).
Модифицируем предыдущий пример, передав функции не саму структуру, а указатель на нее и посмотрим, что за код сгенерировал компилятор.
funct proc near ; CODE XREF: sub_0_401029+29p
var_8 = qword ptr -8
arg_0 = dword ptr 8
; ага! Функция принимает только один аргумент!
push ebp
mov ebp, esp
mov eax, [ebp+arg_0]
; загружаем переданный функции аргумент в EAX
fld dword ptr [eax+14h]
; загружаем в стек FPU вещественное значение, находящееся по смещению
; 0x14 относительно указателя EAX
; Таким образом, во-первых, EAX (аргумент, переданный функции) – это указатель
; во-вторых, это не просто указатель, а базовый указатель, использующийся
; для доступа к элементам структуры или массива.
; Запомним тип первого элемента (вещественное значение) и продолжим анализ
sub esp, 8
; резервируем 8 байт пол локальные переменные
fstp [esp+8+var_8]
; перепихиваем считанное вещественное значение в локальную переменную var_8
mov ecx, [ebp+arg_0]
; Загружаем в ECX значение переданного функции указателя
mov edx, [ecx+10h]
; загружаем в EDX значение, лежащее по смещению 0x10
; Ага! Это явно не вещественное значение, следовательно, мы имеем дело со
; структурой
push edx
; заталкиваем только что считанное значение в стек
mov eax, [ebp+arg_0]
push eax
; получаем указатель на структуру (т.е. на ее первый член)
; и запихиваем его в стек. Поскольку ближайший элемент
; находится по смещению 0x10, то первый элемент структуры по-видимому
; занимает все эти 0x10 байт, хотя это и не обязательно – возможно остальные
; члены структуры просто не используются. Установить: как все обстоит на самом
; деле можно, обратившись к вызывающей (материнской) функции, которая и
; инициализировала эту структуру, но и без этого, мы можем восстановить
; ее приблизительный вид
; struct xxx{
; char x[0x10] || int x[4] || __int16[8] || __int64[2];
; int y;
; float z;
; }
push offset aSXF ; "%s %x %f\n"
; строка спецификаторов, позволяет уточнить типы данных – так, первый элемент
; это, бесспорно, char x[x010], поскольку, он выводится как строка,
; следовательно наше предварительное предположение о формате структуры –
; верное!
call printf
add esp, 14h
pop ebp
retn
funct endp
main proc near ; CODE XREF: start+AFp
var_18 = byte ptr -18h
var_8 = dword ptr -8
var_4 = dword ptr -4
; смотрите: на первый взгляд мы имеем дело с несколькими локальными переменными,
; но давайте не будем торопиться с их идентификацией!
push ebp
mov ebp, esp
sub esp, 18h
; Открываем кадр стека
push offset aHelloSailor ; "Hello,Sailor!"
lea eax, [ebp+var_18]
push eax
call unknown_libname_1
; unknown_libmane_1 – это strcpy и понять это можно даже не анализируя ее код.
; Функция принимает два аргумента – указатель на локальный буфер из 0x10 байт
; (размер 0x10 получен вычитанием смещения ближайшей переменной от смещения
; самой этой переменной относительно карда стека) такой же точно прототип
; и у strcmp, но это не может быть strcmp, т.к. локальный буфер
; не инициализирован, и он может быть только буфером-приемником
add esp, 8
; выталкиваем аргументы из стека
mov [ebp+var_8], 666h
; инициализируем локальную переменную var_8 типа DWORD
mov [ebp+var_4], 40D33333h
; инициализируем локальную переменную var_4 типа... нет, не DWORD
; (хотя она и выглядит как DWORD), - проанализировав, как эта переменная
; используется в функции funct, которой она передается, мы распознаем
; в ней вещественное значение размером 4 байта. Стало быть это float
; (подробнее см. "Идентификация аргументов функций")
lea ecx, [ebp+var_18]
push ecx
; Вот теперь – самое главное! Функции передается указатель на локальную
; переменную var_18, - строковой буфер размером в 0x10 байт,
; но анализ вызываемой функции позволил установить, что она обращается не
; только к первым 0x10 байтам стека материнской функции, а ко всем – 0x18!
; Следовательно, функции передается не указатель на строковой буфер,
; а указатель на структуру
;
; srtuct x{
; char var_18[10];
; int var_8;
; float var_4
; }
;
; Поскольку, типы данных различны, то это – именно структура, а не массив.
call funct
add esp, 4
mov esp, ebp
pop ebp
retn
sub_0_401029 endp
Листинг 49
::Идентификация объектов. Объекты языка Си++ - это, по сути дела, структуры, совмещающие в себе данные, методы их обработки (функции то бишь), и атрибуты защиты (типа public, friend…).
Элементы-данные объекта обрабатываются компилятором равно как и обычные члены структуры. Не виртуальные функции вызываются по фактическому смещению и в объекте отсутствуют. Виртуальные функции вызываются через специальный указатель на виртуальную таблицу, помещенный в объект, а атрибуты защиты уничтожаются еще на стадии компиляции. Отличить публичную функцию от защищенной можно только тем, что публичная вызывается и из других объектов, а защищенная – только из своего объекта.
Теперь обо всем этом подробнее. Итак, объект (вернее, экземпляр объекта) – что он собой представляет? Пусть у нас есть следующий объект:
class MyClass{
void demo_1(void);
int a;
int b;
public:
virtual void demo_2(void);
int c;
};
MyClass zzz;
Листинг 50 Пример, демонстрирующий строение объекта
Экземпляр объекта zzz "перемелется" компилятором в следующую структуру (см. рис 13):
Рисунок 13 0х008 Представление экземпляра объекта в памяти.
Перед исследователем встают следующие проблемы: как отличить объекты от простых структур? Как определить размер объектов? Как определить какая функция к какому объекту принадлежит? Как…. Погодите, погодите, не все сразу! Начнем, отвечать на вопросы по порядку согласно социалистической очереди.
Вообще же, строго говоря, отличить объект от структуры невозможно в силу того, что объект и есть структура с членами приватными по умолчанию. При объявлении объектов можно пользоваться и ключевым словом "struct", и ключевым словом "class". Причем, для классов, все члены которых открыты, предпочтительнее использовать именно "struc", т.к.
члены структуры уже публичны по умолчанию. Сравните два следующих примера:
struct MyClass{ class MyClass{
void demo(void); void demo_private(void);
int x; int y;
private: public:
void demo_private(void); void demo(void);
int y; int x;
}; };
Листинг 51 Классы – это структуры с членами приватными по умолчанию
Одна запись отличается от другой лишь синтаксически, а код, генерируемый компилятором, будет идентичен! Поэтому, с надеждой научиться отличать объекты от структур следует как можно скорее расстаться.
ОК, условимся считать объектами структуры, содержащие одну или более функций, вот только как определить какая функция какому объекту принадлежит? С виртуальными функциями все просто – они вызываются косвенно, через указатель на виртуальную таблицу, помещаемый компилятором в каждый экземпляр объекта, к которому принадлежит данная виртуальная функция. Не виртуальные функции вызываются по их фактическому адресу, равно как и обычные функции, не принадлежащие никакому объекту. Положение безнадежно? Отнюдь нет! Каждой функции-члену объекта передается неявный аргумент – указатель this, ссылающийся на экземпляр объекта, к которому принадлежит данная функция. Экземпляр объекта это, правда, не сам объект, но нечто очень тесно с ним связанное, поэтому, восстановить исходную структуру объектов дизассемблируемой программы – вполне реально (подробнее об этом см. "Объекты и экземпляры")
Размер объектов
определяется теми же указателями this – как разница соседний указателей (если объекты расположены в стеке или в сегменте данных). Если же экземпляры объектов создаются оператором new (как часто и бывает), то в код помещается вызов функции new, принимающий в качестве аргумента количество выделяемых байт, - это и есть размер объекта.
Вот, собственно, и все. Остается добавить, что многие компиляторы, создавая экземпляр объекта, не содержащего ни данных, ни виртуальных функций, все равно выделяют под него минимальное количество памяти (обычно один байт), хотя никак его не используют.
call demo
; Вот мы и добрались до вызова функции demo
– открываем хвост Тигре!
; Пока не ясно, что эта функция делает (символьное имя дано ей для наглядности)
; но известно, что она принадлежит экземпляру объекта, на который
; указывает ECX. Назовем этот экземпляр 'a'. Далее – поскольку
; функция, вызывающая demo (т.е. функция в которой мы сейчас находимся), не
; принадлежит к 'a' (она же его сама и создала – не мог же экземпляр объекта
; сам "вытянуть себя за волосы"), то функция demo
– это public-функция.
; Неплохо для начала?
mov dword ptr [esi], 777h
; так, так... мы помним, что ESI указывает на экземпляр объекта, тогда
; выходит, что в объекте есть еще один public-член, это переменная
; типа int.
; По предварительным заключениям объект выглядел так:
; class myclass{
; public:
; void demo(void); // void –т.к. функция ничего не принимает и не возвращает
; int x;
;}
pop esi
retn
main endp
demo proc near ; CODE XREF: main+Fp
; вот мы в функции demo – члене объекта A
push esi
mov esi, ecx
; Загружаем в ECX – указатель this, переданный функции
push offset aMyclass ; "MyClass\n"
call printf
add esp, 4
; Выводим строку на экран...это не интересно, но вот дальше…
mov ecx, esi
call demo_private
; Опля, вот он, наш Тигра! Вызывается еще одна функция! Судя по this,
; эта функция нашего объекта, причем вероятнее всего имеющая атрибут private,
; поскольку вызывается только из функции самого объекта.
mov dword ptr [esi+4], 666h
; Так, в объекте есть еще одна переменная, вероятно, приватная. Тогда,
; по современным воззрениям, объект должен выглядеть так:
; class myclass{
; void demo_provate(void);
; int y;
; public:
; void demo(void); // void –т.к. функция ничего не принимает и не возвращает
; int x;
; }
;
; Итак, мы не только идентифицировали объект, но даже восстановили его
; структуру! Пускай, не застрахованную от ошибок (так, предположение
; о приватности "demo_private" и "y" базируется лишь на том, что они ни разу
; не вызывались извне объекта), но все же – не так ООП страшно, как его
; малюют и восстановить если не подлинный исходный текст программы, то хотя бы
; какое-то его подобие вполне возможно!
pop esi
retn
demo endp
demo_private proc near ; CODE XREF: demo+12p
; приватная функция demo. – ничего интересного
push offset aPrivate ; "Private\n"
call printf
pop ecx
retn
demo_private endp
Листинг 53
::Объекты и экземпляры. В коде, сгенерированном компилятором, никаких объектов и в помине нет, – одни лишь экземпляры объектов. Вроде бы – да какая разница-то? Экземпляр объекта разве не есть сам объект? Нет, между объектом и экземпляром существует принципиальная разница. Объект – это структура, в то время как экземпляр объекта (в сгенерированном коде!) – подструктура этой структуры. Т.е. пусть имеется объект А, включающий в себя функции a1 и a2. Далее, пусть создано два его экземпляра – из одного мы вызываем функцию a1, а из другого – a2. С помощью указателя this мы сможем выяснить лишь то, что одному экземпляру принадлежит функция a1, а другому – a2. Но установить – являются ли эти экземпляры экземплярами одного объекта или экземплярами двух разных объектов – невозможно! Ситуация усугубляется тем, что в производных классах наследуемые функции не дублируются (во всяком случае, так поступают "умные" компиляторы, хотя… в жизни случается всякое). Возникает двузначность – если с одним экземпляром связаны функции a1 и a2, а с другим - a1, a2 и a3, то это могут быть либо экземпляры одного класса (просто из первого экземпляра функция a3 не вызывается), то ли второй экземпляр – экземпляр класса, производного от первого. Код, сгенерированный компилятором, в обоих случаях будет идентичным! Приходится восстанавливать иерархию классов по смыслу и назначению принадлежащих им функций… понятное дело, приблизиться к исходному коду сможет только провидец (ясновидящий).
Словом, как бы там ни было, никогда не путайте экземпляр объекта с самим объектом, и не забываете, что объекты существуют только в исходном тексте и уничтожаются на стадии компиляции.
::мой адрес – не дом и не улица! Где "живут" структуры, массивы и объекты? Конечно же, в памяти! А поконкретнее? Конкретнее: существуют три типа размещения: в стеке
(автоматическая память), сегменте данных (статическая память) и куче (динамическая память). И каждый тип со своим "характером". Возьмем стек – выделение памяти неявное, фактически происходящее на этапе компиляции, причем гарантированно определяется только общий объем памяти, выделенный под все локальные переменные, а определить: сколько занимает каждая из них – невозможно в принципе. Не верите? А вот скажем, пусть будет такой код: "char a1[13]; char a2[17]; char a3[23]". Если компилятор выровняет массивы по кратным адресам (а это делают многие компиляторы), то разница смещений ближайших друг к другу массивов может и не быть равна их размеру. Единственная надежда восстановить подлинный размер – найти в коде проверки на выход за границы массива (если они есть – их часто не бывает). Второе (самое неприятное) – если один из массивов не используется, а только объявляется, то не оптимизирующие компиляторы (и даже некоторые оптимизирующие!) могут, тем не менее, отвести для него стековое пространство. Он вплотную примкнет к предыдущему массиву и… гадай – то ли размер массива такой, то ли в его конец "вбухан" неиспользуемый массив? Ну, с массивами куда бы еще ни шло, а вот со структурами и объектами дела обстоят намного хуже. Никому и в голову не придет помещать в программу код, отслеживающий выход за пределы структуры (объекта). Такое невозможно в принципе (ну разве что программист слишком вольно работает с указателями)!
Ладно, оставим в стороне размер, перейдем к проблемам "разверстки" и поиску указателей. Как уже говорилось выше, если массив (объект, структура) объявляется в непосредственной области видимости единицы трансляции, он "вспарывается" на этапе компиляции и обращение к его членам происходят по фактическому смещению, а не базовому указателю.
К счастью, идентификацию объектов облегчает наличие в них указателя на виртуальную таблицу, но ведь не факт, что любая таблица указателей на функции – есть виртуальная таблица! Может, это просто массив указателей на функции, определенный самим программистом? Вообще-то, при наличии опыта такие ситуации можно легко распознать (см. "Идентификация виртуальных функций"), но все-таки они достаточно неприятны.
С объектами, расположенными в статической памяти, дела обстоят намного проще, - в силу своей глобальности они имеют специальный флаг, предотвращающий повторный вызов конструктора (подробнее см. "Идентификация конструктора и деструктора"), поэтому, отличить экземпляр объекта, расположенный в сегменте данных, от структуры или массива становится очень легко. С определением его размера, правда, все те же неувязки.
Наконец, объекты (структуры, массивы), расположенные в куче – просто сказка для анализа! Отведение памяти осуществляется функцией, явно принимающей количество выделяемых байт в качестве своего аргумента, и возвращающей указатель, гарантированно указывающий на начало экземпляра объекта (структуры, массива). Радует и то, что обращение к элементам всегда происходит через базовый указатель, даже если объявление совершается в области видимости (иначе и быть не может – фактические адреса выделяемых блоков динамической памяти не известны на стадии компиляции).
__дописать – восстановление структуры многомерных массивов
Идентификация регистровых и временных переменных
Ничто не постоянно так, как временное
Народная мудрость
Стремясь минимализировать количество обращений к памяти, оптимизирующие компиляторы размещают наиболее интенсивно используемые локальные переменные в регистрах общего назначения, только по необходимости сохраняя их в стеке (а в идеальном случае не сохраняя их вовсе).
Какие трудности для анализа это создает? Во-первых, вводит контекстную зависимость в код. Так, увидев в любой точке функции команду типа "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).
Механизм выделения памяти под временные переменные практически идентичен механизму выделения памяти стековым локальным переменным, однако, никаких проблем идентификации не возникает. Во-первых, выделение памяти стековым переменным происходит сразу же после открытия кадра стека, а временным переменными – в любой точке функции. Во-вторых, временные переменные адресуются не через регистр указатель кадра стека, а через указатель вершины стека.
действие |
методы |
||
1й |
2й |
3й |
|
резервирование памяти |
PUSH |
SUB ESP, xxx |
использовать стековые аргументы >>># |
освобождение памяти |
POP |
ADD ESP, xxx |
|
запись переменной |
PUSH |
MOV [ESP+xxx], |
MOVS |
чтение переменной |
POP |
MOV , [ESP+xxx] |
передача вызываемой функции |
>>># Только в 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
Идентификация стартовых функций
…чтобы не наделать ошибок в работе, богу понадобился свет. Судя по этому, в предшествовавшие века он сидел в полной темноте. К счастью, он не рисковал обо что-либо стукнуться, ибо вокруг ничего не было.
Лео Таксиль "Забавная Библия"
Если первого встречного программиста спросить "С какой функции начинается выполнение Windows-программы?", вероятнее всего мы услышим в ответ "С WinMain" и это будет ошибкой. На самом же деле, первым управление получает стартовый код, скрыто вставляемый компилятором, – выполнив необходимые инициализационные процедуры, в какой-то момент он вызывает WinMain, а после ее завершения вновь получает управление и выполняет "капитальную" деинициализацию.
В подавляющем большинстве случаев стартовый код не представляет никакого интереса и первой задачей анализирующего становится поиск функции WinMain. Если компилятор входит в число "знакомых" IDA, она опознает WinMain автоматически, в противном же случае это приходится делать руками и головой. Обычно в штатную поставку компилятора входят исходные тексты его библиотек, в том числе и процедуры стартового кода. Например, у Microsoft Visual C++ стартовый код расположен в файлах "CRT\STC\CRT0.C" – версия для статичной компоновки, "CRT\SRC\CRTEXE.C" – версия для динамичной компоновки (т.е. библиотечный код не пристыкуется к файлу, а вызывается из DLL), "CRT\SRC\wincmdln.c" – версия для консольных приложений. У Borland C++ все файлы со start-up кодом хранятся в отдельной одноименной директории, в частности, стартовый код для Windows-приложений содержится в файле "c0w.asm". Разобравшись с исходными текстами, понять дизассемблерный листинг будет намного легче!
А как быть, если для компиляции исследуемой программы использовался неизвестный или недоступный вам компилятор? Прежде, чем приступать к утомительному ручному анализу, давайте вспомним: какой прототип имеет функция WinMain:
int WINAPI WinMain(
HINSTANCE hInstance, // handle to current instance
HINSTANCE hPrevInstance, // handle to previous instance
LPSTR lpCmdLine, // pointer to command line
int nCmdShow // show state of window
);
Во-первых, четыре аргумента (см. "Идентификация аргументов функций") – это достаточно много и в большинстве случаев WinMain
оказывается самой "богатой" на аргументы функцией стартового кода. Во-вторых, последний заносимый в стек аргумент – hInstance – чаще всего вычисляется "на лету" вызовом GetModuleHandleA, - т.е. встретив конструкцию типа "CALL GetModuleHandleA" можно с высокой степенью уверенности утверждать, что следующая функция – и есть WinMain. Наконец, вызов WinMain обычно расположен практически в самом конце кода стартовой функции. За ней бывает не более двух-трех "замыкающих" строй функций таких как "exit" и "XcptFilter".
Рассмотрим следующий фрагмент кода. Сразу бросается в глаза множество инструкций PUSH, заталкивающих в стек аргументы, последний из которых передает результат завершения GetModuleHandleA. Значит, перед нами ни что иное, как вызов WinMain (и IDA подтверждает, что это именно так):
.text:00401804 push eax
.text:00401805 push esi
.text:00401806 push ebx
.text:00401807 push ebx
.text:00401808 call ds:GetModuleHandleA
.text:0040180E push eax
.text:0040180F call _WinMain@16
.text:00401814 mov [ebp+var_68], eax
.text:00401817 push eax
.text:00401818 call ds:exit
Листинг 21 Идентификация функции WinMain по роду и количеству передаваемых ей аргументов
Но не всегда все так просто, - многие разработчики, пользуясь наличием исходных текстов start-up кода, модифицируют его (под час весьма значительно). В результате – выполнение программы может начинаться не с WinMain, а любой другой функции, к тому же теперь стартовый код может содержать критические для понимания алгоритма программы операции (например, расшифровщик основного кода)! Поэтому, всегда
хотя бы мельком следует изучить start-up код – не содержит ли он чего-нибудь необычного?
Аналогичным образом обстоят дела и с динамическими библиотеками – их выполнение начинается вовсе не с функции DllMain (если она, конечно, вообще присутствует в DLL), а с __DllMainCRTStartup (по умолчанию). Впрочем, разработчики под час изменяют умолчания, назначая ключом "/ENTRY" ту стартовую функцию, которая им нужна. Строго говоря, неправильно называть DllMain стартовой функций – она вызывается не только при загрузке DLL, но так же и при выгрузке, и при создании/уничтожении подключившим ее процессором нового потока. Получая уведомления об этих событиях, разработчик может предпринимать некоторые действия (например, подготавливать код к работе в многопоточной среде). Весьма актуален вопрос – имеет ли все это значение для анализа программы? Ведь чаще всего требуется проанализировать не всю динамическую библиотеку целиком, а исследовать работу некоторых экспортируемых ею функций. Если DllMain выполняет какие-то действия, скажем, инициализирует переменные, то остальные функции, на которых распространяется влияние этих переменных, будут содержать на них прямые ссылки, ведущие прямиком к DllMain. Таким образом, не стоит вручную искать DllMain, - она сама себя обнаружит! Хорошо, если бы всегда это было так! Но жизнь сложнее всяких правил. Вдруг в DllMain находится некий деструктивный код или библиотека помимо основной своей деятельности шпионит за потоками, отслеживая их появление? Тогда без непосредственного анализа ее кода не обойтись!
Обнаружить DllMain на порядок труднее, чем WinMain, если ее не найдет IDA – пиши пропало. Во-первых, прототип DllMain достаточно незамысловат и не содержит ничего характерного:
BOOL WINAPI DllMain(
HINSTANCE hinstDLL, // handle to DLL module
DWORD fdwReason, // reason for calling function
LPVOID lpvReserved // reserved
);
А, во-вторых, ее вызов идет из самой гущи довольно внушительной функции __DllMainCRTStartup
и быстро убедиться, что это именно тот CALL, который нам нужен – нет никакой возможности. Впрочем, некоторые зацепки все-таки есть. Так, при неудачной инициализации DllMain возвращает FALSE, и код __DllMainCRTStartup обязательно проверит это значение, в случае чего прыгая аж к концу функции. Подробных ветвлений в теле стартовой функции не так уж много и обычно только одно из них связано с функций, принимающей три аргумента.
.text:1000121C push edi
.text:1000121D push esi
.text:1000121E push ebx
.text:1000121F call _DllMain@12
.text:10001224 cmp esi, 1
.text:10001227 mov [ebp+arg_4], eax
.text:1000122A jnz short loc_0_10001238
.text:1000122C test eax, eax
.text:1000122E jnz short loc_0_10001267
Листинг 22 Идентификация DllMain по коду неудачной инициализации
Прокрутив экран немного вверх, нетрудно убедиться, что регистры EDI, ESI и EBX содержат lpvReserved, fdwReason и hinstDLL соответственно. А значит, перед нами и есть функция DllMain (Для справки, исходный текст __DllMainCRTStartup содержится в файле "dllcrt0.c", который настоятельно рекомендуется изучить).
Наконец, мы добрались и до функции main
консольных Windows-приложений. Как всегда, выполнение программы начинается не с нее, а c функции mainCRTStartup, инициализирующей кучу, систему ввода-вывода, подготавливающую аргументы командной строки и только потом предающей управление main. Функция main
принимает всего два аргумента: "int main(int argc, char **argv)" – этого слишком мало, чтобы выделить ее среди остальных. Однако приходит на помощь тот факт, что ключи командной строки доступны не только через аргументы, но и через глобальные переменные – __argc
и __argv
соответственно. Поэтому, вызов main обычно выглядит так:
.text:00401293 push dword_0_407D14
.text:00401299 push dword_0_407D10
.text:0040129F call _main
.text:0040129F ; Смотрите: оба аргумента функции – указатели на глобальные переменные
.text:0040129F ; (см. "Идентификация глобальных переменных")
.text:0040129F
.text:004012A4 add esp, 0Ch
.text:004012A7 mov [ebp+var_1C], eax
.text:004012AA push eax
.text:004012AA ; Смотрите: возвращаемое функцией знаечние, передается функции exit
.text:004012AA ; как код завершения процесса
.text:004012AA ; Значит, это и main и есть!
.text:004012AA
.text:004012AB call _exit
Листинг 23 Идентификация main
Обратите внимание и на то, что результат завершения main
передается следующей за ней функции (это, как правило, библиотечная функция exit).
Вот мы и разобрались с идентификацией основных типов стартовых функций. Конечно, в жизни бывает не все так просто, как в теории, но в любом случае, описанные выше приемы заметно упростят анализ.
__дописать идентификацию стартовых функций FreePascal, Fortran….
Идентификация switch – case – break
"…когда вы видите все целиком, то у вас нет выбора, вам не из чего выбирать. Тогда вы имеете два пути одновременно, следуете одновременно этим двум направлениям"
Ошо "Пустая лодка" Беседы по высказываниям Чжуан Цзы
Для улучшения читабельности программ в язык Си был введен оператор множественного выбора – switch. В Паскале с той же самой задачей справляется оператор CASE, кстати, более гибкий, чем его Си-аналог, но об их различиях мы поговорим попозже.
Легко показать, что switch эквивалентен конструкции "IF (a == x1) THEN оператор1
ELSE IF (a == x2) THEN оператор2
IF (a == x2) THEN оператор2
IF (a == x2) THEN оператор2 ELSE …. оператор по умолчанию". Если изобразить это ветвление в виде логического дерева, то образуется характерная "косичка", прозванная так за сходство с завитой в косу прядью волос – см. рис. 29
Казалось бы, идентифицировать switch никакого труда не составит, – даже не стоя дерева, невозможно не обратить внимания на длинную цепочку гнезд, проверяющих истинность условия равенства некоторой переменной с серией непосредственных значений (сравнения переменной с другой переменной switch не допускает).
Рисунок 29 0х01С Трансляция оператора switch в общем случае
Однако в реальной жизни все происходит совсем не так. Компиляторы (даже не оптимизирующие) транслируют switch в настоящий "мясной рулет", доверху нашпигованных всевозможными операциями отношений. Давайте, откомпилируем приведенный выше пример компилятором Microsoft Visual C++ и посмотрим, что из этого выйдет:
main proc near ; CODE XREF: start+AFp
var_tmp = dword ptr -8
var_a = dword ptr –4
push ebp
mov ebp, esp
; Открываем кадр стека
sub esp, 8
; Резервируем место для локальных переменных
mov eax, [ebp+var_a]
; Загружаем в EAX значение переменной var_a
mov [ebp+var_tmp], eax
; Обратите внимание – switch создает собственную временную переменную!
; Даже если значение сравниваемой переменной в каком-то ответвлении CASE
; будет изменено, это не повлияет на результат выборов!
; В дальнейшем во избежании путаницы, мы будем условно называть
; переменную var_tmp
переменной var_a
cmp [ebp+var_tmp], 2
; Сравниваем значение переменной var_a с двойкой
; Хм-хм, в исходном коде CASE начинался с нуля, а заканчивался 0x666
; Причем же тут двойка?!
jg short loc_401026
; Переход, если var_a
> 2
; Обратите на этот момент особое внимание – ведь в исходном тексте такой
; операции отношения не было!
; Причем, этот переход не ведет к вызову функции printf, т.е. этот фрагмент
; кода получен не прямой трансляцией некой ветки case, а как-то иначе!
cmp [ebp+var_tmp], 2
; Сравниваем значение var_a
с двойкой
; Очевидный "прокол" компилятора – мы же только что проделывали эту
; операции, и с того момента не меняли никакие флаги!
jz short loc_40104F
; Переход к вызову printf("a == 2"), если var_a
== 2
; ОК, этот код явно получен трансляцией ветки CASE 2: printf("a == 2")
cmp [ebp+var_tmp], 0
; Сравниваем var_a
с нулем
jz short loc_401031
; Переход к вызову printf("a == 0"), если var_a
== 0
; Этот код получен трансляцией ветки CASE 0: printf("a == 0")
cmp [ebp+var_tmp], 1
; Сравниваем var_a
с единицей
jz short loc_401040
; Переход к вызову printf("a == 1"), если var_a
== 1
; Этот код получен трансляцией ветки CASE 1: printf("a == 1")
jmp short loc_40106D
; Переход к вызову printf("Default")
; Этот код получен трансляцией ветки Default: printf("a == 0")
loc_401026: ; CODE XREF: main+10j
; Эта ветка получает управление, если var_a > 2
cmp [ebp+var_tmp], 666h
; Сравниваем var_a
со значением 0x666
jz short loc_40105E
; Переход к вызову printf("a == 666h"), если var_a
== 0x666
; Этот код получен трансляцией ветки CASE 0x666: printf("a == 666h")
jmp short loc_40106D
; Переход к вызову printf("Default")
; Этот код получен трансляцией ветки Default: printf("a == 0")
loc_401031: ; CODE XREF: main+1Cj
; // printf("A == 0")
push offset aA0 ; "A == 0"
call _printf
add esp, 4
jmp short loc_40107A
; ^^^^^^^^^^^^^^^^^^^^^^ - а вот это оператор break, выносящий управление
; за пределы switch – если бы его не было, то начали бы выполняться все
; остальные ветки CASE, не зависимо от того, к какому значению var_a они
; принадлежат!
loc_401040: ; CODE XREF: main+22j
; // printf("A == 1")
push offset aA1 ; "A == 1"
call _printf
add esp, 4
jmp short loc_40107A
; ^ break
loc_40104F: ; CODE XREF: main+16j
; // printf("A == 2")
push offset aA2 ; "A == 2"
call _printf
add esp, 4
jmp short loc_40107A
; ^ break
loc_40105E: ; CODE XREF: main+2Dj
; // printf("A == 666h")
push offset aA666h ; "A == 666h"
call _printf
add esp, 4
jmp short loc_40107A
; ^ break
loc_40106D: ; CODE XREF: main+24j main+2Fj
; // printf("Default")
push offset aDefault ; "Default"
call _printf
add esp, 4
loc_40107A: ; CODE XREF: main+3Ej main+4Dj ...
; // КОНЕЦ SWITCH
mov esp, ebp
pop ebp
; Закрываем кадр стека
retn
main endp
Листинг 173
Построив логическое дерево (см. "Идентификация IF – THEN – ELSE"), мы получим следующую картину (см. рис. 30). При ее изучении бросается в глаза, во-первых, условие "a >2", которого не было в исходной программе, а во-вторых, изменение порядка обработки case. В то же время, вызовы функций printf следуют один за другим строго согласно их объявлению.
Зачем же компилятор так чудит? Чего он рассчитывает этим добиться?
Рисунок 30 0x01D Пример трансляция оператора switch компилятором Microsoft Visual C
Назначение гнезда (a > 2) объясняется очень просто – последовательная обработка всех операторов case крайне непроизводительная. Хорошо, если их всего четыре-пять штук, а если программист натолкает в switch сотню - другую case? Процессор совсем запарится, пока их все проверит (а по закону бутерброда нужный case будет в самом конце). Вот компилятор и "утрамбовывает" дерево, уменьшая его высоту. Вместо одной ветви, изображенной на рис. 30, транслятор в нашем случае построил две, поместив в левую только числа не большие двух, а в правую – все остальные. Благодаря этому, ветвь "666h" из конца дерева была перенесена в его начало. Данный метод оптимизации поиска значений называют "методом вилки", но не будет сейчас на нем останавливаться, а лучше разберем его в главе "Обрезка длинных деревьев".
Изменение порядка сравнений – право компилятора. Стандарт ничего об этот не говорит и каждая реализация вольна поступать так, как ей это заблагорассудится. Другое дело – case-обработчики (т.е. тот код, которому case передает управление в случае истинности отношения). Они обязаны располагаться так, как были объявлены в программе, т.к. при отсутствии закрывающего оператора break они должны выполняться строго в порядке, замышленном программистом, хотя эта возможность языка Си используется крайне редко.
Таким образом, идентификация оператора switch
не сильно усложняется: если после уничтожения узлового гнезда и прививки правой ветки к левой (или наоборот) мы получаем эквивалентное дерево, и это дерево образует характерную "косичку" – мы имеем дело с оператором множественного выбора или его аналогом.
Весь вопрос в том: правомерны ли мы удалять гнездо, не нарушит ли эта операция структуры дерева? Смотрим – на левой ветке узлового гнезда расположены гнезда (a == 2), (a == 0) и (a == 1), а на левом – (a==0x666) Очевидно, если a == 0x666, то a != 0 и a != 1! Следовательно, прививка правой ветки к левой вполне безопасна и после такого преобразования дерево принимает вид типичный для конструкции switch (см.
рис. 31 ).
Рисунок 31 0x01E Усечение логического дерева
Увы, такой простой прием идентификации срабатывает не всегда! Иные компиляторы такого наворотят, что волосы в разных местах дыбом встанут! Если откомпилировать наш пример компилятором Borland C++ 5.0, то код будет выглядеть так:
; int __cdecl main(int argc,const char **argv,const char *envp)
_main proc near ; DATA XREF: DATA:00407044o
push ebp
mov ebp, esp
; Открываем кадр стека
; Компилятор помещает нашу переменную a в регистр EAX
; Поскольку она не была инициализирована, то заметить этот факт
; не так-то легко!
sub eax, 1
; Уменьшает EAX на единицу! Что бы этого значило, хвост Тиггера?
; Никакого вычитания в нашей программе не было!
jb short loc_401092
; Если EAX < 1, то переход на вызов printf("a == 0")
; (мы ведь помним, что CMP та же команда SUB, только не изменяющая операндов?)
; Ага, значит, этот код сгенерирован в результате трансляции
; ветки
CASE 0: printf("a == 0");
; Внимание! задумайтесь: какие значения может принимать EAX, чтобы
; удовлетворять условию этого отношения? На первый взгляд, EAX < 1,
; в частости, 0, -1, -2,… СТОП! Ведь jb – это беззнаковая инструкция
; сравнения! А –0x1 в беззнаковом виде выглядит как 0xFFFFFFFF
; 0xFFFFFFFF много больше единицы, следовательно, единственным подходящим
; значением будет ноль
; Таким образом, данная конструкция – просто завуалированная проверка EAX на
; равенство нулю! (Ох! и хитрый же этот Borland – компилятор!)
;
jz short loc_40109F
; Переход, если установлен флаг нуля
; Он будет он установлен в том случае, если EAX
== 1
; И действительно переход идет на вызов printf("a == 1")
dec eax
; Уменьшаем EAX на единицу
jz short loc_4010AC
; Переход если установлен флаг нуля, а он будет установлен когда после
; вычитания единицы командой SUB, в EAX останется ровно единица,
; т.е. исходное значение EAX должно быть равно двум
; И точно – управление передается ветке вызова printf("a == 2")!
sub eax, 664h
; Отнимаем от EAX число 0x664
jz short loc_4010B9
; Переход, если установлен флаг нуля, т.е. после двукратного уменьшения EAX
; равен 0x664, следовательно, исходное значение – 0x666
jmp short loc_4010C6
; прыгаем на вызов printf("Default"). Значит, это – конец switch
loc_401092: ; CODE XREF: _main+6j
; // printf("a==0");
push offset aA0 ; "a == 0"
call _printf
pop ecx
jmp short loc_4010D1
loc_40109F: ; CODE XREF: _main+8j
; // printf("a==1");
push offset aA1 ; "a == 1"
call _printf
pop ecx
jmp short loc_4010D1
loc_4010AC: ; CODE XREF: _main+Bj
; // printf("a==2");
push offset aA2 ; "a == 2"
call _printf
pop ecx
jmp short loc_4010D1
loc_4010B9: ; CODE XREF: _main+12j
; // printf("a==666");
push offset aA666h ; "a == 666h"
call _printf
pop ecx
jmp short loc_4010D1
loc_4010C6: ; CODE XREF: _main+14j
; // printf("Default");
push offset aDefault ; "Default"
call _printf
pop ecx
loc_4010D1: ; CODE XREF: _main+21j _main+2Ej ...
xor eax, eax
pop ebp
retn
_main endp
Листинг 174
Код, сгенерированный компилятором, модифицирует сравниваемую переменную в процессе сравнения! Оптимизатор посчитал, что DEC EAX
короче, чем сравнение с константой, да и работает шустрее. Вот только нам, хакером, от этого утешения ничуть не легче! Ведь прямая ретрансляция кода (см. "Идентификация IF – THEN – ELSE") дает конструкцию вроде: "if (a-- == 0) printf("a == 0"); else if (a==0) printf("a == 1"); else if (--a == 0) printf("a == 2"); else if ((a-=0x664)==0) printf("a == 666h); else printf("Default")", - в которой совсем не угадывается оператор switch! Впрочем, почему это "не угадывается"?! Угадывается, еще как! Где есть длинная цепочка "IF-THEN-ELSE-IF-THEN-ELSE…" там и до switch-а недалеко! Узнать оператор множественного выбора будет еще легче, если изобразить его в виде дерева – смотрите (см.
рис. 32) вот она, характерная "косичка"!
Рисунок 32 0x01F Построение логического дерева с гнездами, модифицирующими саму сравниваемую переменную
Другая характерная деталь – case-обработчики, точнее оператор break традиционно замыкающий каждый из них. Они-то и образуют правую половину "косички", сходясь все вместе с точке "Z". Правда, многие программисты питают паралогическую любовь к case-обработчикам размером в два-три экрана, включая в них помимо всего прочего и циклы (о них речь еще впереди – см. "Идентификация for\while"), и ветвления, и даже вложенные операторы множественно выбора! В результате правая часть "косички" превращается в непроходимый таежный лес, сквозь который не проберется и стадо слонопотамов. Но даже если и так – левая часть "косички", все равно останется достаточно простой и легко распознаваемой!
В заключение темы рассмотрим последний компилятор – WATCOM C. Как и следует ожидать, здесь нас подстерегают свои тонкости и "вкусности". Итак, откомпилированный им код предыдущего примера должен выглядеть так:
main_ proc near ; CODE XREF: __CMain+40p
push 8
call __CHK
; Проверка стека на переполнение
cmp eax, 1
; Сравнение регистровой переменной EAX, содержащей в себе переменную a
; со значением 1
jb short loc_41002F
; Если EAX == 0, то переход к ветви с дополнительными проверками
jbe short loc_41003A
; Если EAX == 1 (т.е. условие bellow уже обработано выше), то переход
; к ветке вызова printf("a == 1");
cmp eax, 2
; Сравнение EAX со значением 2
jbe short loc_410041
; Если EAX == 2 (условие EAX <2 уже было обработано выше), то переход
; к ветке вызова printf("a == 2");
cmp eax, 666h
; Сравнение EAX со значением 0x666
jz short loc_410048
; Если EAX == 0x666, то переход к ветке вызова printf("a == 666h");
jmp short loc_41004F
; Что ж, ни одно из условий не подошло – переходит к ветке "Default"
loc_41002F: ; CODE XREF: main_+Dj
; // printf("a == 0");
test eax, eax
jnz short loc_41004F
; Совершенно непонятно – зачем здесь дополнительная проверка?!
; Это ляп компилятора – она ни к чему!
push offset aA0 ; "A == 0"
; Обратите внимание – WATCOM сумел обойтись всего одним вызовом printf!
; Обработчики case всего лишь передают ей нужный аргумент!
; Вот это действительно – оптимизация!
jmp short loc_410054
loc_41003A: ; CODE XREF: main_+Fj
; // printf("a == 1");
push offset aA1 ; "A == 1"
jmp short loc_410054
loc_410041: ; CODE XREF: main_+14j
; // printf("a == 2");
push offset aA2 ; "A == 2"
jmp short loc_410054
loc_410048: ; CODE XREF: main_+1Bj
; // printf("a == 666h");
push offset aA666h ; "A == 666h"
jmp short loc_410054
loc_41004F: ; CODE XREF: main_+1Dj main_+21j
; // printf("Default");
push offset aDefault ; "Default"
loc_410054: ; CODE XREF: main_+28j main_+2Fj ...
call printf_
; А вот он наш printf, получающий аргументы из case-обработчиков!
add esp, 4
; Закрытие кадра стека
retn
main_ endp
Листинг 175
В общем, WATCOM генерирует более хитрый, но, как ни странно, весьма наглядный и читабельный код.
::Отличия switch от оператора case языка Pascal. Оператор CASE языка Pascal практически идентичен своему Си собрату – оператору switch, хотя и близнецами их не назовешь: оператор CASE выгодно отличается поддержкой наборов и диапазонов значений. Ну, если обработку наборов можно реализовать и посредством switch, правда не так элегантно как на Pascal (см.
листинг 176), то проверка вхождения значения в диапазон на Си организуется исключительно с помощью конструкции "IF-THEN-ELSE". Зато в Паскале каждый case-обработчик принудительно завершается неявным break, а Си-программист волен ставить (или не ставить) его по своему усмотрению.
CASE a OF switch(a)
begin {
1 : WriteLn('a == 1'); case 1 : printf("a == 1");
break;
2,4,7 : WriteLn('a == 2|4|7'); case 2 :
case 4 :
case 7 : printf("a == 2|4|7");
break;
9 : WriteLn('a == 9'); case 9 : printf("a == 9");
break;
end;
Листинг 176
Однако оба языка накладывают жесткое ограничение на выбор сравниваемой переменной: она должна принадлежать к перечисленному типу, а все наборы (диапазоны) значений представлять собой константы или константные выражения, вычисляемые на стадии компиляции. Подстановка переменных или вызовов функций не допускается.
Представляет интерес посмотреть: как Pascal транслирует проверку диапазонов и сравнить его с компиляторами Си. Рассмотрим следующий пример:
VAR
a : LongInt;
BEGIN
CASE a OF
2 : WriteLn('a == 2');
4, 6 : WriteLn('a == 4 | 6 ');
10..100 : WriteLn('a == [10,100]');
END;
END.
Листинг 177
Результат его компиляции компилятором Free Pascal должен выглядеть так (для экономии места приведена лишь левая часть "косички"):
mov eax, ds:_A
; Загружаем в EAX значение сравниваемой переменной
cmp eax, 2
; Сравниваем EAX со значением 0х2
jl loc_CA ; Конец CASE
; Если EAX < 2, то – конец CASE
sub eax, 2
; Вычитаем из EAX значение 0x2
jz loc_9E ; WriteLn('a == 2');
; Переход на вызов WriteLn('a
== 2') если EAX
== 2
sub eax, 2
; Вычитаем из EAX значение 0x2
jz short loc_72 ; WriteLn('a == 4 | 6');
; Переход на вызов WriteLn(''a == 4 | 6') если EAX == 2 (соотв. a == 4)
sub eax, 2
; Вычитаем из EAX значение 0x2
jz short loc_72 ; WriteLn('a == 4 | 6');
; Переход на вызов WriteLn(''a == 4 | 6') если EAX == 2 (соотв. a == 6)
sub eax, 4
; Вычитаем из EAX значение 0x4
jl loc_CA ; Конец CASE
; Переход на конец CASE, если EAX < 4 (соотв. a < 10)
sub eax, 90
; Вычитаем из EAX значение 90
jle short loc_46 ; WriteLn('a = [10..100]');
; Переход на вызов WriteLn('a
= [10..100]') если EAX
<= 90 (соотв. a
<= 100)
; Поскольку, случай a > 10 уже был обработан выше, то данная ветка
; срабатывает при условии a>=10 && a<=100.
jmp loc_CA ; Конец CASE
; Прыжок на конец CASE – ни одно из условий не подошло
Листинг 178
Как видно, Free Pascal генерирует практически тот же самый код, что и компилятор Borland C++ 5.х, поэтому его анализ не должен вызвать никаких сложностей.
__::IDA распознает switch
::Обрезка (балансировка) длинных деревьев. В некоторых (хотя и редких) случаях, операторы множественного выбора содержат сотни (а то и тысячи) наборов значений, и если решать задачу сравнения "в лоб", то высота логического дерева окажется гигантской до неприличия, а его прохождение займет весьма длительное время, что не лучшим образом скажется на производительности программы.
Но, задумайтесь: чем собственно занимается оператор switch? Если отвлечься от устоявшейся идиомы "оператор SWITCH дает специальный способ выбора одного из многих вариантов, который заключается в проверке совпадения значения данного выражения с одной из заданных констант и соответствующем ветвлении", то можно сказать, что switch – оператор поиска соответствующего значения. В таком случае каноническое switch - дерево представляет собой тривиальный алгоритм последовательного поиска – самый неэффективный алгоритм из всех.
Пусть, например, исходный текст программы выглядел так:
switch (a)
{
case 98 : …;
case 4 : …;
case 3 : …;
case 9 : …;
case 22 : …;
case 0 : …;
case 11 : …;
case 666: …;
case 096: …;
case 777: …;
case 7 : …;
}
Листинг 179
Тогда соответствующее ему не оптимизированное логическое дерево будет достигать в высоту одиннадцати гнезд (см. рис. 33 слева). Причем, на левой ветке корневого гнезда окажется аж десять других гнезд, а на правой – вообще ни одного (только соответствующий ему case - обработчик).
Исправить "перекос" можно разрезав одну ветку на две и привив образовавшиеся половинки к новому гнезду, содержащему условие, определяющее в какой из веток следует искать сравниваемую переменную. Например, левая ветка может содержать гнезда с четными значениями, а правая – с нечетными. Но это плохой критерий: четных и нечетных значений редко бывает поровну и вновь образуется перекос. Гораздо надежнее поступить так: берем наименьшее из всех значений и бросаем его в кучу А, затем берем наибольшее из всех значений и бросаем его в кучу B. Так повторяем до тех пор, пока не рассортируем все, имеющиеся значения.
Поскольку оператор множественного выбора требует уникальности каждого значения, т.е. каждое число может встречаться в наборе (диапазоне) значений лишь однажды, легко показать, что: а) в обеих кучах будет содержаться равное количество чисел (в худшем случае – в одной куче окажется на число больше); б) все числа кучи A меньше наименьшего из чисел кучи B. Следовательно, достаточно выполнить только одно сравнение, чтобы определить в какой из двух куч следует искать сравниваемое значения.
Высота нового дерева будет равна , где N – количество гнезд старого дерева. Действительно, мы же ветвь дерева надвое и добавляем новое гнездо – отсюда и берется и +1, а (N+1) необходимо для округления результата деления в большую сторону. Т.е. если высота не оптимизированного дерева достигала 100 гнезд, то теперь она уменьшилась до 51. Что? Говорите, 51 все равно много? А что нам мешает разбить каждую из двух ветвей еще на две? Это уменьшит высоту дерева до 27 гнезд! Аналогично, последующее уплотнение даст 16 à
12 à
11 à
9 à
8… и все! Более плотная упаковка дерева невозможна (подумайте почему – на худой конец постройте само дерево). Но, согласитесь, восемь гнезд – это не сто! Полное прохождение оптимизированного дерева потребует менее девяти сравнений!
Рисунок 33 0х21 Логическое дерево до утрамбовки (слева) и после (справа)
"Трамбовать" логические деревья оператора множественного выбора умеют практически все компиляторы – даже не оптимизирующие! Это увеличивает производительность, но затрудняет анализ откомпилированной программы. Взгляните еще раз на рис. 33 – левое несбалансированное дерево наглядно и интуитивно - понятно. После же балансировки (правое дерево) в нем Тиггер хвост обломит.
К счастью, балансировка дерева допускает эффективное обращение. Но прежде, чем засучить рукава и приготовиться к лазанью по деревьям (а Тиггеры по деревьям лазают лучше всех!) введем понятие балансировочного узла. Балансировочный узел не изменяет логики работы двоичного дерева и являются факультативным узлов, единственная функция которого укорачивание длины ветвей. Балансировочный узел без потери функциональности дерева может быть замещен любой из своих ветвей. Причем каждая ветвь балансировочного узла должна содержать одно или более гнезд.
Рассуждая от противного – все узлы логического дерева, правая ветка которых содержит одно или более гнезд, могут быть замещены на эту самую правую ветку без потери функциональности дерева, то данная конструкция представляет собой оператор switch. Почему именно правая ветка? Так ведь оператор множественного выбора в "развернутом" состоянии представляет цепочку гнезд, соединенных левыми ветвями друг с другом, а на правых держащих case-обработчики, - вот мы и пытаемся подцепить все правые гнезда на левую ветвь. Если это удается, мы имеем дело с оператором множественного выбора, а нет – с чем-то другим.
Рассмотрим обращение балансировки на примере следующего дерева (см. рис. 34 слева).
Двигаясь от левой нижней ветви, мы будем продолжать взбираться на дерево до тех пор, пока не встретим узел, держащий на своей правой ветви одно или более гнезд. В нашем случае – это узел (a > 5). Смотрите: если данный узел заменить его гнездами (a==7) и (a == 9) функциональность дерева не нарушиться! (см. рис. 34 посередине). Аналогично узел (a > 10) может быть безболезненно заменен гнездами (a > 96), (a == 96), (a == 22) и (a == 11), а узел (a > 96) в свою очередь – гнездами (a == 98), (a == 666) и (a == 777). В конце -концов образуется классическое switch-дерево, в котором оператор множественного выбора распознается с первого взгляда.
Рисунок 34 0x22 Обращение балансировки логического дерева
Сложные случаи балансировки или оптимизирующая балансировка. Для уменьшения высоты "утрамбовываемого" дерева хитрый трансляторы стремятся замещать уже существующие гнезда балансировочными узлами. Рассмотрим следующий пример: (см. рис. 35). Для уменьшения высоты дерева транслятор разбивает его на две половины – в левую идут гнезда со значениями меньшие или равные единицы, а в правую – все остальные. Казалось бы, на правой ветке узла (a > 1) должно висеть гнездо (a == 2), ан нет! Здесь мы видим узел (a >2), к левой ветки которого прицеплен case-обработчик :2! А что, вполне логично – если (a > 1) и !(a > 2), то a == 2!
Легко видеть, что узел (a > 2) жестко связан с узлом (a > 1) и работает на пару с последним. Нельзя выкинуть один из них, не нарушив работоспособности другого! Обратить балансировку дерева по описанному выше алгоритму без нарушения его функциональности невозможно! Отсюда может создаться мнение, что мы имеем дело вовсе не с оператором множественного выбора, а чем-то другим.
Чтобы развеять это заблуждение придется предпринять ряд дополнительных шагов. Первое – у switch-дерева все case-обработчики всегда находятся на правой ветви. Смотрим – можно ли трансформировать наше дерево так, чтобы case-обработчик 2 оказался на левой ветви балансировочного узла? Да, можно: заменив (a > 2) на (a < 3) и поменяв ветви местами (другими словами выполнив инверсию).
Второе – все гнезда switch-дерева содержат в себе условия равенства, - смотрим: можем ли мы заменить неравенство (a < 3) на аналогичное ему равенство? Ну, конечно же, можем – (a == 2)!
Вот, после всех этих преобразований, обращение балансировки дерева удается выполнить без труда!
Рисунок 35 0x23 Хитрый случай балансировки
Ветвления в case-обработчиках. В реальной жизни case-обработчики прямо-таки кишат ветвлениями, циклами и прочими условными переходами всех мастей. Как следствие – логическое дерево приобретает вид ничуть не напоминающий оператор множественного выбора, а скорее смахивающий на заросли чертополоха, так любимые И-i. Понятное дело – идентифицировав case-обработчики, мы могли бы решить эту проблему, но как их идентифицировать?!
Очень просто – за редкими клиническими исключениями, case-обработчики не содержат ветвлений относительно сравниваемой переменной. Действительно, конструкции "switch(a) …. case 666 : if (a == 666) …." или "switch(a) …. case 666 : if (a > 66) …." абсолютно лишены смысла. Таким образом, мы можем смело удалить из логического дерева все гнезда с условиями, не касающимися сравниваемой переменной (переменной коневого гнезда).
Хорошо, а если программист в порыве собственной глупости или стремлении затруднить анализ программы "впаяет" в case-обработчики ветвления относительно сравниваемой переменной?! Оказывается, это ничуть не затруднит анализ! "Впаянные" ветвления элементарно распознаются и обрезаются либо как избыточные, либо как никогда не выполняющиеся. Например, если к правой ветке гнезда (a == 3) прицепить гнездо (a > 0) – его можно удалить, как не несущее в себе никакой информации. Если же к правой ветке того же самого гнезда прицепить гнездо (a == 2) его можно удалить, как никогда не выполняющееся – если a == 3, то заведомо a != 2!
Идентификация this
"Не все ли равно, о чем спрашивать, если ответа все равно не получишь, правда?"
Льюис Кэрролл. Алиса в стране чудес
Указатель this – это настоящий "золотой ключик" или, если угодно, "спасательный круг", позволяющей не утонуть в бурном океане ООП. Именно благодаря this возможно определять принадлежность вызываемой функции к тому или иному экземпляру объекта. Поскольку, все не виртуальные функции объекта вызываются непосредственно - по фактическому адресу, объект как бы "расщепляется" на составляющие его функции еще на стадии компиляции. Не будь указателей this – восстановить иерархию функций было бы принципиально невозможно!
Таким образом, правильная идентификация this очень важна. Единственная проблема – как его отличить от указателей на массивы и структуры? Ведь идентификация экземпляра объекта осуществляется по указателю this (если на выделенную память указывает this, это – экземпляр объекта), однако, сам this по определению это указатель, ссылающийся на экземпляр объекта. Замкнутый круг! К счастью, есть одна лазейка… Код, манипулирующий указателем this, весьма специфичен, что и позволяет отличить this ото всех остальных указателей.
Вообще-то, у каждого компилятора свой "почерк", который настоятельно рекомендуется изучить, дизассемблируя собственные Cи++ программы, но существуют и универсальные рекомендации, приемлемые к большинству реализацией. Поскольку, this – это неявной аргумент каждой функции-члена класса, то логично отложить разговор о его идентификации до главы "Идентификация аргументов функций", здесь же мы дадим лишь краткую сводную таблицу, описывающую механизмы передачи this различными компиляторами:
Компилятор | тип функции | ||||||||
Default | fastcall | cdecl | stdcall | PASCAL | |||||
Microsoft Visual C++ | ECX | через стек последним аргументом функции | через стек первым аргументом | ||||||
Borland C++ | EAX | ||||||||
WATCOM C |
Таблица 1 Механизм передачи указателя this в зависимости от реализации компилятора и типа функции
Идентификация виртуальных функций
А мы летим орбитами, путями неизбитыми,Прошит метеоритами простор.Оправдан риск и мужество, космическая музыка
Вплывает в деловой наш разговор.
"Трава у дома" Земляне
Виртуальная функция по определению обозначает "определяемая по время выполнения программы". При вызове виртуальной функции выполняемый код должен соответствовать динамическому типу объекта, из которого вызывается функция. Поэтому, адрес виртуальной функции не может быть определен на стадии компиляции – это приходится делать непосредственно в момент ее вызова. Вот почему вызов виртуальной функции – всегда косвенный
вызов (исключение составляют лишь виртуальные функции статических объектов, - см. "Статическое связывание").
В то время как не виртуальные функции вызываются в точности так же, как и обычные Си-функции, вызов виртуальных функций кардинально отличается. Конкретная схема зависит от реализации конкретного компилятора, но общем случае ссылки на все виртуальные функции помещаются в специальный массив – виртуальную таблицу (virtual table –
сокращенно VTBL), а в каждый экземпляр объекта, использующий хотя бы одну виртуальную функцию, помещается указатель на виртуальную таблицу (virtual table pointer – сокращенно VPRT). Причем, независимо от числа виртуальный функций, каждый объект имеет только один указатель.
Вызов виртуальных функций всегда происходит косвенно, через ссылку на виртуальную таблицу – например: CALL [EBX+0х10], где EBX
– регистр, содержащий смещение виртуальной таблицы в памяти, а 0x10 – смещение указателя на виртуальную функцию внутри виртуальной таблицы.
Анализ вызова виртуальных функций наталкивается на ряд сложностей, самая коварная из которых, – необходимость обратной трассировки кода для отслеживания значения регистра, используемого для косвенной адресации. Хорошо, если он инициализируется непосредственным значением типа "MOV EBX, offset VTBL" недалеко от места использования, но значительно чаще указатель на VTBL передается функции как неявный аргумент или (что еще хуже) один и тот же указатель используется для вызова двух различных виртуальных функций и возникает неопределенность – какое именно значение (значения) он имеет в данной ветке программы?
Разберем следующий пример ( предварительно вспомнив, что если одна и та же не виртуальная функция присутствует и базовом, и в производном классе – всегда вызывается функция базового класса).
#include <stdio.h>
class Base{
public:
virtual void demo(void)
{
printf("BASE\n");
};
virtual void demo_2(void)
{
printf("BASE DEMO 2\n");
};
void demo_3(void)
{
printf("Non virtual BASE DEMO 3\n");
};
};
class Derived: public Base{
public:
virtual void demo(void)
{
printf("DERIVED\n");
};
virtual void demo_2(void)
{
printf("DERIVED DEMO 2\n");
};
void demo_3(void)
{
printf("Non virtual DERIVED DEMO 3\n");
};
};
main()
{
Base *p = new Base;
p->demo();
p->demo_2();
p->demo_3();
p = new Derived;
p->demo();
p->demo_2();
p->demo_3();
}
Листинг 24 Демонстрация вызова виртуальных функций
Результат ее компиляции в общем случае должен выглядеть так:
main proc near ; CODE XREF: start+AFp
push esi
push 4
call ??2@YAPAXI@Z ; operator new(uint)
; EAX c- указатель на выдел. блок памяти
; Выделяем четыре байта памяти для экземпляра нового объекта.
; Объект состоит из одного лишь указателя на VTBL.
add esp, 4
test eax, eax
jz short loc_0_401019 ; --> Ошибка выделения памяти
; проверка успешности выделения памяти
mov dword ptr [eax], offset BASE_VTBL
; Вот здесь в только что созданный экземпляр объекта копируется
; указатель на виртуальную таблицу класса BASE.
; То, что это именно виртуальная таблица класса BASE, можно узнать
; проанализировав элементы этой таблицы – они указывают на члены
; класса BASE, следовательно, сама таблица – виртуальная таблица
; класса BASE
mov esi, eax ; ESI = **BASE_VTBL
; заносим в ESI указатель на экземпляр объекта (указатель на указатель
; на BASE_VTBL
; Зачем? Дело в том, что на самом деле в ESI
заносится указатель на
; экземпляр объекта (см. "Идентификация объектов, структур и массивов),
; но нам на данном этапе все эти детали ни к чему, поэтому, мы просто
; говорим, что в ESI – указатель на указатель на виртуальную таблицу
; базового класса, не вникая для чего понадобился этот двойной указатель.
jmp short loc_0_40101B
loc_0_401019: ; CODE XREF: sub_0_401000+Dj
xor esi, esi
; принудительно обнуляем указатель на экземпляр объекта (эта ветка получает управление
; только в случае неудачного выделения памяти для объекта) нулевой указатель
; словит обработчик структурных исключений при первой же попытке обращения
loc_0_40101B: ; CODE XREF: sub_0_401000+17j
mov eax, [esi] ; EAX = *BASE_VTBL == *BASE_DEMO
; заносим в EAX указатель на виртуальную таблицу класса BASE,
; не забывая о том, что указатель на виртуальную таблицу одновременно
; является указателем и на первый элемент этой таблицы.
; А первый элемент виртуальной таблицы, содержащий указатель
; на первую (в порядке объявления) виртуальную функцию класса.
mov ecx, esi ; ECX = this
; заносим в ECX указатель на экземпляр объекта, передавая вызываемой функции
; неявный аргумент – указатель this
(см. "Идентификация аргументов функций")
call dword ptr [eax] ; CALL BASE_DEMO
; Вот он – вызов виртуальной функции! Чтобы понять – какая именно функция
; вызывается, мы должны знать значение регистра EAX. Прокручивая экран
; дизассемблера вверх, мы видим – EAX
указывает на BASE_VTBL, а первый
; член BASE_VTBL
(см. ниже) указывает на функцию BASE_DEMO. Следовательно:
; а) этот код вызывает именно функцию BASE_DEMO
; б) функция BASE_DEMO
– это виртуальная
функция
mov edx, [esi] ; EDX = *BASE_DEMO
; заносим в EDX указатель на первый элемент виртуальной таблицы класса BASE
mov ecx, esi ; ECX = this
; заносим в ECX указатель на экземпляр объекта
; Это неявный аргумент функции – указатель this
(см. "Идентификация this")
call dword ptr [edx+4] ; CALL [BASE_VTBL+4] (BASE_DEMO_2)
; Еще один вызов виртуальной функции! Чтобы понять – какая именно функция
; вызывается, мы должны знать содержимое регистра EDX. Прокручивая экран
; дизассемблера вверх, мы видим, что он указывает на BASE_VTBL, а EDX+4,
; стало быть, указывает на второй элемент виртуальной таблицы класса BASE.
; Он же, в свою очередь, указывает на функцию BASE_DEMO_2
push offset aNonVirtualBase ; "Non virtual BASE DEMO 3\n"
call printf
; а вот вызов не виртуальной функции. Обратите внимание – он происходит
; как и вызов обычной Си функции. (Обратите внимание, что эта функция -
; встроенная, т.к. объявленная непосредственно в самом классе и вместо ее
; вызова осуществляется подстановка кода)
push 4
call ??2@YAPAXI@Z ; operator new(uint)
; Далее идет вызов функций класса DERIVED. Не будем здесь подробно
; его комментировать – сделайте это самостоятельно. Вообще же, класс
; DERIVED
понадобился только для того, чтобы показать особенности компоновки
; виртуальных таблиц
add esp, 8 ; Очистка после printf
& new
test eax, eax
jz short loc_0_40104A ; Ошибка выделения памяти
mov dword ptr [eax], offset DERIVED_VTBL
mov esi, eax ; ESI == **DERIVED_VTBL
jmp short loc_0_40104C
loc_0_40104A: ; CODE XREF: sub_0_401000+3Ej
xor esi, esi
loc_0_40104C: ; CODE XREF: sub_0_401000+48j
mov eax, [esi] ; EAX = *DERIVED_VTBL
mov ecx, esi ; ECX = this
call dword ptr [eax] ; CALL [DERIVED_VTBL] (DERIVED_DEMO)
mov edx, [esi] ; EDX = *DERIVED_VTBL
mov ecx, esi ; ECX=this
call dword ptr [edx+4] ; CALL [DERIVED_VTBL+4] (DERIVED_DEMO_2)
push offset aNonVirtualBase ; "Non virtual BASE DEMO 3\n"
call printf
; Обратите внимание – вызывается функция BASE_DEMO базового,
; а не производного класса!!!
add esp, 4
pop esi
retn
main endp
BASE_DEMO proc near ; DATA XREF: .rdata:004050B0o
push offset aBase ; "BASE\n"
call printf
pop ecx
retn
BASE_DEMO endp
BASE_DEMO_2 proc near ; DATA XREF: .rdata:004050B4o
push offset aBaseDemo2 ; "BASE DEMO 2\n"
call printf
pop ecx
retn
BASE_DEMO_2 endp
DERIVED_DEMO proc near ; DATA XREF: .rdata:004050A8o
push offset aDerived ; "DERIVED\n"
call printf
pop ecx
retn
DERIVED_DEMO endp
DERIVED_DEMO_2 proc near ; DATA XREF: .rdata:004050ACo
push offset aDerivedDemo2 ; "DERIVED DEMO 2\n"
call printf
pop ecx
retn
DERIVED_DEMO_2 endp
DERIVED_VTBL dd offset DERIVED_DEMO ; DATA XREF: sub_0_401000+40o
dd offset DERIVED_DEMO_2
BASE_VTBL dd offset BASE_DEMO ; DATA XREF: sub_0_401000+Fo
dd offset BASE_DEMO_2
; Обратите внимание – виртуальные таблицы "растут" снизу вверх в порядке
; объявления классов в программе, а элементы виртуальных таблиц "растут"
; сверху вниз в порядке объявления виртуальных функций в классе.
; Конечно, так бывает не всегда (порядок размещения таблиц и их элементов
; нигде не декларирован и целиком лежит на "совести" компилятора, но на
; практике большинство из них ведут себя именно так) Сами же виртуальные
; функции располагаются вплотную друг к другу в порядке их объявления
Листинг 25
Рисунок 11 0x006 Художнику – добавить функции A, B и С Реализация вызова виртуальных функций
::идентификация чистой виртуальной функции. Если функция объявляется в базовом, а реализуется в производным классе – такая функция называется чистой виртуальной функцией, а класс, содержащий хотя бы одну такую функцию, называется абстрактным классом.
Язык Си++ запрещает создание экземпляров абстрактного класса, да и как они могут создаваться, если, по крайней мере, одна из функций класса неопределенна?
На первый взгляд – не определена, и ладно, – какая в этом беда? Ведь на анализ программы это не влияет. На самом деле это не так – чистая виртуальная функция в виртуальной таблице замещается указателем на библиотечную функцию __purecall. Зачем она нужна? Дело в том, что на стадии компиляции программы невозможно гарантированно "отловить" все попытки вызова чисто виртуальных функций, но если такой вызов и произойдет, управление получит заранее подставленная сюда __purecall, которая выведет на экран "ругательство" по поводу запрета на вызов чисто виртуальных функций и завершит работу приложения. Подробнее об этом можно прочитать в технической заметке MSNDN
Q120919, датированной 27 июня 1997 года.
Таким образом, встретив в виртуальной таблице указатель на __purecall, можно с уверенностью утверждать, что мы имеем дело с чисто виртуальной функцией. Рассмотрим следующий пример:
#include <stdio.h>
class Base{
public:
virtual void demo(void)=0;
};
class Derived:public Base {
public:
virtual void demo(void)
{
printf("DERIVED\n");
};
};
main()
{
Base *p = new Derived;
p->demo();
}
Листинг 26 Демонстрация вызова чистой виртуальной функции
Результат его компиляции в общем случае должен выглядеть так:
main proc near ; CODE XREF: start+AFp
push 4
call ??2@YAPAXI@Z
add esp, 4
; Выделение памяти для нового экземляра объекта
test eax, eax
; Проверка успешности выделения памяти
jz short loc_0_401017
mov ecx, eax
; ECX = this
call GetDERIVED_VTBL
; занесение в экземпляр объекта указателя на виртуальную таблицу класса
; DERIVED
jmp short loc_0_401019
loc_0_401017: ; CODE XREF: main+Cj
xor eax, eax
; EAX = NULL
loc_0_401019: ; CODE XREF: main+15j
mov edx, [eax]
; тут возникает исключение по обращению к нулевому указателю
mov ecx, eax
jmp dword ptr [edx]
main endp
GetDERIVED_VTBL proc near ; CODE XREF: main+10p
push esi
mov esi, ecx
; Через регистр ECX функции передается неявный аргумент – this
call SetPointToPure
; функция заносит в экземпляр объекта указатель на __purecall
; специальную функцию - заглушку на случай незапланированного вызова
; чисто виртуальной функции
mov dword ptr [esi], offset DERIVED_VTBL
; занесение в экземпляр объекта указателя на виртуальную таблицу производного
; класса, с затиранием предыдущего значения (указателя на __purecall)
mov eax, esi
pop esi
retn
GetDERIVED_VTBL endp
DERIVED_DEMO proc near ; DATA XREF: .rdata:004050A8o
push offset aDerived ; "DERIVED\n"
call printf
pop ecx
retn
DERIVED_DEMO endp
SetPointToPure proc near ; CODE XREF: GetDERIVED_VTBL+3p
mov eax, ecx
mov dword ptr [eax], offset PureFunc
; Заносим по [EAX] (в экземляр нового объекта) указатель на специальную
; функцию - __purecall, которая предназначена для отслеживания попыток
; вызова чисто виртуальной функции в ходе выполнения программы -
; если такая попытка произойдет, __purecall выведет на экран "матюгательство"
; дескать, вызывать чисто виртуальную функцию нельзя и завершит работу
retn
SetPointToPure endp
DERIVED_VTBL dd offset DERIVED_DEMO ; DATA XREF: GetDERIVED_VTBL+8o
PureFunc dd offset __purecall ; DATA XREF: SetPointToPure+2o
; указатель на функцию-заглушку __purecall. Следовательно, мы имеем дело
; с чисто виртуальной функцией
Листинг 27
::совместное использование виртуальной таблицы несколькими экземплярами объекта. Сколько бы экземпляров объекта ни существовало – все они пользуются одной и той же виртуальной таблицей.
Виртуальная таблица принадлежит самому объекту, но не экземпляру (экземплярам) этого объекта. Впрочем, из этого правила существуют и исключения (см. "Копии виртуальных таблиц").
Рисунок 12 0x007 все экземпляры объекта используют одну и ту же виртуальную таблицу
Для подтверждения сказанного рассмотрим следующий пример:
#include <stdio.h>
class Base{
public:
virtual demo ()
{
printf("Base\n");
}
};
class Derived:public Base{
public:
virtual demo()
{
printf("Derived\n");
}
};
main()
{
Base * obj1 = new Derived;
Base * obj2 = new Derived;
obj1->demo();
obj2->demo();
}
Листинг 28 Демонстрация совместного использование одной копии виртуальной таблицы несколькими экземплярами класса
Результат его компиляции в общем случае должен выглядеть так:
main proc near ; CODE XREF: start+AFp
push esi
push edi
push 4
call ??2@YAPAXI@Z ; operator new(uint)
add esp, 4
; выделяем память под первый экземпляр объекта
test eax, eax
jz short loc_0_40101B
mov ecx, eax ; EAX
– указывает на первый экземпляр объекта
call GetDERIVED_VTBL
; в EAX – указатель на виртуальную таблицу класса DERIVED
mov edi, eax ; EDI = *DERIVED_VTBL
jmp short loc_0_40101D
loc_0_40101B: ; CODE XREF: main+Ej
xor edi, edi
loc_0_40101D: ; CODE XREF: main+19j
push 4
call ??2@YAPAXI@Z ; operator new(uint)
add esp, 4
; выделяем память под второй экземпляр объекта
test eax, eax
jz short loc_0_401043
mov ecx, eax ; ECX – this
call GetDERIVED_VTBL
; обратите внимание – второй экземпляр использует ту же самую
; виртуальную
таблицу
DERIVED_VTBL dd offset DERIVED_DEMO ; DATA XREF: GetDERIVED_VTBL+8o
BASE_VTBL dd offset BASE_DEMO ; DATA XREF: GetBASE_VTBL+2o
; Обратите внимание – виртуальная таблица одна на все экземпляры класса
Листинг 29
::копии виртуальных таблиц. ОК, для успешной работы, - понятное дело, - вполне достаточно и одной виртуальной таблицы, однако, на практике приходится сталкиваться с тем, что исследуемый файл прямо-таки кишит копиями этих виртуальных таблиц. Что же это за напасть такая, откуда она берется и как с ней бороться?
Если программа состоит из нескольких файлов, компилируемых в самостоятельные obj-модули (а такой подход используется практически во всех мало-мальски серьезных проектах), компилятор, очевидно, должен поместить в каждый obj "свою" собственную виртуальную таблицу для каждого используемого модулем класса. В самом деле – откуда компилятору знать о существовании других obj и наличии в них виртуальных таблиц? Вот так и возникают никому не нужные дубли, отъедающие память и затрудняющие анализ. Правда, на этапе компоновки, линкер может обнаружить копии и удалить их, да и сами компиляторы используют различные эвристические приемы для повышения эффективности генерируемого кода. Наибольшую популярность завоевал следующий алгоритм: виртуальная таблица помещается в тот модуль, в котором содержится реализация первой невстроенной не виртуальной функции класса. Обычно каждый класс реализуется в одном модуле и в большинстве случаев такая эвристика срабатывает. Хуже если класс состоит из одних виртуальных или встраиваемых функций – в этом случае компилятор "ложится" и начинает запихивать виртуальные таблицы во все модули, где этот класс используется. Последняя надежда на удаление "мусорных" копий ложиться на линкер, но и линкер – не панацея. Собственно, эти проблемы должны больше заботить разработчиков программы (если их волнует количество занимаемой программой памятью), для анализа лишние копии – всего лишь досадна помеха, но отнюдь не непреодолимое препятствие!
::связанный список. В большинстве случаев виртуальная таблица представляет собой обыкновенный массив, но некоторые компиляторы представляют ее в виде связного списка, - каждый элемент виртуальной таблицы содержит указатель на следующий элемент, а сами элементы размещены не вплотную друг к другу, а рассеянны по всему исполняемому файлу.
На практике подобное, однако, встречается крайне редко, поэтому, не будем подробно на этом останавливаться, - достаточно лишь знать, что такое бывает, - если встретись со списками (впрочем, навряд ли вы с ними встретитесь) – разберетесь по обстоятельствам, благо это несложно.
::вызов через шлюз. Будьте так же готовы и к тому, чтобы встретить в виртуальной таблице указатель не на виртуальную функцию, а на код, который модифицирует этот указатель, занося в него смещение вызываемой функции. Этот прием был впервые предложен самим разработчиком языка – Бьерном Страуструпом, позаимствовавшим его из ранних реализаций Алгола-60. В Алголе код, корректирующий указатель вызываемой функции, называется шлюзом (thunk), а сам вызов – вызовом через шлюз. Вполне справедливо употреблять эту терминологии и по отношению к Си++.
Однако в настоящее время вызов через шлюз чрезвычайно мало распространен и не используется практически ни одним компилятором. Несмотря на то, что он обеспечивает более компактное хранение виртуальных таблиц, модификация указателя приводит к излишним накладным расходам на процессорах с конвейерной архитектурой, (а Pentium – наиболее распространенный процессор, - как раз и построен по такой архитектуре). Поэтому, использование шлюзовых вызовов оправдано лишь в программах, критических к размеру, но не к скорости.
Подробнее обо всем этом можно прочесть в руководстве по Алголу-60 (шутка), или у Бьерна Страуструпа в "Дизайне и эволюции языка С++".
::сложный пример или когда не виртуальные функции попадают в виртуальные таблицы. До сих пор мы рассматривали лишь простейшие примеры использования виртуальных функций. В жизни же порой встречается такое… Рассмотрим сложный случай наследования с конфликтом имен:
#include <stdio.h>
class A{
public:
virtual void f() { printf("A_F\n");};
};
class B{
public:
virtual void f() { printf("B_F\n");};
virtual void g() { printf("B_G\n");};
};
class C:public A, public B {
public:
void f(){ printf("C_F\n");}
}
main()
{
A *a = new A;
B *b = new B;
C *c = new C;
a->f();
b->f();
b->g();
c->f();
}
Листинг 30 Демонстрация помещения не виртуальных функций в виртуальные таблицы
Как будет выглядеть виртуальная таблица класса C? Так, давайте подумаем: раз класс C – производный от классов A и B, то он наследует функции обоих, но виртуальная функция f() класса B перекрывает одноименную виртуальную функцию класса A, поэтому, из класса А она не наследуется. Далее, поскольку не виртуальная функция f() присутствует и в производном классе С, она перекрывает виртуальную функцию производного класса (да, именно так, а вот не виртуальная не виртуальную функцию не перекрывает и она всегда вызывается из базового, а не производного класса). Таким образом, виртуальная таблица класса С должна содержать только один элемент – указатель на виртуальную функцию g(), унаследованную от B, а не виртуальная функция f() вызывается как обычная Си-функция. Правильно? Нет!
Это как раз тот случай, когда не виртуальная функция вызывается через указатель – как виртуальная функция. Более того, виртуальная таблица класса будет содержать не два, а три элемента! Третий элемент – это ссылка на виртуальную функцию f(), унаследованную от B, но тут же замещенная компилятором на "переходник" к C::f(). Уф… Как все непросто! Может, после изучения дизассемблерного листинга это станет понятнее?
main proc near ; CODE XREF: start+AFp
push ebx
push esi
push edi
push 4
call ??2@YAPAXI@Z ; operator new(uint)
add esp, 4
; выделяем память для экземпляра объекта A
test eax, eax
jz short loc_0_40101C
mov ecx, eax ; ECX = this
call Get_A_VTBL ; a[0]=*A_VTBL
; помещаем в экземпляр объекта указатель на его виртуальную таблицу
mov ebx, eax ; EBX = *a
jmp short loc_0_40101E
loc_0_40101C: ; CODE XREF: main+Fj
xor ebx, ebx
loc_0_40101E: ; CODE XREF: main+1Aj
push 4
call ??2@YAPAXI@Z ; operator new(uint)
add esp, 4
; выделяем память для экземпляра объекта B
test eax, eax
jz short loc_0_401037
mov ecx, eax ; ECX = this
call Get_B_VTBL ; b[0] = *B_VTBL
; помещаем в экземпляр объекта указатель на его виртуальную таблицу
mov esi, eax ; ESI = *b
jmp short loc_0_401039
loc_0_401037: ; CODE XREF: main+2Aj
xor esi, esi
loc_0_401039: ; CODE XREF: main+35j
push 8
call ??2@YAPAXI@Z ; operator new(uint)
add esp, 4
; выделяем память для экземпляра объекта B
test eax, eax
jz short loc_0_401052
mov ecx, eax ; ECX = this
call GET_C_VTBLs ; ret: EAX=*c
; помещаем в экземпляр объекта указатель на его виртуальную таблицу
; (внимание: загляните в функцию GET_C_VTBLs)
mov edi, eax ; EDI = *c
jmp short loc_0_401054
loc_0_401052: ; CODE XREF: main+45j
xor edi, edi
loc_0_401054: ; CODE XREF: main+50j
mov eax, [ebx] ; EAX = a[0] = *A_VTBL
mov ecx, ebx ; ECX = *a
call dword ptr [eax] ; CALL [A_VTBL] (A_F)
mov edx, [esi] ; EDX = b[0]
mov ecx, esi ; ECX = *b
call dword ptr [edx] ; CALL [B_VTBL] (B_F)
mov eax, [esi] ; EAX = b[0] = B_VTBL
mov ecx, esi ; ECX = *b
call dword ptr [eax+4] ; CALL [B_VTBL+4] (B_G)
mov edx, [edi] ; EDX = c[0] = C_VTBL
mov ecx, edi ; ECX = *c
call dword ptr [edx] ; CALL [C_VTBL] (C_F)
; Внимание! Вызов не виртуальной функции происходит как виртуальной!
pop edi
pop esi
pop ebx
retn
main endp
GET_C_VTBLs proc near ; CODE XREF: main+49p
push esi ; ESI = *b
push edi ; ECX = *c
mov esi, ecx ; ESI = *c
call Get_A_VTBL ; c[0]=*A_VTBL
; помещаем в экземпляр объекта C указатель на виртуальную таблицу класса A
lea edi, [esi+4] ; EDI = *c[4]
mov ecx, edi ; ECX = **_C_F
call Get_B_VTBL ; c[4]=*B_VTBL
; добавляем в экземпляр объекта C
указатель на виртуальную таблицу класса B
; т.е. теперь объект C содержит два указателя на две виртуальные таблицы
; базовых классов. Посмотрим далее, как компилятор справится с конфликтом
; имен…
mov dword ptr [edi], offset C_VTBL_FORM_B ; c[4]=*_C_VTBL
; Ага! указатель на виртуальную таблицу класса B
замещается указателем
; на виртуальную таблицу класса C
(смотри комментарии в самой таблице)
mov dword ptr [esi], offset C_VTBL ; c[0]=C_VTBL
; Ага, еще раз – теперь указатель на виртуальную таблицу класса A замещается
; указателем на виртуальную таблицу класса C. Какой неоптимальный код, ведь это
; было можно сократить еще на стадии компиляции!
mov eax, esi ; EAX = *c
pop edi
pop esi
retn
GET_C_VTBLs endp
Get_A_VTBL proc near ; CODE XREF: main+13p GET_C_VTBLs+4p
mov eax, ecx
mov dword ptr [eax], offset A_VTBL
; помещаем в экземпляр объекта указатель на виртуальную таблицу класса B
retn
Get_A_VTBL endp
A_F proc near ; DATA XREF: .rdata:004050A8o
; виртуальная функиця f() класса A
push offset aA_f ; "A_F\n"
call printf
pop ecx
retn
A_F endp
Get_B_VTBL proc near ; CODE XREF: main+2Ep GET_C_VTBLs+Ep
mov eax, ecx
mov dword ptr [eax], offset B_VTBL
; помещаем в экземпляр объекта указатель на виртуальную таблицу класса B
retn
Get_B_VTBL endp
B_F proc near ; DATA XREF: .rdata:004050ACo
; виртуальная функция f() класса B
push offset aB_f ; "B_F\n"
call printf
pop ecx
retn
B_F endp
B_G proc near ; DATA XREF: .rdata:004050B0o
; виртуальная функция g() класса B
push offset aB_g ; "B_G\n"
call printf
pop ecx
retn
B_G endp
C_F proc near ; CODE XREF: _C_F+3j
; Не виртуальная функция f() класса C
выглядит и вызывается как виртуальная!
push offset aC_f ; "C_F\n"
call printf
pop ecx
retn
C_F endp
_C_F proc near ; DATA XREF: .rdata:004050B8o
sub ecx, 4
jmp C_F
; смотрите, какая странная функция! Во-первых, она никогда не вызывается, а
; во-вторых, это переходник к функции C_F.
; зачем уменьшается ECX? В ECX компилятор поместил указатель this, который
; до уменьшения пытался указывать на виртуальную функцию f(), унаследованную
; от класса B. Но на самом же деле this указывал на этот переходник.
; А после уменьшения он стал указывать на предыдущий элемент виртуальной
; таблицы – т.е. функцию f() класса C, вызов которой и осуществляет JMP
_C_F endp
A_VTBL dd offset A_F ; DATA XREF: Get_A_VTBL+2o
; виртуальная таблица класса A
B_VTBL dd offset B_F ; DATA XREF: Get_B_VTBL+2o
dd offset B_G
; виртуальная таблица класса B – содержит указатели на две виртуальные функции
C_VTBL dd offset C_F ; DATA XREF: GET_C_VTBLs+19o
; виртуальная таблица класса C. Содержит указатель на не виртуальную функцию f()
C_VTBL_FORM_B dd offset _C_F ; DATA XREF: GET_C_VTBLs+13o
dd offset B_G
; виртуальная таблица класса C скопированная компилятором из класса B. Первоначально
; состояла из двух указателей на функции f() и g(), но еще на стадии
; компиляции компилятор разобрался в конфликте имен и заменил указатель на B::f()
; указателем на переходник к C::f()
Листинг 31
Таким образом, на самом деле виртуальная таблица производного класса включает в себя виртуальные таблицы всех базовых классов (во всяком случае, всех, откуда она наследует виртуальные функции).
В данном случае виртуальная таблица класса С содержит указатель на не виртуальную функцию С и виртуальную таблицу класса B. Задача – как определить, что функция C::f() не виртуальная? И как найти все базовые классы класса C?
Начнем с последнего – да, виртуальная таблица класса С не содержит никакого намека на его родственные отношения с классом A, но взгляните на содержимое функции GET_C_VTBLs, - видите: предпринимается попытка внедрить в C указатель на виртуальную таблицу А, следовательно, класс C – производный от A. Мне могут возразить, дескать, это не слишком надежный путь, компилятор мог бы оптимизировать код, выкинув обращение к виртуальной таблице класса А, которое все равно не нужно. Это верно, - мог бы, но на практике большинство компиляторов так не делают, а если и делают, все равно оставляют достаточно избыточной информации, позволяющей установить базовые классы. Другой вопрос – так ли необходимо устанавливать "родителей", от которых не наследуется ни одной функции? (Если хоть одна функция наследуется, никаких сложностей в поиске не возникает). В общем-то, для анализа это действительно некритично, но, чем точнее будет восстановлен исходный код программы, – тем нагляднее он будет и тем легче в нем разобраться.
Теперь перейдем к не виртуальной функции f(). Подумаем, что было бы – будь она на самом деле виртуальной? Тогда – она бы перекрыла одноименную функцию базовых классов и никакой "дикости" наподобие "переходников" в откомпилированной программе и не встретилось бы. А так – они говорят, что тут не все гладко и функция не виртуальная, хоть и стремится казаться такой. Опять-таки, умный компилятор теоретически может выкинуть переходник и дублирующийся элемент виртуальной таблицы класса С, но на практике этой интеллектуальности не наблюдается…
::статическое связывание. Есть ли разница как создавать экземпляр объекта – MyClass zzz;
или MyClass *zzz=new MyClass? Разумеется: в первом случае компилятор может определить адреса виртуальных функций еще на стадии компиляции, тогда как во втором – это приходится вычислять в ходе выполнения программы.
Другое различие: статические объекты размешаются в стеке (сегменте данных), а динамические – в куче. Таблица виртуальных функций упорно создается компиляторами в обоих случаях, а при вызове каждый функции (включая не виртуальные) подготавливается указатель this (как правило, помещаемый в один из регистров общего назначения – подробнее см. "Идентификация аргументов функций"), содержащий адрес экземпляра объекта.
Таким образом, если мы встречаем функцию, вызываемую непосредственно по ее смещению, но в то же время присутствующую в виртуальной таблице класса – можно с уверенностью утверждать, что это – виртуальная функция статичного экземпляра объекта.
Рассмотрим следующий пример:
#include <stdio.h>
class Base{
public:
virtual void demo(void)
{
printf("BASE DEMO\n");
};
virtual void demo_2(void)
{
printf("BASE DEMO 2\n");
};
void demo_3(void)
{
printf("Non virtual BASE DEMO 3\n");
};
};
class Derived: public Base{
public:
virtual void demo(void)
{
printf("DERIVED DEMO\n");
};
virtual void demo_2(void)
{
printf("DERIVED DEMO 2\n");
};
void demo_3(void)
{
printf("Non virtual DERIVED DEMO 3\n");
};
};
main()
{
Base p;
p.demo();
p.demo_2();
p.demo_3();
Derived d;
d.demo();
d.demo_2();
d.demo_3();
}
Листинг 32 Демонстрация вызова статической виртуальной функции
Результат ее компиляции в общем случае должен выглядеть так:
main proc near ; CODE XREF: start+AFp
var_8 = byte ptr -8 ; derived
var_4 = byte ptr -4 ; base
; часто, (но не всегда!) экземпляры объектов в стеке расположены снизу вверх,
; т.е. в обратном порядке их объявления в программе
push ebp
mov ebp, esp
sub esp, 8
lea ecx, [ebp+var_4] ; base
call GetBASE_VTBL ; p[0]=*BASE_VTBL
; обратите внимание – экземпляр объекта размещается в стеке,
; а не в куче! Это, конечно, не еще не свидетельствует о статичной
; природе экземпляра объекта (динамичные объекты тоже могут размещаться в стеке)
; но намеком на "статику" все же служит
lea ecx, [ebp+var_4] ; base
; подготавливаем указатель this (на тот случай если он понадобится функции)
call BASE_DEMO
; непосредственный вызов функции! Вот, вкупе с ее наличием в виртуальной таблице
; свидетельство статичности объявления экземпляра объекта!
lea ecx, [ebp+var_4] ; base
; вновь подготавливаем указатель this
на экземляр base
call BASE_DEMO_2
; непосредственный вызов функции. Она есть в виртуальной таблице? Есть!
; значит, это виртуальная функция, а экземпляр объекта объявлен статичным
lea ecx, [ebp+var_4] ; base
; готовим указатель this для не виртуальной
функции demo_3
call BASE_DEMO_3
; этой функции нет в виртуальной таблице (см. виртуальную таблицу)
; значит, она не виртуальная
lea ecx, [ebp+var_8] ; derived
call GetDERIVED_VTBL ; d[0]=*DERIVED_VTBL
lea ecx, [ebp+var_8] ; derived
call DERIVED_DEMO
; аналогично предыдущему...
lea ecx, [ebp+var_8] ; derived
call DERIVED_DEMO_2
; аналогично
предыдущему...
lea ecx, [ebp+var_8] ; derived
call BASE_DEMO_3_
; внимание! Указатель this указывает на объект DERIVED, в то время как
; вызывается функция объекта BASE!!! Значит, функция BASE – производная
mov esp, ebp
pop ebp
retn
main endp
BASE_DEMO proc near ; CODE XREF: main+11p
; функция demo класса BASE
push offset aBase ; "BASE\n"
call printf
pop ecx
retn
BASE_DEMO endp
BASE_DEMO_2 proc near ; CODE XREF: main+19p
; функция demo_2 класса BASE
push offset aBaseDemo2 ; "BASE DEMO 2\n"
call printf
pop ecx
retn
BASE_DEMO_2 endp
BASE_DEMO_3 proc near ; CODE XREF: main+21p
; функция demo_3 класса BASE
push offset aNonVirtualBase ; "Non virtual BASE DEMO 3\n"
call printf
pop ecx
retn
BASE_DEMO_3 endp
DERIVED_DEMO proc near ; CODE XREF: main+31p
; функция demo класса DERIVED
push offset aDerived ; "DERIVED\n"
call printf
pop ecx
retn
DERIVED_DEMO endp
DERIVED_DEMO_2 proc near ; CODE XREF: main+39p
; функция demo класса DERIVED
push offset aDerivedDemo2 ; "DERIVED DEMO 2\n"
call printf
pop ecx
retn
DERIVED_DEMO_2 endp
BASE_DEMO_3_ proc near ; CODE XREF: main+41p
; функция demo_3 класса BASE
; Внимание! Смотрите – функция demo_3 дважды присутствует в программе!
; первый раз она входила в объект класса BASE, а второй – в объект класса
; DERIVED, который унаследовал ее от базового класса и сделал копию
; глупо, да? ведь лучше бы он обратился к оригиналу... Зато это упрощает
; анализ программы...
push offset aNonVirtualDeri ; "Non virtual DERIVED DEMO 3\n"
call printf
pop ecx
retn
BASE_DEMO_3_ endp
GetBASE_VTBL proc near ; CODE XREF: main+9p
; занесение в экземпляр объекта BASE
смещения его виртуальной таблицы
mov eax, ecx
mov dword ptr [eax], offset BASE_VTBL
retn
GetBASE_VTBL endp
GetDERIVED_VTBL proc near ; CODE XREF: main+29p
; занесение в экземпляр объекта DERIVED
смещения его виртуальной таблицы
push esi
mov esi, ecx
call GetBASE_VTBL
; ага! Значит, наш объект – производный от BASE!
mov dword ptr [esi], offset DERIVED_VTBL
; занесение указателя на виртуальную таблицу DERIVED
mov eax, esi
pop esi
retn
GetDERIVED_VTBL endp
BASE_VTBL dd offset BASE_DEMO ; DATA XREF: GetBASE_VTBL+2o
dd offset BASE_DEMO_2
DERIVED_VTBL dd offset DERIVED_DEMO ; DATA XREF: GetDERIVED_VTBL+8o
dd offset DERIVED_DEMO_2
; обратите внимание на наличие виртуальной таблицы даже там, где она не нужна!
Листинг 33
::идентификация производных функций. Идентификация производных не виртуальных функций – весьма тонкий момент. На первый взгляд, коль они вызываются как и обычные Си-функции, распознать: в каком классе была объявлена функция невозможно – компилятор уничтожает эту информацию еще на стадии компиляции. Уничтожает, да не всю! Перед каждым вызовом функции (не важно производной или нет) в обязательном порядке формируется указатель this – на тот случай если он понадобится функции, указывающей на объект из которого вызывается эта функция. Для производных функций указатель this хранит смещение производного, а не базового объекта. Вот оно! Если функция вызывается с различными указателями this – это производная функция.
Сложнее выяснить – от какого объекта она происходит. Универсальных решений нет, но если выделить объект A с функциями f1(), f2()… И объект B с функциями f1(), f3(),f4()… то можно смело утверждать, что f1() – функция, производная от класса А. Правда, если из экземпляра класса функция f1() не вызывалась ни разу – определить производная она или нет – не удастся.
Рассмотрим все это на следующем примере:
#include <stdio.h>
class Base{
public:
void base_demo(void)
{
printf("BASE DEMO\n");
};
void base_demo_2(void)
{
printf("BASE DEMO 2\n");
};
};
class Derived: public Base{
public:
void derived_demo(void)
{
printf("DERIVED DEMO\n");
};
void derived_demo_2(void)
{
printf("DERIVED DEMO 2\n");
};
};
Листинг 34 Демонстрация идентификации производных функций
Результат компиляции в общем случае должен выглядеть так:
main proc near ; CODE XREF: start+AFp
push esi
push 1
call ??2@YAPAXI@Z ; operator new(uint)
; создаем новый экземпляр некоторого объекта. Пока мы еще не знаем какого
; пусть это будет объект A
mov esi, eax ; ESI = *a
add esp, 4
mov ecx, esi ; ECX = *a (this)
call BASE_DEMO
; вызываем BASE_DEMO, обращая внимание на то, что this
указывает на 'a'
mov ecx, esi ; ECX = *a (this)
call BASE_DEMO_2
; вызываем BASE_DEMO_2, обращая внимание на то, что this
указывает на 'a'
push 1
call ??2@YAPAXI@Z ; operator new(uint)
; создаем еще один экземпляр некоторого объекта, назовем его b
mov esi, eax ; ESI = *b
add esp, 4
mov ecx, esi ; ECX = *b (this)
call BASE_DEMO
; Ага! Вызываем BASE_DEMO, но на этот раз this
указывает на b
; значит, BASE_DEMO
связана родственными отношениями и с 'a' и с 'b'
mov ecx, esi
call BASE_DEMO_2
; Ага! Вызываем BASE_DEMO_2, но на этот раз this
указывает на b
; значит, BASE_DEMO_2 связана родственными отношениями и с 'a' и с 'b'
mov ecx, esi
call DERIVED_DEMO
; вызываем DERIVED_DEMO. Указатель this указывает на b, и никаких родственных
; связей DERIVED_DEMO
с 'a' не замечено. this
никогда не указывал на 'a'
; при ее вызове
mov ecx, esi
call DERIVED_DEMO_2
; аналогично...
pop esi
retn
main endp
Листинг 35
Ок, идентификация не виртуальных производных функций – вполне реальное дело. Единственная сложность – отличить экземпляры двух различных объектов от экземпляров одного и того же объекта.
Что же касается идентификации производных виртуальных функций – об этом уже рассказывалось выше. Производные виртуальные функции вызываются в два этапа – на первом в экземпляр объекта заносится смещение виртуальной таблицы базового класса, а затем оно замещается смещением виртуальной таблицы производного класса. Даже если компилятор оптимизирует код, оставшейся избыточности все равно с лихвой хватит для отличия производных функций от остальных.
::идентификация виртуальных таблиц. Теперь, основательно освоившись с виртуальными таблицами и функциями, рассмотрим очень коварный вопрос – всякий ли массив указателей на функции есть виртуальная таблица? Разумеется, нет! Ведь косвенный вызов функции через указатель – частое дело в практике программиста.
Массив указателей на функции… хм, конечно типичным его не назовешь, но и такое в жизни встречается!
Рассмотрим следующий пример – кривой и наигранный конечно, но чтобы продемонстрировать ситуацию, где массив указателей жизненно необходим, пришлось бы написать не одну сотню строк кода:
#include <stdio.h>
void demo_1(void)
{
printf("Demo 1\n");
}
void demo_2(void)
{
printf("Demo 2\n");
}
void call_demo(void **x)
{
((void (*)(void)) x[0])();
((void (*)(void)) x[1])();
}
main()
{
static void* x[2] =
{ (void*) demo_1,(void*) demo_2};
// Внимание: если инициализировать массив не при его объявлении
// а по ходу программы, т.е. x[0]=(void *) demo_1,...
// то компилятор сгенерирует адекватный код, заносящий
// смещения функций в ходе выполнения программы, что будет
// совсем не похоже на виртуальную таблицу!
// Напротив, инициализация при объявлении помещает уже
// готовые указатели в сегмент данных, смахивая на настоящую
// виртуальную таблицу (и экономя такты процессора к тому же)
call_demo(&x[0]);
}
Листинг 36 Демонстрация имитации виртуальных таблиц
А теперь посмотрим – сможем ли мы отличить "рукотворную" таблицу указателей от настоящей:
main proc near ; CODE XREF: start+AFp
push offset Like_VTBL
call demo_call
; ага, функции передается указатель на нечто очень похожее на виртуальную
; таблицу. Но мы-то, уже умудренные опытом, с легкостью раскалываем эту
; грубую подделку. Во-первых, указатели на VTBL
так просто не передаются,
; (там не такой тривиальный код), во-вторых они передаются не через стек,
; а через регистр. В-третьих, указатель на виртуальную таблицу ни одним
; существующим компилятором не используется непосредственно, а помещается
; в объект. Тут же нет ни объекта, ни указателя this
– в четвертых.
; словом, это не виртуальная таблица, хотя на беглый, нетренированный
; взгляд очень на нее похожа...
pop ecx
retn
main endp
demo_call proc near ; CODE XREF: sub_0_401030+5p
arg_0 = dword ptr 8
; вот-с! указатель – аргумент, а к виртуальным таблицам идет обращение
; через регистр...
push ebp
mov ebp, esp
push esi
mov esi, [ebp+arg_0]
call dword ptr [esi]
; происходит двухуровневый вызов функции – по указателю на массив
; указателей на функцию, что характерно для вызова виртуальных функций
; но, опять-таки слишком тривиальный код, - вызов виртуальных функций
; сопряжен с большой избыточностью, а во-вторых опять нет указателя this
call dword ptr [esi+4]
; аналогично – слишком просто для вызова виртуальной функции
pop esi
pop ebp
retn
demo_call endp
Like_VTBL dd offset demo_1 ; DATA XREF:main
dd offset demo_2
; массив указателей внешне похож на виртуальную таблицу, но
; расположен "не там" где обычно располагаются виртуальные таблицы
Листинг 37
Обобщая выводы, разбросанные по комментариям, повторим основные признаки "подделки" еще раз:
- слишком тривиальный код, - минимум используемых регистров и никакой избыточности, обращение к виртуальным таблицам происходит куда витиеватее;
- указатель на виртуальную функцию заносится в экземпляр объекта, и передается он не через стек, а через регистр (точнее – см. "Идентификация this");
- отсутствует указатель this, всегда подготавливаемый перед вызовом виртуальной функции;
- виртуальные функции и статические переменные располагаются в различных местах сегмента данных – поэтому сразу можно отличить одни от других.
А можно ли так организовать вызов функции по ссылке, чтобы компиляция программы давала код идентичный вызову виртуальной функции? Как сказать… Теоретически да, но практически – едва ли такое удастся осуществить (а уж непреднамеренно – тем более). Код вызова виртуальных функций в связи с большой избыточностью очень специфичен и легко различим "на глаз".Легко сымитировать общую технику работы с виртуальными таблицами, но без ассемблерных вставок невозможно воспроизвести ее в точности.
::заключение.
Вообще же, как мы видим, работа с виртуальными функциями сопряжена с огромной избыточностью и "тормозами", а их анализ связан с большими трудозатратами – приходится постоянно держать в голове множество указателей и помнить какой из них на что указывает. Но, как бы там ни было, никаких принципиально-неразрешимых преград перед исследователем не стоит.
Идентификация значения, возвращаемого функцией
…каждый язык - это своя философия, свой взгляд на деятельность программиста, отражение определенной технологии программирования.
Кауфман
Традиционно под "значением, возвращаемым функцией" понимается значение, возращенное оператором return, однако, это лишь надводная часть айсберга, не раскрывающая всей картины взаимодействия функций друг с другом. В качестве наглядной демонстрации рассмотрим довольно типичный пример, кстати, позаимствованный из реального кода программы:
int xdiv(int a, int b, int *c=0)
{
if (!b) return –1;
if (c) c[0]=a % b;
return a / b;
}
Листинг 88 Демонстрация возвращения значения в аргументе, переданном по ссылке
Функция xdiv возвращает результат целочисленного деления аргумента a
на аргумент b, но помимо этого записывает в переменную c, переданную по ссылке, остаток. Так сколько же значений вернула функция? И чем возращение результата по ссылке хуже или "незаконнее" классического return?
Популярные издания склонны упрощать проблему идентификации значения, возращенного функций, рассматривая один лишь частный случай с оператором return. В частности, так поступает Мэтт Питтерек в своей книге "Секреты системного программирования в Windows 95", все же остальные способы остаются "за кадром". Мы же рассмотрим следующие механизмы:
-- возврат значения оператором return (через регистры или стек сопроцессора);
-- возврат значений через аргументы, переданные по ссылке;
-- возврат значений через динамическую память (кучу);
-- возврат значений через глобальные переменные;
– возврат значений через флаги процессора.
Вообще-то, к этому списку не помешало бы добавить возврат значений через дисковые и проецируемые в память файлы, но это выходит за рамки обсуждаемой темы (хотя, рассматривая функцию как "черный ящик" с входом и выходом, нельзя не признать, что вывод функцией результатов своей работы в файл, – фактически есть возвращаемое ею значение).
::возврат значения оператором return. По общепринятому соглашению значение, возвращаемое оператором return, помещается в регистр EAX (в AX у 16-разрядных компиляторов), а если его оказывается недостаточно, старшие 32 бита операнда помещаются в EDX (в 16-разрядном режиме старшее слово помещается в DX).
Вещественные типы в большинстве случаев возвращаются через стек сопроцессора, реже – через регистры EDX:EAX (DX:AX в 16-разрядном режиме).
А как возвращаются типы, занимающие более 8 байт? Скажем, некая функция возвращает структуру, состоящую из сотен байт или объект не меньшего размера. Ни то, ни другое в регистры не запихнешь, даже стека сопроцессора не хватит!
тип |
способ возврата |
||
однобайтовый |
AL |
AX |
|
двухбайтовый |
AX |
||
четырехбайтовый |
DX:AX |
||
real |
DX:BX:AX |
||
float |
DX:AX |
стек сопроцессора |
|
double |
стек сопроцессора |
||
near pointer |
AX |
||
far pointer |
DX:AX |
||
свыше четырех байт |
через неявный аргумент по ссылке |
||
тип |
способ возврата |
|||
однобайтовый |
AL |
AX |
EAX |
|
двухбайтовый |
AX |
EAX |
||
четырехбайтовый |
EAX |
|||
восьми байтовый |
EDX:EAX |
|||
float |
стек сопроцессора |
EAX |
||
double |
стек сопроцессора |
EDX:EAX |
||
near pointer |
EAX |
|||
свыше восьми байт |
через неявный аргумент по ссылке |
|||
Оказывается, если возвращаемое значение не может быть втиснуто в регистры, компилятор скрыто от программиста передает функции неявный аргумент – ссылку на локальную переменную, в которую и записывается возвращенный результат. Таким образом, функции struct mystuct MyFunc(int a, int b) и void MyFunc(struct mystryct *my, int a, int b) компилируются в идентичный
(или близкий к тому) код и "вытянуть" из машинного кода подлинный прототип невозможно!
Единственную зацепку дает компилятор Microsoft Visual C++, возвращающий в этом случае указатель на возвращаемую переменную, т.е. восстановленный прототип выглядит приблизительно так: struct mystruct* MyFunc(struct mystruct* my, int a, int b). Согласитесь, несколько странно, чтобы программист в здравом уме да при живой теще, возвращал указатель на аргумент, который своими руками только что и передал функции? Компилятор же Borland C++ в данной ситуации возвращает тип void, стирая различие между аргументом, возвращаемым по значению и аргументом, возвращаемым по ссылке. Впрочем, невозможность восстановления подлинного прототипа не должна огорчать. Скорее наоборот! "Истинный прототип" утверждает, что результат работы функции возвращается по значению, а в действительности он возвращается по ссылке! Так ради чего тогда называть кошку мышкой?
Пару слов об определении типа возвращаемого значения. Если функция при выходе явно присваивает регистру EAX или EDX некоторое значение (AX и DX в 16-разрядном режиме), то его тип можно начерно определить по таблицам 11 и 12. Если же оставляет эти регистры неопределенными – то, скорее всего, возвращается тип void, т.е. ничто. Уточнить информацию помогает анализ вызывающей функции, а точнее то, как она обращается с регистрами EAX [EDX] (AX [DX] в 16-разрядном режиме). Например, для типов char характерно либо обращение к младшей половинке регистра EAX (AX) – регистру AL, либо обнуление старших байт операцией логического AND. Логично предположить: если вызывающая функция не использует значения, отставленного вызываемой функцией в регистрах EAX [EDX], – ее тип void. Но это предположение неверно. Частенько программисты игнорируют возвращаемое значение, вводя тем самым исследователей в заблуждение.
Рассмотрим следующий пример, демонстрирующий механизм возвращения основных типов значений:
#include <stdio.h>
#include <malloc.h>
char char_func(char a, char b)
{
return a+b;
}
int int_func(int a, int b)
{
return a+b;
}
__int64 int64_func(__int64 a, __int64 b)
{
return a+b;
}
int* near_func(int* a, int* b)
{
int *c;
c=(int *)malloc(sizeof(int));
c[0]=a[0]+b[0];
return c;
}
main()
{
int a;
int b;
a=0x666;
b=0x777;
printf("%x\n",
char_func(0x1,0x2)+
int_func(0x3,0x4)+
int64_func(0x5,0x6)+
near_func(&a,&b)[0]);
}
Листинг 89 Пример, демонстрирующий механизм возвращения основных типов значений
Результат его компиляции Microsoft Visual C++ 6.0 с настойками по умолчанию будет выглядеть так:
char_func proc near ; CODE XREF: main+1Ap
arg_0 = byte ptr 8
arg_4 = byte ptr 0Ch
push ebp
mov ebp, esp
; Открываем кадр стека
movsx eax, [ebp+arg_0]
; Загружаем в EAX arg_0 тип signed char, попутно расширяя его до int
movsx ecx, [ebp+arg_4]
; Загружаем в EAX arg_0 тип signed char, попутно расширяя его до int
add eax, ecx
; Складываем arg_0 и arg_4 расширенные до int, сохраняя их в регистре EAX -
; это есть значение, возвращаемое функцией.
; К сожалению, достоверно определить его тип невозможно. Он с равным успехом
; может представлять собой и int и char, причем, int даже более вероятен,
; т.к. сумма двух char по соображениям безопасности должна помещаться в int,
; иначе возможно переполнение.
pop ebp
retn
char_func endp
int_func proc near ; CODE XREF: main+29p
arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch
push ebp
mov ebp, esp
; Открываем кадр стека
mov eax, [ebp+arg_0]
; Загружаем в EAX значение аргумента arg_0 типа int
add eax, [ebp+arg_4]
; Складываем arg_0 с arg_4 и оставляем результат в регистре EAX.
; Это и есть значение, возвращаемое функцией, вероятнее всего, типа int.
pop ebp
retn
int_func endp
int64_func proc near ; CODE XREF: main+40p
arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch
arg_8 = dword ptr 10h
arg_C = dword ptr 14h
push ebp
mov ebp, esp
; открываем кадр стека
mov eax, [ebp+arg_0]
; Загружаем в EAX значение аргумента arg_0
add eax, [ebp+arg_8]
; Складываем arg_0 с arg_8
mov edx, [ebp+arg_4]
; Загружаем в EDX значение аргумента arg_4
adc edx, [ebp+arg_C]
; Складываем arg_4 и arg_C
с учетом флага переноса, оставшегося от сложения
; arg_0 с arg_8.
; Выходит, arg_0 и arg_4, как и arg_8 и arg_C
это – половинки двух
; аргументов типа __int64, складываемые друг с другом.
; Стало быть, результат вычислений возвращается в регистрах EDX:EAX
pop ebp
retn
int64_func endp
near_func proc near ; CODE XREF: main+54p
var_4 = dword ptr -4
arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch
push ebp
mov ebp, esp
; Отрываем кадр стека
push ecx
; Сохраняем ECX
push 4 ; size_t
call _malloc
add esp, 4
; Выделяем 4 байта из кучи
mov [ebp+var_4], eax
; Заносим указатель на выделенную память в переменную var_4
mov eax, [ebp+arg_0]
; Загружаем в EAX значение аргумента arg_0
mov ecx, [eax]
; Загружаем в ECX значение ячейки памяти типа int на которую указывает EAX.
; Таким образом, тип аргумента arg_0 – int
*
mov edx, [ebp+arg_4]
; Загружаем в EDX значение аргумента arg_4
add ecx, [edx]
; Складываем с *arg_0 значение ячейки памяти типа int
на которое указывает EDX
; Следовательно, тип аргумента arg_4 – int
*
mov eax, [ebp+var_4]
; Загружаем в EAX указатель на выделенный из кучи блок памяти
mov [eax], ecx
; Копируем в кучу значение суммы *arg_0 и *arg_4
mov eax, [ebp+var_4]
; Загружаем в EAX указатель на выделенный из кучи блок памяти
; Это и будет значением, возвращаемым функцией, т.е.
ее прототип выглядел так:
; int* MyFunc(int *a, int *b);
mov esp, ebp
pop ebp
retn
near_func endp
main proc near ; CODE XREF: start+AFp
var_8 = dword ptr -8
var_4 = dword ptr -4
push ebp
mov ebp, esp
; Открываем кадр стека
sub esp, 8
; Резервируем место для локальных переменных
push esi
push edi
; Сохраняем регистры в стеке
mov [ebp+var_4], 666h
; Заносим в локальную переменную var_4 типа int
значение 0x666
mov [ebp+var_8], 777h
; Заносим в локальную переменную var_8 типа int
значение 0x777
push 2
push 1
call char_func
add esp, 8
; Вызываем
функцию char_func(1,2). Как мы помним, у нас были сомнения в типе
; возвращаемого ею значения – либо int, либо char.
movsx esi, al
; Расширяем возращенное функцией значение до signed int, следовательно, она
; возвратила signed char
push 4
push 3
call int_func
add esp, 8
; Вызываем функцию int_func(3,4), возвращающую значение типа int
add eax, esi
; Прибавляем к значению, возвращенному функцией, содержимое ESI
cdq
; Преобразуем двойное слово, содержащееся в регистре EAX
в четверное,
; помещаемое в регистр EDX:EAX
mov esi, eax
mov edi, edx
; Копируем расширенное четверное слово в регистры EDI:ESI
push 0
push 6
push 0
push 5
call int64_func
add esp, 10h
; Вызываем функцию int64_func(5,6), возвращающую тип __int64
; Теперь становится понятно, чем вызвано расширение предыдущего результата
add esi, eax
adc edi, edx
; К четверному слову, содержащемуся в регистрах EDI:ESI добавляем результат
; возращенный функцией int64_func
lea eax, [ebp+var_8]
; Загружаем в EAX указатель на переменную var_8
push eax
; Передаем функции near_func
указатель на var_8 как аргумент
lea ecx, [ebp+var_4]
; Загружаем в ECX указатель на переменную var_4
push ecx
; Передаем функции near_func
указатель на var_4 как аргумент
call near_func
add esp, 8
; Вызываем near_func
mov eax, [eax]
; Как мы помним, в регистре EAX функция возвратила указатель на переменную
; типа int, - загружаем значение этой переменной в регистр EAX
cdq
; Расширяем EAX до четверного слова
add esi, eax
adc edi, edx
; Складываем два четверных слова
push edi
push esi
; Результат сложения передаем функции printf
push offset unk_406030
; Передаем указатель на строку спецификаторов
call _printf
add esp, 0Ch
pop edi
pop esi
mov esp, ebp
pop ebp
retn
main endp
Листинг 90
Как мы видим: в идентификации типа значения, возращенного оператором return ничего хитрого нет, - все прозаично. Но не будем спешить. Рассмотрим следующий пример. Как вы думаете, что именно и в каких регистрах будет возвращаться?
#include <stdio.h>
#include <string.h>
struct XT
{
char s0[4];
int x;
};
struct XT MyFunc(char *a, int b)
// функция возвращает значение типа структура "XT" по значению
{
struct XT xt;
strcpy(&xt.s0[0],a);
xt.x=b;
return xt;
}
main()
{
struct XT xt;
xt=MyFunc("Hello, Sailor!",0x666);
printf("%s %x\n",&xt.s0[0],xt.x);
}
Листинг 91 Пример демонстрирующий возвращения структуры по значению
Заглянем в откомпилированный результат:
MyFunc proc near ; CODE XREF: sub_401026+10p
var_8 = dword ptr -8
var_4 = dword ptr –4
; Эти локальные переменные на самом деле элементы "расщепленной" структуры XT
; Как уже говорилось в главе "Идентификация объектов, структур и массивов",
; компилятор всегда стремится обращаться к элементам структуры по их фактическим
; адресам, а не через базовый указатель.
; Поэтому, не так-то просто отличить структуру от несвязанных между собой переменных,
; а под час это и вовсе невозможно!
arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch
; Функция принимает два аргумента
push ebp
mov ebp, esp
; Открываем кадр стека
sub esp, 8
; Резервируем место для локальных переменных
mov eax, [ebp+arg_0]
; Загружаем в регистр EAX содержимое аргумента arg_0
push eax
; Передаем arg_0 функции strcpy, следовательно,
; arg_0 представляет собой указатель на строку.
lea ecx, [ebp+var_8]
; Загружаем в ECX указатель на локальную переменную var_8 и…
push ecx
;...передаем его функции strcpy
; Следовательно, var_8 – строковой буфер размером 4 байта
call strcpy
add esp, 8
; Копируем переданную через arg_0 строку в var_8
mov edx, [ebp+arg_4]
; Загружаем в регистр EDX значение аргумента arg_4
mov [ebp+var_4], edx
; Копируем arg_4 в локальную переменную var_4
mov eax, [ebp+var_8]
; Загружаем в EAX содержимое (не указатель!) строкового буфера
mov edx, [ebp+var_4]
; Загружаем в EDX значение переменной var_4
; Столь явная загрузка регистров EDX:EAX перед выходом из функции указывает
; на то, что это и есть значение, взращаемое функцией.
; Надо же какой неожиданный сюрприз! Функция возвращает в EDX
и EAX
; две переменные различного типа! А вовсе не __int64, как могло бы показаться
; при беглом анализе программы.
; Второй сюрприз – возврат типа char[4] не через указатель или ссылку, а через
; регистр!
; Нам еще повезло, если бы структура была объявлена как
; struct XT{short int a, char b, char c}, в регистре EAX возвратились бы
; целых три переменные двух типов!
mov esp, ebp
pop ebp
retn
MyFunc endp
main proc near ; CODE XREF: start+AFp
var_8 = dword ptr -8
var_4 = dword ptr –4
; Две локальные переменные типа int
; Тип установлен путем вычисления размера каждой из них
push ebp
mov ebp, esp
; Открываем кадр стека
sub esp, 8
; Резервируем восемь байт под локальные переменные
push 666h
; Передаем функции MyFunc аргумент типа int
; Следовательно, arg_4 имеет тип int (по коду вызываемой функции это не было
; очевидно, - arg_4 с не меньшим успехом мог оказаться и указателем).
; Значит, в регистре EDX функция возвращает тип int
push offset aHelloSailor ; "Hello, Sailor!"
; Передаем функции MyFunc указатель на строку
; Внимание! Строка занимает более 4-х байт, поэтому, не рекомендуется
; запускать этот пример "вживую".
call MyFunc
add esp, 8
; Вызываем MyFunc. Она неким образом изменяет регистры EDX
и EAX
; Мы уже знаем типы возвращаемых в них значений и остается только
; удостоверится – "правильно" ли они используются вызывающей функцией.
mov [ebp+var_8], eax
; Заносим в локальную переменную var_8 содержимое регистра EAX
mov [ebp+var_4], edx
; Заносим в локальную переменную var_4 содержимое регистра EDX
; Согласитесь, – очень похоже на то, что функция возвращает __int64
mov eax, [ebp+var_4]
; Загружаем в EAX содержимое var_4
; (т.е. регистра EDX, возвращенного функцией MyFunc) и…
push eax
; …передаем его функции printf
; Согласно строки спецификаторов, это тип int
; Следовательно, в EDX функция возвратила int или, по крайней мере, его
; старшую часть
lea ecx, [ebp+var_8]
; Загружаем в ECX указатель на переменную var_8, хранящую значение,
; возвращенное функцией через регистр EAX.
; Согласно строки спецификаторов, это указатель на строку
; Итак, мы подтвердили, что типы значений, возвращенных через регистры EDX:EAX
; различны!
; Немного поразмыслив, мы даже сможем восстановить подлинный прототип:
; struct X{char a[4]; int} MyFunc(char* b, int c);
push ecx
push offset aSX ; "%s %x\n"
call _printf
add esp, 0Ch
mov esp, ebp
pop ebp
; Закрываем кадр стека
retn
main endp
Листинг 92
А теперь слегка изменим структуру XT, заменив char s0[4]
на char9 s0[10], что гарантированно не влезает в регистры EDX:AX и посмотрим, как изменится от этого код:
main proc near ; CODE XREF: start+AFp
var_20 = byte ptr -20h
var_10 = dword ptr -10h
var_C = dword ptr -0Ch
var_8 = dword ptr -8
var_4 = dword ptr -4
push ebp
mov ebp, esp
; Отрываем кадр стека
sub esp, 20h
; Резервируем 0x20 байт под локальные переменные
push 666h
; Передаем функции MyFunc крайний правый аргумент – значение 0x666 типа int
push offset aHelloSailor ; "Hello, Sailor!"
; Передаем функции MyFunc второй справа аргумент – указатель на строку
lea eax, [ebp+var_20]
; Загружаем в EAX адрес локальной переменной var_20
push eax
; Передаем функции MyFunc указатель на переменную var_20
; Стоп! Этого аргумента не было в прототипе функции! Откуда же он взялся?!
; Верно, не было. Его вставил компилятор для возвращения структуры по значению.
; Последнюю фразу вообще-то стоило заключить в кавычки для придания ей
; ироничного оттенка – структура, возвращаемая по значению, в действительности
; возвращается по ссылке.
call MyFunc
add esp, 0Ch
; Вызываем MyFunc
mov ecx, [eax]
; Функция в ECX возвратила указатель на возвращенную ей по ссылке структуру
; Этот прием характерен лишь для Microsoft Visual C++, большинство компиляторов
; оставляют значение EAX на выходе неопределенным или равным нулю.
; Но, так или иначе, в ECX загружается первое двойное слово,
; на которое указывает указатель EAX. На первый взгляд, это элемент типа int
; Однако не будем бежать по перед косы и торопиться с выводами
mov [ebp+var_10], ecx
; Сохранение ECX в локальной переменной var_10
mov edx, [eax+4]
; В EDX загружаем второе двойное слово по указателю EDX
mov [ebp+var_C], edx
; Копируем его в переменную var_C
; Выходит, что и второй элемент структуры – имеет тип int?
; Мы, знающие как выглядел исходный текст программы, уже начинам замечать
; подвох. Что-то здесь определенно не так...
mov ecx, [eax+8]
; Загружаем третье двойное слово, от указателя EAX
и…
mov [ebp+var_8], ecx
; …копируем его в var_8. Еще один тип int? Да откуда же они берутся в таком
; количестве, когда у нас он был только один! И где, собственно, строка?
mov edx, [eax+0Ch]
mov [ebp+var_4], edx
; И еще один тип int переносим из структуры в локальную переменную. Нет, это
; выше наших сил!
mov eax, [ebp+var_4]
; Загружаем в EAX содержимое переменной var_4
push eax
; Передаем значение var_4 функции printf.
; Судя по строке спецификаторов, var_4 действительно, имеет тип int
lea ecx, [ebp+var_10]
; Получаем указатель на переменную var_10 и…
push ecx
;...передаем его функции printf
; Судя по строке спецификаторов, тип ECX
– char
*, следовательно: var_10
; и есть искомая строка. Интуиция нам подсказывает, что var_C и var_8,
; расположенные ниже ее (т.е. в более старших адресах), так же содержат
; строку. Просто компилятор вместо того чтобы вызывать srtcpy
решил, что
; будет быстрее скопировать ее самостоятельно, чем и ввел нас в заблуждение.
; Поэтому, никогда не следует торопится с идентификацией типов элементов
; структур! Тщательно проверяйте каждый байт – как он инициализируется и как
; используется. Операции пересылки в локальные переменные еще ни о чем
; не
говорят!
push offset aSX ; "%s %x\n"
call _printf
add esp, 0Ch
mov esp, ebp
pop ebp
; Закрываем кадр стека
retn
main endp
MyFunc proc near ; CODE XREF: main+14p
var_10 = dword ptr -10h
var_C = dword ptr -0Ch
var_8 = dword ptr –8
var_4 = dword ptr –4
arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch
arg_8 = dword ptr 10h
; Обратите внимание, что функции передаются три аргумента, а не два, как было
; объявлено в прототипе
push ebp
mov ebp, esp
; Открываем кадр стека
sub esp, 10h
; Резервируем память для локальных переменных
mov eax, [ebp+arg_4]
; Загружаем а EAX указатель на второй справа аргумент
push eax
; Передаем указатель на arg_4 функции strcpy
lea ecx, [ebp+var_10]
; Загружаем в ECX указатель на локальную переменную var_10
push ecx
; Передаем функции strcpy указатель на локальную переменную var_10
call strcpy
add esp, 8
; Копируем строку, переданную функции MyFunc, через аргумент arg_4
mov edx, [ebp+arg_8]
; Загружаем в EDX значение самого правого аргумента, переданного MyFunc
mov [ebp+var_4], edx
; Копируем arg_8 в локальную переменную var_4
mov eax, [ebp+arg_0]
; Загружаем в EAX значение аргумента arg_0
; Как мы знаем, этот аргумент функции передает сам компилятор, и передает в нем
; указатель на локальную переменную, предназначенную для возращения структуры
mov ecx, [ebp+var_10]
; Загружаем в ECX двойное слово с локальной переменной var_10
; Как мы помним, в локальную переменную var_10 ранее была скопирована строка,
; следовательно, сейчас мы вновь увидим ее "двухсловное" копирование!
mov [eax], ecx
mov edx, [ebp+var_C]
mov [eax+4], edx
mov ecx, [ebp+var_8]
mov [eax+8], ecx
; И точно! Из локальной переменной var_10 в локальную переменную *arg_0
; копирование происходит "вручную", а не с помощью strcpy!
; В общей сложности сейчас было скопировано 12 байт, значит, первый элемент
; структуры выглядит так: char s0[12].
; Да, конечно, в исходном тесте было 'char s0[10]', но компилятор,
; выравнивая элементы структуры по адресам, кратным четырем, перенес второй
; элемент – int x, по адресу base+0x12, тем самым создав "дыру" между концом
; строки и началом второго элемента.
; Анализ дизассемблерного листинга не позволяет восстановить истинный вид
; структуры, единственное, что можно сказать – длина строки s0
; лежит в интервале [9 - 12]
;
mov edx, [ebp+var_4]
mov [eax+0Ch], edx
; Копируем переменную var_4 (содержащую аргумент arg_8) в [eax+0C]
; Действительно, второй элемент структуры -int x- расположен по смещению
; 12 байт от ее начала.
mov eax, [ebp+arg_0]
; Возвращаем в EAX указатель на аргумент arg_0, содержащий указатель на
; возращенную структуру
mov esp, ebp
pop ebp
; Закрываем кадр стека
retn
; Итак, прототип функции выглядит так:
; struct X {char s0[12], int a} MyFunc(struct X *x, char *y, int z)
;
MyFunc endp
Листинг 93
Возникает вопрос – а как возвращаются структуры, состоящие из сотен и тысяч байт? Ответ: они копируются в локальную переменную, неявно переданную компилятором по ссылке, инструкцией MOVS, в чем мы сейчас и убедимся, изменив в исходном тексте предыдущего примера "char s0[10]", на "char s0[0x666]". Результат перекомпиляции должен выглядеть так:
MyFunc proc near ; CODE XREF: main+1Cp
var_66C = byte ptr -66Ch
var_4 = dword ptr -4
arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch
arg_8 = dword ptr 10h
push ebp
mov ebp, esp
; Открываем кадр стека
sub esp, 66Ch
; Резервируем память для локальных переменных
push esi
push edi
; Сохраняем регистры в стеке
mov eax, [ebp+arg_4]
push eax
lea ecx, [ebp+var_66C]
push ecx
call strcpy
add esp, 8
; Копируем переданную функции строку в локальную переменную var_66C
mov edx, [ebp+arg_8]
mov [ebp+var_4], edx
; Копируем аргумент arg_8 в локальную переменную var_4
mov ecx, 19Bh
; Заносим в ECX значение 0x19B, пока еще не понимая, что оно выражает
lea esi, [ebp+var_66C]
; Устанавливаем регистр ESI на локальную переменную var_66C
mov edi, [ebp+arg_0]
; Устанавливаем регистр EDI на переменную на которую указывает
; указатель, переданный в аргументе arg_0
repe movsd
; Копируем ECX двойных слов с ESI в EDI
; Переводя это в байты, получаем: 0x19B*4 = 0x66C
; Таким образом, копируется и строка var_66C, и переменная var_4
mov eax, [ebp+arg_0]
; Возвращаем в EAX указатель на возвращенную структуру
pop edi
pop esi
mov esp, ebp
pop ebp
; Закрываем кадр стека
retn
MyFunc endp
Листинг 94
Следует учитывать, что многие компиляторы (например, WATCOM) передают функции указатель на буфер для возвращаемого значения не через стек, а через регистр, причем регистр по обыкновению берется не из очереди кандидатов в порядке предпочтения (см. таблицу 6), а используется особый регистр, специально предназначенный для этой цели. Например, у WATCOM-а это регистр ESI.
::возвращение вещественных значений.
Соглашения cdecl и stdcall предписывают возвращать вещественные значения (float, double, long double) через стек сопроцессора, значение же регистров EAX и EDX на выходе из такой функции может быть любым (другими словами, функции, возвращающие вещественные значения, оставляют регистры EAX и EDX в неопределенном состоянии).
fastcall-функции теоретически могут возвращать вещественные переменные и в регистрах, но на практике до этого дело обычно не доходит, поскольку, сопроцессор не может напрямую читать регистры основного процессора и их приходится проталкивать через оперативную память, что сводит на нет всю выгоду быстрого вызова.
Для подтверждения сказанного исследуем следующий пример:
#include <stdio.h>
float MyFunc(float a, float b)
{
return a+b;
}
main()
{
printf("%f\n",MyFunc(6.66,7.77));
}
Листинг 95 Пример, демонстрирующий возвращение вещественных значений
Результат его компиляции Microsoft Visual C++ должен выглядеть приблизительно так:
main proc near ; CODE XREF: start+AFp
var_8 = qword ptr -8
push ebp
mov ebp, esp
; Открываем кадр стека
push 40F8A3D7h
push 40D51EB8h
; Передаем функции MyFunc аргументы. Пока еще мы не можем установить их тип
call MyFunc
fstp [esp+8+var_8]
; Стягиваем со стека сопроцессора вещественное значение, занесенное туда
; функцией MyFunc
; Чтобы определить его тип смотрим опкод инструкции, – DD
1C 24
; По таблице 10 определяем – он принадлежит double
; Постой, постой, как double, ведь функция должна возвращать float?!
; Так-то оно так, но здесь имеет место неявное преобразование типов
; при передаче аргумента функции printf, ожидающей double.
; Обратите внимание на то, куда стягивается возращенное функцией значение:
; [esp+8-8] == [esp], т.е. оно помещается на вершину стека, что равносильно
; его заталкиваю командами PUSH.
push offset aF ; "%f\n"
; Передаем функции printf указатель на строку спецификаторов "%f\n"
call _printf
add esp, 0Ch
pop ebp
retn
main endp
MyFunc proc near ; CODE XREF: main+Dp
arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch
push ebp
mov ebp, esp
; Открываем кадр стека
fld [ebp+arg_0]
; Затягиваем на вершину стека сопроцессора аргумент arg_0
; Чтобы определять его тип, смотрим на опкод инструкции FLD
- D9 45 08
; Раз так, это – float
fadd [ebp+arg_4]
; Складываем arg_0, только что затянутый на вершину стека сопроцессора, с arg_4
; помещая результат в тот же стек и…
pop ebp
retn
; ...возвращаемся из функции, оставляя результат сложения двух float-ов
; на вершине стека сопроцессора
; Забавно, если объявить функцию как double
это даст идентичный код!
MyFunc endp
Листинг 96
Замечание о механизме возращения значений в компиляторе WATCOM C: Компилятор WATCOM C предоставляет программисту возможность "вручную" выбирать: в каком именно регистре (регистрах) функция будет возвращать результат своей работы.
Это серьезно осложняет анализ, ведь (как уже было сказано выше) по общепринятым соглашениям функция не должна портить регистры EBX, ESI и EDI (BX, SI и DI в 16-разрядном коде). Увидев операцию чтения регистра ESI, идущую после вызова функции, в первую очередь мы решим, что он был инициализирован еще до ее вызова, - ведь так происходит в подавляющем большинстве случаев. Но только не с WATCOM! Этот товарищ может заставить функцию возвращать значение в любом регистре общего назначения за исключением EBP (BP), заставляя тем самым, исследовать и вызывающую и вызываемую функцию.
тип |
допустимые регистры |
|||||
однобайтовый |
AL |
BL |
CL |
DL |
||
AH |
BH |
CH |
DH |
|||
двухбайтный |
AX |
CX |
BX |
DX |
SI |
DI |
четырехбайтный |
EAX |
EBX |
ECX |
EDX |
ESI |
EDI |
восьмибайтовый |
EDX:EAX |
ECX:EBX |
ECX:EAX |
ECX:ESI |
EDX:EBX |
EBX:EAX |
EDI:EAX |
ECX:EDI |
EDX:ESI |
EDI:EBX |
ESI:EAX |
ECX:EDX |
|
EDX:EDI |
EDI:ESI |
ESI:EBX |
||||
ближний указатель |
EAX |
EBX |
ECX |
EDX |
ESI |
EDI |
дальний указатель |
DX:EAX |
CX:EBX |
CX:EAX |
CX:ESI |
DX:EBX |
DI:EAX |
CX:EDI |
DX:ESI |
DI:EBX |
SI:EAX |
CX:EDX |
DX:EDI |
|
DI:ESI |
SI:EBX |
BX:EAX |
FS:ECX |
FS:EDX |
FS:EDI |
|
FS:ESI |
FS:EBX |
FS:EAX |
GS:ECX |
GS:EDX |
GS:EDI |
|
GS:ESI |
GS:EBX |
GS:EAX |
DS:ECX |
DS:EDX |
DS:EDI |
|
DS:ESI |
DS:EBX |
DS:EAX |
ES:ECX |
ES:EDX |
ES:EDI |
|
ES:ESI |
ES:EBX |
ES:EAX |
||||
float |
8087 |
??? |
??? |
??? |
??? |
??? |
double |
8087 |
EDX:EAX |
ECX:EBX |
ECX:EAX |
ECX:ESI |
EDX:EBX |
EDI:EAX |
ECX:EDI |
EDX:ESI |
EDI:EBX |
ESI:EAX |
ECX:EDX |
|
EDX:EDI |
EDI:ESI |
ESI:EBX |
EBX:EAX |
В частности, через регистр EAX может возвращаться и переменная типа int и структура из четырех переменных типа char (или двух char или одного short int)
Покажем, как это выглядит на практике. Рассмотрим следующий пример:
#include <stdio.h>
int MyFunc(int a, int b)
{
#pragma aux MyFunc value
[ESI]
// Прагма AUX вкупе с ключевым словом "value" позволяет вручную задавать регистр
// через который будет возращен результат вычислений.
// В данном случае его предписывается возвращать через ESI
return a+b;
}
main()
{
printf("%x\n",MyFunc(0x666,0x777));
}
Листинг 97 Пример, демонстрирующий возвращение значения в произвольном регистре
Результат компиляции этого примера должен выглядеть приблизительно так:
main_ proc near ; CODE XREF: __CMain+40p
push 14h
call __CHK
; Проверка стека на переполнение
push edx
push esi
; Сохраняем ESI и EDX
; Это говорит о том, что данный компилятор придерживается соглашения
; о сохранении ESI. Команды сохранения EDI не видно, однако, этот регистр
; не модифицируется данной функцией и, стало быть, сохранять его незачем
mov edx, 777h
mov eax, 666h
; Передаем функции MyFunc два аргумента типа int
call MyFunc
; Вызываем MyFunc. По общепринятым соглашениям EAX, EDX
и под час ECX
; на выходе из функции содержат либо неопределенное,
; либо возращенное функцией значение
; Остальные регистры в общем случае должны быть сохранены
push esi
; Передаем регистр ESI функции printf. Мы не можем с уверенностью сказать:
; содержит ли он значение, возращенное функцией, или был инициализирован еще
; до ее вызова
push offset asc_420004 ; "%x\n"
call printf_
add esp, 8
pop esi
pop edx
retn
main_ endp
MyFunc proc near ; CODE XREF: main_+16p
push 4
call __CHK
; Проверка стека на переполнение
lea esi, [eax+edx]
; А вот уже знакомый нам хитрый трюк со сложением. На первый взгляд в ESI
; загружается указатель на EAX+EBX, - фактически так оно и происходит, но ведь
; указатель на EAX+EBX
в то же время является и их суммой, т.е. эта команда
; эквивалентна ADD EAX,EDX/MOV ESI,EAX.
; Это и есть возвращаемое функцией значение, - ведь ESI
был модифицирован, и
; не сохранен!
; Таким образом, вызывающая функция командой PUSH ESI
передает printf
; результат сложения 0x666 и 0x777, что и требовалось выяснить
retn
MyFunc endp
Листинг 98
Возращение значений in-line assembler функциями. Создать ассемблерной функции волен возвращать значения в любых регистрах, каких ему будет угодно, однако, поскольку вызывающие функции языка высокого уровня ожидают увидеть результат вычислений в строго определенных регистрах, писаные соглашения приходится соблюдать. Другое дело, "внутренние" ассемблерные функции – они могут вообще не придерживаться никаких правил, что и демонстрирует следующий пример:
#include <stdio.h>
// naked-функция, не имеющая прототипа, - обо всем должен заботится сам программист!
__declspec( naked ) int MyFunc()
{
__asm{
lea ebp, [eax+ecx] ; возвращаем в EBP сумму EAX и
ECX
; Такой трюк допустим лишь при условии, что эта
; функция будет вызываться из ассемблерной функции,
; знающей через какие регистры передаются аргументы
; и через какие – возвращается результат вычислений
ret
}
}
main()
{
int a=0x666;
int b=0x777;
int c;
__asm{
push ebp
push edi
mov eax,[a];
mov ecx,[b];
lea edi,c
// Вызываем функцию MyFunc из ассемблерной функции, передавая ей аргументы
// через те регистры, которые она "хочет"
call MyFunc;
// Принимаем возращенное в EBP значение и сохраняем его в локальной переменной
mov [edi],ebp
pop edi
pop ebp
}
printf("%x\n",c);
}
Листинг 99 Пример, демонстрирующий возвращение значения встроенными ассемблерными функциями
Результат компиляции Microsoft Visual C++ ( а другие компиляторами этот пример откомпилировать и вовсе не удастся, ибо они не поддерживают ключевое слово naked) должен выглядеть так:
MyFunc proc near ; CODE XREF: main+25p
lea ebp, [eax+ecx]
; Принимаем аргументы через регистры EAX
и ECX, возвращая через регистр EBP
; их сумму
; Кончено, пример несколько надуман, зато нагляден!
retn
MyFunc endp
main proc near ; CODE XREF: start+AFp
var_C = dword ptr -0Ch
var_8 = dword ptr -8
var_4 = dword ptr -4
push ebp
mov ebp, esp
; Открываем кадр стека
sub esp, 0Ch
; Резервируем место для локальных переменных
push ebx
push esi
push edi
; Сохраняем изменяемые регистры
mov [ebp+var_4], 666h
mov [ebp+var_8], 777h
; Инициализируем переменные var_4 и var_8
push ebp
push edi
; Сохраняем регистры или передаем их функции? Пока нельзя ответить
; однозначно
mov eax, [ebp+var_4]
mov ecx, [ebp+var_8]
; Загружаем в EAX значение переменной var_4, а в ECX – var_8
lea edi, [ebp+var_C]
; Загружаем в EDI указатель на переменную var_C
call MyFunc
; Вызываем MyFunc – из анализа вызывающей функции не очень понятно как
; ей передаются аргументы. Может через стек, а может и через регистры.
; Только исследование кода MyFunc позволяет установить, что верным оказывается
; последнее предположение. Да, - аргументы передаются через регистры!
mov [edi], ebp
; Что бы это значило? Анализ одной лишь вызывающей функции не может дать
; исчерпывающего ответа и только анализ вызываемой подсказывает, что
; через EBP она возвращает результат вычислений.
pop edi
pop ebp
; Восстанавливаем измененные регистры
; Это говорит о том, что выше эти регистры действительно сохранялись в стеке
; а не передавались функции в качестве аргументов
mov eax, [ebp+var_C]
; Загружаем в EAX содержимое переменной var_C
push eax
push offset unk_406030
call _printf
add esp, 8
; Вызываем printf
pop edi
pop esi
pop ebx
; Восстанавливаем регистры
mov esp, ebp
pop ebp
; Закрываем кадр стека
retn
main endp
Листинг 100
:: возврат значений через аргументы, переданные по ссылке. Идентификация значений, возращенных через аргументы, переданные по ссылке, тесно переплетается с идентификацией самих аргументов (см. главу "Идентификация аргументов функций"). Выделив среди аргументов, переданных функции, указатели – заносим их в список кандидатов на возвращаемые значения.
Теперь поищем: нет ли среди них указателей на неинициализированные переменные, – очевидно, их инициализирует сама вызываемая функция. Однако не стоит вычеркивать указатели на инициализированные переменные (особенно равные нулю) – они так же могут возвращать значения. Уточнить ситуацию позволит анализ вызываемой функции – нас будут интересовать все операции модификации переменных, переданных по ссылке. Только не спутайте это с модификацией переменных, переданных по значению. Последние автоматически умирают в момент завершения функции (точнее – вычистки аргументов из стека). Фактически – это локальные переменные функции и она безболезненно может изменять их как ей вздумается.
#include <stdio.h>
#include <string.h>
// Функция инвертирования строки src
с ее записью в строку dst
void Reverse(char *dst, const char *src)
{
strcpy(dst,src);
_strrev( dst);
}
// Функция инвертирования строки s
// (результат записывается в саму же строку s)
void Reverse(char *s)
{
_strrev( s );
}
// Функция возращает сумму двух аргументов
int sum(int a,int b)
{
// Мы можем безболезненно модифицировать аргументы, переданные по значению,
// обращаясь с ними как с обычными локальными переменными
a+=b;
return a;
}
main()
{
char s0[]="Hello,Sailor!";
char s1[100];
// Инвертируем строку s0, записывая ее в s1
Reverse(&s1[0],&s0[0]);
printf("%s\n",&s1[0]);
// Инвертируем строку s1, перезаписывая ее
Reverse(&s1[0]);
printf("%s\n",&s1[0]);
// Выводим сумму двух числел
printf("%x\n",sum(0x666,0x777));
}
Листинг 101 Пример, демонстрирующий возврат значений через переменные, переданные по ссылке
Результат компиляции этого примера должен выглядеть приблизительно так:
main proc near ; CODE XREF: start+AFp
var_74 = byte ptr -74h
var_10 = dword ptr -10h
var_C = dword ptr -0Ch
var_8 = dword ptr -8
var_4 = word ptr -4
push ebp
mov ebp, esp
; Открываем кадр стека
sub esp, 74h
; Резервируем память для локальных переменных
mov eax, dword ptr aHelloSailor ; "Hello,Sailor!"
; Заносим в регистр EAX четыре первых байта строки "Hello, Sailor!"
; Вероятно, компилятор копирует строку в локальную переменную таким
; хитро-тигриным способом
mov [ebp+var_10], eax
mov ecx, dword ptr aHelloSailor+4
mov [ebp+var_C], ecx
mov edx, dword ptr aHelloSailor+8
mov [ebp+var_8], edx
mov ax, word ptr aHelloSailor+0Ch
mov [ebp+var_4], ax
; Точно, строка "Hello,Sailor!" копируется в локальную переменную var_10
; типа char s[0x10]
; Число 0x10 было получено подсчетом количества копируемых байт –
; четыре итерации по четыре байт в каждой – итого, шестнадцать!
lea ecx, [ebp+var_10]
; Загрузка в ECX указателя на локальную переменную var_10,
; содержащую строку "Hello, World!"
push ecx ; int
; Передача функции Reverse_1 указателя на строку "Hello, World!"
; Смотрите, - IDA неверно определила тип, - ну какой же это int,
; когда это char *
; Однако, вспомнив, как копировалась строка, мы поймем, почему ошиблась IDA
lea edx, [ebp+var_74]
; Загрузка в ECX указателя на неинициализированную локальную переменную var_74
push edx ; char *
; Передача функции Reverse_1 указателя на неинициализированную переменную
; типа char s1[100]
; Число 100 было получено вычитанием смещения переменной var_74 от смещения
; следующей за ней переменной, var_10, содержащей строку "Hello, World!"
; 0x74 – 0x10 = 0x64 или в десятичном представлении - 100
; Факт передачи указателя на неинициализированную переменную говорит о том,
; что, скорее всего, функция возвратит через нее некоторое значение –
; возьмите это себе на заметку.
call Reverse_1
add esp, 8
; Вызов функции Reverse_1
lea eax, [ebp+var_74]
; Загрузка в EAX указателя на переменную var_74
push eax
; Передача функции printf указателя на переменную var_74, - поскольку,
; вызывающая функция не инициализировала эту переменную, можно предположить,
; что вызываемая возвратила в через нее свое значение
; Возможно, функция Reverse_1 модифицировала и переменную var_10, однако,
; об этом нельзя сказать с определенностью до тех пор пока не будет
; изучен ее код
push offset unk_406040
call _printf
add esp, 8
; Вызов функции printf для вывода строки
lea ecx, [ebp+var_74]
; Загрузка в ECX указателя на переменную var_74, по-видимому,
; содержащую возращенное функцией Reverse_1 значение
push ecx ; char *
; Передача функции Reverse_2 указателя на переменную var_74
; Функция Reverse_2 так же может возвратить в переменной var_74
; свое значение, или некоторым образом, модифицировать ее
; Однако может ведь и не возвратить!
; Уточнит ситуацию позволяет анализ кода вызываемой функции.
call Reverse_2
add esp, 4
; Вызов функции Reverse_2
lea edx, [ebp+var_74]
; Загрузка в EDX указателя на переменную var_74
push edx
; Передача функции printf указателя на переменную var_74
; Поскольку, значение, возвращенное функцией через регистры EDX:EAX
; не используется, можно предположить, что она возвращает его не через
; регистры, а в переменной var_74. Но это не более чем предположение
push offset unk_406044
call _printf
add esp, 8
; Вызов
функции printf
push 777h
; Передача функции Sum значения 0x777 типа int
push 666h
; Передача функции Sum значения 0x666 типа int
call Sum
add esp, 8
; Вызов
функции Sum
push eax
; В регистре EAX содержится возращенное функцией Sum
значение
; Передаем его функции printf в качестве аргумента
push offset unk_406048
call _printf
add esp, 8
; Вызов
функции printf
mov esp, ebp
pop ebp
; Закрытие кадра стека
retn
main endp
; int __cdecl Reverse_1(char *,int)
; Обратите внимание, что прототип функции определен неправльно!
; На самом деле, как мы уже установили из анализа вызывающей функции, он выглядит так:
; Reverse(char *dst, char *src)
; Название аргументов дано на основании того, что левый аргумент – указатель
; на неинициализированный буфер и, скорее всего, он выступает в роли приемника,
; соответственно, правый аргумент в таком случае – источник.
Reverse_1 proc near ; CODE XREF: main+32p
arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch
push ebp
mov ebp, esp
; Открываем кадр стека
mov eax, [ebp+arg_4]
; Загружаем в EAX значение аргумента arg_4
push eax
; Передаем arg_4 функции strcpy
mov ecx, [ebp+arg_0]
; Загружаем в ECX значение аргумента arg_0
push ecx
; Передаем arg_0 функции strcpy
call strcpy
add esp, 8
; Копируем содержимое строки, на которую указывает arg_4, в буфер
; на который указывает arg_0
mov edx, [ebp+arg_0]
; Загружаем в EDX содержимое аргумента arg_0, указывающего на буфер,
; содержащий только что скопированную строку
push edx ; char *
; Передаем функции __strrev arg_0
call __strrev
add esp, 4
; функция strrev инвертирует строку, на которую указывает arg_0
; следовательно, функция Reverse_1 действительно возвращает свое значение
; через аргумент arg_0, переданный по ссылке.
; Напротив, строка на которую указывает arg_4, остается неизменной, поэтому,
; прототип функции Reverse_1 выглядит так:
; void Reverse_1(char *dst, const char *src);
; Никогда не пренебрегайте квалификатором const, т.к. он ясно указывает на
; то, что переменная, на которую указывает данный указатель используется
; лишь на чтение. Эта информация значительно облегчит работу с
; дизассемблерным листингом, особенно когда вы вернетесь к нему спустя
; некоторое время, основательно подзабыв алгоритм исследуемой программы
pop ebp
; Закрываем кадр стека
retn
Reverse_1 endp
; int __cdecl Reverse_2(char *)
; А вот на этот раз прототип функции определен верно!
; (Ну, за исключением того, что возвращаемый тип void, а не int)
Reverse_2 proc near ; CODE XREF: main+4Fp
arg_0 = dword ptr 8
push ebp
mov ebp, esp
; Открываем кадр стека
mov eax, [ebp+arg_0]
; Загружаем в EAX содержимое аргумента arg_0
push eax ; char *
; Передаем arg_0 функции strrev
call __strrev
add esp, 4
; Инвертируем строку, записывая результат на то же самое место
; Следовательно, функция Reverse_2 действительно возвращает значение
; через arg_0, и наше предварительное предположение оказалось правильным!
pop ebp
; Закрываем кадр стека
retn
; Прототип функции Reverse_2 по данным последних исследований выглядит так:
; void Reverse_2(char *s)
Reverse_2 endp
Sum proc near ; CODE XREF: main+72p
arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch
push ebp
mov ebp, esp
; Открываем кадр стека
mov eax, [ebp+arg_0]
; Загружаем в EAX значение аргумента arg_0
add eax, [ebp+arg_4]
; Складываем arg_0 с arg_4, записывая результат в EAX
mov [ebp+arg_0], eax
; Копируем результат сложения arg_0 и arg_4 обратно в arg_0
; Неопытные хакеры могут принять это за возращение значения через аргумент,
; однако, это предположение неверно.
; Дело в том, что аргументы, переданные функции, после ее завершения
; выталкиваются из стека и тут же "погибают". Не забывайте:
; Аргументы, переданные по значению, ведут себя так же, как и локальные
; переменные.
mov eax, [ebp+arg_0]
; А вот сейчас в регистр EAX действительно копируется возвращаемое значение
; Следовательно, прототип функции выглядит так:
; int Sum(int a, int b);
pop ebp
; Закрываем кадр стека
retn
Sum endp
Листинг 102
::возврат значений через динамическую память (кучу). Возращение значения через аргумент, переданный по ссылке, не очень-то украшает прототип функции. Он вмиг перестает быть интуитивно – понятным и требует развернутых пояснений, что с этим аргументом ничего передать не надо, напротив – будьте готовы отсюда принять. Но хвост с ней, с наглядностью и эстетикой (кто говорил, что был программистом легко?), существует и более серьезная проблема – далеко не во всех случаях размер возвращаемых данных известен наперед, - частенько он выясняется лишь в процессе работы вызываемой функции. Выделить буфер "с запасом"? Некрасиво и неэкономично – даже в системах с виртуальной памятью ее объем не безграничен.
Вот если бы вызываемая функция самостоятельно выделяла для себя память, как раз по потребности, а потом возвращала на нее указатель. Сказано – сделано! Ошибка многих начинающих программистов как раз и заключается в попытке вернуть указать на локальные переменные, - увы, они "умирают" вместе с завершением функции и указатель указывает в "космос". Правильное решение заключается в выделении памяти из кучи (динамической памяти), скажем, вызовом malloc
или new, - эта память "живет" вплоть до ее принудительного освобождения функцией free или delete соответственно.
Для анализа программы механизм выделения памяти не существенен, - основную роль играет тип возвращаемого значения. Отличить указатель от остальных типов достаточно легко – только указатель может использоваться в качестве подадресного выражения.
Разберем следующий пример:
#include <stdio.h>
#include <malloc.h>
#include <stdlib.h>
char* MyFunc(int a)
{
char *x;
x = (char *) malloc(100);
_ltoa(a,x,16);
return x;
}
main()
{
char *x;
x=MyFunc(0x666);
printf("0x%s\n",x);
free(x);
}
Листинг 103 Пример, демонстрирующий возвращения значения через кучу
main proc near ; CODE XREF: start+AFp
var_4 = dword ptr -4
push ebp
mov ebp, esp
; Открываем кадр стека
push ecx
; Выделяем память под локальную переменную размером 4 байта (см. var_4)
push 666h
; Передаем функции MyFunc значение 666 типа int
call MyFunc
add esp, 4
; Вызываем MyFunc – обратите внимание, что функции ни один аргумент
; не был передан по ссылке!
mov [ebp+var_4], eax
; Копирование содержимого возращеного функцией значение в переменную var_4
mov eax, [ebp+var_4]
; Супер! Загружаем в EAX возращенное функцией значение обратно!
push eax
; Передаем возращенное функцией значение функции printf
; Судя по спецификатору, тип возвращенного значения – char
*
; Поскольку, функции MyFunc ни один из аргументов не передавался по ссылке,
; она явно выделила память самостоятельно и записала туда полученную строку.
; А если бы функции MyFunc передавались один или более аргументов по ссылке?
; Тогда – не было бы никакой уверенности, что она не возвратила один из таких
; аргументов обратно, предварительно его модифицировав.
; Впрочем, модификация необязательно, - скажем передаем функции указатели на
; две строки и она возвращает указатель на ту из них, которая, скажем, короче
; или содержит больше гласных букв.
; Поэтому, не всякое возращение указателя свидетельствует о модификации
push offset a0xS ; "0x%s\n"
call _printf
add esp, 8
; Вызов printf – вывод на экран строки, возращенной функцией MyFunc
mov ecx, [ebp+var_4]
; В ECX загружаем значение указателя, возращенного функцией MyFunc
push ecx ; void *
; Передаем указатель, возращенный функцией MyFunc, функции free
; Значит, MyFunc действительно самостоятельно выделяла память вызовом malloc
call _free
add esp, 4
; Освобождаем память, выделенную MyFunc
для возращения значения
mov esp, ebp
pop ebp
; Закрываем кадр стека
retn
; Таким образом, протип MyFunc выглядит так:
; char* MyFunc(int a)
main endp
MyFunc proc near ; CODE XREF: main+9p
var_4 = dword ptr -4
arg_0 = dword ptr 8
push ebp
mov ebp, esp
; Открываем кадр стека
push ecx
; Резервируем память под локальные переменные
push 64h ; size_t
call _malloc
add esp, 4
; Выделяем 0x64 байта памяти из кучи либо для собственных нужд функции, либо
; для возращения результата. Поскольку из анализа кода вызывающей функции нам
; уже известно, что MyFunc возвращает указатель, очень вероятно, что вызов
; malloc
выделяет память как раз для этой цели.
; Впрочем, вызовов malloc может быть и несколько, а указатель возвращается
; только на один из них
mov [ebp+var_4], eax
; Запоминаем указатель в локальной переменной var_4
push 10h ; int
; Передаем функции __ltoa аргумент 0x10 (крайний справа) – требуемая система
; исчисления для перевода числа
mov eax, [ebp+var_4]
; Загружаем в EAX содержимое указателя на выделенную из кучи память
push eax ; char *
; Передаем функции ltoa указатель на буфер для возращения результата
mov ecx, [ebp+arg_0]
; Загружаем в EAX значение аргумента arg_0
push ecx ; __int32
; Передаем функции ltoa аргумент arg_0 – значение типа int
call __ltoa
add esp, 0Ch
; Функция ltoa переводит число в строку и записывает ее в буфер по переданному
; указателю
mov eax, [ebp+var_4]
; Возвращаем указатель на регион памяти, выделенный самой MyFunc
из кучи, и
; содержащий результат работы ltoa
mov esp, ebp
pop ebp
; Закрываем кадр стека
retn
MyFunc endp
Листинг 104
::Возврат значений через глобальные переменные. "Мыльную оперу" перепевов с возращением указателей продолжает серия "Возращение значений через глобальные переменные (и/или указателя на глобальные переменные)". Вообще-то глобальные переменные – плохой тон и такой стиль программирования характерен в основном для программистов с мышлением, необратимо искалеченным идеологий Бацика с его недоразвитым механизмом вызова подпрограмм.
Подробнее об идентификации глобальных переменных рассказывается в одноименном разделе данной главы, здесь же мы сосредоточим наши усилия именно на изучении механизмов возвращения значений через глобальные переменные.
Фактически, все глобальные переменные можно рассматривать как неявные аргументы каждой вызываемой функции и в то же время – как возвращаемые значения. Любая функция может произвольным образом читать и модифицировать их, причем, ни "передача", ни "возращение" глобальных переменных не "видны" анализом кода вызывающей функции, - для этого необходимо тщательно исследовать вызываемую – манипулирует ли она с глобальными переменными и если да, то с какими. Можно зайти и с обратной стороны, - просмотром сегмента данных найти все глобальные переменные, определить их смещение и, пройдясь контекстным поиском по всему файлу, выявить функции, которые на них ссылаются (подробнее см. "Идентификация глобальных переменных :: перекрестные ссылки").
Помимо глобальных, еще существуют и статические переменные.
Они так же располагаются в сегменте данных, но непосредственно доступны только объявившей их функции. Точнее, ограничение наложено не на сами переменных, а на их имена. Чтобы предоставить другим функциям доступ к собственным статическим переменным достаточно передать указатель. К счастью, этот трюк не создает хакерам никаких проблем (хоть некоторые злопыхатели и объявляют его "прорехой в защите"), - отсутствие непосредственного доступа к "чужим" статическим переменным и необходимость взаимодействовать с функцией-владелицей через предсказуемый интерфейс (возращенный указатель), позволяет разбить программу на отдельные независимые модули, каждый из которых может быть проанализирован отдельно. Чтобы не быть голословным, продемонстрируем это на следующем примере:
#include <stdio.h>
char* MyFunc(int a)
{
static char x[7][16]={"Понедельник", "Вторник", "Среда", "Четверг", "Пятница",
"Суббота", "Воскресенье"};
return
&x[a-1][0];
}
main()
{
printf("%s\n",MyFunc(6));
}
Листинг 105 Пример, демонстрирующий возврат значения через глобальные статические переменные
Результат компиляции компилятором Microsoft Visual C++ 6.0 c настройками по умолчанию выглядит так:
MyFunc proc near ; CODE XREF: main+5p
arg_0 = dword ptr 8
push ebp
mov ebp, esp
; Открываем кадр стека
mov eax, [ebp+arg_0]
; Загружаем в EAX значение аргумента arg_0
sub eax, 1
; Уменьшаем EAX на единицу. Это косвенно свидетельствует о том, что arg_0 –
; не указатель, хотя математические операции над указателями в Си разрешены
; и активно используются
shl eax, 4
; Умножаем (arg_0 –1) на 16. Битовый сдвиг вправо на четыре равносилен 24 == 16
add eax, offset aPonedelNik ; "Понедельник"
; Складываем полученное значение с базовым указателем на таблицу строк,
; расположенных в сегменте данных. А в сегменте данных находятся либо
; статические, либо глобальные переменные.
; Поскольку, значение аргумента arg_0 умножаемся на некоторую величину
; (в данном случае на 16), можно предположить, что мы имеем дело с
; двухмерным массивом. В данном случае – массивом строк фиксированной длины.
; Таким образом, в EAX содержится указатель на строку с индексом arg_0 – 1
; Или, другими словами, – с индексом arg_0, считая с одного.
pop ebp
; Закрываем кадр стека, возвращая в регистре EAX
указатель на соответствующий
; элемент массива.
; Как мы видим, нет никакой принципиальной разницы между возвращением указателя
; на регион памяти, выделенный из кучи, с возращением указателя на статические
; переменные, расположенные в сегменте данных.
retn
MyFunc endp
main proc near ; CODE XREF: start+AFp
push ebp
mov ebp, esp
; Открываем кадр стека
push 6
; Передаем функции MyFunc значение типа int
; (шестой день – суббота)
call MyFunc
add esp, 4
; Вызываем MyFunc
push eax
; Передаем возращенное MyFunc значение функции printf
; Судя по строке спецификаторов, это – указатель на строку
push offset aS ; "%s\n"
call _printf
add esp, 8
pop ebp
; Закрываем кадр стека
retn
main endp
aPonedelNik db 'Понедельник',0,0,0,0,0 ; DATA XREF: MyFunc+Co
; Наличие перекрестной ссылки только на одну функцию, подсказывает, что тип
; этой переменной – static
aVtornik db 'Вторник',0,0,0,0,0,0,0,0,0
aSreda db 'Среда',0,0,0,0,0,0,0,0,0,0,0
aCetverg db 'Четверг',0,0,0,0,0,0,0,0,0
aPqtnica db 'Пятница',0,0,0,0,0,0,0,0,0
aSubbota db 'Суббота',0,0,0,0,0,0,0,0,0
aVoskresenE db 'Воскресенье',0,0,0,0,0
aS db '%s',0Ah,0 ; DATA XREF: main+Eo
Листинг 106
А теперь сравним предыдущий пример с настоящими глобальными переменными:
#include <stdio.h>
int a;
int b;
int c;
MyFunc()
{
c=a+b;
}
main()
{
a=0x666;
b=0x777;
MyFunc();
printf("%x\n",c);
}
Листинг 107 Пример, демонстрирующий возврат значения через глобальные переменные
main proc near ; CODE XREF: start+AFp
push ebp
mov ebp, esp
; Открываем кадр стека
call MyFunc
; Вызываем MyFunc. Обратите внимание – функции явно ничего не передается
; и ничего не возвращается. Потому, ее прототип выглядит
; (по предварительным заключением) так:
; void MyFunc()
call Sum
; Вызываем функцию Sum, явно не принимающую и не возвращающую никаких значений
; Ее предварительный прототип выглядит так: void Sum()
mov eax, c
; Загружаем в EAX значение глобальной переменной 'c'
; Смотрим в сегмент данных, - так-так, вот она переменная 'c', равная нулю
; Однако этому значению нельзя доверять – быть может, ее уже успели изменить
; ранее вызванные функции.
; Предположение о модификации подкрепляется парой перекрестных ссылок,
; одна из которых указывает на функцию Sum. Суффикс 'w', завершающий
; перекрестную ссылку, говорит о том, что Sum
записывает в переменную 'c'
; какое-то значение. Какое? Это можно узнать из анализа кода самой Sum.
push eax
; Передаем значение, возращенное функцией Sum, через глобальную переменную 'c'
; функции printf.
; Судя по строке спецификаторов, аргумент имеет тип int
push offset asc_406030 ; "%x\n"
call _printf
add esp, 8
; Выводим возвращенный Sum результат на терминал
pop ebp
; Закрываем кадр стека
retn
main endp
Sum proc near ; CODE XREF: main+8p
; Функция Sum не принимает через стек никаких аргументов!
push ebp
mov ebp, esp
; Открываем кадр стека
mov eax, a
; Загружаем в EAX значение глобальной переменной 'a'
; Находим 'a' в сегменте данных, - ага, есть перекрестная ссылка на MyFunc,
; которая что-то записывает в переменную 'a'.
; Поскольку, вызов MyFunc предшествовал вызову Sum, можно сказать, что MyFunc
; возвратила в 'a' некоторое значение
add eax, b
; Складываем EAX (хранящий значение глобальной переменной 'a') с содержимым
; глобальной переменной 'b'
; (все, сказанное выше относительно 'a', справедливо и для 'b')
mov c, eax
; Помещаем результат сложения a+b в переменную 'c'
; Как мы уже знаем (из анализа функции main), функция Sum
в переменной 'c'
; возвращает результат своих вычислений. Теперь мы узнали – каких именно.
pop ebp
; Закрываем кадр стека
retn
Sum endp
MyFunc proc near ; CODE XREF: main+3p
push ebp
mov ebp, esp
; Открываем кадр стека
mov a, 666h
; Присваиваем глобальной переменной 'a' значение 0x666
mov b, 777h
; Присваиваем глобальной переменной 'b' значение 0x777
; Как мы выяснили из анализа двух предыдущих функций – функция MyFunc
; возвращает в переменных а и b
результат своих вычислений
; Теперь мы определили какой именно, а вместе с тем смогли разобраться
; как три функции взаимодействуют друг с другом.
; main() вызывает MyFunc(), та инициализирует глобальные переменные 'a' и 'b',
; затем main() вызывает Sum(), помещающая сумму 'a' и 'b' в глобальную 'c',
; наконец, main() берет эту 'c' и передает ее через стек printf
; для вывода на экран.
; Уф! Как все запутано, а ведь это простейший пример из трех функций!
; Что же говорить о реальной программе, в которой этих функций тысячи, причем
; порядок вызова и поведение каждой из них далеко не так очевидны!
pop ebp
retn
MyFunc endp
a dd 0 ; DATA XREF: MyFunc+3w Sum+3r
b dd 0 ; DATA XREF: MyFunc+Dw Sum+8r
c dd 0 ; DATA XREF: Sum+Ew main+Dr
; Судя по перекрестным ссылкам – все три переменные глобальные, т.к. к
; каждой из них имеет непосредственный доступ более одной функции.
Листинг 108
::возврат значений через флаги процессора. Для большинства ассемблерных функций характерно использование регистра флагов процессора для возвращения результата успешности выполнения функции. По общепринятому соглашению установленный флаг переноса (CF) свидетельствует об ошибке, второе место по популярности занимает флаг нуля (ZF), а остальные флаги практически вообще не используются.
Установка флага переноса осуществляется командой STC
или любой математической операцией, приводящей к образованию переноса (например, CMP a, b
где a < b), а сброс – командой CLC или соответствующей математической операцией.
Проверка флага переноса обычно осуществляется условными переходами JC xxx
и JNC xxx, соответственно исполняющихся при наличии и отсутствии переноса. Условные переходы JB xxx и JNB xxx – их синтаксические синонимы, дающие при ассемблировании идентичный код.
#include <stdio.h>
// Функция сообщения об ошибке деления
Err(){ printf("-ERR: DIV by Zero\n");}
// Вывод результата деления на экран
Ok(int a){printf("%x\n",a);}
// Ассемблерная функция деления.
// Делит EAX на EBX, возвращая частное в EAX, а остаток – в EDX
// При попытке деления на ноль устанавливает флаг переноса
__declspec(naked) MyFunc()
{
__asm{
xor edx,edx ; Обнуляем EDX, т.е. команда div ожидает
делимого в EDX:EAX
test ebx,ebx ; Проверка делителя на равенство нулю
jz
_err ; Если делитель равен нулю, перейти к ветке _err
div ebx ; Делим EDX:EAX на EBX (EBX
заведомо не равен нулю)
ret ; Выход в с возвратом частного в EAX
и остатка в EDX
_err: ; // Эта ветка получает управление при попытке деления на ноль
stc ; устанавливаем флаг переноса, сигнализируя об ошибке и...
ret ; ...выходим
}
}
// Обертка для MyFunc
// Принимаем два аргумента через стек – делимое и делитель
// и выводим результат деления (или сообщение об ошибке) на экран
__declspec(naked) MyFunc_2(int a, int b)
{
__asm{
mov eax,[esp+4] ; Загружаем в EAX содержимое
аргумента
'a'
mov ebx,[esp+8] ; Загружаем в EDX содержимое аргумента 'b'
call MyFunc ; Пытаемся делить a/b
jnc
_ok ; Если флаг переноса сброшен выводим результат, иначе…
call Err ; …сообщение об ошибке
ret ; Возвращаемся
_ok:
push eax ; Передаем результат деления и…
call Ok ; …выводим его на экран
add esp,4 ; Вычищаем за собой стек
ret ; Возвращаемся
}
}
main(){MyFunc_2(4,0);}
Листинг 109
Использование WriteProcessMemory
Если требуется изменить некоторое количество байт своего (или чужого) процесса, самый простой способ сделать это – вызвать функцию WriteProcessMemory. Она позволяет модифицировать существующие страницы памяти, чей флаг супервизора не взведен, т.е., все страницы, доступные из кольца 3, в котором выполняются прикладные приложения. Совершенно бесполезно с помощью WriteProcessMemory пытаться изменить критические структуры данных операционной системы (например, page directory или page table) – они доступны лишь из нулевого кольца. Поэтому, эта функция не представляет никакой угрозы для безопасности системы и успешно вызывается независимо от уровня привилегий пользователя (автору этих строк доводилось слышать утверждение, дескать, WriteProcessMemory требует прав отладки приложений, но это не так).
Процесс, в память которого происходит запись, должен быть предварительно открыт функцией OpenProcess с атрибутами доступа "PROCESS_VM_OPERATION" и "PROCESS_VM_WRITE". Часто программисты, ленивые от природы, идут более коротким путем, устанавливая все атрибуты – "PROCESS_ALL_ACCESS". И это вполне законно, хотя справедливо считается дурным стилем программирования.
Простейший пример использования функции WriteProcessMemory для создания самомодифицирующегося кода, приведен в листинге 1. Она заменяет инструкцию бесконечного цикла "JMP short $-2" на условный переход "JZ $-2", который продолжает нормальное выполнение программы. Неплохой способ затруднить взломщику изучение программы, не правда ли? (Особенно, если вызов WriteMe расположен не возле изменяемого кода, а помещен в отдельный поток; будет еще лучше, если модифицируемый код вполне естественен сам по себе и внешне не вызывает никаких подозрений – в этом случае хакер может долго блуждать в той ветке кода, которая при выполнении программы вообще не получает управления).
int WriteMe(void *addr, int wb)
{
HANDLE h=OpenProcess(PROCESS_VM_OPERATION|PROCESS_VM_WRITE,
true,GetCurrentProcessId());
return WriteProcessMemory(h, addr,&wb,1,NULL);
}
int main(int argc, char* argv[])
{
_asm {
push 0x74 ; JMP --> > JZ
push offset Here
call WriteMe
add esp,8
Here: JMP short here
}
printf("#JMP SHORT $-2 was changed to JZ $-2\n");
return
0;
}
Листинг 227 Пример, иллюстрирующий использования функции WriteProcessMemory для создания самомодифицирующегося кода
Поскольку Windows для экономии оперативной памяти разделяет код между процессами, возникает вопрос: а что произойдет, если запустить вторую копию самомодифицирующейся программы? Создаст ли операционная система новые страницы или отошлет приложение к уже модифицируемому коду? В документации на Windows NT и Windows 2000 сказано, что они поддерживают копирование при записи (copy on write), т.е. автоматически дублируют страницы кода при попытке их модификации. Напротив, Windows 95 и Windows 98 не поддерживают такую возможность. Означает ли это то, что все копии самомодифицирующегося приложения будут вынуждены работать с одними и теми же страницами кода, что неизбежно приведет к конфликтам и сбоям?
Нет, и вот почему – несмотря на то, что копирование при записи в Windows 95 и Windows 98 не реализовано, эту заботу берет на себя сама функция WriteProcessMemory, создавая копии всех модифицируемых страниц, распределенных между процессами. Благодаря этому, самомодифицирующийся код одинаково хорошо работает как под Windows 95\Windows 98\Windows Me, так и под Windows NT\Windows 2000. Однако следует учитывать, что все копии приложения, модифицируемые любым иным путем (например, командой mov нулевого кольца) будучи запущенными под Windows 95\Windows 98 будут разделять одни и те же страницы кода со всеми вытекающими отсюда последствиями.
Теперь об ограничениях. Во-первых, использовать WriteProcessMemory разумно только в компиляторах, компилирующих в память или распаковщиках исполняемых файлов, а в защитах – несколько наивно.Мало-мальски опытный взломщик быстро обнаружит подвох, обнаружив эту функцию в таблице импорта. Затем он установит точку останова на вызов WriteProcessMemory, и будет контролировать каждую операцию записи в память. А это никак не входит в планы разработчика защиты!
Другое ограничение WriteProcessMemory заключается в невозможности создания новых страниц – ей доступны лишь уже существующие страницы. А как быть в том случае, если требуется выделить некоторое количество памяти, например, для кода, динамически генерируемого "на лету"? Вызов функций, управления кучей, таких как malloc, не поможет, поскольку в куче выполнение кода запрещено. И вот тогда-то на помощь приходит возможность выполнения кода в стеке…
Как хакеры ломают программы
Вскрыть защитный механизм взломщику в общем случае не проблема. Куда сложнее найти его во многих мегабайтах кода ломаемого приложения. Сегодня мало кто использует для этой цели автоматическую трассировку – на смену ей пришли аппаратные контрольные точки.
Например, пусть некая защита запрашивает пароль и затем каким-то образом удостоверяется в его подлинности (например, сравнивает с оригиналом), и в зависимости от результатов проверки передает управление соответствующей ветке программы. Вскрыть такую защиту взломщик может, даже не вникая в алгоритм аутентификации! Он просто введет первый пришедший ему на ум пароль (не обязательно совпадающий с правильным), найдет его в памяти, установит контрольную точку на первый символ строки своего пароля, дождется "всплытия" отладчика, отследившего обращение к паролю, выйдет из сравнивающей процедуры и "подправит" условие перехода так, чтобы управление получала всегда получала нужная ветвь программы.
Время снятия подобных защит измеряется секундами
(!) и обычно такие программы ломаются раньше, чем успевают дойти до легального потребителя. К счастью, этому можно противостоять!
Как обнаружить отладку средствами Windows
В своей книге "Секреты системного программирования в Windows 95" Мэт Питтрек описал структуру информационного блока цепочки (Thread Information Block), рассказав о назначении многих недокументированных полей. Особый интерес для данной статьи представляет двойное слово, лежащие по смещению 0x20 от начала структуры TIB, содержащие контекст отладчика (если данный процесс отлаживается) или ноль в противном случае. Информационный блок цепочки доступен через селектор, загруженный в регистр FS, и без проблем может читаться прикладным кодом.
Если двойное слово FS:[0x20] не равно нулю – процесс находится под отладкой. Это настолько заманчиво, что некоторые программисты включили такую проверку в свои защиты, не обратив внимания на ее "недокументированность". В результате, их программы не смогли исполняться под Windows NT, поскольку, она хранит в этом поле не контекст отладчика, а идентификатор процесса, который никогда не бывает равным нулю, отчего защита ошибочно полагает, что находится под отладкой.
Это обстоятельство было подробно описано самим же Мэтом Питтреком в майском номере журнала "Microsoft Systems Journal" за 1996 год, где в статье "Under The Hood" он привел следующую структуру:
union // 1Ch (NT/Win95 differences)
{
struct // Win95 fields
{
WORD TIBFlags; // 1Ch
WORD Win16MutexCount; // 1Eh
DWORD DebugContext; // 20h
DWORD pCurrentPriority; // 24h
DWORD pvQueue; // 28h Message Queue selector
} WIN95;
struct // WinNT fields
{
DWORD unknown1; // 1Ch
DWORD processID; // 20h
DWORD threadID; // 24h
DWORD unknown2; // 28h
} WINNT;
} TIB_UNION2;
Листинг 226
Этот случай в очередной раз подтвердил – не стоит без особой необходимости использовать недокументированные особенности, – как правило, они приносят больше проблем, чем пользы.
Как противостоять контрольным точкам останова
Контрольные точки, установленные на важнейшие системные функции, – мощное оружие в руках взломщика. Путь, к примеру, защита пытается открыть ключевой файл. Под Windows существует только один документированный способ это сделать – вызвать функцию CreateFile (точнее CreateFileA или CreateFileW для ASCII и UNICODE-имени файла соответственно). Все остальные функции, наподобие OpenFile, доставшиеся в наследство от ранних версий Windows, на самом деле представляют собой переходники к CreateFile.
Зная об этом, взломщик может заблаговременно установить точку останова на адрес начала этой функции (благо он ему известен) и мгновенно локализовать защитный код, вызывающий эту функцию, ну а остальное, как говорится, дело техники.
Но не всякий взломщик осведомлен, что открыть файл можно и другим путем – вызвать функцию ZwCreateFile (равно как и NtCreateFile), экспортируемую NTDLL.DLL, или обратится напрямую к ядру вызовом прерывания INT 0x2Eh. Сказанное справедливо не только для CreateFile, но и для всех остальных функций ядра. Причем для этого не нужны никакие привилегии, и такой вызов можно осуществить даже из прикладного кода!
Опытного взломщика, такой трюк надолго не остановит, но почему бы ему ни приготовить один маленький сюрприз, поместив вызов INT 0x2E в блок __try. Это приведет к тому, что управление получит не ядро системы, а обработчик данного исключения, находящийся за блоком _try. Взломщик же, не имеющий исходных текстов, не сможет быстро определить: относится ли данный вызов к блоку _try или нет. Отсюда: он может быть легко введен в заблуждение – достаточно имитировать открытие файла, не выполняя его на самом деле! Кроме того, ничего не мешает использовать прерывание INT 0x2E для взаимодействия компонентов свой программы – взломщику будет очень не просто отличить какой вызов пользовательский, а какой системный.
Хорошо, с ядром все понятно, а как же быть с функциями модулей USER и GDI, например, GetWindowsText, использующейся для считывания введенной пользователем ключевой информации (как правило, серийного номера или пароля)? На помощь приходит то обстоятельство, что практически все эти функции начинаются с инструкций PUSH EBP\MOV EBP,ESP, которые прикладной код может выполнить и самостоятельно, передав управление не на начало функции, а на три байта ниже. (Поскольку PUSH EBP изменяет стек, приходится прибегать к передаче управления посредством JMP вместо CALL).
Контрольная точка, установленная взломщиком на начало функции, не возымеет никакого действия! Такой трюк может сбить с толку даже опытного хакера, хотя рано или поздно он все равно раскусит обман, но…
Если есть желание окончательно отравить взломщику жизнь, следует скопировать системную функцию в свой собственный стек и передать на него управление – контрольные точки взломщика "отдыхают"! Основная сложность заключается в необходимости распознания всех инструкций с относительными адресными аргументами и их соответствующей коррекции. Например, двойное слово, стоящее после инструкции CALL, представляет собой не адрес перехода, а разность целевого адреса и адреса следующей за CALL инструкции. Перенос инструкции CALL на новое место потребует коррекции ее аргумента. Впрочем, эта задача не так сложна, как может показаться на первый взгляд (глаза страшатся, а руки делают), и результат оправдывает средства – во-первых, при каждом запуске функции можно произвольным образом менять ее адрес, во-вторых, проверкой целости кода легко обнаружить программные точки останова – а аппаратных точек на все вызовы просто не хватит!
Разве ж не заслуживают награды за свою целеустремленность те единицы, которую такую защиту взломают?! (Под наградой здесь подразумевается отнюдь не сама взломанная программа, а глубокое чувство удовлетворения от того, что "я это сделал!").
Еще легче противостоять аппаратным точкам останова на память – поскольку их всего четыре и каждая может контролировать не более двойного слова, взломщик может одновременно контролировать не более 16 байт памяти. Если же обращения к буферам, содержащим ключевую информацию, будут происходить не последовательно байт за байтам от начала до конца, а произвольно, и количество самих буферов окажется больше четырех, отследить все операции чтения-записи в них станет невозможно.
Некоторые отладчики поддерживают возможность установки точки останова на диапазон памяти, но ее функциональность вызывает большие сомнения – единственный способ контролировать целый регион – трассировать исследуемую программу, проверяя, не обращается ли очередная команда к охраняемому диапазону и если да, – генерировать исключение.
Во-первых, команд, манипулирующих с памятью очень много, и можно придумать самые неожиданные комбинации – например, установить указатель стека на требуемую ячейку памяти и вызвать RET для чтения содержащегося в ней значения. Во-вторых, возникшее при этом исключение, может служить хорошим средством избавления от трассировщка (см. раздел "Как противостоять трассировке").
Таким образом, справится с контрольными точками, защитному механизму совсем не трудно!
Точка останова представляет собой однобайтовую команду 0xCC, генерирующую исключение 0x3 при попытке ее выполнения (в просторечии "дергающие отладочным прерыванием"). Обработчик INT 0x3 получает управление и может делать с программой абсолютно все, что ему заблагорассудится, но прежде – до вызова прерывания – в стек заносятся текущие регистр флагов, указатель кодового сегмента (регистр CS), указатель команд (регистр IP), запрещаются прерывания (очищается флаг IF) и сбрасывается флаг трассировки – словом, вызов отладочного прерывания не отличатся от вызова любого прерывания вообще. (см. рис)
Чтобы узнать в какой точке программы произошел останов, отладчик извлекает из стека сохраненное значение регистров, не забывая о том, – CS:IP указывают на следующую выполняемую команду.
Рисунок 37 0x005 Состояние стека на момент входа в обработчик прерывания
Условно точки останова (называемые так же контрольными точками) можно разделить на две категории: точки останова жестко прописанные в программе самим разработчиком и точки динамические устанавливаемые самим отладчиком. Ну, с первыми все ясно – хочешь остановить программу и передать управление отладчику в там-то месте – пишешь __asm{ int 0x3} и – надевай тигра Шляпу!
Несколько сложнее установить точку в произвольное место программы – сначала отладчик должен сохранить текущее значение ячейки памяти по указанному адресу, затем записать сюда код 0xCC, а перед выходом из отладочного прерывания вернуть все на место и модифицировать сохраненный в стеке IP, для перемещения его на начало восстановленной команды (иначе, он будет указывать на ее середину).
Какими недостатками обладает механизм точек останова 8086-процессора? Первое, и самое неприятное, состоит в том, что точка устанавливая точку останова, отладчик вынужден непосредственно модифицировать код. Отлаживая программа тривиальной проверкой собственной целостности может легко обнаружить факт отладки и даже удалить точку останова! Не стоит использовать конструкции наподобие if (CalculateMyCRC()!=MyValidCRC) {printf("Hello, Hacker!\n");return;} их слишком легко обнаружить и нейтрализовать, подправив условный переход так, чтобы он всегда передавал управление нужной ветке программы. Лучше расшифровывать полученным значением контрольной суммы критические данные или некоторый код.
Простейшая защита может выглядеть, например, так (только не удивляйтесь откуда взялись 32-разрядные регистры в процессоре 8086 – пример, разумеется, предназначен для 386+, сохранившего точки останова от своего предшественника, причем их активно используют не только прикладные отладчики, но даже… сам Айс!):
int main(int argc, char* argv[])
{
// зашифрованная
строка Hello, Free World!
char s0[]="\x0C\x21\x28\x28\x2B\x68\x64\x02\x36\
\x21\x21\x64\x13\x2B\x36\x28\x20\x65\x49\x4E";
__asm
{
BeginCode: ; //начало контролируемого кода
pusha ; //сохранение всех регистров общего назначения
lea ebx,s0 ; // ebx=&s0[0]
GetNextChar: ; // do
XOR eax,eax ; // eax = 0;
LEA esi,BeginCode;// esi = &BeginCode
LEA ecx,EndCode ; // выислиление длины...
SUB ecx,esi ; // ...контролируемого кода
HarvestCRC: ; // do
LODSB ; // загрузка очередного байта в al
ADD eax,eax ; // выисление контрольной суммы
LOOP HarvestCRC ; // until(--cx>0)
xor [ebx],ah ; // расшифровка очередного символа s0
inc ebx ; // указатель на след. симв.
cmp [ebx],0 ; // until (пока не конец строки)
jnz GetNextChar ; // продолжить расшифровку
popa ; // восстановить все регистры
EndCode: ; // конец контролируемого кода
NOP ; // Safe BreakPoint here
}
printf(s0); // вывод строки на экран
return
0;
}
Листинг 224
При нормальном запуске на экране должна появиться строка "Hello, Free World!", но при прогоне под отладчиком при наличии хотя бы одной точки останова, установленной в пределах от BeginCode до EndCode на экране появится бессмысленный мусор наподобие: "Jgnnm."Dpgg"Umpnf#0"
Причем, Soft-Ice неявно помещает точку останова в начало каждой следующей команды при трассировке программы по Step Over (<F10>)! Разумеется, это искажает контрольную сумму, чем и пользуются защита.
Самое простое решение проблемы - положить кирпич на клавишу <F8> (покомандная трассировка) и идти пить чай, пока программа будет расшифровываться. Шутка, конечно. А если говорить серьезно, то необходимо вспомнить в каком веке мы живем и, отбросив каменные топоры, установить аппаратную точку останова (см. "Приемы против отладчиков защищенного режима"). {>>>>> сноска Кстати, значительно усилить защиту можно, если поместить процедуру подсчета контрольной суммы в отдельный поток, занимающийся (для сокрытия свой деятельности) еще чем-нибудь полезным так, чтобы защитный механизм по возможности не бросался в глаза.}
Наши же предки (хакеры восьмидесятых) в этой ситуации обычно вручную расшифровывали программу, а затем затирали процедуру расшифровки NOP-ми, после чего отладка программы уже не представляла проблемы (естественно, если в защите не было других нычек). До появления IDA расшифровщик приходилось писать на Си (Паскале, Бацике) в виде самостоятельной программы, теперь же эта задача упростилась, и заниматься расшифровкой стало можно непосредственно в самом дизассемблере.
Техника расшифровки сводится к воспроизведению расшифровщика на языке IDA-Си – в данном случае сначала необходимо вычислить контрольную сумму от BginCode до EndCode подчитывая сумму байтов, используя при этом младший байт контрольной суммы для загрузки следующего символа, а затем полученным значением "поксорить" строку s0.
Все это можно сделать следующим скриптом (предполагается, что в дизассемблированном тексте соответствующие метки уже расставлены):
auto a; auto p; auto crc; auto ch;
for (p=LocByName("s0");Byte(p)!=0;p++)
{
crc=0;
for(a=LocByName("BeginCode");a<(LocByName("EndCode"));a++)
{
ch=Byte(a);
// Поскольку IDA не поддерживает типов byte и word
// (а напрасно) приходится заниматься битовыми
// выкрутасами – сначала очищать младший байт crc,
// а затем копировать в него считанное значение ch
crc = crc & 0xFFFFFF00;
crc = crc | ch;
crc=crc+crc;
}
// Берем старший байт от crc
crc=crc & 0xFFFF;
crc=crc / 0x100;
// Расшифровываем очередной байт строки
PatchByte(p,Byte(p) ^ crc);
}
Листинг 225
Если под рукой нет IDA, эту же операцию можно осуществить и в HIEW-е:
NoTrace.exe vW PE 00001040 a32 <Editor> 28672 ? Hiew 6.04 (c)SEN 00401003: 83EC18 sub esp,018 ;"^" 00401006: 53 push ebx 00401007: 56 push esi 00401008: 57 push edi 00401009: B905000000 000005 ;" ¦" 0040100E: BE30604000 г=[Byte/Forward ] =============¬ 406030 ;" @`0" 00401013: 8D7DE8 ¦ 1>mov bl,al ¦ AX=0061 ¦p][-0018] 00401016: F3A5 ¦ 2 add ebx,ebx ¦ BX=44C2 ¦гнать 00401018: A4 ¦ 3 ¦ CX=0000 ¦отсюда-> 00401019: 6660 ¦ 4 ¦ DX=0000 ¦ 0040101B: 8D9DE8FFFF ¦ 5 ¦ SI=0000 ¦ [0FFFFFFE8] 00401021: 33C0 ¦ 6 ¦ DI=0000 ¦.0040101B: 8D9DE8FFFFFF L==============================-.00401021: 33C0 xor eax,eax.00401023: 8D3519104000 lea esi,[000401019] ; < BeginCode.00401029: 8D0D40104000 lea ecx,[000401040] ; < EndCode.0040102F: 2BCE sub ecx,esi.00401031: AC lodsb 00401032: 03C0 add eax,eax 00401034: E2FB loop 000001031 00401036: 3023 xor [ebx],ah 00401038: 43 inc ebx 00401039: 803B00 cmp b,[ebx],000 ;" " 0040103C: 75E3 jne 000001021 0040103E: 6661 popaдосюда-> 00401040: 90 nop 00401041: 8D45E8 lea eax,[ebp][-0018] 00401044: 50 push eax 00401045: E80C000000 call 000001056 0040104A: 83C404 add esp,004 ;"¦"1Help 2Size 3Direct 4Clear 5ClrReg 6 7Exit 8 9Store 10Load
На первой стадии производится подсчет контрольной суммы. Загрузив файл в HIEW, находим нужный фрагмент (<ENTER>, <ENTER> для перехода в режим ассемблера и <F8>, <F5> для прыжка в точку входа, далее находим в стартовом коде процедуру main), нажимаем <F3> для разрешение правки файла, вызываем редактор скрипта-расшифровщика (<CTRL-F7>, впрочем, эта комбинация варьируется от версии к версии) и вводим следующий код:
mov bl, al
add ebx, ebx
Вместо EBX можно использовать и другой регистр, но не EAX – HIEW, считывая очередной байт обнуляет EAX целиком. Теперь установим курсор на строку 0x401019 и, нажимая <F7>, погоним расшифрошик до строки 0x401040, не включая последнюю. Если все сделано правильно в старшем байте BX должно находится значение 0x44, - это и есть контрольная сумма.
На второй стадии находим шифрованную строку (ее смещение грузится в ESI и равно .406030) и ксорим ее по 0x44. (Нажимаем <F3> для перехода в режим правки, <CTRL-F8> для задания ключа шифрования – 0x44, а затем ведем расшифровщик по строке, нажимая <F8>)
NoTrace.exe vW PE 00006040 <Editor> 28672 ? Hiew 6.04 (c)SEN
00006030: 48 65 6C 6C-6F 2C 20 46-72 65 65 20-57 6F 72 6C Hello, Free Worl
00006040: 20 65 49 4E-00 00 00 00-7A 1B 40 00-01 00 00 00 eIN z<@ O
Остается лишь забить NOP-ми XOR в строке 0x401036, иначе при запуске программы он испортит расшифрованный текст (зашифрует его вновь) и программа, работать, естественно не будет.
Теперь, после снятия защиты, ее можно безболезненно отлаживать сколько душе угодно – да, контрольная сумма по-прежнему считается, но теперь она не используется (если бы в защите была проверка на корректность CRC, пришлось бы нейтрализовать и ее, но в этом примере для упрощения понимания ничего подобного нет).