Техника хакерских атак. Фундаментальные основы хакерства [Крис Касперски] (doc) читать онлайн

-  Техника хакерских атак. Фундаментальные основы хакерства  0.99 Мб (скачать doc) (скачать doc+fbd)  (читать)  (читать постранично) - Крис Касперски

Книга в формате doc! Изображения и текст могут не отображаться!


Настройки текста:



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

Крис Касперски

Светлой памяти Сергея Иванова – главного редактора издательства "Солон" – посвящается эта книга.
Автор.

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

Предисловие редактора
"The only secure computer is one that's unplugged, locked in a safe, and buried 20 feets under the ground in a secret location... and I'm not even too sure about that one…"
Дэннис Хьюжз (Dennis Huges),
ФБР США

Эпиграф выбран неслучайно. Информационная безопасность сегодня представляет одну из весьма “горячих” тем. Ее актуальность весьма велика, и каждое пособие связанное с этой темой подвергается анализу со стороны обычно весьма скептически настроенных специалистов. Исследование программ связано с вопросами информационной безопасности напрямую. Когда автор этой книги пригласил меня, как специалиста, стать ее научным редактором, я отнесся к этой затее с большим интересом.
Сама мысль о возможности опубликования подобных материалов допускает для многих некоторую крамолу, как некогда было, к примеру, с криптографией и некоторыми областями теории чисел. Более того, тематика данной книги до некоторого времени расценивалась как близкая к широко обсуждаемым криминальным темам и лишь в последнее время вернулась в свое естественное научное русло.
На мой взгляд, эта книга будет интересна весьма широкому кругу читателей. Наверняка ею заинтересуются и те, кто лишь начинает свой восход к Олимпу знаний, и уже “матерые” специалисты в области программирования и исследования программ (или на иностранный манер “reverse engineering”). Хочется особенно отметить, что материалы книги устроены таким образом, что будут полезны и обычному программисту (как пособие по оптимизации программ для современных интеллектуальных компиляторов), и специалистам различных направлений (например, специалистам информационной защиты ‑ в качестве пособия по поиску так называемых “закладок”). Стиль изложения “от простого к сложному” позволяет говорить также и о том, что данная книга послужит также и учебным пособием для начинающих исследователей и “кодокопателей”.
Книга содержит бесценное количество уникального по своей сути практического материала. Множество поверхностных работ за рубежом представляет очень мало практического интереса для тех, кто с интересом изучает прикладную математику, программирование и устройство компьютеров. Да и среди публикаций современного российского научного сообщества читатель не найдет лучшего пособия по изучению техники исследования программ.
Однако, я все-таки рекомендую читателю подвергнуть сомнению все вышесказанное и убедиться во всем самостоятельно, прочитав данную книгу.

С уважением,
Хади Р.А.

Что нового во втором издании

"Как бы плохо вы ни написали вашу повесть, у вас обязательно найдутся читатели, тысячи читателей, которые сочтут ее шедевром... Как бы хорошо вы ни написали свою повесть, обязательно найдутся читатели, и это будут тысячи читателей, которые сочтут ее чистым барахлом"
Борис Hатанович Стругацкий

Первое издание "Техники и философии хакерских атак" – довольно фривольное и хаотичное – по стилю изложения напоминало собой "Путевые заметки охотника" – читается, может быть и интересно, но вот на учебник, увы не тянет. К моему огромному удивлению книга имела ощутимый успех и множество одобрительных откликов. Одно, конечно, понятно – на безрыбье и рак рыба – за последнее время ничего путного по данной тематике не выходило.
Когда же тираж книги был полностью распродан, но заявки на нее по-прежнему продолжали поступать, встал вопрос – что делать дальше: выпускать "в один к одному" допечатку или переработанное и дополненное второе издание? Издатель склонялся к последнему, да и я в желании утолить свой профессиональный зуд, признаться, тоже. Однако за время, прошедшее с момента первого издания, я стал писать значительно структурней и "чище". Поэтому, после долгих колебаний, сомнений и размышлений решил полностью переписать книгу "с нуля", превратив ее в реальную настольную книгу хакера. Своеобразный справочник кодокопателя, но вместе с тем и учебник, помогающий начинающим сделать в мире хакерства свои первые шаги.
Попутно – движимый просьбами читателей, ожидающих поскорее увидеть продолжение трилогии "Образ мышления – дизассемблер IDA", я рискнул включить в настоящее издание двадцать глав из моей будущей книги "Искусство дизассемблирования" (название рабочее), которая увидит свет в своем полном объеме не раньше чем через три – пять лет.
Объем книги увеличился настолько, что ее пришлось разбить на несколько томов. Этот, первый из них, посвящен базовым основам хакерства – технике работы с отладчиком и дизассемблером. Затронуты вопросы защиты программ от изучения и техника нейтрализации защит, впрочем, подробный рассказ о методике создания и снятия защитных механизмов – тема последующих томов.

Кто такие хакеры

…Назови ты меня вчера быком, я был бы быком. Назвал бы ты меня лошадью -- и я был бы лошадью. Если люди дают имя какой-то сущности, то, не приняв этого имени, навлечешь на себя беду.
Приписывается китайскому мудрецу Лао-цзы

Прежде чем подавать на стол блюда хакерской кухни, неплохо бы разобраться кто, собственно, эти хакеры и что они едят? Заглянув в толковый словарь английского языка, например в "The American Heritage Dictionary", мы убедимся, что глагол "hack" возник в английском лексиконе задолго до появления компьютеров и в прямом смысле обозначал "бить, рубить, кромсать" (но не уродовать!) топором, мотыгой или молотом. Т.е. делать физически тяжелую, монотонную, занудную, интеллектуально непритязательную работу – удел батраков, неудачников и бездарей. Неудивительно, что производные от глагола "хак" обозначали "бить баклуши", "халтурить", "выполнять работу наспех" – ведь наемные рабочие испокон веков "фунциклировали" из-под палки! Термин считался пренебрежительным, если не ругательным: "хак" стало даже синонимом нашего "кляча"! Словом, в докомпьютерную эпоху титулом "хакера" ни один здравомыслящий человек ни возгордился бы…
Сегодня же "хакер" звучит практически так же как "национальный герой", пускай и преступный, но все же крутой малый, которому не грех подражать. Чем же объясняется такая метаморфоза?
По одной из гипотез в щелчке, издаваемом реле, американцам слышалось "хак - хак". Динозавры машинной эры состояли из многих тысяч реле и "хакали" во всю, особенно когда оператор ЭВМ запускал очередную программу на выполнение. Возможно, именно за это операторов и прозвали "хакерами". Или, говоря по-русски "клацальщиками". По другой гипотезе звук "хак" приписывается перфоратору, кромсающему перфоленту на мелкие куски, так что щепки (такие аккуратненькие круглые "щепочки") во все стороны летят!
На ассоциативном уровне обе гипотезы вполне правдоподобны. И реле, и перфоратор издают повторяющиеся монотонные удары, чем-то напоминающие кашель, а выражение "кашлять сухим кашлем" - одно из значений слова "hack". К тому же, программировали "динозавров" исключительно в машинных кодах, подчас с помощью переключателей или перетыкивания разъемов, - физически тяжелая, нудная, неблагодарная работа, достающаяся наименее привилегированной части персонала. Какой там романтизм? Какое изящество решений или полет мысли? Халтура сплошная… Редкая программа обходится без ошибок, а программа, составленная в машинных кодах – тем более. При желании любого оператора было можно назвать халтурщиком – "хакером" в ругательном смысле этого слова. "Вот, наделал кучу ошибок, хакер ты наш!"
Обыватели же, далекие от вычислительной техники, и знакомые с ней исключительно по фантастическим романам, испытывали перед ЭВМ благоговейное уважение, подогреваемое гордостью за научно-технические достижения всего рода homo sapiens в целом и американской нации в частности. "Белые воротнички" – цвет нации, управляющие махиной размером с супермаркет и стоящей дороже тысячи таких супермаркетов, вызывали у рядового американца смесь восторга, зависти и стремления к подражанию. Вроде как "я тоже хочу быть космонавтом", не задумываясь о том, что космонавтика это только с виду романтика, а в действительности – каторжная работа.
Но, если желание побывать в космосе до сих пор смогли реализовать лишь единицы, то ЭВМ стали широко доступными уже в начале шестидесятых. К тому времени их было можно встретить и в подвалах университетов, и в стенах крупных корпораций, и практически во всех исследовательских учреждениях. Очутиться за пультом ЭВМ в создании студента означало практически то же самое, что и "сесть за штурвал реактивного бомбардировщика". Программирование ассоциировалось отнюдь не с "батрачеством", а с интеллектуальной игрой. И "старшие наставники" студентов – операторы ЭВМ были не только их руководителями, но и кумирами. Студенты, одержимые вычислительной техникой, стремились во всем копировать персонал, обслуживающий большие ЭВМ, часто без понимания сути происходящего. Прознав жаргонное прозвище операторов, студенты, не догадываясь о его иронично – оскорбительном оттенке, с достоинством стали называть хакерами и себя и своих товарищей, и даже свою работу окрестили "хакерством". Но в их устах слово "хакер" звучало отнюдь не насмешкой, а расценивалось как титул. Ты – хакер, значит, ты такой же мастер, как и настоящий оператор ЭВМ. Значит, ты крутой парень и перед тобой не стыдно снять шляпу.
Так "хакеры" из работяг превратились в программистов – энтузиастов, помешенных на компьютерах и выделывающих на них такое… такое, что другим и не снилось. Термин продолжал видоизменяться, мигрируя своими значениями в сторону "крутого трюка", "забавного эффекта", "выполненного со вкусом розыгрыша". Этот дух подхватили и другие факультеты, порой и вовсе не связанные ни с электроникой, ни с вычислительной техникой, ни даже с точными науками вообще. "Хаком" стали называть любой классный розыгрыш или нестандартное решение знакомой задачи, – жаргонный термин технического языка превратился в модное словечко, употребляемое всеми кому не лень.
Тем временем мутация "хакера" продолжалась… Чтобы понять ее причины мысленно перенесемся в конец шестидесятых – начало семидесятых, а, может, даже чуточку позже. В те годы среди западной молодежи витал дух борьбы. Борьбы с кем? Да разве это важно! Протестовали против войны во Вьетнаме (кто не хотел служить в армии – жгли повестки), ломали пуританские устои старого мира, провозглашая свободу любви, презирали деньги (или только делали вид, что презирали, завистливо поглядывая в сторону того, у кого они есть). По большому счету вся борьба сводилась к суете в песочнице и власть имущих в общем-то ничуть не раздражала. Молодежные лидеры не имели в руках никакого оружия – ни политического, ни экономического, ни идеологического, не говоря уже об огнестрельном. К тому же, через десяток лет дух борьбы покинул Америку и весь шум закончился.
"Счастливое исключение" составили программисты. В те дни компьютерные системы еще не успели обзавестись достойной защитой, но уже управляли стратегически и экономическими важными объектами. Власть над компьютерами позволяла дать хорошего пинка и правительственным организациям, и финансовым магнатам, и корпорациям, и другим сильным мира сего, причем, оставаясь безнаказанным. Не существовало ни соответствующих законов, ни компьютерной полиции, способной "вычислить" преступника…
Словом, дикий запад времен разбоя, романтики и беспредела, когда человек с кольтом мог заставить шерифа мирного уездного городка "слушать Шопена лежа". У американцев надо сказать, по поводу освоения Америки очень сильный комплекс – одних вестернов они сняли больше, чем мы фильмов про Великую Отечественную Войну. Понятно дело, каждый юный американец в душе мнит себя полноправным ковбоем!
Компьютеры же позволили воплотить эту мечту в жизнь. Освой ЭВМ и носись по электронным сетям, как "неуловимый Джо", отстреливающий индейцев (банкиров, ЦРУ-шников и т.д.). Да и как не носиться, когда на книжных лотках как грибы появлялись фантастические романы, главными героями которых были компьютерные взломщики – хакеры. Писатели, никогда в жизни не видевшие ЭВМ, плохо разбирались в техническом жаргоне и употребляли его на интуитивно-бессознательном уровне безо всякого понимания. Достаточно перелистать "The Shockware Rider" Джона Бруннера (John Brunner) 1975 года, "The Adolescence of P‑1" Томаса Риана (Thomas Ryan) 1977 года или "Necromancer" Вильяма Гибсона (Wilam Gibson), опубликованный в 1984 году, чтобы убедиться насколько их авторы были далеки от вычислительной техники. Впрочем, литературных достоинств произведений это ничуть не ущемляло, а читатели в своей массе были от вычислительной техники еще более далеки, чем писатели, и у них сложился устойчивый образ "ЭВМ – это круто", а "хак – это вообще круто". Нейроматик, кстати, был самой любимой книгой Роберта Тапплана Морриса, создавшего своей знаменитый вирус – червь, надо полагать, не без влияния Вильяма Гибсона.
Журналисты, не обременение ни знаниями ЭВМ, ни лингвистическим образованием, из всего этого поняли только одно: некто, называющие себя хакерами, ломают компьютеры по всей стране, причем ломают весьма круто с убытками в особо крупных размерах.
Слово "хакер" вырвалось на страницы газет, но в широких массах глагол "хак" по-прежнему означал все те же "бить--кромсать", и американцы, вполне естественно, заключили, что хакер -- это тот, кто вламывается в чужие системы и раздалбывает их в пух и прах.
Вот, собственно, и все… Кольцо замкнулось, - термин "хакер" вернул свое "историческое" значение, но не прекратил эволюцию! Хакерам прошлого поколения (т.е. энтузиастам программирования) очень не понравилось, что их титул смешали, мягко выражаясь, с дерьмом, и при его упоминании от них все стали шарахаться как от огня. Стремясь реабилитировать себя в глазах общественности, хакеры предприняли попытку разделить всех своих на "хороших" и "плохих", оставив за "хорошими" парнями право называться "хакерами", для "плохих" придумав специальный термин "кракер" – от слова "crack" – ломать (кстати, почему не "брейкер" от слова "break"?), в буквальном смысле обозначающий "ломатель". Затея с треском провалилось, - далеко не каждый взломщик был готов нацепить на себя ярлык плохого паря. Называться хакером по-прежнему считалось и модно, и престижно, пускай все "хакерство" ограничилось "wannabe" (в дословном русском переводе "хочубытькак", т.е. подражанием). Предметы хакерской культуры обожествлялись, становясь предметом поклонения, догматом, иконой на стене.
Эта ветка генеалогического древа "хакеров" не имеет будущего и обречена на медленное, но неотвратимое вымирание. Уже сегодня, в начале первого десятилетия двадцать первого века, термин "хакер" стал всеобъемлющим и утратил всякий смысл. Кто пишет вирусы? Хакеры! Кто ломает программы? Хакеры! Кто крадет деньги из банков? Хакеры! Кто пакостит в Сети? Хакеры! Кто программирует на ассемблере? Хакеры! Кто знает все тонкости операционной системы и железа? Хакеры! Сказать собеседнику, что ты хакер, не уточив, что конкретно ты имеешь под этим ввиду, все равно, что ничего не сказать.
Термин "хакер" умер, но ведь хакеры – остались! Остались и работяги-кодеры, пускай уже не клацающие реле, но зато шумящие пропеллерами вентиляторов, остались и энтузиасты программирования, упоенно программирующие и на древних, и на современных языках, остались и исследователи защит, и умельцы по их взлому… Люди есть, а термина, определяющего их принадлежность, уже нет.
Почему бы не назвать определенную категорию компьютерщиков "кодокопателями"? Этот термин, впервые употребленный Безруковым, на мой взгляд, очень удачен и интуитивно понятен без дополнительный объяснений. Любой, кто любит копаться в коде (не обязательно машинном) по праву может считать себя кодокопателем.
Таким людям, собственно и посвящена эта книга…

Чем мы будем заниматься
На протяжении всей книги мы будем заниматься занимательной интеллектуальной игрой – созданием защитных механизмов и исследованием их стойкости. Скажу сразу - ничего общего со взломом коммерческих программ или кражей денег из банка это не занятие не имеет. Автор искренне надеется, что его читатели – граждане в своей массе законопослушные и обладающие высокой нравственностью люди.
Умение нейтрализовать защиты еще не дает права применять это умение в преступных целях. Какие же цели являются преступными, а какие нет – вопрос, относящийся уже не к хакерству, а юриспруденции в которой автор не силен и все, что может он порекомендовать – если имеются какие-то сомнения на счет правомерности совершения некоторых действий, – обратитесь к юристам.
Однако экспериментировать с вашей личной интеллектуальной собственностью – программами, написанными вами самими, – ни один закон не вправе запретить, да ни один закон этого, собственно, и не запрещает.
А раз так, на плечи – рюкзак, охотничий ножик в карман и – в густой таежный лес…

Что нам понадобиться
Выбор рабочего инструментария – дело сугубо личное и интимное. Тут на вкус и цвет товарищей нет. Поэтому, примите все нижесказанное не как догму, а как рекомендацию к действию. Итак, для чтения книги нам понадобиться:

– отладчик Soft-Ice версии 3.25 или более старший,
– дизассемблер IDA версии 3.7х (рекомендуется 3.8, а еще лучше 4.x),
– HEX-редактор HIEW любой версии,
– пакеты SDK и DDK (последний не обязателен, но очень желателен),
– операционная система – любая из семейства Windows, но настоятельно рекомендуется Windows 2000,
– любой Си\Си++ и Pascal компилятор по вкусу (в книге подробно описываются особенности компиляторов Microsoft Visual C++, Borland C++, WATCOM C, GNU C, FreePascal, а за основу взят Microsoft Visual C++ 6.0).

Теперь обо всем этом подробнее:

::Soft-Ice. Отладчик Soft-Ice – основное оружие хакера. Хотя, с ним конкурируют бесплатные WINDEB от Microsoft и TRW от LiuTaoTao – Soft-Ice много лучше и удобнее всех их вместе взятых. Для наших экспериментов подойдет практически любая версия Айса, например, автор использует давно апробированную и устойчиво работающую 3.26, замечательно уживающуюся с Windows 2000. Новомодная 4.x не очень-то дружит с моим видеоадаптером (Matrox Millennium G450 для справки) и вообще временами "едет крышей". К тому же, из всех новых возможностей четвертой версии полезна лишь поддержка FPO (Frame point omission – см. "Идентификация локальных стековых переменных") – локальных переменных, напрямую адресуемых через регистр ESP, – бесспорно полезная фишка, но без нее можно и обойтись. Найти Soft-Ice можно и на дисках известного происхождения, и у российского дистрибьютора - http://www.quarta.ru/bin/soft/winntutils/softicent.asp?ID=59. Купите, не пожалеете (хакерство это ведь не то же самое, что пиратство и честность еще никто не отменял).

::IDA Pro. Бесспорно самый мощный дизассемблер в мире – это IDA. Прожить без нее, конечно, можно, но… нужно ли? IDA обеспечивает удобную навигацию по исследуемому тексту, автоматически распознает библиотечные функции и локальные переменные, в том числе и адресуемые через ESP, поддерживает множество процессоров и форматов файлов. Одним словом, хакер без IDA – не хакер. Впрочем, агитации излишни, - единственная проблема: где же эту IDA взять? На пиратских дисках она встречается крайне редко (самая последняя виденная мной версия 3.74, да и то нестабильно работающая), на сайтах в Интернете – еще реже. Фирма-разработчик жестоко пресекает любые попытки несанкционированного распространения своего продукта и единственный надежный путь его приобретения – покупка в самой фирме или у российского дистрибьютора ("GelioSoft Ltd" ). К сожалению, с дизассемблером не распространяется никакой документации (не считая встроенного хелпа – очень короткого и бессистемного), поэтому мне ничего не остается, как порекомендовать собственный трехтомник "Образ мышления – дизассемблер IDA", подробно рассказывающей и о самой IDA, и о дизассемблировании вообще.

::HIEW. "Хьювев" – это не только HEX-редактор, но и дизассемблер, ассемблер и крипт "в одном флаконе". Он не избавит от необходимости приобретения IDA, но с лихвой заменит ее в ряде случаев (IDA очень медленно работает и обидно тратить кучу времени, если все, что нам нужно – посмотреть на препарируемый файл "одним глазком"). Впрочем, основное назначение "хьюева" отнюдь не дизассемблирование, а bit hack – небольшое хирургическое вмешательство в двоичный файл, – обычно вырезание жизненного важного органа защитного механизма, без которого он не может функцилировать.

::SDK (Software Development Kit – комплект прикладного разработчика). Из пакета SDK нам, в первую очередь, понадобится документация по Win32 API и утилита для работы с PE-файлами DUMPBIN. Без документации ни хакерам, ни разработчикам никак не обойтись. Как минимум, необходимо знать прототипы и назначение основных функций системы. Эту информацию, в принципе, можно почерпнуть и из многочисленных русскоязычных книг по программированию, но ни одна из них не может похвастаться полнотой и глубиной изложения. Поэтому, рано или поздно, вам придется обратиться к SDK. Правда, некоторым перед этим потребуется плотно засесть за английский, поскольку все документация написана именно на английском языке и ждать ее перевода все равно, что караулить у моря погоду (правда, с некоторых времен на сайте Microsoft стало появляться много информации для разработчиков и на русском языке). Где приобрести SDK? Во-первых, SDK входит в состав MSDN, а сам MSDN ежеквартально издается на компакт-дисках и распространяется по подписке (подробнее об условиях его приобретения можно узнать на официальном сайте msdn.Microsoft.com). Во-вторых, MSDN прилагается и к компилятору Microsoft Visual C++ 6.0, правда далеко не в первой свежести. Впрочем, для чтения данной книги его будет вполне достаточно.

::DDK. (Driver Development Kit – комплект разработчика драйверов). Какую пользу может извлечь хакер из пакета DDK? Ну, в первую очередь, он поможет разобраться: как устроены, работают (и ломаются) драйвера. Помимо основополагающей документации и множества примеров, в него входит очень ценный файл NTDDK.h, содержащий определения большинства недокументированных структур и буквально нашпигованный комментариями, раскрывающих некоторые любопытные подробности функционирования системы. Не лишним будет и инструментарий, прилагающийся к DDK. Среди прочего сюда входит и отладчик WINDEB. Весьма неплохой, кстати, отладчик, но все же значительно уступающий Soft-Ice, поэтому и не рассматриваемый в данной книге (но если вы не найдете Айса – сгодится и WINDEB). Не бесполезным окажется ассемблер MASM, на котором собственно и пишутся драйвера, а так же маленькие полезные программки, облегчающие жизнь хакеру. Последнюю версию DKK можно бесплатно скачать с сайта Microsoft, только имейте ввиду, что для NT полный DKK занимает свыше 40 мегабайт в упакованном виде и еще больше места требует на диске.

::операционная система. Вовсе не собираясь навязывать читателю собственные вкусы и пристрастия, я, тем не менее, настоятельно рекомендую установить именно Windows 2000. Мотивация – это действительно стабильная и устойчиво работающая операционная система, мужественно переносящая все критические ошибки приложений. Специфика работы хакера такова, что хирургические вмешательства в недра программ частенько срывают им "крышу", доводя ломаемое приложение до буйного помешательства с непредсказуемым поведением. ОС Windows 9x, демонстрируя социалистическую солидарность, зачастую очень часто "ложится" рядом с зависшей программой. Порой компьютер приходится перезагружать не один десяток раз за деньпо дню! И хорошо, если только перезагружать, а не восстанавливать разрушенные сбоем диски (такое, хотья и редко, но случается). Завесить же Windows 2000 на порядок сложнее, – мне это "удается" не больше пары чаще одного-двух раз за месяц, да и то с недосыпу или по небрежности. Потом, Windows 2000 позволяет загружать Soft-Ice в любой момент без необходимости перезагрузки, что очень удобно! Наконец, весь материал этой книги рассчитан именно на Windows 2000, – а ее отличия от других систем упоминаются далеко не всегда. Все равно, все мы когда-нибудь перейдем на Windows 2000 и забудем о Windows 9x как о страшном сне, так стоит ли хвататься за эту умирающую платформу? К слову сказать, Windows Me это не то же самое, что Windows 2000 и ставить ееMe на свой компьютер я никому не рекомендую (такое впечатление, что Windows Me вообще не тестировали, а о том, что ее писали садисты – кто ставил, тот поймет – я вообще молчу).

Итак, Худо-бедно разобравшись с инструментарием, поговорим о сером веществе, ибо в его отсутствии весь собранный инструмент бесполезен. Автор предполагаетполагает, что читатель уже знаком с ассемблером и, если не пишет программ на этом языке, то, по крайней мере, представляет себе что такое регистры, сегменты, машинные инструкции и т.д. В противном случае эта книга рискует показаться через чур сложной и непонятной. Отыщите в магазине любой учебник по ассемблеру (например: В. Юрова "ASSEMBLER – учебник", П.И. Рудакова "Программируем на языке ассемблера IBM PC" или "Assembler – язык неограниченных возможностей" Зубкова С.В) и основательно проштудируйте его.
Помимо значения ассемблера так же потребуется иметь хотя бы общие понятия о функционировании операционной системы. Купите и вдумчиво изучите (если не сделали этого до сих пор) "Windows для профессионалов" Джефри Рихтера {>>>> сноска см "Приложение", "Ошибки Джефри Рихтера"} и (если найдете) "Секреты системного программирования в Windows 95" Мэта Питрека. Хотья, его книга посвящена Windows 95, частично она справедлива и для Windows 2000. Для знакомства с архитектурой самой же Windows 2000 рекомендуется ознакомиться с шедевром Хелен Кастер "Основы Windows NT" и брошюрой "Недокументированные возможности Windows NT" А.В. Коберниченко.
Касаемо общей теории информатики и алгоритмов – бесспорный авторитет Кнут. Впрочем, на мой вкус монография М. Броя "Информатика" куда лучше, - при том что она намного короче, круг охватываемых ей тем и глубина изложения – намного шире. Зачем хакеру теория информатики? Да куда же без нее! Вот, скажем, встретится ему защита со движком-встроенным эмулятором машины Тьюринга. или Маркова. Слету ее не сломать, - надо как минимум опознать сам алгоритм: что это вообще такое – Тьюринг, Марков, или сеть Петри, а потом затем – отобразить его на язык высокого уровня, дабы в удобочитаемом виде анализировать работу защиты. Куда же тут без теории информатики!
За сим все., Ну, разве что стоит дополнить наш походный рюкзачок паруой учебников по английскому (они пригодятся, поверьте) и выкачать с сайтов Intel и AMD всю имеющуюся там документацию по процессорам. На худой конец подойдет и ее русский перевод, например, Ровдо А.А. "Микропроцессоры от 8086 до Pentium III Xeon и AMD K6-3".
Ну-с, рюкзачок на плечо и в путь…

Знакомство с базовыми приемами работы хакера

Введение.

Классификация защит

"Стать хакером очень просто. Достаточно выучить и понять: математические дисциплины (математический анализ, теорию функций комплексного переменного, алгебру, геометрию, теорию вероятностей, математическую статистику, математическую логику и дискретную математику...)".
Борис Леонтьев "Хакеры & Internet".

Проверка аутентичности (от греческого "authentikos" – подлинный) – "сердце" подавляющего большинства защитных механизмов. Проверка аутентичности необходима,Должны же мы чтобы удостовериться,: то ли лицо работает с программой, за которое оно себя выдает, и разрешено ли этому лицу работать с программой вообще.! В зависимости от результатов проверки защита либо передает управление основной ветке программы, либо "ругается" и блокирует работу.
В качестве "лица" может выступать не только пользователь, но и его компьютер или носитель информации, хранящий лицензионную копию программы. Таким образом, все защитные механизмы можно разделить на две основных категории:

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

Защиты, основанные на знании, бесполезны, если обладатель защищенной с их помощью программы, не заинтересован в сохранении ее секретности. Он может сообщить пароль (серийный номер) кому угодно, после чего любой сможет запустить такую программу на своем компьютере.
Поэтому, парольные защиты для предотвращения пиратского копирования программ непригодны. Почему же тогда практически все крупные производители в обязательном порядке используют серийные номера? Ответ прост – для защиты своей интеллектуальной собственности грубой физической силой. Происходит это приблизительно так: …рабочая тишина такой-то фирмы внезапно нарушается топотом сапог парней в камуфляже, сверяющих лицензионные номера Windows (Microsoft Office, Microsoft Visual Studio) с лицензионными соглашениями, и стоит обнаружиться хотя бы одной "левой" копии, как появившийся, словно из-под земли, сотрудник фирмы начинает радостно потираеть руки в предвкушении дождя вечнозеленых… В лучшем случае – заставят купить все "левые" копии, в худшем же…
К домашним пользователям в квартиру, понятное дело, никто не врывается – частная собственность и все такое, да к этому никто собственно и не стремится. Что с домашнего пользователя возьмешь-то? К тому же, самим фирмам выгодно массовое распространение их продукции, а кто его обеспечит лучше пиратов? Но и здесь серийные номера не лишние – они разгружают службу технической поддержки от "левых" звонков незарегистрированных пользователей, одновременно с этим склоняя последних к покупке легальной версии.
Такая схема защиты идеальна для корпораций -гигантов, но она не подходит для мелких программистских коллективов и индивидуальных разработчиков, особенно если они зарабатывают на жизнь написанием узкоспециализированных программ с ограниченным рынком назначения сбыта (скажем, анализаторов звездных спектров или системы моделирования ядерных реакций). Не имея достаточного влияния, "раскачать" сотрудников известных органов на облаву по проверки лицензионности своего ПО нереально, а "выбить" деньги из нелегальных пользователей можно разве что с помощью криминальных структур, да и то навряд ли. Вот и приходится рассчитывать только лишь на собственныесобственную силыу и смекалку.
Тут лучше подходит тип защит, основанных на обладании некоторым уникальным предметом, скопировать который очень чрезвычайно тяжело, а в идеале – вообще невозможно. Первые ласточки этой серии – ключевые дискеты, записанные с таким расчетом, чтобы при их копировании копия чем-нибудь да отличалась от оригинала. Самое простое (но не самое лучше) слегка изуродовать дискету гвоздем (шилом, перочинным ножом), а затем, определив местоположение дефекта относительно сектора (это можно сделать записью-чтением некоторой тестовой информации – до какого-то момента чтение будет идти нормально, а потом начнется "мусор"), жестко прописать его в программе и при каждом запуске проверять – на том же самом месте дефект или нет? Когда же дискеты вышли из употребления, эта же техника была адоптирована и для компакт-дисков. Кто побогаче уродует их лазером, кто победнее – все тем же шилом или гвоздем.
Таким образом, программа жестко привязана к диску (дискете) и требует ее присутствия для своей работы, а, поскольку скопировать такой диск нереально (попробуй-ка, добиться идентичных дефектов на копиях), пираты "отдыхают".
Защитные механизмы, основанные на обладании, часто модифицирует предмет обладания в процессе работы программы, ограничивая количество запусков программы или время ее использования. Особенно часто такая "фишка" используется в инсталляторах – чтобы не нервировать пользователя, ключ запрашивается лишь однажды – на стадии установки программы, а работать с ней можно и без него. Если количество инсталляций ограничено, ущербом от несанкционированных установок одной копии программы на несколько компьютеров можно пренебречь.
Единственная проблема – все это ущемляет права легального пользователя. Кому понравится ограничение на количество инсталляций? (А ведь некоторые люди переустанавливают систему и все ПО буквально каждый месяц, а то и несколько раз на дню). Ключевые диски распознаются не всеми типами приводов, зачастую "не видимы" по сети, а, если защитный механизм для увеличения стойкости к взлому, обращается к оборудованию напрямую, в обход драйверов, такая программа наверняка не будет функционировать под Windows NT\2000 и весьма вероятно откажет в работе под Windows 9x (если, конечно, она не была заранее спроектирована соответствующим образом, но если так – это хуже, ибо некорректно работающая защита, исполняющаяся с наивысшими привидениями, может причинить немалый урон системе). Помимо этого, ключевой предмет можно потерять, его могут украсть, да и сам он может выйти из строя (дискеты склонны сыпаться и размагничиваться, диски – царапаться, а электронные ключи – "сгорать").
Конечно, эти претензии относится к качеству реализации, а не к идее ключей вообще, но конечным пользователям от этого ничуть не легче! Если же защита создает неудобства, у пользователей появляется очень сильная мотивация к посещению ближайшего доступного пирата на предмет приобретения у него контрфактного программного обеспечения. И никакие разговоры о морали, этике, добропорядочности и т.д. не подействуют – своя рубашка ближе к телу, а о добропорядочности нужно в первую очередь задуматься разработчикам таких защит. Тов…, тьфу, господа, не отравляйте жизнь пользователям! Пользователи – тоже люди!
В последнее время наибольшую популярность обрели защиты, основанные на регистрационных номерах – удачно сочетающие защиты обоих типов. при первом запуске программа привязывается к компьютеру и включает "счетчик" (вариант – блокирует некоторые функциональные возможности). А чтобы ее "освободить" необходимо ввести пароль, сообщаемый разработчиком за некоторое материальное вознаграждение. Часто для предотвращения пиратского копирования пароль представляет собой некоторую производную от ключевых параметров компьютера (или производную от имени пользователя в простейшем случае).
Разумеется, этот краткий обзор типов защит очень много оставил за кадром, но подробный разговор о классификации защит выходит за рамки этой книги, так что отложим его до второго тома.

Рисунок 1 0x026 Основные типы защит

Философия стойкости
Однажды один из друзей сказал Катону Старшему: "Какое безобразие, что в Риме тебе до сих пор не воздвигли памятника! Я обязательно позабочусь об этом".
"Не надо, - ответил Катон, - я предпочитаю, чтобы люди спрашивали, почему нет памятника Катону, чем почему он есть.
Т. Мессон

Если защита базируется на одном лишь предположении, что ее код не будет изучен и/или изменен – это плохая защита. Отсутствие исходных текстов отнюдь не является непреодолимым препятствием для изучения и модификации приложения. Современные технологии обратного проектирования позволяют автоматически распознавать библиотечные функции, локальные переменные, стековые аргументы, типы данных, ветвления, циклы и т.д. А в недалеком будущем дизассемблеры, вероятно, вообще научатся генерировать листинги близкие по внешнему виду к языкам высокого уровня.
Но даже сегодня трудоемкость анализа двоичного кода не настолько велика, чтобы надолго остановить злоумышленников. Огромное количество постоянно совершаемых взломов – лучшее тому подтверждение. В идеальном случае знание алгоритма работы защиты не должно влиять на ее стойкость, но это достижимо далеко не всегда. Например, если разработчик серверной программы решит установить в демонстрационной версии ограничение на количество одновременно обрабатываемых соединений (как часто и случается), злоумышленнику достаточно найти инструкцию процессора, осуществляющую такую проверку и удалить ее. Модификации программы можно воспрепятствовать постоянной проверкой контрольной суммы, но опять-таки, код, который вычисляет эту контрольную сумму и сверяет ее с эталоном, может быть найден и удален.
Сколько бы уровней защиты ни существовало, один или миллион, программа может быть взломана! Это только вопрос времени и усилий. Но в отсутствии реально действующих законов защиты интеллектуальной собственности разработчикам приходится больше полагаться на стойкость своей защиты, чем на помощь правоохранительных органов. Бытует мнение, дескать, что если затраты на нейтрализацию защитного механизма, будут не ниже стоимости легальной копии, ее никто не будет ломать. Это неверно! Материальный стимул – не единственное, что движет хакером. Гораздо более сильной мотивацией оказывается интеллектуальная борьба (кто умнее: я или автор защиты?), спортивный азарт (кто из хакеров сломает больше всего защит?), любопытство (а как это работает?), повышение своего профессионализма (чтобы научится создавать защиты, сначала нужно научиться их снимать), да и просто интересное времяпровождение (если его нечем занять). Многие молодые люди могут неделями корпеть над отладчиком, снимая защиту с программы стоимостью в несколько долларов, а то и вовсе распространяемой бесплатно (пример, файл - менеджер FAR для жителей России и СНГ абсолютно бесплатен, но это не спасает его взлома).
Целесообразность защиты ограничивается конкуренцией – при прочих равных условиях клиент всегда выбирает незащищенный продукт, даже если защита не ущемляет его прав. В настоящее время спрос на программистов значительно превышает предложение, но в отдаленном будущем разработчикам придется либо сговориться, либо полностью отказаться от защит. И специалисты по защитам будут вынуждены искать себе другую работу.
Это не значит, что данная книга бесполезна, напротив, полученные знания следует применить как можно быстрее, пока в защитах еще не отпала необходимость.

Шаг первый. Разминочный.

Бороться со своими мыслями, это уподобиться одному глупцу, который в целях аккуратности и гигиены решил больше не какать. День не какал, два не какал. Потом, конечно не выдержал, но всех продолжал уверять, что не какает.
Аноним

Алгоритм простейшего механизма аутентификации состоит в посимвольном сравнении введенного пользователем паролем с эталонным значением, хранящимся либо в самой программе (как часто и бывает), либо вне ее, например, в конфигурационном файле или реестре (что встречается реже).
Достоинство такой защиты – крайне простая программная реализация. Ее ядро состоит фактически из одной строки, котораяую на языке Си обычно выглядит можно записать так: – "if (strcmp(&введенный пароль, &эталонный пароль)) { /* Пароль неверен */} else {/* Пароль ОК */}"
Давайте дополним этот код процедурами запроса пароля и вывода результатов сравнения, а затем испытаем полученную программу на "прочность", т.е. стойкость к взлому.

// Простейшая система аутентификации
// посимвольное сравнение пароля
#include
#include

#define PASSWORD_SIZE 100
#define PASSWORD "myGOODpassword\n"
// этот перенос нужен затем, чтобы ^^^^
// не выкусывать перенос из строки,
// введенной пользователем

int main()
{
// Счетчик неудачных попыток аутентификации
int count=0;

// Буфер для пароля, введенного пользователем
char buff[PASSWORD_SIZE];

// Главный цикл аутентификации
for(;;)
{
// Запрашиваем и считываем пользовательский
// пароль
printf("Enter password:");
fgets(&buff[0],PASSWORD_SIZE,stdin);

// Сравниваем оригинальный и введенный пароль
if (strcmp(&buff[0],PASSWORD))
// Если пароли не совпадают – "ругаемся"
printf("Wrong password\n");
// Иначе (если пароли идентичны)
// выходим из цикла аутентификации
else break;

// Увеличиваем счетчик неудачных попыток
// аутентификации и, если все попытки
// исчерпаны – завершаем программу
if (++count>3) return –1;
}

// Раз мы здесь, то пользователь ввел правильный пароль
printf("Password OK\n");
}
Листинг 1 Пример простейшей системы аутентификации

В популярных кинофильмах крутые хакеры легко проникают в любые жутко защищенные системы, каким-то непостижимым образом угадывая искомый пароль с нескольких попыток. Почему бы неи попробовать пойти их путем?
Не так уж редко пароли представляют собой осмысленные слова, наподобие "Ferrari", "QWERTY", имена любимых хомячков, названия географических пунктов и т.д. Угадывание пароля сродни гаданию на кофейной гуще – никаких гарантий на успех нет, и остается рассчитывать на одно лишь везение. А удача, как известно, птица гордая – палец ей в рот не клади. Нет ли более надежного способа взлома?
Давайте подумаем – раз эталонный пароль хранится в теле программы, то, если он не зашифрован каким-нибудь хитрым образом, его можно обнаружить тривиальным просмотром двоичного кода программы. Перебирая все, встретившиеся в ней текстовые строки, начиная с тех, что более всего смахивают на пароль, мы очень быстро подберем нужный ключ и "откроем" им программу!
Причем, область просмотра можно существенно сузить, – в подавляющем большинстве случаев компиляторы размешают все инициализированные переменные в сегменте данных (в PE-файлах он размещается в секции ".data"). Исключение составляют, пожалуй, ранние Багдадские (Borland-вые в смысле) компиляторы с их маниакальной любовью всовывать текстовые строки в сегмент кода – непосредственно по месту их вызова. Это упрощает сам компилятор, но порождает множество проблем. Современные операционные системы, в отличие от старушки MS-DOS, запрещают модификацию кодового сегмента, и все, размешенные в нем переменные, доступны лишь для чтения. К тому же, на процессорах с раздельной системой кэширования (на тех же Pentium-ах, например) они "засоряют" кодовый кэш, попадая туда при упреждающем чтении, но при первом же к ним обращении вновь загружаются из медленной оперативной памяти (кэша второго уровня) в кэш данных. В результате – тормоза и падение производительности.
Что ж, пусть это будет секция данных! Остается только найти удобный инструмент для просмотра двоичного файла. Можно, конечно, нажать в своей любимой оболочке (FAR, DOS Navigator) и, придавив кирпичом любоваться бегущими циферками до тех пор, пока не надоест. Можно воспользоваться любым hex-редактором (QVIEW, HIEW…) – кому какой по вкусу, но в книге по соображениям наглядности я приведу результат работы утилиты DUMPBIN из штатной поставки Microsoft Visual Studio.
Попросим ее распечатать секцию данных (ключ "/SECTION:.data") в "сыром" виде (ключ "/RAWDATA:BYTES"), указав значок ">" для перенаправления вывода в файл (ответ программы занимает много места и на экране помещается один лишь "хвост").

> dumpbin /RAWDATA:BYTES /SECTION:.data simple.exe >filename

RAW DATA #3
00406000: 00 00 00 00 00 00 00 00 00 00 00 00 3B 11 40 00 ............;.@.
00406010: A4 40 40 00 00 00 00 00 00 00 00 00 E0 11 40 00 д@@.........р.@.
00406020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00406030: 45 6E 74 65 72 20 70 61 73 73 77 6F 72 64 3A 00 Enter password:.
00406040: 6D 79 47 4F 4F 44 70 61 73 73 77 6F 72 64 0A 00 myGOODpassword..
^^^^^^^^^^^^^^
00406050: 57 72 6F 6E 67 20 70 61 73 73 77 6F 72 64 0A 00 Wrong password..
00406060: 50 61 73 73 77 6F 72 64 20 4F 4B 0A 00 00 00 00 Password OK.....
00406070: 40 6E 40 00 00 00 00 00 40 6E 40 00 01 01 00 00 @n@.....@n@.....

Смотрите! Среди всего прочего тут есть одна строка до боли похожая на эталонный пароль (в тексте она выделена жирным шрифтом). Испытаем ее? Впрочем, какой смысл – судя по исходному тексту программы, это действительно искомый пароль, открывающий защиту, словно Золотой Ключик. Только Слишком уж видное место выбрал компилятор для его хранения – пароль не мешало бы запрятать получше.
Один из способов сделать это – насильно поместить эталонный пароль в собственноручно выбранную нами секцию. Такая возможность не предусмотрена стандартном и потому каждый разработчик компилятора (строго говоря, не компилятора, а линкера, но это не суть важно) волен реализовывать ее по-своему (или не реализовывать вообще). В Microsoft Visual C++ для этой цели предусмотрена специальная прагма data_seg, указывающая в какую секцию помещать следующие за ней инициализированные переменные. Неинициализированные переменные по умолчанию располагаются в секции ".bbs" и управляются прагмой bss_seg соответственно.
Добавим в Листинг 1 следующие строки и посмотрим, что из этого у нас получится.

int count=0;
// С этого момента все инициализированные переменные будут
// размещаться в секции ".kpnc"
#pragma data_seg(".kpnc") // точку перед именем ставить
// не обязательно – просто так
// принято
char passwd[]=PASSWORD;
#pragma data_seg()
// Теперь все инициализированные переменные вновь будут
// размещаться в секции по умолчанию, т.е. ".data"
char buff[PASSWORD_SIZE]="";
...
if (strcmp(&buff[0],&passwd[0]))

> dumpbin /RAWDATA:BYTES /SECTION:.data simple2.exe >filename

RAW DATA #3
00406000: 00 00 00 00 00 00 00 00 00 00 00 00 9B 11 40 00 ............Ы.@.
00406010: 04 41 40 00 00 00 00 00 00 00 00 00 40 12 40 00 .A@.........@.@.
00406020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00406030: 45 6E 74 65 72 20 70 61 73 73 77 6F 72 64 3A 00 Enter password:.
00406040: 57 72 6F 6E 67 20 70 61 73 73 77 6F 72 64 0A 00 Wrong password..
00406050: 50 61 73 73 77 6F 72 64 20 4F 4B 0A 00 00 00 00 Password OK.....
00406060: 20 6E 40 00 00 00 00 00 20 6E 40 00 01 01 00 00 n@..... n@.....
00406070: 00 00 00 00 00 00 00 00 00 10 00 00 00 00 00 00 ................

Ага, теперь в секции данных пароля нет и хакеры "отдыхают"! Но не спешите с выводами. Давайте сначала выведем на экран список всех секций, имеющихся в файле:

> dumpbin simple2.exe

Summary
2000 .data
1000 .kpnc
^^^^
1000 .rdata
4000 .text

Нестандартная секция ".kpnc" сразу же приковывает к себе внимание. А ну-ка глянем, что там в ней?

dumpbin /SECTION:.kpnc /RAWDATA simple2.exe

RAW DATA #4
00408000: 6D 79 47 4F 4F 44 70 61 73 73 77 6F 72 64 0A 00 myGOODpassword..
^^^^^^^^^^^^^^

Вот он, пароль! Спрятали, называется… Можно, конечно, извратится и засунуть секретные данные в секцию неинициализированных данных (".bss"), служебную RTL-секцию (".rdata") или даже секцию кода (".text") – не все там догадаются поискать, а работоспособность программы такое размещение не нарушит. Но не стоит забывать о возможности автоматизированного поиска текстовых строк в двоичном фале. Пример реализации такого фильтра приведен в "Приложении" (см. "исходный текст filter.c"). В какой бы секции ни содержался эталонный пароль – фильтр без труда его найдет (единственная проблема – определить какая из множества текстовых строк представляет собой искомый ключ; возможно, потребуется перебрать с десяток-другой потенциальных "кандидатов").
Правда, если пароль записан в уникоде, его поиск несколько осложняется, т.к. не все утилиты поддерживают эту кодировку, но надеяться, что это препятствие надолго задержит хакера – несколько наивно.

Шаг второй. Знакомство с дизассемблером

Надо ли милостивого бога все время просить о пощаде?
Велимир

О'кей, пароль мы узнали. Но как же утомительно вводить его каждый раз с клавиатуры перед запуском программы! Хорошо бы ее хакнуть так, чтобы никакой пароль вообще не запрашивался или любой введенный пароль программа воспринимала бы как правильный.
Хакнуть говорите?! Что ж, это не сложно! Куда проблематичнее определиться – чем именно ее хакать. Инструментарий хакеров чрезвычайно разнообразен – чего тут только нет: и дизассемблеры, и отладчики, и API-, и message- шпионы, и мониторы обращений к файлам (портам, реестру), и распаковщики исполняемых файлов, и… Попробуй-ка, начинающему кодокопателю со всем этих хозяйством разобраться!
Впрочем, шпионы, мониторы, распаковщики – второстепенные утилиты заднего плана, а основное оружие взломщика – отладчик и дизассемблер. Рассмотрим их поближе.
Как и следует из его названия, диз-ассемблер, предназначен для диз-ассемблирования или "раз-ассемблирования" если перейти с латыни на русский {ДИС…, ДИЗ… [лат. dis, ге. dys] – приставка, обозначающая разделение отделение, отрицание; соответствует русским "раз…", "не…", сообщает понятию, к которому прилагается, отрицательный или противоположный смысл, напр. дизассоциация, дисгармония – "словарь иностранных слов"}. То есть если ассемблирование – перевод ассемблерных команд в машинный код, то дизассемблирование, напротив, перевод машинного кода в ассемблерные команды.
Но пусть название не вводит вас в заблуждение: дизассемблер пригоден для изучения не только тех программ, что были написаны на ассемблере, – круг его применения очень широк, хотя и не безграничен. Спрашиваете – где же пролегает эта граница? Отвечаю.
Грубо говоря, все реализации языков программирования делятся на компиляторы и интерпретаторы.
::Интерпретаторы исполняют программу в том виде, в каком она была набрана программистом. Другими словами говоря – интерпретаторы "пережевывают" исходный текст, при этом код программы доступен для непосредственного изучения безо всяких дополнительных средств. Примером могут служить приложения, написанные на Бацике или Перле. Как известно, для их запуска требуется помимо исходного текста программы требуется иметь еще и сам интерпретатор, что неудобно ни пользователям (для исполнения программы в 10 килобайт приходится устанавливать интерпретатор в 10 мегабайт), ни разработчикам (в здравом уме и трезвой памяти раздавать всем исходные тексты своей программы!), к тому же синтаксический разбор отнимает много времени и ни один интерпретатор не может похвастаться производительностью.

::Компиляторы ведут себя иначе – при первом запуске они "перемалывают" программу в машинный код, исполняемый непосредственно самим процессором без обращений к исходным текстам или самому компилятору. С человеческой точки зрения человека откомпилированная программа представляет бессмысленную мешанину шестнадцатеричных байт, разобраться в которой неспециалисту абсолютно невозможно. Это облегчает разработку защитных механизмов – не зная алгоритма, вслепую защиту не сломаешь, ну разве что она будет совсем простая.
Можно ли из машинного кода получить исходный текст программы? Нет,! Компиляция – процесс однонаправленный. И дело тут не только в том, что безвозвратно удаляются метки и комментарии (ррразберемся и без комментариев – хакеры мы или нет?!), основной камень преткновения – неоднозначность соответствия машинных инструкций конструкциям языков высокого уровня. Более того, ассемблирование так же являет собой однонаправленный процесс и автоматическое дизассемблирование принципиально невозможно. Впрочем, не будем сейчас забивать голову начинающих кодокопателей такими тонкостями и оставим эту проблему на потом.

::Ряд систем разработки занимает промежуточное положение между компиляторами и интерпретаторами, – исходная программа преобразуется не в машинный код, а в некоторый другой интерпретируемый язык, для исполнения которого к "откомпилированному" файлу дописывается собственный интерпретатор. Именно по такой схеме функционируют FoxPro, Clipper, многочисленные диалекты Бацика и некоторые другие языки.
Да, код программы по-прежнему исполняется в режиме интерпретации, но теперь из него удалена вся избыточная информация – метки, имена переменных, комментарии, а осмысленные названия операторов заменены их цифровыми кодами. Этот "выстрел" укладывает сразу двух зайцев: а) язык, на который переведена программа, заранее "заточен" под быструю интерпретацию и оптимизирован по размеру; б) код программы теперь недоступен для непосредственного изучения (и/или модификации).
Дизассемблирование таких программ невозможно – дизассемблер нацелен именно на машинный код, а неизвестный ему интерпретируемый язык (так же называемый -кодом) он "не переваривает". Разумеется, -код не переваривает и процессор!, Его исполняет интерпретатор, дописанный к программе. Вот интерпретатор-то дизассемблер и "возьмет"! Изучая алгоритм его работы, можно понять "устройство" -кода и выяснить назначение всех его команд. Это очень трудоемкий процесс! Интерпретаторы порой так сложны и занимают столько много мегабайт, что их анализ растягивается на многие месяцы, а то и годы. К счастью, нет нужны анализировать каждую программу – ведь интерпретаторы одной версии идентичны, а сам -код обычно мало меняется от версии к версии, во всяком случае его ядро не переписывается каждый день. Поэтому, вполне возможно создать программу, занимающуюся переводом -кода обратно в исходный язык. Конечно, символьные имена восстановить не удастся, но в остальном листинг будет выглядеть вполне читабельно.

Итак, дизассемблер применим для исследования откомпилированных программ и частично пригоден для анализа "псевдокомпилированного" кода. Раз так – он должен подойти для вскрытия парольной защиты simple.exe. Весь вопрос в том, – какой дизассемблер выбрать.
Не все дизассемблеры одинаковы. Есть среди них и "интеллектуалы", автоматически распознающие многие конструкции как-то: прологи и эпилоги функций, локальные переменные, перекрестные ссылки и т.д., а есть и "простаки" чьи способности ограничены одним лишь переводом машинных команд в ассемблерные инструкции.
Логичнее всего воспользоваться услугами дизассемблера - интеллектуала (если он есть), но… давайте не будем спешить, а попробуем выполнить весь анализ вручную. Техника, понятное дело, – штука хорошая, на то она и придумана, чтобы решать проблемы, а не создавать новые, да вот только не всегда она оказывается под рукой и неплохо бы заранее научиться работе "в полевых условиях". на том, что всегда есть под рукой. К тому же, общение с плохим дизассемблером как нельзя лучше подчеркивает "вкусности" хорошего.
Воспользуемся уже знакомой нам утилитой DUMPBIN, настоящим "Швейцарским ножиком" со множеством полезных функций, среди которых притаился и дизассемблер. Дизассемблируем секцию кода (как мы помним, носящую имя ".text"), перенаправив вывод в файл, т.к. на экран он, очевидно, не помститься.

> dumpbin /SECTION:.text /DISASM simple.exe >.code

Так, менее чем через секунду образовался файл ".code" с размером… с размером в целых триста с четвертью килобайт. Да исходная программа была на два порядка короче! Это же сколько времени потребуется, чтобы со всей этой шаманской грамотой разобраться?! Самое обидное – подавляющая масса кода никакого отношения к защитному механизму не имеет и представляет собой функции стандартных библиотек компилятора, анализировать которые нам ни к чему. Но как же их отличить от "полезного" кода?
Давайте подумаем. Мы не знаем, где именно расположена процедура сравнения паролей и нам неизвестно ее устройство, но можно с уверенностью утверждать, что один из ее аргументов – указатель на эталонный пароль. Остается только выяснить – по какому адресу расположен этот пароль в памяти – он-то и будет искомым значением указателя.
Заглянем еще раз в секцию данных (или в другую – в зависимости от того, где хранится пароль):

> dumpbin /SECTION:.data /RAWDATA simple.exe >.data

RAW DATA #3
00406000: 00 00 00 00 00 00 00 00 00 00 00 00 7B 11 40 00 ............{.@.
00406010: E4 40 40 00 00 00 00 00 00 00 00 00 20 12 40 00 ф@@......... .@.
00406020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00406030: 45 6E 74 65 72 20 70 61 73 73 77 6F 72 64 3A 00 Enter password:.
00406040: 6D 79 47 4F 4F 44 70 61 73 73 77 6F 72 64 0A 00 myGOODpassword..
^^^^^^^^^ ^^^^^^^^^^^^^^^
00406050: 57 72 6F 6E 67 20 70 61 73 73 77 6F 72 64 0A 00 Wrong password..
00406060: 50 61 73 73 77 6F 72 64 20 4F 4B 0A 00 00 00 00 Password OK.....

Ага, пароль расположен по смещению 0x406040 (левая колонка чисел), стало быть и указатель на него равен 0x406040. Попробуем найти это число в дизассемблированном листинге тривиальным контекстным поиском в любом текстовом редакторе.
Нашли? Вот оно (в тексте выделено жирным шрифтом):

00401045: 68 40 60 40 00 push 406040h
0040104A: 8D 55 98 lea edx,[ebp-68h]
0040104D: 52 push edx
0040104E: E8 4D 00 00 00 call 004010A0
00401053: 83 C4 08 add esp,8
00401056: 85 C0 test eax,eax
00401058: 74 0F je 00401069

Это один из двух аргументов функции 0х04010A0, заносимых в стек машинной командой push. Второй аргумент – указатель на локальный буфер, вероятно, содержащий введенный пользователем пароль.
Тут нам придется немного отклониться от темы разговора и подробно рассмотреть передачу параметров. Наиболее распространенны всего два следующие способаы передачи аргументов функции – через регистры и через стек.
Передача параметров через регистры наиболее быстра, но не лишена недостатков – во-первых, количество регистров весьма ограничено, а во-вторых, это затрудняет реализацию рекурсии – вызова функции из самой себя. Прежде чем заносить в регистры новые аргументы, необходимо предварительно сохранить старые в оперативной памяти. А раз так – не проще ли сразу передать аргументы через оперативную память, не мучаясь с регистрами?
Подавляющее большинство компиляторов передает аргументы через стек. Единого мнения по вопросам передачи у разработчиков компиляторов нет и встречаются по крайней мере два различных механизма, именуемые соглашениями "Си" и "Паскаль".

::Си-соглашение предписывает заталкивать в стек аргументы справа на лево, т.е. самый первый аргумент функции заносится в стек последним и оказывается на его верхушке. Удаление аргументов из стека возложено не на саму функцию, а на вызываемый ее код. Это довольно расточительное решение, т.к. каждый вызов функции утяжеляет программу на несколько байт кода, но зато оно это позволяет создавать функции с переменным числом аргументов – ведь удаляет-то их из стека не сама функция, а вызывающий ее код, который наверняка знает точное количество переданных аргументов.
Очистка стека обычно выполняется командой "ADD ESP,xxx" – где 'xxx' количество удаляемых байт. Поскольку, в 32-разрядном режиме каждый аргумент, как правило, занимает четыре байта, количество аргументов функции вычисляется так: . Оптимизирующие компиляторы могут использовать более хитрый код – для очистки стека от нескольких аргументов они частенько из "выталкивают" в неиспользуемые регистры командой "POP" или и вовсе очищают стек не сразу же после выхода из функции, а совсем в другом месте – где это удобнее компилятору.

::Паскаль-соглашение предписывает заносить аргументы в стек слева на право, т.е. самый первый аргумент функции заносится в стек в первую очередь и оказывается в самом его "низу". Удаление аргументов из функции возложено на саму функцию, и обычно осуществляется командой "RET xxx" – т.е. возврат из подпрограммы со снятием xxx байт со стека.
Возвращаемое функцией значение в обоих соглашениях передается через регистр EAX (или EDX:EAX при возвращении 64-разрядных переменных).
Поскольку, исследуемая нами программа написана на Си и, стало быть, заносит аргументы справа налево, ее исходный текст выглядел приблизительно так:

(*0x4010A0) (ebp-68, "myGOODpassword")

В том, что аргументов именно два, а не, скажем, четные или десять, нас убеждает команда "ADD ESP,8", расположенная вслед за CALL.

0040104E: E8 4D 00 00 00 call 004010A0
00401053: 83 C4 08 add esp,8

Остается выяснить назначение функции 0x4010A0, хотя… если поднапрячь свою интуицию этого можно и не делать.! И так ясно – это функция сравнивает пароль, иначе, зачем бы ей его передавали? Как она это делает – вопрос десятый, а вот что нас действительно интересует – возвращенное ею значение. Так, опускаемся на одну строчку ниже:

0040104E: E8 4D 00 00 00 call 004010A0
00401053: 83 C4 08 add esp,8
00401056: 85 C0 test eax,eax
00401058: 74 0F je 00401069

Что мы видим? Команда TEST EAX,EAX проверяет возвращенное функцией значение на равенство нулю, и если оно действительно равно нулю следующая за ней команда JE совершает прыжок на 0x401096 строку.
В противном же случае (т.е. если EAX !=0)…

0040105A: 68 50 60 40 00 push 406050h

Похоже еще на один указатель. Не правда ли? Проверим это предположение, заглянув в сегмент данных:

00406050: 57 72 6F 6E 67 20 70 61 73 73 77 6F 72 64 0A 00 Wrong password..

Уже теплее! Указатель вывел нас на строку "Wrong password", очевидно выводимую следующей функцией на экран. Значит, ненулевое значение EAX свидетельствует о ложном пароле, а нуль – об истинном.
О'кей, тогда переходим к анализу валидной ветви программы…

0040105F: E8 D0 01 00 00 call 00401234
00401064: 83 C4 04 add esp,4
00401067: EB 02 jmp 0040106B
00401069: EB 16 jmp 00401081

00401081: 68 60 60 40 00 push 406060h
00401086: E8 A9 01 00 00 call 00401234

Так еще, один указатель. Ну, а с функцией 0x401234 мы уже встречались выше – она (предположительно) служит для вывода строк на экран. Ну а сами строки можно отыскать в сегменте данных. На этот раз там притаилась "Password OK"
Оперативные соображения следующие: если заменить команду JE на JNE, то программа отвергнет истинный пароль, как неправильный, а любой неправильный пароль воспримет как истинный. А если заменить "TEST EAX,EAX" на "XOR EAX,EAX", то после исполнения этой команды регистр EAX будет всегда равен нулю, какой бы пароль не вводился.
Дело за малым – найти эти самые байтики в исполняемом файле и малость поправить их.

Шаг третий. Хирургический

Не торопитесь на встречу с Богом, еще встретитесь.
Народная мудрость

Внесение изменений непосредственно в исполняемый файл – дело серьезное. Стиснутыми уже существующим кодом, нам приходится довольствоваться только тем, что есть – и ни раздвинуть команды, ни даже "сдвинуть" их, выкинув из защиты "лишние запчасти", не получится. Ведь это привело бы к "сдвигу" смещений всех остальных команд, тогда как значения указателей и адресов переходов останутся остались без изменений , – они будут и стали указывать совсем не туда, куда нужно!
Ну, с "выкидываем запчастей" справится как раз таки просто – достаточно забить код командами NOP (опкод который 0x90, а вовсе не 0х0, как почему-то думают многие начинающие кодокопатели) – т.е. пустой операцией (вообще-то NOP это просто другая форма записи инструкции XCHG EAX,EAX – если интересно). С "раздвижкой" куда сложнее! К счастью, в WindowsPE-файлах всегда присутствует множество "дыр", оставшихся от выравнивания – в них-то и можно разместить свой код или данные.
Но не проще ли просто откомпилировать ассемблированный файл, предварительно внеся в него требуемые изменения? Нет, не проще, и вот почему – если ассемблер не распознает указатели, передаваемые функции (а, как мы видели, наш дизассемблер не смог отличить их от констант), он, соответственно, не позаботится должным образом их скорректировать и, естественно, программа работать не будет.
Приходится "резать" программу в "живую". Легче всего это сделать с помощью утилиты HIEW, "переваривающей" PE-формат файлов и упрощающей тем самым поиск нужного фрагмента. Запустим его, указав имя файла в командной строке "hiew simple.exe", двойным нажатием переключимся в режим ассемблера и по перейдем к требуемому адресу. Как мы помним, команда "TEST", проверяющая результат, возвращенный функцией на равенство нулю, располагалась по адресу 0x401056.

0040104E: E8 4D 00 00 00 call 004010A0
00401053: 83 C4 08 add esp,8
00401056: 85 C0 test eax,eax
^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^
00401058: 74 0F je 00401069

Чтобы HIEW мог отличить адрес от смещения в самом файле, предварим его символом точки: ".401056"

00401056: 85C0 test eax,eax
00401058: 740F je .000401069 -------- (1)

Ага, как раз то, что нам надо! Нажмем для перевода HIEW в режим правки, подведем курсор к команде "TEST EAX,EAX" и, нажав , заменим ее на "XOR EAX,EAX".

00001056: 33C0 xor eax,eax
00001058: 740F je 000001069

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

>simple.exe
Enter password:Привет, шляпа!
Password OK

Получилось! Защита пала! Хорошо, а как бы мы действовали, не умей HIEW "переваривать" PE-файлы? Тогда бы пришлось прибегнуть к контекстному поиску. Обратим свой взор на шестнадцатеричный дамп, расположенный дизассемблером слева от ассемблерных команд. Конечно, если пытаться найти последовательность "85 C0" – код команды "TEST EAX,EAX" ничего путного из этого не выйдет, – этих самых TEST-ов в программе может быть несколько сотен, а то и больше. Комбинация "ADD ESP,8\TEST EAX,EAX" так же вряд ли будет уникальна, поскольку встречается во многих типовых конструкциях языка Си "if (func(arg1,arg2))…", "if (!func(arg1,arg2))…", "while(func(arg1,arg2)" и т.д. А вот адрес перехода, скорее всего, во всех ветках программы различен и подстрока "ADD ESP,8/TEST EAX,EAX/JE 00401069" имеет хорошие шансы на уникальность. Попробуем найти в файле соответствующий ей код: "83 C4 08 85 C0 74 0F" (в HIEW-е для этого достаточно нажать ).
Опп-с! Найдено только одно вхождение, что нам собственно и нужно. Давайте теперь попробуем модифицировать файл непосредственно в hex-режиме, не переходя в ассемблер. Попутно возьмем себе на заметку – инверсия младшего бита кода команды приводит к изменению условия перехода на противоположное. Т.е. 74 JE  75 JNE.
Работает? (В смысле защита свихнулась окончательно – не признает истинные пароли, зато радостно приветствует остальные). Замечательно! Остается решить: как эту взломанную программу распространять. То есть, распространить-то ее дело не хитрое – на то и существуют CDR-писцы, BBS-ы, сеть Интернет, наконец! Заливай, пиши, нарезай – не хочу. Не хотите – и правильно! Незаконное это дело – распространять программное обеспечение в обход его владельца. Эдак, и засадить могут (причем прецеденты уже имеются). Куда безопаснее возложить распространение программы на ее дистрибьюторов, но до каждого пользователя донести: как эту программу сломать. Ковыряться в законном образом приобретенном приложении потребитель вправе, а распространение информации о взломе не запрещено в силу закона о свободе информации. Правда, при ближайшем рассмотрении выясняется, что этот закон и у нас, и за океаном действует лишь формально, и, если не посадить, то по крайне мере попытаться это сделать, право охранительные органы вполне могут (и не только могут, но и делают). Когда дело касается чьих-то финансовых интересов, правосудие "отдыхает". Наивно думать, что соблюдение закона автоматически дает некие гарантии. Нет, и еще раз нет! Чувствовать себя в относительной безопасности можно лишь при условии соблюдения кодекса "да не навреди сильным мира сего".
В любом случае – информация о взломе это не совсем то же, что сам взлом и за это труднее привлечь к ответственности. Единственная проблема – попробуй-ка, объясни этим пользователям: как пользоваться hex-редактором и искать в нем такие-то байтики. Запорют же ведь файл за милую душу! Вот для этой цели и существуют автоматические взломщики.
Для начала нужно установить, какие именно байты были изменены. Для этого нам вновь потребуется оригинальная копия модифицированного файла (предусмотрительно сохраненная перед его правкой) и какой-нибудь "сравниватель" файлов. Наиболее популярными на сегодняшний день являются c2u by Professor Nimnul и MakeCrk by Doctor Stein's labs. Первый гораздо предпочтительнее, т.к. он не только более точно придерживается наиболее популярного "стандарта", но и умеет генерировать расширенный xck формат. На худой конец можно воспользоваться и штатной утилитой, входящей в поставку MS-DOS\Windows – fc.exe (сокращение от FileCompare).
Запустим свой любимый компаратор (это уж какой кому больше по душе) и посмотрим на результат его работы:

> fc simple.exe simple.ex_ > simple.dif
^-оригинальный ^ файл ^
└- хакнутый‌файл
└- файл различий
> type simple.dif
Сравнение файлов simple.exe и SIMPLE.EX_
00001058: 74 75

Первая слева колонка указывает смещение байта от начала файла, вторая – содержимое байта оригинального файла, а третья – его значение после модификации. Теперь сравним это с отчетом утилиты c2u:

>c2u simple.exe simple.ex_

Все исправления заносятся в файл *.crx, где "*" – имя оригинального файла. Рассмотрим результат сравнения поближе:

>type simple.crx
[BeginXCK]───────────────────────────────────
■ Description : $) 1996 by Professor Nimnul
■ Crack subject :
■ Used packer : None/UnKn0wN/WWPACK/PKLITE/AINEXE/DIET/EXEPACK/PRO-PACK/LZEXE
■ Used unpacker : None/UNP/X-TRACT/iNTRUDER/AUT0Hack/CUP/TR0N
■ Comments :
■ Target OS : D0S/WiN/WNT/W95/0S¤/UNX
■ Protection : [███▓░░░░░░░░░░░░░░░░] %17
■ Type of hack : Bit hack/JMP Correction
■ Language : UnKn0wN/Turbo/Borland/Quick/MS/Visual C/C++/Pascal/Assembler
■ Size : 28672
■ Price : $000
■ Used tools : TD386 v3.2, HiEW 5.13, C2U/486 v0.10
■ Time for hack : 00:00:00
■ Crack made at : 21-07-2001 12:34:21
■ Under Music : iR0N MAiDEN
[BeginCRA]───────────────────────────────────
Difference(s) between simple.exe & simple.ex_
SIMPLE.EXE
00001058: 74 75
[EndCRA]─────────────────────────────────────
[EndXCK]─────────────────────────────────────

Собственно, сам результат сравнений ничуть не изменился, разве что к файлу добавился текстовой заголовок, поясняющий, что это за серверный олень такой и с чем его едят. Все поля не стандартизированы, и их набор сильно разнится от одного взломщика к другому, – при желании вы можете снабдить заголовок своими собственными полями или же, напротив, выкинуть из него чужие. Однако не стоит злоупотреблять этим без серьезной необходимости и лучше придерживаться какого-то одного шаблона.
Итак. "Description" – пояснение к взлому, заполняемое в меру буйства фантазии и уровня распущенности. В нашем случае оно может выглядеть, например, так: "Тестовой взлом N1".

"Crack subject" – предмет крака, - т.е. что собственно мы только что сломали. Пишем "Парольная защита simple.exe"

"Used packer" – используемый упаковщик. Еще во времена старушки MS-DOS существовали и были широко распространены упаковщики исполняемых файлов, автоматически разжимающие файл в памяти при его запуске. Этим достигалась экономия дискового пространства (вспомните: какими смехотворными по нынешним временам были размеры винчестеров конца восьмидесятых-начала девяностых?) и параллельно с этим усиливалась защита – ведь упакованный файл недоступен для непосредственного изучения, а тем более – правки. Прежде, чем начать что-то делать, файл необходимо распаковать, причем это делать приходится и самому ломателю, и всем пользователям этого crk-файла. Поскольку, наш файл не был упакован – оставим это поле пустым, или запишем в него "None".
"Used unpacker" – рекомендуемый распаковщик (если он необходим). Дело в том, что не все распаковщики одинаковы, многие упаковщики весьма продвинуты в технике защиты и умело сопротивляются попыткам их "снять". Понятное дело, распаковщики то же не лыком шиты, и держат своих "тузов" в рукавах, но… автоматическая распаковка – штука капризная. Бывает "интеллектуальный" unpacker легко расправляется со всеми "крутыми" packer-ми, но тихо сдыхает на простых защитах, и, соответственно, случается и наоборот. Дабы не мучить пользователей утомительным перебором всех имеющихся у них распаковщиков (пользователь – он ведь то же человек!) правила хорошо тона обязывают указывать по крайней мере один заведомо подходящий unpacker, а лучше – два или три сразу (вдруг какого-то из них у пользователя и не будет). Если же распаковщик не требуется – оставляйте это поле пустым или "None".

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

"Target OS" – операционная система для которой предназначен и (внимание!) в которой хакер тестировал сломанный продукт. Вовсе не факт, что программа сохранит после взлома черты своей прежней совместимости. Так, например, поле контрольной суммы Win 9x всегда игнорирует, а Win NT – нет и если его не скорректировать, файл запускаться не будет! В нашем случае контрольная сумма заголовка PE-файла равна нулю (так ведет себя компилятор), что означает – целостность файла не проверяется и он, после хака, будет успешно работать как под Win 9x, так и под Win NT.

"Protection" – степень "крутизны" защиты, выражаемой в процентах. 100% по идее соответствуют пределу интеллектуальных возможностей хакера – но кто же в этом захочет признаваться? Неудивительно, что "крутизну" защиты обычно занижают, порой даже больше, чем на порядок (смотрите все, вот я какой крутой хакер, для меня что угодно взломать не сложнее чем кончик хвоста обмочить!). Нечестность – не порок, но…

"Type of hack" – тип хака, - поле полезное, скорее для других хакеров, чем для пользователей, ничего не смыслящих в защитах и типах их взлома. Впрочем, с типами взломов не все гладко и у самих хакеров – общепризнанных классификацией нет. Наиболее употребляемый термин "bit-hack", как и следует из его названия, обозначает взлом посредством изменения одного или нескольких бит в одном или нескольких байтах. Частный случай bit-hack-а – JMP correction (jumping) – модификация адреса или условия перехода (то, что мы только что и проделали). "NOPing" – это bit-hack с заменой прежних инструкций на команду NOP или вставку незначащих команд, например для затирания двухбайтового JZ xxx можно применить сочетание однобайтовых INC EAX/DEC EAX.

"Language" – язык, а точнее компилятор, на котором написана программа. В нашем случае Microsoft Visual C++ (мы это знаем, поскольку только что ее компилировали), а вот как быть с чужими программами? Первое, что приходит на ум, – поискать в файле копирайты – их оставляют очень многие компиляторы, в том числе и Visual C++ - сморите: "000053d9:Microsoft Visual C++ Runtime Library". Если же компиляторов нет, то пробуем прогнать файл через IDA – она автоматически распознает большинство стандартных библиотек даже с указанием конкретной версии. В крайнем случае – пробует определить язык по самому коду, вспоминая о соглашениях Си и Паскаль, и пытаясь найти знакомые черты известных вам компиляторов (у каждого компилятора свой "почерк" и опытный хакер можно узнать не только чем компилировалась программа, но даже определить ключ оптимизации).

"Size" – размер ломаемой программы, служащий для контроля версии (чаще всего, хотя и не всегда, каждая версия программы имеет свой размер). Размер автоматически определяется утилитой c2u и самостоятельно его вставлять нет никакой нужды.

"Price" – стоимость лицензионной копии программы (должен же пользовать знать: сколько денег ему сэкономитсэкономил этот крак!)

"Used tools" – используемые инструменты. Не заполнение этого поля считается дурным тоном – действительно же, интересно, чем именно была хакнута программа! Особенно этим интересуются пользователи, наивно полагающие, что если они раздобудут тот же DUMPBIN и HIEW защита сама собой сломается.

"Time for hack" – время, затраченное на хак, включая перерывы на "перекурить" и "сходить водички попить". Интересно, какой процент людей честно заполняет это поле, не пытаясь показаться "куче" в чужих глазах. Так что особенно доверять ему не следует…

"Crack made at" – дата завершения крака. Подставляется автоматически и править ее нет необходимости (разве что вы "жаворонок" и хотите выдать себя за "сову", проставляя время окончания взлома 3 часами ночи 31 декабря)

"Under Music" – музыка, прослушиваемая во время хака (еще не хватает поля "Имя любимого хомячка"). Вы слушали музыку во время хака? Если да – то пишете – пусть все знают ваши вкусы (за одно не забудьте цвет майки и температуру воздуха за ботом выше нуля).
В результате всех мучений у нас должно получится приблизительно следующее:

[BeginXCK]───────────────────────────────────
■ Description : Тестовый взлом №1
■ Crack subject : Парольная защита simple.exe
■ Used packer : None
■ Used unpacker : None
■ Comments : Hello, Sailor! Ты слишклм долго плавал!
■ Target OS : WNT/W95
■ Protection : [█░░░░░░░░░░░░░░░░░░░] %1
■ Type of hack : JMP Correction
■ Language : Visual C/C++
■ Size : 28672
■ Price : $000
■ Used tools : DUMPBIN, HiEW 6.05, C2U/486 v0.10 & Brain
■ Time for hack : 00:10:00
■ Crack made at : 21-07-2001 12:34:21
■ Under Music : Paul Mauriat L'Ete Indeien "Africa"
[BeginCRA]───────────────────────────────────
Difference(s) between simple.exe & simple.ex_
SIMPLE.EXE
00001058: 74 75
[EndCRA]─────────────────────────────────────
[EndXCK]─────────────────────────────────────

Теперь нам потребуется другая утилита, цель которой прямо противоположна: используя crk (xcrk) файл, изменить эти самые байты в оригинальной программе. Таких утилит на сегодняшний день очень много, что не лучшим образом сказывается на их совместимости с различными crk форматами. Самые известные из них, – cra386 by Professor и pcracker by Doctor Stein's labs.
Из современных Windows-разработок можно отметить "Patch maker" с продвинутым пользовательским интерфейсом (см. Рисунок 2). Он включает в себя сравниватель файлов, crk-редактор, hex-редактор (для ручной замены?) и компилятор crk в исполняемые файлы, чтобы пользователям не приходилось ломать голову: что это за крак такой и как им ломать.
Может, кому-то такой интерфейс и понравится, а вот хакеры в свой массе мышь органически не переносят и любят текстовые (консольные) приложения и тетю Клаву.

Рисунок 2 0x001 Patch Maker за работой!

Шаг четвертый. Знакомство с отладчиком

Оставь свои мозги за дверью и внеси сюда только тело
Фредерик Тейлор

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

Подробный разговор об устройстве отладчика мы отложим на потом (см. "Приемы против отладчиков"), а здесь ограничимся лишь перечнем основных функциональных возможностей типовых отладчиков (без этого невозможно их осмысленное применение):

– отслеживание обращений на запись/чтение/исполнение к заданной ячейке (региону) памяти, далее по тексту именуемое "бряком" ("брейком");

– отслеживание обращений на запись/чтение к портам ввода-вывода (уже не актуально для современных операционных систем, запрещающих пользовательским приложениям проделывать такие трюки – это теперь прерогатива драйверов, а очень на уровне драйверов реализованы очень немногие защиты);

– отслеживание загрузки DLL и вызова из них таких-то функций, включая системные компоненты (как мы увидим далее – это основное оружие современного взломщика);

– отслеживание вызова программных/аппаратных прерываний (большей частью уже не актуально, - не так много защит балуется с прерываниями);

– отслеживание сообщений посылаемых приложением окну;

– и, разумеется, контекстный поиск в памяти.

Как именно делает отладчик – пока знать необязательно, достаточно знать, что он это умеет и все. Куда актуальнее вопрос, – какой отладчик умеет это делать? Широко известный в пользовательских кругах Turbo Debugger на само деле очень примитивный и никчемный отладчик – очень мало хакеров им что-то ломает.
Самое мощное и универсальное средство – Soft-Ice, сейчас доступный для всех Windows-платформ (а когда он поддерживал лишь одну Windows 95, но не Windows NT). Последняя на момент написания книги, четвертая версия, не очень-то стабильно работалает с моим видеоадаптером, поэтому пришлось приходится ограничитваться более ранней, но зато устойчивой версией 3.25.

Способ 0. Бряк на оригинальный пароль.

Используя поставляемую вместе с "Айсом" утилиту "wldr" загрузим ломаемый нами файл, указав его имя в командной строке, например, так:

>wldr simple.exe

Да, я знаю, что wldr – 16-разрядный загрузчик, и NuMega рекомендует использовать его 32-разрядную версию loadrer32, специально разработанную для Win 9x\NT. Это так, но loader32 частенько глючит (в частности не всегда останавливается на первой строчке запускаемой программы), а wldr успешно работает и 32-разрядными приложениями, единственный присущий ему недостаток – отсутствие поддержки длинных имен файлов.
Если отладчик настроен корректно, на экране появится черное текстовое окно, обычно вызывающее большое удивление у начинающих – это в нашу-то это эпоху визуальщины серый текст и командный язык a la command.com.! А почему бы и нет? Набрать на клавиатуре нужную команду куда быстрее, чем отыскать ее в длинной веренице вложенных меню, мучительно вспоминая где же вы ее в последний раз видели. К тому же язык – это естественное средство выражения мыслей, а меню – оно годится разве что для выбора блюд в ресторане. Вот хороший пример – попробуйте с помощью проводника Windows вывести на печать список файлов такой-то директории. Не получается? А в MS-DOS это было так просто dir >PRN и никаких лаптей!
Если в окне кода видны одни лишь инструкции "INVALID" (а оно так и будет) не пугайтесь – просто Windows еще не успела спроецировать исполняемый файл в память и выделилавыделить ему страницы. Стоит нажать (аналог команды "P" – трассировка без заходов в функцию) или (аналог команды "T" – трассировка с заходами в функции) как все сразу же станет на свои места.

001B:00401277 INVALID
001B:00401279 INVALID
001B:0040127B INVALID
001B:0040127D INVALID
:P

001B:00401285 PUSH EBX
001B:00401286 PUSH ESI
001B:00401287 PUSH EDI
001B:00401288 MOV [EBP-18],ESP
001B:0040128B CALL [KERNEL32!GetVersion]
001B:00401291 XOR EDX,EDX
001B:00401293 MOV DL,AH
001B:00401295 MOV [0040692C],EDX

Обратите внимание: в отличие от дизассемблера DUMPBIN, Айс распознает имена системных функций, чем существенно упрощает анализ. Впрочем, анализировать всю программу целиком, нет никакой нужды. Давайте попробуем наскоро найти защитный механизм, и, не вникая в подробности его функционирования, напрочь отрубить защиту. Легко сказать, но сделать еще проще! Вспомним: по какому адресу расположен в памяти оригинальный пароль. Э… что-то плохо у нас с этим получается – то ли память битая, то ли медведь на лапоть наступил, но точный адрес никак не хочет вспоминаться. Не хочет – не надо. Найдем-ка мы его самостоятельно!
В этом нам поможет команда "map32" выдающая карту памяти выбранного модуля (наш модуль называется "simple" – по имени исполняемого файла за вычетом расширения).

:map32 simple
Owner Obj Name Obj# Address Size Type
simple .text 0001 001B:00401000 00003F66 CODE RO
simple .rdata 0002 0023:00405000 0000081E IDATA RO
simple .data 0003 0023:00406000 00001E44 IDATA RW
^^^^ ^^^^^^^^^^^^^

Вот он, адрес начала секции ".data". То, что пароль находится в секции ".data", надеюсь, читатель все еще помнит. Даем команду "d 23:406000" (возможно предварительно придется создать окно командой "wc" – если окна данных нет) и, нажав, для перехода в это окно, прокрутим его содержимое <стрелкой вниз> или кирпичом на . Впрочем, кирпич излишен, – долго искать не придется:

0023:00406040 6D 79 47 4F 4F 44 70 61-73 73 77 6F 72 64 0A 00 myGOODpassword..
0023:00406050 57 72 6F 6E 67 20 70 61-73 73 77 6F 72 64 0A 00 Wrong password..
0023:00406060 50 61 73 73 77 6F 72 64-20 4F 4B 0A 00 00 00 00 Password OK.....
0023:00406070 47 6E 40 00 00 00 00 00-40 6E 40 00 01 01 00 00 Gn@.....@n@.....
0023:00406080 00 00 00 00 00 00 00 00-00 10 00 00 00 00 00 00 ................
0023:00406090 00 00 00 00 00 00 00 00-00 00 00 00 02 00 00 00 ................
0023:004060A0 01 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
0023:004060B0 00 00 00 00 00 00 00 00-00 00 00 00 02 00 00 00 ................

Есть контакт! Задумаемся еще раз (второй раз за этот день) чтобы проверить корректность введенного пользователем пароля защита, очевидно должна сравнить его с оригинальным. А раз так – установив точку останова на чтение памяти по адресу 0x406040, мы поймаем "за хвост" сравнивающий механизм. Сказано – сделано.

:bmpm 406040

Теперь нажимаем для выхода из отладчика (или отдаем команду "x") и вводим любой пришедший на ум пароль, например, "KPNC++". Отладчик "всплывает" незамедлительно:

001B:004010B0 MOV EAX,[EDX]
001B:004010B2 CMP AL,[ECX]
001B:004010B4 JNZ 004010E4 (JUMP ↑)
001B:004010B6 OR AL,AL
001B:004010B8 JZ 004010E0
001B:004010BA CMP AH,[ECX+01]
001B:004010BD JNZ 004010E4
001B:004010BF OR AH,AH

Break due to BPMB #0023:00406040 RW DR3 (ET=752.27 milliseconds)
MSR LastBranchFromIp=0040104E
MSR LastBranchToIp=004010A0

В силу архитектурных особенностей процессоров Intel, бряк срабатывает после инструкции, выполнившей "поползновение", т.е. CS:EIP указывают на следующую выполняемую команду. В нашем случае – JNZ 004010E4, а к памяти, стало быть, обратилась инструкция CMP AL, [ECX]. А что находится в AL? Поднимаем взгляд еще строкой выше – "MOV EAX,[EDX]". Можно предположить, что EСX содержит указатель на строку оригинального пароля (поскольку он вызвал всплытие отладчика), а EDX в таком случае – указатель на введенный пользователем пароль. Проверим наше предположение.

:d edx
0023:00406040 6D 79 47 4F 4F 44 70 61-73 73 77 6F 72 64 0A 00 myGOODpassword..
:d edx
0023:0012FF18 4B 50 4E 43 2B 2B 0A 00-00 00 00 00 00 00 00 00 KPNC++..........

И правда – догадка оказалась верна. Теперь вопрос – а как это заломить? Вот, скажем, JNZ можно поменять на JZ или, еще оригинальнее, заменить EDX на ECX – тогда оригинальный пароль будет сравниваться сам с собой! Погодите, погодите… не стоит так спешить! А что если мы находится не в теле защиты, а в библиотечной функции (действительно, мы находится в теле strcmp), – ее изменение приведет к тому, что программа любые строки будет воспринимать как идентичные. Любые – а не только строки пароля. Это не повредит нашему примеру, где strcmp вызывалась лишь однажды, но завалит нормальное полнофункциональное приложение. Что же делать?
Выйти из strcmp и подкорректировать тот самый "IF", который анализирует правильный – не правильный пароль. Для этого служит команда "P RET" (трассировать пока не встреться ret – инструкция возврата из функции).

:P RET
001B:0040104E CALL 004010A0
001B:00401053 ADD ESP,08
001B:00401056 TEST EAX,EAX
001B:00401058 JZ 00401069
001B:0040105A PUSH 00406050
001B:0040105F CALL 00401234
001B:00401064 ADD ESP,04
001B:00401067 JMP 0040106B

Знакомые места! Помните, мы их посещали дизассемблером? Алгоритм действий прежний – запоминаем адрес команды "TEST" для последующей замены ее на "XOR" или записываем последовательность байт, идентифицирующую… эй, постойте, а где же наши байты – шестнадцатеричное представление команд? Коварный Айс по умолчанию их не выводит, и заставить его это делать помогает команда "CODE ON"

code on
001B:0040104E E84D000000 CALL 004010A0
001B:00401053 83C408 ADD ESP,08
001B:00401056 85C0 TEST EAX,EAX
001B:00401058 740F JZ 00401069
001B:0040105A 6850604000 PUSH 00406050
001B:0040105F E8D0010000 CALL 00401234
001B:00401064 83C404 ADD ESP,04
001B:00401067 EB02 JMP 0040106B

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

001B:0042104E E87D000000 CALL 004210D0
001B:00421053 83C408 ADD ESP,08====
001B:00421056 85C0 TEST EAX,EAX
001B:00421058 740F JZ 00421069

Сейчас мы запустим HIEW, перейдем по адресу 0x421053 и… эй, постой, HIEW ругается и говорит, что в файле нет такого адреса! Последний байт заканчивается на 0x407FFF. Быть может, мы находимся в теле системной функции Windows? Но нет – системные функции Windows расположены значительно выше – начиная с адреса 0x80000000.
Фокус весь в том, что PE-файл может быть загружен по адресу отличному от того, для которого он был создан (это свойство называется перемещаемостью), - при этом система автоматически корректирует все ссылки на абсолютные адреса, заменяя их новыми значениями. В результате – образ файла в памяти не будет соответствовать тому, что записано на диске. Хорошенькое начало! Как же теперь найти место, которое нужно править?
Задачу несколько облегчает тот факт, что системный загрузчик умеет перемещать только DLL, а исполняемые файлы всегда пытается загрузить по "родному" для них адресу. Если же это невозможно – загрузка прерывается с выдачей сообщения об ошибке. Выходит, мы имеем дело с DLL, загруженной исследуемой нами защитой. Хм… вроде бы не должно быть здесь никаких DLL – да и откуда бы им взяться?
Что ж, изучим листинг 2 на предмет выяснения: как же он работает.

#include
#include

__declspec(dllexport) void Demo()
^^^^^^^^^^^^^^^^^^^^^
{
#define PASSWORD_SIZE 100
#define PASSWORD "myGOODpassword\n"

int count=0;
char buff[PASSWORD_SIZE]="";

for(;;)
{
printf("Enter password:");
fgets(&buff[0],PASSWORD_SIZE-1,stdin);

if (strcmp(&buff[0],PASSWORD))
printf("Wrong password\n");
else break;

if (++count>2) return -1;
}
printf("Password OK\n");
}

main()
{
HMODULE hmod;
void (*zzz)();

if ((hmod=LoadLibrary("crack0~1.exe"))
&& (zzz=(void (*)())GetProcAddress(h,"Demo")))
zzz();

}
Листинг 2 Исходный текст защиты crackme 0x3
Какой, однако, извращенный способ вызова функции! Защита экспортирует ее непосредственно из самого исполняемого файла и этот же файл загружает как DLL (да, один и тот же файл может быть одновременно и исполняемым приложением и динамической библиотекой!).
"Все равно ничего не сходится", - возразит программист средней квалификации, - "всем же известно, что Windows не настолько глупа, чтобы дважды грузить один и тот же файл, - LoadLibrary всего лишь возвратит базовый адрес модуля crackme0x03, но не станет выделять для него память". А вот как бы не так! Хитрая защита обращается к файлу по его альтернативному короткому имени, вводя системный загрузчик в глубокое заблуждение!
Система выделяет память и возвращает базовый адрес загружаемого модуля в переменной hmod. Очевидно, код и данные этого модуля смещены на расстояние hmod – base, где base – базовый адрес модуля – тот, с которым работают HIEW и дизассемблер. Базовый адрес узнать нетрудно – достаточно вызвать тот же DUMPBIN с ключом "/HEADERS" (его ответ приведен в сокращенном виде)

>dumpbin /HEADERS crack0x03
OPTIONAL HEADER VALUES
...
400000 image base
^^^^^^^^^^^^^^^^^
...

Значит, базовый адрес – 0x400000 (в байтах). А опередить адрес загрузки можно командой "mod -u" отладчика: (ключ u разрешает выводить только прикладные, т.е. не системные модули).

:mod -u
hMod Base PEHeader Module Name File Name
00400000 004000D8 crack0x0 \.PHCK\src\crack0x03.exe
00420000 004200D8 crack0x0 \.PHCK\src\crack0x03.exe
^^^^^^^^
77E80000 77E800D0 kernel32 \WINNT\system32\kernel32.dll
77F80000 77F800C0 ntdll \WINNT\system32\ntdll.dll

Смотрите, загружено сразу две копии crack0x03, причем последняя расположена по адресу 0x420000, как раз что нам надо! Теперь нетрудно посчитать, что адрес 0x421056 (тот, что мы пытались последний раз найти в ломаемом файле) "на диске" будет соответствовать адресу 0x421056 – (0x42000 – 0x400000) == 0x421056 – 0x20000 == 0x401056. Смотрим:

00401056: 85C0 test eax,eax
00401058: 740F je .000401069 -------- (1)

Все верно – посмотрите, как хорошо это совпадает с дампом отладчика:

001B:00421056 85C0 TEST EAX,EAX
001B:00421058 740F JZ 00421069

Разумеется, описанная методика вычислений применима к любым DLL, а не только тем, что представляют собой исполняемый файл.
А вот, если бы мы пошли не путем адресов, а попытались найти в ломаемой программе срисованную с отладчика последовательность байт, включая и ту часть, которая входит в CALL 00422040 – интересно, нашли бы мы ее или нет?

001B:0042104E E87D000000 CALL 004210D0
001B:00421053 83C408 ADD ESP,08
001B:00421056 85C0 TEST EAX,EAX
001B:00421058 740F JZ 00421069
:Образ файла в памяти.

.0040104E: E87D000000 call .0004010D0 -------- (1)
.00401053: 83C408 add esp,008 ;"◘"
.00401056: 85C0 test eax,eax
.00401058: 740F je .000401069 -------- (2)
:Образ файла на диске

Вот это новость – командам CALL 0x4210D0 и CALL 0x4010D0 соответствует один и тот же машинный код – E8 7D 00 00 00! Как же такое может быть?! А вот как – аргумент операнд процессорной инструкции "0xE8" представляет собой не смещение подпрограммы, а разницу смещений подпрограммы и инструкции, следующей за командой call. Т.е. в первом случае: 0x421053 (смещение инструкции, следующей за CALL) + 0x0000007D (не забываем об обратном порядке байтов в двойном слове) == 0x4210D0, - вот он, искомый адрес. Таким образом, при изменении адреса загрузки, коррекция кодов команд CALL не требуется.

"Оценка по аналогии основывается на предположении, что если два или более объекта согласуются друг с другом в некоторых отношениях, то они, вероятно, согласуются и в других отношениях"
Ганс Селье "От мечты к открытию"

Рассуждения по аналогии – опасная штука. Увлеченные стройностью аналогии мы подчас даже не задумываемся о проверке. Между тем, аналогии лгут чаще, чем этого хотелось бы.
В примере crack0x03 среди прочего кода есть и такая строка (найдите ее с помощью hiew):
004012C5: 89154C694000 mov [00040694C],edx

Легко видеть, что команда MOV обращается к ячейке не по относительному, а по абсолютному адресу. Вопрос: а) выясните, что произойдет при изменении адреса загрузки модуля; б) как вы думаете – будет ли теперь совпадать образ файла на диске и в памяти?
Заглянув отладчиком по адресу 0x4212C5 (0x4012C5 + 0x2000) мы увидим, что обращение идет совсем не к ячейке 0x42694C, а – 0x40694C! Наш модуль самым бессовестным образом вторгается в чужие владения, модифицируя их по своему усмотрению. Так и до краха системы докатиться недолго! В данном случае этого не происходит только потому, что искомая строка расположена в Startup-процедуре (стартовом коде) и выполняется лишь однажды – при запуске приложения, а из загруженного модуля не вызывается.
Другое дело, если бы функция Demo() обращалась к какой-нибудь статической переменной – компилятор, подставив ее непосредственное смещение, сделал бы модуль неперемещаемым! После сказанного становится непонятно: как же тогда ухитряются работать динамически подключаемые библиотеки (DLL), адрес загрузки которых заранее неизвестен? Поразмыслив некоторое время, мы найдем, по крайней мере, два решения проблемы:
Первое – вместо непосредственной адресации использовать относительную, например: [reg+offset_val], где reg – регистр, содержащий базовый адрес загрузки, а offset_val – смещение ячейки от начала модуля. Это позволит модулю грузится по любому адресу, но заметно снизит производительность программы уже хотя бы за счет потери одного регистра….
Второе – научить загрузчик корректировать непосредственные смещения в соответствии с выбранным базовым адресом загрузки. Это, конечно, несколько замедлит загрузку, но зато не ухудшит быстродействие самой программы. Не факт, что временем загрузки можно свободно пренебречь, но парни из Microsoft выбрали именно этот способ.
Единственная проблема – как отличить действительные непосредственные смещения от констант, совпадающих с ними по значению? Не дизассемблировать же в самом деле DLL, чтобы разобраться какие именно ячейки в ней необходимо "подкрутить"? Верно, куда проще перечислить их адреса в специальной таблице, расположенной непосредственно в загружаемом файле и носящей гордое имя "Таблицы перемещаемых элементов" или (Relocation [Fix Up] table по-английски). За ее формирование отвечает линкер (он же – компоновщик) и такая таблица присутствует в каждой DLL.
Чтобы познакомиться с ней поближе откомпилируем и изучим следующий пример:

::fixupdemo.c
__declspec(dllexport) void meme(int x)
{
static int a=0x666;
a=x;
}
> cl fixupdemo.c /LD

Листинг 3 Исходный текст fixupdemo.c

Откомпилируем и тут же дизассемблируем его: "DUMPBIN /DISASM fixupdemo.dll" и "DUMPBIN /SECTION:.data /RAWDATA".

10001000: 55 push ebp
10001001: 8B EC mov ebp,esp
10001003: 8B 45 08 mov eax,dword ptr [ebp+8]
10001006: A3 30 50 00 10 mov [10005030],eax
^^^^^^^^^^^ ^^^^^^^^
1000100B: 5D pop ebp
1000100C: C3 ret

RAW DATA #3
10005000: 00 00 00 00 00 00 00 00 00 00 00 00 33 24 00 10 ............3$..
10005010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
10005020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
10005030: 66 06 00 00 E3 11 00 10 FF FF FF FF 00 00 00 00 f...у...    ....
^^^^^

Судя по коду, запись содержимого EAX всегда происходит в ячейку 0x10005030, но не торопите с выводами! "DUMPBIN /RELOCATIONS fixupdemo.dll":

BASE RELOCATIONS #4
1000 RVA, 154 SizeOfBlock
7 HIGHLOW
^
1C HIGHLOW
23 HIGHLOW
32 HIGHLOW
3A HIGHLOW

Таблица перемещаемых элементов-то не пуста! И первая же ее запись указывает на ячейку 0x100001007, полученную алгебраическим сложением смещения 0x7 с RVA-адресом 0x1000 и базовым адресом загрузки 0x10000000 (получите его с помощью DUMPBIN самостоятельно). Смотрим – ячейка 0x100001007 принадлежит инструкции "MOV [0x10005030],EAX" и указывает на самый старший байт непосредственного смещения. Вот это самое смещение и корректирует загрузчик в ходе подключения динамической библиотеки (разумеется, если в этом есть необходимость).
Хотите проверить? Пожалуйста, - создадим две копии одной DLL (например, copy fixupdemo.dll fixupdemo2.dll) и загрузим их поочередной следующей программой:

::fixupload.c
#include

main()
{
void (*demo) (int a);
HMODULE h;
if ((h=LoadLibrary("fixupdemo.dll")) &&
(h=LoadLibrary("fixupdemo2.dll")) &&
(demo=(void (*)(int a))GetProcAddress(h,"meme")))
demo(0x777);
}
> cl fixupload
Листинг 4 Исходный текст fixupload
Поскольку, по одному и тому же адресу две различные DLL не загрузишь (откуда же системе знать, что это одна и та же DLL!), загрузчику приходится прибегать к ее перемещению. Загрузим откомпилированную программу в отладчик и установим точку останова на функцию LoadLibraryA. Это, – понятное дело, – необходимо чтобы пропустить Startup-код и попасть в тело функции main. (Как легко убедиться исполнение программы начинается отнюдь не с main, а со служебного кода, в котором очень легко утонуть). Но откуда взялась загадочная буква 'A' на конце имени функции? Ее происхождение тесно связано с введением в Windows поддержки уникода – специальной кодировки, каждый символ в которой кодируется двумя байтами, благодаря чему приобретает способность выражать любой из 216 = 65.536 знаков, – количество достаточно для вмещения практически всех алфавитов нашего мира. Применительно к LoadLibrary – теперь имя библиотеки может быть написано на любом языке, а при желании и на любом количестве любых языков одновременно, например, на русско-француско-китайском. Звучит заманчиво, но не ухудшает ли это производительность? Разумеется, ухудшает, еще как – уникод требует жертв! Самое обидное – в подавляющем большинстве случаев вполне достаточно старой доброй кодировки ASCII (во всяком случае нам, – русским, и американцам). Так какой же смысл бросать драгоценные такты процесса на ветер? Ради производительности было решено поступиться размером, создав отдельные варианты функций для работы с уникодом и ASCII-символами. Первые получили суффикс 'W' (от Wide – широкий), а вторые – 'A' (от ASCII). Эта тонкость скрыта от прикладных программистов – какую именно функцию вызывать 'W' или 'A' решает компилятор, но при работе с отладчиком необходимо указывать точное имя функции – самостоятельно определить суффикс он не в состоянии. Камень преткновения в том, что некоторые функции, например, ShowWindows вообще не имеют суффиксов – ни 'A', ни 'W' и их библиотечное имя совпадает с каноническим. Как же быть?
Самое простое – заглянуть в таблицу импорта препарируемого файла и отыскать там вашу функцию. Например, применительно к нашему случаю:

> DUMPBIN /IMPORTS fixupload.exe > filename
> type filename
19D HeapDestroy
1C2 LoadLibraryA
CA GetCommandLineA
174 GetVersion
7D ExitProcess
29E TerminateProcess
...

Из приведенного выше фрагменты видно, что LoadLibrary все-таки 'A', а вот функции ExitProcess и TerminateProcess не имеют суффиксов, поскольку вообще не работают со строками.
Другой путь – заглянуть в SDK. Конечно, библиотечное имя функций в нем отсутствует, но в "Quick Info" мимоходом приводится информация и поддержке уникода (если таковая присутствует). А раз есть уникод – есть суффиксы 'W' и 'A', соответственно, наоборот – где нет уникода, нет и суффиксов. Проверим?
Вот так выглядит Quick Info от LoadLibrary:

QuickInfo
Windows NT: Requires version 3.1 or later.
Windows: Requires Windows 95 or later.
Windows CE: Requires version 1.0 or later.
Header: Declared in winbase.h.
Import Library: Use kernel32.lib.
Unicode: Implemented as Unicode and ANSI versions on Windows NT.

На чистейшем английском языке здесь сказано – "Реализовано как Unicode и ANSI версии на Windows NT". Стоп! С NT все понятно, а как насчет "народной" девяносто восьмой (пятой)? Беглый взгляд на таблицу экспорта KERNEL32.DLL показывает: такая функция там есть, но, присмотревшись повнимательнее, мы с удивлением обнаружим, что ее точка входа совпадает с точками входа десятка других функций!

ordinal hint RVA name
556 1B3 00039031 LoadLibraryW

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

21: 118 1 00039031 AddAtomW
116: 217 60 00039031 DeleteFileW
119: 220 63 00039031 DisconnectNamedPipe
178: 279 9E 00039031 FindAtomW
204: 305 B8 00039031 FreeEnvironmentStringsW
260: 361 F0 00039031 GetDriveTypeW
297: 398 115 00039031 GetModuleHandleW
341: 442 141 00039031 GetStartupInfoW
377: 478 165 00039031 GetVersionExW
384: 485 16C 00039031 GlobalAddAtomW
389: 490 171 00039031 GlobalFindAtomW
413: 514 189 00039031 HeapLock
417: 518 18D 00039031 HeapUnlock
440: 541 1A4 00039031 IsProcessorFeaturePresent
455: 556 1B3 00039031 LoadLibraryW
508: 611 1E8 00039031 OutputDebugStringW
547: 648 20F 00039031 RemoveDirectoryW
590: 691 23A 00039031 SetComputerNameW
592: 693 23C 00039031 SetConsoleCP
597: 698 241 00039031 SetConsoleOutputCP
601: 702 245 00039031 SetConsoleTitleW
605: 706 249 00039031 SetCurrentDirectoryW
645: 746 271 00039031 SetThreadLocale
678: 779 292 00039031 TryEnterCriticalSection

Вот это сюрприз! Все уникодеовые – функции под одной крышей! Поскольку, трудно поверить в идентичность реализаций LoadLibraryW и, скажем, DeleteFileW, остается предположить, что мы имеем дело с "заглушкой", которая ничего не делает, а только возвращает ошибку. Следовательно, в 9x действительно, функция LoadLibraryW не реализована.
Но, вернемся, к нашим баранам от которых нам пришлось так далеко отойти. Итак, вызываем отладчик, ставим бряк на LoadLibraryA, выходим из отладчика и терпеливо дожидаемся его всплытия. Должно ждать, к счастью, не приходится…

KERNEL32!LoadLibraryA                 
001B:77E98023 PUSH EBP
001B:77E98024 MOV EBP,ESP
001B:77E98026 PUSH EBX
001B:77E98027 PUSH ESI
001B:77E98028 PUSH EDI
001B:77E98029 PUSH 77E98054
001B:77E9802E PUSH DWORD PTR [EBP+08]

Отдаем команду "P RET" для выхода из LoadLibraryA (анализировать ее, в самом деле, ни к чему) и оказываемся в легко узнаваемом теле функции main.

001B:0040100B CALL [KERNEL32!LoadLibraryA]
001B:00401011 MOV [EBP-08],EAX           
001B:00401014 CMP DWORD PTR [EBP-08],00
001B:00401018 JZ 00401051
001B:0040101A PUSH 00405040
001B:0040101F CALL [KERNEL32!LoadLibraryA]
001B:00401025 MOV [EBP-08],EAX
001B:00401028 CMP DWORD PTR [EBP-08],00

Обратите внимание на содержимое регистра EAX – функция возвратила в нем адрес загрузки (на моем компьютере равный 0x10000000). Продолжая трассировку (), дождитесь выполнения второго вызова LoadLibraryA – не правда ли, на этот раз адрес загрузки изменился? (на моем компьютере он равен 0x0530000).
Приблизившись к вызову функции demo (в отладчике это выглядит как PUSH 00000777\ CALL [EBP-04] – "EBP-04" ни о чем не говорит, но вот аргумент 0x777 определенно что-то нам напоминает, - см. исходный текст fixupload.c), не забудьте переменить руку с на , чтобы войти внутрь функции.

001B:00531000 55 PUSH EBP
001B:00531001 8BEC MOV EBP,ESP
001B:00531003 8B4508 MOV EAX,[EBP+08]
001B:00531006 A330505300 MOV [00535030],EAX
001B:0053100B 5D POP EBP
001B:0053100C C3 RET

Вот оно! Системный загрузчик скорректировал адрес ячейки согласно базовому адресу загрузки самой DLL. Это, конечно, хорошо, да вот проблема – в оригинальной DLL нет ни такой ячейки, ни даже последовательности "A3 30 50 53 00", в чем легко убедиться контекстным поиском. Допустим, вознамерились бы мы затереть эту команду NOP-ми. Как это сделать?! Вернее, как найти это место в оригинальной DLL?
Обратим свой взор выше, на команды, заведомо не содержащие перемещаемых элементов – PUSH EBP/MOV EBP, ESP/MOV EAX,[EBP+08]. Отчего бы не поискать последовательность "55 8B EC xxx A3"? В данном случае это сработает, но если бы перемещаемые элементы были густо перемешаны "нормальными" ничего бы не вышло. Опорная последовательность оказалась бы слишком короткой для поиска и выдала бы множество ложных срабатываний.
Более изящно и надежно вычислить истинное содержимое перемещаемых элементов, вычтя их низ разницу между действительным и рекомендуемым адресом загрузки. В данном случае: 0x535030 /модифицированный загрузчиком адрес/ – (0x530000 /базовый адрес загрузки/ - 0x10000000 /рекомендуемый адрес загрузки/) == 0x10005030. Учитывая обратный порядок следования байт, получаем, что инструкция MOV [10005030], EAX в машинном коде должна выглядеть так: "A3 30 50 00 10". Ищем ее HIEW-ом, и чудо – она есть!

Способ 1. Прямой поиск введенного пароля в памяти

Был бы омут, а черти будут.
народная поговорка

Пароль, хранящийся в теле программы открытым текстом, – скорее из ряда вон выходящее исключение, чем правило. К чему услуги хакера, если пароль и без того виден невооруженным взглядом? Поэтому, разработчики защиты всячески пытаются скрыть его от посторонних глаз (о том, как именно они это делают, мы поговорим позже). Впрочем, учитывая размер современных пакетов, программист может, не особо напрягаясь, поместить пароль в каком-нибудь завалявшемся файле, попутно снабдив его "крякушами" – строками, выглядевшими как пароль, но паролем не являющимися. Попробуй, разберись, где тут липа, а где нет, тем паче, что подходящих на эту роль строк в проекте средней величины может быть несколько сотен, а то и тысяч!
Давайте подойдем к решению проблемы от обратного – будем искать не оригинальный пароль, который нам не известен, а ту строку, которую мы скормили программе в качестве пароля. А, найдя – установим на нее бряк, и дальше все точно так же, как и раньше. Бряк всплывает на обращение по сравнению, мы выходим из сравнивающей процедуры, корректируем JMP, и…
Взглянем еще раз на исходный текст ломаемого нами примера "simple.c"

for(;;)
{
printf("Enter password:");
fgets(&buff[0],PASSWORD_SIZE,stdin);

if (strcmp(&buff[0],PASSWORD))
printf("Wrong password\n");
else break;
if (++count>2) return -1;
}

Обратите внимание – в buff читается введенный пользователем пароль, сравнивается с оригиналом, затем (при неудачном сравнении) запрашивается еще раз, но (!) при этом buff не очищается! Отсюда следует, что если после выдачи ругательства "Wrong password" вызвать отладчик и пройтись по памяти контекстным поиском, можно обнаружить тот заветный buff, а остальное уже – дело техники!
Итак, приступим (мы еще не знаем, во что мы ввязываемся – но, увы – в жизни все сложнее, чем в теории). Запускам SIMPLE.EXE, вводим любой пришедший на ум пароль (например, "KPNC Kaspersky++"), пропускаем возмущенный вопль "Wrong" мимо ушей и нажимаем - "горячую" комбинацию клавиш для вызова Айса. Так, теперь будем искать? Подождите, не надо бежать впереди лошадей: Windows 9x\NT – это не Windows 3.x и, тем более, не MS-DOS с единым адресным пространством для всех процессоров. Теперь, по соображениям безопасности, - дабы один процесс ненароком не залез во владения другого, каждому из них предоставляется собственное адресное пространство. Например, у процесса A по адресу 23:0146660 может быть записано число "0x66", у процесса B по тому же самому адресу 23:0146660 может находиться "0x0", а у процесса C и вовсе третье значение. Причем, процессы А, B и C не будет даже подозревать о существовании друг друга (ну, разве что воспользуются специальными средствами межпроцессорного взаимодействия).
Подробнее обо всем этом читайте у Хелен или Рихтера, здесь же нас больше заботит другое – вызванный по отладчик "всплывает" в произвольном процессе (скорее всего Idle) и контекстный поиск в памяти ничего не даст. Необходимо насильно переключить отладчик в необходимый контекст адресного пространства и лишь затем что-то предпринимать.
Из прилагаемой к Айсу документации можно узнать, что переключение контекстов осуществляется командой ADDR, за которой следует либо имя процесса, урезанное до восьми символов, либо его PID. Узнать и то, и другое можно с помощью другой команды – PROC (В том, случае если имя процесса синтаксически неотличимо от PID, например, "123", приходится использовать PID процесса – вторая колонка цифр слева, в отчете PROC).

:addr simple

Отдаем команду "addr simple" и… ничего не происходит, даже значения регистров остаются неизменными! Не волнуйтесь – все ОК, что и подтверждает надпись 'simple' в правом нижнем углу, идентифицирующая текущий процесс. А регистры… это небольшой глюк Айса. Он них игнорирует, переключая только адреса. В частности поэтому, трассировка переключенной программы невозможна. Вот поиск – другое дело. Это – пожалуйста!

:s 23:0 L -1 "KPNC Kaspersky"

Пояснения: первый слева аргумент после s – адрес, записанный в виде "селектор: смещение". Под Windows 2000 для адресации данных и стека используется селектор номер 23, в других операционных системах он может отличаться (и отличается!). Узнать его можно загрузив любую программу, и списав содержимое регистра DS. Смещение – вообще-то, начинать поиск с нулевого смещения – идея глупая. Судя по карте памяти, здесь расположен служебный код и искомого пароля быть не может. Впрочем, это ничему не вредит, и так гораздо быстрее, чем разбираться: с какого адреса загружена программа, и откуда именно начинать поиск. Третий аргумент – "L –1" – длина региона для поиска. "-1", как нетрудно догадаться, – поиск "до победы". Далее - обратите внимание, что мы ищем не всю строку – а только ее часть ("KPNC Kaspersky++" против "KPNC Kaspersky") . Это позволяет избавиться от ложных срабатываний – Айс любит выдавать ссылки на свои внутренние буфера, содержащие шаблон поиска. Вообще-то они всегда расположены выше 0х80000000. Там – где никакой нормальный пароль "не живет", но все же будет нагляднее если по неполной подстроке находится именно наша строка.

Pattern found at 0023:00016E40 (00016E40)

Так, по крайней мере, одно вхождение уже найдено. Но вдруг в памяти есть еще несколько? Проверим это, последовательно отдавая команды "s" вплоть до выдачи сообщения "Pattern not found" или превышении адреса поиска 0x80000000.

:s
Pattern found at 0023:0013FF18 (0013FF18)
:s
Pattern found at 0023:0024069C (0024069C)
:s
Pattern found at 0023:80B83F18 (80B83F18)

Целых два вхождения, да еще одно "в уме" – итого три! Не много ли для нас, начинающих? Во-первых, неясно – вводимые пароли они, что плоятся ака кролики? Во-вторых, ну не ставить же все три точки останова. В данном случае четырех отладочных регистров процессора хватит, а как быть, если бы мы нашли десяток вхождений? Да и в трех бряках немудрено заблудиться с непривычки!
Итак – начинаем работать головой. Вхождений много, вероятнее всего потому, что при чтении ввода с клавиатуры символы сперва попадают в системные буфера, которые и дают ложные срабатывания. Звучит вполне правдоподобно, но вот как отфильтровать "помехи"?
На помощь приходит карта памяти – зная владельца региона, которому принадлежит буфер, об этом буфере очень многое можно сказать. Наскоро набив команду "map32 simple" мы получим приблизительно следующее.

:map32 simple
Owner Obj Name Obj# Address Size Type
simple .text 0001 001B:00011000 00003F66 CODE RO
simple .rdata 0002 0023:00015000 0000081E IDATA RO
simple .data 0003 0023:00016000 00001E44 IDATA RW

Ура, держи Тигру за хвост, есть одно отождествление! Буфер на 0x16E40 принадлежит сегменту данных и, видимо, это и есть то, что нам нужно. Но не стоит спешить! Все не так просто. Поищем-ка адрес 0x16E40 в самом файле simple.exe (учитывая обратный порядок байт это будет "40 E6 01 00"):

> dumpbin /SECTION:.data /RAWDATA simple.exe
RAW DATA #3
00016030: 45 6E 74 65 72 20 70 61 73 73 77 6F 72 64 3A 00 Enter password:.
00016040: 6D 79 47 4F 4F 44 70 61 73 73 77 6F 72 64 0A 00 myGOODpassword..
00016050: 57 72 6F 6E 67 20 70 61 73 73 77 6F 72 64 0A 00 Wrong password..
00016060: 50 61 73 73 77 6F 72 64 20 4F 4B 0A 00 00 00 00 Password OK.....
00016070: 40 6E 01 00 00 00 00 00 40 6E 01 00 01 01 00 00 @n......@n......
00016080: 00 00 00 00 00 00 00 00 00 10 00 00 00 00 00 00 ................

Есть, да? Даже два раза! Посмотрим теперь, кто на него ссылается – попробуем найти в дизассемблированном тексте подстроку "16070" – адрес первого двойного слова, указывающего на наш буфер.

00011032: 68 70 60 01 00 push 16070h ; <<<
00011037: 6A 64 push 64h ; Макс. длина пароля (== 100 dec)
00011039: 8D 4D 98 lea ecx,[ebp-68h]
;Указатель ^^^^^^ на буфер куда записывать пароль
0001103C: 51 push ecx
0001103D: E8 E2 00 00 00 call 00011124 ; fgets
00011042: 83 C4 0C add esp,0Ch ; Выталкиваем три аргумента

В общем, все ясно, за исключением загадочного указателя на указатель 0x16070. Заглянув в MSDN, где описан прототип этой функции, мы обнаружим, что "таинственный незнакомец" – указатель на структуру FILE (аргументы по Си-соглашению, как мы помним заносятся в стек справа налево). Первый член структуры FILE – указатель на буфер (файловый ввод-вывод в стандартной библиотеке Си буферизован, и размер буфера по умолчанию составляет 4 Кб). Таким образом, адрес 0x16E40 – это указатель на служебный буфер и из списка "кандидатов в мастера" мы его вычеркиваем.
Двигаемся дальше. Претендент номер два – 0x24069C. Легко видеть он выходит за пределы сегмента данных и вообще непонятно чему принадлежит. Почесав затылок, мы вспомним о такой "вкусности" Windows как куча (heap). Посмотрим, что у нас там…

:heap 32 simple
Base Id Cmmt/Psnt/Rsvd Segments Flags Process
00140000 01 0003/0003/00FD 1 00000002 simple
00240000 02 0004/0003/000C 1 00008000 simple
00300000 03 0008/0007/0008 1 00001003 simple

Ну, Тигр, давай на счастье хвост! Есть отождествление! Остается выяснить, кто выделил этот блок памяти – система под какие-то свои нужды или же сам программист. Первое, что бросается в глаза – какой-то подозрительно-странный недокументированный флаг 0x8000. Заглянув в WINNT.H можно даже найти его определение, которое, впрочем, мало чем нам поможет, разве что намекнет на системное происхождение оного.

#define HEAP_PSEUDO_TAG_FLAG 0x8000

А чтобы окончательно укрепить нашу веру, загрузим в отладчик любое подвернувшееся под лапу приложение и тут же отдадим команду "heap 32 proc_name". Смотрите – система автоматически выделяет из кучи три региона! Точь-в-точь такие, как и в нашем случае. ОК, значит, и этот кандидат ушел лесом.
Остается последний адрес – 0x13FF18. Ничего он не напоминает? Постой-ка, постой. Какое было значение ESP при загрузке?! Кажется 0x13FFC4 или около того (внимание, в Windows 9x стек расположен совершенно в другом месте, но все рассуждения справедливы и для нее – необходимо лишь помнить местоположение стека в собственной операционной системе и уметь навскидку его узнавать).
Поскольку, стек растет снизу вверх (т.е. от старших адресов к младшим), адрес 0x13FF18 явно находится в стеке, а потому очень сильно похож на наш буфер. Уверенность подогревает тот факт, что большинство программистов размешают буфера в локальных переменных, ну а локальные переменные, в свою очередь, размешаются компилятором в стеке.
Ну что, попробуем установить сюда бряк?

:bpm 23:13FF18
:x
Break due to BPMB #0023:0013FF18 RW DR3 (ET=369.65 microseconds)
MSR LastBranchFromIp=0001144F
MSR LastBranchToIp=00011156

001B:000110B0 MOV EAX,[EDX]
001B:000110B2 CMP AL,[ECX]     
001B:000110B4 JNZ 000110E4
001B:000110B6 OR AL,AL
001B:000110B8 JZ 000110E0
001B:000110BA CMP AH,[ECX+01]
001B:000110BD JNZ 000110E4
001B:000110BF OR AH,AH

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

:d edx
0023:0013FF18 4B 50 4E 43 2D 2D 0A 00-70 65 72 73 6B 79 2B 2B KPNC Kaspersky++

:d ecx
0023:00016040 6D 79 47 4F 4F 44 70 61-73 73 77 6F 72 64 0A 00 myGOODpassword..

Ну, а остальное мы уже проходили. Выходим из сравнивающей процедуры по P RET, находим условный переход, записываем его адрес (ключевую последовательность для поиска), правим исполняемый файл и все ОК.

Итак, мы познакомились с одним более или менее универсальным способом взлома защит основанных на сравнении пароля (позже мы увидим, что он так же подходит и для защит, основанных на регистрационных номерах). Его основное достоинство – простота. А недостатки… недостатков у него много.

– если программист очистит буфера после сравнения, поиск веденного пароля ничего не даст. Разве что останутся системные буфера, которые так просто не затрешь, но отследить перемещения пароля из системных буферов в локальные не так-то просто!

– ввиду изобилия служебных буферов, очень трудно определить: какой из них "настоящий". Программист же может располагать буфер и в сегменте данных (статический буфер), и в стеке (локальный буфер), и в куче, и даже выделять память низкоуровневыми вызовами типа VirtualAlloc или… да мало ли как разыграется его фантазия. В результате, под час приходится "просеивать" все найденные вхождения тупым перебором.

В качестве тренировки разберем другой пример – "crackme 01". Это то же самое, что simple.exe, только с GUI-рым интерфейсом и ключевая процедура выглядит так:

void CCrackme_01Dlg::OnOK()
{
char buff[PASSWORD_SIZE];

m_password.GetWindowText(&buff[0],PASSWORD_SIZE);
if (strcmp(&buff[0],PASSWORD))
{
MessageBox("Wrong password");
m_password.SetSel(0,-1,0);
return;
}
else
{

MessageBox("Password OK");
}
CDialog::OnOK();
}
Листинг 5 Исходный текст ядра защитного механизма crackme 01

Кажется, никаких сюрпризов не предвидится. Что ж, вводим пароль (как обычно "KPNC Kaspersky++"), выслушиваем "ругательство" и, до нажатия ОК, вызываем отладчик, переключаем контекст…

:s 23:0 L -1 'KPNC Kaspersky'
Pattern found at 0023:0012F9FC (0012F9FC)
:s
Pattern found at 0023:00139C78 (00139C78)

Есть два вхождения! И оба лежат в стеке. Подбросим монетку, чтобы определить с какого из них начать? (Правильный ответ – с первого). Устанавливаем точку останова и терпеливо ждем всплытия отладчика. Всплытие ждать себя не заставляет, но показывает какой-то странный, откровенно "левый" код. Ждем "x" для выхода, - следует целый каскад всплытий одно непонятнее другого.
Лихорадочно подергивая бородку (варианты – усики, волосы в разных местах) соображаем: функция "CCrackme_01Dlg::OnOK" вызывается непосредственно в момент нажатия на "ОК" – ей отводится часть стекового пространства под локальные переменные, которая автоматически "экспроприируется" при выходе из функции – переходя во всеобщее пользование. Таким образом, локальный буфер с введенным нами паролем существует только в момент его проверки, а потом автоматически затирается. Единственная зацепка – модальный диалог с ругательством. Пока он на экране – буфер еще содержит пароль и его можно найти в памяти. Но это не сильно помогает в отслеживании когда к этому буферу произведет обращение… Приходится терпеливо ждать, отсеивая ложные всплытия один за другим. Наконец, в окне данных искомая строка, а в окне кода – какой-то осмысленный код:

0023:0012F9FC 4B 50 4E 43 20 4B 61 73-70 65 72 73 6B 79 2B 2B KPNC Kaspersky++
0023:0012FA0C 00 01 00 00 0D 00 00 00-01 00 1C C0 A8 AF 47 00 ..............G.
0023:0012FA1C 10 9B 13 00 78 01 01 00-F0 3E 2F 00 00 00 00 00 ....x....>/.....
0023:0012FA2C 01 01 01 00 83 63 E1 77-F0 AD 47 00 78 01 01 00 .....c.w..G.x...

001B:004013E3 8A10 MOV DL,[EAX]
001B:004013E5 8A1E MOV BL,[ESI] 
001B:004013E7 8ACA MOV CL,DL
001B:004013E9 3AD3 CMP DL,BL
001B:004013EB 751E JNZ 0040140B
001B:004013ED 84C9 TEST CL,CL
001B:004013EF 7416 JZ 00401407
001B:004013F1 8A5001 MOV DL,[EAX+01]

На всякий "пожарный" смотрим, на что указывает ESI:

:d esi
0023:0040303C 4D 79 47 6F 6F 64 50 61-73 73 77 6F 72 64 00 00 MyGoodPassword..

Остается "пропадчить" исполняемый файл, и тут (как и следовало ожидать по закону бутерброда) нас ждут очередные трудности. Во-первых, хитрый компилятор заоптимизировал код, подставив код функции strcmp вместо ее вызова, а во-вторых, условных переходов… да ими все кишит! Попробуй-ка, найди нужный. На этот раз бросать монетку мы не станем, а попытаемся подойти к делу по-научному. Итак, перед нами дизассемблированный код, точнее его ключевой фрагмент, осуществляющий анализ пароля:

>dumpbin /DISASM crackme_01.exe
004013DA: BE 3C 30 40 00 mov esi,40303Ch

0040303C: 4D 79 47 6F 6F 64 50 61 73 73 77 6F 72 64 00 MyGoodPassword
В регистр ESI помещается указатель на оригинальный пароль

004013DF: 8D 44 24 10 lea eax,[esp+10h]
В регистр EAX – указатель на пароль, введенный пользователем

004013E3: 8A 16 mov dl,byte ptr [esi]
004013E5: 8A 1E mov bl,byte ptr [esi]
004013E7: 8A CA mov cl,dl
004013E9: 3A D3 cmp dl,bl
Проверка первого символа на совпадение

004013EB: 75 1E jne 0040140B ---(3) --- (1)
Первый символ уже не совпадает – дальше проверять бессмысленно!

004013ED: 84 C9 test cl,cl
Первый символ первой строки равен нулю?

004013EF: 74 16 je 00401407 -- (2)
Да, достигнут конец строки – значит, строки идентичны

004013F1: 8A 50 01 mov dl,byte ptr [eax+1]
004013F4: 8A 5E 01 mov bl,byte ptr [esi+1]
004013F7: 8A CA mov cl,dl
004013F9: 3A D3 cmp dl,bl
Проверяем следующую пару символов

004013FB: 75 0E jne 0040140B --- (1)
Если не равна – конец проверке

004013FD: 83 C0 02 add eax,2
00401400: 83 C6 02 add esi,2
Перемещаем указатели строк на два символа вперед

00401403: 84 C9 test cl,cl
Достигнут конец строки?

00401405: 75 DC jne 004013E3 - (3)
Нет, еще не конец, сравниваем дальше.

00401407: 33 C0 xor eax,eax --- (2)
00401409: EB 05 jmp 00401410 -- (4)
Обнуляем EAX (strcmp в случае успеха возвращает ноль) и выходим

0040140B: 1B C0 sbb eax,eax --- (3)
0040140D: 83 D8 FF sbb eax,0FFFFFFFFh
Эта ветка получат управление при несовпадении строк. EAX устанавливает равным в ненулевое значение (подумайте почему).

00401410: 85 C0 test eax,eax --- (4)
Проверка значения EAX на равенство нулю

00401412: 6A 00 push 0
00401414: 6A 00 push 0
Что-то заносим в стек…

00401416: 74 38 je 00401450 <<<< ---(5)
Прыгаем куда-то….

00401418: 68 2C 30 40 00 push 40302Ch
0040302C: 57 72 6F 6E 67 20 70 61 73 73 77 6F 72 64 00 .Wrong password
Ага, "Вронг пысворд". Значит, прыгать все-таки надо…. Смотрим, куда указывает je (а код ниже – уже не представляет интереса – и так ясно: это "матюгальщик").

Теперь, когда алгоритм защиты в общих чертах ясен, можно ее и сломать, например, поменяв условный переход в строке 0x401416 на безусловный jump short (код 0xEB).

Способ 2. Бряк на функции ввода пароля

Вы боитесь творить, потому что творения ваши отражают вашу истинную суть.
Фрэнк Херберт "Ловец душ"

При всем желании метод прямого поиска пароля в памяти элегантным назвать нельзя, да и практичным тоже. А, собственно, зачем искать сам пароль, спотыкаясь об беспорядочно разбросанные буфера, когда можно поставить бряк непосредственно на функцию, его считывающую? Хм, можно и так… да вот угадать какой именно функцией разработчик вздумал читать пароль, вряд ли будет намного проще.
На самом деле одно и тоже действие может быть выполнено всего лишь несколькими функциями и их перебор не займет много времени. В частности, содержимое окна редактирование обычно добывается либо GetWinodowTextA (что чаще всего и происходит), либо GetDlgItemTextA (а это – значительно реже).
Раз уж речь зашла за окна, запустим наш GUI "крякмис" и установим точку останова на функцию GetWindowTextA ("bpx GetWinodwTextA"). Поскольку, эта функция – системная, точка останова будет глобальной, т.е. затронет все приложения в системе, поэтому, заблаговременно закройте все лишнее от греха подальше. Если установить бряк до запуска "крякмиса", то мы словим несколько ложных всплытий, возникающих вследствие того, что система сама читает содержимое окна в процессе формирования диалога.
Вводим какой-нибудь пароль ("KPNC Kaspersky++" по обыкновению), нажимаем - отладчик незамедливает всплыть:

USER32!GetWindowTextA                                 
001B:77E1A4E2 55 PUSH EBP         
001B:77E1A4E3 8BEC MOV EBP,ESP
001B:77E1A4E5 6AFF PUSH FF
001B:77E1A4E7 6870A5E177 PUSH 77E1A570
001B:77E1A4EC 68491DE677 PUSH 77E61D49
001B:77E1A4F1 64A100000000 MOV EAX,FS:[00000000]
001B:77E1A4F7 50 PUSH EAX

Во многих руководствах по взлому советуется тут же выйти из функции по P RET, мол, что ее анализировать-то, но не стоит спешить! Сейчас самое время выяснить: где расположен буфер вводимой строки и установить на него бряк. Вспомним какие аргументы и в какой последовательности принимает функция (а, если не вспомним, то заглянем в SDK):

int GetWindowText(
HWND hWnd, // handle to window or control with text
LPTSTR lpString, // address of buffer for text
int nMaxCount // maximum number of characters to copy
);

Может показаться, раз программа написана на Си, то и аргументы заносятся в стек по Си-соглашению. А вот и нет! Все API функции Windows всегда вызываются по Паскаль- соглашению, на каком бы языке программа ни была написана. Таким образом, аргументы заносятся в стек слева направо, а последним в стек попадает адрес возврата. В 32-разрядной Windows все аргументы и сам адрес возврата занимают двойное слово (4 байта), поэтому, чтобы добраться до указателя на строку, необходимо к регистру указателю вершины стека (ESP) добавить восемь (одно двойное слово на nMaxCount, другое – на сам lpString). Нагляднее это изображено на рис. 3

Рисунок 3 0х02 Состояние стека на момент вызова GetWindowsText
Получить содержимое ячейки по заданному адресу в Айсе можно с помощью оператора "звездочка", вызов которого в нашем случае выглядит так (подробнее – см. документацию, прилагаемую к отладчику):

:d *(esp+8)
0023:0012F9FC 1C FA 12 00 3B 5A E1 77-EC 4D E1 77 06 02 05 00 ....;Z.w.M.w....
0023:0012FA0C 01 01 00 00 10 00 00 00-01 00 2A C0 10 A8 48 00 ..........*...H.
0023:0012FA1C 10 9B 13 00 0A 02 04 00-E8 3E 2F 00 00 00 00 00 .........>/.....
0023:0012FA2C 01 02 04 00 83 63 E1 77-08 DE 48 00 0A 02 04 00 .....c.w..H.....

В буфере мусор – так и следовало ожидать, ведь строка еще не считана. Давайте выйдем из функции по p ret и посмотрим что произойдет (только потом уже нельзя будет пользоваться конструкцией d *esp+8, т.к. после выхода из функции аргументы будут вытолкнуты из стека):

: p ret
:d 0012F9FC
0023:0012F9FC 4B 50 4E 43 20 4B 61 73-70 65 72 73 6B 79 2B 2B KPNC Kaspersky++
0023:0012FA0C 00 01 00 00 0D 00 00 00-01 00 1C 80 10 A8 48 00 ..............H.
0023:0012FA1C 10 9B 13 00 0A 02 04 00-E8 3E 2F 00 00 00 00 00 .........>/.....
0023:0012FA2C 01 02 04 00 83 63 E1 77-08 DE 48 00 0A 02 04 00 .....c.w..H.....

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

001B:004013E3 8A10 MOV DL,[EAX]
001B:004013E5 8A1E MOV BL,[ESI]
001B:004013E7 8ACA MOV CL,DL
001B:004013E9 3AD3 CMP DL,BL
001B:004013EB 751E JNZ 0040140B
001B:004013ED 84C9 TEST CL,CL
001B:004013EF 7416 JZ 00401407
001B:004013F1 8A5001 MOV DL,[EAX+01]

Замечательно! Вот так, безо всяких ложных срабатываний, элегантно, быстро и красиво мы победили защиту!
Этот способ – универсален и впоследствии мы еще не раз им воспользуемся. Вся соль – определить ключевую функцию защиты и поставить на нее бряк. Под Windows все "поползновения" (будь то обращения к ключевому файлу, реестру и т.д.) сводятся к вызову API-функций, перечень которых хотя и велик, но все же конечен и известен заранее.

Способ 3. Бряк на сообщения

Любая завершенная дисциплина имеет свои штампы, свои модели, свое влияние на обучающихся.

Френк Херберт "Дюна"

Если у Вас еще не закружилась голова от количества выпитого во время хака пива, с вашего позволения мы продолжим. Каждый, кто хоть однажды программировал под Windows, наверняка знает, что в Windows все взаимодействие с окнами завязано на сообщениях. Практически все оконные API-функции на самом деле представляют собой высокоуровневые "обертки", посылающие окну сообщения. Не является исключением и GetWindowTextA, – аналог сообщения WM_GETTEXT.
Отсюда следует – чтобы считать текст из окна вовсе не обязательно обращаться к GetWindowTextA, - можно сделать это через SendMessageA(hWnd, WM_GETTEXT, (LPARAM) &buff[0]). Именно так и устроена защита в примере "crack 02". Попробуйте загрузить его и установить бряк на GetWindowTextA (GetDlgItemTextA). Что, не срабатывает? Подобная мера используется разработчиками для запутывания совсем уж желторотых новичков, бегло изучивших пару faq по хаку и тут же бросившихся в бой.
Так может, поставить бряк на SendMessageA? В данном случае в принципе можно, но бряк на сообщение WM_GETTEXT – более универсальное решение, срабатывающее независимо от того, как читают окно.
Для установки бряка на сообщение в Айсе предусмотрена специальная команда – "BMSG", которой мы и пользовались в первом издании этой книги. Но не интереснее ли сделать это своими руками?
Как известно, с каждым окном связана специальная оконная процедура, обслуживающая это окно, т.е. отвечающая за прием и обработку сообщений. Вот если бы узнать ее адрес, да установить на него бряк! И это действительно можно сделать! Специальная команда "HWND" выдает всю информацию об окнах указанного процесса.


:addr crack02
:hwnd crack02
Handle Class WinProc TID Module
050140 #32770 (Dialog) 6C291B81 2DC crack02
05013E Button 77E18721 2DC crack02
05013C Edit 6C291B81 2DC crack02
05013A Static 77E186D9 2DC crack02

Быстро обнаруживает себя окно редактирования, с адресом оконной процедуры 0x6C291B81. Поставим сюда бряк? Нет, еще не время – ведь оконная процедура вызывается не только при чтении текста, а гораздо чаще. Как бы установить бряк на то, что нам нужно, отсеяв все остальные сообщения? Для начала изучим прототип этой функции:

LRESULT CALLBACK WindowProc(
HWND hwnd, // handle to window
UINT uMsg, // message identifier
WPARAM wParam, // first message parameter
LPARAM lParam // second message parameter
);

Как нетрудно подсчитать, в момент вызова функции, аргумент uMsg – идентификатор сообщения будет лежать по смещению 8 относительно указателя вершины стека ESP. Если он равен WM_GETTEXT (непосредственное значение 0xD) – недурно бы всплыть!
Вот и настало время познакомиться с условными бряками. Подробнее об их синтаксисе рассказано в прилагаемой к отладчику документации. А, впрочем, программисты, знакомые Си вряд ли к ней обратится, ибо синтаксис лаконичен и интуитивно - понятен.

:bpx 6C291B81 IF (esp->8)==WM_GETTEXT
:x
Выходим их отладчика, вводим какой-нибудь текст в качесвте пароля, скажем "Hello", нажимаем , отладчик тут же "всплывает"

Break due to BPX #0008:6C291B81 IF ((ESP->8)==0xD) (ET=2.52 seconds)

Вот, он хвост Тигры и уши плюшевого медведя! Остается определить адрес буфера, в который возвращается считанная строка. Начинаем соображать: указатель на буфер передается через аргумент lParam (см. в SDK описание WM_GETTEXT), а сам lParam размещается в стеке по смещению 0x10, относительно ESP:

адрес возврата  ESP
hwnd  ESP + 0x4
uMsg  ESP + 0x8
wParam  ESP + 0xC
lParam  ESP + 0x10

Даем команду вывода этого буфера в окно данных, выходим из оконной процедуры по P RET и… видим только что введенный нами текст "Hello"

:d *(esp+10)
:p ret
0023:0012EB28 48 65 6C 6C 6F 00 05 00-0D 00 00 00 FF 03 00 00 Hello...........
0023:0012EB38 1C ED 12 00 01 00 00 00-0D 00 00 00 FD 86 E1 77 ...............w
0023:0012EB48 70 3C 13 00 00 00 00 00-00 00 00 00 00 00 00 00 p<..............
0023:0012EB58 00 00 00 00 00 00 00 00-98 EB 12 00 1E 87 E1 77 ...............w

:bpm 23:12EB28
Установив точку останова, мы ловим одно откровенно "левое" всплытие отладчика (это видно по явно не "юзерскому" значению селектора CS, равного 8) и, уже тянем руку, чтобы нажать 'x' продолжив отслеживание нашего бряка, как вдруг краем глаза замечаем….

0008:A00B017C 8A0A MOV CL,[EDX]
0008:A00B017E 8808 MOV [EAX],CL
0008:A00B0180 40 INC EAX
0008:A00B0181 42 INC EDX
0008:A00B0182 84C9 TEST CL,CL
0008:A00B0184 7406 JZ A00B018C
0008:A00B0186 FF4C2410 DEC DWORD PTR [ESP+10]
0008:A00B018A 75F0 JNZ A00B017C

Эге, буфер-то не "сквозной", - система не отдает его "народу", а копирует в другой буфер. Это видно потому, как из указателя на "наш" буфер EDX символ копируется в CL (то, что EDX – указатель на "наш" буфер следует из того, что он вызвал всплытие отладчика), а из CL он копируется в [EAX], где EAX – какой-то указатель (о котором пока мы еще не можем сказать ничего определенного). Далее – оба указателя увеличиваются на единицу и CL (последний считанный символ) проверяется на равенство нулю – если конец строки не достигнут, то все повторяется. Что ж, суждено нам следить сразу за двумя буферами – ставим еще один бряк.

:bpm EAX
:x
На втором бряке отладчик вскорости всплывает, и мы узнаем нашу родную процедуру сравнения. Ну, а дальнейшее – дело техники.

001B:004013F2 8A1E MOV BL,[ESI]
001B:004013F4 8ACA MOV CL,DL    
001B:004013F6 3AD3 CMP DL,BL
001B:004013F8 751E JNZ 00401418
001B:004013FA 84C9 TEST CL,CL
001B:004013FC 7416 JZ 00401414
001B:004013FE 8A5001 MOV DL,[EAX+01]
001B:00401401 8A5E01 MOV BL,[ESI+01]

В Windows 9x обработка сообщений реализована несколько иначе, чем в NT. В частности, оконная процедура окна редактирования находится в 16-разрядном коде. А это – сегментная модель памяти (треска хвостом вперед под хвост Тигре) a la сегмент : смещение. Представляется любопытным механизм передачи адреса – в какой же параметр засунут сегмент? Чтобы ответить на это, взглянем на отчет Айса:

Break due to BMSG 0428 WM_GETTEXT (ET=513.11 milliseconds)
hWnd=0428 wParam=0666 lParam=28D70000 msg=000D WM_GETTEXT
^ ^ ^--^^--^
| | сегмент/ \смещение
дескриптор окна |
|
макс. кол-во символов для чтения

Адрес целиком умещается в 32-разрядном аргументе lParam – 16-разрядный сегмент и 16-разрядное смещение. Посему, точка останова должна выглядеть так: "bpm 28D7:0000"

Шаг пятый. На сцене появляется IDA

"Реальность такова, какой ее описывает язык" тезис лингвистической относительности Б.Л. Уорфа

С легкой руки Дениса Ричи повелось начинать освоение нового языка программирования с создания простейшей программы “Hello, World!”, -- и здесь не будет нарушена эта традиция. Оценим возможности IDA Pro следующим примером (для совместимости с книгой рекомендуется откомпилировать его с помощью Microsoft Visual C++ 6.0 вызовом “cl.exe first.cpp” в командной строке):

#include
void main()
{
cout<<"Hello, Sailor!\n";
}
a) исходный текст программы first.cpp

Компилятор сгенерирует исполняемый файл размером почти в 40 килобайт, большую часть которого займет служебный, стартовый или библиотечный код! Попытка дизассемблирования с помощью таких дизассемблеров как W32DASM (или аналогичных ему) не увенчается быстрым успехом, поскольку над полученным листингом размером в пятьсот килобайт (!) можно просидеть не час и не два. Легко представить сколько времени уйдет на серьезные задачи, требующие изучения десятков мегабайт дизассемблированного текста.
Попробуем эту программу дизассемблировать с помощью IDA. Если все настройки оставить по умолчанию, после завершения анализа экран (в зависимости от версии) должен выглядеть следующим образом:

Рисунок 4 “0x000.bmp” Так выглядит результат работы консольной версии IDA Pro 3.6
Рисунок 5 “0x001.bmp” Так выглядит результат работы консольной версии IDA Pro 4.0
Рисунок 6 “0x002.bmp” Так выглядит результат работы графической версии IDA Pro 4.0

С версии 3.8x1 в IDA появилась поддержка «сворачивания» (Collapsed) функций. Такой прием значительно упрощает навигацию по тексту, позволяя убрать с экрана не интересные в данный момент строки. По умолчанию все библиотечные функции сворачиваются автоматически.
Развернуть функцию можно подведя к ней курсор и нажав <+> на дополнительной цифровой клавиатуре, расположенной справа. Соответственно, клавиша <-> предназначена для сворачивания.
По окончании автоматического анализа файла “first.exe”, IDA переместит курсор к строке “.text:00401B2C” – точке входа в программу. Среди начинающих программистов широко распространено заблуждение, якобы программы, написанные на Си, начинают выполняться с функции “main”, но в действительности это не совсем так. На самом деле сразу после загрузки файла управление передается на функцию “Start”, вставленную компилятором. Она подготавливает глобальные переменные _osver (билд), _winmajor (старшая версия операционной системы), _winminor (младшая версия операционной системы), _winver (полная версия операционной системы), __argc (количество аргументов командной строки), __argv (массив указателей на строки аргументов), _environ (массив указателей на строки переменных окружения); инициализирует кучи (heap); вызывает функцию main, а после возращения управления завершает процесс с помощью функции Exit.
Наглядно продемонстрировать инициализацию переменных, совершаемую стартовым кодом, позволяет следующая программа.

#include
#include
void main()
{
int a;
printf(">Версия OS:\t\t\t%d.%d\n\
>Билд:\t\t\t%d\n\
>Количество агрументов:\t%d\n",\
_winmajor,_winminor,_osver,__argc);
for (a=0;a<__argc;a++)
printf(">\tАгрумент %02d:\t\t%s\n",a+1,__argv[a]);
a=!a-1;
while(_environ[++a]) ;
printf(">Количество переменных окружения:%d\n",a);
while(a) printf(">\tПеременная %d:\t\t%s\n",a,_environ[--a]);
}
a) исходный текст программы CRt0.demo.c

Прототип функции main как будто указывает, что приложение не принимает ни каких аргументов командной строки, но результат работы программы доказывает обратное и на машине автора выглядит так (приводится в сокращенном виде):

>Версия OS: 5.0
>Билд: 2195
>Количество агрументов: 1
> Агрумент 01: CRt0.demo
>Количество переменных окружения: 30
> Переменная 29: windir=C:\WINNT
>...
b) результат работы программы CRt0.demo.c

Очевидно, нет никакой необходимости анализировать стандартный стартовый код приложения, и первая задача исследователя – найти место передачи управления на функцию main. К сожалению, гарантированное решение этой задачи требует полного анализа содержимого функции “Start”. У исследователей существует множество хитростей, но все они базируются на особенностях реализации конкретных компиляторов2 и не могут считаться универсальными.
Рекомендуется изучить исходные тексты стартовых функций популярных компиляторов, находящиеся в файлах CRt0.c (Microsoft Visual C) и c0w.asm (Borland C) – это упросит анализ дизассемблерного листинга.
Ниже, в качестве иллюстрации, приводится содержимое стартового кода программы “first.exe”, полученное в результате работы W32Dasm:

//******************** Program Entry Point ********
:00401B2C 55 push ebp
:00401B2D 8BEC mov ebp, esp
:00401B2F 6AFF push FFFFFFFF
:00401B31 6870714000 push 00407170
:00401B36 68A8374000 push 004037A8
:00401B3B 64A100000000 mov eax, dword ptr fs:[00000000]
:00401B41 50 push eax
:00401B42 64892500000000 mov dword ptr fs:[00000000], esp
:00401B49 83EC10 sub esp, 00000010
:00401B4C 53 push ebx
:00401B4D 56 push esi
:00401B4E 57 push edi
:00401B4F 8965E8 mov dword ptr [ebp-18], esp

Reference To: KERNEL32.GetVersion, Ord:0174h
|
:00401B52 FF1504704000 Call dword ptr [00407004]
:00401B58 33D2 xor edx, edx
:00401B5A 8AD4 mov dl, ah
:00401B5C 8915B0874000 mov dword ptr [004087B0], edx
:00401B62 8BC8 mov ecx, eax
:00401B64 81E1FF000000 and ecx, 000000FF
:00401B6A 890DAC874000 mov dword ptr [004087AC], ecx
:00401B70 C1E108 shl ecx, 08
:00401B73 03CA add ecx, edx
:00401B75 890DA8874000 mov dword ptr [004087A8], ecx
:00401B7B C1E810 shr eax, 10
:00401B7E A3A4874000 mov dword ptr [004087A4], eax
:00401B83 6A00 push 00000000
:00401B85 E8D91B0000 call 00403763
:00401B8A 59 pop ecx
:00401B8B 85C0 test eax, eax
:00401B8D 7508 jne 00401B97
:00401B8F 6A1C push 0000001C
:00401B91 E89A000000 call 00401C30
:00401B96 59 pop ecx

Referenced by a (U)nconditional or (C)onditional Jump at Address:
|:00401B8D(C)
|
:00401B97 8365FC00 and dword ptr [ebp-04], 00000000
:00401B9B E8D70C0000 call 00402877

Reference To: KERNEL32.GetCommandLineA, Ord:00CAh
|
:00401BA0 FF1560704000 Call dword ptr [00407060]
:00401BA6 A3E49C4000 mov dword ptr [00409CE4], eax
:00401BAB E8811A0000 call 00403631
:00401BB0 A388874000 mov dword ptr [00408788], eax
:00401BB5 E82A180000 call 004033E4
:00401BBA E86C170000 call 0040332B
:00401BBF E8E1140000 call 004030A5
:00401BC4 A1C0874000 mov eax, dword ptr [004087C0]
:00401BC9 A3C4874000 mov dword ptr [004087C4], eax
:00401BCE 50 push eax
:00401BCF FF35B8874000 push dword ptr [004087B8]
:00401BD5 FF35B4874000 push dword ptr [004087B4]
:00401BDB E820F4FFFF call 00401000
:00401BE0 83C40C add esp, 0000000C
:00401BE3 8945E4 mov dword ptr [ebp-1C], eax
:00401BE6 50 push eax
:00401BE7 E8E6140000 call 004030D2
:00401BEC 8B45EC mov eax, dword ptr [ebp-14]
:00401BEF 8B08 mov ecx, dword ptr [eax]
:00401BF1 8B09 mov ecx, dword ptr [ecx]
:00401BF3 894DE0 mov dword ptr [ebp-20], ecx
:00401BF6 50 push eax
:00401BF7 51 push ecx
:00401BF8 E8AA150000 call 004031A7
:00401BFD 59 pop ecx
:00401BFE 59 pop ecx
:00401BFF C3 ret
a) стартовый код программы “first.exe”, полученный дизассемблером W32Dasm

Иначе выглядит результат работы IDA, умеющей распознавать библиотечные функции по их сигнатурам (приблизительно по такому же алгоритму работает множество антивирусов). Поэтому, способности дизассемблера тесно связаны с его версией и полнотой комплекта поставки – далеко не все версии IDA Pro в состоянии работать с программами, сгенерированными современными компиляторами. (Перечень поддерживаемых компиляторов можно найти в файле “%IDA%/SIG/list”).

00401B2C start proc near
00401B2C
00401B2C var_20 = dword ptr -20h
00401B2C var_1C = dword ptr -1Ch
00401B2C var_18 = dword ptr -18h
00401B2C var_14 = dword ptr -14h
00401B2C var_4 = dword ptr -4
00401B2C
00401B2C push ebp
00401B2D mov ebp, esp
00401B2F push 0FFFFFFFFh
00401B31 push offset stru_407170
00401B36 push offset __except_handler3
00401B3B mov eax, large fs:0
00401B41 push eax
00401B42 mov large fs:0, esp
00401B49 sub esp, 10h
00401B4C push ebx
00401B4D push esi
00401B4E push edi
00401B4F mov [ebp+var_18], esp
00401B52 call ds:GetVersion
00401B58 xor edx, edx
00401B5A mov dl, ah
00401B5C mov dword_4087B0, edx
00401B62 mov ecx, eax
00401B64 and ecx, 0FFh
00401B6A mov dword_4087AC, ecx
00401B70 shl ecx, 8
00401B73 add ecx, edx
00401B75 mov dword_4087A8, ecx
00401B7B shr eax, 10h
00401B7E mov dword_4087A4, eax
00401B83 push 0
00401B85 call __heap_init
00401B8A pop ecx
00401B8B test eax, eax
00401B8D jnz short loc_401B97
00401B8F push 1Ch
00401B91 call sub_401C30 ; _fast_error_exit
00401B96 pop ecx
00401B97
00401B97 loc_401B97: ; CODE XREF: start+61j
00401B97 and [ebp+var_4], 0
00401B9B call __ioinit
00401BA0 call ds:GetCommandLineA
00401BA6 mov dword_409CE4, eax
00401BAB call ___crtGetEnvironmentStringsA
00401BB0 mov dword_408788, eax
00401BB5 call __setargv
00401BBA call __setenvp
00401BBF call __cinit
00401BC4 mov eax, dword_4087C0
00401BC9 mov dword_4087C4, eax
00401BCE push eax
00401BCF push dword_4087B8
00401BD5 push dword_4087B4
00401BDB call sub_401000
00401BE0 add esp, 0Ch
00401BE3 mov [ebp+var_1C], eax
00401BE6 push eax
00401BE7 call _exit
00401BEC ; ------------------------------------------------------
00401BEC
00401BEC loc_401BEC: ; DATA XREF: _rdata:00407170o
00401BEC mov eax, [ebp-14h]
00401BEF mov ecx, [eax]
00401BF1 mov ecx, [ecx]
00401BF3 mov [ebp-20h], ecx
00401BF6 push eax
00401BF7 push ecx
00401BF8 call __XcptFilter
00401BFD pop ecx
00401BFE pop ecx
00401BFF retn
00401BFF start endp ; sp = -34h
b) стартовый код программы “first.exe”, полученный дизассемблером IDA Pro 4.01

С приведенным примером IDA Pro успешно справляется, о чем свидетельствует стока “Using FLIRT signature: VC v2.0/4.x/5.0 runtime” в окне сообщений

Рисунок 7 "0x003" Загрузка библиотеки сигнатур

Дизассемблер сумел определить имена всех функций вызываемых стартовым кодом, за исключением одной, расположенной по адресу 0х0401BDB. Учитывая передачу трех аргументов и обращение к _exit, после возращения функцией управления, можно предположить, что это main и есть.
Перейти по адресу 0x0401000 для изучения содержимого функции main можно несколькими способами – прокрутить экран с помощью стрелок управления курсором, нажать клавишу и ввести требуемый адрес в появившемся окне диалога, но проще и быстрее всего воспользоваться встроенной в IDA Pro системой навигации. Если подвести курсор в границы имени, константы или выражения и нажать , IDA автоматически перейдет на требуемый адрес.
В данном случае требуется подвести к строке “sub_401000” (аргументу команды call) и нажать на , если все сделано правильно, экран дизассемблера должен выглядеть следующим образом:

00401000 ; -------------- S U B R O U T I N E ----------------------
00401000
00401000 ; Attributes: bp-based frame
00401000
00401000 sub_401000 proc near ; CODE XREF: start+AFp
00401000 push ebp
00401001 mov ebp, esp
00401003 push offset aHelloSailor ; "Hello, Sailor!\n"
00401008 mov ecx, offset dword_408748
0040100D call ??6ostream@@QAEAAV0@PBD@Z ; ostream::operator<<(char const *)
00401012 pop ebp
00401013 retn
00401013 sub_401000 endp

Дизассемблер сумел распознать строковую переменную и дал ей осмысленное имя “aHelloSailor”, а в комментарии, расположенном справа, для наглядности привел оригинальное содержимое “Hello, Sailor!\n”. Если поместить курсор в границы имени “aHelloSailor”:и нажать , IDA автоматически перейдет к требуемой строке:

00408040 aHelloSailor db 'Hello, Sailor!',0Ah,0 ; DATA XREF: sub_401000+3o

Выражение “DATA XREF: sub_401000+3o” называется перекрестной ссылкой и свидетельствует о том, что в третьей строке процедуры sub_401000, произошло обращение к текущему адресу по его смещению (“o” от offset), а стрелка, направленная вверх, указывает на относительное расположение источника перекрестной ссылки.
Если в границы выражения “sub_401000+3” подвести курсор и нажать , IDA Pro перейдет к следующей строке:

00401003 push offset aHelloSailor ; "Hello, Sailor!\n"

Нажатие клавиши отменяет предыдущее перемещение, возвращая курсор в исходную позицию. (Аналогично команде “back” в web-браузере). Смещение строки “Hello, Sailor!\n”, передается процедуре “??6ostream@@QAEAAV0@PBD@Z”, представляющей собой оператор “<<” языка С++. Странное имя объясняется ограничениями, наложенными на символы, допустимые в именах библиотечных функций. Поэтому, компиляторы автоматически преобразуют (замангляют) такие имена в “абракадабру”, пригодную для работы с линкером, и многие начинающие программисты даже не догадываются об этой скрытой “кухне”.
Для облегчения анализа текста, IDA Pro в комментариях отображает «правильные» имена, но существует возможность заставить ее везде показывать незамангленные имена. Для этого необходимо в меню “Options” выбрать пункт “Demangled names” и в появившемся окне диалога переместить радио кнопку на “Names”, после этого вызов оператора “<<” станет выглядеть так:

0040100D call ostream::operator<<(char const *)

На этом анализ приложения “first.cpp” можно считать завершенным. Для полноты картины остается переименовать функцию “sub_401000” в main. Для этого необходимо подвести курсор к строке 0x0401000 (началу функции) и нажать клавишу , в появившемся диалоге ввести “main”. Конечный результат должен выглядеть так:

00401000 ; --------------- S U B R O U T I N E ---------------------------------------
00401000
00401000 ; Attributes: bp-based frame
00401000
00401000 main proc near ; CODE XREF: start+AFp
00401000 push ebp
00401001 mov ebp, esp
00401003 push offset aHelloSailor ; "Hello, Sailor!\n"
00401008 mov ecx, offset dword_408748
0040100D call ostream::operator<<(char const *)
00401012 pop ebp
00401013 retn
00401013 main endp

Для сравнения результат работы W32Dasm выглядит следующим образом (ниже приводится лишь содержимое функции main):

:00401000 55 push ebp
:00401001 8BEC mov ebp, esp

Possible StringData Ref from Data Obj ->"Hello, Sailor!"
|
:00401003 6840804000 push 00408040
:00401008 B948874000 mov ecx, 00408748
:0040100D E8AB000000 call 004010BD
:00401012 5D pop ebp
:00401013 C3 ret

Другое важное преимущество IDA – способность дизассемблировать зашифрованные программы. В демонстрационном примере ??? “/SRC/Crypt.com” использовалась статическая шифровка, часто встречающаяся в “конвертных” защитах. Этот простой прием полностью “ослепляет” большинство дизассемблеров. Например, результат обработки файла “Crypt.com” SOURCER-ом выглядит так:

Crypt proc far

7E5B:0100 start:
7E5B:0100 83 C6 06 add si,6
7E5B:0103 FF E6 jmp si ;*
;*No entry point to code
7E5B:0105 B9 14BE mov cx,14BEh
7E5B:0108 01 AD 5691 add ds:data_1e[di],bp ; (7E5B:5691=0)
7E5B:010C 80 34 66 xor byte ptr [si],66h ; 'f'
7E5B:010F 46 inc si
7E5B:0110 E2 FA loop $-4 ; Loop if cx > 0

7E5B:0112 FF E6 jmp si ;*
;* No entry point to code
7E5B:114 18 00 sbb [bx+si],al
7E5B:116 D2 6F DC shr byte ptr [bx-24h],cl ; Shift w/zeros fill
7E5B:119 6E 67 AB 47 A5 2E db 6Eh, 67h,0ABh, 47h,0A5h, 2Eh
7E5B:11F 03 0A 0A 09 4A 35 db 03h, 0Ah, 0Ah, 09h, 4Ah, 35h
7E5B:125 07 0F 0A 09 14 47 db 07h, 0Fh, 0Ah, 09h, 14h, 47h
7E5B:12B 6B 6C 42 E8 00 00 db 6Bh, 6Ch, 42h, E8h, 00h, 00h
7E5B:131 59 5E BF 00 01 57 db 59h, 5Eh, BFh, 00h, 01h, 57h
7E5B:137 2B CE F3 A4 C3 db 2Bh, CEh, F3h, A4h, C3h

Crypt endp

SOURCER половину кода вообще не смог дизассемблировать, оставив ее в виде дампа, а другую половину дизассемблировал неправильно! Команда “JMP SI” в строке :0x103 осуществляет переход по адресу :0x106 (значение регистра SI после загрузки com файла равно 0x100, поэтому после команды “ADD SI,6” регистр SI равен 0x106). Но следующая за “JMP” команда расположена по адресу 0x105! В исходном тексте в это место вставлен байт-пустышка, сбивающий дизассемблер с толку.

Start:
ADD SI,6
JMP SI
DB 0B9h ;
LEA SI,_end ; На начало зашифрованного фрагмента

SOURCER не обладает способностью предсказывать регистровые переходы и, встретив команду “JMP SI” продолжает дизассемблирование, молчаливо предполагая, что команды последовательно расположены вплотную друг к другу. Существует возможность создать файл определений, указывающий, что по адресу:0x105 расположен байт данных, но подобное взаимодействие с пользователем очень неудобно.
Напротив, IDA изначально проектировалась как дружественная к пользователю интерактивная среда. В отличие от SURCER-подобных дизассемблеров, IDA не делает никаких молчаливых предположений, и при возникновении затруднений обращается за помощью к человеку. Поэтому, встретив регистровый переход по неизвестному адресу, она прекращает дальнейший анализ, и результат анализа файла “Crypt.com” выглядит так:

seg000:0100 start proc near
seg000:0100 add si, 6
seg000:0103 jmp si
seg000:0103 start endp
seg000:0103
seg000:0103 ; ------------------------------------------------------------------------
seg000:0105 db 0B9h ; ¦
seg000:0106 db 0BEh ; -
seg000:0107 db 14h ;
seg000:0108 db 1 ;
seg000:0109 db 0ADh ; í
seg000:010A db 91h ; Ñ
...

Необходимо помочь дизассемблеру, указав адрес перехода. Начинающие пользователи в этой ситуации обычно подводят курсор к соответствующей строке и нажимают клавишу , заставляя IDA дизассемблировать код с текущей позиции до конца функции. Несмотря на кажущуюся очевидность, такое решение ошибочно, ибо по-прежнему остается неизвестным куда указывает условный переход в строке :0x103 и откуда код, расположенный по адресу :0x106 получает управление.
Правильное решение – добавить перекрестную ссылку, связывающую строку :0x103, со строкой :0x106. Для этого необходимо в меню “View” выбрать пункт “Cross references” и в появившемся окне диалога заполнить поля “from” и “to” значениями seg000:0103 и seg000:0106 соответственно.
После этого экран дизассемблера должен выглядеть следующим образом (в IDA версии 4.01.300 содержится ошибка, и добавление новой перекрестной ссылки не всегда приводит к автоматическому дизассемблированию):

seg000:0100 public start
seg000:0100 start proc near
seg000:0100 add si, 6
seg000:0103 jmp si
seg000:0103 start endp
seg000:0103
seg000:0103 ; -----------------------------------------------------------------------
seg000:0105 db 0B9h ; ¦
seg000:0106 ; -----------------------------------------------------------------------
seg000:0106
seg000:0106 loc_0_106: ; CODE XREF: start+3u
seg000:0106 mov si, 114h
seg000:0109 lodsw
seg000:010A xchg ax, cx
seg000:010B push si
seg000:010C
seg000:010C loc_0_10C: ; CODE XREF: seg000:0110j
seg000:010C xor byte ptr [si], 66h
seg000:010F inc si
seg000:0110 loop loc_0_10C
seg000:0112 jmp si
seg000:0112 ; ----------------------------------------------------------------------
seg000:0114 db 18h ;
seg000:0115 db 0 ;
seg000:0116 db 0D2h ; T
seg000:0117 db 6Fh ; o
...

Поскольку IDA Pro не отображает адреса-приемника перекрестной ссылки, то рекомендуется выполнить это самостоятельно. Такой примем улучшит наглядность текста и упростит навигацию. Если повести курсор к строке :0x103 нажать клавишу <:>, введя в появившемся диалоговом окне любой осмысленный комментарий (например “переход по адресу 0106”), то экран примет следующий вид:

seg000:0103 jmp si ; Переход по адресу 0106

Ценность такого приема заключается в возможности быстрого перехода по адресу, на который ссылается “JMP SI”, - достаточно лишь подвести курсор к числу “0106” и нажать . Важно соблюдать правильность написания – IDA Pro не распознает шестнадцатеричный формат ни в стиле Си (0x106), ни в стиле MASM\TASM (0106h).
Что представляет собой число “114h” в строке :0x106 – константу или смещение? Чтобы узнать это, необходимо проанализировать следующую команду – “LODSW”, поскольку ее выполнение приводит к загрузке в регистр AX слова, расположенного по адресу DS:SI, очевидно, в регистр SI заносится смещение.

seg000:0106 mov si, 114h
seg000:0109 lodsw

Однократное нажатие клавиши преобразует константу в смещение и дизассемблируемый текст станет выглядеть так:

seg000:0106 mov si, offset unk_0_114
seg000:0109 lodsw

seg000:0114 unk_0_114 db 18h ; ; DATA XREF: seg000:0106o
seg000:0115 db 0 ;
seg000:0116 db 0D2h ; T
seg000:0117 db 6Fh ; o


IDA Pro автоматически создала новое имя “unk_0_114”, ссылающееся на переменную неопределенного типа размером в байт, но команда “LODSW” загружает в регистр AX слово, поэтому необходимо перейти к строке :0144 и дважды нажать пока экран не станет выглядеть так:

seg000:0114 word_0_114 dw 18h ; DATA XREF: seg000:0106o
seg000:0116 db 0D2h ; T

Но что именно содержится в ячейке “word_0_144”? Понять это позволит изучение следующего кода:

seg000:0106 mov si, offset word_0_114
seg000:0109 lodsw
seg000:010A xchg ax, cx
seg000:010B push si
seg000:010C
seg000:010C loc_0_10C: ; CODE XREF: seg000:0110j
seg000:010C xor byte ptr [si], 66h
seg000:010F inc si
seg000:0110 loop loc_0_10C

В строке :0x10A значение регистра AX помещается в регистр CX, и затем он используется командой “LOOP LOC_010C” как счетчик цикла. Тело цикла представляет собой простейший расшифровщик – команда “XOR” расшифровывает один байт, на который указывает регистр SI, а команда “INC SI” перемещает указатель на следующий байт. Следовательно, в ячейке “word_0_144” содержится количество байт, которые необходимо расшифровать. Подведя к ней курсор, нажатием клавиши можно дать ей осмысленное имя, например “BytesToDecrypt”.
После завершения цикла расшифровщика встречается еще один безусловный регистровый переход.

seg000:0112 jmp si

Чтобы узнать куда именно он передает управление, необходимо проанализировать код и определить содержимое регистра SI. Часто для этой цели прибегают к помощи отладчика – устанавливают точку останова в строке 0x112 и дождавшись его «всплытия» просматривают значения регистров. Специально для этой цели, IDA Pro поддерживает генерацию map-файлов, содержащих символьную информацию для отладчика. В частности, чтобы не заучивать численные значения всех «подопытных» адресов, каждому из них можно присвоить легко запоминаемое символьное имя. Например, если подвести курсор к строке “seg000:0112”, нажать и ввести “BreakHere”, отладчик сможет автоматически вычислить обратный адрес по его имени.
Для создания map-файла в меню “File” необходимо кликнуть по «Produce output file» и в развернувшемся подменю выбрать «Produce MAP file» или вместо всего этого нажать на клавиатуре «горячую» комбинацию «. Независимо от способа вызова на экран должно появится диалоговое окно следующего вида. Оно позволяет выбрать какого рода данные будут включены в map-файл – информация о сегментах, имена автоматически сгенерированные IDA Pro (такие как, например, “loc_0_106”, “sub_0x110” и т.д.) и «размангленные» (т.е. приведенные в читабельный вид) имена. Содержимое полученного map-файла должно быть следующим:

Start Stop Length Name Class
00100H 0013BH 0003CH seg000 CODE
Address Publics by Value
0000:0100 start
0000:0112 BreakHere
0000:0114 BytesToDecrypt
Program entry point at 0000:0100

Такой формат поддерживают большинство отладчиков, в том числе и популярнейший Soft-Ice, в поставку которого входит утилита “msym”, запускаемая с указанием имени конвертируемого map-файла в командной стоке. Полученный sym-файл необходимо разместить в одной директории с отлаживаемой программой, загружаемой в загрузчик без указания расширения, т.е., например, так “WLDR Crypt”. В противном случае символьная информация не будет загружена!
Затем необходимо установить точку останова командой “bpx BreakHere” и покинуть отладчик командной “x”. Спустя секунду его окно вновь появиться на экране, извещая о достижении процессором контрольной точки. Посмотрев на значения регистров, отображаемых по умолчанию вверху экрана, можно выяснить, что содержимое SI равно 0x12E.
С другой стороны, это же значение можно вычислить «в уме», не прибегая к отладчику. Команда MOV в строке 0x106 загружает в регистр SI смещение 0x114, откуда командой LODSW считывается количество расшифровываемых байт – 0x18, при этом содержимое SI увеличивается на размер слова – два байта. Отсюда, в момент завершения цикла расшифровки значение SI будет равно 0x114+0x18+0x2 = 0x12E.
Вычислив адрес перехода в строке 0x112, рекомендуется создать соответствующую перекрестную ссылку (from: 0x122; to: 0x12E) и добавить комментарий к строке 0x112 (“Переход по адресу 012E”). Создание перекрестной ссылки автоматически дизассемблирует код, начиная с адреса seg000:012E и до конца файла.

seg000:012E loc_0_12E: ; CODE XREF: seg000:0112u
seg000:012E call $+3
seg000:0131 pop cx
seg000:0132 pop si
seg000:0133 mov di, 100h
seg000:0136 push di
seg000:0137 sub cx, si
seg000:0139 repe movsb
seg000:013B retn

Назначение команды “CALL $+3” (где $ обозначает текущее значение регистра указателя команд IP) состоит в заталкивании в стек содержимого регистра IP, откуда впоследствии оно может быть извлечено в любой регистр общего назначения. Необходимость подобного трюка объясняется тем, что в микропроцессорах серии Intel 80x86 регистр IP не входит в список непосредственно адресуемых и читать его значение могут лишь команды, изменяющие ход выполнения программы, в том числе и call.
Для облегчения анализа листинга можно добавить к стокам 0x12E и 0x131 комментарий – “MOV CX, IP”, или еще лучше – сразу вычислить и подставить непосредственное значение – “MOV CX,0x131”.
Команда “POP SI” в строке 0x132 снимает слово из стека и помещает его в регистр SI. Прокручивая экран дизассемблера вверх в строке 0x10B можно обнаружить парную ей инструкцию “PUSH SI”, заносящую в стек смещение первого расшифровываемого байта. После этого становится понятным смысл последующих команд “MOV DI, 0x100\SUB CX,SI\REPE MOVSB”. Они перемещают начало расшифрованного фрагмента по адресу, начинающегося со смещения 0x100. Такая операция характерна для «конвертных» защит, накладывающихся на уже откомпилированный файл, который перед запуском должен быть размещен по своим «родным» адресам.
Перед началом перемещения в регистр CX заносится длина копируемого блока, вычисляемая путем вычитания смещения первого расшифрованного байта от смещения второй команды перемещающего кода. В действительности, истинная длина на три байта короче и по идее от полученного значения необходимо вычесть три. Однако, такое несогласование не нарушает работоспособности, поскольку содержимое ячеек памяти, лежащих за концом расшифрованного фрагмента, не определено и может быть любым.
Пара команд “0x136:PUSH DI” и “0x13B:RETN” образуют аналог инструкции “CALL DI” – “PUSH” заталкивает адрес возврата в стек, а “RETN” извлекает его оттуда и передает управление по соответствующему адресу. Зная значение DI (оно равно 0x100) можно было бы добавить еще одну перекрестную ссылку (“from:0x13B; to:0x100”) и комментарий к строке :0x13B – “Переход по адресу 0x100”, но ведь к этому моменту по указанным адресам расположен совсем другой код! Поэтому, логически правильнее добавить перекрестную ссылку “from:0x13B; to:0x116” и комментарий “Переход по адресу 0x116”.
Сразу же после создания новой перекрестной ссылки IDA попытается дизассемблировать зашифрованный код, в результате чего получится следующее:

seg000:0116 loc_0_116: ; CODE XREF: seg000:013Bu
seg000:0116 shr byte ptr [bx-24h], cl
seg000:0119 outsb
seg000:011A stos word ptr es:[edi]
seg000:011C inc di
seg000:011D movsw
seg000:011E add cx, cs:[bp+si]
seg000:0121 or cl, [bx+di]
seg000:0123 dec dx
seg000:0124 xor ax, 0F07h
seg000:0127 or cl, [bx+di]
seg000:0129 adc al, 47h
seg000:0129;──────────────────────────────────────────────────────
seg000:012B db 6Bh ; k
seg000:012C db 6Ch ; l
seg000:012D db 42h ; B
seg000:012E;──────────────────────────────────────────────────────

Непосредственное дизассемблирование зашифрованного кода невозможно – предварительно его необходимо расшифровать. Подавляющее большинство дизассемблеров не могут модифицировать анализируемый текст налету и до загрузки в дизассемблер исследуемый файл должен быть полностью расшифрован. На практике, однако, это выглядит несколько иначе – прежде чем расшифровывать необходимо выяснить алгоритм расшифровки, проанализировав доступную часть файла. Затем выйти из дизассемблера, тем или иным способом расшифровать «секретный» фрагмент, вновь загрузить файл в дизассемблер (причем предыдущие результаты дизассемблирования окажутся утеряны) и продолжить его анализ до тех пор, пока не встретится еще один зашифрованный фрагмент, после чего описанный цикл «выход из дизассемблера –расшифровка – загрузка - анализ» повторяется вновь.
Достоинство IDA заключается в том, что она позволяет выполнить ту же задачу значительно меньшими усилиями, никуда не выходя из дизассемблера. Это достигается за счет наличия механизма виртуальной памяти, – если не вдаваться в технические тонкости, упрощенно можно изобразить IDA в виде «прозрачной» виртуальной машины, оперирующей с физической памятью компьютера. Для модификации ячеек памяти необходимо знать их адрес, состоящий из пары чисел – сегмента и смещения.
Слева каждой строки указывается ее смещение и имя сегмента, например “seg000:0116”. Узнать базовый адрес сегмента по его имени можно, открыв окно «Сегменты» выбрав в меню «View» пункт «Segments».

╔═[■]═══════════════════════════ Program Segmentation ══════════════════════════3═[↑]═╗
║ Name Start End Align Base Type Cls 32es ss ds ▲
║ seg000 00000100 0000013C byte 1000 pub CODE N FFFF FFFF 1000 00010100 0001013C ▓
║ ▓
║ ▼
╚1/1 ═════════════════◄■▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒►─┘

Рисунок 8 Окно «Сегменты»

Искомый адрес находится в столбце “Base” и для наглядности на приведенной копии экрана выделен жирным шрифтом. Обратится к любой ячейке сегмента поможет конструкция “[segment:offset]”, а для чтения и модификации ячеек предусмотрены функции Byte и PatchByte соответственно. Их вызов может выглядеть, например, так: a=Byte([0x1000,0x100]) – читает ячейку, расположенную по смещению 0x100 в сегменте с базовым адресом 0x1000; PatchByte([0x1000,0x100],0x27) – присваивает значение 0x27 ячейке памяти, расположенной по смещению 0x100 в сегменте с базовым адресом 0x1000. Как следует из названия функций, они манипулируют с ячейками размером в один байт.
Знания этих двух функций вполне достаточно для написания скрипта -расшифровщика при условии, что читатель знаком с языком Си. Реализация IDA-Си не полностью поддерживается стандарта – подробнее об этом рассказывается в главе «Язык скриптов IDA-Си», здесь же достаточно заметить, что в частности IDA не позволяет разработчику задавать тип переменной и определяет его автоматически по ее первому использованию, а объявление осуществляется ключевым словом “auto”. Например, “auto MyVar, s0” объявляет две переменных – MyVar и s0.
Для создания скрипта необходимо нажать комбинацию клавиш или выбрать в меню “File” пункт “IDC Command” и в появившемся окне диалога ввести исходный текст программы:

╔═[■]════════════════ Notepad ═════════════════════╗
║ Enter IDC statement(s) ║
║ auto a; ▲ ║
║ for (a=0x116;a<0x12E;a++) ▓ ║
║ PatchByte([0x1000,a], ▓ OK ▄ ║
║ Byte([0x1000,a])^0x66); ▓ ▀▀▀▀▀▀▀▀ ║
║ ▓ ║
║ ▓ ║
║ ▓ Cancel ▄ ║
║ ▓ ▀▀▀▀▀▀▀▀ ║
║ ▓ ║
║ ▓ ║
║ ▓ Help ▄ ║
║ ▼ ▀▀▀▀▀▀▀▀ ║
║☼═════ 5:1 ═══◄■▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒► ║
╚══════════════════════════════════════════════════╝

Рисунок 9 Встроенный редактор скриптов

auto a;
for (a=0x116;a<0x12E;a++)
PatchByte([0x1000,a],Byte([0x1000,a])^0x66);
a) исходный текст скрипта - расшифровщика

Пояснение: как было показано выше алгоритм расшифровщика сводится к последовательному преобразованию каждой ячейки зашифрованного фрагмента операцией XOR 0x66, (см. ниже – выделено жирным шрифтом)

seg000:010C xor byte ptr [si], 66h
seg000:010F inc si
seg000:0110 loop loc_0_10C

Сам же зашифрованный фрагмент начинается с адреса seg000:0x116 и продолжается вплоть до seg000:0x12E. Отсюда – цикл расшифровки на языке Си выглядит так: for (a=0x116;a<0x12E;a++) PatchByte([0x1000,a],Byte([0x1000,a])^0x66);

В зависимости от версии IDA для выполнения скрипта необходимо нажать либо (версия 3.8x и старше), либо в более ранних версиях. Если все сделано правильно, после выполнения скрипта экран дизассемблера должен выглядеть так (b).
Возможные ошибки – несоблюдение регистра символов (IDA к этому чувствительна), синтаксические ошибки, базовый адрес вашего сегмента отличается от 0x1000 (еще раз вызовете окно «Сегменты» чтобы узнать его значение). В противном случае необходимо подвести курсор к строке “seg000:0116”, нажать клавишу для удаления результатов предыдущего дизассемблирования зашифрованного фрагмента и затем клавишу для повторного дизассемблирования расшифрованного кода.

seg000:0116 loc_0_116: ; CODE XREF: seg000:013Bu
seg000:0116 mov ah, 9
seg000:0118 mov dx, 108h
seg000:011B int 21h ; DOS - PRINT STRING
seg000:011B ; DS:DX -> string terminated by "$"
seg000:011D retn
seg000:011D ; ───────────────────────────────────────────────────────────────────────────
seg000:011E db 48h ; H
seg000:011F db 65h ; e
seg000:0120 db 6Ch ; l
seg000:0121 db 6Ch ; l
seg000:0122 db 6Fh ; o
seg000:0123 db 2Ch ; ,
seg000:0124 db 53h ; S
seg000:0125 db 61h ; a
seg000:0126 db 69h ; i
seg000:0127 db 6Ch ; l
seg000:0128 db 6Fh ; o
seg000:0129 db 72h ; r
seg000:012A db 21h ; !
seg000:012B db 0Dh ;
seg000:012C db 0Ah ;
seg000:012D db 24h ; $
seg000:012E ; ───────────────────────────────────────────────────────────────────────────
b) результат работы скрипта расшифровщика

Цепочку символов, расположенную начиная с адреса “seg000:011E” можно преобразовать в удобочитаемый вид, подведя к ней курсор и нажав клавишу “”. Теперь экран дизассемблера будет выглядеть так:

seg000:0116 loc_0_116: ; CODE XREF: seg000:013Bu
seg000:0116 mov ah, 9
seg000:0118 mov dx, 108h
seg000:011B int 21h ; DOS - PRINT STRING
seg000:011B ; DS:DX -> string terminated by "$"
seg000:011D retn
seg000:011D ; ───────────────────────────────────────────────────────────────────────────
seg000:011E aHelloSailor db 'Hello,Sailor!',0Dh,0Ah,'$'
seg000:012E ; ───────────────────────────────────────────────────────────────────────────
с) создание ASCII-строки

Команда “MOV AH,9” в строке :0116 подготавливает регистр AH перед вызовом прерывания 0x21, выбирая функцию вывода строки на экран, смещение которой заносится следующей командой в регистр DX. Т.е. для успешного ассемблирования листинга необходимо заменить константу 0x108 соответствующим смещением. Но ведь выводимая строка на этапе ассемблирования (до перемещения кода) расположена совсем в другом месте! Одно из возможных решений этой проблемы заключается в создании нового сегмента с последующим копированием в него расшифрованного кода – в результате чего достигается эмуляции перемещения кода работающей программы.
Для создания нового сегмента можно выбрать в меню «View» пункт «Segments» и в раскрывшемся окне нажать клавишу . Появится диалог следующего вида (см. рис. 10):

╔═[■]════════════ Create a new segment ════════════════╗
║ ║
║ Start address and end address should be valid. ║
║ End address > Start address ║
║ ║
║ Segment name MySeg ▐↓▌ ║
║ Start address 0x20100 ▐↓▌ C-notation: ║
║ End address 0x20125 ▐↓▌ hex is 0x... ║
║ Base 0x2000 ▐↓▌ in paragraphs ║
║ Class ▐↓▌ (class is any text)║
║ ║
║ [ ] 32-bit segment ║
║ ║
║ OK ▄ Cancel ▄ F1 - Help ▄ ║
║ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀ ║
╚══════════════════════════════════════════════════════╝

Рисунок 10 IDAC: Создание нового сегмента

Пояснение: Базовый адрес сегмента может быть любым если при этом не происходит перекрытия сегментов seg000 и MySeg; начальный адрес сегмента задается так, чтобы смещение первого байта было равно 0x100; разница между конечным и начальным адресом равна длине сегмента, вычислить которую можно вычитанием смещения начала расшифрованного фрагмента от смещения его конца – 0x13B – 0x116 = 0x25.

Скопировать требуемый фрагмент в только что созданный сегмент можно скриптом следующего содержания.

auto a;
for (a=0x0;a<0x25;a++) PatchByte([0x2000,a+0x100],Byte([0x1000,a+0x116]));
a) исходный текст скрипта - копировщика

Для его ввода необходимо вновь нажать , при этом предыдущий скрипт будет утерян (IDA позволяет работать не более чем с один скриптом одновременно). После завершения его работы экран дизассемблера будет выглядеть так:

MySeg:0100 MySeg segment byte public '' use16
MySeg:0100 assume cs:MySeg
MySeg:0100 ;org 100h
MySeg:0100 assume es:nothing, ss:nothing, ds:nothing, fs:nothing, gs:nothing
MySeg:0100 db 0B4h ; ┤
MySeg:0101 db 9 ;
MySeg:0102 db 0BAh ; ║
MySeg:0103 db 8 ;
MySeg:0104 db 1 ;
MySeg:0105 db 0CDh ; ═
MySeg:0106 db 21h ; !
MySeg:0107 db 0C3h ; ├
MySeg:0108 db 48h ; H
MySeg:0109 db 65h ; e
MySeg:010A db 6Ch ; l
MySeg:010B db 6Ch ; l
MySeg:010C db 6Fh ; o
MySeg:010D db 2Ch ; ,
MySeg:010E db 53h ; S
MySeg:010F db 61h ; a
MySeg:0110 db 69h ; i
MySeg:0111 db 6Ch ; l
MySeg:0112 db 6Fh ; o
MySeg:0113 db 72h ; r
MySeg:0114 db 21h ; !
MySeg:0115 db 0Dh ;
MySeg:0116 db 0Ah ;
MySeg:0117 db 24h ; $
MySeg:0117 MySeg ends
b) результат работы скрипта-копировщика

Теперь необходимо создать перекрестную ссылку “from:seg000:013B; to:MySeg:0x100”, преобразовать цепочку символов в удобочитаемую строку, подведя курсор к строке MySeg:0108 и нажав клавишу
. Экран дизассемблера должен выглядеть так:

MySeg:0100 loc_1000_100: ; CODE XREF: seg000:013Bu
MySeg:0100 mov ah, 9
MySeg:0102 mov dx, 108h
MySeg:0105 int 21h ; DOS - PRINT STRING
MySeg:0105 ; DS:DX -> string terminated by "$"
MySeg:0107 retn
MySeg:0107 ; ───────────────────────────────────────────────────────────────────────────
MySeg:0108 aHelloSailorS db 'Hello,Sailor!',0Dh,0Ah
MySeg:0108 db '$'
MySeg:0118 MySeg ends
с) результат дизассемблирования скопированного фрагмента

Результатом всех этих операций стало совпадение смещения строки со значением, загружаемым в регистр DX (в тексте они выделены жирным шрифтом). Если подвести курсор к константе “108h” и нажать клавишу она будет преобразована в смещение:

MySeg:0102 mov dx, offset aHelloSailorS ; "Hello,Sailor!\r\n$ш"
MySeg:0105 int 21h ; DOS - PRINT STRING
MySeg:0105 ; DS:DX -> string terminated by "$"
MySeg:0107 retn
MySeg:0107 ; ───────────────────────────────────────────────────────────────────────────
MySeg:0108 aHelloSailorS db 'Hello,Sailor!',0Dh,0Ah ; DATA XREF: MySeg:0102o
d) преобразование константы в смещение

Полученный листинг удобен для анализа, но все еще не готов к ассемблированию, хотя бы уже потому, что никакой ассемблер не в состоянии зашифровать требуемый код. Конечно, эту операцию можно выполнить вручную, после компиляции, но IDA позволит проделать то же самое не выходя из нее и не прибегая к помощи стороннего инструментария.
Демонстрация получится намного нагляднее, если в исследуемый файл внести некоторые изменения, например, добавить ожидание клавиши на выходе. Для этого можно прибегнуть к интегрированному в IDA ассемблеру, но прежде, разумеется, необходимо несколько «раздвинуть» границы сегмента MySeg, дабы было к чему дописывать новый код.
Выберете в меню “View” пункт “Segments” и в открывшемся окне подведите курсор к стоке “MySeg”. Нажатие открывает диалог свойств сегмента, содержащий среди прочих полей конечный адрес, который и требуется изменить. Не обязательно указывать точное значение – можно «растянуть» сегмент с небольшим запасом от предполагаемых изменений.
Если попытаться добавить к программе код “XOR AX,AX; INT 16h” он неминуемо затрет начало строки “Hello, Sailor!”, поэтому, ее необходимо заблаговременно передвинуть немного «вниз» (т.е. в область более старших адресов), например, с помощью скрипта следующего содержания «for(a=0x108;a<0x11A;a++) PatchByte([0x2000,a+0x20],Byte([0x2000,a]);».

Пояснение: объявление переменной a для краткости опущено (сами должны понимать, не маленькие :-), длина строки, как водится, берется с запасом, чтобы не утомлять себя лишними вычислениями и перемещение происходит справа налево, поскольку исходный и целевой фрагменты заведомо не пересекаются.

Подведя к курсор к строке :0128 нажатием
преобразуем цепочку символов к удобно-читаемому виду; подведем курсор к строке :0102 и, выбрав в меню “Edir” пункт “Path program”, “Assembler”, введем команду “MOV DX,128h”, где «128h» - новое смещение строки, и тут же преобразуем его в смещение нажатием .
Вот теперь можно вводить новый текст – переместив курсор на инструкцию “ret”, вновь вызовем ассемблер и введем “XOR AX,AXINT 16hRET”. На последок рекомендуется произвести «косметическую» чистку – уменьшить размер сегмента до необходимого и переместить строку “Hello, Sailor” вверх, прижав ее вплотную к коду.

Пояснение: удалить адреса, оставшиеся при уменьшении размеров сегмента за его концом можно взводом флажка “Disable Address” в окне свойств сегмента, вызываемом нажатием

Если все было сделано правильно конечный результат должен выглядеть как показано ниже:

seg000:0100 ; File Name : F:\IDAN\SRC\Crypt.com
seg000:0100 ; Format : MS-DOS COM-file
seg000:0100 ; Base Address: 1000h Range: 10100h-1013Ch Loaded length: 3Ch
seg000:0100
seg000:0100
seg000:0100 ; ===========================================================================
seg000:0100
seg000:0100 ; Segment type: Pure code
seg000:0100 seg000 segment byte public 'CODE' use16
seg000:0100 assume cs:seg000
seg000:0100 org 100h
seg000:0100 assume es:nothing, ss:nothing, ds:seg000, fs:nothing, gs:nothing
seg000:0100
seg000:0100 ; --------------- S U B R O U T I N E ---------------------------------------
seg000:0100
seg000:0100
seg000:0100 public start
seg000:0100 start proc near
seg000:0100 add si, 6
seg000:0103 jmp si ; Ïåðåõîä ïî àäðåñó 0106
seg000:0103 start endp
seg000:0103
seg000:0103 ; ---------------------------------------------------------------------------
seg000:0105 db 0B9h ; ¦
seg000:0106 ; ---------------------------------------------------------------------------
seg000:0106 mov si, offset BytesToDecrypt
seg000:0109 lodsw
seg000:010A xchg ax, cx
seg000:010B push si
seg000:010C
seg000:010C loc_0_10C: ; CODE XREF: seg000:0110j
seg000:010C xor byte ptr [si], 66h
seg000:010F inc si
seg000:0110 loop loc_0_10C
seg000:0112
seg000:0112 BreakHere: ; Ïåðåõîä ïî àäðåñó 012E
seg000:0112 jmp si
seg000:0112 ; ---------------------------------------------------------------------------
seg000:0114 BytesToDecrypt dw 18h ; DATA XREF: seg000:0106o
seg000:0116 ; ---------------------------------------------------------------------------
seg000:0116
seg000:0116 loc_0_116: ; CODE XREF: seg000:013Bu
seg000:0116 mov ah, 9
seg000:0118 mov dx, 108h ; "Hello,Sailor!\r\n$"
seg000:011B int 21h ; DOS - PRINT STRING
seg000:011B ; DS:DX -> string terminated by "$"
seg000:011D retn
seg000:011D ; ---------------------------------------------------------------------------
seg000:011E aHelloSailor db 'Hello,Sailor!',0Dh,0Ah,'$' ; DATA XREF: seg000:0118o
seg000:012E ; ---------------------------------------------------------------------------
seg000:012E
seg000:012E loc_0_12E: ; CODE XREF: seg000:0112u
seg000:012E call $+3
seg000:0131 pop cx
seg000:0132 pop si
seg000:0133 mov di, 100h
seg000:0136 push di
seg000:0137 sub cx, si
seg000:0139 repe movsb
seg000:013B retn
seg000:013B seg000 ends
seg000:013B
MySeg:0100 ; ---------------------------------------------------------------------------
MySeg:0100 ; ===========================================================================
MySeg:0100
MySeg:0100 ; Segment type: Regular
MySeg:0100 MySeg segment byte public '' use16
MySeg:0100 assume cs:MySeg
MySeg:0100 ;org 100h
MySeg:0100 assume es:nothing, ss:nothing, ds:nothing, fs:nothing, gs:nothing
MySeg:0100
MySeg:0100 loc_1000_100: ; CODE XREF: seg000:013Bu
MySeg:0100 mov ah, 9
MySeg:0102 mov dx, offset aHelloSailor_0 ; "Hello,Sailor!\r\n$"
MySeg:0105 int 21h ; DOS - PRINT STRING
MySeg:0105 ; DS:DX -> string terminated by "$"
MySeg:0107 xor ax, ax
MySeg:0109 int 16h ; KEYBOARD - READ CHAR FROM BUFFER, WAIT IF EMPTY
MySeg:0109 ; Return: AH = scan code, AL = character
MySeg:010B retn
MySeg:010B ; ---------------------------------------------------------------------------
MySeg:010C aHelloSailor_0 db 'Hello,Sailor!',0Dh,0Ah,'$' ; DATA XREF: MySeg:0102o
MySeg:010C MySeg ends
MySeg:010C
MySeg:010C
MySeg:010C end start
a) окончательно дизассемблированный текст

Структурно программа состоит из следующих частей – расшифровщика, занимающего адреса seg000:0x100 – seg000:0x113, переменной размером в слово, содержащей количество расшифровываемых байт, занимающей адреса seg000:0x114-seg000:0x116, исполняемого кода программы, занимающего целиком сегмент MySeg и загрузчика, занимающего адреса seg000:0x12E-seg000:0x13B. Все эти части должны быть в перечисленном порядке скопированы в целевой файл, причем исполняемый код программы необходимо предварительно зашифровать, произведя над каждым его байтом операцию XOR 0x66.
Ниже приведен пример скрипта, автоматически выполняющего указанные действия. Для его загрузки достаточно нажать или выбрать в меню “File” пункт “Load file”, “IDC file”.

// Компилятор для файла Crypt
//
static main()
{
auto a,f;

// Открывается файл Crtypt2.com для записи в двоичном режиме
f=fopen("crypt2.com","wb");

// В файл Crypt2 копируется расшифровщик
for (a=0x100;a<0x114;a++) fputc(Byte([0x1000,a]),f);
// Определяется и копируется в файл слово, содержащее число
// байтов для расшифровки
fputc( SegEnd([0x2000,0x100]) - SegStart([0x2000,0x100]),f);
fputc(0,f);

// Копируется и налету шифруется расшифрованный фрагмент
for(a=SegStart([0x2000,0x100]);a!=SegEnd([0x2000,0x100]);a++)
fputc(Byte(a) ^ 0x66,f);

// Дописывается загрузчик
for(a=0x12E;a<0x13C;a++)
fputc(Byte([0x1000,a]),f);

// Закрывается файл.
fclose(f);
}
a) исходный код скрипта-компилятора

Выполнение скрипта приведет к созданию файла “Crypt2.com”, запустив который можно убедиться в его работоспособности – он выводит строку на экран и, дождавшись нажатия любой клавиши, завершает свою работу.
Огромным преимуществом такого подхода является «сквозная» компиляция файла, т.е. дизассемблированный листинг в действительности не ассемблировался! Вместо этого из виртуальной памяти байт-за-байтом читалось оригинальное содержимое, которое за исключением модифицированных строк доподлинно идентично исходному файлу. Напротив, повторное ассемблирование практически никогда не позволяет добиться полного сходства с дизассемблируемым файлом.
IDA – очень удобный инструмент для модификации файлов, исходные тексты которых утеряны или отсутствуют; она практически единственный дизассемблер, способный анализировать зашифрованные программы, не прибегая к сторонним средствам; она обладает развитым пользовательским интерфейсом и удобной системой навигации по исследуемому тексту; она дает может справится с любой мыслимой и немыслимой задачей…
…но эти, и многие другие возможности, невозможно реализовать в полной мере, без владения языком скриптов, что и подтвердил приведенный выше пример.

___Рассказать о языке комментариев. "Дом который построил Джек"

___Трассированное дизасссемблирование

___Большинство защит вскрываются стандартными приемами, которые вовсе не требуют понимания "как это работает". Мой тезка (широко известный среди спектрумистов уже едва ли не десяток лет) однажды сказал "Умение снимать защиту, еще не означает умения ее ставить". Это типично для кракера, которому, судя по всему, ничто не мешает ломать и крушить. Хакер же не ставит целью взлом (т.е. способ любой ценой заставить программу работать), а интересуется именно МЕХАНИЗМОМ: "как оно работает". Взлом для него вторичен.

Шаг шестой. Дизассемблер & отладчик в связке

"Кот с улыбкой - и то редкость, но уж улыбка без кота - это я прямо не знаю что такое"
Льюис Кэрролл. Алиса в стране чудес

Существует два способа исследования программ, распространяющихся без исходных текстов: дизассемблирование (статический анализ) и отладка (динамический анализ). Вообще-то, любой отладчик обязательно включает в себя дизассемблер, – иначе отлаживать программу пришлось непосредственно в машинных кодах!
Однако тот дизассемблер, что включен в отладчик, обычно слишком примитивен и не может похвастаться богатыми функциональными возможностями. Во всяком случае, дизассемблер, встроенный в популярнейший отладчик Soft-Ice, недалеко ушел от DUMPBIN, с недостатками которого мы уже имели честь столкнуться. Насколько же понятнее становится код, если его загрузить в IDA!
Чем же тогда ценен отладчик? Дело в том, что дизассемблер в силу своей статичности имеет ряд ограничений. Во-первых, исследователю приходится выполнять программу на "эмуляторе" процессора, "зашитом" в их собственной голове, следовательно, необходимо знать и назначение всех команд процессора, и все структуры операционной системы (включая недокументированные), и… Во-вторых, начать анализ с произвольного места программы не так-то просто – требуется знать содержимое регистров и ячеек памяти на данный момент, а как их узнать? С регистрами и локальными переменными еще бы куда ни шло – прокрутим экран дизассемблера вверх и посмотрим какие значения им присваиваются, но этот фокус не пройдет с глобальными переменными, модифицировать которые может кто угодно и когда угодно. Вот бы установить точку останова… но какая же в дизассемблере может быть точка останова? В третьих, дизассемблирование вынуждает на полную реконструкцию алгоритма каждой функции, в то время как отладка позволяет рассматривать ее как "черный ящик" со входом и выходом. Допустим, имеется у нас функция, которая расшифровывает основной модуль программы. В дизассемблере нам придется сначала разобраться в алгоритме шифрования (что может оказаться совсем не просто), затем "переложить" эту функцию на IDA-Си, отладить ее, запустить расшифровщик… В отладчике же можно поручить выполнение этой функции процессору, не вникая в то, как она работает, и дождавшись ее завершения, продолжить анализ расшифрованного модуля программы. Можно перечислять бесконечно, но и без того ясно, что отладчик отнюдь не конкурент дизассемблеру, а партнер.
Опытные хакеры всегда используют эти два инструмента в паре. Алгоритм реконструируется в дизассемблере, а все непонятные моменты оперативно уточняются, прогоном под отладчиком. При этом возникает естественное желание видеть в отладчике все те символические имена, которые были внесены в дизассемблерный листинг.
И IDA Pro действительно позволяет это сделать! Выберем в меню "Fail" подменю "Produce output file", а в нем пункт "Produce MAP file" (или нажмем "горячую" клавишу ). На экране появится окно с запросом имени файла (введем, например, "simple.map"), а затем возникнет модальный диалог, уточняющий какие именно имена стоит включать в map-файл. Нажмем , оставив все галочки в состоянии по умолчанию (подробно о назначении каждой из них можно прочитать в моей книге "Образ мышления – дизассемблер IDA"). Парой секунд спустя на диске образуется "simple.map" файл, содержащий всю необходимую отладочную информацию, представленную в map-формате Borland. Отладчик Soft-ice не поддерживает такой формат, поэтому, перед его использованием файл необходимо конвертировать в sym-формат специально на то предназначенной утилитой idasym, которую можно бесплатно скачать с сайта www.idapro.com или получить у дистрибьютора, продавшего вам IDA.
Набрав в командной строке "idasym simple.map" и, с удовлетворением убедившись, что файл "simple.sym" действительно создан, запустим загрузим исследуемое приложение "simple.exe" в отладчик любым возможным способом. Дождавшись появления Soft-Ice на экране, отладим ему команду "SYM" для отображения содержимого таблицы символов. Если все было сделано правильно, ответ Soft-Ice должен выглядеть приблизительно так (ниже приведен сокращенный вариант):

:sym
CODE(001B)
001B:00401000 start
001B:00401074 __GetExceptDLLinfo
001B:0040107C _Main
001B:00401104 _memchr
001B:00401124 _memcpy
001B:00401148 _memmove
001B:00401194 _memset
001B:004011C4 _strcmp
001B:004011F0 _strlen
001B:0040120C _memcmp
001B:00401250 _strrchr
001B:00403C08 _printf
DATA(0023)
0023:00407000 aBorlandCCopyri
0023:004070D9 aEnterPassword
0023:004070E9 aMygoodpassword
0023:004070F9 aWrongPassword
0023:00407109 aPasswordOk
0023:00407210 aNotype
0023:00407219 aBccxh1

Wow! Это функциклирует! Теперь символьные имена не только отображаются на экране, упрощая понимание кода, – на любое из них можно быстро и с комфортом установить точку останова, скажем "bpm aMygoodpassword" и отладчик поймет, что от него хотят! Нет больше нужны держать в серо-мозговой памяти эти трудно запоминаемые шестнадцатеричные адреса!

Шаг седьмой. Идентификация ключевых структур языков высокого уровня

"Если твое оружие стоит только малой части энергии, затраченной твоим врагом, ты имеешь мощный рычаг, который может одолеть непреодолимые с виду трудности"

Френк Херберт "Дом глав Дюны"

Исследование алгоритма работы программ, написанных на языках высокого уровня, традиционно начинается с реконструкции ключевых структур исходного языка – функций, локальных и глобальных переменных, ветвлений, циклов и т.д. Это делает дизассемблерный листинг более наглядным и значительно упрощает его анализ.
Современные дизассемблеры достаточно интеллектуальны и львиную долю работы по распознаванию ключевых структур берут на себя. В частности, IDA Pro успешно справляется с идентификацией стандартных библиотечных функций, локальных переменных, адресуемых через регистр ESP, case-ветвлений и т.д. Однако порой она ошибается, вводя исследователя в заблуждение, к тому же ее высокая стоимость не всегда оправдывает применение. Так, например, студентам, изучающим ассемблер (а лучше средство изучение ассемблера – дизассемблирование чужих программ), она едва ли по карману.
Разумеется, на IDA свет клином не сошелся – существуют же и другие дизассемблеры, скажем тот же DUMPBIN, входящий в штатную поставку SDK, - почему бы на худой конец не воспользоваться им? Конечно, если под рукой нет ничего лучшего, сойдет и DUMPBIN, но в этом случае об интеллектуальности дизассемблера придется забыть и все делать своей головой.
Первым делом мы познакомимся с не оптимизирующими компиляторами – анализ их кода относительно прост и вполне доступен для понимания даже новичкам программирования. Затем же, освоившись с дизассемблером, перейдем к вещам более сложным – оптимизирующим компиляторам, генерирующих очень хитрый, запутанный и витиеватый код.

Идентификация функций

"Для некоторых людей программирование является такой же внутренней потребностью, подобно тому, как коровы дают молоко, или писатели стремятся писать
Николай Безруков

Функция (так же называемая процедурой или подпрограммой) – основная структурная единица процедурных и объективно-ориентированных языков, поэтому дизассемблирование кода обычно начинается с отождествления функций и идентификации передаваемых им аргументов.
Строгого говоря, термин "функция" присутствует не во всех языках, но даже там, где он присутствует, его определение варьируется от языка к языку. Не вдаваясь в детали, мы будем понимать под функцией обособленную последовательность команд, вызываемую из различных частей программы. Функция может принимать один и более аргументов, а может не принимать ни одного; может возвращать результат своей работы, а может и не возвращать, - это уже не суть важно. Ключевое свойство функции – возвращение управления на место ее вызова, а ее характерный признак – множественный вызов из различных частей программы (хотя некоторые функции вызываются лишь из одного места).
Откуда функция знает: куда следует возвратить управление? Очевидно, вызывающий код должен предварительно сохранить адрес возврата и вместе с прочими аргументами передать его вызываемой функции. Существует множество способов решения этой проблемы: можно, например, перед вызовом функции поместить в ее конец безусловный переход на адрес возврата, можно сохранить адрес возврата в специальной переменной и после завершения функции выполнить косвенный переход, используя эту переменную как операнд инструкции jump,… Не останавливаясь на обсуждении сильных и слабых сторон каждого метода, отметим, что компиляторы в подавляющем большинстве случаев используют специальные машинные команды CALL и RET соответственно предназначенные для вызова и выхода из функции.
Инструкция CALL закидывает адрес следующей за ней инструкции на вершину стека, а RET стягивает и передает на него управление. Тот адрес, на который указывает инструкция CALL, и есть адрес начала функции. А замыкает функцию инструкция RET (но, внимание: не всякий RET обозначает конец функции! подробнее об этом см. "Идентификация значения, возращенного функцией").
Таким образом, распознать функцию можно двояко: по перекрестным ссылкам, ведущим к машинной инструкции CALL и по ее эпилогу, завершающемуся инструкцией RET. Перекрестные ссылки и эпилог в совокупности позволяют определить адреса начала и конца функции. Немного забегая вперед (см. "Идентификация локальных стековых переменных") заметим, что в начале многих функций присутствует характерная последовательность команд, называемая эпилогом, которая так же пригодна для идентификации функций. А теперь расскажем обо всем этом поподробнее.

::Перекрестные ссылки. Просматривая дизассемблерный код, находим все инструкции CALL – содержимое их операнда и будет искомым адресом начала функции. Адрес не виртуальных функций, вызываемых по имени, вычисляется еще на стадии компиляции и операнд инструкции CALL в таких случаях представляет собой непосредственное значение. Благодаря этому адрес начала функции выявляется простым синтаксическим анализом: ищем контекстным поиском все подстроки "CALL" и запоминаем (записываемым) непосредственные операнды.
Рассмотрим следующий пример:

func();

main(){
int a;
func();
a=0x666;
func();
}

func(){
int a;
a++;
}
Листинг 6 Пример, демонстрирующий непосредственный вызов функции
Результат его компиляции должен выглядеть приблизительно так:

.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 push ecx
.text:00401004 call 401019
.text:00401004 ; Вот мы выловили инструкцию call c непосредственным операндом,
.text:00401004 ; представляющим собой адрес начала функции. Точнее - ее смещение
.text:00401004 ; в кодовом сегменте (в данном случае в сегменте ".text")
.text:00401004 ; Теперь можно перейти к строке ".text:00401019" и, дав функции
.text:00401004 ; собственное имя, заменить операнд инструкции call на конструкцию
.text:00401004 ; "call offset Имя функции"
.text:00401004 ;
.text:00401009 mov dword ptr [ebp-4], 666h
.text:00401010 call 401019
.text:00401010 ; А вот еще один вызов функции! Обратившись к строке ".text:401019"
.text:00401010 ; мы увидим, что эта совокупность инструкций уже определена как функция
.text:00401010 ; и все, что потребуется сделать, – заменить call 401019 на
.text:00401010 ; "call offset Имя функции"
.text:00401010
.text:00401015 mov esp, ebp
.text:00401017 pop ebp
.text:00401018 retn
.text:00401018 ; Вот нам встретилась инструкция возврата из функции, однако, не факт
.text:00401018 ; что это действительно конец функции – ведь функция может иметь и
.text:00401018 ; и несколько точек выхода. Однако, смотрите: следом за ret
.text:00401018 ; расположено начало функции "моя функция", отождествленное по
.text:00401018 ; операнду инструкции call.
.text:00401018 ; Поскольку, функции не могут перекрываться, выходит, что данный ret -
.text:00401018 ; конец функции!
.text:00401018 ;
.text:00401019 push ebp
.text:00401019 ; На эту строку ссылаются операнды нескольких инструкций call.
.text:00401019 ; Следовательно, это – адрес начала функции.
.text:00401019 ; Каждая функция должна иметь собственное имя – как бы нам ее назвать?
.text:00401019 ; Назовем ее "моя функция" :-)
.text:00401019 ;
.text:0040101A mov ebp, esp ; <-
.text:0040101C push ecx ; <-
.text:0040101D mov eax, [ebp-4] ; <-
.text:00401020 add eax, 1 ; <- Это – тело "моей функции"
.text:00401023 mov [ebp-4],eax ; <-
.text:00401026 mov esp, ebp ; <-
.text:00401028 pop ebp ; <-
.text:00401029 retn
.text:00401029; Конец "моей функции"
Листинг 7
Как мы видим, все очень просто. Однако задача заметно усложняется, если программист (или компилятор) использует косвенные вызовы функций, передавая их адрес в регистре и динамически вычисляя его (адрес, не регистр!) на стадии выполнения программы. Именно так, в частности, реализована работа с виртуальными функциями (см. "Идентификация виртуальных функций"), однако, в любом случае компилятор должен каким-то образом сохранить адрес функции в коде, значит, его можно найти и вычислить! Еще проще загрузить исследуемое приложение в отладчик, установить на "подследственную" инструкцию CALL точку останова и, дождавшись всплытия отладчика, посмотреть по какому адресу она передаст управление.
Рассмотрим следующий пример:

func();
main(){
int (a*)();
a=func;
a();
}
Листинг 8 Пример, демонстрирующий вызов функции по указателю
Результат его компиляции должен в общем случае выглядеть так:

.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 push ecx
.text:00401004 mov dword ptr [ebp-4], 401012
.text:0040100B call dword ptr [ebp-4]
.text:0040100B ; Вот инструкция CALL, осуществляющая косвенный вызов функции
.text:0040100B ; по адресу, содержащемуся в ячейке [EBP-4].
.text:0040100B ; Как знать – что же там содержится? Прокрутим экран дизассемблера
.text:0040100B ; немного вверх, пока не встретим строку "mov dword ptr [ebp-4],401012"
.text:0040100B ; Ага! Значит, управление передается по адресу ".text: 401012", -
.text:0040100B ; это и есть адрес начала функции!
.text:0040100B ; Даем функции имя и заменяем "mov dword ptr [ebp-4], 401012" на
.text:0040100B ; "mov dword ptr [ebp-4], offset Имя функции"
.text:0040100B ;
.text:0040100E mov esp, ebp
.text:00401010 pop ebp
.text:00401011 retn
Листинг 9
В некоторых, достаточно немногочисленных, программах встречается и косвенный вызов функции с комплексным вычислением ее адреса. Рассмотрим следующий пример:

func_1();
func_2();
func_3();

main()
{
int x;
int a[3]={(int) func_1,(int) func_2, (int) func_3};
int (*f)();

for (x=0;x < 3;x++)
{
f=(int (*)()) a[x];
f();
}
}
Листинг 10 Пример, демонстрирующий вызов функции по указателю с комплексным вычислением целевого адреса
Результат его дизассемблирования в общем случае должен выглядеть так:

.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 sub esp, 14h
.text:00401006 mov [ebp+0xC], offset sub_401046
.text:0040100D mov [ebp+0x8], offset sub_401058
.text:00401014 mov [ebp+0x4], offset sub_40106A
.text:0040101B mov [ebp+0x14], 0
.text:00401022 jmp short loc_40102D
.text:00401024 mov eax, [ebp+0x14]
.text:00401027 add eax, 1
.text:0040102A mov [ebp+0x14], eax
.text:0040102D cmp [ebp+0x14], 3
.text:00401031 jge short loc_401042
.text:00401033 mov ecx, [ebp+0x14]
.text:00401036 mov edx, [ebp+ecx*4+0xC]
.text:0040103A mov [ebp+0x10], edx
.text:0040103D call [ebp+0x10]
.text:0040103D ; Так-с, косвенный вызов функции. А что у нас в [EBP+0x10]?
.text:0040103D ; Поднимаем глаза на строку вверх – в [EBP+0x10] у нас значение EDX.
.text:0040103D ; А чем равен сам EDX? Прокручиваем еще одну строку вверх – EDX равен
.text:0040103D ; содержимому ячейки [EBP+ECX*4+0xC]. Вот дела! Мало, что нам надо
.text:0040103D ; узнать содержимое этой ячейки, так еще предстоит вычислить ее адрес!
.text:0040103D ; Чему равен ECX? Содержимому [EBP+0x14]. А оно чему равно?
.text:0040103D ; "Сейчас выясним…" бормочем мы себе под нос, прокручивая экран
.text:0040103D ; дизассемблера вверх. Ага, нашли, - в строке 0x40102A в него
.text:0040103D ; загружается содержимое EAX! Какая радость! И долго мы по коду так
.text:0040103D ; блуждать будем?
.text:0040103D ; Конечно, можно затратив неопределенное количество времени и усилий
.text:0040103D ; реконструировать весь ключевой алгоритм целиком (тем более, что мы
.text:0040103D ; практически подошли к концу анализа), но где гарантия, что при этом
.text:0040103D ; не будут допущены ошибки?
.text:0040103D ; Гораздо быстрее и надежнее загрузить исследуемую программу в
.text:0040103D ; отладчик, установить бряк на строку "text:0040103D" и,
.text:0040103D ; дождавшись всплытия отладчика, посмотреть: что у нас расположено
.text:0040103D ; в ячейке [EBP+0х10]. Отладчик будут всплывать трижды, причем каждый
.text:0040103D ; раз показывать новый адрес! Заметим, что определить этот факт в
.text:0040103D ; дизассемблере можно только после полной реконструкции алгоритма!
.text:0040103D ; Однако не стоит по поводу мощи отладчика питать излишних иллюзий!
.text:0040103D ; Программа может тысячу раз вызывать одну и ту же функцию, а на
.text:0040103D ; тысяче первый – вызвать совсем другую! Отладчик бессилен это
.text:0040103D ; определить. Ведь вызов такой функции может произойти в
.text:0040103D ; непредсказуемый момент,например, при определенном сочетании времени,
.text:0040103D ; данных, обрабатываемых программой и текущей фазы Луны. Ну не будем же
.text:0040103D ; мы целую вечность гонять программу под отладчиком?
.text:0040103D ; Дизассемблер – дело другое. Полная реконструкция алгоритма позволит
.text:0040103D ; однозначно и гарантированно отследить все адреса косвенных вызовов.
.text:0040103D ; Вот потому, дизассемблер и отладчик должны скакать в одной упряжке!
.text:0040103D ;
.text:00401040 jmp short loc_401024
.text:00401042
.text:00401042 mov esp, ebp
.text:00401044 pop ebp
.text:00401045 retn

Самый тяжелый случай представляют "ручные" вызовы функции командой JMP с предварительной засылок в стек адреса возврата. Вызов через JMP в общем случае выглядит так: "PUSH ret_addrr/JMP func_addr", где "ret_addrr" и "func_addr" – непосредственные или косвенные адреса возврата и начала функции соответственно. (Кстати, заметим, что команды PUSH и JPM не всегда следует одна за другой, и порой бывают разделены другими командами)
Возникает резонный вопрос – чем же там плох CALL, и зачем прибегать к JMP? Дело в том, что функция, вызванная по CALL, после возврата управления материнской функции всегда передает управление команде, следующей за CALL. В ряде случаев (например, при структурной обработке исключений) возникает необходимость после возврата из функции продолжать выполнение не со следующей за CALL командой, а совсем с другой ветки программы. Тогда-то и приходится "вручную" заносить требуемый адрес возврата и вызывать дочернею функцию через JMP.
Идентифицировать такие функции (особенно если они не имею пролога – см. "Пролог") очень сложно – контекстный поиск ничего не даст, поскольку команд JMP, использующихся для локальных переходов, в теле любой программы очень и очень много – попробуй-ка, проанализируй их все! Если же этого не сделать – из поля зрения выпадут сразу две функции – вызываемая функция и функция, на которую передается управление после возврата. К сожалению, быстрых решений этой проблемы не существует – единственная зацепка – вызывающий JMP практически всегда выходит за границы функции, в теле которой он расположен. Определить же границы функции можно по эпилогу (см. "Эпилог").
Рассмотрим следующий пример:

funct();

main()
{
__asm
{
LEA ESI, return_addr
PUSH ESI
JMP funct
return_addr:
}

}
Листинг 11 Пример, демонстрирующий "ручной" вызов функции инструкцией JPM
Результат его компиляции в общем случае должен выглядеть так:

.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 push ebx
.text:00401004 push esi
.text:00401005 push edi
.text:00401006 lea esi, [401012h]
.text:0040100C push esi
.text:0040100D jmp 401017
.text:0040100D ; Смотрите – казалось бы тривиальный условный переход, - что в нем
.text:0040100D ; такого? Ан, нет! Это не простой переход, - это замаскированный
.text:0040100D ; вызов функции! Откуда это следует? А давайте перейдем по адресу
.text:0040100D ; 0x401017 и посмотрим
.text:0040100D ; .text:00401017 push ebp
.text:0040100D ; .text:00401018 mov ebp, esp
.text:0040100D ; .text:0040101A pop ebp
.text:0040100D ; .text:0040101B retn
.text:0040100D ; ^^^^
.text:0040100D ; Как вы думаете, куда этот ret возвращает управление? Естественно,
.text:0040100D ; по адресу, лежащему на верхушке стека. А что у нас лежит на стеке?
.text:0040100D ; PUSH EBP из строки 401017 обратно выталкивается инструкцией POP
.text:0040100D ; из строки 40101B, так… возвращаемся назад, к месту безусловного
.text:0040100D ; перехода и начинаем медленно прокручивать экран дизассемблера вверх
.text:0040100D ; отслеживая все обращения к стеку. Ага, попалась птичка! Инструкция
.text:0040100D ; PUSH ESI из строки 401000C закидывает на вершину стека содержимое
.text:0040100D ; регистра ESI, а он сам, в свою очередь, строкой выше принимает
.text:0040100D ; "на грудь" значение 0x401012 – это и есть адрес начала функции,
.text:0040100D ; вызываемой командой "JMP" (вернее, не адрес, а смещение, но это не
.text:0040100D ; принципиально важно).
.text:0040100D ;
.text:00401012 pop edi
.text:00401013 pop esi
.text:00401014 pop ebx
.text:00401015 pop ebp
.text:00401016 retn
Листинг 12

Автоматическая идентификация функций посредством IDA Pro. Дизассемблер IDA Pro способен анализировать операнды инструкций CALL, что позволяет ему автоматически разбивать программу на функции. Причем, IDA вполне успешно справляется с большинством косвенных вызовов! С комплексными вызовами и "ручными" вызовами функций командой JMP она, правда, совладеть пока не в состоянии, но это не повод для огорчения – ведь подобные конструкции крайне редки и составляют менее процента от "нормальных" вызов функций, тех, которые IDA без труда распознает!

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

push ebp
mov ebp, esp
sub esp, xx
Листинг 13 Обобщенный код пролога функции
В общих чертах назначение пролога сводиться к следующему: если регистр EBP используется для адресации локальных переменных (как часто и бывает), то перед его использованием он должен быть сохранен в стеке (иначе вызываемая функция "сорвет крышу" материнской), затем в EBP копируется текущее значение регистра указателя вершины стека (ESP) – происходит, так называемое, открытие кадра стека, и значение ESP уменьшается на размер области памяти, выделенной под локальные переменные.
Последовательность PUSH EBP/MOV EBP,ESP/SUB ESP,xx может служить хорошей сигнатурой для нахождения всех функций в исследуемом файле, включая и тех, на которые нет прямых ссылок. Такой прием, в частности, использует в своей работе IDA Pro, однако, оптимизирующие компиляторы умеют адресовать локальные переменные через регистр ESP и используют EBP как и любой другой регистр общего назначения. Пролог оптимизированных функций состоит из одной лишь команды SUB ESP, xxx – последовательность слишком короткая для использования ее в качестве сигнатуры функции, - увы. Более подробный рассказ об эпилогах функций нас ждет впереди (см. "Идентификация локальных стековых переменных"), поэтому, во избежание никому не нужного дублирования, не будем здесь на нем останавливаться.

::Эпилог. В конце своей жизни функция закрывает кадр стека, перемещая указатель вершины стека "вниз", и восстанавливает прежнее значение EBP (если только оптимизирующий компилятор не адресовал локальные переменные через ESP, используя EBP как обычный регистр общего назначения). Эпилог функции может выглядеть двояко: либо ESP увеличивается на нужное значение командой ADD, либо в него копируется значение EBP, указывающие на низ кадра стека:

pop ebp mov esp, ebp
add esp, 64h pop ebp
retn retn
Эпилог 1 Эпилог 2
Листинг 14 Обобщенный код эпилога функции
Важно отметить: между командами POP EBP/ADD ESP, xxx и MOV ESP,EBP/POP EBP могут находиться и другие команды – они не обязательно должны следовать вплотную друг к другу. Поэтому, для поиска эпилогов контекстный поиск непригоден – требуется применять поиск по маске.
Если функция написана с учетом соглашение PASCAL, то ей приходится самостоятельно очищать стек от аргументов. В подавляющем большинстве случаев это осуществляется инструкцией RET n, где n – количество байт, снимаемых из стека после возврата. Функции же, соблюдающие Си-соглашение, предоставляют очистку стека вызывающему их коду и всегда оканчиваются командой RET. API-функции Windows представляют собой комбинацию соглашений Си и PASCAL – аргументы заносятся в стек справа налево, но очищает стек сама функция (подробнее обо всем этом см. "Идентификация аргументов функций").
Таким образом, RET может служить достаточным признаком эпилога функции, но не всякий эпилог – это конец. Если функция имеет в своем теле несколько операторов return (как часто и бывает) компилятор в общем случае генерирует для каждого из них свой собственный эпилог. Посмотрите – находится ли за концом эпилога новый пролог или продолжается код старой функции? Не забывайте и о том, что компиляторы обычно не помещают в исполняемый файл код, никогда не получающий управления. Т.е. у функции будет всего один эпилог, а все, находящееся после первого return, будет выброшено как ненужное:

int func(int a) push ebp
{ mov ebp, esp
mov eax, [ebp+arg_0]
return a++; mov ecx, [ebp+arg_0]
a=1/a; add ecx, 1
return a; mov [ebp+arg_0], ecx
pop ebp
} retn
Листинг 15 Пример, демонстрирующий выбрасывание компилятором кода, расположенного за безусловным оператором return
Напротив, если внеплановый выход из функции происходит при срабатывании некоторого условия, – такой return будет сохранен компилятором и "окаймлен" условным переходом, прыгающим через эпилог.

int func(int a)
{
if (!a) return a++;
return 1/a;
}
Листинг 16 Пример, демонстрирующий функцию с несколькими эпилогами

push ebp
mov ebp, esp
cmp [ebp+arg_0], 0
jnz short loc_0_401017
mov eax, [ebp+arg_0]
mov ecx, [ebp+arg_0]
add ecx, 1
mov [ebp+arg_0], ecx
pop ebp
retn
; Да, это ^^^^^^^^^^^^^^ -- явно эпилог функции, но,
; смотрите: следом идет продолжение кода функции, а
; вовсе не новый пролог!

loc_0_401017: ; CODE XREF: sub_0_401000+7↑j
; Данная перекрестная ссылка, приводящая нас к условному переходу,
; говорит о том, что этот код – продолжение прежней функции, а отнюдь не
; начало новой, ибо "нормальные" функции вызываются не jump, а CALL!
; А если это "ненормальная" функция? Что ж, это легко проверить – достаточно
; выяснить: лежит ли адрес возврата на вершине стека или нет? Смотрим –
; нет, не лежит, следовательно, наше предположение относительно продолжения
; кода функции верно.

mov eax, 1
cdq
idiv [ebp+arg_0]

loc_0_401020: ; CODE XREF: sub_0_401000+15↑j
pop ebp
retn
Листинг 17

Специальное замечание: начиная с 80286-процессора, в наборе команд появились две инструкции ENTER и LEAVE, предназначенные специально для открытия и закрытия кадра стека. Однако они практически никогда не используются современными компиляторами. Почему? Причина в том, что ENTER и LEAVE очень медлительны, намного медлительнее PUSH EBP/MOV EBP,ESP/SUB ESB, xxx и MOV ESP,EBP/POP EBP. Так, на Pentium ENTER выполняется за десять тактов, а приведенная последовательность команд – за семь. Аналогично, LEAVE требует пять тактов, хотя туже операцию можно выполнить за два (и даже быстрее, если разделить MOV ESP,EBP/POP EBP какой-нибудь командой). Поэтому, современный читатель никогда не столкнется ни с ENTER, ни с LEAVE. Хотя, помнить об их назначении будет нелишне (мало ли, вдруг придется дизассемблировать древние программы, или программы, написанные на ассемблере, – не секрет, что многие пишущие на ассемблере очень плохо знают тонкости работы процессора и их "ручная оптимизация" заметно уступает компилятору по производительности).

"Голые" (naked) функции. Компилятор Microsoft Visual C++ поддерживает нестандартный квалификатор "naked", позволяющий программистам создавать функции без пролога и эпилога. Без пролога и эпилога вообще! Компилятор даже не помещает в конце функции RET и это придется делать вручную, прибегая к ассемблерной вставке "__asm{ret}" (Использование return не приводит к желаемому результату).
Вообще-то, поддержка naked-функций задумывалась исключительно для написания драйверов на чистом Си (ну, почти чистом, с небольшой примесью ассемблерных включений), но она нашла неожиданное признание и среди разработчиков защитных механизмов. Действительно, приятно иметь возможность "ручного" создания функций, не беспокоясь, что их непредсказуемым образом "изуродует" компилятор.
Для нас же, кодокопателей, в первом приближении это обозначает, что в программе может встретиться одна (или несколько) функций, не содержащих ни пролога, ни эпилога. Ну и что в этом страшного? Оптимизирующие компиляторы так же выкидывают пролог, а от эпилога оставляют один лишь RET, - но функции элементарно идентифицируются по вызывающей их инструкции CALL.

Идентификация встраиваемых (inline) функций. Самый эффективный способ избавится от накладных расходов на вызов функций – не вызывать их. В самом деле – почему бы ни встроить код функции непосредственно в саму вызывающую функцию? Конечно, это ощутимо увеличит размер (и тем ощутимее, чем из больших мест функция вызывается), но зато значительно увеличит скорость выполнения программы (и тем значительнее, чем чаще "развернутая" функция вызывается).
Чем плоха "развертка" функций для исследования программы? Прежде всего – она увеличивает размер "материнской" функции и делает ее код менее наглядным, - вместо "CALL\TEST EAX,EAX\JZ xxx" с бросающимся в глаза условным переходом, – теперь куча ничего не напоминающих инструкций, в логике работы которых еще предстоит разобраться!
Вспомним: мы уже сталкивались с таким приемом при анализе crackme02:

mov ebp, ds:SendMessageA
push esi
push edi
mov edi, ecx
push eax
push 666h
mov ecx, [edi+80h]
push 0Dh
push ecx
call ebp ; SendMessageA
lea esi, [esp+678h+var_668]
mov eax, offset aMygoodpassword ; "MyGoodPassword"

loc_0_4013F0: ; CODE XREF: sub_0_4013C0+52j
mov dl, [eax]
mov bl, [esi]
mov cl, dl
cmp dl, bl
jnz short loc_0_401418
test cl, cl
jz short loc_0_401414
mov dl, [eax+1]
mov bl, [esi+1]
mov cl, dl
cmp dl, bl
jnz short loc_0_401418
add eax, 2
add esi, 2
test cl, cl
jnz short loc_0_4013F0

loc_0_401414: ; CODE XREF: sub_0_4013C0+3Cj
xor eax, eax
jmp short loc_0_40141D

loc_0_401418: ; CODE XREF: sub_0_4013C0+38j
sbb eax, eax
sbb eax, 0FFFFFFFFh

loc_0_40141D: ; CODE XREF: sub_0_4013C0+56j
test eax, eax
push 0
push 0
jz short loc_0_401460
Листинг 18
Встроенные функции не имеют ни собственного пролога, ни эпилога, их код и локальные переменные (если таковые имеются) полностью "вживлены" в вызывающую функцию, – результат компиляции выглядит в точности так, как будто бы никакого вызова функции и не было. Единственная зацепка – встраивание функции неизбежно приводит к дублированию ее кода во всех местах вызова, а это хоть с трудом, но можно обнаружить. "С трудом" – потому, что встраиваемая функция, становясь частью вызывающей функции, "в сквозную" оптимизируется в контексте последней, что приводит к значительным вариациям кода. Рассмотрим такой пример:

#include
__inline int max( int a, int b )
{
if( a > b ) return a;
return b;
}

int main(int argc, char **argv)
{
printf("%x\n",max(0x666,0x777));
printf("%x\n",max(0x666,argc));
printf("%x\n",max(0x666,argc));

return 0;
}
Листинг 19 Пример, демонстрирующий, сквозную оптимизацию встраиваемых функций
Результат его компиляции в общем случае должен выглядеть так:

push esi
push edi
push 777h ;  код 1-го вызова max
; Компилятор вычислил значение функции max еще на этапе компиляции и
; вставил его в программу, избавившись от лишнего вызова функции

push offset aProc ; "%x\n"
call printf
mov esi, [esp+8+arg_0]
add esp, 8

cmp esi, 666h ;  код 2-го вызова max
mov edi, 666h ;  код 2-го вызова max
jl short loc_0_401027 ;  код 2-го вызова max
mov edi, esi ;  код 2-го вызова max

loc_0_401027: ; CODE XREF: sub_0_401000+23j
push edi
push offset aProc ; "%x\n"
call printf
add esp, 8

cmp esi, 666h ;  код 3-го вызова max
jge short loc_0_401042 ;  код 2-го вызова max
mov esi, 666h ;  код 2-го вызова max
; Смотрите – как изменился код функции! Во-первых, нарушилась очередность
; выполнения инструкций – было "CMP -> MOV – Jx", а стало "CMP -> Jx, MOV"
; А во-вторых, условный переход JL загадочным образом превратился в JGE!
; Впрочем, ничего загадочного тут нет – просто идет сквозная оптимизация!
; Поскольку, после третьего вызова функции max переменная argc, размещенная
; компилятором в регистре ESI, более не используется, у компилятора появляется
; возможность непосредственно модифицировать этот регистр, а не вводить
; временную переменную, выделяя под нее регистр EDI
; (см. "Идентификация переменных -> регистровых и временныех переменныех")

loc_0_401042: ; CODE XREF: sub_0_401000+3Bj
push esi
push offset aProc ; "%x\n"
call printf
add esp, 8
mov eax, edi
pop edi
pop esi
retn
Листинг 20
Смотрите, - при первом вызове компилятор вообще выкинул весь код функции, вычислив результат ее работы еще на стадии компиляции (действительно, 0x777 всегда больше 0x666 и не за чем тратить процессорные такты на их сравнение). А второй вызов очень мало похож на третий, несмотря на то, что в обоих случаях функции передавались один и те же аргументы! Тут не то, что поиск по маске (не говоря уже о контекстном поиске), человек не разберется – одна и та же функция вызывается или нет!

Модели памяти и 16-разрядные компиляторы. Под "адресом" функции в данной главе до настоящего момента подразумевалось исключительно ее смещение в кодовом сегменте. Плоская (flat) модель памяти 32-разрядной Windows 9х\NT "упаковывает" все три сегмента – сегмент кода, сегмент стека и сегмент данных – в единое четырех гигабайтное адресное пространство, позволяя вообще забыть о существовании сегментов.
Иное дело – 16-разрядные приложения для MS-DOS и Windows 3.x. В них максимально допустимый размер сегментов составляет всего лишь 64 килобайта, чего явно недостаточно для большинства приложений. В крошечной (tiny) модели памяти сегменты кода, стека и данных так же расположены в одном адресном пространстве, но в отличие от плоской модели это адресное пространство чрезвычайно ограничено в размерах, и мало-мальски серьезное приложение приходится рассовывать по нескольким сегментам.
Теперь для вызова функции уже не достаточно знать ее смещение, – требуется указать еще и сегмент, в котором она расположена. Однако сегодня об этом рудименте старины можно со спокойной совестью забыть. На фоне грядущей 64-разрядной версии Windows, подробно описывать 16-разрядный код просто смешно!

___Порядок трансляции функций: Большинство компиляторов располагают функции в исполняемом файле в том же самом порядке, в котором они были объявлены в программе.

Идентификация стартовых функций

…чтобы не наделать ошибок в работе, богу понадобился свет. Судя по этому, в предшествовавшие века он сидел в полной темноте. К счастью, он не рисковал обо что-либо стукнуться, ибо вокруг ничего не было.

Лео Таксиль "Забавная Библия"

Если первого встречного программиста спросить "С какой функции начинается выполнение 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….

Идентификация виртуальных функций

А мы летим орбитами, путями неизбитыми,
Прошит метеоритами простор.
Оправдан риск и мужество, космическая музыка
Вплывает в деловой наш разговор.
"Трава у дома" Земляне

Виртуальная функция по определению обозначает "определяемая по время выполнения программы". При вызове виртуальной функции выполняемый код должен соответствовать динамическому типу объекта, из которого вызывается функция. Поэтому, адрес виртуальной функции не может быть определен на стадии компиляции – это приходится делать непосредственно в момент ее вызова. Вот почему вызов виртуальной функции – всегда косвенный вызов (исключение составляют лишь виртуальные функции статических объектов, - см. "Статическое связывание").
В то время как не виртуальные функции вызываются в точности так же, как и обычные Си-функции, вызов виртуальных функций кардинально отличается. Конкретная схема зависит от реализации конкретного компилятора, но общем случае ссылки на все виртуальные функции помещаются в специальный массив – виртуальную таблицу (virtual table – сокращенно VTBL), а в каждый экземпляр объекта, использующий хотя бы одну виртуальную функцию, помещается указатель на виртуальную таблицу (virtual table pointer – сокращенно VPRT). Причем, независимо от числа виртуальный функций, каждый объект имеет только один указатель.
Вызов виртуальных функций всегда происходит косвенно, через ссылку на виртуальную таблицу – например: CALL [EBX+0х10], где EBX – регистр, содержащий смещение виртуальной таблицы в памяти, а 0x10 – смещение указателя на виртуальную функцию внутри виртуальной таблицы.
Анализ вызова виртуальных функций наталкивается на ряд сложностей, самая коварная из которых, – необходимость обратной трассировки кода для отслеживания значения регистра, используемого для косвенной адресации. Хорошо, если он инициализируется непосредственным значением типа "MOV EBX, offset VTBL" недалеко от места использования, но значительно чаще указатель на VTBL передается функции как неявный аргумент или (что еще хуже) один и тот же указатель используется для вызова двух различных виртуальных функций и возникает неопределенность – какое именно значение (значения) он имеет в данной ветке программы?
Разберем следующий пример (предварительно вспомнив, что если одна и та же не виртуальная функция присутствует и базовом, и в производном классе – всегда вызывается функция базового класса).

#include

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

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

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

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

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

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

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, всегда подготавливаемый перед вызовом виртуальной функции;

- виртуальные функции и статические переменные располагаются в различных местах сегмента данных – поэтому сразу можно отличить одни от других.

А можно ли так организовать вызов функции по ссылке, чтобы компиляция программы давала код идентичный вызову виртуальной функции? Как сказать… Теоретически да, но практически – едва ли такое удастся осуществить (а уж непреднамеренно – тем более). Код вызова виртуальных функций в связи с большой избыточностью очень специфичен и легко различим "на глаз". Легко сымитировать общую технику работы с виртуальными таблицами, но без ассемблерных вставок невозможно воспроизвести ее в точности.

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

Идентификация конструктора и деструктора

"то, что не существует в одном тексте (одном возможном мире), может существовать в других текстах (возможных мирах)"
тезис семантики возможных миров

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

Деструктор, как и конструктор факультативен, т.е. последняя вызываемая функция объекта не факт, что деструктор. Тем не менее, отличить деструктор от любой другой функции очень просто – он вызывается только при результативном создании объекта (т.е. успешном выделении памяти) и игнорируется в противном случае. Это – документированное свойство языка, следовательно, обязательное к реализации всеми компиляторами. Таким образом, в код помещается такое же "кольцо", как и у конструктора, но никакой путаницы не возникает, т.к. конструктор вызывается всегда первым (если он есть), а деструктор – последним.
Особый случай представляет объект, целиком состоящий из одного конструктора (или деструктора) – попробуй, разберись, с чем мы имеем дело. И разобраться можно! За вызовом конструктора практически всегда присутствует код, обнуляющий this в случае неудалого выделения памяти, - а у деструктора этого нет! Далее – деструктор обычно вызывается не непосредственно из материнской процедуры, а из функции-обертки, вызывающей помимо деструктора и оператор delete, освобождающий занятую объектом память. Так, что отличить конструктор от деструктора вполне можно!
Давайте, для лучшего уяснения сказанного рассмотрим следующий пример:

#include

class MyClass{
public:
MyClass(void);
void demo(void);
~MyClass(void);

};

MyClass::MyClass()
{
printf("Constructor\n");
}

MyClass::~MyClass()
{
printf("Destructor\n");
}

void MyClass::demo(void)
{
printf("MyClass\n");
}

main()
{
MyClass *zzz = new MyClass;
zzz->demo();
delete zzz;

}
Листинг 38 Демонстрация конструктора и деструктора

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

Constructor proc near ; CODE XREF: main+11p
; функция конструктора. То, что это именно конструктор можно понять из реализации
; его вызова (см. main)

push esi
mov esi, ecx
push offset aConstructor ; "Constructor\n"
call printf
add esp, 4
mov eax, esi
pop esi
retn
Constructor endp

Destructor proc near ; CODE XREF: __destructor+6p
; функция деструктора. То, что это именно деструктор, можно понять из реализации
; его вызова (см. main)
push offset aDestructor ; "Destructor\n"
call printf
pop ecx
retn
Destructor endp

demo proc near ; CODE XREF: main+1Ep
; обычная функия demo
push offset aMyclass ; "MyClass\n"
call printf
pop ecx
retn
demo endp

main proc near ; CODE XREF: start+AFp
push esi
push 1
call ??2@YAPAXI@Z ; operator new(uint)
add esp, 4
; выделяем память для нового объекта
; точнее, пытаемся это сделать

test eax, eax
jz short loc_0_40105A
; Проверка успешности выделения памяти для объекта.
; Обратите внимание: куда направлен jump.
; Он направлен на инструкцию XOR ESI,ESI, обнуляющую указатель на объект –
; при попытке использования нулевого указателя возникнет исключение,
; но конструктор не должен вызывать исключение даже если память под объект
; отвести не удалось.
; Поэтому, конструктор получает управление только при успешном отводе памяти!
; Следовательно, функция, находящаяся до XOR ESI,ESI, и есть конструктор!!!
; И мы сумели надежно идентифицировать ее.

mov ecx, eax
; готовим указатель this

call Constructor
; эта функция – конструктор, т.к. вызывается только при удачном отводе памяти

mov esi, eax
jmp short loc_0_40105C

loc_0_40105A: ; CODE XREF: main+Dj
xor esi, esi
; обнуляем указатель на объект, чтобы вызвать исключение при попытке его
; использования
; Внимание: конструктор никогда не вызывает исключения, поэтому,
; нижележащая функция гарантированно не является конструктором

loc_0_40105C: ; CODE XREF: main+18j
mov ecx, esi
; готовим указатель this

call demo
; вызываем обычную функцию объекта

test esi, esi
jz short loc_0_401070
; проверка указателя this на NULL. Деструктор вызываться только в том случае
; если память под объект была отведена (если же она не была отведена
; освобождать особо нечего)
; таким образом, следующая функция – именно деструктор, а не что-нибудь еще

push 1
; количество байт для освобождения (необходимо для delete)

mov ecx, esi
; готовим указатель this

call __destructor
; вызываем деструктор

loc_0_401070: ; CODE XREF: main+25j
pop esi
retn
main endp

__destructor proc near ; CODE XREF: main+2Bp
; функция деструктора. Обратите внимание, что деструктор обычно вызывается
; из той же функции, что и delete (хотя так бывает и не всегда, но очень часто)

arg_0 = byte ptr 8
push ebp
mov ebp, esp
push esi
mov esi, ecx
call Destructor
; вызываем функцию деструктора, определенную пользователем

test [ebp+arg_0], 1
jz short loc_0_40109A
push esi
call ??3@YAXPAX@Z ; operator delete(void *)
add esp, 4
; освобождаем память, ранее выделенную объекту

loc_0_40109A: ; CODE XREF: __destructor+Fj
mov eax, esi
pop esi
pop ebp
retn 4
__destructor endp

Листинг 39

::объекты в автоматической памяти или когда конструктор/деструктор идентифицировать невозможно. Если объект размещается в стеке (автоматической памяти), то никаких проверок успешности ее выделения не выполняется и вызов конструктора становится неотличим от вызова остальных функций. Аналогичная ситуация и с деструктором – стековая память автоматически освобождается по завершению функции, а вместе с ней умирает и сам объект безо всякого вызова delete (delete применяется только для удаления объектов из кучи).
Чтобы убедиться в этом, модифицируем функцию main нашего предыдущего примера следующим образом:

main()
{
MyClass zzz;
zzz.demo();
}
Листинг 40

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

main proc near ; CODE XREF: start+AFp

var_4 = byte ptr -4
; локальная переменная zzz – экземпляр объекта MyClass

push ebp
mov ebp, esp
push ecx
lea ecx, [ebp+var_4]
; подготавливаем указатель this

call constructor
; вызываем конструктор, как и обычную функцию!
; долгаться, что это конструктор можно разве что по его содержимому
; (обычно конструктор инициализирует объект), да и то неуверенно

lea ecx, [ebp+var_4]
call demo
; вызываем функцию demo, - обратите внимание, ее вызов ничем не отличается
; от вызова конструктора!

lea ecx, [ebp+var_4]
call destructor
; вызываем деструктор – его вызов, как мы уже поняли, ничем
; характерным не отмечен

mov esp, ebp
pop ebp
retn
main endp
Листинг 41
::идентификация конструктора/деструктора в глобальных объектах. Глобальные объекты (так же называемые статическими объектами) размешаются в сегменте данных еще на стадии компиляции. Стало быть, ошибки выделения памяти в принципе невозможны и, выходит, что по аналогии со стековыми объектами, надежно идентифицировать конструктор/деструктор и здесь нельзя? А вот и нет!
Глобальный объект, в силу свой глобальности, доступен из многих мест программы, но его конструктор должен вызываться лишь однажды. Как можно это обеспечить? Конечно, возможны самые различные варианты реализации, но большинство компиляторов идут по простейшему пути, используя для этой цели глобальную переменную-флаг, изначально равную нулю, а перед первым вызовом конструктора увеличивающуюся на единицу (в более общем случае устанавливающуюся в TRUE). При повторных итерациях остается проверить – равен ли флаг нулю, и если нет – пропустить вызов конструктора. Таким образом, конструктор вновь "окольцовывается" условным переходом, что позволяет его безошибочно отличить ото всех остальных функций.
С деструктором еще проще – раз объект глобальный, то он уничтожается только при завершении программы. А кто это может отследить кроме поддержки времени исполнения? Специальная функция, такая как _atexit, принимает на вход указатель на конструктор, запоминает его и затем вызывает при возникновении в этом необходимости. Интересный момент - _atexit (или что там используется в вашем конкретном случае) должна быть вызвана лишь однократно (надеюсь, понятно почему?). И, чтобы не вводить еще один флаг, она вызывается сразу же после вызова конструктора! На первый взгляд объект может показаться состоящим из одних конструктора/деструктора, но это не так! Не забывайте, что _atexit не передает немедленно управление на код деструктора, а только запоминает его указатель для дальнейшего использования!
Таким образом, конструктор/деструктор глобального объекта очень просто идентифицировать, что и доказывает следующий пример:

main()
{
static MyClass zzz;
zzz.demo();

}
Листинг 42
Результат его компиляции в общем случае должен выглядеть так:

main proc near ; CODE XREF: start+AFp
mov cl, byte_0_4078E0 ; флаг инициализации экземпляра объекта
mov al, 1
test al, cl
; объект инициализирован?

jnz short loc_0_40106D
; --> да, инициализирован, - не вызываем конструктор

mov dl, cl
mov ecx, offset unk_0_4078E1 ; экземляр объекта
; готовим указатель this

or dl, al
; устанавливаем флаг инициализации в TRUE
; и вызываем конструктор

mov byte_0_4078E0, dl ; флаг инициализации экземпляра объекта
call constructor
; Вызов конструктора.
; Обратите внимание, что если экземпляр объекта уже инициализирован
; (см. проверку выше) конструктор не вызывается.
; Таким образом, его очень легко отождествить!

push offset thunk_destructo
call _atexit
add esp, 4
; Передаем функции _atexit указатель на деструктор,
; который она должна вызвать по завершении программы

loc_0_40106D: ; CODE XREF: main+Aj
mov ecx, offset unk_0_4078E1 ; экземпляр объекта
; готовим указатель this

jmp demo
; вызываем demo

main endp

thunk_destructo: ; DATA XREF: main+20o
; переходник к функции-деструктору

mov ecx, offset unk_0_4078E1 ; экземпляр объекта
jmp destructor

byte_0_4078E0 db 0 ; DATA XREF: mainr main+15w
; флаг инициализации экземпляра объекта
unk_0_4078E1 db 0 ; ; DATA XREF: main+Eo main+2Do ...
; экземпляр объекта
Листинг 43
Аналогичный код генерирует и Borland C++. Единственное отличие – более хитрый вызов деструктора. Вызовы всех деструкторов помещены в специальную процедуру, которая выдает себя тем, что обычно располагается перед библиотечными функциями (или в непосредственной близости от них), так что идентифицировать ее очень легко. Смотрите сами:

_main proc near ; DATA XREF: DATA:00407044o
push ebp
mov ebp, esp
cmp ds:byte_0_407074, 0 ; флаг инициализации объекта
jnz short loc_0_4010EC
; Если объект уже инициализирован – конструктор не вызывается

mov eax, offset unk_0_4080B4 ; Экземпляр объекта
call constructor
inc ds:byte_0_407074 ; флаг инициализации объекта
; Увеличиваем флаг на единицу, возводя его в TRUE

loc_0_4010EC: ; CODE XREF: _main+Aj
mov eax, offset unk_0_4080B4 ; Экземляр объекта
call demo
; Вызов функции demo

xor eax, eax
pop ebp
retn
_main endp

call_destruct proc near ; DATA XREF: DATA:004080A4o
; Эта функция содержит в себе вызовы всех деструкторов глобальных объектов,
; поскольку, вызов каждого деструктора "окольцован" проверкой флага инициализации,
; эту функцию легко идентифицировать – только она содержит подобный "калечный код"
; (вызовы конструкторов обычно разбросаны по всей программе)

push ebp
mov ebp, esp
cmp ds:byte_0_407074, 0 ; флаг инициализации объекта
jz short loc_0_401117
; объект был инициализирован?

mov eax, offset unk_0_4080B4 ; Экземпляр объекта
; готовим указатель this

mov edx, 2
call destructor
; вызываем деструктор

loc_0_401117: ; CODE XREF: call_destruct+Aj
pop ebp
retn
call_destruct endp
Листинг 44

:: виртуальный деструктор. Деструктор тоже может быть виртуальным! А почему бы и нет? Это бывает полезно, когда экземпляр производного класса удаляется через указатель на базовый объект. Поскольку, виртуальные функции связаны с классом объекта, а не с классом указателя, то вызывается виртуальный деструктор, связанный с типом объекта, а не с типом указателя. Впрочем, эти тонкости относятся к непосредственному программированию, а исследователей в первую очередь интересует: как идентифицировать виртуальный деструктор. О, это просто – виртуальный деструктор совмещает в себе свойства обычного деструктора и виртуальной функции (см. "Идентификация виртуальных функций").

::виртуальный конструктор. Виртуальный конструктор?! А что, разве есть такой? Ничего подобного стандартный Си++ не поддерживает. Непосредственно не поддерживает. И, когда виртуальный конструктор позарез требуется программистом (впрочем, бывает это лишь в весьма экзотических случаях), они прибегают к ручной эмуляции некоторого его подобия. В специально выделенную для этих целей виртуальную функцию (не конструктор!) помещается приблизительно следующий код: "return new имя класса (*this)" или "return new имя класса (*this)". Этот трюк кривее, чем бумеранг, но… он работает. Разумеется, существуют и другие решения.
Подробное их обсуждение далеко выходит за рамки данной книги и требует глубоко знания Си++ (гораздо более глубокого, чем у рядового разработчика), к тому же это заняло бы слишком много места… но едва ли оказалось интересно рядовому читателю.
Итак, идентификация виртуального конструктора в силу отсутствия самого понятия – в принципе невозможна. Его эмуляция насчитывает десятки решений (если не больше), – попробуй-ка, перечисли их все! Впрочем, этого и не нужно делать – в большинстве случаев виртуальные конструкторы представляют собой виртуальные функции, принимающие в качестве аргумента указатель this и возвращающие указатель на новый объект. Не слишком-то надежно для идентификации, но все же лучше, чем ничего.

::конструктор раз, конструктор два… Количество конструкторов объекта может быть и более одного (и очень часто не только может, но и бывает). Однако это никак не влияет на анализ. Сколько бы конструкторов ни присутствовало, – для каждого экземпляра объекта всегда вызывается только один, выбранный компилятором в зависимости от формы объявления объекта. Единственная деталь – различные экземпляры объекта могут вызывать различные конструкторы – будьте внимательны!

::а зачем козе баян или внимание: пустой конструктор. Некоторые ограничения конструктора (в частности, отсутствие возвращаемого значения) привели к появлению стиля программирования "пустой конструктор". Конструктор умышленно оставляется пустым, а весь код инициализации помещается в специальную функцию-член, как правило, называемую Init. Обсуждение сильных и слабых сторон такого стиля – предмет отдельного разговора, никаким боком не относящегося к данной книге. Исследователям достаточно знать – такой стиль есть и активно используется не только отдельными индивидуальными программистами, но и крупнейшими компаниями-гигантами (например, той же Microsoft). Поэтому, встретив вызов пустого конструктора, – не удивляйтесь, - это нормально, и ищите функцию инициализации среди обычных членов.

Идентификация объектов, структур и массивов

Для целого поколения Эйнштейн был глашатаем передовой науки, пророком разума и мира. А сам он в глубине своей кроткой и невозмутимой души без всякой горечи оставался скептиком… Он хотел затеряться и как бы раствориться в окружающем его мире, а оказался одним из самых разрекламированных людей нашего века, и его лицо, вдохновенное и отрешенное от всех грехов мира, стало таким же широко известным, как фотография какой-нибудь кинозвезды.
Чарлз Перси Сноу «ЭЙНШТЕЙН»

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

#include
#include

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, принимающий в качестве аргумента количество выделяемых байт, - это и есть размер объекта.

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

#include

class MyClass{
public:
void demo(void);
int x;
private:
demo_private(void);
int y;
};

void MyClass::demo_private(void)
{
printf("Private\n");
}

void MyClass::demo(void)
{
printf("MyClass\n");
this->demo_private();
this->y=0x666;
}

main()
{
MyClass *zzz = new MyClass;
zzz->demo();
zzz->x=0x777;
}
Листинг 52
Результат его компиляции в общем случае должен выглядеть так:

main proc near ; CODE XREF: start+AFp
push esi
push 8
call ??2@YAPAXI@Z ; operator new(uint)
; Выделяем 8 байт под экземляр некоторого объекта оператором new
; Вообще-то, вовсе не факт, что память выделяется именно под объект
; (может тут было что-то типа char *x = new char[8]), так что
; не будем считать это утверждение догмой, а примем как рабочую гипотезу -
; дальнейшие исследования покажут: что к чему

mov esi, eax
add esp, 4

mov ecx, esi
; Ухо-хвост тигра! готовится указатель this который передается функции
; через регистр. Значит, ECX – ни что иное, как указатель на экземпляр объекта!
; (подробнее – см. "Идентификация this")

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]". Если компилятор выровняет массивы по кратным адресам (а это делают многие компиляторы), то разница смещений ближайших друг к другу массивов может и не быть равна их размеру. Единственная надежда восстановить подлинный размер – найти в коде проверки на выход за границы массива (если они есть – их часто не бывает). Второе (самое неприятное) – если один из массивов не используется, а только объявляется, то не оптимизирующие компиляторы (и даже некоторые оптимизирующие!) могут, тем не менее, отвести для него стековое пространство. Он вплотную примкнет к предыдущему массиву и… гадай – то ли размер массива такой, то ли в его конец "вбухан" неиспользуемый массив? Ну, с массивами куда бы еще ни шло, а вот со структурами и объектами дела обстоят намного хуже. Никому и в голову не придет помещать в программу код, отслеживающий выход за пределы структуры (объекта). Такое невозможно в принципе (ну разве что программист слишком вольно работает с указателями)!
Ладно, оставим в стороне размер, перейдем к проблемам "разверстки" и поиску указателей. Как уже говорилось выше, если массив (объект, структура) объявляется в непосредственной области видимости единицы трансляции, он "вспарывается" на этапе компиляции и обращение к его членам происходят по фактическому смещению, а не базовому указателю. К счастью, идентификацию объектов облегчает наличие в них указателя на виртуальную таблицу, но ведь не факт, что любая таблица указателей на функции – есть виртуальная таблица! Может, это просто массив указателей на функции, определенный самим программистом? Вообще-то, при наличии опыта такие ситуации можно легко распознать (см. "Идентификация виртуальных функций"), но все-таки они достаточно неприятны.
С объектами, расположенными в статической памяти, дела обстоят намного проще, - в силу своей глобальности они имеют специальный флаг, предотвращающий повторный вызов конструктора (подробнее см. "Идентификация конструктора и деструктора"), поэтому, отличить экземпляр объекта, расположенный в сегменте данных, от структуры или массива становится очень легко. С определением его размера, правда, все те же неувязки.
Наконец, объекты (структуры, массивы), расположенные в куче – просто сказка для анализа! Отведение памяти осуществляется функцией, явно принимающей количество выделяемых байт в качестве своего аргумента, и возвращающей указатель, гарантированно указывающий на начало экземпляра объекта (структуры, массива). Радует и то, что обращение к элементам всегда происходит через базовый указатель, даже если объявление совершается в области видимости (иначе и быть не может – фактические адреса выделяемых блоков динамической памяти не известны на стадии компиляции).

__дописать – восстановление структуры многомерных массивов

Идентификация 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 в зависимости от реализации компилятора и типа функции

Идентификация 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, а вот наоборот – таки да. Уж не знаю, какой он такой редкоземельный компилятор имел ввиду (точнее, не сам компилятор, а библиотеки, поставляемые вместе с ним, но это не суть важно), или, скорее всего, просто писал не думавши. Отсюда мораль – все умозаключения, прежде чем переносить на бумагу, необходимо тщательно проверять.

Идентификация библиотечных функций

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

Читая текст программы, написанный на языке высокого уровня, мы только в исключительных случаях изучаем реализацию стандартных библиотечных функций, таких, например, как printf. Да и зачем? Ее назначение известно и без того, а если и есть какие непонятки – всегда можно заглянуть в описание…
Анализ дизассемблерного листинга – дело другое. Имена функций за редкими исключениями в нем отсутствуют, и определить printf это или что-то другое "на взгляд" невозможно. Приходится вникать в алгоритм… Легко сказать! Та же printf представляет собой сложный интерпретатор строки спецификаторов – с ходу в нем не разберешься! А ведь есть и более монструозные функции. Самое обидное – алгоритм их работы не имеет никакого отношения к анализу исследуемой программы. Тот же new может выделять память и из Windows-кучи, и реализовывать собственный менеджер, но нам-то от этого что? Достаточно знать, что это именно new, - т.е. функция выделения памяти, а не free или fopen, скажем.
Доля библиотечных функций в программе в среднем составляет от пятидесяти до девяноста процентов. Особенно она велика у программ, составленных в визуальных средах разработки, использующих автоматическую генерацию кода (например, Microsoft Visual C++, DELPHI). Причем, библиотечные функции под час намного сложнее и запутаннее тривиального кода самой программы. Обидно – львиная доля усилий по анализу вылетает впустую… Как бы оптимизировать этот процесс?
Уникальная способность IDA различать стандартные библиотечные функции множества компиляторов, выгодно отличает ее от большинства других дизассемблеров, этого делать не умеющих. К сожалению, IDA (как и все, созданное человеком) далека от идеала – каким бы обширный список поддерживаемых библиотек ни был, конкретные версии конкретных поставщиков или моделей памяти могут отсутствовать. И даже из тех библиотек, что ей известны, распознаются не все функции (о причинах будет рассказано чуть ниже). Впрочем, нераспознанная функция – это полбеды, неправильно распознанная функция – много хуже, ибо приводит к ошибкам (иногда трудноуловимым) анализа исследуемой программы или ставит исследователя в глухой тупик. Например, вызывается fopen и возвращенный ей результат спустя некоторое время передается free – с одной стороны: почему бы и нет? Ведь fopen возвращает указатель на структуру FILE, а free ее и удаляет. А если free – никакой не free, а, скажем, fseek? Пропустив операцию позиционирования, мы не сможем правильно восстановить структуру файла, с которым работает программа.
Распознать ошибки IDA будет легче, если представлять: как именно она выполняет распознание. Многие почему-то считают, что здесь задействован тривиальный подсчет CRC (контрольной суммы). Что ж, заманчивый алгоритм, но, увы, непригодный для решения данной задачи. Основной камень преткновения – наличие непостоянных фрагментов, а именно – перемещаемых элементов (подробнее см. "Шаг четвертый Знакомство с отладчиком :: Бряк на оригинальный пароль"). И хотя при подсчете CRC перемещаемые элементы можно элементарно игнорировать (не забывая проделывать ту же операцию и в идентифицируемой функции), разработчик IDA пошел другим, более запутанным и витиеватым, но и более быстрым путем.
Ключевая идея заключается в том, что незачем тратить время на вычисление CRC, - для предварительной идентификации функции вполне сойдет и тривиальное посимвольное сравнение, за вычетом перемещаемых элементов (они игнорируются и в сравнении не участвуют). Точнее говоря, не сравнение, а поиск заданной последовательности байт в эталонной базе, организованной в виде двоичного дерева. Время двоичного поиска, как известно, пропорционально логарифму количества записей в базе. Здравый смысл подсказывает, что длина шаблона (иначе говоря, сигнатуры – т.е. сравниваемой последовательности) должна быть достаточной для однозначной идентификации функции. Однако разработчик IDA по непонятным для меня причинам решил ограничиться только первыми тридцать двумя байтами, что (особенно с учетом вычета пролога, который у всех функций практически одинаков) – довольно мало.
И верно! Достаточно многие функции попадают на один и тот же лист дерева – возникает коллизия, - неоднозначность отождествления. Для разрешения ситуации, у всех "коллизиеных" функций подсчитывается CRC16 с тридцать второго байта до первого перемещаемого элемента и сравнивается с CRC16 эталонных функций. Чаще всего это "срабатывает", но если первый перемещаемый элемент окажется расположенным слишком близко к тридцать второму байту – последовательность подсчета контрольной суммы окажется слишком короткой, а то и вовсе равной нулю (может же быть тридцать второй байт перемещаемым элементом, - почему бы и нет?). В случае повторной коллизии – находим в функциях байт, в котором все они отличаются, и запоминаем его смещение в базе.
Все это (да просит меня разработчик IDA!) напоминает следующий анекдот: поймали туземцы немца, американца и хохла и говорят им: мол, или откупайтесь чем-нибудь, или съедим. На откуп предлагается: миллион долларов (только не спрашивайте меня: зачем туземцам миллион долларов – может, костер жечь), сто щелбанов или съесть мешок соли. Ну, американец достает сотовый, звонит кому-то… Приплывает катер с миллионом долларов и американца благополучно отпускают. Немец в это время героически съедает мешок соли, и его полуметрового спускают на воду. Хохол же ел соль, ел-ел, две трети съел, не выдержал и говорит, а, ладно, черти, бейте щелбаны. Бьет вождь его, и только девяносто ударов отщелкал, хохол не выдержал и говорит, да нате миллион, подавитесь! Так и с IDA, - посимвольное сравнение не до конца, а только тридцати двух байт, подсчет CRC не для всей функции – а сколько случай на душу положит, наконец, последний ключевой байт – и тот то "ключевой", да не совсем. Дело в том, что многие функции совпадают байт в байт, но совершенно различны по названию и назначению. Не верите? Тогда как вам понравится следующее:

read: write:
push ebp push ebp
mov ebp,esp mov ebp,esp
call _read call _write
pop ebp pop ebp
ret ret
Листинг 55
Тут без анализа перемещаемых элементов не обойтись! Причем, это не какой-то специально надуманный пример, - подобных функций очень много. В частности библиотеки от Borland ими так и кишат. Неудивительно, что IDA часто "спотыкается" и впадает в грубые ошибки. Взять, к примеру, следующую функцию:

void demo(void)
{
printf("DERIVED\n");
};

Даже последняя на момент написания этой книги версия IDA 4.17 ошибается, "обзывая" ее __pure_error:

CODE:004010F3 __pure_error_ proc near ; DATA XREF: DATA:004080BC↓o
CODE:004010F3 push ebp
CODE:004010F4 mov ebp, esp
CODE:004010F6 push offset aDerived ; format
CODE:004010FB call _printf
CODE:00401100 pop ecx
CODE:00401101 pop ebp
CODE:00401102 retn
CODE:00401102 __pure_error_ endp

Стоит ли говорить: какие неприятные последствия для анализа эта ошибка может иметь? Бывает, сидишь, тупо уставившись в листинг дизассемблера, и никак не можешь понять: что же этот фрагмент делает? И только потом обнаруживаешь – одна или несколько функций опознаны неправильно!
Для уменьшения количества ошибок IDA пытается по стартовому коду распознать компилятор, подгружая только библиотеку его сигнатур. Из этого следует, что "ослепить" IDA очень просто – достаточно слегка видоизменить стартовый код. Поскольку, он по обыкновению поставляется вместе с компилятором в исходных текстах, сделать это будет нетрудно. Впрочем, хватит и изменения одного байта в начале startup-функции. И все, - хакер скинет ласты! К счастью, в IDA предусмотрена возможность ручной загрузки базы сигнатур ("FILE\Load file\FLIRT signature file"), но… попробуй-ка вручную определить: сигнатуры какой именно версии библиотеки требуется загружать! Наугад – слишком долго… Хорошо, если удастся визуально опознать компилятор (опытным исследователям это обычно удается, т.к. каждый из них имеет свой уникальный "почерк"). К тому же, существует принципиальная возможность использования библиотек из поставки одного компилятора, в программе, скомпилированной другим компилятором.
Словом, будьте готовы к тому, что в один прекрасный момент столкнетесь с необходимостью самостоятельно опознавать библиотечные функции. Решение задачи состоит из двух этапов. Первое – определение самого факта "библиотечности" функции, второе – определение происхождения библиотеки и третье – идентификация функция по этой библиотеке.
Используя тот факт, что линкер обычно располагает функции в порядке перечисления obj модулей и библиотек, а большинство программистов указывают сначала собственные obj-модули, а библиотеки – потом (кстати, так же поступают и компиляторы, самостоятельно вызывающие линкер по окончании своей работы), можно заключить: библиотечные функции помещаются в конце программы, а собственно ее код – в начале. Кончено, из этого правила есть исключения, но все же срабатывает оно достаточно часто.

Рисунок 14 0х009 Художнику заштриховать что ли? Структура pkzip.exe. Обратите внимание - все библиотечные функции (голубые) в одном месте - в конце сегмента кода перед началом сегмента данных

Рассмотрим, к примеру, структуру общеизвестной программы pkzip.exe, - на диаграмме, построенной IDA 4.17, видно, что все библиотечные функции сосредоточены в одном месте – в конце сегмента кода, вплотную примыкая к сегменту данных. Самое интересное – start-up функция в подавляющем большинстве случаев расположена в самом начале региона библиотечных функций или находится в непосредственной близости от него. Найти же саму start-up не проблема – она совпадает с точкой входа в файл!
Таким образом, можно с высокой долей уверенности утверждать, что все функции, расположенные "ниже" Start-up (т.е. в более старших адресах) – библиотечные. Посмотрите – распознала ли их IDA или переложила эту заботу на вас? Грубо - возможны две ситуации: вообще никакие функции не распознаны и не распознана только часть функций.
Если не распознана ни одна функция, скорее всего IDA не сумела опознать компилятор или использовались неизвестные ей версии библиотек. Техника распознавания компиляторов – разговор особый, а вот распознание версий библиотек – это то, чем мы сейчас и займемся.
Прежде всего, многие из них содержат копирайты с названием имени производителя и версии библиотеки – просто поищите текстовые строки в бинарном файле. Если их нет, - не беда – ищем любые другие текстовые строки (как правило, сообщения об ошибках) и простым контекстным поиском пытаемся найти во всех библиотеках, до которых удастся "дотянуться" (хакер должен иметь большую библиотеку компиляторов и библиотек на своем жестком диске). Возможные варианты: никаких других текстовых строк вообще нет; строки есть, но они не уникальны – обнаруживаются во многих библиотеках; наконец, искомый фрагмент нигде не обнаружен. В первых двух случаях следует выделить из одной (нескольких) библиотечных функций характерную последовательность байт, не содержащую перемещаемых элементов, и вновь попытаться отыскать ее во всех доступных вам библиотеках. Если же это не поможет, то… увы, искомой библиотеки у вас в наличие нет и положение – ласты.
Ласты, да не совсем! Конечно, автоматически восстановить имена функций уже не удастся, но надежда на быстрое выяснение назначения функций все же есть. Имена API-функций Windows, вызываемые из библиотек, позволяют идентифицировать по крайней мере категорию библиотеки (например, работа с файлами, памятью, графикой и т.д.) Математические же функции по обыкновению богаты командами сопроцессора.
Дизассемблирование очень похоже на разгадывание кроссворда (хотя не факт, что хакеры любят разгадывать кроссворды) – неизвестные слова угадываются за счет известных. Применительно к данной ситуации – в некоторых контекстах название функции вытекает из ее использования. Например, запрашиваем у пользователя пароль, передаем ее функции X вместе с эталонным паролем, - если результат завершения нулевой – пишем "пароль ОК" и, соответственно, наоборот. Не подсказывает ли ваша интуиция, что функция X ни что иное, как strcmp? Конечно, это простейший случай, но по любому, столкнувшись с незнакомой подпрограммой, не спешите впадать в отчаяние, приходя в ужас от ее "монстроузности" – просмотрите все вхождения, обращая внимания кто вызывает ее, когда и сколько раз.
Статистический анализ на очень многое проливает свет (функции, как и буквы алфавита, встречаются каждая со своей частотой), а контекстная зависимость дает пищу для размышлений. Так, функция чтения из файла не может предшествовать функции открытия!
Другие зацепки: аргументы и константы. Ну, с аргументами все более или менее ясно. Если функция получает строку, то это очевидно функция из библиотеки работы со строками, а если вещественное значение – возможно, функция математической библиотеки. Количество и тип аргументов (если их учитывать) весьма сужают круг возможных кандидатов. С константами же еще проще, - очень многие функции принимают в качестве аргумента флаг, принимающий одно из нескольких значений. За исключением битовых флагов, которые все похожи друг на друга как один, довольно часто встречаются уникальные значения, пускай не однозначно идентифицирующие функцию, но все равно сужающие круг "подозреваемых". Да и сами функции могут содержать характерные константы, скажем, встретив стандартный полином для подсчета CRC, можно быть уверенным, что "подследственная" вычисляет контрольную сумму…
Мне могут возразить: мол, все это частности. Возможно, но, опознав часть функций, назначения остальных можно вычислить "от противного" и уж по крайней мере понять: что это за библиотека такая и где ее искать.
Напоследок, - идентификацию алгоритмов (т.е. назначения функции) очень сильно облегчает значение этих самих алгоритмов. В частности, код, осуществляющий LZ-сжатие (распаковку), настолько характерен, что узнается с беглого взгляда – достаточно знать этот механизм упаковки. Напротив, если не иметь о нем никакого представления – ох, и нелегко же будет анализировать программу! Зачем открывать изобретать колесо, когда можно взять уже готовое? Хоть и бытует мнение, что хакер – в первую очередь хакер, а уж потом программист (да и зачем ему уметь программировать?), в жизни все наоборот, - программист, не умеющий программировать, проживет – вокруг уйма библиотек, воткни – и заработает! Хакеру же знание информатики необходимо, - без этого далеко не уплывешь (разумеется, отломать серийный номер можно и без высшей математики).
Понятное дело, библиотеки как раз на то и создавались, чтобы избавить разработчиков от необходимости вникать в те предметные области, без которых им и так хорошо. Увы, у исследователей программ нет простых путей – приходится думать и руками, и головой, и даже… пятой точкой опоры вкупе со спинным мозгом, - только так и дизассемблируются серьезные программы. Бывает, готовое решение приходит в поезде или во сне…
Анализ библиотечных функций – это сложнейший аспект дизассемблирования и просто замечательно, когда есть возможность идентифицировать их имена по сигнатурам.

Идентификация аргументов функций

То, что пугает зверя, не пугает человека.
Фрэнк Херберт "Ловец душ"

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

::соглашения о передаче параметров. Для успешной совместной работы вызывающая функция должна не только знать прототип вызываемой, но и "договориться" с ней о способе передачи аргументов: по ссылке или значению, через регистры или через стек? Если через регистры – оговорить какой аргумент в какой регистр помещен, а если через стек – определить порядок занесения аргументов и выбрать "ответственного" за очистку стека от аргументов после завершения вызываемой функции.
Неоднозначность механизма передачи аргументов – одна из причин несовместимости различных компиляторов. Казалось, почему бы ни заставить всех производителей компиляторов придерживаться какой-то одной схемы? Увы, это решение принесет больше проблем, чем решит.
Каждый механизм имеет свои достоинства и недостатки и, что еще хуже, тесно связан с самим языком. В частности, "Сишные" вольности в отношении соблюдения прототипов функцией возможны именно потому, что аргументы из стека выталкивает не вызываемая, а вызывающая функция, которая наверняка "помнит", что она передавала. Например, функции main передаются два аргумента – количество ключей командной строки и указатель на содержащий их массив. Однако если программа не работает с командной строкой (или получает ключ каким-то иным путем), прототип main может быть объявлен и так: main().
На Паскале бы подобная выходка привела бы либо к ошибке компиляции, либо к краху программы, т.к. в нем стек очищает непосредственно вызываемая функция и, если она этого не сделает (или сделает неправильно, вытолкнув не то же самое количество машинных слов, которое ей было передано), стек окажется не сбалансированным и все рухнет. (Точнее, у материнской функции "слетит" вся адресация локальных переменных, а вместо адреса возврата в стеке окажется, что глюк на душу положит).
Минусом "Сишного" решения является незначительное увеличении размера генерируемого кода, ведь после каждого вызова функции приходится вставлять машинную команду (и порой не одну) для выталкивания аргументов из стека, а у Паскаля эта команда внесена непосредственно в саму функцию и потому встречается в программе один единственный раз.
Не найдя "золотой середины", разработчики компиляторов решили использовать все возможные механизмы передачи данных, а, чтобы справится с проблемой совместимости, стандартизировали каждый из механизмов, введя ряд соглашений.

Си-соглашение (обозначаемое __cdecl) предписывает засылать аргументы в стек справа налево в порядке их объявления, а очистку стека возлагает на плечи вызывающей функции. Имена функций, следующих Си-соглашению, предваряются символом прочерка "_", автоматически вставляемого компилятором. Указатель this (в Си++ программах) передается через стек последним по счету аргументом.

Паскаль-соглашение (обозначаемое PASCAL) { >>> сноска В настоящее время ключевое слово PASCAL считается устаревшим и выходит из употребления, вместо него можно использовать аналогичное соглашение WINAPI} предписывает засылать аргументы в стек слева направо в порядке их объявления, и возлагает очистку стека на саму вызывающую функцию.

Стандартное соглашение (обозначаемое __stdcall) является гибридом Си- и Паскаль- соглашений. Аргументы засылаются в стек справа налево, но очищает стек сама вызываемая функция. Имена функций, следующих стандартному соглашению, предваряются символом прочерка "_", а заканчиваются суффиксом "@", за которым следует количество байт передаваемых функции. Указатель this (в Си++ программах) передается через стек последним по счету аргументом.

Соглашения быстрого вызова: Предписывает передавать аргументы через регистры. Компиляторы от Microsoft и Borland поддерживают ключевое слово __fastcall, но интерпретируют его по-разному, а WATCOM С++ вообще не понимает ключевого слова __fastcall, но имеет в "арсенале" своего лексикона специальную прагму "aux", позволяющую вручную выбрать регистры для передачи аргументов (подробнее см. "соглашения о быстрых вызовах – fastcall"). Имена функций, следующих соглашению __fastcall, предваряются символом "@", автоматически вставляемым компилятором.

Соглашение по умолчанию: Если явное объявление типа вызова отсутствует, компилятор обычно использует собственные соглашения, выбирая их по своему усмотрению. Наибольшему влиянию подвергается указатель this, - большинство компиляторов при вызове по умолчанию передают его через регистр. У Microsoft это – ECX, у Borland – EAX, у WATCOM – либо EAX, либо EDX, либо и то, и другое разом. Остальные аргументы так же могут передаться через регистры, если оптимизатор посчитает, что так будет лучше. Механизм передачи и логика выборки аргументов у всех разная и наперед непредсказуемая, - разбирайтесь по ситуации.

::цели и задачи. При исследовании функции перед исследователем стоят следующее задачи: определить, какое соглашение используется для вызова; подсчитать количество аргументов, передаваемых функции (и/или используемых функцией); наконец, выяснить тип и назначение самих аргументов. Начнем?
Тип соглашения грубо идентифицируется по способу вычистки стека. Если его очищает вызываемая функция - мы имеем c cdecl, в противном случае – либо с stdcall, либо с PASCAL. Такая неопределенность в отождествлении вызвана тем, что подлинный прототип функции неизвестен и, стало быть, порядок занесения аргументов в стек определить невозможно. Единственная зацепка: зная компилятор и предполагая, что программист использовал тип вызовов по умолчанию, можно уточнить тип вызова функции. Однако в программах под Windows широко используются оба типа вызовов: и PASCAL (он же WINAPI) и stdcall, поэтому, неопределенность по-прежнему остается. Впрочем, порядок передачи аргументов ничего не меняет – имея в наличии и вызывающую, и вызываемую функцию между передаваемыми и принимаемыми аргументами всегда можно установить взаимно однозначность. Или, проще говоря, если действительный порядок передачи аргументов известен (а он и будет известен - см. вызывающую функцию), то знать очередность расположения аргументов в прототипе функции уже ни к чему.
Другое дело – библиотечные функции, прототип которых известен. Зная порядок занесения аргументов в стек, по прототипу можно автоматически восстановить тип и назначение аргументов!

::определение количества и типа передачи аргументов. Как уже было сказано выше, аргументы могут передаваться либо через стек, либо через регистры, либо и через стек, и через регистры сразу, а так же – неявно через глобальные переменные.
Если бы стек использовался только для передачи аргументов, подсчитать их количество было относительно легко. Увы, стек активно используется и для временного хранения регистров с данными. Поэтому, встретив инструкцию заталкивания PUSH, не торопитесь идентифицировать ее как аргумент. Узнать количество байт, переданных функции в качестве аргументов, невозможно, но достаточно легко определить количество байт, выталкиваемых из стека после завершения функции!
Если функция следует соглашению stdcall (или PASCAL) она наверняка очищает стек командой RET n, где n и есть искомое значение в байтах. Хуже с cdecl-функциями. В общем случае за их вызовом следует инструкция "ADD ESP,n" – где n искомое значение в байтах, но возможны и вариации – отложенная очистка стека или выталкивание аргументов в какой-нибудь свободный регистр (подробнее об этом рассказано в главе "Коварства оптимизирующих компиляторов"). Впрочем, отложим головоломки оптимизации на потом, пока ограничившись лишь кругом не оптимизирующих компиляторов.
Логично предположить, что количество занесенных в стек байт равно количеству выталкиваемых – иначе после завершения функции стек окажется несбалансированным, и программа рухнет (о том, что оптимизирующие компиляторы допускают дисбаланс стека на некотором участке, мы помним, но поговорим об этом потом). Отсюда: количество аргументов равно количеству переданных байт, деленному на размер машинного слова { >>> сноска Под машинным словом понимается не только два байта, но и размер операндов по умолчанию, в 32-разрядном режиме машинное слово равно четырем байтам} Верно ли это? Нет! Далеко не всякий аргумент занимает ровно один элемент стека. Взять тот же тип double, отъедающий восемь байт, или символьную строку, переданную не по ссылке, а по непосредственному значению, - она "скушает" столько байт, сколько захочет… К тому же засылаться в стек строка (как и структура данных, массив, объект) может не командой PUSH, а с помощью MOVS! (Кстати, наличие MOVS – явное свидетельство передачи аргумента по значению)
Если я не успел окончательно вас запутать, то попробуем разложить по полочкам тот кавардак, что образовался в нашей голове. Итак, анализом кода вызывающей функции установить количество переданных через стек аргументов невозможно. Даже количество переданных байт определяется весьма неуверенно. С типом передачи полный мрак. Позже (см. "Идентификация констант и смещений") мы к этому еще вернемся, а пока вот пример: PUSH 0x40404040/CALL MyFuct: 0x404040 – что это: аргумент передаваемый по значению (т.е. константа 0x404040) или указатель на нечто, расположенное по смещению 0x404040 (и тогда, стало быть, передача происходит по ссылке)? Определить невозможно, не правда ли?
Но не волнуйтесь, нам не пришли кранты – мы еще повоюем! Большую часть проблем решает анализ вызываемой функции. Выяснив, как она манипулирует переданными ей аргументами, мы установим и их тип и количество! Для этого нам придется познакомиться с адресацией аргументов в стеке, но прежде чем приступить к работе, рассмотрим в качестве небольшой разминки следующий пример:

#include
#include

struct XT{
char s0[20];
int x;
};

void MyFunc(double a, struct XT xt)
{
printf("%f,%x,%s\n",a,xt.x,&xt.s0[0]);
}

main()
{
struct XT xt;
strcpy(&xt.s0[0],"Hello,World!");
xt.x=0x777;
MyFunc(6.66,xt);
}

Листинг 56 Демонстрация механизма передачи аргументов

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

main proc near ; CODE XREF: start+AFp

var_18 = byte ptr -18h
var_4 = dword ptr -4

push ebp
mov ebp, esp
sub esp, 18h
; Первый PUSH явно относится к прологу функции, а не к передаваемым аргументам

push esi
push edi
; Отсутствие явной инициализации регистров говорит о том, что, скорее всего,
; они просто сохраняются в стеке, а не передаются как аргументы,
; однако если данной функции аргументы передавались не только через стек,
; но и через регистры ESI и EDI, то их засылка в стек вполне может
; преследовать цель передачи аргументов следующей функции

push offset aHelloWorld ; "Hello,World!"
; Ага, а вот здесь явно имеет место передача аргумента – указателя на строку
; (строго говоря, предположительно имеет место, - см. "Идентификация констант")
; Хотя теоретически возможно временное сохранение константы в стеке для ее
; последующего выталкивания в какой-нибудь регистр, или же непосредственному
; обращению к стеку, ни один из известных мне компиляторов не способен на такие
; хитрости и засылка константы в стек всегда является передаваемым аргументом

lea eax, [ebp+var_18]
; в EAX заносится указатель на локальный буфер

push eax
; EAX (указатель на локальный буфер) сохраняется в стеке.
; Поскольку, ряд аргументов непрерывен, то после распознания первого аргумента
; можно не сомневаться, что все последующие заносы чего бы то ни было в стек –
; так же аргументы

call strcpy
; Прототип функции strcpy(char *, char *) не позволяет определить порядок
; занесения аргументов, однако, поскольку все библиотечные Си-функции
; следует соглашению cdecl, то аргументы заносятся справа налево
; и исходный код выглядел так: strcpy(&buff[0],"Hello,World!")
; Но, может быть, программист использовал преобразование, скажем, в stdcall?
; Крайне маловероятно, – для этого пришлось бы перекомпилировать и саму
; strcpy – иначе откуда она бы узнала, что порядок занесения аргументов
; изменился? Хотя обычно стандартные библиотеки поставляются с исходными
; текстами их перекомпиляцией практически никто и никогда не занимается

add esp, 8
; Выталкиваем 8 байт из стека. Из этого мы заключаем, что функции передавалось
; два машинных слова аргументов и, следовательно, PUSH ESI и PUSH EDI не были
; аргументами функции!

mov [ebp+var_4], 777h
; Заносим в локальную переменную константу 0x777. Это явно константа, а не
; указатель, т.к. у Windows в этой области памяти не могут храниться никакие
; пользовательские данные

sub esp, 18h
; Резервирование памяти для временной переменной. Временные переменные
; в частности создаются при передаче аргументов по значению, поэтому,
; будем готовы к тому, что следующий "товарищ" – аргумент
; (см. "Идентификация регистровых и временных переменных")

mov ecx, 6
; Заносим в ECX константу 0х6. Пока еще не известно зачем.

lea esi, [ebp+var_18]
; Загружаем в ESI указатель на локальный буффер, содержащий скопированную
; строку "Hello, World!"

mov edi, esp
; Копируем в EDI указатель на вершину стека

repe movsd
; вот она – передача строки по значению. Строка целиком копируется в стек,
; отъедая от него 6*4 байт.
; (6 – значение счетчика ECX, а 4 – размер двойного слова – movsD)
; следовательно, этот аргумент занимает 20 (0x14) байт стекового пространства –
; эта цифра нам пригодится при определении количества аргументов по количеству
; выталкиваемых байт.
; В стек копируются данные с [ebp+var_18], до [ebp+var_18-0x14], т.е.
; с var_18 до var_4. Но ведь в var_4 содержится константа 0x777!
; следовательно, она будет передана функции вместе со строкой.
; Это позволяет нам воссоздать исходную структуру:
; struct x{
; char s0[20]
; int x
; }
; да, функции, выходит, передается структура, а не одна строка!

push 401AA3D7h
push 0A3D70A4h
; Заносим в стек еще два аргумента. Впрочем, почему именно два?
; Это вполне может быть и один аргумент типа int64 или double
; Определить – какой именно по коду вызывающей функции не представляется
; возможным

call MyFunc
; Вызов MyFunc. Прототип функции установить, увы, не удается... Ясно только,
; что первый (слева? справа?) аргумент – структура, а за ним идут либо два int
; либо один int64 или double
; Уточнить ситуацию позволяет анализ вызываемой функции, но мы это отложим
; на потом, - до того как изучим адресацию аргументов в стеке
; Пока же придется прибывать в полной неопределенности

add esp, 20h
; выталкиваем 0x20 байт. Поскольку, 20 байт (0x14) приходится на структуру
; и 8 байт – на два следующих аргумента, получаем 0x14+0x8=0x20, что
; и требовалось доказать.

pop edi
pop esi
mov esp, ebp
pop ebp
retn
sub_401022 endp

aHelloWorld db 'Hello,World!',0 ; DATA XREF: sub_401022+8o
align 4
Листинг 57
Результат компиляции компилятором Borland C++ будет несколько иным и довольно поучительным. Рассмотрим и его:

_main proc near ; DATA XREF: DATA:00407044o

var_18 = byte ptr -18h
var_4 = dword ptr -4

push ebp
mov ebp, esp
add esp, 0FFFFFFE8h
; Ага! Это сложение со знаком минус. Жмем в IDA <-> и получаем ADD ESP,-18h

push esi
push edi
; Пока все идет как в предыдущем случае

mov esi, offset aHelloWorld ; "Hello,World!"
; А вот тут начинаются различия! Вызов strcpy как корова языком слизала –
; нету его и все! Причем, компилятор даже не развернул функцию,
; подставляя ее на место вызова, а просто исключил сам вызов!

lea edi, [ebp+var_18]
; Заносим в EDI указатель на локальный буфер

mov eax, edi
; Заносим тот же самый указатель в EAX

mov ecx, 3
repe movsd
movsb
; Обратите внимание: копируется 4*3+1=13 байт. Тринадцать, а вовсе не
; двадцать, как следует из объявления структуры. Это компилятор так
; оптимизировал код, копируя в буфер лишь саму строку, и игнорируя ее
; не инициализированный "хвост"

mov [ebp+var_4], 777h
; Заносим в локальную переменную константу 0x777

push 401AA3D7h
push 0A3D70A4h
; Аналогично. Мы не может определить: чем являются эти два числа –
; одним или двумя аргументами.

lea ecx, [ebp+var_18]
; Заносим в ECX указатель на начало строки

mov edx, 5
; Заносим в EDX константу 5 (пока не понятно зачем)

loc_4010D3: ; CODE XREF: _main+37j
push dword ptr [ecx+edx*4]
; Ой, что это за кошмарный код? Давайте подумаем, начав раскручивать его
; с самого конца. Прежде всего – чему равно ECX+EDX*4? ECX – указатель на
; буфер и с этим все ясно, а вот EDX*4 == 5*4 == 20.
; Ага, значит, мы получаем указатель не на начало строки, а на конец, вернее
; даже не на конец, а на переменную ebp+var_4 (0x18-0x14=0x4).
; Подумаем – если это указатель на саму var_4, то зачем его вычислять таким
; закрученным макаром? Вероятнее всего мы имеем дело со структурой...
; Далее – смотрите, команда push засылает в стек двойное слово,
; хранящееся по этому указателю

dec edx
; Уменьшаем EDX... Вы уже почувствовали, что мы имеем дело с циклом?

jns short loc_4010D3
; вот – этот переход, срабатывающий пока EDX не отрицательное число,
; подтверждает наше предположение о цикле.
; Да, такой вот извращенной конструкций Borland передает аргумент - структуру
; функции по значению!

call MyFunc
; Вызов функции... смотрите – нет очистки стека! Да, это последняя вызываемая
; функция и очистки стека не требуется – Borland ее и не выполняет...

xor eax, eax
; Обнуление результата, возращенного функцией. Borland так поступает с void
; функциями – они у него всегда возвращают ноль,
; точнее: не они возвращают, а помещенный за их вызовом код, обнуления EAX

pop edi
pop esi
; Восстанавливаем ранее сохраненные регистры EDI и ESI

mov esp, ebp
; восстанавливаем ESI, - вот почему стек не очищался после вызова последней
; функции!

pop ebp
retn
_main endp

Листинг 58

Обратите внимание – по умолчанию Microsoft C++ передает аргументы справа налево, а Borland C++ - слева направо! Среди стандартных типов вызов нет такого, который, передавая аргументы слева направо, поручал бы очистку стека вызывающей функции! Выходит, что Borland C++ использует свой собственный, ни с чем не совместимый тип вызова!

::адресация аргументов в стеке. Базовая концепция стека включает лишь две операции – занесение элемента в стек и снятие последнего занесенного элемента со стека. Доступ к произвольному элементу – это что-то новенькое! Однако такое отступление от канонов существенно увеличивает скорость работы – если нужен, скажем, третий по счету элемент, почему бы ни вытащить из стека напрямую, не снимая первые два? Стек это не только "стопка", как учат популярные учебники по программированию, но еще и массив. А раз так, то, зная положение указателя вершины стека (а не знать его мы не можем, иначе куда прикажите класть очередной элемент?), и размер элементов, мы сможем вычислить смещению любого из элементов, после чего не составит никакого труда его прочитать.
Попутно отметим один из недостатков стека – как и любой другой гомогенный массив, стек может хранить данные лишь одного типа, например, двойные слова. Если же требуется занести один байт (скажем, аргумент типа char), то приходится расширять его до двойного слова и заносить его целиком. Аналогично, если аргумент занимает четыре слова (double, int64) на его передачу расходуется два стековых элемента!
Помимо передачи аргументов стек используется и для сохранения адреса возврата из функции, что требует в зависимости от типа вызова функции (ближнего или дальнего) от одного до двух элементов. Ближний (near) вызов действует в рамках одного сегмента, - в этом случае достаточно сохранить лишь смещение команды, следующей за инструкций CALL. Если же вызывающая функция находится в одном сегменте, а вызываемая в другом, то помимо смещения приходится запоминать и сам сегмент, чтобы знать в какое место вернуться. Поскольку адрес возврата заносится после аргументов, то относительно вершины стека аргументы оказываются "за" ним и их смещение варьируется в зависимости от того: один элемент занимает адрес возврата или два. К счастью, плоская модель памяти Windows NT\9x позволяет забыть о моделях памяти как о страшном сне и всюду использовать только ближние вызовы.
Не оптимизирующие компиляторы используют для адресации аргументов специальный регистр (как правило, EBP), копируя в него значение регистра-указателя вершины стека в самом начале функции. Поскольку стек растет снизу вверх, т.е. от старших адресов к младшим, смещение всех аргументов (включая адрес возврата) положительны, а смещение N-ого по счету аргумента вычисляется по следующей формуле:

arg_offset = N*size_element+size_return_address

где N – номер аргумента, считая от вершины стека, начиная с нуля, size_element – размер одного элемента стека, в общем случае равный разрядности сегмента (под Windows NT\9x – четыре байта), size_return_address – размер в байтах, занимаемый адресом возврата (под Windows NT\9x – обычно четыре байта).
Часто приходится решать и обратную задачу: зная смещение элемента, определять к какому по счету аргументу происходит обращение. В этом нам поможет следующая формула, элементарно выводящаяся из предыдущей:

Поскольку, перед копированием в EBP текущего значения ESP, старое значение EBP приходится сохранять в том же самом стеке, в приведенную формулу приходится вносить поправку, добавляя к размеру адреса возврата еще и размер регистра EBP (BP в 16-разрядном режиме, который все еще жив на сегодняшний день).
С точки зрения хакера главное достоинства такой адресации аргументов в том, что, увидев где-то в середине кода инструкцию типа "MOV EAX,[EBP+0x10]", можно мгновенно вычислить к какому именно аргументу происходит обращение. Однако оптимизирующие компиляторы для экономии регистра EBP адресуют аргументы непосредственно через ESP. Разница принципиальна! Значение ESP не остается постоянным на протяжении выполнения функции и изменяется всякий раз при занесении и снятии данных из стека, следовательно, не остается постоянным и смещение аргументов относительно ESP. Теперь, чтобы определить к какому именно аргументу происходит обращение, необходимо знать: чему равен ESP в данной точке программы, а для выяснения этого все его изменения приходится отслеживать от самого начала функции! Подробнее о такой "хитрой" адресации мы поговорим потом (см. "Идентификация локальных стековых переменных"), а для начала вернемся к предыдущему примеру (надо ж его "добить") и разберем вызываемую функцию:

MyFunc proc near ; CODE XREF: main+39p

arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch
arg_8 = byte ptr 10h
arg_1C = dword ptr 24h
; IDA распознала четыре аргумента, передаваемых функции. Однако,
; не стоит безоговорочно этому доверять, – если один аргумент (например, int64)
; передается в нескольких машинных словах, то IDA ошибочно примет его не за один,
; а за несколько аргументов!
; Поэтому, результат, полученный IDA, надо трактовать так: функции передается не менее
; четырех аргументов. Впрочем, и здесь не все гладко! Ведь никто не мешает вызываемой
; функции залезать в стек материнской так далеко, как она захочет! Может быть,
; нам не передавали никаких аргументов вовсе, а мы самовольно полезли в стек и
; стянули что-то оттуда. Хотя это случается в основном вследствие программистских
; ошибок из-за путаницы с прототипами, считаться с такой возможностью необходимо.
; (Когда ни будь вы все равно с этим встретитесь, так что будьте готовы)
; Число, стоящее после 'arg', выражает смещение аргумента относительно начала
; кадра стека.
; Обратите внимание: сам кадр стека смещен на восемь байт относительно EBP -
; четыре байта занимает сохраненный адрес возврата, и еще четыре уходят на сохранение
; регистра EBP.

push ebp
mov ebp, esp
lea eax, [ebp+arg_8]
; получение указателя на аргумент.
; Внимание: именно указателя на аргумент, а не изволение аргумента-указателя!
; Теперь разберемся – на какой именно аргумент мы получаем указатель.
; IDA уже вычислила, что этот аргумент смещен на восемь байт относительно
; начала кадра стека. В оригинале выражение, заключенное в скобках выглядело
; как ebp+0x10 – так его и отображает большинство дизассемблеров. Не будь IDA
; такой умной, нам бы пришлось постоянно вручную отнимать по восемь байт от
; каждого такого адресного выражения (впрочем, с этим мы еще поупражняемся)
;
; Логично: на вершине то, что мы клали в стек в последнею очередь.
; Смотрим вызывающую функцию – что ж мы клали-то?
; (см. вариант, откомпилированный Microsoft Visual C++)
; Ага, последними были те два непонятные аргумента, а перед ними в стек
; засылалась структура, состоящая из строки и переменной типа int
; Таким образом, EBP+ARG_8 указывает на строку

push eax
; Засылаем в стек полученный указатель.
; Похоже, что он передается очередной функции.

mov ecx, [ebp+arg_1C]
; Заносим в ECX содержимое аргумента EBP+ARG_1C. На что он указывает?
; Вспомним, что тип int находится в структуре по смещению 0x14 байт от начала,
; а ARG_8 – и есть ее начало. Тогда, 0x8+0x14 == 0x1C.
; Т.е. в ECX заносится значение переменной типа int, члена структуры

push ecx
; Заносим полученную переменную в стек, передавая ее по значению
; (по значению – потому что ECX хранит значение, а не указатель)

mov edx, [ebp+arg_4]
; Берем один их тех двух непонятных аргументов, занесенных последними в стек

push edx
; ...и, вновь заталкиваем в стек, передавая его очередной функции.

mov eax, [ebp+arg_0]
push eax
; Берем второй непонятный аргумент и пихаем его в стек.

push offset aFXS ; "%f,%x,%s\n"
call _printf
; Опа! Вызов printf с передачей строкой спецификаторов! Функция, printf,
; как известно, имеет переменное число аргументов, тип и количество которых
; как раз и задают спецификаторы.
; Вспомним, – сперва в стек мы заносили указатель на строку, и действительно,
; крайний правый спецификатор "%s" обозначает вывод строки.
; Затем в стек заносилась переменная типа int и второй справа спецификатор
; есть %x – вывод целого в шестнадцатеричной форме.
; А вот затем... затем идет последний спецификатор %f, в то время как в стек
; заносились два аргумента.
; Заглянув в руководство программиста по Microsoft Visual C++, мы прочтем,
; что спецификатор %f выводит вещественное значение, которое в зависимости от
; типа может занимать и четыре байта (float), и восемь (double).
; В нашем случае оно явно занимает восемь байт, следовательно, это double
; Таким образом, мы восстановили прототип нашей функции, вот он:
; cdecl MyFunc(double a, struct B b)
; Тип вызова cdecl – т.е. стек вычищала вызывающая функция. Вот только, увы,
; подлинный порядок передачи аргументов восстановить невозможно. Вспомним,
; Borland C++ так же вычищал стек вызывающей функцией, но самвовольно изменил
; порядок передачи параметров.
; Кажется, если программа компилилась Borland C++, то мы просто изменяем
; порядк арументов на обратный – вот и все. Увы, это не так просто. Если имело
; место явное преобразование типа функции в cdecl, то Borland C++ без лишней
; самодеятельности поступил бы так, как ему велели и тогда бы обращение
; порядка аргументов дало бы неверный резлуьтат!
; Впрочем, подлинный порядок следования аргументов в прототипе функции
; не играет никакой роли. Важно лишь связать передаваемые и принимаемые
; аргументы, что мы и сделали.
; Обратите внимание: это стало возможно лишь при совместом анализе и вызываемой
; и вызывающей функуий! Анализ лишь одной из них ничего бы не дал!
; Примечание: никогда не следует безоговорочно полагаться на достоверность
; строки спецификаторов. Посколкьу, спецификаторы формируются вручную самим
; программистом, тут возможны ошибки, под час весьма трудноуловимые и дающие
; после компиляции чрезвычайно загадочный код!
; Подробнее об этом рассказывается в статье
; "неизвестная уявзимость ошибка printf", помещенный в главу "Приложения"

add esp, 14h
pop ebp
retn
MyFunc endp
Листинг 59
Так, кое-какие продвижения уже есть – мы уверенно восстановили прототип нашей первой функции. Но это только начало… Еще много миль предстоит пройти, прежде чем будет достигнут конец главы. Если вы устали – передохните. Тяпните пивка (колы), позвоните своей любимой девушке (а, что, у хакеров и любимые девушки есть?), словом, как хотите, но обеспечьте свежую голову. Мы приступаем к еще одной нудной, но важной теме – сравнительному анализу различных типов вызовов функций и их реализации в популярных компиляторах.
Начнем с изучения стандартного соглашения о вызове – stdcall. Рассмотрим следующий пример:

#include
#include

__stdcall MyFunc(int a, int b, char *c)
{
return a+b+strlen(c);
}

main()
{
printf("%x\n",MyFunc(0x666,0x777,"Hello,World!"));
}
Листинг 60 Демонстрация stdcall

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

main proc near ; CODE XREF: start+AFp
push ebp
mov ebp, esp

push offset aHelloWorld ; const char *
; Заносим в стек указатель на строку aHelloWorld.
; Заглянув в исходные тексты (благо они у нас есть), мы обнаружим, что
; это – самый правый аргумент, передаваемый функции. Следовательно,
; перед нами вызов типа stdcall или cdecl, но не PASCAL.
; Обратите внимание – строка передается по ссылке, но не по знаниючению.

push 777h ; int
; Заносим в стек еще один аргумент - константу типа int.
; (IDA начиная с версии 4.17 автоматически определяет ее тип).

push 666h ; int
; Передаем функции последний, самый левый аргумент, – константу типа int

call MyFunc
; Обратите внимание – после вызова функции отсутствуют команды очистки стека
; от занесенных в него аргументов. Если компилятор не схитрил и не прибегнул
; к отложенной очистке, то скорее всего, стек очищает сама вызываемая функция,
; значит, тип вызова – stdcall (что, собственно, и требовалось доказать)

push eax
; Передаем возвращенное функцией значение следующей функции как аргумент

push offset asc_406040 ; "%x\n"
call _printf
; ОК, эта следующая функция printf, и строка спецификаторов показывает,
; что переданный аргумент имеет тип int

add esp, 8
; Выталкивание восьми байт из стека – четыре приходятся на аргумент типа int
; остальные четыре – на указатель на строку спецификаторов

pop ebp
retn
main endp

; int __cdecl MyFunc(int,int,const char *)
MyFunc proc near ; CODE XREF: sub_40101D+12p
; С версии 4.17 IDA автоматически восстанавливает прототипы функций, но делает это
; не всегда правильно. В данном случае она допустила грубую ошибку – тип вызова
; никак не может иметь тип cdecl, т.к. стек вычищает вызываемая функция! Сдается, что
; вообще не предпринимает никаких попыток анализа типа вызова, а берет его из настроек
; распознанного компилятора по умолчанию.
; В общем, как бы там ни было, но с результатами работы IDA следует обращаться
; очень осторожно.

arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch
arg_8 = dword ptr 10h

push ebp
mov ebp, esp
push esi
; Это, как видно, сохранение регистра в стеке, а не передача его функции, т.к.
; регистр явным образом не инициализировался ни вызывающей, ни вызываемой
; функцией.

mov esi, [ebp+arg_0]
; Заносим в регистр ESI последней занесенный в стек аргумент

add esi, [ebp+arg_4]
; Складываем содержимое ESI с предпоследним занесенным в стек аргументом

mov eax, [ebp+arg_8]
; Заносим в в EAX пред- предпоследний аргумент и…

push eax ; const char *
; …засылаем его в стек.

call _strlen
; Поскольку strlen ожидает указателя на строку, можно с уверенностью
; заключить, что пред- предпоследний аргумент – строка, переданная по ссылке.

add esp, 4
; Вычистка последнего аргумента из стека

add eax, esi
; Как мы помним, в ESI хранится сумма двух первых аргументов,
; а в EAX – возвращенная длина строки. Таким образом, функция суммирует
; два своих аргумента с длиной строки.

pop esi
pop ebp
retn 0Ch
; Стек чистит вызываемая функция, следовательно, тип вызова stdcall или PASCAL.
; Будем считать, что это stdcall, тогда прототип функции выглядит так:
; int MyFunc(int a, int b, char *c)
;
; Порядок аргументов вытекает из того, что на вершине стека были две
; переменные типа int, а под ними строка. Поскольку на верху стека лежит
; всегда то, что заносилось в него в последнюю очередь, а по stdcall
; аргументы заносятся справа налево, мы получаем именно такой порядок
; следования аргументов
MyFunc endp
Листинг 61
А теперь рассмотрим, как происходит вызов cdecl функции. Изменим в предыдущем примере ключевое слово stdcall на cdecl:

#include
#include

__cdecl MyFunc(int a, int b, char *c)
{
return a+b+strlen(c);
}

main()
{
printf("%x\n",MyFunc(0x666,0x777,"Hello,World!"));
}
Листинг 62 Демонстрация cdecl
Результат компиляции должен выглядеть так:

main proc near ; CODE XREF: start+AFp
push ebp
mov ebp, esp

push offset aHelloWorld ; const char *
push 777h ; int
push 666h ; int
; Передаем функции аргументы через стек

call MyFunc
add esp, 0Ch
; Смотрите: стек вычищает вызывающая функция. Значит, тип вызова cdecl,
; поскольку, все остальные предписывают вычищать стек вызываемой функции.

push eax
push offset asc_406040 ; "%x\n"
call _printf
add esp, 8
pop ebp
retn
main endp

; int __cdecl MyFunc(int,int,const char *)
; А вот сейчас IDA правильно определила тип вызова. Однако, как уже показывалось выше,
; она могла и ошибиться, поэтому полагаться на нее не стоит.

MyFunc proc near ; CODE XREF: main+12p

arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch
arg_8 = dword ptr 10h
; Поскольку, как мы уже выяснили, функция имеет тип cdecl, аргументы передаются
; справа налево и ее прототип выглядит так: MyFunc(int arg_0, int arg_4, char *arg_8)

push ebp
mov ebp, esp
push esi
; Сохраняем ESI в стеке

mov esi, [ebp+arg_0]
; Заносим в ESI аргумент arg_0 типа int

add esi, [ebp+arg_4]
; Складываем его с arg_4

mov eax, [ebp+arg_8]
; Заносим в EAX указатель на строку

push eax ; const char *
; Передаем его функции strlen через стек

call _strlen
add esp, 4

add eax, esi
; Добавляем к сумме arg_0 и arg_4 длину строки arg_8

pop esi
pop ebp
retn
MyFunc endp
Листинг 63

Прежде, чем перейти к вещам по настоящему серьезным, рассмотрим на закуску последний стандартный тип – PASCAL:

#include
#include

// Внимание! Microsoft Visual C++ уже не поддерживает тип вызова PASCAL
// вместо этого используйте аналогичный ему тип вызова WINAPI, определенный в файле
// .
#if defined(_MSC_VER)
#include
// включать windows.h только если мы компилируется Microsoft Visual C++
// для остальных компиляторов более эффективное решение – использование ключевого
// слова PASACAL, если они, конечно, его поддерживают. (Borland поддерживает)
#endif

// Подобный примем программирования может и делает листинг менее читабельным,
// но зато позволяет компилировать его не только одним компилятором!
#if defined(_MSC_VER)
WINAPI
#else
__pascal
#endif

MyFunc(int a, int b, char *c)
{
return a+b+strlen(c);
}

main()
{
printf("%x\n",MyFunc(0x666,0x777,"Hello,World!"));
}
Листинг 64 Демонстрация вызова PASCAL

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

; int __cdecl main(int argc,const char **argv,const char *envp)
_main proc near ; DATA XREF: DATA:00407044o

push ebp
mov ebp, esp

push 666h ; int
push 777h ; int
push offset aHelloWorld ; s
; Передаем функции аргументы. Заглянув в исходный текст, мы заметим, что
; аргументы передаются слева направо. Однако если исходных текстов нет,
; установить этот факт невозможно! К счастью, подлинный прототип функции
; не важен.

call MyFunc
; Функция не вычищает за собой стек! Если это не результат оптимизации –
; ее тип вызова либо PASCAL, либо stdcall. Ввиду того, что PASACAL уже вышел
; из употребления, будем считать, что имеем дело с stdcall

push eax
push offset unk_407074 ; format
call _printf
add esp, 8

xor eax, eax
pop ebp
retn
_main endp

; int __cdecl MyFunc(const char *s,int,int)
; Ага! IDA вновь дала неправильный результат! Тип вызова явно не cdecl!
; Однако, в остальном прототип функции верен, вернее, не то что бы он верен
; (на самом деле порядок аргументов обратный), но для использования – пригоден

MyFunc proc near ; CODE XREF: _main+12p

s = dword ptr 8
arg_4 = dword ptr 0Ch
arg_8 = dword ptr 10h

push ebp
mov ebp, esp
; Открываем кадр стека

mov eax, [ebp+s]
; Заносим в EAX указатель на строку

push eax ; s
call _strlen
; Передаем его функции strlen

pop ecx
; Очищаем стек от одного аргумента, выталкивая его в неиспользуемый регистр

mov edx, [ebp+arg_8]
; Заносим в EDX аргумент arg_8 типа int

add edx, [ebp+arg_4]
; Складываем его с аргументом arg_4

add eax, edx
; Складываем сумму arg_8 и arg_4 с длиной строки

pop ebp
retn 0Ch
; Стек чистит вызываемая функция. Значит, ее тип PASCAL или stdcall

MyFunc endp
Листинг 65

Как мы видим, идентификация базовых типов вызов и восстановление прототипов функции – занятие несложное. Единственное, что портит настроение – путаница с PASCAL и stdcall, но порядок занесения аргументов в стек не имеет никакого значения, разве что в особых случаях, один из которых перед вами:

#include
#include
#include

// CALLBACK процедура для приема сообщений от таймера
VOID CALLBACK TimerProc(
HWND hwnd, // handle of window for timer messages
UINT uMsg, // WM_TIMER message
UINT idEvent, // timer identifier
DWORD dwTime // current system time
)
{
// Бибикаем всеми пиками на все голоса
MessageBeep((dwTime % 5)*0x10);

// Выводим время в секундах, прошедшее с момента пуска системы
printf("\r:=%d",dwTime / 1000);
}

main()
// Да, это консольное приложение, но оно так же может иметь цикл выборки сообщений
// и устанавливать таймер!
{
int a;
MSG msg;

// Устанавливаем таймер, передавая ему адрес процедуры TimerProc
SetTimer(0,0,1000,TimerProc);

// Цикл выборки сообщений. Когда надоест – жмем Ctrl-Break и прерываем его
while (GetMessage(&msg, (HWND) NULL, 0, 0))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}

Листинг 66 Пример, демонстрирующий тот случай, когда требуется точно отличать PASCAL от stdcall

Откомпилируем этот пример так: "cl pascal.callback.c USER32.lib" и посмотрим, что из этого получилось:

main proc near ; CODE XREF: start+AFp
; На сей раз IDA не определила прототип функции. Ну и ладно...

Msg = MSG ptr -20h
; IDA распознала одну локальную переменную и даже восстановила ее тип, что радует

push ebp
mov ebp, esp
sub esp, 20h

push offset TimerProc ; lpTimerFunc
; Передаем указатель на функцию TimerProc

push 1000 ; uElapse
; Передаем время задержки таймера

push 0 ; nIDEvent
; В консольных приложениях аргумент nIDEvent всегда игнорируется

push 0 ; hWnd
; Окон нет, передаем NULL

call ds:SetTimer
; Win32 API функции вызываются по соглашению stdcall – это дает возможность,
; зная их прототип,(а он описан в SDK) восстановить тип и назначение аргументов
; в данном случае исходный текст выглядел так:
; SetTimer(NULL, BULL, 1000, TimerProc);

loc_401051: ; CODE XREF: main+42j
push 0 ; wMsgFilterMax
; NULL – нет фильтра

push 0 ; wMsgFilterMin
; NULL – нет фильтра

push 0 ; hWnd
; NULL – нет окон в консольном приложении

lea eax, [ebp+Msg]
; Получаем указатель на локальную переменную msg -
; тип этой переменной определяется, кстати, только на основе прототипа
; функции GetMessageA

push eax ; lpMsg
; Передаем указатель на msg

call ds:GetMessageA
; Вызываем функцию GetMessageA(&msg, NULL, NULL, NULL);

test eax, eax
jz short loc_40107B
; Проверка на получение WM_QUIT

lea ecx, [ebp+Msg]
; В ECX – указатель на заполненную структуру MSG…

push ecx ; lpMsg
; …передаем его функции TranslateMessage

call ds:TranslateMessage
; Вызываем функцию TranslateMessage(&msg);

lea edx, [ebp+Msg]
; В EDX – указатель на msg…

push edx ; lpMsg
; …передаем его функции DispatchMessageA

call ds:DispatchMessageA
; Вызов функции DispatchMessageA

jmp short loc_401051
; Цикл выборки сообщений

loc_40107B: ; CODE XREF: main+2Cj
; Выход

mov esp, ebp
pop ebp
retn
main endp

TimerProc proc near ; DATA XREF: main+6o
; Прототип TimerProc в следствие ее неявного вызова операционной системой
; не был автоматически восстановлен IDA, - этим придется заниматься нам
; Мы знаем, что TimerProc передается функции SetTimer.
; Заглянув в описание SetTimer (SDK всегда должен быть под рукой!) мы найдем
; ее прототип:
;
;VOID CALLBACK TimerProc(
; HWND hwnd, // handle of window for timer messages
; UINT uMsg, // WM_TIMER message
; UINT idEvent, // timer identifier
; DWORD dwTime // current system time
;)
;
; Остается разобраться с типом вызова. На сей раз он приниципиален, т.к. не имеея
; кода вызывающей функции (он расположен глубоко в недрах операционной системы),
; мы разберемся с типами аргументов только в том случае, если будет знать их
; порядок передачи.
; Выше уже говорилось, что все CALLBACK функции следуют соглашению PASCAL.
; Не путайте CALLBACK-функции с Win32 API-функциями! Первые вызывает сама
; операционная система, а вторые – прикладная программа.
;
; ОК, тип вызова этой функции – PASCAL. Значит, аргументы заносятся слевно направо,
; а стек чистит вызываемая функция (убедитесь, что это действительно так).

arg_C = dword ptr 14h
; IDA обнаружила только один аргумент, хотя, судя по прототипу, их передается четыре.
; Почему? Очень просто – функция использовала всего один аргумент, а к остальным и
; не обращалась. Вот IDA и не смогла их восстановить!
; Кстати, что это за аргумент? Смотрим: его смещение равно 0xC. А на вершине стека то,
; что в него заталкивалось в последнюю очередь. Внизу, соответственно, наоборот.
; Постой, постой, что за чертовщина?! Выходит, dwTime был занесен в стек в первую
; очередь?! (Мы-то, имея исходный текст, знаем, что arg_C – наверняка dwTime).
; Но ведь соглашение PASCAL диктует противоположный порядок занесения аргументов!
; Что-то здесь не так... но ведь программа работает (запустите ее, чтобы проверить)
; А в SDK написано, что CALLBACK – аналог FAR PASACAL. С FAR-ом понятно, в Win9x\NT
; все вызовы ближние, но вот как объяснить инверсию засылки аргументов?!
; Сдаетесь?(Нет, не сдавайтесь, попытайтесь найти решение сами – иначе какой интерес?)
; Тогда загляните в и посмотрите, как там определен тип PASCAL
;
; #elif (_MSC_VER >= 800) || defined(_STDCALL_SUPPORTED)
; #define CALLBACK __stdcall
; #define WINAPI __stdcall
; #define WINAPIV __cdecl
; #define APIENTRY WINAPI
; #define APIPRIVATE __stdcall
; #define PASCAL __stdcall
;
; Нет, ну кто бы мог подумать!!! Вызов, объявленный как PASCAL, на самом деле
; представляет собой stdcall! И CALLBACK – так же определен, как stdcall.
; Наконец-то все объяснилось! Теперь, если вам скажут, что CALLBACK – это PASCAL
; вы можете усмехнуться и сказать, что еж тоже птица, правда гордая – пока не пнешь
; не полетит! (Оказывается, копания в дебрях include-файлов могут приносить пользу)
; Кстати, это извращения с перекрытием типов создают большую проблему при подключении
; к Си-проекту модулей, написанных в среде, поддерживающей PASACAL-соглашения о вызове
; функций. Поскольку в Windows PASCAL никакой не PASCAL, а stdcall – ничего работать
; соответственно не будет! Правда, есть еще ключевое слово __pascal, которое не
; перекрывается, но и не поддерживается последними версиями Microsoft Visual C++.
; Выход состоит в использовании ассемблерных вставок или переходе на Borland C++
; он, как и многие другие компиляторы, соглашение PASACAL до сих пор исправно
; поддерживает.
;
; Итак, мы выяснили, что аргументы CALLBACL-функциям передаются справа налево, но
; стек вычищает сама вызываемая функция, как и положено по stdcall соглашению.

push ebp
mov ebp, esp

mov eax, [ebp+arg_C]
; заносим в EAX аргумент dwTime.
; Как мы получили его? Смотрим – перед ним в стеке лежат три аргумента
; каждый из которых размеров в 4 байта, тогда 4*3=0xC

xor edx, edx
; Обнуляем EDX

mov ecx, 5
; Присваиваем ECX значение 5

div ecx
; Делим dwTime (он в EAX) на 5

shl edx, 4
; В EDX – остаток от деления, циклическим сдвигом умножаем его на 0x10
; точнее, умножаем его на 24

push edx ; uType
; Передаем полученный результат функции MessageBeep.
; Заглянув в SDK, мы найдем, что MessageBeep принимает одну из констант:
; NB_OK, MB_ICONASTERISK, MB_ICONHAND и т.д., но там ничего не сказано о том,
; какое непосредственное значение каждое из них принимает.
; Зато сообщается, что MessageBeep описана в файле
; Открываем его и ищем контекстным поиском MB_OK:
;
; #define MB_OK 0x00000000L
; #define MB_OKCANCEL 0x00000001L
; #define MB_ABORTRETRYIGNORE 0x00000002L
; #define MB_YESNOCANCEL 0x00000003L
; #define MB_YESNO 0x00000004L
; #define MB_RETRYCANCEL 0x00000005L
;
; #define MB_ICONHAND 0x00000010L
; #define MB_ICONQUESTION 0x00000020L
; #define MB_ICONEXCLAMATION 0x00000030L
; #define MB_ICONASTERISK 0x00000040L
;
; Есть хвост у Тигры! Смотрите: все, интересующее нас константы, равны:
; 0x0, 0x10, 0x20, 0x30, 0x40. Теперь становится понятным смысл программы
; Взяв остаток, полученный делением количества миллисекунд, прошедших с минуты
; включения системы на 5, мы получаем число в интервале от 0 до 4. Умножая его
; на 0x10, - 0x0, 0x0x10 – 0x40.

call ds:MessageBeep
; Бибикаем на все лады

mov eax, [ebp+arg_C]
; Заносим в EAX dwTime

xor edx, edx
; Обнуляем EDX

mov ecx, 3E8h
; В десятичном 0x3E8 равно 1000

div ecx
; Делим dwTime на 1000 – т.е. переводим миллисекунды в секунды и…

push eax
; …передаем его функции printf

push offset aD ; "\r:=%d"
call _printf
add esp, 8
; printf("\r:=%d")

pop ebp
retn 10h
; Выходя – гасите свет, т.е. чистите за собой стек!

TimerProc endp
Листинг 67
Важное замечание о типах, определенных в ! Хотя об этом уже говорилось в комментариях к предыдущему листингу, повторение не будет лишним, хотя бы уже потому, что не все читатели вчитываются в разборы дизассемблерных текстов.
Итак, CALLBACK и WINAPI функции следуют соглашению о вызовах PASCAL, но сам PASACAL определен в как stdcall (а на некоторых платформах и как cdecl). Таким образом, на платформе INTEL все Windows-функции следуют соглашению: аргументы заносятся справа налево, а стек вычищает вызываемая функция.

Давайте для знакомства в PASCAL-соглашением создадим простенькую PASCAL программу и дизассемблируем ее (это, не обозначает, что PASCAL-вызовы встречаются только в PASCAL-программах, но так будет справедливо):

USES WINCRT;

Procedure MyProc(a:Word; b:Byte; c:String);
begin
WriteLn(a+b,' ',c);
end;

BEGIN
MyProc($666,$77,'Hello,Sailor!');
END.
Листинг 68 Демонстрация PASCAL-вызова
Результат компиляции компилятором "Turbo Pascal for Windows" должен выглядеть так:

PROGRAM proc near
call INITTASK
; Вызов INITTASK из KRNL386.EXE для инициализации 16-разрядной задачи

call @__SystemInit$qv ; __SystemInit(void)
; Инициализация модуля SYSTEM

call @__WINCRTInit$qv ; __WINCRTInit(void)
; Инициализация модуля WinCRT

push bp
mov bp, sp
; Пролог функции в середине функции!
; Вот такой он, Turbo-PASCAL!

xor ax, ax
call @__StackCheck$q4Word ; Stack overflow check (AX)
; Проверка стека на переполнение

push 666h
; Обратите внимание – передача аргументов идет слева направо

push 77h ; 'w'
mov di, offset aHelloSailor ; "Hello,Sailor!"
; В DI – указатель на строку "Hello, Sailor"

push ds
push di
; Смотрите: передается не ближний (NEAR), а дальний (FAR) указатель –
; т.е. и сегмент, и смещение строки.

call MyProc
; Стек чистит вызываемая функция.

leave
; Эпилог функции – закрытие кадра стека.

xor ax, ax
call @Halt$q4Word ; Halt(Word)
; Конец программы!

PROGRAM endp

MyProc proc near ; CODE XREF: PROGRAM+23p
; IDA не определила прототип функции. Что ж, сделаем это сами!

var_100 = byte ptr -100h
; Локальная переменная. Судя по тому, что она находится на 0x100 байт выше кадра
; стека, сдается, что это массив их 0x100 байт. Поскольку, максимальная длина строки
; в PASACAL как раз и равна 0xFF байтам. Похоже, это буфер, зарезервированный под
; строку.

arg_0 = dword ptr 4
arg_4 = byte ptr 8
arg_6 = word ptr 0Ah
; Функция принимает три аргумента

push bp
mov bp, sp
; Открываем кадр стека

mov ax, 100h
call @__StackCheck$q4Word ; Stack overflow check (AX)
; Проверяем – если ли в стеке необходимые нам 100 байт для локальных переменных

sub sp, 100h
; Резервируем пространство под локальные переменные

les di, [bp+arg_0]
; получаем указатель на самый правый аргумент

push es
push di
; Смотрите – передаем дальний указатель на аргумент arg_0, причем его
; сегмент из стека даже не извлекался!

lea di, [bp+var_100]
; Получаем указатель на локальный буфер

push ss
; Заносим его сегмент в стек

push di
; Заносим смещение буфера в стек

push 0FFh
; Заносим макс. длину строки

call @$basg$qm6Stringt14Byte ; Store string
; Копируем строку в локальный буфер (значит, arg_0 – это строка).
; Правда, совершенно непонятно зачем. Неужто нельзя пользоваться ссылкой?
; Дурной-дурной этот Turbo-Pascal!
; Да что делать – в самом Паскале строки передаются по значению :-(

mov di, offset unk_1E18
; Получаем указатель на буфер вывода
; Тут надобно познакомимся с системой вывода Паскаля – она весьма разительно
; отличается от Си.
; Во-первых, левосторонний порядок засылки аргументов в стек не позволяет
; организовать поддержку процедур с переменным числом аргументов
; (во всяком случае, без дополнительных ухищрений)
; Но ведь WriteLn и есть процедура с переменным числом параметров. Разве нет?!
; Вот именно, что нет!!! Никакая это не процедура, а оператор!
; Компилятор еще на стадии компиляции разбивает ее на множество вызовов
; процедур для вывода каждого аргумента по отдельности. Поэтому,
; в откомпилированном коде каждая процедура примет фиксированное количество
; аргументов. В нашем случае их будет три: первая для вывода суммы двух
; чисел – этим занимается процедура WriteLongint, вторая – для вывода символа
; пробела в символьной форме – этим занимается WriteChar и, наконец, последняя
; для вывода строки – WriteSting
; Размышляем далее – под Windows непосредственно вывести строку в окно и тут же
; забыть о ней нельзя, т.к. окно в любой момент может потребовать перерисовки –
; операционная система не сохраняет его содержимого – в графической среде
; при высоком разрешении это привело бы к большим затратам памяти.
; Код, выводящий строку, должен уметь повторять свой вывод по запросу.
; Каждый, кто хоть раз программировал под Windows, наверняка помнит, что весь
; вывод приходилось помещать в обработчик сообщения WM_PAINT.
; Turbo Pascal же позволяет обращаться к Windows-окном точно так,
; как с консолью. А раз так – он должен где-то хранить все, ранее выведенное
; на экран. Поскольку, локальный переменные умирают вместе с завершением
; их процедуры, то для хранения буфера они не годятся. Остается либо куча, либо
; сегмент данных. Pascal использует последнее – указатель на такой буфер мы
; только что получили.
; Далее, для повышения производительности вывода Turbo-Pascal реализует
; простейший кэш. Функции WriteLingint, WriteChar, WriteString сливают
; результат своей деятельности в символьном виде в этом самый буфер, а в конце
; следует вызов WriteLn, выводящий содержимое буфера в окно.
; Run-time systems следит за его перерисовками и при необходимости повторяет
; вывод уже без участия программиста.

push ds
push di
; Заносим адрес буфера в стек

mov al, [bp+arg_4]
; Тип аргумента arg_4 - Byte

xor ah, ah
; Обнуляем старший байт регистра ah

add ax, [bp+arg_6]
; Складываем arg_4 с arg_6. Поскольку, al было предварительно расширено до AX
; то arg_6 имеет тип Word, т.к. при сложении двух чисел разного типа PASCAL
; расширяет их до большего из них.
; Кроме того, вызывающая процедура передает с этим аргументом значение 0x666,
; что явно не влезло бы в Byte.

xor dx, dx
; Обнуляем DX…

push dx
; …и заносим его в стек.

push ax
; Заносим в стек сумму двух левых аргументов

push 0
; Еще один ноль!

call @Write$qm4Text7Longint4Word ; Write(var f; v: Longint; width: Word)
; Функция WriteLongint имеет следующий прототип
; WriteLongint(Text far &, a: Longint, count:Word); где -
; Text far & - указатель на буфер вывода
; a - выводимое длинное целое
; count - сколько переменных выводить (ноль – одна переменная)
;
; Значит, в нашем случае мы выводим одну переменную – сумму двух аргументов.
; Маленькое дополнение – функция WriteLongint не следует соглашению PASCAL
; т.к. не до конца чистит за собой стек, оставляя указать на буфер в стеке.
; На этот шаг разработчики компилятора пошли для увеличения производительности:
; раз указатель на буфер будет нужен и другим функциям
;(по крайней мере одной из них – WriteLn), зачем его то стягивать, то опять
; лихорадочно запихивать?
; Если вы загляните в конец функции WriteLongint, вы обнаружите там RET 6,
; т.е. функция выпихивает два аргумента – два машинных слова на Longint и один
; Word на count.
; Вот такая милая маленькая техническая деталь. Маленькая-то она, маленькая,
; но как сбивает с толку!
; (особенно, если исследователь не знаком с системой ввода-вывода Паскаля)

push 20h ; ' '
; Заносим в стек следующий аргумент, передаваемый функции WriteLn
; (указатель на буфер все еще находится в стеке).

push 0
; Нам надо вывести только одни символ

call @Write$qm4Text4Char4Word ; Write(var f;c: Char; width:Word)

lea di, [bp+var_100]
; Получаем указатель на локальную копию переданной функции строки

push ss
push di
; Заносим ее адрес в стек

push 0
; Выводить только одну строку!

call @Write$qm4Textm6String4Word ; Write(var f; s: String; width: Word)

call @WriteLn$qm4Text ; WriteLn(var f: Text)
; Кажется, функции не передаются никакие параметры, но на самом деле на вершине
; стека лежит указатель на буфер и ждет своего "звездного часа"
; после завершения WriteLn он будет снят со стека

call @__IOCheck$qv ; Exit if error
; Проверка операции вывода на успешность

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

retn 8
; Выталкиваем восемь байт со стека. ОК, теперь мы знаем все необходимое для
; восстановления прототипа нашей процедуры. Он выглядит так:
; MyProc(a:Byte, b:Word, c:String);

MyProc endp
Листинг 69
Да, хитрым оказался Turbo-PASCAL! Анализ откомпилированной с его помощью программы преподнес нам один очень важный урок – никогда нельзя быть уверенным, что функция выталкивает все переданные ей аргументы из стека, и уж тем более нельзя определять количество аргументов по числу снимаемых из стека машинных слов!

::соглашения о быстрых вызовах – fastcall. Какой бы непроизводительной передача аргументов через стек ни была, а типы вызовы stdcall и cdecl стандартизированы и хочешь – не хочешь, а их надо соблюдать. Иначе, модули, скомпилированные один компилятором (например, библиотеки), окажутся не совместимы с модулями, скомпилированными другими компиляторами. Впрочем, если вызываемая функция компилируется тем же самым компилятором, что и вызывающая, - придерживаться типовых соглашений ни к чему и можно воспользоваться более эффективной передачей аргументов через регистры.
Многие начинающие программисты удивляются: а почему передача аргументов через регистры до сих пор не стандартизирована и вряд ли когда будет стандартизирована вообще? Ответ: кем бы она могла быть стандартизирована? Комитетами по стандартизации Си и Си++? Нет, конечно! – все платформенно – зависимые решения оставляются на откуп разработчикам компиляторов – каждый из них волен реализовывать их по-своему или не реализовывать вообще. "Хорошо, уговорили", - не согласится иной читатель, "но что мешает разработчикам компиляторов одной конкретной платформы договориться об общих соглашениях. Ведь договорились же они передавать возвращенное функцией значение через [E]AX:[[E]DX], хотя стандарт о конкретных регистрах вообще никакого понятия не имеет".
Ну, отчасти разработчики и договорись: большинство 16-разрядных компиляторов придерживалось общих соглашений (хотя об этом не сильно трубилось вслух), но без претензий на совместимость друг с другом. Быстрый вызов – он на то и называется быстрым, чтобы обеспечить максимальную производительность. Техника же оптимизации не стоит на месте и вводить стандарт – это все равно, что привязывать гирю к ноге. С другой стороны, средний выигрыш от передачи аргументов через регистры составляет единичные проценты, – вот многие разработчики компиляторов отказываются от быстроты в пользу простоты (реализации). К тому же, если так критична производительность – используйте встраиваемые функции.
Впрочем, все эти рассуждения интересны в первую очередь программистам, исследователей же программ волнует не эффективность, а восстановление прототипов функций. Можно ли узнать какие аргументы принимает fastcall - функция, не анализируя ее код (т.е. смотря только на вызывающую функцию). Чрезвычайно популярный ответ "Нет, это невозможно, поскольку компилятор передает аргументы в наиболее "удобных" регистрах" неправилен, и говорящий наглядно демонстрирует свое полное незнание техники компиляции.
Существует такой термин как "единица трансляции", - в зависимости от реализации компилятор может либо транслировать весь текст программы целиком (что весьма накладно, т.к. придется хранить в памяти все дерево синтаксического разбора), либо транслировать каждую функцию по отдельности, сохраняя в памяти лишь ее имя и ссылку на сгенерированный для нее код. Компиляторы первого типа крайне редки, во всяком случае для ОС Windows я не встречал ни одного такого Си\Cи++ компилятора (хотя и слышал о таких). Компиляторы второго типа более производительны, требуют меньше памяти, проще в реализации, словом, всем хороши, за исключением органической неспособности к "сквозной" оптимизации, - каждая функция оптимизируется "персонально" и независимо от другой. Поэтому, подобрать оптимальные регистры для передачи аргументов компилятор не может, поскольку он не знает, как с ними манипулирует вызываемая функция. Поскольку, функции транслируются независимо, им приходится придерживаться общих соглашений, даже если это и невыгодно.
Таким образом, зная "почерк" конкретного компилятора, восстановить прототип функции можно без труда.

::Borland C++ 3.x – передача аргументов осуществляется через регистры: AX (AL), DX (DL), BX (BL), а, когда регистры кончаются, аргументы начинают засылаться в стек, заносясь в него слева направо и выталкиваясь самой вызываемой функцией (a la stdcall).
Схема передачи аргументов довольно интересна – компилятор не закрепляет за каждым аргументом "своих" регистров, вместо этого он предоставляет свободный доступ каждому из них к "стопке" кандидатов, уложенных в порядке предпочтения. Каждый аргумент снимает со стопки столько регистров, сколько ему нужно, а когда стопка исчерпается – тогда придется отправляться в стек. Исключение составляет тип long int, всегда передаваемый через DX:AX (причем, в DX передается старшее слово), а если это невозможно – то через стек.
Если каждый аргумент занимает не более 16-ти бит (как обычно и происходит), то первый слева аргумент помещается в AX (AL), второй – в DX (DL), третий – в BX (BL). Если же первый слева аргумент представляет тип long int, он снимает со стопки сразу два регистра – DX:AX, тогда второму аргументу остается регистр BX (BL), а третьему – и вовсе ничего (и тогда он передается через стек). Когда же long int передается вторым аргументом, он отправляется в стек, т.к. необходимый ему регистр AX уже занят первым аргументом, третий же аргумент передается через DX. Наконец, будучи третьим слева аргументом, long int идет в стек, а первые два аргумента передаются через AX (AL) и DX (DL) соответственно.
Передача дальних указателей и вещественных значений всегда осуществляется через основной стек (а не стек сопроцессора, как иногда приходится слышать, и как подсказывает здравый смысл).

тип
предпочтения




char
AL
DL
BL
int
AX
DX
BX
long int
DX:AX

ближний указатель
AX
DX
BX
дальний указатель
stack

float
stack

double
stack

Таблица 2 Порядок предпочтений Borland C++ 3.x при передаче аргументов по соглашению fastcall

::Microsoft C++ 6.0 – ведет себя аналогично компилятору Borland C++ 3.x за исключением того, что изменяет порядок предпочтений кандидатов для передачи указателей, выдвигая на первое место BX. И это – правильно, ибо ранние микропроцессоры 80x86 не поддерживали косвенную адресацию ни через AX, ни через DX и переданное функции значение все равно приходилось перепихивать либо в BX, либо в SI или DI.

тип
предпочтения




char
AL
DL
BL
int
AX
DX
BX
long int
DX:AX

ближний указатель
BX
AX
DX
дальний указатель
stack

float
stack

double
stack

Таблица 3 Порядок предпочтений Microsoft C++ 6.x при передаче аргументов по соглашению fastcall

::Borland C++ 5.x – очень похож на своего предшественника – компилятор Borland C++ 3.x, за исключением того, что вместо регистра BX отдает предпочтение регистру CX, и аргументы типа int и long int помещает в любой из подходящих 32-разрядных регистров, а не DX:AX. Как, впрочем, и следовало ожидать при переводе компилятора с 16- на 32-разрядный режим.

тип
предпочтения




char
AL
DL
CL
int
EAX
EDX
ECX
long int
EAX
EDX
ECX
ближний указатель
EAX
EDX
ECX
дальний указатель
stack

float
stack

double
stack

Таблица 4 Порядок предпочтений Borland C++ 5.x при передаче аргументов по соглашению fastcall

::Microsoft Visual C++ 4.x – 6.x: при возможности передает первый слева аргумент в регистре ECX, второй – в регистре EDX, а все остальные через стек. Вещественные значения и дальние указатели всегда передаются через стек. Аргумент типа __int64 (нестандартный тип, 64-разрядное целое, введенный Microsoft) всегда передается через стек.
Если __int64 – первый слева аргумент, то второй аргумент передается через ECX, а третий – через EDX. Соответственно, если __int64 – второй аргумент, то первый передается через ECX, а третий – через EDX.

тип
предпочтения




char
CL
DL
--
int
ECX
EDX
--
__int64
stack

long int
ECX

--
ближний указатель
ECX
EDX
--
дальний указатель
stack

--
float
stack

--
double
stack

--

Таблица 5 Порядок предпочтений Microsoft Visual C++ 4.x – 6.x при передаче аргументов по соглашению fastcall

::WATCOM C. Компилятор от WATCOM сильно отличается от компиляторов от Borland и Microsoft. В частности, он не поддерживает ключевого слова fastcall (что, кстати, приводит к серьезным проблемам совместимости), но по умолчанию всегда стремиться передавать аргументы через регистры. Вместо общепринятой "стопки предпочтений" WATCOM жестко закрепляет за каждым аргументом свой регистр: за первым - EAX, за вторым - EDX, за третьим -EBX, за четвертым – ECX, причем, если какой-то аргумент в указанный регистр поместить не удается, он и все остальные аргументы, находящиеся правее него, помещаются в стек! В частности, типы float и double по умолчанию помещаются в стек основного процессора, что "портит всю малину"!

тип
аргумент





char
AL
DL
BL
CL
int
EAX
EDX
EBX
ECX
long int
EAX
EDX
EBX
ECX
ближний указатель
ECX
EDX
EBX
ECX
дальний указатель
stack
stack
stack
stack
float
stack CPU
stack CPU
stack CPU
stack CPU

stack FPU
stack FPU
stack FPU
stack FPU
double
stack CPU
stack CPU
stack CPU
stack CPU

stack FPU
stack FPU
stack FPU
stack FPU

Таблица 6 Схема передачи аргументов компилятором WATCOM по умолчанию

При желании программист может "вручную" задать собственный порядок передачи аргументом, прибегнув к прагме aux, имеющий следующий формат: "#pragma aux имя функции parm [перечь регистров]". Список допустимых регистров для каждого типа аргументов приведен в следующей таблице:

тип
допустимые регистры
char
EAX
EBX
ECX
EDX
ESI
EDI
int
EAX
EBX
ECX
EDX
ESI
EDI
long int
EAX
EBX
ECX
EDX
ESI
EDI
ближний указатель
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

Таблица 7 Допустимые регистры для передачи различных типов аргументов в WATCOM C

Несколько пояснений – во-первых, аргументы типа char передаются не в 8-, а в 32- разрядных регистрах, во-вторых, бросается в глаза неожиданно больше число возможных пар регистров для передачи дальнего указателя, причем сегмент может передаваться не только в сегментных регистрах, но и 16-разрядных регистрах общего назначения.
Вещественные аргументы могут передаваться через стек сопроцессора – для этого достаточно лишь указать '8087' вместо названия регистра и обязательно скомпилировать программу с ключом –7 (или –fpi, -fpu87), показывая компилятору, что инструкции сопроцессора разрешены. В документации по WATCOM сообщается, что аргументы типа double могут так же передаваться и через пары 32-разрядных регистров общего назначения, но мне, увы, не удалось заставить компилятор генерировать такой код. Может быть, я плохо знаю WATCOM или глюк какой. Так же, мне не встречалось ни одной программы, в которой вещественные значения передавались бы через регистры общего назначения. Впрочем, это уже никому не нужные тонкости. (Подробнее о передаче вещественных аргументов рассказывается в одноименном разделе данной главы).
Таким образом, при исследовании программ, откомпилированных компилятором WATCOM, необходимо быть готовыми к тому, что аргументы могут передаваться практически в любых регистрах, какие заблагорассудится программисту.

::идентификация передачи и приема регистров. Поскольку, вызываемая и вызывающая функция вынуждены придерживаться общих соглашений при передаче аргументов через регистры, компилятору приходится помещать аргументы в те регистры, в каких их ожидает вызываемая функция, а не в какие ему "удобно". В результате, перед каждой функций, следующей соглашению fastcall, появляется код, "тасующий" содержимое регистров строго определенным образом. Каким – это уже зависит от конкретного компилятора. Наиболее популярные схемы передачи аргументов уже были рассмотрены выше, не будем здесь возвращаться к этому вопросу. Если же "ваш" компилятор отсутствует в списке (что вполне вероятно, - компиляторы сейчас растут как грибы после дождя), попробуйте установить его "характер" экспериментальным путем самостоятельно или загляните в документацию. Вообще-то разработчики за редкими исключениями не раскрывают подобных тонкостей (причем даже не из-за желания утаить это в тайне, просто если документировать каждый байт компилятора, полный комплект документации не поместится и на поезд), но быть может вам повезет. Если же нет, - не беда! (см. "Техника исследования характера передачи аргументов компилятором")
Анализом кода вызывающей функции не всегда можно распознать передачу аргументов через регистры (ну, разве что их инициализация будет слишком наглядна), поэтому, приходится обращаться непосредственно к вызываемой функции. Регистры, сохраняемые в стеке сразу после получения управления функцией, в подавляющем большинстве случаев не являются регистрами, передающими аргументы и из списка "кандидатов" их можно вычеркнуть. Среди оставшихся смотрим – есть ли такие, содержимое которых используется без явной инициализации. В первом приближении через эти регистры функция и принимает аргументы. При детальном же рассмотрении проблемы всплывает несколько оговорок. Во-первых, через регистры могут передаваться (и очень часто передаются) неявные аргументы функции – указатель this, указатели на виртуальные таблицы объекта и т.д. Во-вторых, если криворукий программист, надеясь, что значение переменной после объявления должно быть равно нулю, забывает об инициализации, а компилятор помещает ее в регистр, то при анализе программы она может быть принята за аргумент функции, передаваемый через регистр. Самое интересное: что этот регистр может по случайному стечению обстоятельств явно инициализироваться вызывающей функций. Пусть, например, программист перед этим вызывал некоторую функцию, возвращаемого значения которой (помещаемого компилятором в EAX) не использовал, а компилятор поместил неинициализированную переменную в EAX. Причем, если функция при своем нормальном завершении возвращает ноль (как часто и бывает) все может работать… Чтобы вывить такого жука, исследователю придется проанализировать алгоритм – действительно ли в EAX помещается код успешности завершения функции или же имеет место "наложение" переменных?
Впрочем, если откинуть "клинические" случаи, в передаче аргументов через регистры не сильно усложняет анализ, в чем мы сейчас и убедимся.

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

#include
#include

#if defined(__BORLANDC__) || defined (_MSC_VER)
// Эта ветка компилируется только компиляторами Borland C++ и Microsoft C++,
// поддерживающими ключевое слово fastcall
__fastcall
#endif

// Функция MyFunc с различными типами аргументов для демонстрации механизма
// их передачи
MyFunc(char a, int b, long int c, int d)
{

#if defined(__WATCOMC__)
// А эта ветка специально предназначена для WATCOM C.
// прагма aux принудительно задает порядок передачи аргументов через
// следующие регистры: EAX ESI EDI EBX
#pragma aux MyFunc parm [EAX] [ESI] [EDI] [EBX];
#endif
return a+b+c+d;
}

main()
{

printf("%x\n",MyFunc(0x1,0x2,0x3,0x4));
return 0;
}
Листинг 70
Результат компиляции этого примера компилятором Microsoft Visual C++ 6.0 должен выглядеть так:

main proc near ; CODE XREF: start+AFp
push ebp
mov ebp, esp

push 4
push 3
; аргументы, которым не хватило регистров, передаются через стек, заносясь
; туда справа налево и вычищает их оттуда вызываемая функция
; (т.е. все происходит как по stdcall соглашению)

mov edx, 2
; Через EDX передается второй слева аргумент.
; Легко определить его тип – это int.
; Т.е. это явно не char, но и не указатель (2-странное значение для указателя)

mov cl, 1
; Через cl передается первый слева аргумент типа char
;(лишь у переменных типа char размер 8 бит)
;

call MyFunc
; Уже можно восстановить прототип функции MyFunc(char, int, int, int)
; Да, мы ошиблись и тип long int приняли за int, но, поскольку в компиляторе
; Microsoft Visual C++ эти типы идентичны, такой ошибкой можно пренебречь

push eax
; Передаем полученный результат функции printf

push offset asc_406030 ; "%x\n"
call _printf
add esp, 8

xor eax, eax
pop ebp
retn
main endp

MyFunc proc near ; CODE XREF: main+Ep

var_8 = dword ptr -8
var_4 = byte ptr –4

arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch
; Через стек функции передавались лишь два аргумента и их успешно распознала IDA

push ebp
mov ebp, esp
sub esp, 8
; Резервируем 8 байт для локальных переменных

mov [ebp+var_8], edx
; Регистр EDX не был явно инициализирован до того загрузки в
; локальную переменную var_8. Значит, он используется для передачи аргументов!
; Поскольку эта программа была скомпилирована компилятором Microsoft Visual C,
; а он, как известно, передает аргументы в регистрах ECX:EDX можно сделать
; вывод, что мы имеем дело со вторым, считая слева, аргументом функции
; и где-то ниже по тексту нам должно встретиться обращение к ECX – первому
; слева аргументу функции.
; (хотя не обязательно – первый аргумент функцией может и не использоваться)

mov [ebp+var_4], cl
; Действительно, обращение к CL не заставило должно себя ждать. Поскольку,
; через CL передается тип char, то, вероятно, первый аргумент функции – char.
; Некоторая неуверенность вызвана тем, что функция может просто обращаться
; к младшему байту аргумента типа int, скажем.
; Однако, посмотрев на код вызывающей функции, мы можем убедиться, что
; функции передается именно char, а не int.
; Попутно отметим глупость компилятора – стоило ли передавать аргументы через
; регистры, чтобы тут же заслать их в локальные переменные!
; Ведь обращение к памяти сжирает всю выгоду от быстрого вызова!
; Такой "быстрый" вызов быстрым даже язык не поворачивается назвать.

movsx eax, [ebp+var_4]
; В EAX загружается первый слева аргумент, переданный через CL, типа char
; со знаковым расширением до двойного слова. Значит, это signed char
; (т.е. char по умолчанию для Microsoft Visual C++)

add eax, [ebp+var_8]
; Складываем EAX со вторым слева аргументом

add eax, [ebp+arg_0]
; Складываем результат предыдущего сложения с третьим слева аргументом,
; переданным через стек…

add eax, [ebp+arg_4]
; …и все это складываем с четвертым аргументом, так же переданным через стек.

mov esp, ebp
pop ebp
; Закрываем кадр стека

retn 8
; Чистим за собой стек, как и положено по fastcall соглашению

MyFunc endp

Листинг 71
А теперь сравним это с результатом компиляции Borland C++:

; 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

push ebp
mov ebp, esp

push 4
; Передаем аргумент через стек. Скосив глаза вниз, мы обнаруживаем явную
; инициализацию регистров ECX, EDX, AL. Для четвертого аргумента регистров
; не хватило и его пришлось передавать через стек. Значит, четвертый слева
; аргумент функции – 0x4

mov ecx, 3
mov edx, 2
mov al, 1
; Этот код не может быть ничем иным, как передачей аргументов через регистры

call MyFunc

push eax
push offset unk_407074 ; format
call _printf
add esp, 8

xor eax, eax

pop ebp
retn
_main endp

MyFunc proc near ; CODE XREF: _main+11p

arg_0 = dword ptr 8
; через стек функции передавался лишь один аргумент

push ebp
mov ebp, esp
; Открываем кадр стека

movsx eax, al
; Borland сгенерировал более оптимальный код, чем Microsoft, не помещая
; регистр в локальную переменную и экономя тем самым память. Впрочем, если бы
; был задан соответствующий ключ оптимизации, Microsoft Visual C++ поступил
; точно так же.
; Обратите внимание еще и на то, что Borland обрабатывает аргументы
; в выражениях слева направо в порядке их перечисления в прототипе функции,
; в то время как Microsoft Visual C++ поступает наоборот.

add edx, eax
add ecx, edx
; Регистры EDX и CX не были инициализированы, значит, в них функции были
; переданы аргументы.

mov edx, [ebp+arg_0]
; Загружаем в EDX последний аргумент функции, переданный через стек…

add ecx, edx
; …складываем еще раз

mov eax, ecx
; Передаем в EAX (в EAX функция возвращает результат своего завершения)

pop ebp
retn 4
; Вычищаем за собой стек

MyFunc endp
Листинг 72
Наконец, результат компиляции WATCOM C должен выглядеть так:

main_ proc near ; CODE XREF: __CMain+40p
push 18h
call __CHK
; Проверка стека на переполнение

push ebx
push esi
push edi
; Сохраняем регистры в стеке

mov ebx, 4
mov edi, 3
mov esi, 2
mov eax, 1
; Смотрите, аргументы передаются через те аргументы, которые мы указали!
; Более того, отметьте, что первый аргумент типа char передается через
; 32-разрядный регистр EAX! Такое поведение WATCOM-а чрезвычайно
; затрудняет восстановление прототипов функций! В данном случае присвоение
; регистрам значений происходит согласно порядку объявления аргументов
; в прототипе функции, считая справа. Но так, увы, бывает далеко не всегда.

call MyFunc

push eax
push offset unk_420004
call printf_

add esp, 8
xor eax, eax
pop edi
pop esi
pop ebx
retn
main_ endp

MyFunc proc near ; CODE XREF: main_+21p
; Функция не принимает через стек ни одного аргумента

push 4
call __CHK

and eax, 0FFh
; Обнуление старших двадцати четырех бит вкупе с обращением к регистру
; до его инициализации наводит на мысль, что через EAX передается тип char
; какой это аргумент мы сказать не можем, увы...

add esi, eax
; Регистр ESI не был инициализирован нашей функцией, следовательно, через
; него передается аргумент типа int. Можно предположить, что это – второй
; слева аргумент в прототипе функции, т.к. (если ничто не препятствует),
; регистры в вызывающей функции инициализируются согласно их порядку
; перечисления в прототипе, считая справа, а выражения вычисляются
; слева направо.
; Разумеется, подлинный порядок следования аргументов некритичен, но
; все-таки приятно, если удается его восстановить

lea eax, [esi+edi]
; Опаньки, выдерем Тигре хвост с корнем! Вы думаете, что в EAX загружается
; указатель? А ESI и EDI переданные функции – так же указатели? EAX с его
; типом char становится очень похожим на индекс...
; Увы! Компилятор WATCOM слишком хитер и при анализе программ,
; скомпилированных с его помощью, очень легко впасть в грубые ошибки.
; Да, EAX это указатель, в том смысле, что LEA используется для вычисления
; суммы ESI и EDI, но обращения к памяти по этому указателю не происходит
; ни в вызывающей, ни в вызываемой функции. Следовательно, аргументы функции
; не указатели, а константы!

add eax, ebx
; Аналогично – EDX содержит в себе аргумент, переданный функции.
; Итак, прототип функции должен быть выглядеть так:
; MyFunc(char a, int b, int c, int d)
; Однако, порядок следования аргументов может быть и иным...

retn
MyFunc endp
Листинг 73
Как мы видим, в передаче аргументов через регистры ничего особенного сложно нет, можно даже восстановить подлинный прототип вызываемой функции. Однако ситуация, рассмотренная выше, достаточно идеализирована, и в реальных программах передача одних лишь непосредственных значений встречается редко. Давайте же теперь, освоившись с быстрыми вызовами, дизассемблируем более трудный пример:

#if defined(__BORLANDC__) || defined (_MSC_VER)
__fastcall
#endif
MyFunc(char a, int *b, int c)
{
#if defined(__WATCOMC__)
#pragma aux MyFunc parm [EAX] [EBX] [ECX];
#endif
return a+b[0]+c;
}

main()
{
int a=2;
printf("%x\n",MyFunc(strlen("1"),&a,strlen("333")));
}
Листинг 74 Трудный пример с fastcall
Результат компиляции Microsoft Visual C++ должен выглядеть так:

main proc near ; CODE XREF: start+AFp

var_4 = dword ptr -4

push ebp
mov ebp, esp
; Открываем кадр стека

push ecx
push esi
; Сохраняем регистры в стеке

mov [ebp+var_4], 2
; Присваиваем локальной переменной var_4 типа int значение 2.
; Тип определяется на основе того, что переменная занимает 4 байта
; (подробнее см. "Идентификация локальных стековых переменных")

push offset a333 ; const char *
; Передаем функции strlen указатель на строку "333".
; Аргументы функции MyFunc как и положено передаются справа налево

call _strlen
add esp, 4

push eax
; Здесь – либо мы сохраняем возвращенное функцией значение в стеке,
; либо передаем его следующей функции.

lea esi, [ebp+var_4]
; В ESI заносим указатель на локальную переменную var_4

push offset a1 ; const char *
; Передаем функции strlen указатель на строку "1"

call _strlen
add esp, 4

mov cl, al
; Возвращенное значение копируется в регистр CL, а ниже инициализируется EDX.
; Поскольку, ECX:EDX используются для передачи аргументов fastcall-функциям,
; инициализация этих двух регистров перед вызовом функции явно не случайна!
; Можно предположить, что через CL передается крайний левый аргумент типа char

mov edx, esi
; В ESI содержится указатель на var_4, следовательно, второй аргумент функции,
; типа int, заносимый в EDX, передается по ссылке.

call MyFunc
; Предварительный прототип функции выглядит так:
; MyFunc(char *a, int *b, inc c)
; Откуда взялся аргумент с? А помните, выше в стек был затолкнут EAX и
; ни до вызова функции, ни после так и не вытолкнут? Впрочем, чтобы
; убедится в этом окончательно, требуется посмотреть сколько байт со стека
; снимает вызываемая функция
; Обратите так же внимание и на то, что значения, возвращенные функцией strlen,
; не заносилось в локальные переменные, а передавались непосредственно MyFunc.
; Это наводит на мысль, что исходный код программы выглядел так:
; MyFunc(strlen("1"),&var_4,strlen("333"));
; Хотя, впрочем, не факт, - компилятор мог оптимизировать код, выкинув
; локальную переменную, если она нигде более не используется. Однако,
; во-первых, судя по коду вызываемой функции компилятор работает без
; оптимизации, а во-вторых, если значения, возвращенные функциями strlen,
; используются один единственный раз в качестве аргумента MyFunc, то помещение
; их в локальную переменную – большая глупость, только затуманивающая суть
; программы. Тем более, что исследователю важно не восстановить подлинный
; исходный код, а понять его алгоритм.

push eax
push offset asc_406038 ; "%x\n"
call _printf
add esp, 8

pop esi

mov esp, ebp
pop ebp
; Закрываем кадр стека

retn
main endp

MyFunc proc near ; CODE XREF: main+2Ep

var_8 = dword ptr -8
var_4 = byte ptr -4
arg_0 = dword ptr 8
; Функция принимает один аргумент – значит, это и есть тот EAX, занесенный в стек

push ebp
mov ebp, esp
; Открываем кадр стека

sub esp, 8
; Резервируем восемь байт под локальные переменные

mov [ebp+var_8], edx
; Поскольку, EDX используется без явной инициализации, очевидно,
; через него передается второй слева аргумент функции.
; (согласно соглашению fastcall компилятора Microsoft Visual C++)
; Из анализа кода вызывающей функции мы уже знаем,
; что в EDX помещается указатель на var_4, следовательно,
; var_8 теперь содержит указатель на var_4.

mov [ebp+var_4], cl
; Через CL передается самый левый аргумент функции типа char и тут же
; заносится в локальную переменную var_4.

movsx eax, [ebp+var_4]
; Переменная var_4 расширяется до signed int.

mov ecx, [ebp+var_8]
; В регистр ECX загружается содержимое указателя var_8, переданного через EDX.
; Действительно, как мы помним, через EDX функции передавался указатель.

add eax, [ecx]
; Складываем EAX (хранит первый слева аргумент функции) с содержимым
; ячейки памяти, на которую указывает указатель ECX (второй слева аргумент).

add eax, [ebp+arg_0]
; А вот и обращение к тому аргументу функции, что был передан через стек

mov esp, ebp
pop ebp
; Закрываем кадр стека

retn 4
; Функции был передан 1 аргумент через стек

MyFunc endp
Листинг 75
Просто? Просто! Тогда рассмотрим результат творчества Borland C++, который должен выглядеть так:

; int __cdecl main(int argc,const char **argv,const char *envp)
_main proc near ; DATA XREF: DATA:00407044o

var_4 = dword ptr -4
argc = dword ptr 8
argv = dword ptr 0Ch
envp = dword ptr 10h

push ebp
mov ebp, esp
; Открываем кадр стека

push ecx
; Сохраняем ECX... Постойте! Это что-то новое! В прошлых примерах Borland
; никогда не сохранял ECX при входе в функцию. Очень похоже, что через ECX
; функции был передан какой-то аргумент, и теперь она передает его другой
; функции через стек.
; Увы, каким бы убедительным такое решение ни выглядело оно неверно!
; Компилятор просто резервирует под локальные переменные четыре байта. Почему?
; Из чего это следует? Смотрите: IDA распознала одну локальную переменную var_4
; но память под нее явно не резервировалась, во всяком случае команды SUB ESP,4
; не было. Постой-ка, постой, но ведь PUSH ECX как раз и приводит к уменьшению
; регистра ESP на четыре! Ох, уж эта оптимизация!

mov [ebp+var_4], 2
; Заносим в локальную переменную значение 2

push offset a333 ; s
; Передаем функции strlen указатель на строку "333"

call _strlen
pop ecx
; Выталкиваем аргумент из стека

push eax
; Здесь – либо мы передаем возращенное функцией strlen значение следующей
; функции как стековый аргумент, либо временно сохраняем EAX в стеке
; (позже выяснится, что справедливо последнее предположение)

push offset a1 ; s
; Передаем функции strlen указатель на строку "1"

call _strlen
pop ecx
; Выталкиваем аргумент из стека

lea edx, [ebp+var_4]
; Загружаем в EDX смещение локальной переменной var_4

pop ecx
; Что-то выталкиваем из стека, но что именно? Прокручивая экран
; дизассемблера вверх, находим, что последним в стек заносился EAX,
; содержащий значение, возвращенное функцией strlen("333").
; Теперь оно помещается в регистр ECX
; (как мы помним, Borland передает через него второй слева аргумент)
; Попутно отметим для любителей fastcall-а: не всегда он приводит к одидаемому
; ускорению вызова, - у Intel 80x86 слишком мало регистров и их то и дело
; приходится сохранять в стеке.
; Передача аргумента через стек потребовала бы всего одного обращения: PUSH EAX
; здесь же мы наблюдаем два – PUSH EAX и POP ECX!

call MyFunc
; При восстановлении прототипа функции не забудьте о регистре EAX, - он
; не инициализируется явно, но хранит значение, возращенное последним вызовом
; strlen. Поскольку, компилятор Borland C++ 5.x использует следующий список
; предпочтений: EAX, EDX, ECX можно сделать вывод, что в EAX передается первый
; слева аргумент функции, а два остальных в EDX и ECX соответственно.
; Обратите внимание и на то, что Borland C++, в отличие от Microsoft Visual C++
; обрабатывает аргументы не в порядке их перечисления, а сначала вычисляет
; значение всех функций, "выдергивая" их справа налево, и только
; потом переходит к переменным и константам.
; И в этом есть свой здравый смысл – функции
; изменяют значение многих регистров общего назначения и, до тех пор пока не
; будет вызвана последняя функция, нельзя приступать к передаче аргументов
; через регистры.

push eax
push offset asc_407074 ; format
call _printf
add esp, 8

xor eax, eax
; Возвращаем нулевое значение

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

retn
_main endp

MyFunc proc near ; CODE XREF: _main+26p
push ebp
mov ebp, esp
; Открываем кадр стека

movsx eax, al
; Расширяем EAX до знакового двойного слова

mov edx, [edx]
; Загружаем в EDX содержимое ячейки памяти, на которую указывает указатель EDX

add eax, edx
; Складываем первый аргумент функции с переменной типа int, переданной
; вторым аргументом по ссылке

add ecx, eax
; Складываем третий аргумент типа int с результатом предыдущего сложения

mov eax, ecx
; Помещаем результат обратно в EAX
; Глупый компилятор, не проще ли было переставить местами аргументы предыдущей
; команды?

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

retn
MyFunc endp

Листинг 76
А теперь рассмотрим результат компиляции того же примера компилятором WATCOM C, у которого всегда есть чему поучиться:

main_ proc near ; CODE XREF: __CMain+40p

var_C = dword ptr -0Ch
; Локальная переменная

push 18h
call __CHK
; Проверка стека на переполнение

push ebx
push ecx
; Сохранение модифицируемых регистров
; Или – быть может, резервирование памяти под локальные переменные?

sub esp, 4
; Вот это уж точно явное резервирование памяти под одну локальную переменную,
; следовательно, две команды PUSH, находящиеся выше, действительно сохраняют
; регистры.

mov [esp+0Ch+var_C], 2
; Занесение в локальную переменную значения 2

mov eax, offset a333 ; "333"
call strlen_
; Обратите внимание – WATCOM передает функции strlen указатель на строку
; через регистр!

mov ecx, eax
; Возращенное функцией значение копируется в регистр ECX.
; WATCOM знает, что следующий вызов strlen не портит этот регистр!

mov eax, offset a1 ; "1"
call strlen_

and eax, 0FFh
; Поскольку strlen возвращает тип int, здесь имеет место явное преобразование
; типов: int -> char

mov ebx, esp
; В EBX заносится указатель на переменную var_C

call MyFunc
; Какие же аргументы передавались функции? Во-первых, EAX – вероятно крайний
; левый аргумент, во-вторых, EBX – явно инициализированный перед вызовом
; функции, и, вполне возможно, ECX, хотя последнее и не обязательно.
; ECX может содержать и регистровую переменную, но в таком случае вызываемая
; функция не должна к нему обращаться.

push eax
push offset asc_42000A ; "%x\n"

call printf_

add esp, 8
add esp, 4
; А еще говорят, что WATCOM – оптимизирующий компилятор! А вот две команды
; объединить в одну, он увы не смог!

pop ecx
pop ebx

retn
main_ endp

MyFunc proc near ; CODE XREF: main_+33p
push 4
call __CHK
; Проверка стека

and eax, 0FFh
; Повторное обнуление 24-старших бит. WATCOM-у следовало бы определиться:
; где выполнять эту операцию – в вызываемой или вызывающей функции, но зато
; подобный "дублеж" упрощает восстановление прототипов функций

add eax, [ebx]
; Складываем EAX типа char и теперь расширенное до int с переменной типа int
; переданной по ссылке через регистр EBX

add eax, ecx
; Ага, вот оно обращение к ECX, - следовательно, этот регистр использовался
; для передачи аргументов

retn
; Таким образом, прототип функции должен выглядеть так:
; MyFunc(char EAX, int *EBX, int ECX)
; Обратите внимание, что восстановить его удалось лишь совместным анализом
; вызываемой и вызывающей функций!

MyFunc endp
Листинг 77

::передача вещественных значений. Кодоломатели в своей массе не очень-то разбираются в вещественной арифметике, избегая ее как огня. Между тем, в ней нет ничего сверхсложного и освоить управление сопроцессором можно буквально за полтора-два дня. Правда, с математическими библиотеками, поддерживающими вычисления с плавающей запятой, справиться намного труднее, (особенно если IDA не распознает имен их функций), но какой компилятор сегодня пользуется библиотеками? Микропроцессор и сопроцессор монтируются на одном кристалле, и сопроцессор, начиная с 80486DX (если мне не изменяет память), доступен всегда, поэтому, прибегать к его программной эмуляции нет никакой нужды.
До конца девяностых среди хакеров бытовало мнение, что можно всю жизнь прожить, но так и не столкнуться с вещественной арифметикой. Действительно, в старые добрые времена процессоры в своей медлительности ни в чем не уступали черепахам, сопроцессоры имелись не у всех, а задачи, стоящие перед компьютерами, допускали (не без ухищрений, правда) решения и в целочисленной арифметике.
Сегодня все кардинально изменилось. Вычисления с плавающей точкой, выполняемые сопроцессором параллельно с работой основной программы, даже быстрее целочисленных вычислений, обсчитываемых основным процессором. И программисты, окрыленные такой перспективой, стали лепить вещественные типы данных даже там, где раньше с лихвой хватало целочисленных. (Например, если a=b/c*100, то, изменив порядок вычислений a=b*100/c, мы можем обойтись и типами int). Современным исследователям программ без значения команд сопроцессора очень трудно обойтись.
Сопроцессоры 80x87 поддерживают три вещественных типа данных: короткий 32-битный, длинный 64-битный и расширенный 80-битный, соответствующие следующим типам языка Си: float, double и long double. {>>> сноска Внимание: Стандарт ANSI С не оговаривает точного представления указанных выше типов и это утверждение справедливо только для платформы PC, да и то не для всех реализаций}

тип
размер
диапазон значений
предпочтительные типы передачи
float
4 байта
10-38...10+38
регистры CPU, стек CPU, стек FPU
double
8 байт
10-308...10+308
регистры CPU, стек CPU, стек FPU
long double
10 байт
10-4932...10+4932
стек CPU, стек FPU
real3
6 байт
2.9*10-39...1.7*10+38
регистры CPU, стек CPU, стек FPU
Таблица 8 Основная информация о вещественных типах сопроцессоров 80x87

Аргументы типа float и double могут быть переданы функции тремя различными способами: через регистры общего назначения основного процессора, через стек основного процессора и через стек сопроцессора. Аргументы типа long double потребовали бы для своей передачи слишком много регистров общего назначения, поэтому, в подавляющем большинстве случаев они заталкиваются в стек основного процессора или сопроцессора.
Первые два способа передачи нам уже знакомы, а вот третий – это что-то новенькое! Сопроцессор 80x87 имеет восемь восьмидесятибитных регистров, обозначаемых ST(0), ST(1), ST(2), ST(3), ST(4), ST(5), ST(6) и ST(7), организованных в форме кольцевого стека. Это обозначает, что большинство команд сопроцессора не оперируют номерами регистров, а в качестве приемника (источника) используют вершину стека. Например, чтобы сложить два вещественных числа сначала необходимо затолкнуть их в стек сопроцессора, а затем вызывать команду сложения, суммирующую два числа, лежащих на вершине стека, и возвращающую результат свой работы опять-таки через стек. Существует возможность сложить число, лежащее в стеке сопроцессора с числом, находящимся в оперативной памяти, но непосредственно сложить два числа из оперативной памяти невозможно!
Таким образом, первый этап операций с вещественными типами – запихивание их в стек сопроцессора. Эта операция осуществляется командами из серии FLDxx, перечисленных с краткими пояснениями в таблице 9. В подавляющем большинстве случаев используется инструкция "FLD источник", заталкивающая в стек сопроцессора вещественное число из оперативной памяти или регистра сопроцессора. Строго говоря, это не одна команда, а четыре команды в одной упаковке с опкодами 0xD9 0x0?, 0xDD 0x0?, 0xDB 0x0? и 0xD9 0xCi, для загрузки короткого, длинного, расширенного типов и регистра FPU соответственно, где ? – адресное поле, уточняющие в регистре или в памяти находится операнд, а 'i' – индекс регистра FPU.
Отсутствие возможности загрузки вещественных чисел из регистров CPU, обессмысливает их использование для передачи аргументов типа float, double или long double. Все равно, чтобы затолкать эти аргументы в стек сопроцессора, вызываемая функция будет вынуждена скопировать содержимое регистров в оперативную память. Как ни крути, от обращения к памяти не избавишься. Вот поэтому-то, регистровая передача вещественных типов крайне редка и в подавляющем большинстве случаев они, как и обычные аргументы, передаются через стек основного процессора или через стек сопроцессора. (Последнее умеют только продвинутые компиляторы, в частности WATCOM, но не Microsoft Visual C++ и не Borland C++).
Впрочем, некоторые "избранные" значения могут загружаться и без обращений к памяти, в частности, существуют команды для заталкивания в стек сопроцессора чисел ноль, один, π и некоторые другие – полный список приведен в таблице 9.
Любопытной особенностью сопроцессора является поддержка операций с целочисленными вычислениями. Мне не известно ни одного компилятора, использующего эту возможность, но такой прием иногда встречается в ассемблерных вставках, поэтому, пренебрегать изучением целочисленных команд сопроцессора все же не стоит.

Команда
Назначение
FLD источник
Заталкивает вещественное число из источника на вершину стека сопроцессора
FSTP приемник
Выталкивает вещественное число из вершины стека сопроцессора в приемник
FST приемник
Копирует вещественное число из вершины стека сопроцессора в приемник
FLDZ
Заталкивает ноль на вершину стека сопроцессора
FLD1
Заталкивает единицу на вершину стека сопроцессора
FLDPI
Заталкивает на вершину стека сопроцессора число π
FLDL2T
Заталкивает на вершину стека сопроцессора двоичный логарифм десяти
FLDL2E
Заталкивает на вершину стека сопроцессора двоичный логарифм числа e
FLDLG2
Заталкивает на вершину стека сопроцессора десятичный логарифм двух
FLDLN2
Заталкивает на вершину стека сопроцессора натуральный логарифм двух
FILD источник
Заталкивает целое число из источника на вершину стека сопроцессора
FIST приемник
Копирует целое число с вершины стека сопроцессора в приемник
FISTP приемник
Выталкивает целое число с вершины стека сопроцессора в приемник
FBLD источник
Заталкивает десятичное число из приемника на вершину стека сопроцессора
FBSTP приемник
Копирует десятичное число с вершины стека сопроцессора в приемник
FXCH ST(индекс)
Обмен значениями между вершиной стека сопроцессора и регистром ST(индекс)

Таблица 9 Основные команды сопроцессора, применяющиеся для передачи/приема аргументов

Типы double и long double занимают более одного машинного слова и через стек основного процессора передаются за несколько итераций. Это приводит к тому, что анализ кода вызывающей функции не всегда позволяет установить количество и тип передаваемых вызываемой функции аргументов. Выход – в исследовании алгоритма работы вызываемой функции. Поскольку сопроцессор не может самостоятельно определить тип операнда, находящегося в памяти (т.е. не знает: сколько ячеек он занимает), за каждым типом закрепляется "своя" команда. Синтаксис ассемблера скрывает эти различия, позволяя программисту абстрагироваться от тонкостей реализации (а еще говорят, что ассемблер – язык низкого уровня), и мало кто знает, что FADD [float] и FADD [double] это разные машинные инструкции с опкодами 0xD8 ??000??? и 0xDC ??000??? соответственно. Плохая новость, помет Тигры! Анализ дизассемблерного листинга не дает никакой информации о вещественных типах – для получения этой информации приходится спускаться на машинный уровень, вгрызаясь в шестнадцатеричные дампы инструкций.
В таблице 10 приведены опкоды основных команд сопроцессора, работающих с памятью. Обратите внимание, что с вещественными значениями типа long double непосредственные математические операции невозможны – прежде их необходимо загрузить в стек сопроцессора.

Команда
Тип

короткий (float)
длинный (double)
расширенный (long double)
FLD
0xD9 ??000???
0xDD ??000???
0xDB ??101???
FSTP
0xD9 ??011???
0xDD ??011???
0xDB ??111???
FST
0xD9 ??010???
0xDD ??010???
нет
FADD
0xD8 ??000???
0xDC ??000???
нет
FADDP
0xDE ??000???
0xDA ??000???
нет
FSUB
0xD8 ??100???
0xDC ??100???
нет
FDIV
0xD8 ??110???
0xDC ??110???
нет
FMUL
0xD* ??001???
0xDC ??001???
нет
FCOM
0xD8 ??010???
0xDC ??010???
нет
FCOMP
0xD8 ??011???
0xDC ??011???
нет

Таблица 10 Опкоды основных команд сопроцессора. Второй байт опкода представлен в двоичном виде. Знак вопроса обозначает любой бит.

Замечание о вещественных типах языка Turbo Pascal. Вещественные типы языка Си вследствие его машиноориентированности совпадают с вещественными типами сопроцессора, что логично. Основной же вещественный тип Turbo Pascal-я, - Real, занимает 6 байт и противоестественен для машины. Поэтому, при вычислениях через сопроцессор он программно переводится в Extended тип (long double в терминах Си). Это "съедает" львиную долю производительности, но других типов встроенная математическая библиотека, призванная заменить собой сопроцессор, увы - не поддерживает. При наличии же "живого" сопроцессора появляются чисто процессорные типы Single, Double, Extended и Comp, соответствующие float, double, long double и __int64.
Функциям математической библиотеки, обеспечивающий поддержу вычислений с плавающей запятой, вещественные аргументы передаются через регистры: в AX, BX, DX помещается первый слева аргумент, а в CX, SI, DI – второй (если он есть). Системные функции сопряжения с интерфейсом процессора (в частности, функции преобразования Real в Extended) принимают аргументы через регистры, а результат возвращают через стек сопроцессора. Наконец, прикладные функции и процедуры получают вещественные аргументы через стек основного процессора.
В зависимости от настроек компилятора программа может компилироваться либо с использованием встроенной математической библиотеки (по умолчанию), либо с непосредственным вызовом команд сопроцессора (ключ N$+). В первом случае программа вообще не использует возможности сопроцессора, даже если он и установлен на машине. Во втором же: при наличии сопроцессора возлагает все вычислительные возможности на него, а если он отсутствует, попытка вызова сопроцессорных команд приводит к генерации основным процессором исключения int 0x7. Его "отлавливает" программный эмулятор сопроцессора – фактически та же самая встроенная библиотека поддержки вычислений с плавающей точкой.

Что ж, теперь мы общих чертах представляем себе как происходит передача вещественных аргументов и горим нетерпением увидеть как это происходит "в живую". Для начала возьмем тривиальный пример:

#include

float MyFunc(float a, double b)
{
#if defined(__WATCOMC__)
#pragma aux MyFunc parm [8087];
// Компилить с ключом -7
#endif
return a+b;
}

main()
{
printf("%f\n",MyFunc(6.66,7.77));
}
Листинг 78 Демонстрация передачи функции вещественных аргументов
Результат компиляции Microsoft Visual C++ должен выглядеть так:

main proc near ; CODE XREF: start+AFp

var_8 = qword ptr -8
; Локальная переменная, занимающая судя по всему 8 байт

push ebp
mov ebp, esp
; Открываем кадр стека

push 401F147Ah
; К сожалению IDA не может представить операнд в виде числа с плавающей запятой
; К тому же у нас нет возможности определить, что это именно вещественное число
; Его тип может быть каким угодно: и int, и указателем
; (кстати, оно очень похоже на указатель).

push 0E147AE14h
push 40D51EB8h
; "Черновой" вариант прототипа выглядит так: MyFunc(int a, int b, int c)

call MyFunc
add esp, 4
; Хвост Тигра! Со стека снимается только одно машинное слово, тогда как
; ложится туда три!

fstp [esp+8+var_8]
; Стягиваем со стека сопроцессора какое-то вещественное число. Чтобы узнать
; какое, придется нажать , выбрать в открывшемся меню пункт
; "Text representation", и в нем в окно "Number of opcode bytes" ввести
; сколько знакомест отводится под опкод команд, например, 4.
; Тут же слева от FSTP появляется ее машинное представление - DD 1C 24
; По таблице 10 определяем тип данных с которым манипулирует эта команда.
; Это – double. Следовательно функция возвратила в через стек сопроцессора
; вещественное значение.
; Раз функция возвращает вещественные значения, вполне возможно, что она их и
; принимает в качестве аргументов. Однако, без анализа MyFunc подтвердить это
; предположение невозможно.

push offset aF ; "%f\n"
; Передаем функции printf указатель на строку спецификаторов, предписывая ей
; вывести одно вещественное число. Но... при этом мы его не заносим в стек!
; Как же так?! Прокручиваем окно дизассемблера вверх, параллельно с этим
; обдумывая все возможные пути разрешения ситуации.
; Внимательно рассматривая команду "FSTP [ESP+8+var_8]" попытаемся вычислить
; куда же она помещает результат своей работы.
; IDA определила var_8 как "qword ptr –8", следовательно [ES+8-8] эквивалентно
; [ESP], т.е. вещественная переменная стягивается прямо на вершину стека.
; А что у нас на вершине? Два аргумента, переданных MyFunc и так и не
; вытолкнутых из стека. Какой хитрый компилятор! Он не стал создавать локальную
; переменную, а использовал аргументы функции для временного хранилища данных!

call _printf
add esp, 0Ch
; Выталкиваем со стека три машинных слова

pop ebp
retn
main endp

MyFunc proc near ; CODE XREF: sub_401011+12p

var_4 = dword ptr -4
arg_0 = dword ptr 8
arg_4 = qword ptr 0Ch
; Смотрим – IDA обнаружила только два аргумента, в то время как функции передавалось
; три машинных слова! Очень похоже, что один из аргументов занимает 8 байт...

push ebp
mov ebp, esp
; Открываем кадр стека

push ecx
; Нет, это не сохранение ECX – это резервирование памяти под локальную
; переменную. Т.к. на том месте, где лежит сохраненный ECX находится
; переменная var_4.

fld [ebp+arg_0]
; Затягиваем на стек сопроцессора вещественную переменную, лежащую по адресу
; [ebp+8] (первый слева аргумент). Чтобы узнать тип этой переменной, смотрим
; опкод инструкции FLD - D9 45 08. Ага, D9 – значит, float
; Выходит, первый слева аргумент – float.

fadd [ebp+arg_4]
; Складываем arg_0 типа float со вторым слева аргументом типа... Вы думаете,
; раз первый был float, то и второй так же будет float-ом?
; А вот и не обязательно! Лезем в опкод - DC 45 0C, значит, второй аргумент
; double, а не float!

fst [ebp+var_4]
; Копируем значение с верхушки стека сопроцессора
;(там лежит результат сложения) в локальную переменную var_4.
; Зачем? Ну... мало ли, вдруг бы она потребовалась?
; Обратите внимание – значение не стягивается, а копируется! Т.е. оно все еще
; остается в стеке. Таким образом, прототип функции MyFunc выглядел так:
; double MyFunc(float a, double b);

mov esp, ebp
pop ebp
; Закрываем кадр стека

retn

MyFunc endp
Листинг 79
Поскольку результат компиляции Borland C++ 5.x практически в точности идентичен уже рассмотренному выше примеру от Microsoft Visual C++ 6.x, не будем терять на него время и сразу перейдем к разбору WATCOM C (как всегда – у WATCOM-а есть чему поучиться):

main_ proc near ; CODE XREF: __CMain+40p

var_8 = qword ptr -8
; локальная переменная на 8 байт

push 10h
call __CHK
; Проверка стека на переполнение

fld ds:dbl_420008
; Закидываем на вершину стека сопроцессора переменную типа double,
; взимаемую из сегмента данных.
; Тип переменной успешно определила сама IDA, предварив его префиксом 'dbl'.
; А если бы не определила – тогда бы мы обратились к опкоду команды FLD.

fld ds:flt_420010
; Закидываем на вершину стека сопроцессора переменную типа float

call MyFunc
; Вызываем MyFunc с передачей двух аргументов через стек сопроцессора,
; значит, ее прототип выглядит так: MyFunc(float a, double b).

sub esp, 8
; Резервируем место для локальной переменной размеров в 8 байт

fstp [esp+8+var_8]
; Стягиваем с вершины стека вещественное типа double
; (тип определяется размером переменной).

push offset unk_420004
call printf_
; Ага, уже знакомый нам трюк передачи var_8 функции printf!

add esp, 0Ch
retn
main_ endp

MyFunc proc near ; CODE XREF: main_+16p

var_C = qword ptr -0Ch
var_4 = dword ptr –4
; IDA нашла две локальные переменные

push 10h
call __CHK

sub esp, 0Ch
; Резервируем место под локальные переменные

fstp [esp+0Ch+var_4]
; Стягиваем с вершины стека сопроцессора вещественное значение типа float
; (оно, как мы помним, было занесено туда последним).
; На всякий случай, впрочем, можно удостоверится в этом, посмотрев опкод
; команды FSTP - D9 5C 24 08. Ну, раз, 0xD9, значит, точно float.

fstp [esp+0Ch+var_C]
; Стягиваем с вершины стека сопра вещественное значение типа double
; (оно, как мы помним, было занесено туда перед float).
; На всякий случай удостоверяемся в этом, взглянув на опкод команды FSTP.
; Он есть - DD 1C 24. 0xDD – раз 0xDD, значит, действительно, double.

fld [esp+0Ch+var_4]
; Затаскиваем на вершину стека наш float обратно и…

fadd [esp+0Ch+var_C]
; …складываем его с нашим double. Вот, а еще говорят, что WATCOM C
; оптимизирующий компилятор! Трудно же с этим согласится, раз компилятор
; не знает, что от перестановки слагаемых сумма не изменяется!

add esp, 0Ch
; Освобождаем память, ранее выделенную для локальных переменных
retn
MyFunc endp

dbl_420008 dq 7.77 ; DATA XREF: main_+A↑r
flt_420010 dd 6.6599998 ; DATA XREF: main_+10↑r

Листинг 80

Настала очередь компилятора Turbo Pascal for Windows 1.0. Наберем в текстовом редакторе следующий пример:

USES WINCRT;

Procedure MyProc(a:Real);
begin
WriteLn(a);
end;

VAR
a: Real;
b: Real;

BEGIN
a:=6.66;
b:=7.77;
MyProc(a+b);
END.

Листинг 81 Демонстрация передачи вещественных значений компилятором Turbo Pascal for Windows 1.0

А теперь, тяпнув с Тигрой пивка для храбрости, откомпилируем его без поддержки сопроцессора (так и происходит с настройками по умолчанию).

PROGRAM proc near

call INITTASK
call @__SystemInit$qv ; __SystemInit(void)
; Инициализация модуля SYSTEM

call @__WINCRTInit$qv ; __WINCRTInit(void)
; Инициализация модуля WINCRT

push bp
mov bp, sp
; Открываем кадр стека

xor ax, ax
call @__StackCheck$q4Word ; Stack overflow check (AX)
; Проверяем есть ли в стеке хотя бы ноль свободных байт

mov word_2030, 0EC83h
mov word_2032, 0B851h
mov word_2034, 551Eh
; Инициализируем переменную типа Real. Что это именно Real мы пока, конечно,
; знаем только лишь из исходного текста программы.
; Визуально отличить эту серию команд от трех переменных типа Word невозможно.

mov word_2036, 3D83h
mov word_2038, 0D70Ah
mov word_203A, 78A3h
; Инициализируем другую переменную типа Real

mov ax, word_2030
mov bx, word_2032
mov dx, word_2034
mov cx, word_2036
mov si, word_2038
mov di, word_203A
; Передаем через регистры две переменные типа Real

call @$brplu$q4Realt1 ; Real(AX:BX:DX)+=Real(CX:SI:DI)
; К счастью, IDA "узнала" в этой функции оператор сложения и даже
; подсказала нам ее прототип.
; Без ее помощи нам вряд ли удалось понять что делает эта очень длинная и
; запутанная функция.

push dx
push bx
push ax
; Передаем возращенное значение процедуре MyProc через стек,
; следовательно, ее прототип выглядит так: MyProc(a:Real).

call MyProc

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

xor ax, ax
call @Halt$q4Word ; Halt(Word)
; Прерываем выполнение программы

PROGRAM endp

MyProc proc near ; CODE XREF: PROGRAM+5Cp

arg_0 = word ptr 4
arg_2 = word ptr 6
arg_4 = word ptr 8
; Три аргумента, переданные процедуре, как мы уже выяснили на самом деле представляют
; собой три "дольки" одного аргумента типа Real.

push bp
mov bp, sp
; Открываем кадр стека

xor ax, ax
call @__StackCheck$q4Word ; Stack overflow check (AX)
; Есть ли в стеке ноль байт?

mov di, offset unk_2206
push ds
push di
; Заталкиваем в стек указатель на буфер для вывода строки

push [bp+arg_4]
push [bp+arg_2]
push [bp+arg_0]
; Заталкиваем все три полученные аргумента в стек

mov ax, 11h
push ax
; Ширина вывода – 17 символов

mov ax, 0FFFFh
push ax
; Число точек после запятой – max

call @Write$qm4Text4Real4Wordt3 ; Write(var f; v: Real; width, decimals: Word)
; Выводим вещественное число в буфер unk_2206

call @WriteLn$qm4Text ; WriteLn(var f: Text)
; Выводим строку из буфера на экран

call @__IOCheck$qv ; Exit if error
pop bp
retn 6
MyProc endp

Листинг 82

А теперь, используя ключ '/$N+' задействуем команды сопроцессора и посмотрим: как это скажется на код:

PROGRAM proc near

call INITTASK
call @__SystemInit$qv ; __SystemInit(void)
; Инициализируем модуль System

call @__InitEM86$qv ; Initialize software emulator
; Врубаем эмулятор сопроцессора

call @__WINCRTInit$qv ; __WINCRTInit(void)
; Инициализируем модуль WINCRT

push bp
mov bp, sp
; Открываем кадр стека

xor ax, ax
call @__StackCheck$q4Word ; Stack overflow check (AX)
; Проверка стека на переполнение

mov word_21C0, 0EC83h
mov word_21C2, 0B851h
mov word_21C4, 551Eh
mov word_21C6, 3D83h
mov word_21C8, 0D70Ah
mov word_21CA, 78A3h
; Пока мы не можем определить тип инициализируемых переменных.
; Это с равным успехом может быть и WORD и Real

mov ax, word_21C0
mov bx, word_21C2
mov dx, word_21C4
call @Extended$q4Real ; Convert Real to Extended
; А вот теперь мы передаем word_21C0, word_21C2 и word_21C4 функции,
; преобразующий Real в Extend с загрузкой последнего в стек сопроцессора,
; значит, word_21C0 – word_21C4 это переменная типа Real.

mov ax, word_21C6
mov bx, word_21C8
mov dx, word_21CA
call @Extended$q4Real ; Convert Real to Extended
; Аналогично – word_21C6 – word_21CA – переменная типа Real

wait
; Ждем-с пока сопроцессор не закончит свою работу

faddp st(1), st
; Складываем два числа типа extended, лежащих на вершине стека сопроцессора
; с сохранением результата в том же самом стеке.

call @Real$q8Extended ; Convert Extended to Real
; Преобразуем Extended в Real
; Аргумент передается через стек сопроцессора, а возвращается в
; регистрах AX BX DX.

push dx
push bx
push ax
; Регистры AX, BX и DX содержат значение типа Real,
; следовательно прототип процедуры выглядит так:
; MyProc(a:Real);

call MyProc

pop bp
xor ax, ax
call @Halt$q4Word ; Halt(Word)
PROGRAM endp

MyProc proc near ; CODE XREF: PROGRAM+6Dp

arg_0 = word ptr 4
arg_2 = word ptr 6
arg_4 = word ptr 8
; Как мы уже помним, эти три аргумента – на самом деле один аргумент типа Real

push bp
mov bp, sp
; Открываем кадр стека

xor ax, ax
call @__StackCheck$q4Word ; Stack overflow check (AX)
; Проверка стека на переполнение

mov di, offset unk_2396
push ds
push di
; Заносим в стек указатель на буфер для вывода строки

mov ax, [bp+arg_0]
mov bx, [bp+arg_2]
mov dx, [bp+arg_4]
call @Extended$q4Real ; Convert Real to Extended
; Преобразуем Real в Extended

mov ax, 17h
push ax
; Ширина вывода 0х17 знаков

mov ax, 0FFFFh
push ax
; Количество знаков после запятой – все что есть, все и выводить

call @Write$qm4Text8Extended4Wordt3 ; Write(var f; v: Extended{st(0); width decimals: Word)
; Вывод вещественного числа со стека сопроцессора в буфер

call @WriteLn$qm4Text ; WriteLn(var f: Text)
; Печать строки из буфера

call @__IOCheck$qv ; Exit if error
pop bp
retn 6
MyProc endp
Листинг 83

::соглашения о вызовах thiscall и соглашения о вызове по умолчанию. В Си++ программах каждая функция объекта неявно принимает аргумент this – указатель на экземпляр объекта, из которого вызывается функция. Подробнее об этом уже рассказывалось в главе "Идентификация this", поэтому не будет здесь повторяться.
По умолчанию все известные мне Си++ компиляторы используют комбинированное соглашение о вызовах – передавая явные аргументы через стек (если только функция не объявлена как fastcall), а указать this через регистр с наибольшим предпочтением (см. таблицы 2 - 7).
Соглашения же cdecl и stdcall предписывают передать все аргументы через стек, включая неявный аргумент this, заносимый в стек в последнюю очередь – после всех явных аргументов (другими словами, this – самый левый аргумент).
Рассмотрим следующий пример:

#include

class MyClass{
public:
void demo(int a);
// прототип demo в действительности выглядит так demo(this, int a)

void __stdcall demo_2(int a, int b);
// прототип demo_2 в действительности выглядит так demo_2(this, int a, int b)

void __cdecl demo_3(int a, int b, int c);
// прототип demo_2 в действительности выглядит так demo_2(this, int a, int b, int c)

};

// Реализзация функция demo, demo_2, demo_3 для экономии места опущена

main()
{
MyClass *zzz = new MyClass;
zzz->demo();
zzz->demo_2();
zzz->demo_3();
}
Листинг 84 Демонстрация передачи неявного аргумента - this

Результат компиляции этого примера компилятором Microsoft Visual C++ 6.0 должен выглядеть так (показана лишь функция main, все остальное не представляет на данный момент никакого интереса):

main proc near ; CODE XREF: start+AFp
push esi
; Сохраняем ESI в стеке

push 1
call ??2@YAPAXI@Z ; operator new(uint)
; Выделяем один байт для экземпляра объекта

mov esi, eax
; ESI содержит указатель на экземпляр объекта

add esp, 4
; Выталкиваем аргумент из стека

mov ecx, esi
; Через ECX функции Demo передается указатель this.
; Как мы помним, компилятор Microsoft Visual C++ использует регистр ECX
; для передачи самого первого аргумента функции.
; В данном случае этим аргументом и является указатель this.
; А компилятор Borland C++ 5.x передал бы this через регистр EAX, т.к.
; он отдает ему наибольшее предпочтение (см. таблицу 4)

push 1
; Заносим в стек явный аргумент функции. Значит, это не fastcall-функция,
; иначе бы данный аргумент был помещен в регистр EDX. Выходит,
; мы имеем дело с типом вызова по умолчанию.

call Demo

push 2
; Заталкиваем в стек первый справа аргумент

push 1
; Заталкиваем в стек второй справа аргумент

push esi
; Заталкиваем в стек неявный аргумент this.
; Такая схема передачи говорит о том, что имело место явное преобразование
; типа функции в stdcall или cdecl. Прокручивая экран дизассемблера немного
; вниз, мы видим, что стек вычищает вызываемая функция, значит, она следует
; соглашению stdcall.

call demo_2

push 3
push 2
push 1
push esi
call sub_401020
add esp, 10h
; Раз функция вычищает за собой стек сама, то она имеет либо тип по умолчанию,
; либо -- cdecl. Передача указателя this через стек подсказывает, что истинно
; второе предположение.

xor eax, eax
pop esi
retn
main endp
Листинг 85

::аргументы по умолчанию. Для упрощения вызова функций с "хороводом" аргументов в язык Си++ была введена возможность задания аргументов по умолчанию. Отсюда возникает вопрос – отличается ли чем ни будь вызов функций с аргументами по умолчанию от обычных функций? И кто инициализирует опущенные аргументы вызываемая или вызывающая функция?
Так вот, при вызове функций с аргументами по умолчанию, компилятор самостоятельно добавляет недостающие аргументы, и вызов такой функции ни чем не отличается от вызова обычных функций.
Докажем это на следующем примере:

#include

MyFunc(int a=1, int b=2, int c=3)
{
printf("%x %x %x\n",a,b,c);
}

main()
{
MyFunc();
}
Листинг 86 Демонстрация передачи аргументов по умолчанию
Результат его компиляции будет выглядеть приблизительно так (для экономии места показана только вызывающая функция):

main proc near ; CODE XREF: start+AFp
push ebp
mov ebp, esp

push 3
push 2
push 1
; Как видно, все опущенные аргументы были переданы функции
; самим компилятором

call MyFunc

add esp, 0Ch
pop ebp
retn
main endp

Листинг 87

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

Идентификация значения, возвращаемого функцией

…каждый язык - это своя философия, свой взгляд на деятельность программиста, отражение определенной технологии программирования.
Кауфман

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

Таблица 11 Механизм возращения значения оператором return в 16-разрядных компиляторах

тип
способ возврата
однобайтовый
AL
AX
EAX
двухбайтовый
AX
EAX
четырехбайтовый
EAX
восьми байтовый
EDX:EAX
float
стек сопроцессора
EAX
double
стек сопроцессора
EDX:EAX
near pointer
EAX

свыше восьми байт
через неявный аргумент по ссылке

Таблица 12 Механизм возращения значения оператором return в 32-разрядных компиляторах

Оказывается, если возвращаемое значение не может быть втиснуто в регистры, компилятор скрыто от программиста передает функции неявный аргумент – ссылку на локальную переменную, в которую и записывается возвращенный результат. Таким образом, функции 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
#include

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
#include

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

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

Таблица 13 Допустимые регистры для возращения значения функции в компиляторе WATOM C. Жирным шрифтом выделен регистр (регистры) используемые по умолчанию. Обратите внимание, что по используемому регистру невозможно непосредственно узнать тип возвращаемого значения, а только его размер. В частности, через регистр EAX может возвращаться и переменная типа int и структура из четырех переменных типа char (или двух char или одного short int)

Покажем, как это выглядит на практике. Рассмотрим следующий пример:

#include

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

// 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
#include

// Функция инвертирования строки 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
#include
#include

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

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

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

// Функция сообщения об ошибке деления
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

Идентификация локальных стековых переменных

…общая масса бактерий гораздо больше, чем наша с вами суммарная масса. Бактерии - основа жизни на земле…
А.П. Капица

Локальные переменные размещаются в стеке (так же называемым автоматической памятью) и удаляются оттуда вызываемой функцией по ее завершению. Рассмотрим подробнее: как это происходит. Сначала в стек затягиваются аргументы, передаваемые функции (если они есть), а сверху на них кладется адрес возврата, помещаемый туда инструкцией CALL вызывающей эту функцию. Получив управление, функция открывает кадр стека – сохраняет прежнее значение регистра EBP и устанавливает его равным регистру ESP (регистр указатель вершины стека). "Выше" (т.е. в более младших адресах) EBP находится свободная область стека, ниже – служебные данные (сохраненный EBP, адрес возврата) и аргументы.
Сохранность области стека, расположенная выше указателя вершины стека (регистра ESP), не гарантируется от затирания и искажения. Ее беспрепятственно могут использовать, например, обработчики аппаратных прерываний, вызываемые в непредсказуемом месте в непредсказуемое время. Да и использование стека самой функцией (для сохранения ль регистров или передачи аргументов) приведет к его искажению. Какой из этой ситуации выход? – принудительно переместить указатель вершины стека вверх, тем самым "занимая" данную область стека. Сохранность память, находящейся "ниже" ESP гарантируется (имеется ввиду – гарантируется от непреднамеренных искажений), - очередной вызов инструкции PUSH занесет данные на вершину стека, не затирая локальные переменные.
По окончании же своей работы, функция обязана вернуть ESP на прежнее место, иначе функция RET снимет со стека отнюдь не адрес возврата, а вообще не весь что (значение самой "верхней" локальной переменной) и передаст управление "в космос"…

Рисунок 15 0х00E Механизм размещения локальных переменных в стеке. На левой картинке показано состояние стека на момент вызова функции. Она открывает кадр стека, сохраняя прежнее значение регистра EBP и устанавливает его равным ESP. На правой картинке изображено резервирование 0x14 байт стековой памяти под локальные переменные. Резервирование осуществляется перемещением регистра ESP "вверх" – в область младший адресов. Фактически локальные переменные размещаются в стеке так, как будто бы они были туда запихнуты командной PUSH. При завершении своей работы, функция увеличивает значение регистра ESP, возвращая его на прежнюю позицию, освобождая тем самым паять, занятую локальными переменными, стягивает со стека и восстанавливает значение EBP, закрывая тем самым кадр стека.

Адресация локальных переменных. Адресация локальных переменных очень похожа на адресацию стековых аргументов (см. "Идентификация аргументов функций :: адресация аргументов в стеке"), только аргументы располагаются "ниже" EBP, а локальные переменные "выше". Другими словами, аргументы имеют положительные смещения относительно EBP, а локальные переменные – отрицательные. Поэтому, их очень легко отличить друг от друга. Так, например, [EBP+xxx] – аргумент, а [EBP-xxx] – локальная переменная.
Регистр-указатель кадра стека служит как бы барьером: по одну сторону от него аргументы функции, по другую – локальные переменные. (см. рис. 16). Теперь понятно, почему при открытии кадра стека значение ESP копируется в EBP, иначе бы адресация локальных переменных и аргументов значительно усложнилась, а разработчики компиляторов, они (как это ни странно) тоже люди и не ходят без нужды осложнять себе жизнь. Впрочем, оптимизирующие компиляторы умеют адресовать локальные переменные и аргументы непосредственно через ESP, освобождая регистр EBP для более полезных целей. Подробнее об этом см. "FPO Frame Pointer Omission".

Рисунок 16 0х00F Адресация локальных переменных. Механизм адресации локальных переменных очень похож на адресацию стековых аргументов, только аргументы расположены ниже указателя кадра стека – регистра EBP, а локальные переменные "проживают" выше него.

Детали технической реализации. Существует множество вариаций реализации выделения и освобождения памяти под локальные переменные. Казалось бы, чем плохо очевидное 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

Таблица 14 Наиболее распространенные варианты реализации резервирования памяти под локальные переменные и ее освобождение

Идентификация механизма выделения памяти. Выделение памяти инструкциями 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
#include

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" или нажимаем "горячую" комбинацию
; Открывается окно с перечнем всех распознанных локальных переменных.
; Подводим курсор к "var_34" и нажимаем <;> для ввода повторяемого комментария
; и пишем нечто вроде "char s[0x34]". Теперь для завершения ввода
; и для закрытия окна локальных переменных.
; Все! Теперь возле всех обращений к var_34 появляется введенный нами
; комментарий
;

push ecx ; char *
; Передаем функции ltoa указатель на локальный буфер var_38

mov edx, [ebp+var_4]
; Загружаем в EDX значение локальной переменной var_4

push edx ; __int32
; Передаем значение локальной переменной var_38 функции ltoa
; На основании прототипа этой функции IDA уже определила тип переменной – int
; Вновь нажмем и прокомментируем 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+AF↓p
.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!

Идентификация регистровых и временных переменных

Ничто не постоянно так, как временное
Народная мудрость

Стремясь минимализировать количество обращений к памяти, оптимизирующие компиляторы размещают наиболее интенсивно используемые локальные переменные в регистрах общего назначения, только по необходимости сохраняя их в стеке (а в идеальном случае не сохраняя их вовсе).
Какие трудности для анализа это создает? Во-первых, вводит контекстную зависимость в код. Так, увидев в любой точке функции команду типа "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).
Механизм выделения памяти под временные переменные практически идентичен механизму выделения памяти стековым локальным переменным, однако, никаких проблем идентификации не возникает. Во-первых, выделение памяти стековым переменным происходит сразу же после открытия кадра стека, а временным переменными – в любой точке функции. Во-вторых, временные переменные адресуются не через регистр указатель кадра стека, а через указатель вершины стека.

действие
методы




резервирование памяти
PUSH
SUB ESP, xxx
использовать стековые аргументы >>>#
освобождение памяти
POP
ADD ESP, xxx
запись переменной
PUSH
MOV [ESP+xxx],
MOVS
чтение переменной
POP
MOV , [ESP+xxx]
передача вызываемой функции

Таблица 15 Основные механизмы манипуляция со временными переменными

>>># Только в 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

Идентификация глобальных переменных

Да, подумала Алиса, - вот это дерябнулась, так дерябнулась!

Программа, нашпигованная глобальными переменными, - едва ли на самое страшное проклятие хакеров, – вместо древа строгой иерархии, компоненты программы тесно переплетаются друг с другом и, чтобы понять алгоритм одного из них, – приходится "прочесывать" весь листинг в поисках перекрестных ссылок. А в совершенстве восстанавливать перекрестные ссылки не умеет ни один дизассемблер, - даже IDA!
Идентифицировать глобальные переменные очень просто, гораздо проще, чем все остальные конструкции языков высокого уровня. Глобальные переменные сразу же выдают себя непосредственной адресаций памяти, т.е. обращение к ним выглядит приблизительно так: "MOV EAX,[401066]", где 0x401066 и есть адрес глобальной переменной.
Сложнее понять: для чего эта переменная, собственно, нужна и каково ее содержимое на данный момент. В отличие от локальных переменных, глобальные – контекстно-зависимы. В самом деле, каждая локальная переменная инициализируется "своей" функцией и не зависит от того, какие функции были вызваны до нее. Напротив, глобальные переменные может модифицировать кто угодно и когда угодно, - значение глобальной переменной в произвольной точке программы не определено. Чтобы его выяснить, необходимо проанализировать все, манипулирующие с ней функции, и – более того – восстановить порядок из вызова. Подробнее этот вопрос будет рассмотрен в главе "___Построение дерева вызовов", - пока же разберемся с техникой восстановления перекрестных ссылок.

Техника восстановления перекрестных ссылок. В большинстве случаев с восстановлением перекрестных ссылок сполна справляется автоматический анализатор IDA, и делать это "вручную" практически никогда не придется. Однако бывает, что IDA ошибается, да и не всегда (и не у всех!) она бывает под рукой. Поэтому, совсем нелишне уметь справляться с глобальными переменными самому.

Отслеживание обращений к глобальным переменным контекстным поиском их смещения в сегменте кода [данных]. Непосредственная адресация глобальных переменных чрезвычайно облегчает поиск манипулирующих с ними машинных команд. Рассмотрим, например, такую конструкцию: "MOV EA,[0x41B904]". После ассемблирования она будет выглядеть так: "A1  04 B9 41 00". Смещение глобальной переменной записывается "как есть" (естественно, с соблюдением обратного порядка следования байт – старшие располагаются по большему адресу, а младшие – по меньшему).
Тривиальный контекстный поиск позволит выявить все обращения к интересующей вас глобальной переменной, достаточно лишь узнать ее смещение, переписать его справа налево и… вместе с полезной информацией получить какое-то количество мусора. Ведь не каждая число, совпадающее по значению со смещением глобальной переменной, обязано быть указателем на эту переменную. Тому же "04 B9 41 00" удовлетворяет, например, следующий контекст:

83EC04 sub esp,004
B941000000 mov ecx,000000041

Ошибка очевидна – искомое значение не является операндом инструкции, более того, оно "захватило" сразу две инструкции! Отбрасыванием всех вхождений, пересекающих границы инструкции, мы сразу же избавляется от значительной части "мусора". Единственная проблема – как определить границы инструкций, - по части инструкции о самой инструкции сказать ничего нельзя.
Вот, например, встречается нам следующее: "…8D 81 04 B9 41 00 00…". Эту последовательность, за вычетом последнего нуля, можно интерпретировать так: "lea eax,[ecx+0х41B904]", но если предположить, что 0x8D принадлежит "хвосту" предыдущей команды, то получится следующее: "add d,[ecx][edi]*4,000000041", а, может быть, здесь и вовсе несколько команд…
Самый надежный способ определения границ машинных команд – трассированное дизассемблирование, но, к сожалению, это чрезвычайно ресурсоемкая операция, и далеко не всякий дизассемблер умеет трассировать код. Поэтому, приходится идти другим путем…
Образно машинный код можно изобразить в виде машинописного текста, напечатанного без пробелов. Если попробовать читать с произвольной позиции, мы, скорее всего, попадем на середину слова и ничего не поймем. Может быть, волей случая, первые несколько слогов и сложатся в осмысленное слово (а то и два!), но дальше пойдет сплошная чепуха. Например: "мамылараму". Ага, "мамы" – множественное число от "мама", подходит? Подходит. Дальше – "лараму". "Лараму" – это что, народный индийский герой такой со множеством родительниц? Или "Мамы ла Раму?" А как вам "Мамы Ла Ра Му" – в смысле три мамы "Ла, Ра и Му"? Да, скажите тоже, - вот, ерунда какая!!!
Смещаемся на одну букву вперед, оставляя "м" предыдущему слову. "А", - что ж, вполне возможно, это и есть союз "А", тем более что за ним идет осмысленное местоимение "мы", получается – "А мы Лараму" или "А мы Лара Му". Кто такой этот Лараму?!
Сдвигаемся еще на одну букву и читаем "мыла", а за ним "раму". Заработало! А "ам" стало быть, хвост от "мама".
Вот, примерно так читается и машинный код, причем, такая аналогия весьма полная. Слово (русское) не может начинаться с некоторых букв (например, с "Ы", мягкого и твердого знака), существуют характерные суффиксы и окончания, с сочетанием букв, практически не встречающихся в других частях предложения. Соответственно, видя в конец несколько подряд идущих нулей, можно с высокой степенью уверенности утверждать, что это непосредственное значение, а непосредственные значения располагаются в конце команды (см. "___Тонкости дизассемблирования").

Отличия констант от указателей или продолжаем разгребать мусор дальше. Вот, наконец, мы избавились от ложных срабатываний, бессмысленность которых очевидна с первого взгляда. Куча мусора заметно приуменьшилась, но… в ней все еще продолжают встречаться такие штучки как "PUSH 0x401010". Что такое 0x401010 – константа или смещение? С равным успехом может быть и то, и другое. Пока не доберемся до манипулирующего с ней кода, мы вообще не сможем сказать ничего вразумительного. Если манипулирующий код обращается к 0x401010 по значению, - это константа (выражающая, например, скорость улепетывания Пяточка от Слонопотама), а если по ссылке – это указатель (в данном контексте смещение).
Подробнее эту проблему мы еще обсудим в главе "Идентификация констант и смещений", пока же заметим с большим облегчением, что минимальный адрес загрузки файла в Windows 9x равен 0x400000, и немного существует констант, выражаемых таким большим числом.
Замечание: минимальный адрес загрузки Windows NT равен 0x10000, однако, чтобы программа могла успешно работать и под NT, и под 9x, она должна грузиться не ниже 0x400000.

Кошмары 16-разрядного режима. В 16-разрядном режиме отличить константу от указателя не так-то просто, как в 32-разрядном режиме! В 16-разрядном режиме под данные отводится один (или несколько) сегментов размером 0x10000 байт и допустимые значения смещений заключены в узком интервале [0x0, 0xFFFF], причем у большинства переменных смещения очень невелики и визуально неотличимы от констант.
Другая проблема – один сегмент чаще всего не вмещает в себя всех данных и приходится заводить еще один (а то и больше). Два сегмента – это еще ничего: один адресуется через регистр DS, другой – через ES и никаких трудностей в определении "это указатель на переменную какого сегмента" не возникает. Например, если нас интересуют все обращения к глобальной переменной X, расположенной в основном сегменте по смещению 0x666, то команду MOV AX, ES:[0x666], мы сразу же откинем в мусорную корзину, т.к. основной сегмент адресуется через DS (по умолчанию), а здесь – ES. Правда, обращение может происходить и в два этапа. Например: "MOV BX,0x666/xxx---xxx/MOV AX,ES:[BX]", увидев "MOV BX,0x666" мы не только не можем определить сегмент, но и даже сказать – смещение ли это вообще? Впрочем, это не сильно затрудняет анализ…
Хуже, если сегментов данных в программе добрый десяток (а, что, может же потребоваться порядка 640 килобайт статической памяти?). Никаких сегментных регистров на это не хватит, и их переназначения будут происходить многократно. Тогда, чтобы узнать к какому именно сегменту происходит обращение, потребуется определить значение сегментного регистра. А как его определить? Самое простое – прокрутить экран дизассемблера немного вверх, ища глазами инициализацию данного сегментного регистра, помня то том, что она может осуществляться не только командой MOV segREG, REG, но довольно частенько и POP! Например, PUSH ES/POP DS равносильно MOV DS, ES – правда, команды MOV segREG, segREG в "языке" микропроцессоров 80x86, увы, нет. Как нет команды MOV segREG, CONST, и ее приходится эмулировать вручную либо так: MOV AX, 0x666/MOV ES,AX, либо так: PUSH 0x666/POP ES.
Как хорошо, что 16-разрядный режим практически полностью ушел в прошлое, унося в песок истории все свои проблемы. Не только программисты, но и хакеры с переходом на 32-разрядный режим вздыхают с облегчением.

Косвенная адресация глобальных переменных. Довольно часто приходится слышать утверждение, что глобальные переменные всегда адресуются непосредственно (исключая, конечно, ассемблерные вставки, - на ассемблере программист может обращаться к переменным как захочет). На самом же деле все далеко не так... Если глобальная переменная передается функции по ссылке (а почему бы программисту ни передать глобальную переменную по ссылке?), она будет адресоваться косвенно – через указатель.
Мне могут возразить – а зачем вообще явно передавать глобальную переменную функции? Любая функция и без этого может к ней обратится. Не спорю. Да, может, но только если знает об этом заранее. Вот, скажем, есть у нас функция xchg, обменивающая свои аргументы местами, и есть две глобальные переменные, которые позарез приспичило обменять. Функции xchg доступны все глобальные переменные, но она "не знает" какие из них необходимо обменивать (и необходимо ли это вообще?), вот и приходится ей явно передавать глобальные переменные как аргументы. А это значит, что всех обращений к глобальным переменным простым контекстным поиском мы не нейдем. Самое печальное – не найдет их и IDA Pro (да и как бы она их могла найти? для этого ей потребовался бы полноценный эмулятор процессора или хотя бы основных команд), на чем мы и убедимся в следующем примере:

#include

int a; int b; // Глобальные переменные a и b

// Функция, обменивающая значения аргументов
xchg(int *a, int *b)
{
int c; c=*a; *b=*a; *b=c;
// ^^^^^^^^^^^^^^^^^^ косвенное обращение к аругментам по указателю
// если аргументы функции – глобальные переменные, то они будут адресоваться
// не прямо, а косвенно
}

main()
{

a=0x666; b=0x777; // Здесь – непосредственное обращение к глобальным переменным

xchg(&a, &b); // Передача глобальной переменной по ссылке
}
Листинг 121 Явная передача глобальных переменных

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

main proc near ; CODE XREF: start+AFp
push ebp
mov ebp, esp
; Открываем кадр стека

mov dword_405428, 666h
; Инициализируем глобальную переменную dword_405428
; На то, что это действительно глобальная переменная указывает непосредственная
; адресация

mov dword_40542C, 777h
; Инициализируем глобальную переменную dword_40542C

push offset dword_40542C
; Смотрите! Передаем функции смещение глобальной переменной dword_40542C как
; аргумент (т.е. другими словами, передаем ее по ссылке)
; Это значит, что вызываемая функция будет обращаться к переменной косвенно,
; через указатель – точно так, как она обращается с локальными переменными

push offset dword_405428
; Передаем функции смещение глобальной переменной dword_405428

call xchg
add esp, 8

pop ebp
retn
main endp

xchg proc near ; CODE XREF: main+21p

var_4 = dword ptr -4
arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch

push ebp
mov ebp, esp
; Открываем кадр стека

push ecx
; Выделяем память для локальной переменной var_4

mov eax, [ebp+arg_0]
; Загружаем а EAX содержимое аргумента arg_0

mov ecx, [eax]
; Смотрите! Косвенное обращение к глобальной переменной!
; А еще говорят – будто бы таких не бывает!
; Разумеется, определить, что обращение происходит именно к глобальной
; переменной (и какой именно глобальной переменной) можно только анализом
; кода вызывающей функции

mov [ebp+var_4], ecx
; Копируем значение *arg_0 в локальную переменную var_4

mov edx, [ebp+arg_4]
; Загружаем в EDX содержимое аргумента arg_4

mov eax, [ebp+arg_0]
; Загружаем в EAX содержимое аргумента arg_0

mov ecx, [eax]
; Копируем в ECX значение аргумента *arg_0

mov [edx], ecx
; Копируем в [arg_4] значение arg_0[0]

mov edx, [ebp+arg_4]
; Загружаем в EDX значение arg_4

mov eax, [ebp+var_4]
; Загружаем в EAX значение локальной переменной var_4 (хранит *arg_0)

mov [edx], eax
; Загружаем в *arg_4 значение *arg_0

mov esp, ebp
pop ebp
retn
xchg endp

dword_405428 dd 0 ; DATA XREF: main+3w main+1Co
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
dword_40542C dd 0 ; DATA XREF: main+Dw main+17o
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
; IDA нашла все ссылки на обе глобальные переменные
; Первые две: main+3w и main+Dw на код инициализации
; ('w' – от "write" – т.е. в обращение на запись)
: Вторые две: main+1Co и main+17o
; ('o' – от "offset" – т.е. получение смещения глобальной переменной)
Листинг 122

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

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

Всякая непосредственно адресуемая ячейка памяти – глобальная (статическая) переменная (см. исключения ниже), но не всякая глобальная (глобальная) переменная всегда адресуется непосредственно.

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

"То, что для одного человека константа, для другого - переменная"
Алан Перлис "Афоризмы программирования"

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

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

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

MOV ECX,[0x401020]  непосредственная адресация
MOV ECX, [EAX]  косвенная адресация

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

LEA EAX, [0x401020] ; регистру EAX присваивается значение указателя 0x401020
MOV ECX, [EAX] ; косвенная адресация – загрузка в ECX двойного слова,
; расположенного по смещению 0x401020

Правый операнд команды LEA всегда представляет собой ближний (near) указатель. (Исключение составляют случаи использования LEA для сложения констант – подробнее об этом смотри в одноименном пункте). И все было бы хорошо…. да вот, оказывается, внутреннее представление ближнего указателя эквивалентно константе того же значения. Отсюда – "LEA EAX, [0x401020]" равносильно "MOV EAX,0x401020". В силу определенных причин MOV значительно обогнал в популярности "LEA", практически вытеснив последнюю инструкцию из употребления.
Изгнание "LEA" породило фундаментальную проблему ассемблирования - "проблему OFFSETа". В общих чертах ее суть заключается в синтаксической неразличимости констант и смещений (ближних указателей). Конструкция "MOV EAX, 0x401020" может грузить в EAX и константу, равную 0x401020 (пример соответствующего Си-кода: a=0x401020), и указатель на ячейку памяти, расположенную по смещению 0x401020 (пример соответствующего Си-кода: a=&x). Согласитесь, a=0x401020 совсем не одно и тоже, что a=&x! А теперь представьте, что произойдет, если в заново ассемблированной программе переменная "x" в силу некоторых обстоятельств окажется расположена по иному смещению, а не 0x401020? Правильно, - программа рухнет, ибо указатель "a" по-прежнему указывает на ячейку памяти 0x401020, но здесь теперь "проживает" совсем другая переменная!
Почему переменная может изменить свое смещение? Основных причин тому две. Во-первых, язык ассемблера неоднозначен и допускает двоякую интерпретацию. Например, конструкции "ADD EAX, 0x66" соответствуют две машинные инструкции: "83 C0 66" и "05 66 00 00 00" длиной три и пять байт соответственно. Транслятор может выбрать любую из них и не факт, что ту же самую, которая была в исходной программе (до дизассемблирования). Неверно "угаданный" размер вызовет уплывание всех остальных инструкций, а вместе с ними и данных. Во-вторых, уплывание не замедлит вызвать модификация программы (разумеется, речь идет не о замене JZ на JNZ, а настоящей адоптации или модернизации) и все указатели тут же "посыпаться".
Вернуть работоспособность программы помогает директива "offset". Если "MOV EAX, 0x401020" действительно загружает в EAX указатель, а не константу, по см