tekhnika-i-filosofiya-hakerskih-atak-text

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

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

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

Автор.

Аннотация

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

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

Предисловие редактора

«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),

ФБРСША

Эпиграф выбран неслучайно. Информационная безопасность сегодня представляет одну из весьма “горячих” тем. Ее актуальность весьма велика, и каждое пособие связанное с этой темой подвергается анализу со стороны обычно весьма скептически настроенных специалистов. Исследование программ связано с вопросами информационной безопасности напрямую. Когда автор этой книги пригласил меня, как специалиста, стать ее научным редактором, я отнесся к этой затее с большим интересом.

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

На мой взгляд, эта книга будет интересна весьма широкому кругу читателей. Наверняка ею заинтересуются и те, кто лишь начинает свой восход к Олимпу знаний, и уже “матерые” специалисты в области программирования и исследования программ (или на иностранный манер “reverseengineering”). Хочется особенно отметить, что материалы книги устроены таким образом, что будут полезны и обычному программисту (как пособие по оптимизации программ для современных интеллектуальных компиляторов), и специалистам различных направлений (например, специалистам информационной защиты ‑ в качестве пособия по поиску так называемых “закладок”). Стиль изложения “от простого к сложному” позволяет говорить также и о том, что данная книга послужит также и учебным пособием для начинающих исследователей и “кодокопателей”.

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

Однако, я все-таки рекомендую читателю подвергнуть сомнению все вышесказанное и убедиться во всем самостоятельно, прочитав данную книгу.

С уважением,

Хади Р.А.

«Как бы плохо вы ни написали вашу повесть, у вас обязательно найдутся читатели, тысячи читателей, которые сочтут ее шедевром… Как бы хорошо вы ни написали свою повесть, обязательно найдутся читатели, и это будут тысячи читателей, которые сочтут ее чистым барахлом»

Борис Hатанович Стругацкий

Первое издание «Техники и философии хакерских атак» – довольно фривольное и хаотичное – по стилю изложения напоминало собой «Путевые заметки охотника» – читается, может быть и интересно, но вот на учебник, увы не тянет. К моему огромному удивлению книга имела ощутимый успех и множество одобрительных откликов. Одно, конечно, понятно – на безрыбье и рак рыба – за последнее время ничего путного по данной тематике не выходило.

Когда же тираж книги был полностью распродан, но заявки на нее по-прежнему продолжали поступать, встал вопрос – что делать дальше: выпускать «в один к одному» допечатку или переработанное и дополненное второе издание? Издатель склонялся к последнему, да и я в желании утолить свой профессиональный зуд, признаться, тоже. Однако за время, прошедшее с момента первого издания, я стал писать значительно структурней и «чище». Поэтому, после долгих колебаний, сомнений и размышлений решил полностью переписать книгу «с нуля», превратив ее в реальную настольную книгу хакера. Своеобразный справочник кодокопателя, но вместе с тем и учебник, помогающий начинающим сделать в мире хакерства свои первые шаги.

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

Объем книги увеличился настолько, что ее пришлось разбить на несколько томов. Этот, первый из них, посвящен базовым основам хакерства – технике работы с отладчиком и дизассемблером. Затронуты вопросы защиты программ от изучения и техника нейтрализации защит, впрочем, подробный рассказ о методике создания и снятия защитных механизмов – тема последующих томов.

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

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

Приписывается китайскому мудрецу Лао-цзы

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

Сегодня же «хакер» звучит практически так же как «национальный герой», пускай и преступный, но все же крутой малый, которому не грех подражать. Чем же объясняется такая метаморфоза?

По одной из гипотез в щелчке, издаваемом реле, американцам слышалось «хак - хак». Динозавры машинной эры состояли из многих тысяч реле и «хакали» во всю, особенно когда оператор ЭВМ запускал очередную программу на выполнение. Возможно, именно за это операторов и прозвали «хакерами». Или, говоря по-русски «клацальщиками». По другой гипотезе звук «хак» приписывается перфоратору, кромсающему перфоленту на мелкие куски, так что щепки (такие аккуратненькие круглые «щепочки») во все стороны летят!

На ассоциативном уровне обе гипотезы вполне правдоподобны. И реле, и перфоратор издают повторяющиеся монотонные удары, чем-то напоминающие кашель, а выражение «кашлять сухим кашлем» - одно из значений слова «hack». К тому же, программировали «динозавров» исключительно в машинных кодах, подчас с помощью переключателей или перетыкивания разъемов, - физически тяжелая, нудная, неблагодарная работа, достающаяся наименее привилегированной части персонала. Какой там романтизм? Какое изящество решений или полет мысли? Халтура сплошная… Редкая программа обходится без ошибок, а программа, составленная в машинных кодах – тем более. При желании любого оператора было можно назвать халтурщиком – «хакером» в ругательном смысле этого слова. «Вот, наделал кучу ошибок, хакер ты наш!»

Обыватели же, далекие от вычислительной техники, и знакомые с ней исключительно по фантастическим романам, испытывали перед ЭВМ благоговейное уважение, подогреваемое гордостью за научно-технические достижения всего рода homosapiens в целом и американской нации в частности. «Белые воротнички» – цвет нации, управляющие махиной размером с супермаркет и стоящей дороже тысячи таких супермаркетов, вызывали у рядового американца смесь восторга, зависти и стремления к подражанию. Вроде как «я тоже хочу быть космонавтом», не задумываясь о том, что космонавтика это только с виду романтика, а в действительности – каторжная работа.

Но, если желание побывать в космосе до сих пор смогли реализовать лишь единицы, то ЭВМ стали широко доступными уже в начале шестидесятых. К тому времени их было можно встретить и в подвалах университетов, и в стенах крупных корпораций, и практически во всех исследовательских учреждениях. Очутиться за пультом ЭВМ в создании студента означало практически то же самое, что и «сесть за штурвал реактивного бомбардировщика». Программирование ассоциировалось отнюдь не с «батрачеством», а с интеллектуальной игрой. И «старшие наставники» студентов – операторы ЭВМ были не только их руководителями, но и кумирами. Студенты, одержимые вычислительной техникой, стремились во всем копировать персонал, обслуживающий большие ЭВМ, часто без понимания сути происходящего. Прознав жаргонное прозвище операторов, студенты, не догадываясь о его иронично – оскорбительном оттенке, с достоинством стали называть хакерами и себя и своих товарищей, и даже свою работу окрестили «хакерством». Но в их устах слово «хакер» звучало отнюдь не насмешкой, а расценивалось как титул. Ты – хакер, значит, ты такой же мастер, как и настоящий оператор ЭВМ. Значит, ты крутой парень и перед тобой не стыдно снять шляпу.

Так «хакеры» из работяг превратились в программистов – энтузиастов, помешенных на компьютерах и выделывающих на них такое… такое, что другим и не снилось. Термин продолжал видоизменяться, мигрируя своими значениями в сторону «крутого трюка», «забавного эффекта», «выполненного со вкусом розыгрыша». Этот дух подхватили и другие факультеты, порой и вовсе не связанные ни с электроникой, ни с вычислительной техникой, ни даже с точными науками вообще. «Хаком» стали называть любой классный розыгрыш или нестандартное решение знакомой задачи, – жаргонный термин технического языка превратился в модное словечко, употребляемое всеми кому не лень.

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

«Счастливое исключение» составили программисты. В те дни компьютерные системы еще не успели обзавестись достойной защитой, но уже управляли стратегически и экономическими важными объектами. Власть над компьютерами позволяла дать хорошего пинка и правительственным организациям, и финансовым магнатам, и корпорациям, и другим сильным мира сего, причем, оставаясь безнаказанным. Не существовало ни соответствующих законов, ни компьютерной полиции, способной «вычислить» преступника…

Словом, дикий запад времен разбоя, романтики и беспредела, когда человек с кольтом мог заставить шерифа мирного уездного городка «слушать Шопена лежа». У американцев надо сказать, по поводу освоения Америки очень сильный комплекс – одних вестернов они сняли больше, чем мы фильмов про Великую Отечественную Войну. Понятно дело, каждый юный американец в душе мнит себя полноправным ковбоем!

Компьютеры же позволили воплотить эту мечту в жизнь. Освой ЭВМ и носись по электронным сетям, как «неуловимый Джо», отстреливающий индейцев (банкиров, ЦРУ-шников и т.д.). Да и как не носиться, когда на книжных лотках как грибы появлялись фантастические романы, главными героями которых были компьютерные взломщики – хакеры. Писатели, никогда в жизни не видевшие ЭВМ, плохо разбирались в техническом жаргоне и употребляли его на интуитивно-бессознательном уровне безо всякого понимания. Достаточно перелистать «TheShockwareRider» Джона Бруннера (JohnBrunner) 1975 года, «TheAdolescenceofP‑1» Томаса Риана (ThomasRyan) 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 компилятор по вкусу (в книге подробно описываются особенности компиляторов MicrosoftVisualC++, BorlandC++, WATCOMC, GNUC, FreePascal, а за основу взят MicrosoftVisualC++ 6.0).

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

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

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

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

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

::DDK. (DriverDevelopmentKit – комплект разработчика драйверов). Какую пользу может извлечь хакер из пакета 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 как о страшном сне, так стоит ли хвататься за эту умирающую платформу? К слову сказать, WindowsMe это не то же самое, что Windows 2000 и ставить Me на свой компьютер я никому не рекомендую (такое впечатление, что WindowsMe вообще не тестировали, а о том, что ее писали садисты – кто ставил, тот поймет – я вообще молчу).

Худо-бедно разобравшись с инструментарием, поговорим о сером веществе, ибо в его отсутствии весь собранный инструмент бесполезен. Авторполагает, что читатель уже знаком с ассемблером и, если не пишет программ на этом языке, то, по крайней мере, представляет себе что такое регистры, сегменты, машинные инструкции и т.д. В противном случае эта книга рискует показаться через чур сложной и непонятной. Отыщите в магазине любой учебник по ассемблеру (например: В. Юрова «ASSEMBLER – учебник», П.И. Рудакова «Программируем на языке ассемблера IBMPC» или «Assembler – язык неограниченных возможностей» Зубкова С.В) и основательно проштудируйте его.

Помимо знания ассемблера так же потребуется иметь хотя бы общие понятия о функционировании операционной системы. Купите и вдумчиво изучите (если не сделали этого до сих пор) «Windows для профессионалов» Джефри Рихтера {»» сноска см «Приложение», «Ошибки Джефри Рихтера»} и (если найдете) «Секреты системного программирования в Windows 95» Мэта Питрека. Хотя его книга посвящена Windows 95, частично она справедлива и для Windows 2000. Для знакомства с архитектурой самой же Windows 2000 рекомендуется ознакомиться с шедевром Хелен Кастер «Основы WindowsNT» и брошюрой «Недокументированные возможности WindowsNT» А.В. Коберниченко.

Касаемо общей теории информатики и алгоритмов – бесспорный авторитет Кнут. Впрочем, на мой вкус монография М. Броя «Информатика» куда лучше, - при том что она намного короче, круг охватываемых ей тем и глубина изложения – намного шире. Зачем хакеру теория информатики? Да куда же без нее! Вот, скажем, встретится ему защита со встроенным эмулятором машины Тьюринга.. Слету ее не сломать, - надо как минимум опознать сам алгоритм: что это вообще такое – Тьюринг, Марков, сеть Петри, а затем – отобразить его на язык высокого уровня, дабы в удобочитаемом виде анализировать работу защиты. Куда же тут без теории информатики!

За сим все. Ну, разве что стоит дополнить наш походный рюкзачок парой учебников по английскому (они пригодятся, поверьте) и выкачать с сайтов Intel и AMD всю имеющуюся там документацию по процессорам. На худой конец подойдет и ее русский перевод, например, Ровдо А.А. «Микропроцессоры от 8086 до PentiumIIIXeon и AMDK6-3».

Ну-с, рюкзачок на плечо и в путь…

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

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

«Стать хакером очень просто. Достаточно выучить и понять: математический анализ, теорию функций комплексного переменного, алгебру, геометрию, теорию вероятностей, математическую статистику, математическую логику и дискретную математику…».

Борис Леонтьев «Хакеры & Internet».

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

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

Защиты, основанные на знании, бесполезны, если обладатель защищенной с их помощью программы, не заинтересован в сохранении ее секретности. Он может сообщить пароль (серийный номер) кому угодно, после чего любой сможет запустить такую программу на своем компьютере.

Поэтому, парольные защиты для предотвращения пиратского копирования программ непригодны. Почему же тогда практически все крупные производители в обязательном порядке используют серийные номера? Ответ прост – для защиты своей интеллектуальной собственности грубой физической силой. Происходит это приблизительно так: …рабочая тишина такой-то фирмы внезапно нарушается топотом парней в камуфляже, сверяющих лицензионные номера Windows (MicrosoftOffice, MicrosoftVisualStudio) с лицензионными соглашениями, и стоит обнаружиться хотя бы одной «левой» копии, как появившийся, словно из-под земли, сотрудник фирмы начинает радостно потирать руки в предвкушении дождя вечнозеленых… В лучшем случае – заставят купить все «левые» копии, в худшем же…

К домашним пользователям в квартиру, понятное дело, никто не врывается – частная собственность и все такое, да к этому никто собственно и не стремится. Что с домашнего пользователя возьмешь-то? К тому же, самим фирмам выгодно массовое распространение их продукции, а кто его обеспечит лучше пиратов? Но и здесь серийные номера не лишние – они разгружают службу технической поддержки от «левых» звонков незарегистрированных пользователей, одновременно с этим склоняя последних к покупке легальной версии.

Такая схема защиты идеальна для корпораций-гигантов, но она не подходит для мелких программистских коллективов и индивидуальных разработчиков, особенно если они зарабатывают на жизнь написанием узкоспециализированных программ с ограниченным рынком сбыта (скажем, анализаторов звездных спектров или системы моделирования ядерных реакций). Не имея достаточного влияния, «раскачать» сотрудников известных органов на облаву по проверки лицензионности своего ПО нереально, а «выбить» деньги из нелегальных пользователей можно разве что с помощью криминальных структур, да и то навряд ли. Вот и приходится рассчитывать лишь на собственную силу и смекалку.

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

Таким образом, программа жестко привязана к диску (дискете) и требует ее присутствия для своей работы, а, поскольку скопировать такой диск нереально (попробуй-ка, добиться идентичных дефектов на копиях), пираты «отдыхают».

Защитные механизмы, основанные на обладании, часто модифицирует предмет обладания в процессе работы, ограничивая количество запусков программы или время ее использования. Особенно часто такая «фишка» используется в инсталляторах – чтобы не нервировать пользователя, ключ запрашивается лишь однажды – на стадии установки программы, а работать с ней можно и без него. Если количество инсталляций ограничено, ущербом от несанкционированных установок одной копии программы на несколько компьютеров можно пренебречь.

Единственная проблема – все это ущемляет права легального пользователя. Кому понравится ограничение на количество инсталляций? (А ведь некоторые люди переустанавливают систему и все ПО буквально каждый месяц, а то и несколько раз на дню). Ключевые диски распознаются не всеми типами приводов, зачастую «не видимы» по сети, а, если защитный механизм для увеличения стойкости к взлому, обращается к оборудованию напрямую, в обход драйверов, такая программа наверняка не будет функционировать под WindowsNT\2000 и весьма вероятно откажет в работе под Windows 9x (если, конечно, она не была заранее спроектирована соответствующим образом, но если так – это хуже, ибо некорректно работающая защита, исполняющаяся с наивысшими привидениями, может причинить немалый урон системе). Помимо этого ключевой предмет можно потерять, его могут украсть, да и сам он может выйти из строя (дискеты склонны сыпаться и размагничиваться, диски – царапаться, а электронные ключи – «сгорать»).

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

В последнее время наибольшую популярность обрели защиты, основанные на регистрационных номерах –при первом запуске программа привязывается к компьютеру и включает «счетчик» (вариант – блокирует некоторые функциональные возможности). А чтобы ее «освободить» необходимо ввести пароль, сообщаемый разработчиком за некоторое материальное вознаграждение. Часто для предотвращения пиратского копирования пароль представляет собой некоторую производную от ключевых параметров компьютера (или производную от имени пользователя в простейшем случае).

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

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

Философия стойкости

Однажды один из друзей сказал Катону Старшему: «Какое безобразие, что в Риме тебе до сих пор не воздвигли памятника! Я обязательно позабочусь об этом».

«Не надо, - ответил Катон, - я предпочитаю, чтобы люди спрашивали, почему нет памятника Катону, чем почему он есть.

Т. Мессон

Если защита базируется на одном лишь предположении, что ее код не будет изучен и/или изменен – это плохая защита. Отсутствие исходных текстов отнюдь не является непреодолимым препятствием для изучения и модификации приложения. Современные технологии обратного проектирования позволяют автоматически распознавать библиотечные функции, локальные переменные, стековые аргументы, типы данных, ветвления, циклы и т.д. А в недалеком будущем дизассемблеры, вероятно, вообще научатся генерировать листинги близкие по внешнему виду к языкам высокого уровня.

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

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

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

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

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

Аноним

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

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

Давайте дополним этот код процедурами запроса пароля и вывода результатов сравнения, а затем испытаем полученную программу на «прочность», т.е. стойкость к взлому.

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

#include <stdio.h>

#include <string.h>

#define PASSWORD_SIZE 100

#define PASSWORD «myGOODpassword\n»

этот перенос нужен затем, чтобы ^^^^ не выкусывать перенос из строки,

введенной пользователем int main() { Счетчик неудачных попыток аутентификации

intcount=0;

Буфер для пароля, введенного пользователем char buff[PASSWORD_SIZE]; Главный цикл аутентификации

for(;;)

{

Запрашиваем и считываем пользовательский пароль

printf(«Enter password:»);

fgets(&buff[0],PASSWORD_SIZE,stdin);

Сравниваем оригинальный и введенный пароль if (strcmp(&buff[0],PASSWORD)) Если пароли не совпадают – «ругаемся»

printf(«Wrong password\n»);

Иначе (если пароли идентичны) выходим из цикла аутентификации

elsebreak;

Увеличиваем счетчик неудачных попыток аутентификации и, если все попытки

исчерпаны – завершаем программу if (++count>3) return –1; } Раз мы здесь, то пользователь ввел правильный пароль

printf(«Password OK\n»);

}

Листинг 1 Пример простейшей системы аутентификации

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

Не так уж редко пароли представляют собой осмысленные слова, наподобие «Ferrari», «QWERTY», имена любимых хомячков, названия географических пунктов и т.д. Угадывание пароля сродни гаданию на кофейной гуще – никаких гарантий на успех нет, остается рассчитывать на одно лишь везение. А удача, как известно, птица гордая – палец ей в рот не клади. Нет ли более надежного способа взлома?

Давайте подумаем – раз эталонный пароль хранится в теле программы, то, если он не зашифрован каким-нибудь хитрым образом, его можно обнаружить тривиальным просмотром двоичного кода программы. Перебирая все, встретившиеся в ней текстовые строки, начиная с тех, что более всего смахивают на пароль, мы очень быстро подберем нужный ключ и «откроем» им программу!

Причем, область просмотра можно существенно сузить, – в подавляющем большинстве случаев компиляторы размешают все инициализированные переменные в сегменте данных (в PE-файлах он размещается в секции «.data»). Исключение составляют, пожалуй, ранние Багдадские (Borland-вые в смысле) компиляторы с их маниакальной любовью всовывать текстовые строки в сегмент кода – непосредственно по месту их вызова. Это упрощает сам компилятор, но порождает множество проблем. Современные операционные системы, в отличие от старушки MS-DOS, запрещают модификацию кодового сегмента, и все, размешенные в нем переменные, доступны лишь для чтения. К тому же, на процессорах с раздельной системой кэширования (на тех же Pentium-ах, например) они «засоряют» кодовый кэш, попадая туда при упреждающем чтении, но при первом же к ним обращении вновь загружаются из медленной оперативной памяти (кэша второго уровня) в кэш данных. В результате – тормоза и падение производительности.

Что ж, пусть это будет секция данных! Остается только найти удобный инструмент для просмотра двоичного файла. Можно, конечно, нажать <F3> в своей любимой оболочке (FAR, DOSNavigator) и, придавив кирпичом <PageDown> любоваться бегущими циферками до тех пор, пока не надоест. Можно воспользоваться любым hex-редактором (QVIEW, HIEW…) – кому какой по вкусу, но в книге по соображениям наглядности я приведу результат работы утилиты DUMPBIN из штатной поставки MicrosoftVisualStudio.

Попросим ее распечатать секцию данных (ключ »/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@…..

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

Один из способов сделать это – насильно поместить эталонный пароль в собственноручно выбранную нами секцию. Такая возможность не предусмотрена стандартом и потому каждый разработчик компилятора (строго говоря, не компилятора, а линкера, но это не суть важно) волен реализовывать ее по-своему (или не реализовывать вообще). В MicrosoftVisualC++ для этой цели предусмотрена специальная прагма data_seg, указывающая в какую секцию помещать следующие за ней инициализированные переменные. Неинициализированные переменные по умолчанию располагаются в секции «.bbs» и управляются прагмой bss_seg соответственно.

Добавим в Листинг 1 следующие строки и посмотрим, что из этого у нас получится.

int count=0;

С этого момента все инициализированные переменные будут размещаться в секции ».kpnc«

#pragmadata_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«) – не все там догадаются поискать, а работоспособность программы такое размещение не нарушит. Но не стоит забывать о возможности автоматизированного поиска текстовых строк в двоичном фале.. В какой бы секции ни содержался эталонный пароль – фильтр без труда его найдет (единственная проблема – определить какая из множества текстовых строк представляет собой искомый ключ; возможно, потребуется перебрать с десяток-другой потенциальных «кандидатов»). Правда, если пароль записан в уникоде, его поиск несколько осложняется, т.к. не все утилиты поддерживают эту кодировку, но надеяться, что это препятствие надолго задержит хакера – несколько наивно. ===== Шаг второй. Знакомство с дизассемблером ===== Надо ли милостивого бога все время просить о пощаде? Велимир О'кей, пароль мы узнали. Но как же утомительно вводить его каждый раз с клавиатуры перед запуском программы! Хорошо бы ее хакнуть так, чтобы никакой пароль вообще не запрашивался или любой введенный пароль программа воспринимала бы как правильный. Хакнуть говорите?! Что ж, это не сложно! Куда проблематичнее определиться – чем именно ее хакать. Инструментарий хакеров чрезвычайно разнообразен – чего тут только нет: и дизассемблеры, и отладчики, и 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. Второй аргумент – указатель на локальный буфер, вероятно, содержащий введенный пользователем пароль. Тут нам придется немного отклониться от темы разговора и подробно рассмотреть передачу параметров. Наиболее распространенны следующие способы передачи аргументов функции – через регистры и через стек. Передача параметров через регистры наиболее быстра, но не лишена недостатков – во-первых, количество регистров весьма ограничено, а во-вторых, это затрудняет реализацию рекурсии – вызова функции из самой себя. Прежде чем заносить в регистры новые аргументы, необходимо предварительно сохранить старые в оперативной памяти. А раз так – не проще ли сразу передать аргументы через оперативную память, не мучаясь с регистрами? Подавляющее большинство компиляторов передает аргументы через стек. Единого мнения по вопросам передачи у разработчиков компиляторов нет и встречаются по крайней мере два различных механизма, именуемые соглашениями »Си« и »Паскаль«. ::Си-соглашение предписывает заталкивать в стек аргументы справа налево, т.е. самый первый аргумент функции заносится в стек последним и оказывается на его верхушке. Удаление аргументов из стека возложено не на саму функцию, а на вызываемый ее код. Это довольно расточительное решение, т.к. каждый вызов функции утяжеляет программу на несколько байт кода, но зато это позволяет создавать функции с переменным числом аргументов – ведь удаляет-то их из стека не сама функция, а вызывающий ее код, который наверняка знает точное количество переданных аргументов. Очистка стека обычно выполняется командой «ADDESP,xxx» – где 'xxx' количество удаляемых байт. Поскольку, в 32-разрядном режиме каждый аргумент, как правило, занимает четыре байта, количество аргументов функции вычисляется так: . Оптимизирующие компиляторы могут использовать более хитрый код – для очистки стека от нескольких аргументов они частенько из «выталкивают» в неиспользуемые регистры командой «POP» или и вовсе очищают стек не сразу же после выхода из функции, а совсем в другом месте – где это удобнее компилятору. ::Паскаль-соглашение предписывает заносить аргументы в стек слева направо, т.е. самый первый аргумент функции заносится в стек в первую очередь и оказывается в его «низу». Удаление аргументов из функции возложено на саму функцию, и обычно осуществляется командой «RETxxx» – т.е. возврат из подпрограммы со снятием xxx байт со стека. Возвращаемое функцией значение в обоих соглашениях передается через регистр EAX (или EDX:EAX при возвращении 64-разрядных переменных). Поскольку, исследуемая нами программа написана на Си и, стало быть, заносит аргументы справа налево, ее исходный текст выглядел приблизительно так: (*0x4010A0) (ebp-68, «myGOODpassword») В том, что аргументов именно два, а не, скажем, четные или десять, нас убеждает команда «ADDESP,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 Что мы видим? Команда TESTEAX,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.. Уже теплее! Указатель вывел нас на строку »Wrongpassword«, очевидно выводимую следующей функцией на экран. Значит, ненулевое значение 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 мы уже встречались выше – она (предположительно) служит для вывода строк на экран. Ну а сами строки можно отыскать в сегменте данных. На этот раз там притаилась «PasswordOK» Оперативные соображения следующие: если заменить команду JE на JNE, то программа отвергнет истинный пароль, как неправильный, а любой неправильный пароль воспримет как истинный. А если заменить «TESTEAX,EAX» на «XOREAX,EAX», то после исполнения этой команды регистр EAX будет всегда равен нулю, какой бы пароль не вводился. Дело за малым – найти эти самые байтики в исполняемом файле и малость поправить их. ===== Шаг третий. Хирургический ===== Не торопитесь на встречу с Богом, еще встретитесь. Народная мудрость Внесение изменений непосредственно в исполняемый файл – дело серьезное. Стиснутым уже существующим кодом, нам приходится довольствоваться только тем, что есть – и ни раздвинуть команды, ни даже «сдвинуть» их, выкинув из защиты «лишние запчасти», не получится. Ведь это привело бы к «сдвигу» смещений всех остальных команд, тогда как значения указателей и адресов переходов остались без изменений и стали указывать совсем не туда, куда нужно! Ну, с «выкидываем запчастей» справится как раз таки просто – достаточно забить код командами NOP (опкод который 0x90, а вовсе не 0х0, как почему-то думают многие начинающие кодокопатели) – т.е. пустой операцией (вообще-то NOPэто просто другая форма записи инструкции XCHGEAX,EAX– если интересно). С «раздвижкой» куда сложнее! К счастью, в PE-файлах всегда присутствует множество «дыр», оставшихся от выравнивания – в них-то и можно разместить свой код или данные. Но не проще ли просто откомпилировать ассемблированный файл, предварительно внеся в него требуемые изменения? Нет, не проще, и вот почему – если ассемблер не распознает указатели, передаваемые функции (а, как мы видели, наш дизассемблер не смог отличить их от констант), он, соответственно, не позаботится должным образом их скорректировать и, естественно, программа работать не будет. Приходится «резать» программу в «живую». Легче всего это делать с помощью утилиты HIEW, «переваривающей» PE-формат файлов и упрощающей тем самым поиск нужного фрагмента. Запустим его, указав имя файла в командной строке «hiewsimple.exe», двойным нажатием <Enter> переключимся в режим ассемблера и по <F5> перейдем к требуемому адресу. Как мы помним, команда «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) Ага, как раз то, что нам надо! Нажмем <F3> для перевода HIEW в режим правки, подведем курсор к команде «TESTEAX,EAX» и, нажав <Enter>, заменим ее на «XOREAX,EAX». 00001056: 33C0 xor eax,eax 00001058: 740F je 000001069 С удовлетворением заметив, что новая команда в аккурат вписалась в предыдущую, нажмем <F9> для сохранения изменений на диске, а затем выйдет из HIEW и попробуем запустить программу, вводя первый пришедший на ум пароль. >simple.exe Enter password:Привет, шляпа! Password OK Получилось! Защита пала! Хорошо, а как бы мы действовали, не умей HIEW «переваривать» PE-файлы? Тогда бы пришлось прибегнуть к контекстному поиску. Обратим свой взор на шестнадцатеричный дамп, расположенный дизассемблером слева от ассемблерных команд. Конечно, если пытаться найти последовательность «85 C0» – код команды «TESTEAX,EAX» ничего путного из этого не выйдет, – этих самых TEST-ов в программе может быть несколько сотен, а то и больше. Комбинация «ADDESP,8\TESTEAX,EAX» так же вряд ли будет уникальна, поскольку встречается во многих типовых конструкциях языка Си «if (func(arg1,arg2))…», «if (!func(arg1,arg2))…», «while(func(arg1,arg2)» и т.д. А вот адрес перехода, скорее всего, во всех ветках программы различен и подстрока «ADDESP,8/TESTEAX,EAX/JE 00401069» имеет хорошие шансы на уникальность. Попробуем найти в файле соответствующий ей код: «83 C4 08 85 C0 74 0F» (в HIEW-е для этого достаточно нажать <F7>). Опп-с! Найдено только одно вхождение, что нам собственно и нужно. Давайте теперь попробуем модифицировать файл непосредственно в hex-режиме, не переходя в ассемблер. Попутно возьмем себе на заметку – инверсия младшего бита кода команды приводит к изменению условия перехода на противоположное. Т.е. 74 JE 75 JNE. Работает? (В смысле защита свихнулась окончательно – не признает истинные пароли, зато радостно приветствует остальные). Замечательно! Остается решить: как эту взломанную программу распространять. То есть, распространить-то ее дело не хитрое – на то и существуют CDR-писцы, BBS-ы, сеть Интернет, наконец! Заливай, пиши, нарезай – не хочу. Не хотите – и правильно! Незаконное это дело – распространять программное обеспечение в обход его владельца. Эдак, и засадить могут (причем прецеденты уже имеются). Куда безопаснее возложить распространение программы на ее дистрибьюторов, но до каждого пользователя донести: как эту программу сломать. Ковыряться в законном образом приобретенном приложении потребитель вправе, а распространение информации о взломе не запрещено в силу закона о свободе информации. Правда, при ближайшем рассмотрении выясняется, что этот закон и у нас, и за океаном действует лишь формально, и, если не посадить, то по крайне мере попытаться это сделать, право охранительные органы вполне могут (и не только могут, но и делают). Когда дело касается чьих-то финансовых интересов, правосудие «отдыхает». Наивно думать, что соблюдение закона автоматически дает некие гарантии. Нет, и еще раз нет! Чувствовать себя в относительной безопасности можно лишь при условии соблюдения кодекса »да не навреди сильным мира сего«. В любом случае – информация о взломе это не совсем тоже, что сам взлом и за это труднее привлечь к ответственности. Единственная проблема – попробуй-ка, объясни этим пользователям: как пользоваться hex-редактором и искать в нем такие-то байтики. Запорют же ведь файл за милую душу! Вот для этой цели и существуют автоматические взломщики. Для начала нужно установить, какие именно байты были изменены. Для этого нам вновь потребуется оригинальная копия модифицированного файла (предусмотрительно сохраненная перед его правкой) и какой-нибудь «сравниватель» файлов. Наиболее популярными на сегодняшний день являются c2u by ProfessorNimnulи MakeCrk by DoctorStein'slabs. Первый гораздо предпочтительнее, т.к. он не только более точно придерживается наиболее популярного «стандарта», но и умеет генерировать расширенный xck формат. На худой конец можно воспользоваться и штатной утилитой, входящей в поставку MS-DOS\Windows – fc.exe (сокращение от FileCompare). Запустим свой любимый компаратор (это уж какой кому больше по душе) и посмотрим на результат его работы: > fc simple.exe simple.ex_ > simple.dif ^-оригинальный ^ файл ^ └- хакнутый‌файл └- файл различий > typesimple.dif Сравнение файлов simple.exe и SIMPLE.EX_ 00001058: 74 75 Первая слева колонка указывает смещение байта от начала файла, вторая – содержимое байта оригинального файла, а третья – его значение после модификации. Теперь сравним это с отчетом утилиты c2u: >c2u simple.exe simple.ex_ Все исправления заносятся в файл *.crx, где «*» – имя оригинального файла. Рассмотрим результат сравнения поближе: >typesimple.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». »Cracksubject« – предмет крака, - т.е. что собственно мы только что сломали. Пишем «Парольная защита simple.exe» »Usedpacker« – используемый упаковщик. Еще во времена старушки MS-DOS существовали и были широко распространены упаковщики исполняемых файлов, автоматически разжимающие файл в памяти при его запуске. Этим достигалась экономия дискового пространства (вспомните: какими смехотворными по нынешним временам были размеры винчестеров конца восьмидесятых-начала девяностых?) и параллельно с этим усиливалась защита – ведь упакованный файл недоступен для непосредственного изучения, а тем более – правки. Прежде, чем начать что-то делать, файл необходимо распаковать, причем это делать приходится и самому ломателю, и всем пользователям этого crk-файла. Поскольку, наш файл не был упакован – оставим это поле пустым, или запишем в него »None«. »Usedunpacker« – рекомендуемый распаковщик (если он необходим). Дело в том, что не все распаковщики одинаковы, многие упаковщики весьма продвинуты в технике защиты и умело сопротивляются попыткам их «снять». Понятное дело, распаковщики то же не лыком шиты, и держат своих «тузов» в рукавах, но… автоматическая распаковка – штука капризная. Бывает «интеллектуальный» unpacker легко расправляется со всеми «крутыми» packer-ми, но тихо сдыхает на простых защитах, и, соответственно, случается и наоборот. Дабы не мучить пользователей утомительным перебором всех имеющихся у них распаковщиков (пользователь – он ведь то же человек!) правила хорошо тона обязывают указывать по крайней мере один заведомо подходящий unpacker, а лучше – два или три сразу (вдруг какого-то из них у пользователя и не будет). Если же распаковщик не требуется – оставляйте это поле пустым или «None». »Comments« – комментарии. Вообще-то это поле задумано для перечисления дополнительных действий, которые пользователь должен выполнить перед взломом, ну, например, снять с файла атрибут «системный» или, напротив, установить его. Но, поскольку, какие-либо дополнительные действия требуются только в экзотических случаях, в это поле обычно помещают разнообразные лозунги и комментарии (да, правильно, бывает и нецензурную брань по поводу умственных способностей разработчика защиты). »TargetOS« – операционная система для которой предназначен и (внимание!) в которой хакер тестировал сломанный продукт. Вовсе не факт, что программа сохранит после взлома черты своей прежней совместимости. Так, например, поле контрольной суммы Win 9x всегда игнорирует, а WinNT – нет и если его не скорректировать, файл запускаться не будет! В нашем случае контрольная сумма заголовка PE-файла равна нулю (так ведет себя компилятор), что означает – целостность файла не проверяется и он, после хака, будет успешно работать как под Win 9x, так и под WinNT. »Protection« – степень «крутизны» защиты, выражаемой в процентах. 100% по идее соответствуют пределу интеллектуальных возможностей хакера – но кто же в этом захочет признаваться? Неудивительно, что «крутизну» защиты обычно занижают, порой даже больше, чем на порядок (смотрите все, вот я какой крутой хакер, для меня что угодно взломать не сложнее чем кончик хвоста обмочить!). Нечестность – не порок, но… »Typeofhack« – тип хака, - поле полезное, скорее для других хакеров, чем для пользователей, ничего не смыслящих в защитах и типах их взлома. Впрочем, с типами взломов не все гладко и у самих хакеров – общепризнанных классификацией нет. Наиболее употребляемый термин «bit-hack», как и следует из его названия, обозначает взлом посредством изменения одного или нескольких бит в одном или нескольких байтах. Частный случай bit-hack-а – JMPcorrection (jumping) – модификация адреса или условия перехода (то, что мы только что и проделали). «NOPing» – это bit-hack с заменой прежних инструкций на команду NOP или вставку незначащих команд, например для затирания двухбайтового JZxxx можно применить сочетание однобайтовых INCEAX/DECEAX. »Language« – язык, а точнее компилятор, на котором написана программа. В нашем случае MicrosoftVisualC++ (мы это знаем, поскольку только что ее компилировали), а вот как быть с чужими программами? Первое, что приходит на ум, – поискать в файле копирайты – их оставляют очень многие компиляторы, в том числе и VisualC++ - сморите: «000053d9:MicrosoftVisualC++ RuntimeLibrary». Если же компиляторов нет, то пробуем прогнать файл через IDA – она автоматически распознает большинство стандартных библиотек даже с указанием конкретной версии. В крайнем случае – пробует определить язык по самому коду, вспоминая о соглашениях Си и Паскаль, и пытаясь найти знакомые черты известных вам компиляторов (у каждого компилятора свой «почерк» и опытный хакер можно узнать не только чем компилировалась программа, но даже определить ключ оптимизации). »Size« – размер ломаемой программы, служащий для контроля версии (чаще всего, хотя и не всегда, каждая версия программы имеет свой размер). Размер автоматически определяется утилитой c2u и самостоятельно его вставлять нет никакой нужды. »Price« – стоимость лицензионной копии программы (должен же пользовать знать: сколько денег ему сэкономил этот крак!) »Usedtools« – используемые инструменты. Не заполнение этого поля считается дурным тоном – действительно же, интересно, чем именно была хакнута программа! Особенно этим интересуются пользователи, наивно полагающие, что если они раздобудут тот же DUMPBIN и HIEW защита сама собой сломается. »Timeforhack« – время, затраченное на хак, включая перерывы на «перекурить» и «сходить водички попить». Интересно, какой процент людей честно заполняет это поле, не пытаясь показаться «куче» в чужих глазах. Так что особенно доверять ему не следует… »Crackmadeat« – дата завершения крака. Подставляется автоматически и править ее нет необходимости (разве что вы «жаворонок» и хотите выдать себя за «сову», проставляя время окончания взлома 3 часами ночи 31 декабря) »UnderMusic« – музыка, прослушиваемая во время хака (еще не хватает поля «Имя любимого хомячка»). Вы слушали музыку во время хака? Если да – то пишете – пусть все знают ваши вкусы (за одно не забудьте цвет майки и температуру воздуха за ботом выше нуля). В результате всех мучений у нас должно получится приблизительно следующее: [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-разработок можно отметить «Patchmaker» с продвинутым пользовательским интерфейсом (см. Рисунок 2). Он включает в себя сравниватель файлов, crk-редактор, hex-редактор (для ручной замены?) и компилятор crk в исполняемые файлы, чтобы пользователям не приходилось ломать голову: что это за крак такой и как им ломать. Может, кому-то такой интерфейс и понравится, а вот хакеры в свой массе мышь органически не переносят и любят текстовые (консольные) приложения и тетю Клаву. Рисунок 2 0x001 PatchMaker за работой! ===== Шаг четвертый. Знакомство с отладчиком ===== Оставь свои мозги за дверью и внеси сюда только тело Фредерик Тейлор Помимо дизассемблирования существует и другой способ программ – отладка. Изначально под отладкой понималось пошаговое исполнение кода, так же называемое трассировкой. Сегодня же программы распухли настолько, что трассировать их бессмысленно – вы тут же утоните в омуте вложенных процедур, так и не поняв, что они собственно делают. Отладчик – не лучше средство изучения алгоритма программы – с этим лучше справляется интерактивный дизассемблер (например, IDA). Подробный разговор об устройстве отладчика мы отложим на потом (см. »Приемы против отладчиков«), а здесь ограничимся лишь перечнем основных функциональных возможностей типовых отладчиков (без этого невозможно их осмысленное применение): – отслеживание обращений на запись/чтение/исполнение к заданной ячейке (региону) памяти, далее по тексту именуемое «бряком» («брейком»); – отслеживание обращений на запись/чтение к портам ввода-вывода (уже не актуально для современных операционных систем, запрещающих пользовательским приложениям проделывать такие трюки – это теперь прерогатива драйверов, а на уровне драйверов реализованы очень немногие защиты); – отслеживание загрузки DLL и вызова из них таких-то функций, включая системные компоненты (как мы увидим далее – это основное оружие современного взломщика); – отслеживание вызова программных/аппаратных прерываний (большей частью уже не актуально, - не так много защит балуется с прерываниями); – отслеживание сообщений посылаемых приложением окну; – и, разумеется, контекстный поиск в памяти. Как именно делает отладчик – пока знать необязательно, достаточно знать, что он это умеет и все. Куда актуальнее вопрос, – какой отладчик умеет это делать? Широко известный в пользовательских кругах TurboDebugger на само деле очень примитивный и никчемный отладчик – очень мало хакеров им что-то ломает. Самое мощное и универсальное средство – Soft-Ice, сейчас доступный для всех Windows-платформ (а когда он поддерживал лишь одну Windows 95, но не WindowsNT). Последняя на момент написания книги, четвертая версия, не очень-то стабильно работает с моим видеоадаптером, поэтому приходится ограничиваться более ранней, но зато устойчивой версией 3.25. ==== Способ 0. Бряк на оригинальный пароль. ==== Используя поставляемую вместе с «Айсом» утилиту «wldr» загрузим ломаемый нами файл, указав его имя в командной строке, например, так: >wldrsimple.exe Да, я знаю, что wldr – 16-разрядный загрузчик, и NuMega рекомендует использовать его 32-разрядную версию loader32, специально разработанную для Win 9x\NT. Это так, но loader32 частенько глючит (в частности не всегда останавливается на первой строчке запускаемой программы), а wldr успешно работает и 32-разрядными приложениями, единственный присущий ему недостаток – отсутствие поддержки длинных имен файлов. Если отладчик настроен корректно, на экране появится черное текстовое окно, обычно вызывающее большое удивление у начинающих – это в нашу-то это эпоху визуальщины серый текст и командный язык alacommand.com! А почему бы и нет? Набрать на клавиатуре нужную команду куда быстрее, чем отыскать ее в длинной веренице вложенных меню, мучительно вспоминая где же вы ее в последний раз видели. К тому же язык – это естественное средство выражения мыслей, а меню – оно годится разве что для выбора блюд в ресторане. Вот хороший пример – попробуйте с помощью проводника Windows вывести на печать список файлов такой-то директории. Не получается? А в MS-DOS это было так просто dir >PRNи никаких лаптей! Если в окне кода видны одни лишь инструкции «INVALID» (а оно так и будет) не пугайтесь – просто Windows еще не успела спроецировать исполняемый файл в память и выделить ему страницы. Стоит нажать <F10> (аналог команды «P» – трассировка без заходов в функцию) или <F8> (аналог команды «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» – если окна данных нет) и, нажав, <ALT-D> для перехода в это окно, прокрутим его содержимое <стрелкой вниз> или кирпичом на <PageDown>. Впрочем, кирпич излишен, – долго искать не придется: 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, мы поймаем «за хвост» сравнивающий механизм. Сказано – сделано. :bpm 406040 Теперь нажимаем <CTRL-D> для выхода из отладчика (или отдаем команду «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, а к памяти, стало быть, обратилась инструкция CMPAL, [ECX]. А что находится в AL? Поднимаем взгляд еще строкой выше – «MOVEAX,[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», который анализирует правильный – не правильный пароль. Для этого служит команда «PRET» (трассировать пока не встреться 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» или записываем последовательность байт, идентифицирующую… эй, постойте, а где же наши байты – шестнадцатеричное представление команд? Коварный Айс по умолчанию их не выводит, и заставить его это делать помогает команда «CODEON» 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 <stdio.h> #include <windows.h> 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 1) && (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 [FixUp] table по-английски). За ее формирование отвечает линкер (он же – компоновщик) и такая таблица присутствует в каждой DLL. Чтобы познакомиться с ней поближе откомпилируем и изучим следующий пример: ::fixupdemo.c declspec(dllexport) void meme(int x) { static int a=0x666; a=x; } > cl fixupdemo.c /LD Листинг 3 Исходный текст fixupdemo.c Откомпилируем и тут же дизассемблируем его: «DUMPBIN /DISASMfixupdemo.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 (например, copyfixupdemo.dllfixupdemo2.dll) и загрузим их поочередной следующей программой: ::fixupload.c #include <windows.h> main() { void (*demo) (int a); HMODULE h; if 2) && (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. Конечно, библиотечное имя функций в нем отсутствует, но в «QuickInfo» мимоходом приводится информация и поддержке уникода (если таковая присутствует). А раз есть уникод – есть суффиксы 'W' и 'A', соответственно, наоборот – где нет уникода, нет и суффиксов. Проверим? Вот так выглядит QuickInfo от 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 версии на WindowsNT«. Стоп! С 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] Отдаем команду «PRET» для выхода из 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). Продолжая трассировку (<F10>), дождитесь выполнения второго вызова LoadLibraryA – не правда ли, на этот раз адрес загрузки изменился? (на моем компьютере он равен 0x0530000). Приблизившись к вызову функции demo (в отладчике это выглядит как PUSH 00000777\ CALL [EBP-04] – «EBP-04» ни о чем не говорит, но вот аргумент 0x777 определенно что-то нам напоминает, - см. исходный текст fixupload.c), не забудьте переменить руку с <F10> на <F8>, чтобы войти внутрь функции. 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? Обратим свой взор выше, на команды, заведомо не содержащие перемещаемых элементов – PUSHEBP/MOVEBP, ESP/MOVEAX,[EBP+08]. Отчего бы не поискать последовательность «55 8BEC 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 не очищается! Отсюда следует, что если после выдачи ругательства «Wrongpassword» вызвать отладчик и пройтись по памяти контекстным поиском, можно обнаружить тот заветный buff, а остальное уже – дело техники! Итак, приступим (мы еще не знаем, во что мы ввязываемся – но, увы – в жизни все сложнее, чем в теории). Запускам SIMPLE.EXE, вводим любой пришедший на ум пароль (например, «KPNCKaspersky++»), пропускаем возмущенный вопль «Wrong» мимо ушей и нажимаем <Ctrl-D> - «горячую» комбинацию клавиш для вызова Айса. Так, теперь будем искать? Подождите, не надо бежать впереди лошадей: Windows 9x\NT – это не Windows 3.x и, тем более, не MS-DOS с единым адресным пространством для всех процессоров. Теперь, по соображениям безопасности, - дабы один процесс ненароком не залез во владения другого, каждому из них предоставляется собственное адресное пространство. Например, у процесса A по адресу 23:0146660 может быть записано число «0x66», у процесса B по тому же самому адресу 23:0146660 может находиться «0x0», а у процесса C и вовсе третье значение. Причем, процессы А, B и C не будет даже подозревать о существовании друг друга (ну, разве что воспользуются специальными средствами межпроцессорного взаимодействия). Подробнее обо всем этом читайте у Хелен или Рихтера, здесь же нас больше заботит другое – вызванный по <Ctrl-D> отладчик «всплывает» в произвольном процессе (скорее всего Idle) и контекстный поиск в памяти ничего не даст. Необходимо насильно переключить отладчик в необходимый контекст адресного пространства и лишь затем что-то предпринимать. Из прилагаемой к Айсу документации можно узнать, что переключение контекстов осуществляется командой ADDR, за которой следует либо имя процесса, урезанное до восьми символов, либо его PID. Узнать и то, и другое можно с помощью другой команды – PROC (В том, случае если имя процесса синтаксически неотличимо от PID, например, «123», приходится использовать PID процесса – вторая колонка цифр слева, в отчете PROC). :addrsimple Отдаем команду »addrsimple« и… ничего не происходит, даже значения регистров остаются неизменными! Не волнуйтесь – все ОК, что и подтверждает надпись 'simple' в правом нижнем углу, идентифицирующая текущий процесс. А регистры… это небольшой глюк Айса. Он них игнорирует, переключая только адреса. В частности поэтому, трассировка переключенной программы невозможна. Вот поиск – другое дело. Это – пожалуйста! :s 23:0 L -1 «KPNCKaspersky» Пояснения: первый слева аргумент после s – адрес, записанный в виде «селектор: смещение». Под Windows 2000 для адресации данных и стека используется селектор номер 23, в других операционных системах он может отличаться (и отличается!). Узнать его можно загрузив любую программу, и списав содержимое регистра DS. Смещение – вообще-то, начинать поиск с нулевого смещения – идея глупая. Судя по карте памяти, здесь расположен служебный код и искомого пароля быть не может. Впрочем, это ничему не вредит, и так гораздо быстрее, чем разбираться: с какого адреса загружена программа, и откуда именно начинать поиск. Третий аргумент – »L –1« – длина региона для поиска. »-1«, как нетрудно догадаться, – поиск «до победы». Далее - обратите внимание, что мы ищем не всю строку – а только ее часть (»KPNCKaspersky++« против »KPNCKaspersky«) . Это позволяет избавиться от ложных срабатываний – Айс любит выдавать ссылки на свои внутренние буфера, содержащие шаблон поиска. Вообще-то они всегда расположены выше 0х80000000. Там – где никакой нормальный пароль «не живет», но все же будет нагляднее если по неполной подстроке находится именно наша строка. Pattern found at 0023:00016E40 (00016E40) Так, по крайней мере, одно вхождение уже найдено. Но вдруг в памяти есть еще несколько? Проверим это, последовательно отдавая команды «s» вплоть до выдачи сообщения «Patternnotfound» или превышении адреса поиска 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.. Ну, а остальное мы уже проходили. Выходим из сравнивающей процедуры по PRET, находим условный переход, записываем его адрес (ключевую последовательность для поиска), правим исполняемый файл и все ОК. Итак, мы познакомились с одним более или менее универсальным способом взлома защит основанных на сравнении пароля (позже мы увидим, что он так же подходит и для защит, основанных на регистрационных номерах). Его основное достоинство – простота. А недостатки… недостатков у него много. – если программист очистит буфера после сравнения, поиск веденного пароля ничего не даст. Разве что останутся системные буфера, которые так просто не затрешь, но отследить перемещения пароля из системных буферов в локальные не так-то просто! – ввиду изобилия служебных буферов, очень трудно определить: какой из них «настоящий». Программист же может располагать буфер и в сегменте данных (статический буфер), и в стеке (локальный буфер), и в куче, и даже выделять память низкоуровневыми вызовами типа 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 Кажется, никаких сюрпризов не предвидится. Что ж, вводим пароль (как обычно «KPNCKaspersky++»), выслушиваем «ругательство» и, до нажатия ОК, вызываем отладчик, переключаем контекст… :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 на безусловный jumpshort (код 0xEB). ==== Способ 2. Бряк на функции ввода пароля ==== Вы боитесь творить, потому что творения ваши отражают вашу истинную суть. Фрэнк Херберт «Ловец душ» При всем желании метод прямого поиска пароля в памяти элегантным назвать нельзя, да и практичным тоже. А, собственно, зачем искать сам пароль, спотыкаясь об беспорядочно разбросанные буфера, когда можно поставить бряк непосредственно на функцию, его считывающую? Хм, можно и так… да вот угадать какой именно функцией разработчик вздумал читать пароль, вряд ли будет намного проще. На самом деле одно и тоже действие может быть выполнено всего лишь несколькими функциями и их перебор не займет много времени. В частности, содержимое окна редактирование обычно добывается либо GetWinodowTextA (что чаще всего и происходит), либо GetDlgItemTextA (а это – значительно реже). Раз уж речь зашла за окна, запустим наш GUI «крякмис» и установим точку останова на функцию GetWindowTextA (»bpx GetWinodwTextA«). Поскольку, эта функция – системная, точка останова будет глобальной, т.е. затронет все приложения в системе, поэтому, заблаговременно закройте все лишнее от греха подальше. Если установить бряк до запуска «крякмиса», то мы словим несколько ложных всплытий, возникающих вследствие того, что система сама читает содержимое окна в процессе формирования диалога. Вводим какой-нибудь пароль (»KPNCKaspersky++« по обыкновению), нажимаем <ENTER> - отладчик незамедливает всплыть: 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 Во многих руководствах по взлому советуется тут же выйти из функции по PRET, мол, что ее анализировать-то, но не стоит спешить! Сейчас самое время выяснить: где расположен буфер вводимой строки и установить на него бряк. Вспомним какие аргументы и в какой последовательности принимает функция (а, если не вспомним, то заглянем в 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…..

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

<Ctrl-D>

: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«, нажимаем <ENTER>, отладчик тут же «всплывает»

Break due to BPX #0008:6C291B81 IF 3) в IDA появилась поддержка «сворачивания» (Collapsed) функций. Такой прием значительно упрощает навигацию по тексту, позволяя убрать с экрана не интересные в данный момент строки. По умолчанию все библиотечные функции сворачиваются автоматически.

Развернуть функцию можно подведя к ней курсор и нажав <+> на дополнительной цифровой клавиатуре, расположенной справа. Соответственно, клавиша ↔ предназначена для сворачивания.

По окончании автоматического анализа файла “first.exe”, IDA переместит курсор к строке “.text:00401B2C” – точке входа в программу. Среди начинающих программистов широко распространено заблуждение, якобы программы, написанные на Си, начинают выполняться с функции “main”, но в действительности это не совсем так. На самом деле сразу после загрузки файла управление передается на функцию “Start”, вставленную компилятором. Она подготавливает глобальные переменные _osver (билд), _winmajor (старшая версия операционной системы), _winminor (младшая версия операционной системы), _winver (полная версия операционной системы), argc (количество аргументов командной строки), argv (массив указателей на строки аргументов), _environ (массив указателей на строки переменных окружения); инициализирует кучи (heap); вызывает функцию main, а после возращения управления завершает процесс с помощью функции Exit.

Наглядно продемонстрировать инициализацию переменных, совершаемую стартовым кодом, позволяет следующая программа.

#include <stdio.h>

#include <stdlib.h>

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”. У исследователей существует множество хитростей, но все они базируются на особенностях реализации конкретных компиляторов4) и не могут считаться универсальными. Рекомендуется изучить исходные тексты стартовых функций популярных компиляторов, находящиеся в файлах 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”, полученный дизассемблером IDAPro 4.01 С приведенным примером IDA Pro успешно справляется, о чем свидетельствует стока “UsingFLIRTsignature: VCv2.0/4.x/5.0 runtime” в окне сообщений Рисунок 7 «0x003» Загрузка библиотеки сигнатур Дизассемблер сумел определить имена всех функций вызываемых стартовым кодом, за исключением одной, расположенной по адресу 0х0401BDB. Учитывая передачу трех аргументов и обращение к _exit, после возращения функцией управления, можно предположить, что это main и есть. Перейти по адресу 0x0401000 для изучения содержимого функции main можно несколькими способами – прокрутить экран с помощью стрелок управления курсором, нажать клавишу <G> и ввести требуемый адрес в появившемся окне диалога, но проще и быстрее всего воспользоваться встроенной в IDA Pro системой навигации. Если подвести курсор в границы имени, константы или выражения и нажать <Enter>, IDA автоматически перейдет на требуемый адрес. В данном случае требуется подвести к строке “sub_401000” (аргументу команды call) и нажать на <Enter>, если все сделано правильно, экран дизассемблера должен выглядеть следующим образом: 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”:и нажать <Enter>, IDA автоматически перейдет к требуемой строке: 00408040 aHelloSailor db 'Hello, Sailor!',0Ah,0 ; DATA XREF: sub_401000+3o Выражение “DATAXREF: sub_401000+3o” называется перекрестной ссылкой и свидетельствует о том, что в третьей строке процедуры sub_401000, произошло обращение к текущему адресу по его смещению (“o” от offset), а стрелка, направленная вверх, указывает на относительное расположение источника перекрестной ссылки. Если в границы выражения “sub_401000+3” подвести курсор и нажать <Enter>, IDA Pro перейдет к следующей строке: 00401003 push offset aHelloSailor ; «Hello, Sailor!\n» Нажатие клавиши <Ecs> отменяет предыдущее перемещение, возвращая курсор в исходную позицию. (Аналогично команде “back” в web-браузере). Смещение строки “Hello, Sailor!\n”, передается процедуре “??6ostream@@QAEAAV0@PBD@Z”, представляющей собой оператор “«” языка С++. Странное имя объясняется ограничениями, наложенными на символы, допустимые в именах библиотечных функций. Поэтому, компиляторы автоматически преобразуют (замангляют) такие имена в “абракадабру”, пригодную для работы с линкером, и многие начинающие программисты даже не догадываются об этой скрытой “кухне”. Для облегчения анализа текста, IDA Pro в комментариях отображает «правильные» имена, но существует возможность заставить ее везде показывать незамангленные имена. Для этого необходимо в меню “Options” выбрать пункт “Demanglednames” и в появившемся окне диалога переместить радио кнопку на “Names”, после этого вызов оператора “«” станет выглядеть так: 0040100D call ostream::operator«(char const *) На этом анализ приложения “first.cpp” можно считать завершенным. Для полноты картины остается переименовать функцию “sub_401000” в main. Для этого необходимо подвести курсор к строке 0x0401000 (началу функции) и нажать клавишу <N>, в появившемся диалоге ввести “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-ом выглядит так: Cryptprocfar 7E5B:0100start: 7E5B:0100 83 C6 06addsi,6 7E5B:0103 FF E6jmpsi;* ;*No entry point to code 7E5B:0105 B9 14BEmovcx,14BEh 7E5B:0108 01 AD 5691addds:data_1e[di],bp; (7E5B:5691=0) 7E5B:010C 80 34 66xorbyte ptr [si],66h; 'f' 7E5B:010F 46incsi 7E5B:0110 E2 FAloop$-4; Loop if cx > 0 7E5B:0112 FF E6jmpsi;* ;* No entry point to code 7E5B:114 18 00sbb[bx+si],al 7E5B:116 D2 6F DCshrbyte ptr [bx-24h],cl; Shift w/zeros fill 7E5B:119 6E 67 AB 47 A5 2Edb 6Eh, 67h,0ABh, 47h,0A5h, 2Eh 7E5B:11F 03 0A 0A 09 4A 35db 03h, 0Ah, 0Ah, 09h, 4Ah, 35h 7E5B:125 07 0F 0A 09 14 47db 07h, 0Fh, 0Ah, 09h, 14h, 47h 7E5B:12B 6B 6C 42 E8 00 00db 6Bh, 6Ch, 42h, E8h, 00h, 00h 7E5B:131 59 5E BF 00 01 57db 59h, 5Eh, BFh, 00h, 01h, 57h 7E5B:137 2B CE F3 A4 C3db 2Bh, CEh, F3h, A4h, C3h Cryptendp SOURCER половину кода вообще не смог дизассемблировать, оставив ее в виде дампа, а другую половину дизассемблировал неправильно! Команда “JMP SI” в строке :0x103 осуществляет переход по адресу :0x106 (значение регистра SI после загрузки com файла равно 0x100, поэтому после команды “ADD SI,6” регистр SI равен 0x106). Но следующая за “JMP” команда расположена по адресу 0x105! В исходном тексте в это место вставлен байт-пустышка, сбивающий дизассемблер с толку. Start: ADDSI,6 JMPSI DB0B9h; LEASI,_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 ; Ñ … Необходимо помочь дизассемблеру, указав адрес перехода. Начинающие пользователи в этой ситуации обычно подводят курсор к соответствующей строке и нажимают клавишу <C>, заставляя IDA дизассемблировать код с текущей позиции до конца функции. Несмотря на кажущуюся очевидность, такое решение ошибочно, ибо по-прежнему остается неизвестным куда указывает условный переход в строке :0x103 и откуда код, расположенный по адресу :0x106 получает управление. Правильное решение – добавить перекрестную ссылку, связывающую строку :0x103, со строкой :0x106. Для этого необходимо в меню “View” выбрать пункт “Crossreferences” и в появившемся окне диалога заполнить поля “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” и нажать <Enter>. Важно соблюдать правильность написания – IDA Pro не распознает шестнадцатеричный формат ни в стиле Си (0x106), ни в стиле MASM\TASM (0106h). Что представляет собой число “114h” в строке :0x106 – константу или смещение? Чтобы узнать это, необходимо проанализировать следующую команду – “LODSW”, поскольку ее выполнение приводит к загрузке в регистр AX слова, расположенного по адресу DS:SI, очевидно, в регистр SI заносится смещение. seg000:0106 mov si, 114h seg000:0109 lodsw Однократное нажатие клавиши <O> преобразует константу в смещение и дизассемблируемый текст станет выглядеть так: 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 и дважды нажать <D> пока экран не станет выглядеть так: 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” содержится количество байт, которые необходимо расшифровать. Подведя к ней курсор, нажатием клавиши <N> можно дать ей осмысленное имя, например “BytesToDecrypt”. После завершения цикла расшифровщика встречается еще один безусловный регистровый переход. seg000:0112 jmp si Чтобы узнать куда именно он передает управление, необходимо проанализировать код и определить содержимое регистра SI. Часто для этой цели прибегают к помощи отладчика – устанавливают точку останова в строке 0x112 и дождавшись его «всплытия» просматривают значения регистров. Специально для этой цели, IDA Pro поддерживает генерацию map-файлов, содержащих символьную информацию для отладчика. В частности, чтобы не заучивать численные значения всех «подопытных» адресов, каждому из них можно присвоить легко запоминаемое символьное имя. Например, если подвести курсор к строке “seg000:0112”, нажать <N> и ввести “BreakHere”, отладчик сможет автоматически вычислить обратный адрес по его имени. Для создания map-файла в меню “File” необходимо кликнуть по «Produceoutputfile» и в развернувшемся подменю выбрать «ProduceMAPfile» или вместо всего этого нажать на клавиатуре «горячую» комбинацию <Shift-F10>. Независимо от способа вызова на экран должно появится диалоговое окно следующего вида. Оно позволяет выбрать какого рода данные будут включены в 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-файл необходимо разместить в одной директории с отлаживаемой программой, загружаемой в загрузчик без указания расширения, т.е., например, так “WLDRCrypt”. В противном случае символьная информация не будет загружена! Затем необходимо установить точку останова командой “bpxBreakHere” и покинуть отладчик командной “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:012Ecall$+3 seg000:0131popcx seg000:0132popsi seg000:0133movdi, 100h seg000:0136pushdi seg000:0137subcx, si seg000:0139repemovsb seg000:013Bretn Назначение команды “CALL $+3” (где $ обозначает текущее значение регистра указателя команд IP) состоит в заталкивании в стек содержимого регистра IP, откуда впоследствии оно может быть извлечено в любой регистр общего назначения. Необходимость подобного трюка объясняется тем, что в микропроцессорах серии Intel 80×86 регистр IP не входит в список непосредственно адресуемых и читать его значение могут лишь команды, изменяющие ход выполнения программы, в том числе и call. Для облегчения анализа листинга можно добавить к стокам 0x12E и 0x131 комментарий – “MOVCX, IP”, или еще лучше – сразу вычислить и подставить непосредственное значение – “MOVCX,0x131”. Команда “POPSI” в строке 0x132 снимает слово из стека и помещает его в регистр SI. Прокручивая экран дизассемблера вверх в строке 0x10B можно обнаружить парную ей инструкцию “PUSHSI”, заносящую в стек смещение первого расшифровываемого байта. После этого становится понятным смысл последующих команд “MOV DI, 0x100\SUB CX,SI\REPE MOVSB”. Они перемещают начало расшифрованного фрагмента по адресу, начинающегося со смещения 0x100. Такая операция характерна для «конвертных» защит, накладывающихся на уже откомпилированный файл, который перед запуском должен быть размещен по своим «родным» адресам. Перед началом перемещения в регистр CX заносится длина копируемого блока, вычисляемая путем вычитания смещения первого расшифрованного байта от смещения второй команды перемещающего кода. В действительности, истинная длина на три байта короче и по идее от полученного значения необходимо вычесть три. Однако, такое несогласование не нарушает работоспособности, поскольку содержимое ячеек памяти, лежащих за концом расшифрованного фрагмента, не определено и может быть любым. Пара команд “0x136:PUSHDI” и “0x13B:RETN” образуют аналог инструкции “CALLDI” – “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:0116shrbyte ptr [bx-24h], cl seg000:0119outsb seg000:011Astosword ptr es:[edi] seg000:011Cincdi seg000:011Dmovsw seg000:011Eaddcx, cs:[bp+si] seg000:0121orcl, [bx+di] seg000:0123decdx seg000:0124xorax, 0F07h seg000:0127orcl, [bx+di] seg000:0129adcal, 47h seg000:0129;────────────────────────────────────────────────────── seg000:012Bdb6Bh ; k seg000:012Cdb6Ch ; l seg000:012Ddb42h ; 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 не позволяет разработчику задавать тип переменной и определяет его автоматически по ее первому использованию, а объявление осуществляется ключевым словом “auto”. Например, “autoMyVar, s0” объявляет две переменных – MyVar и s0. Для создания скрипта необходимо нажать комбинацию клавиш <Shift-F2> или выбрать в меню “File” пункт “IDCCommand” и в появившемся окне диалога ввести исходный текст программы: ╔═[■]════════════════ Notepad ═════════════════════╗ ║ EnterIDCstatement(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 для выполнения скрипта необходимо нажать либо <Enter> (версия 3.8x и старше), либо <Ctrl-Enter> в более ранних версиях. Если все сделано правильно, после выполнения скрипта экран дизассемблера должен выглядеть так (b). Возможные ошибки – несоблюдение регистра символов (IDA к этому чувствительна), синтаксические ошибки, базовый адрес вашего сегмента отличается от 0x1000 (еще раз вызовете окно «Сегменты» чтобы узнать его значение). В противном случае необходимо подвести курсор к строке “seg000:0116”, нажать клавишу <U> для удаления результатов предыдущего дизассемблирования зашифрованного фрагмента и затем клавишу <C> для повторного дизассемблирования расшифрованного кода. 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” можно преобразовать в удобочитаемый вид, подведя к ней курсор и нажав клавишу “<A>”. Теперь экран дизассемблера будет выглядеть так: 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-строки Команда “MOVAH,9” в строке :0116 подготавливает регистр AH перед вызовом прерывания 0x21, выбирая функцию вывода строки на экран, смещение которой заносится следующей командой в регистр DX. Т.е. для успешного ассемблирования листинга необходимо заменить константу 0x108 соответствующим смещением. Но ведь выводимая строка на этапе ассемблирования (до перемещения кода) расположена совсем в другом месте! Одно из возможных решений этой проблемы заключается в создании нового сегмента с последующим копированием в него расшифрованного кода – в результате чего достигается эмуляции перемещения кода работающей программы. Для создания нового сегмента можно выбрать в меню «View» пункт «Segments» и в раскрывшемся окне нажать клавишу <Insert>. Появится диалог следующего вида (см. рис. 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) исходный текст скрипта - копировщика Для его ввода необходимо вновь нажать <Shift-F2>, при этом предыдущий скрипт будет утерян (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 и нажав клавишу <A>. Экран дизассемблера должен выглядеть так: 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” и нажать клавишу <Ctrl-O> она будет преобразована в смещение: 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”. Нажатие <Ctrl-E> открывает диалог свойств сегмента, содержащий среди прочих полей конечный адрес, который и требуется изменить. Не обязательно указывать точное значение – можно «растянуть» сегмент с небольшим запасом от предполагаемых изменений. Если попытаться добавить к программе код “XORAX,AX; INT 16h” он неминуемо затрет начало строки “Hello, Sailor!”, поэтому, ее необходимо заблаговременно передвинуть немного «вниз» (т.е. в область более старших адресов), например, с помощью скрипта следующего содержания «for(a=0x108;a<0x11A;a++) PatchByte([0x2000,a+0x20],Byte([0x2000,a]);». Пояснение: объявление переменной a для краткости опущено (сами должны понимать, не маленькие :-), длина строки, как водится, берется с запасом, чтобы не утомлять себя лишними вычислениями и перемещение происходит справа налево, поскольку исходный и целевой фрагменты заведомо не пересекаются. Подведя к курсор к строке :0128 нажатием <A> преобразуем цепочку символов к удобно-читаемому виду; подведем курсор к строке :0102 и, выбрав в меню “Edir” пункт “Pathprogram”, “Assembler”, введем команду “MOVDX,128h”, где «128h» - новое смещение строки, и тут же преобразуем его в смещение нажатием <Ctrl-O>. Вот теперь можно вводить новый текст – переместив курсор на инструкцию “ret”, вновь вызовем ассемблер и введем “XORAX,AX<ENTER>INT 16h<Enter>RET<Enter><Esc>”. На последок рекомендуется произвести «косметическую» чистку – уменьшить размер сегмента до необходимого и переместить строку “Hello, Sailor” вверх, прижав ее вплотную к коду. Пояснение: удалить адреса, оставшиеся при уменьшении размеров сегмента за его концом можно взводом флажка “DisableAddress” в окне свойств сегмента, вызываемом нажатием <Alt-S> Если все было сделано правильно конечный результат должен выглядеть как показано ниже: 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. Ниже приведен пример скрипта, автоматически выполняющего указанные действия. Для его загрузки достаточно нажать <F2> или выбрать в меню “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-Си, отладить ее, запустить расшифровщик… В отладчике же можно поручить выполнение этой функции процессору, не вникая в то, как она работает, и дождавшись ее завершения, продолжить анализ расшифрованного модуля программы. Можно перечислять бесконечно, но и без того ясно, что отладчик отнюдь не конкурент дизассемблеру, а партнер.

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

И IDAPro действительно позволяет это сделать! Выберем в меню «Fail» подменю «Produceoutputfile», а в нем пункт «ProduceMAPfile» (или нажмем «горячую» клавишу <Shift-F10>). На экране появится окно с запросом имени файла (введем, например, »simple.map«), а затем возникнет модальный диалог, уточняющий какие именно имена стоит включать в map-файл. Нажмем <Enter>, оставив все галочки в состоянии по умолчанию (подробно о назначении каждой из них можно прочитать в моей книге »Образ мышления – дизассемблер IDA«). Парой секунд спустя на диске образуется «simple.map» файл, содержащий всю необходимую отладочную информацию, представленную в map-формате Borland. Отладчик Soft-ice не поддерживает такой формат, поэтому, перед его использованием файл необходимо конвертировать в sym-формат специально на то предназначенной утилитой idasym, которую можно бесплатно скачать с сайта www.idapro.com или получить у дистрибьютора, продавшего вам IDA.

Набрав в командной строке «idasymsimple.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! Это функциклирует! Теперь символьные имена не только отображаются на экране, упрощая понимание кода, – на любое из них можно быстро и с комфортом установить точку останова, скажем «bpmaMygoodpassword» и отладчик поймет, что от него хотят! Нет больше нужны держать в серо-мозговой памяти эти трудно запоминаемые шестнадцатеричные адреса! ===== Шаг седьмой. Идентификация ключевых структур языков высокого уровня ===== »Если твое оружие стоит только малой части энергии, затраченной твоим врагом, ты имеешь мощный рычаг, который может одолеть непреодолимые с виду трудности« Френк Херберт «Дом глав Дюны» Исследование алгоритма работы программ, написанных на языках высокого уровня, традиционно начинается с реконструкции ключевых структур исходного языка – функций, локальных и глобальныхпеременных, ветвлений, циклов и т.д. Это делает дизассемблерный листинг более наглядным и значительно упрощает его анализ. Современные дизассемблеры достаточно интеллектуальны и львиную долю работы по распознаванию ключевых структур берут на себя. В частности, IDAPro успешно справляется с идентификацией стандартных библиотечных функций, локальных переменных, адресуемых через регистр 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:00401000pushebp .text:00401001movebp, esp .text:00401003pushecx .text:00401004call401019 .text:00401004 ; Вот мы выловили инструкцию callc непосредственным операндом, .text:00401004 ; представляющим собой адрес начала функции. Точнее - ее смещение .text:00401004 ; в кодовом сегменте (в данном случае в сегменте «.text») .text:00401004 ; Теперь можно перейти к строке «.text:00401019» и, дав функции .text:00401004 ; собственное имя, заменить операнд инструкции call на конструкцию .text:00401004 ; «call offset Имяфункции» .text:00401004 ; .text:00401009movdword ptr [ebp-4], 666h .text:00401010call401019 .text:00401010 ; А вот еще один вызов функции! Обратившись к строке «.text:401019» .text:00401010 ; мы увидим, что эта совокупность инструкций уже определена как функция .text:00401010 ; и все, что потребуется сделать, – заменить call 401019 на .text:00401010 ; «call offset Имяфункции» .text:00401010 .text:00401015mov esp, ebp .text:00401017pop ebp .text:00401018retn .text:00401018 ; Вот нам встретилась инструкция возврата из функции, однако, не факт .text:00401018 ; что это действительно конец функции – ведь функция может иметь и .text:00401018 ; и несколько точек выхода. Однако, смотрите: следом за ret .text:00401018 ; расположено начало функции «моя функция», отождествленное по .text:00401018 ; операнду инструкции call. .text:00401018 ; Поскольку, функции не могут перекрываться, выходит, что данный ret - .text:00401018 ; конец функции! .text:00401018 ; .text:00401019push ebp .text:00401019 ; На эту строку ссылаются операнды нескольких инструкций call. .text:00401019 ; Следовательно, это – адрес начала функции. .text:00401019 ; Каждая функция должна иметь собственное имя – как бы нам ее назвать? .text:00401019 ; Назовем ее «моя функция» :-) .text:00401019 ; .text:0040101Amovebp, esp; ← .text:0040101Cpushecx; ← .text:0040101Dmoveax, [ebp-4]; ← .text:00401020addeax, 1; ← Это – тело «моей функции» .text:00401023mov[ebp-4],eax; ← .text:00401026movesp, ebp; ← .text:00401028popebp; ← .text:00401029retn .text:00401029; Конец «моей функции» Листинг 7 Как мы видим, все очень просто. Однако задача заметно усложняется, если программист (или компилятор) использует косвенные вызовы функций, передавая их адрес в регистре и динамически вычисляя его (адрес, не регистр!) на стадии выполнения программы. Именно так, в частности, реализована работа с виртуальными функциями (см. «Идентификация виртуальных функций»), однако, в любом случае компилятор должен каким-то образом сохранить адрес функции в коде, значит, его можно найти и вычислить! Еще проще загрузить исследуемое приложение в отладчик, установить на «подследственную» инструкцию CALL точку останова и, дождавшись всплытия отладчика, посмотреть по какому адресу она передаст управление. Рассмотрим следующий пример: func(); main(){ int (a*)(); a=func; a(); } Листинг 8 Пример, демонстрирующий вызов функции по указателю Результат его компиляции должен в общем случае выглядеть так: .text:00401000pushebp .text:00401001movebp, esp .text:00401003pushecx .text:00401004movdword ptr [ebp-4], 401012 .text:0040100Bcalldword ptr [ebp-4] .text:0040100B ; Вот инструкция CALL, осуществляющая косвенный вызов функции .text:0040100B ; по адресу, содержащемуся в ячейке [EBP-4]. .text:0040100B ; Как знать – что же там содержится? Прокрутим экран дизассемблера .text:0040100B ; немного вверх, пока не встретим строку «movdwordptr [ebp-4],401012» .text:0040100B ; Ага! Значит, управление передается по адресу «.text: 401012», - .text:0040100B ; это и есть адрес начала функции! .text:0040100B ; Даем функции имя и заменяем «movdwordptr [ebp-4], 401012» на .text:0040100B ; «movdwordptr [ebp-4], offset Имя функции» .text:0040100B ; .text:0040100Emovesp, ebp .text:00401010popebp .text:00401011retn Листинг 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:00401000pushebp .text:00401001movebp, esp .text:00401003subesp, 14h .text:00401006mov[ebp+0xC], offset sub_401046 .text:0040100Dmov[ebp+0x8], offset sub_401058 .text:00401014mov[ebp+0x4], offset sub_40106A .text:0040101Bmov[ebp+0x14], 0 .text:00401022jmpshort loc_40102D .text:00401024moveax, [ebp+0x14] .text:00401027addeax, 1 .text:0040102Amov[ebp+0x14], eax .text:0040102Dcmp[ebp+0x14], 3 .text:00401031jgeshort loc_401042 .text:00401033movecx, [ebp+0x14] .text:00401036movedx, [ebp+ecx*4+0xC] .text:0040103Amov[ebp+0x10], edx .text:0040103Dcall[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:00401040jmpshort loc_401024 .text:00401042 .text:00401042movesp, ebp .text:00401044popebp .text:00401045retn Самый тяжелый случай представляют «ручные» вызовы функции командой JMP с предварительной засылок в стек адреса возврата. Вызов через JMP в общем случае выглядит так: «PUSHret_addrr/JMPfunc_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:00401000pushebp

.text:00401001movebp, esp

.text:00401003pushebx

.text:00401004pushesi

.text:00401005pushedi

.text:00401006leaesi, [401012h]

.text:0040100Cpushesi

.text:0040100Djmp401017

.text:0040100D ; Смотрите – казалось бы тривиальный условный переход, - что в нем

.text:0040100D ; такого? Ан, нет! Это не простой переход, - это замаскированный

.text:0040100D ; вызов функции! Откуда это следует? А давайте перейдем по адресу

.text:0040100D ; 0x401017 и посмотрим

.text:0040100D; .text:00401017pushebp

.text:0040100D ; .text:00401018movebp, esp

.text:0040100D ; .text:0040101Apopebp

.text:0040100D ; .text:0040101Bretn

.text:0040100D ; ^^^^

.text:0040100D ; Как вы думаете, куда этот ret возвращает управление? Естественно,

.text:0040100D ; по адресу, лежащему на верхушке стека. А что у нас лежит на стеке?

.text:0040100D ; PUSHEBP из строки 401017 обратно выталкивается инструкцией POP

.text:0040100D ; из строки 40101B, так… возвращаемся назад, к месту безусловного

.text:0040100D ; перехода и начинаем медленно прокручивать экран дизассемблера вверх

.text:0040100D ; отслеживая все обращения к стеку. Ага, попалась птичка! Инструкция

.text:0040100D ; PUSHESI из строки 401000C закидывает на вершину стека содержимое

.text:0040100D ; регистра ESI, а он сам, в свою очередь, строкой выше принимает

.text:0040100D ; «на грудь» значение 0x401012 – это и есть адрес начала функции,

.text:0040100D ; вызываемой командой «JMP» (вернее, не адрес, а смещение, но это не

.text:0040100D ; принципиально важно).

.text:0040100D ;

.text:00401012popedi

.text:00401013popesi

.text:00401014popebx

.text:00401015popebp

.text:00401016retn

Листинг 12

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

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

pushebp

movebp, esp

subesp, xx

Листинг 13 Обобщенный код пролога функции

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

Последовательность PUSHEBP/MOVEBP,ESP/SUBESP,xx может служить хорошей сигнатурой для нахождения всех функций в исследуемом файле, включая и тех, на которые нет прямых ссылок. Такой прием, в частности, использует в своей работе IDAPro, однако, оптимизирующие компиляторы умеют адресовать локальные переменные через регистр ESPи используют EBP как и любой другой регистр общего назначения. Пролог оптимизированных функций состоит из одной лишь команды SUBESP, xxx– последовательность слишком короткая для использования ее в качестве сигнатуры функции, - увы. Более подробный рассказ об эпилогах функций нас ждет впереди (см. «Идентификация локальных стековых переменных»), поэтому, во избежание никому не нужного дублирования, не будем здесь на нем останавливаться.

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

pop ebpmov esp, ebp

add esp, 64hpop ebp

retnretn

Эпилог 1Эпилог 2

Листинг 14 Обобщенный код эпилога функции

Важно отметить: между командами POPEBP/ADDESP, xxx и MOVESP,EBP/POPEBP могут находиться и другие команды – они не обязательно должны следовать вплотную друг к другу. Поэтому, для поиска эпилогов контекстный поиск непригоден – требуется применять поиск по маске.

Если функция написана с учетом соглашение PASCAL, то ей приходится самостоятельно очищать стек от аргументов. В подавляющем большинстве случаев это осуществляется инструкцией RETn, где 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очень медлительны, намного медлительнее PUSHEBP/MOVEBP,ESP/SUB ESB, xxx и MOVESP,EBP/POPEBP. Так, на PentiumENTERвыполняется за десять тактов, а приведенная последовательность команд – за семь. Аналогично, LEAVEтребует пять тактов, хотя туже операцию можно выполнить за два (и даже быстрее, если разделить MOVESP,EBP/POPEBPкакой-нибудь командой). Поэтому, современный читатель никогда не столкнется ни с ENTER, ни с LEAVE. Хотя, помнить об их назначении будет нелишне (мало ли, вдруг придется дизассемблировать древние программы, или программы, написанные на ассемблере, – не секрет, что многие пишущие на ассемблере очень плохо знают тонкости работы процессора и их «ручная оптимизация» заметно уступает компилятору по производительности).

«Голые» (naked) функции. Компилятор MicrosoftVisualC++ поддерживает нестандартный квалификатор «naked», позволяющий программистам создавать функции без пролога и эпилога. Без пролога и эпилога вообще! Компилятор даже не помещает в конце функции RET и это придется делать вручную, прибегая к ассемблерной вставке «asm{ret}» (Использование return не приводит к желаемому результату). Вообще-то, поддержка naked-функций задумывалась исключительно для написания драйверов на чистом Си (ну, почти чистом, с небольшой примесью ассемблерных включений), но она нашла неожиданное признание и среди разработчиков защитных механизмов. Действительно, приятно иметь возможность «ручного» создания функций, не беспокоясь, что их непредсказуемым образом «изуродует» компилятор. Для нас же, кодокопателей, в первом приближении это обозначает, что в программе может встретиться одна (или несколько) функций, не содержащих ни пролога, ни эпилога. Ну и что в этом страшного? Оптимизирующие компиляторы так же выкидывают пролог, а от эпилога оставляют один лишь RET, - но функции элементарно идентифицируются по вызывающей их инструкции CALL. Идентификация встраиваемых (inline) функций. Самый эффективный способ избавится от накладных расходов на вызов функций – не вызывать их. В самом деле – почему бы ни встроить код функции непосредственно в саму вызывающую функцию? Конечно, это ощутимо увеличит размер (и тем ощутимее, чем из больших мест функция вызывается), но зато значительно увеличит скорость выполнения программы (и тем значительнее, чем чаще «развернутая» функция вызывается). Чем плоха «развертка» функций для исследования программы? Прежде всего – она увеличивает размер «материнской» функции и делает ее код менее наглядным, - вместо «CALL\TESTEAX,EAX\JZxxx» с бросающимся в глаза условным переходом, – теперь куча ничего не напоминающих инструкций, в логике работы которых еще предстоит разобраться! Вспомним: мы уже сталкивались с таким приемом при анализе crackme02: movebp, ds:SendMessageA pushesi pushedi movedi, ecx pusheax push666h movecx, [edi+80h] push0Dh pushecx callebp ; SendMessageA leaesi, [esp+678h+var_668] moveax, offset aMygoodpassword ; «MyGoodPassword» loc_0_4013F0:; CODE XREF: sub_0_4013C0+52j movdl, [eax] movbl, [esi] movcl, dl cmpdl, bl jnzshort loc_0_401418 testcl, cl jzshort loc_0_401414 movdl, [eax+1] movbl, [esi+1] movcl, dl cmpdl, bl jnzshort loc_0_401418 addeax, 2 addesi, 2 testcl, cl jnzshort loc_0_4013F0 loc_0_401414:; CODE XREF: sub_0_4013C0+3Cj xoreax, eax jmpshort loc_0_40141D loc_0_401418:; CODE XREF: sub_0_4013C0+38j sbbeax, eax sbbeax, 0FFFFFFFFh loc_0_40141D:; CODE XREF: sub_0_4013C0+56j testeax, eax push0 push0 jzshort loc_0_401460 Листинг 18 Встроенные функции не имеют ни собственного пролога, ни эпилога, их код и локальные переменные (если таковые имеются) полностью «вживлены» в вызывающую функцию, – результат компиляции выглядит в точности так, как будто бы никакого вызова функции и не было. Единственная зацепка – встраивание функции неизбежно приводит к дублированию ее кода во всех местах вызова, а это хоть с трудом, но можно обнаружить. «С трудом» – потому, что встраиваемая функция, становясь частью вызывающей функции, «в сквозную» оптимизируется в контексте последней, что приводит к значительным вариациям кода. Рассмотрим такой пример: #include <stdio.h> 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 Пример, демонстрирующий, сквозную оптимизацию встраиваемых функций Результат его компиляции в общем случае должен выглядеть так: pushesi pushedi push777h;код 1-говызова max ; Компилятор вычислил значение функции max еще на этапе компиляции и ; вставил его в программу, избавившись от лишнего вызова функции pushoffset aProc; «%x\n» callprintf movesi, [esp+8+arg_0] addesp, 8 cmpesi, 666h; код 2-говызова max movedi, 666h; код 2-говызова max jlshort loc_0_401027; код 2-говызова max movedi, esi; код 2-говызова max loc_0_401027:; CODE XREF: sub_0_401000+23j pushedi pushoffset aProc; «%x\n» callprintf addesp, 8 cmpesi, 666h; код 3-говызова max jgeshort loc_0_401042; код 2-говызова max movesi, 666h; код 2-говызова max ; Смотрите – как изменился код функции! Во-первых, нарушилась очередность ; выполнения инструкций – было «CMP → MOV – Jx», а стало «CMP → Jx, MOV» ; А во-вторых, условный переход JL загадочным образом превратился в JGE! ; Впрочем, ничего загадочного тут нет – просто идет сквозная оптимизация! ; Поскольку, после третьего вызова функции max переменная argc, размещенная ; компилятором в регистре ESI, более не используется, у компилятора появляется ; возможность непосредственно модифицировать этот регистр, а не вводить ; временную переменную, выделяя под нее регистр EDI ; (см. «Идентификация регистровых и временных переменных») loc_0_401042:; CODE XREF: sub_0_401000+3Bj pushesi pushoffset aProc; «%x\n» callprintf addesp, 8 moveax, edi popedi popesi 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 автоматически, в противном же случае это приходится делать руками и головой. Обычно в штатную поставку компилятора входят исходные тексты его библиотек, в том числе и процедуры стартового кода. Например, у MicrosoftVisualC++ стартовый код расположен в файлах «CRT\STC\CRT0.C» – версия для статичной компоновки, «CRT\SRC\CRTEXE.C» – версия для динамичной компоновки (т.е. библиотечный код не пристыкуется к файлу, а вызывается из DLL), «CRT\SRC\wincmdln.c» – версия для консольных приложений. У BorlandC++ все файлы со 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, - т.е. встретив конструкцию типа «CALLGetModuleHandleA» можно с высокой степенью уверенности утверждать, что следующая функция – и есть WinMain. Наконец, вызов WinMain обычно расположен практически в самом конце кода стартовой функции. За ней бывает не более двух-трех «замыкающих» строй функций таких как «exit» и «XcptFilter». Рассмотрим следующий фрагмент кода. Сразу бросается в глаза множество инструкций PUSH, заталкивающих в стек аргументы, последний из которых передает результат завершения GetModuleHandleA. Значит, перед нами ни что иное, как вызов WinMain (и IDA подтверждает, что это именно так): .text:00401804pusheax .text:00401805pushesi .text:00401806pushebx .text:00401807pushebx .text:00401808callds:GetModuleHandleA .text:0040180Epusheax .text:0040180Fcall_WinMain@16 .text:00401814mov [ebp+var_68], eax .text:00401817pusheax .text:00401818callds: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(intargc, 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…. ==== Идентификация виртуальных функций ==== А мы летим орбитами, путями неизбитыми, Прошит метеоритами простор. Оправдан риск и мужество, космическая музыка Вплывает в деловой наш разговор. «Трава у дома» Земляне Виртуальная функция по определению обозначает «определяемая по время выполнения программы». При вызове виртуальной функции выполняемый код должен соответствовать динамическому типу объекта, из которого вызывается функция. Поэтому, адрес виртуальной функции не может быть определен на стадии компиляции – это приходится делать непосредственно в момент ее вызова. Вот почему вызов виртуальной функции – всегда косвенный вызов (исключение составляют лишь виртуальные функции статических объектов, - см. «Статическое связывание»). В то время как не виртуальные функции вызываются в точности так же, как и обычные Си-функции, вызов виртуальных функций кардинально отличается. Конкретная схема зависит от реализации конкретного компилятора, но общем случае ссылки на все виртуальные функции помещаются в специальный массив – виртуальную таблицу (virtualtable сокращенно VTBL), а в каждый экземпляр объекта, использующий хотя бы одну виртуальную функцию, помещается указатель на виртуальную таблицу (virtualtablepointer– сокращенно VPRT). Причем, независимо от числа виртуальный функций, каждый объект имеет только один указатель. Вызов виртуальных функций всегда происходит косвенно, через ссылку на виртуальную таблицу – например: CALL [EBX+0х10], где EBX – регистр, содержащий смещение виртуальной таблицы в памяти, а 0x10 – смещение указателя на виртуальную функцию внутри виртуальной таблицы. Анализ вызова виртуальных функций наталкивается на ряд сложностей, самая коварная из которых, – необходимость обратной трассировки кода для отслеживания значения регистра, используемого для косвенной адресации. Хорошо, если он инициализируется непосредственным значением типа «MOVEBX, offsetVTBL» недалеко от места использования, но значительно чаще указатель на VTBL передается функции как неявный аргумент или (что еще хуже) один и тот же указатель используется для вызова двух различных виртуальных функций и возникает неопределенность – какое именно значение (значения) он имеет в данной ветке программы? Разберем следующий пример (предварительно вспомнив, что если одна и та же не виртуальная функция присутствует и базовом, и в производном классе – всегда вызывается функция базового класса). #include <stdio.h> class Base{ public: virtual void demo(void) { printf(«BASE\n»); }; virtual void demo_2(void) { printf(«BASE DEMO 2\n»); }; void demo_3(void) { printf(«Non virtual BASE DEMO 3\n»); }; }; class Derived: public Base{ public: virtual void demo(void) { printf(«DERIVED\n»); }; virtual void demo_2(void) { printf(«DERIVED DEMO 2\n»); }; void demo_3(void) { printf(«Non virtual DERIVED DEMO 3\n»); }; }; main() { Base *p = new Base; p→demo(); p→demo_2(); p→demo_3(); p = new Derived; p→demo(); p→demo_2(); p→demo_3(); } Листинг 24 Демонстрация вызова виртуальных функций Результат ее компиляции в общем случае должен выглядеть так: mainproc near; CODE XREF: start+AFp pushesi push4 call??2@YAPAXI@Z; operator new(uint) ; EAX c- указатель на выдел. блок памяти ; Выделяем четыре байта памяти для экземпляра нового объекта. ; Объект состоит из одного лишь указателя на VTBL. addesp, 4 testeax, eax jzshort loc_0_401019 ; –> Ошибка выделения памяти ; проверка успешности выделения памяти movdword ptr [eax], offset BASE_VTBL ; Вот здесь в только что созданный экземпляр объекта копируется ; указатель на виртуальную таблицу класса BASE. ; То, что это именно виртуальная таблица класса BASE, можно узнать ; проанализировав элементы этой таблицы – они указывают на члены ; класса BASE, следовательно, сама таблица – виртуальная таблица ; класса BASE movesi, eax; ESI = BASE_VTBL ; заносим в ESI указатель на экземпляр объекта (указатель на указатель ; на BASE_VTBL ; Зачем? Дело в том, что на самом деле в ESI заносится указатель на ; экземпляр объекта (см. «Идентификация объектов, структур и массивов), ; но нам на данном этапе все эти детали ни к чему, поэтому, мы просто ; говорим, что в ESI – указатель на указатель на виртуальную таблицу ; базового класса, не вникая для чего понадобился этот двойной указатель. jmpshort loc_0_40101B loc_0_401019:; CODE XREF: sub_0_401000+Dj xoresi, esi ; принудительно обнуляем указатель на экземпляр объекта (эта ветка получает управление ; только в случае неудачного выделения памяти для объекта) нулевой указатель ; словит обработчик структурных исключений при первой же попытке обращения loc_0_40101B:; CODE XREF: sub_0_401000+17j moveax, [esi]; EAX = *BASE_VTBL == *BASE_DEMO ; заносим в EAX указатель на виртуальную таблицу класса BASE, ; не забывая о том, что указатель на виртуальную таблицу одновременно ; является указателем и на первый элемент этой таблицы. ; А первый элемент виртуальной таблицы, содержащий указатель ; на первую (в порядке объявления) виртуальную функцию класса. movecx, esi; ECX = this ; заносим в ECX указатель на экземпляр объекта, передавая вызываемой функции ; неявный аргумент – указатель this (см. «Идентификация аргументов функций») calldword ptr [eax]; CALL BASE_DEMO ; Вот он – вызов виртуальной функции! Чтобы понять – какая именно функция ; вызывается, мы должны знать значение регистра EAX. Прокручивая экран ; дизассемблера вверх, мы видим – EAX указывает на BASE_VTBL, а первый ; член BASE_VTBL (см. ниже) указывает на функцию BASE_DEMO. Следовательно: ; а) этот код вызывает именно функцию BASE_DEMO ; б) функция BASE_DEMO – это виртуальная функция movedx, [esi]; EDX =*BASE_DEMO ; заносим в EDX указатель на первый элемент виртуальной таблицы класса BASE movecx, esi; ECX = this ; заносим в ECX указатель на экземпляр объекта ; Это неявный аргумент функции – указатель this (см. «Идентификация this») calldword ptr [edx+4] ; CALL [BASE_VTBL+4] (BASE_DEMO_2) ; Еще один вызов виртуальной функции! Чтобы понять – какая именно функция ; вызывается, мы должны знать содержимое регистра EDX. Прокручивая экран ; дизассемблера вверх, мы видим, что он указывает на BASE_VTBL, а EDX+4, ; стало быть, указывает на второй элемент виртуальной таблицы класса BASE. ; Он же, в свою очередь, указывает на функцию BASE_DEMO_2 pushoffset aNonVirtualBase ; «Non virtual BASE DEMO3\n» callprintf ; а вот вызов не виртуальной функции. Обратите внимание – он происходит ; как и вызов обычной Си функции. (Обратите внимание, что эта функция - ; встроенная, т.к. объявленная непосредственно в самом классе и вместо ее ; вызова осуществляется подстановка кода) push4 call??2@YAPAXI@Z; operator new(uint) ; Далее идет вызов функций класса DERIVED. Не будем здесь подробно ; его комментировать – сделайте это самостоятельно. Вообще же, класс ; DERIVED понадобился только для того, чтобы показать особенности компоновки ; виртуальных таблиц addesp, 8; Очистка послеprintf & new testeax, eax jzshortloc_0_40104A; Ошибка выделения памяти movdword ptr [eax], offset DERIVED_VTBL movesi, eax; ESI == DERIVED_VTBL jmpshort loc_0_40104C loc_0_40104A:; CODE XREF: sub_0_401000+3Ej xoresi, esi loc_0_40104C:; CODE XREF: sub_0_401000+48j moveax, [esi]; EAX =*DERIVED_VTBL movecx, esi; ECX = this calldword ptr [eax]; CALL [DERIVED_VTBL] (DERIVED_DEMO) movedx, [esi]; EDX =*DERIVED_VTBL movecx, esi; ECX=this calldword ptr [edx+4] ; CALL [DERIVED_VTBL+4] (DERIVED_DEMO_2) pushoffset aNonVirtualBase ; «Non virtual BASE DEMO 3\n» callprintf ; Обратите внимание – вызывается функция BASE_DEMO базового, ; а не производного класса!!! addesp, 4 popesi retn mainendp BASE_DEMOproc near; DATA XREF: .rdata:004050B0o pushoffset aBase; «BASE\n» callprintf popecx retn BASE_DEMOendp BASE_DEMO_2proc near; DATA XREF: .rdata:004050B4o pushoffset aBaseDemo2 ; «BASE DEMO 2\n» callprintf popecx retn BASE_DEMO_2endp DERIVED_DEMOproc near; DATA XREF: .rdata:004050A8o pushoffset aDerived; «DERIVED\n» callprintf popecx retn DERIVED_DEMOendp DERIVED_DEMO_2proc near; DATA XREF: .rdata:004050ACo pushoffset aDerivedDemo2 ; «DERIVEDDEMO 2\n» callprintf popecx retn DERIVED_DEMO_2endp DERIVED_VTBLdd offset DERIVED_DEMO; DATA XREF: sub_0_401000+40o dd offset DERIVED_DEMO_2 BASE_VTBLdd offset BASE_DEMO; DATA XREF: sub_0_401000+Fo dd offset BASE_DEMO_2 ; Обратите внимание – виртуальные таблицы «растут» снизу вверх в порядке ; объявления классов в программе, а элементы виртуальных таблиц «растут» ; сверху вниз в порядке объявления виртуальных функций в классе. ; Конечно, так бывает не всегда (порядок размещения таблиц и их элементов ; нигде не декларирован и целиком лежит на «совести» компилятора, но на ; практике большинство из них ведут себя именно так) Сами же виртуальные ; функции располагаются вплотную друг к другу в порядке их объявления Листинг 25 Рисунок 11 0x006 Художнику – добавить функции A, B и С Реализация вызова виртуальных функций ::идентификация чистой виртуальной функции.Если функция объявляется в базовом, а реализуется в производным классе – такая функция называется чистой виртуальной функцией, а класс, содержащий хотя бы одну такую функцию, называется абстрактным классом. Язык Си++ запрещает создание экземпляров абстрактного класса, да и как они могут создаваться, если, по крайней мере, одна из функций класса неопределенна? На первый взгляд – не определена, и ладно, – какая в этом беда? Ведь на анализ программы это не влияет. На самом деле это не так – чистая виртуальная функция в виртуальной таблице замещается указателем на библиотечную функцию purecall. Зачем она нужна? Дело в том, что на стадии компиляции программы невозможно гарантированно «отловить» все попытки вызова чисто виртуальных функций, но если такой вызов и произойдет, управление получит заранее подставленная сюда purecall, которая выведет на экран «ругательство» по поводу запрета на вызов чисто виртуальных функций и завершит работу приложения. Подробнее об этом можно прочитать в технической заметке MSDN Q120919, датированной 27 июня 1997 года. Таким образом, встретив в виртуальной таблице указатель на purecall, можно с уверенностью утверждать, что мы имеем дело с чисто виртуальной функцией. Рассмотрим следующий пример: #include <stdio.h> class Base{ public: virtual void demo(void)=0; }; class Derived:public Base { public: virtual void demo(void) { printf(«DERIVED\n»); }; }; main() { Base *p = new Derived; p→demo(); } Листинг 26 Демонстрация вызова чистой виртуальной функции Результат его компиляции в общем случае должен выглядеть так: mainproc near; CODE XREF: start+AFp push4 call??2@YAPAXI@Z addesp, 4 ; Выделение памяти для нового экземляра объекта testeax, eax ; Проверка успешности выделения памяти jzshort loc_0_401017 movecx, eax ; ECX = this callGetDERIVED_VTBL ; занесение в экземпляр объекта указателя на виртуальную таблицу класса ; DERIVED jmpshort loc_0_401019 loc_0_401017:; CODE XREF: main+Cj xoreax, eax ; EAX = NULL loc_0_401019:; CODE XREF: main+15j movedx, [eax] ; тут возникает исключение по обращению к нулевому указателю movecx, eax jmpdword ptr [edx] mainendp GetDERIVED_VTBLproc near; CODE XREF: main+10p pushesi movesi, ecx ; Через регистр ECX функции передается неявный аргумент – this callSetPointToPure ; функция заносит в экземпляр объекта указатель на purecall ; специальную функцию - заглушку на случай незапланированного вызова ; чисто виртуальной функции movdwordptr [esi], offsetDERIVED_VTBL ; занесение в экземпляр объекта указателя на виртуальную таблицу производного ; класса, с затиранием предыдущего значения (указателя на purecall) moveax, esi popesi retn GetDERIVED_VTBLendp DERIVED_DEMOproc near; DATA XREF: .rdata:004050A8o pushoffset aDerived; «DERIVED\n» callprintf popecx retn DERIVED_DEMOendp SetPointToPureproc near; CODE XREF: GetDERIVED_VTBL+3p moveax, ecx movdword ptr [eax], offset PureFunc ; Заносим по [EAX] (в экземляр нового объекта) указатель на специальную ; функцию - purecall, которая предназначена для отслеживания попыток ; вызова чисто виртуальной функции в ходе выполнения программы - ; если такая попытка произойдет, purecall выведет на экран «матюгательство» ; дескать, вызывать чисто виртуальную функцию нельзя и завершит работу retn SetPointToPureendp DERIVED_VTBLdd offset DERIVED_DEMO; DATA XREF: GetDERIVED_VTBL+8o PureFuncdd offset purecall; DATA XREF: SetPointToPure+2o ; указатель на функцию-заглушку purecall. Следовательно, мы имеем дело ; с чисто виртуальной функцией Листинг 27 ::совместное использование виртуальной таблицы несколькими экземплярами объекта. Сколько бы экземпляров объекта ни существовало – все они пользуются одной и той же виртуальной таблицей. Виртуальная таблица принадлежит самому объекту, но не экземпляру (экземплярам) этого объекта. Впрочем, из этого правила существуют и исключения (см. »Копии виртуальных таблиц«). Рисунок 12 0x007 все экземпляры объекта используют одну и ту же виртуальную таблицу Для подтверждения сказанного рассмотрим следующий пример: #include <stdio.h> class Base{ public: virtual demo () { printf(«Base\n»); } }; class Derived:public Base{ public: virtual demo() { printf(«Derived\n»); } }; main() { Base * obj1 = new Derived; Base * obj2 = new Derived; obj1→demo(); obj2→demo(); } Листинг 28 Демонстрация совместного использование одной копии виртуальной таблицы несколькими экземплярами класса Результат его компиляции в общем случае должен выглядеть так: mainproc near; CODE XREF: start+AFp pushesi pushedi push4 call??2@YAPAXI@Z; operator new(uint) addesp, 4 ; выделяем память под первый экземпляр объекта testeax, eax jzshort loc_0_40101B movecx, eax; EAX – указывает на первый экземпляр объекта callGetDERIVED_VTBL ; в EAX – указатель на виртуальную таблицу класса DERIVED movedi, eax; EDI = *DERIVED_VTBL jmpshort loc_0_40101D loc_0_40101B:; CODE XREF: main+Ej xoredi, edi loc_0_40101D:; CODE XREF: main+19j push4 call??2@YAPAXI@Z; operator new(uint) addesp, 4 ; выделяем память под второй экземпляр объекта testeax, eax jzshort loc_0_401043 movecx, eax; ECX – this callGetDERIVED_VTBL ; обратите внимание – второй экземпляр использует ту же самую ; виртуальнуютаблицу DERIVED_VTBLdd offset DERIVED_DEMO; DATA XREF: GetDERIVED_VTBL+8o BASE_VTBLdd offset BASE_DEMO; DATA XREF: GetBASE_VTBL+2o ; Обратите внимание – виртуальная таблица одна на все экземпляры класса Листинг 29 ::копии виртуальных таблиц. ОК, для успешной работы, - понятное дело, - вполне достаточно и одной виртуальной таблицы, однако, на практике приходится сталкиваться с тем, что исследуемый файл прямо-таки кишит копиями этих виртуальных таблиц. Что же это за напасть такая, откуда она берется и как с ней бороться? Если программа состоит из нескольких файлов, компилируемых в самостоятельные obj-модули (а такой подход используется практически во всех мало-мальски серьезных проектах), компилятор, очевидно, должен поместить в каждый obj «свою» собственную виртуальную таблицу для каждого используемого модулем класса. В самом деле – откуда компилятору знать о существовании других obj и наличии в них виртуальных таблиц? Вот так и возникают никому не нужные дубли, отъедающие память и затрудняющие анализ. Правда, на этапе компоновки, линкер может обнаружить копии и удалить их, да и сами компиляторы используют различные эвристические приемы для повышения эффективности генерируемого кода. Наибольшую популярность завоевал следующий алгоритм: виртуальная таблица помещается в тот модуль, в котором содержится реализация первой невстроенной не виртуальной функции класса. Обычно каждый класс реализуется в одном модуле и в большинстве случаев такая эвристика срабатывает. Хуже если класс состоит из одних виртуальных или встраиваемых функций – в этом случае компилятор «ложится» и начинает запихивать виртуальные таблицы во все модули, где этот класс используется. Последняя надежда на удаление «мусорных» копий ложиться на линкер, но и линкер – не панацея. Собственно, эти проблемы должны больше заботить разработчиков программы (если их волнует количество занимаемой программой памятью), для анализа лишние копии – всего лишь досадна помеха, но отнюдь не непреодолимое препятствие! ::связанный список. В большинстве случаев виртуальная таблица представляет собой обыкновенный массив, но некоторые компиляторы представляют ее в виде связного списка, - каждый элемент виртуальной таблицы содержит указатель на следующий элемент, а сами элементы размещены не вплотную друг к другу, а рассеянны по всему исполняемому файлу. На практике подобное, однако, встречается крайне редко, поэтому, не будем подробно на этом останавливаться, - достаточно лишь знать, что такое бывает, - если встретись со списками (впрочем, навряд ли вы с ними встретитесь) – разберетесь по обстоятельствам, благо это несложно. ::вызов через шлюз. Будьте так же готовы и к тому, чтобы встретить в виртуальной таблице указатель не на виртуальную функцию, а на код, который модифицирует этот указатель, занося в него смещение вызываемой функции. Этот прием был впервые предложен самим разработчиком языка – Бьерном Страуструпом, позаимствовавшим его из ранних реализаций Алгола-60. В Алголе код, корректирующий указатель вызываемой функции, называется шлюзом (thunk), а сам вызов – вызовом через шлюз. Вполне справедливо употреблять эту терминологии и по отношению к Си++. Однако в настоящее время вызов через шлюз чрезвычайно мало распространен и не используется практически ни одним компилятором. Несмотря на то, что он обеспечивает более компактное хранение виртуальных таблиц, модификация указателя приводит к излишним накладным расходам на процессорах с конвейерной архитектурой, (а Pentium – наиболее распространенный процессор, - как раз и построен по такой архитектуре). Поэтому, использование шлюзовых вызовов оправдано лишь в программах, критических к размеру, но не к скорости. Подробнее обо всем этом можно прочесть в руководстве по Алголу-60 (шутка), или у Бьерна Страуструпа в »Дизайне и эволюции языка С++«. ::сложный пример или когда не виртуальные функции попадают в виртуальные таблицы. До сих пор мы рассматривали лишь простейшие примеры использования виртуальных функций. В жизни же порой встречается такое… Рассмотрим сложный случай наследования с конфликтом имен: #include <stdio.h> class A{ public: virtual void f() { printf(«A_F\n»);}; }; class B{ public: virtual void f() { printf(«B_F\n»);}; virtual void g() { printf(«B_G\n»);}; }; class C:public A, public B { public: void f(){ printf(«C_F\n»);} } main() { A *a = new A; B *b = new B; C *c = new C; a→f(); b→f(); b→g(); c→f(); } Листинг 30 Демонстрация помещения не виртуальных функций в виртуальные таблицы Как будет выглядеть виртуальная таблица класса C? Так, давайте подумаем: раз класс C – производный от классов A и B, то он наследует функции обоих, но виртуальная функция f() класса B перекрывает одноименную виртуальную функцию класса A, поэтому, из класса А она не наследуется. Далее, поскольку не виртуальная функция f() присутствует и в производном классе С, она перекрывает виртуальную функцию производного класса (да, именно так, а вот не виртуальная не виртуальную функцию не перекрывает и она всегда вызывается из базового, а не производного класса). Таким образом, виртуальная таблица класса С должна содержать только один элемент – указатель на виртуальную функцию g(), унаследованную от B, а не виртуальная функция f() вызывается как обычная Си-функция. Правильно? Нет! Это как раз тот случай, когда не виртуальная функция вызывается через указатель – как виртуальная функция. Более того, виртуальная таблица класса будет содержать не два, а три элемента! Третий элемент – это ссылка на виртуальную функцию f(), унаследованную от B, но тут же замещенная компилятором на «переходник» к C::f(). Уф… Как все непросто! Может, после изучения дизассемблерного листинга это станет понятнее? mainproc near; CODE XREF: start+AFp pushebx pushesi pushedi push4 call??2@YAPAXI@Z; operator new(uint) addesp, 4 ; выделяем память для экземпляра объекта A testeax, eax jzshort loc_0_40101C movecx, eax; ECX =this callGet_A_VTBL; a[0]=*A_VTBL ; помещаем в экземпляр объекта указатель на его виртуальную таблицу movebx, eax; EBX =*a jmpshort loc_0_40101E loc_0_40101C:; CODE XREF: main+Fj xorebx, ebx loc_0_40101E:; CODE XREF: main+1Aj push4 call??2@YAPAXI@Z; operator new(uint) addesp, 4 ; выделяем память для экземпляра объекта B testeax, eax jzshort loc_0_401037 movecx, eax; ECX = this callGet_B_VTBL; b[0] = *B_VTBL ; помещаем в экземпляр объекта указатель на его виртуальную таблицу movesi, eax; ESI =*b jmpshort loc_0_401039 loc_0_401037:; CODE XREF: main+2Aj xoresi, esi loc_0_401039:; CODE XREF: main+35j push8 call??2@YAPAXI@Z; operator new(uint) addesp, 4 ; выделяем память для экземпляра объекта B testeax, eax jzshort loc_0_401052 movecx, eax; ECX = this callGET_C_VTBLs; ret: EAX=*c ; помещаем в экземпляр объекта указатель на его виртуальную таблицу ; (внимание:загляните в функцию GET_C_VTBLs) movedi, eax; EDI =*c jmpshort loc_0_401054 loc_0_401052:; CODE XREF: main+45j xoredi, edi loc_0_401054:; CODE XREF: main+50j moveax, [ebx]; EAX =a[0] = *A_VTBL movecx, ebx; ECX =*a calldword ptr [eax]; CALL [A_VTBL] (A_F) movedx, [esi]; EDX =b[0] movecx, esi; ECX =*b calldword ptr [edx]; CALL [B_VTBL] (B_F) moveax, [esi]; EAX =b[0] = B_VTBL movecx, esi; ECX =*b calldword ptr [eax+4] ; CALL [B_VTBL+4] (B_G) movedx, [edi]; EDX =c[0] = C_VTBL movecx, edi; ECX =*c calldword ptr [edx]; CALL [C_VTBL] (C_F) ; Внимание! Вызов не виртуальной функции происходит как виртуальной! popedi popesi popebx retn mainendp GET_C_VTBLsproc near; CODE XREF: main+49p pushesi; ESI =*b pushedi; ECX =*c movesi, ecx; ESI =*c callGet_A_VTBL; c[0]=*A_VTBL ; помещаем в экземпляр объекта C указатель на виртуальную таблицу класса A leaedi, [esi+4]; EDI =*c[4] movecx, edi; ECX =_C_F callGet_B_VTBL; c[4]=*B_VTBL ; добавляем в экземпляр объекта C указатель на виртуальную таблицу класса B ; т.е. теперь объект C содержит два указателя на две виртуальные таблицы ; базовых классов. Посмотрим далее, как компилятор справится с конфликтом ; имен… movdword ptr [edi], offset C_VTBL_FORM_B ; c[4]=*_C_VTBL ; Ага! указатель на виртуальную таблицу класса B замещается указателем ; на виртуальную таблицу класса C (смотри комментарии в самой таблице) movdword ptr [esi], offsetC_VTBL ; c[0]=C_VTBL ; Ага, еще раз – теперь указатель на виртуальную таблицу класса A замещается ; указателем на виртуальную таблицу класса C. Какой неоптимальный код, ведь это ; было можно сократить еще на стадии компиляции! moveax, esi; EAX =*c popedi popesi retn GET_C_VTBLsendp Get_A_VTBLproc near; CODE XREF: main+13p GET_C_VTBLs+4p moveax, ecx movdword ptr [eax], offsetA_VTBL ; помещаем в экземпляр объекта указатель на виртуальную таблицу класса B retn Get_A_VTBLendp A_Fproc near; DATA XREF: .rdata:004050A8o ; виртуальная функиця f() класса A pushoffset aA_f; «A_F\n» callprintf popecx retn A_Fendp Get_B_VTBLproc near; CODE XREF: main+2Ep GET_C_VTBLs+Ep moveax, ecx movdword ptr [eax], offsetB_VTBL ; помещаем в экземпляр объекта указатель на виртуальную таблицу класса B retn Get_B_VTBLendp B_Fproc near; DATA XREF: .rdata:004050ACo ; виртуальная функция f() класса B pushoffset aB_f; «B_F\n» callprintf popecx retn B_Fendp B_Gproc near; DATA XREF: .rdata:004050B0o ; виртуальная функция g() класса B pushoffset aB_g; «B_G\n» callprintf popecx retn B_Gendp C_Fproc near; CODE XREF: _C_F+3j ; Не виртуальная функция f() класса C выглядит и вызывается как виртуальная! pushoffset aC_f; «C_F\n» callprintf popecx retn C_Fendp _C_Fproc near; DATA XREF: .rdata:004050B8o subecx, 4 jmpC_F ; смотрите, какая странная функция! Во-первых, она никогда не вызывается, а ; во-вторых, это переходник к функции C_F. ; зачем уменьшается ECX? В ECX компилятор поместил указатель this, который ; до уменьшения пытался указывать на виртуальную функцию f(), унаследованную ; от класса B. Но на самом же деле this указывал на этот переходник. ; А после уменьшения он стал указывать на предыдущий элемент виртуальной ; таблицы – т.е. функцию f() класса C, вызов которой и осуществляет JMP _C_Fendp A_VTBLdd offset A_F; DATA XREF: Get_A_VTBL+2o ; виртуальная таблица класса A B_VTBLdd offset B_F; DATA XREF: Get_B_VTBL+2o dd offset B_G ; виртуальная таблица класса B – содержит указатели на две виртуальные функции C_VTBLdd offset C_F; DATA XREF: GET_C_VTBLs+19o ; виртуальная таблица класса C. Содержит указатель на не виртуальную функцию f() C_VTBL_FORM_Bdd 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(). Подумаем, что было бы – будь она на самом деле виртуальной? Тогда – она бы перекрыла одноименную функцию базовых классов и никакой «дикости» наподобие «переходников» в откомпилированной программе и не встретилось бы. А так – они говорят, что тут не все гладко и функция не виртуальная, хоть и стремится казаться такой. Опять-таки, умный компилятор теоретически может выкинуть переходник и дублирующийся элемент виртуальной таблицы класса С, но на практике этой интеллектуальности не наблюдается… ::статическое связывание. Есть ли разница как создавать экземпляр объекта – MyClasszzz; или MyClass *zzz=newMyClass? Разумеется: в первом случае компилятор может определить адреса виртуальных функций еще на стадии компиляции, тогда как во втором – это приходится вычислять в ходе выполнения программы. Другое различие: статические объекты размешаются в стеке (сегменте данных), а динамические – в куче. Таблица виртуальных функций упорно создается компиляторами в обоих случаях, а при вызове каждый функции (включая не виртуальные) подготавливается указатель this (как правило, помещаемый в один из регистров общего назначения – подробнее см. »Идентификация аргументов функций«), содержащий адрес экземпляра объекта. Таким образом, если мы встречаем функцию, вызываемую непосредственно по ее смещению, но в то же время присутствующую в виртуальной таблице класса – можно с уверенностью утверждать, что это – виртуальная функция статичного экземпляра объекта. Рассмотрим следующий пример: #include <stdio.h> class Base{ public: virtual void demo(void) { printf(«BASE DEMO\n»); }; virtual void demo_2(void) { printf(«BASE DEMO 2\n»); }; void demo_3(void) { printf(«Non virtual BASE DEMO 3\n»); }; }; class Derived: public Base{ public: virtual void demo(void) { printf(«DERIVED DEMO\n»); }; virtual void demo_2(void) { printf(«DERIVED DEMO 2\n»); }; void demo_3(void) { printf(«Non virtual DERIVED DEMO 3\n»); }; }; main() { Base p; p.demo(); p.demo_2(); p.demo_3(); Derived d; d.demo(); d.demo_2(); d.demo_3(); } Листинг 32 Демонстрация вызова статической виртуальной функции Результат ее компиляции в общем случае должен выглядеть так: mainproc near; CODE XREF: start+AFp var_8= byte ptr -8; derived var_4= byte ptr -4; base ; часто, (но не всегда!) экземпляры объектов в стеке расположены снизу вверх, ; т.е. в обратном порядке их объявления в программе pushebp movebp, esp subesp, 8 leaecx, [ebp+var_4] ; base callGetBASE_VTBL; p[0]=*BASE_VTBL ; обратите внимание – экземпляр объекта размещается в стеке, ; а не в куче! Это, конечно, не еще не свидетельствует о статичной ; природе экземпляра объекта (динамичные объекты тоже могут размещаться в стеке) ; но намеком на «статику» все же служит leaecx, [ebp+var_4] ; base ; подготавливаем указатель this (на тот случай если он понадобится функции) callBASE_DEMO ; непосредственный вызов функции! Вот, вкупе с ее наличием в виртуальной таблице ; свидетельство статичности объявления экземпляра объекта! leaecx, [ebp+var_4] ; base ; вновь подготавливаем указатель this на экземляр base callBASE_DEMO_2 ; непосредственный вызов функции. Она есть в виртуальной таблице? Есть! ; значит, это виртуальная функция, а экземпляр объекта объявлен статичным leaecx, [ebp+var_4] ; base ; готовим указатель this для не виртуальной функции demo_3 callBASE_DEMO_3 ; этой функции нет в виртуальной таблице (см. виртуальную таблицу) ; значит, она не виртуальная leaecx, [ebp+var_8] ; derived callGetDERIVED_VTBL; d[0]=*DERIVED_VTBL leaecx, [ebp+var_8] ; derived callDERIVED_DEMO ; аналогично предыдущему… leaecx, [ebp+var_8] ; derived callDERIVED_DEMO_2 ; аналогичнопредыдущему… leaecx, [ebp+var_8] ; derived callBASE_DEMO_3_ ; внимание! Указатель this указывает на объект DERIVED, в то время как ; вызывается функция объекта BASE!!! Значит, функция BASE – производная movesp, ebp popebp retn mainendp BASE_DEMOproc near; CODE XREF: main+11p ; функция demo класса BASE pushoffset aBase; «BASE\n» callprintf popecx retn BASE_DEMOendp BASE_DEMO_2proc near; CODE XREF: main+19p ; функция demo_2 класса BASE pushoffset aBaseDemo2 ; «BASE DEMO 2\n» callprintf popecx retn BASE_DEMO_2endp BASE_DEMO_3proc near; CODE XREF: main+21p ; функция demo_3 класса BASE pushoffset aNonVirtualBase ; «Non virtual BASE DEMO3\n» callprintf popecx retn BASE_DEMO_3endp DERIVED_DEMOproc near; CODE XREF: main+31p ; функция demo класса DERIVED pushoffset aDerived; «DERIVED\n» callprintf popecx retn DERIVED_DEMOendp DERIVED_DEMO_2proc near; CODE XREF: main+39p ; функция demo класса DERIVED pushoffset aDerivedDemo2 ; «DERIVEDDEMO 2\n» callprintf popecx retn DERIVED_DEMO_2endp BASE_DEMO_3_proc near; CODE XREF: main+41p ; функция demo_3 класса BASE ; Внимание! Смотрите – функция demo_3 дважды присутствует в программе! ; первый раз она входила в объект класса BASE, а второй – в объект класса ; DERIVED, который унаследовал ее от базового класса и сделал копию ; глупо, да? ведь лучше бы он обратился к оригиналу… Зато это упрощает ; анализ программы… pushoffset aNonVirtualDeri ; «Non virtual DERIVED DEMO 3\n» callprintf popecx retn BASE_DEMO_3_endp GetBASE_VTBLproc near; CODE XREF: main+9p ; занесение в экземпляр объекта BASE смещения его виртуальной таблицы moveax, ecx movdword ptr [eax], offsetBASE_VTBL retn GetBASE_VTBLendp GetDERIVED_VTBLproc near; CODE XREF: main+29p ; занесение в экземпляр объекта DERIVED смещения его виртуальной таблицы pushesi movesi, ecx callGetBASE_VTBL ; ага! Значит, наш объект – производный от BASE! movdword ptr [esi], offsetDERIVED_VTBL ; занесение указателя на виртуальную таблицу DERIVED moveax, esi popesi retn GetDERIVED_VTBLendp BASE_VTBLdd offset BASE_DEMO; DATA XREF: GetBASE_VTBL+2o dd offset BASE_DEMO_2 DERIVED_VTBLdd offset DERIVED_DEMO; DATA XREF: GetDERIVED_VTBL+8o dd offset DERIVED_DEMO_2 ; обратите внимание на наличие виртуальной таблицы даже там, где она не нужна! Листинг 33 ::идентификация производных функций. Идентификация производных не виртуальных функций – весьма тонкий момент. На первый взгляд, коль они вызываются как и обычные Си-функции, распознать: в каком классе была объявлена функция невозможно – компилятор уничтожает эту информацию еще на стадии компиляции. Уничтожает, да не всю! Перед каждым вызовом функции (не важно производной или нет) в обязательном порядке формируется указатель this – на тот случай если он понадобится функции, указывающей на объект из которого вызывается эта функция. Для производных функций указатель this хранит смещение производного, а не базового объекта. Вот оно! Если функция вызывается с различными указателями this – это производная функция. Сложнее выяснить – от какого объекта она происходит. Универсальных решений нет, но если выделить объект A с функциями f1(), f2()… И объект B с функциями f1(), f3(),f4()… то можно смело утверждать, что f1() – функция, производная от класса А. Правда, если из экземпляра класса функция f1() не вызывалась ни разу – определить производная она или нет – не удастся. Рассмотрим все это на следующем примере: #include <stdio.h> class Base{ public: void base_demo(void) { printf(«BASE DEMO\n»); }; void base_demo_2(void) { printf(«BASE DEMO 2\n»); }; }; class Derived: public Base{ public: void derived_demo(void) { printf(«DERIVED DEMO\n»); }; void derived_demo_2(void) { printf(«DERIVED DEMO 2\n»); }; }; Листинг 34 Демонстрация идентификации производных функций Результат компиляции в общем случае должен выглядеть так: mainproc near; CODE XREF: start+AFp pushesi push1 call??2@YAPAXI@Z; operator new(uint) ; создаем новый экземпляр некоторого объекта. Пока мы еще не знаем какого ; пусть это будет объект A movesi, eax; ESI = *a addesp, 4 movecx, esi; ECX = *a (this) callBASE_DEMO ; вызываем BASE_DEMO, обращая внимание на то, что this указывает на 'a' movecx, esi; ECX = *a (this) callBASE_DEMO_2 ; вызываем BASE_DEMO_2, обращая внимание на то, что this указывает на 'a' push1 call??2@YAPAXI@Z; operator new(uint) ; создаем еще один экземпляр некоторого объекта, назовем его b movesi, eax; ESI = *b addesp, 4 movecx, esi; ECX = *b (this) callBASE_DEMO ; Ага! Вызываем BASE_DEMO, но на этот раз this указывает на b ; значит, BASE_DEMO связана родственными отношениями и с 'a' и с 'b' movecx, esi callBASE_DEMO_2 ; Ага! Вызываем BASE_DEMO_2, но на этот раз this указывает на b ; значит, BASE_DEMO_2 связана родственными отношениями и с 'a' и с 'b' movecx, esi callDERIVED_DEMO ; вызываем DERIVED_DEMO. Указатель this указывает на b, и никаких родственных ; связей DERIVED_DEMO с 'a' не замечено. this никогда не указывал на 'a' ; при ее вызове movecx, esi callDERIVED_DEMO_2 ; аналогично… popesi retn mainendp Листинг35 Ок, идентификация не виртуальных производных функций – вполне реальное дело. Единственная сложность – отличить экземпляры двух различных объектов от экземпляров одного и того же объекта. Что же касается идентификации производных виртуальных функций – об этом уже рассказывалось выше. Производные виртуальные функции вызываются в два этапа – на первом в экземпляр объекта заносится смещение виртуальной таблицы базового класса, а затем оно замещается смещением виртуальной таблицы производного класса. Даже если компилятор оптимизирует код, оставшейся избыточности все равно с лихвой хватит для отличия производных функций от остальных. ::идентификация виртуальных таблиц. Теперь, основательно освоившись с виртуальными таблицами и функциями, рассмотрим очень коварный вопрос – всякий ли массив указателей на функции есть виртуальная таблица? Разумеется, нет! Ведь косвенный вызов функции через указатель – частое дело в практике программиста. Массив указателей на функции… хм, конечно типичным его не назовешь, но и такое в жизни встречается! Рассмотрим следующий пример – кривой и наигранный конечно, но чтобы продемонстрировать ситуацию, где массив указателей жизненно необходим, пришлось бы написать не одну сотню строк кода: #include <stdio.h> void demo_1(void) { printf(«Demo 1\n»); } void demo_2(void) { printf(«Demo 2\n»); } void call_demo(void x) { 5) x[0])(); 6) x[1])(); } main() { static void* x[2] = { (void*) demo_1,(void*) demo_2}; Внимание: если инициализировать массив не при его объявлении а по ходу программы, т.е. x[0]=(void *) demo_1,… то компилятор сгенерирует адекватный код, заносящий смещения функций в ходе выполнения программы, что будет совсем не похоже на виртуальную таблицу! Напротив, инициализация при объявлении помещает уже готовые указатели в сегмент данных, смахивая на настоящую виртуальную таблицу (и экономя такты процессора к тому же) call_demo(&x[0]); } Листинг 36 Демонстрация имитации виртуальных таблиц А теперь посмотрим – сможем ли мы отличить «рукотворную» таблицу указателей от настоящей: mainproc near; CODE XREF: start+AFp pushoffset Like_VTBL calldemo_call ; ага, функции передается указатель на нечто очень похожее на виртуальную ; таблицу. Но мы-то, уже умудренные опытом, с легкостью раскалываем эту ; грубую подделку. Во-первых, указатели на VTBL так просто не передаются, ; (там не такой тривиальный код), во-вторых они передаются не через стек, ; а через регистр. В-третьих, указатель на виртуальную таблицу ни одним ; существующим компилятором не используется непосредственно, а помещается ; в объект. Тут же нет ни объекта, ни указателя this – в четвертых. ; словом, это не виртуальная таблица, хотя на беглый, нетренированный ; взгляд очень на нее похожа… popecx retn mainendp demo_callproc near; CODE XREF: sub_0_401030+5p arg_0= dwordptr 8 ; вот-с! указатель – аргумент, а к виртуальным таблицам идет обращение ; через регистр… pushebp movebp, esp pushesi movesi, [ebp+arg_0] calldword ptr [esi] ; происходит двухуровневый вызов функции – по указателю на массив ; указателей на функцию, что характерно для вызова виртуальных функций ; но, опять-таки слишком тривиальный код, - вызов виртуальных функций ; сопряжен с большой избыточностью, а во-вторых опять нет указателя this calldword ptr [esi+4] ; аналогично – слишком просто для вызова виртуальной функции popesi popebp retn demo_callendp Like_VTBLdd offset demo_1; DATA XREF:main dd offset demo_2 ; массив указателей внешне похож на виртуальную таблицу, но ; расположен «не там» где обычно располагаются виртуальные таблицы Листинг 37 Обобщая выводы, разбросанные по комментариям, повторим основные признаки «подделки» еще раз: - слишком тривиальный код, - минимум используемых регистров и никакой избыточности, обращение к виртуальным таблицам происходит куда витиеватее; - указатель на виртуальную функцию заносится в экземпляр объекта, и передается он не через стек, а через регистр (точнее – см. »Идентификация this«); - отсутствует указатель this, всегда подготавливаемый перед вызовом виртуальной функции; - виртуальные функции и статические переменные располагаются в различных местах сегмента данных – поэтому сразу можно отличить одни от других. А можно ли так организовать вызов функции по ссылке, чтобы компиляция программы давала код идентичный вызову виртуальной функции? Как сказать… Теоретически да, но практически – едва ли такое удастся осуществить (а уж непреднамеренно – тем более). Код вызова виртуальных функций в связи с большой избыточностью очень специфичен и легко различим «на глаз». Легко сымитировать общую технику работы с виртуальными таблицами, но без ассемблерных вставок невозможно воспроизвести ее в точности. ::заключение. Вообще же, как мы видим, работа с виртуальными функциями сопряжена с огромной избыточностью и «тормозами», а их анализ связан с большими трудозатратами – приходится постоянно держать в голове множество указателей и помнить какой из них на что указывает. Но, как бы там ни было, никаких принципиально-неразрешимых преград перед исследователем не стоит. ==== Идентификация конструктора и деструктора ==== »то, что не существует в одном тексте (одном возможном мире), может существовать в других текстах (возможных мирах)« тезис семантики возможных миров Конструктор, в силу своего автоматического вызова при создании нового экземпляра объекта, – первая по счету вызываемая функция объекта. Так какие сложности в его идентификации? Камень преткновения в том, что конструктор факультативен, т.е. может присутствовать в объекте, а может и не присутствовать. Поэтому, совсем не факт, что первая вызываемая функция – конструктор! Заглянув в описание языка Си++, можно обнаружить, что конструктор не возвращает никакого значения, что нехарактерно для обычных функций, однако, все же не настолько редко встречается, чтобы однозначно идентифицировать конструктор. Как же тогда быть? Выручает то обстоятельство, что по стандарту конструктор не должен автоматически вызывать исключения, даже если отвести память под объект не удалось. Реализовать это требование можно множеством различных способов, но все, виденные мной компиляторы, просто помещают перед вызовом конструктора проверку на нулевой указатель, передавая ему управление только при удачном выделении памяти для объекта. Напротив, все остальные функции объекта вызываются всегда – даже при неуспешном выделении памяти. Вернее, пытаются вызываться, но нулевой указатель (возращенный при ошибке отведения памяти) при первой же попытке обращения вызывает исключение, передавая «бразды правления» обработчику соответствующей исключительной ситуации. Таким образом, функция, «окольцованная» проверкой нулевого указателя, и есть конструктор, а ни что иное. Теоретически, впрочем, подобная проверка может присутствовать и при вызове других функций, конструктором не являющихся, но… во всяком случае мне на практике с каким еще не приходилось встречаться. Деструктор, как и конструктор факультативен, т.е. последняя вызываемая функция объекта не факт, что деструктор. Тем не менее, отличить деструктор от любой другой функции очень просто – он вызывается только при результативном создании объекта (т.е. успешном выделении памяти) и игнорируется в противном случае. Это – документированное свойство языка, следовательно, обязательное к реализации всеми компиляторами. Таким образом, в код помещается такое же «кольцо», как и у конструктора, но никакой путаницы не возникает, т.к. конструктор вызывается всегда первым (если он есть), а деструктор – последним. Особый случай представляет объект, целиком состоящий из одного конструктора (или деструктора) – попробуй, разберись, с чем мы имеем дело. И разобраться можно! За вызовом конструктора практически всегда присутствует код, обнуляющий this в случае неудалого выделения памяти, - а у деструктора этого нет! Далее – деструктор обычно вызывается не непосредственно из материнской процедуры, а из функции-обертки, вызывающей помимо деструктора и оператор delete, освобождающий занятую объектом память. Так, что отличить конструктор от деструктора вполне можно! Давайте, для лучшего уяснения сказанного рассмотрим следующий пример: #include <stdio.h> 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 Демонстрация конструктора и деструктора Результат его компиляции в общем случае должен выглядеть так: Constructorproc near; CODE XREF: main+11p ; функция конструктора. То, что это именно конструктор можно понять из реализации ; его вызова (см. main) pushesi movesi, ecx pushoffset aConstructor ; «Constructor\n» callprintf addesp, 4 moveax, esi popesi retn Constructorendp Destructorproc near; CODE XREF: destructor+6p ; функция деструктора. То, что это именно деструктор, можно понять из реализации ; его вызова (см. main) pushoffset aDestructor ; «Destructor\n» callprintf popecx retn Destructorendp demoproc near; CODE XREF: main+1Ep ; обычнаяфункия demo pushoffset aMyclass; «MyClass\n» callprintf popecx retn demoendp mainproc near; CODE XREF: start+AFp pushesi push1 call??2@YAPAXI@Z; operator new(uint) addesp, 4 ; выделяем память для нового объекта ; точнее, пытаемся это сделать testeax, eax jzshort loc_0_40105A ; Проверка успешности выделения памяти для объекта. ; Обратите внимание: куда направлен jump. ; Он направлен на инструкцию XORESI,ESI, обнуляющую указатель на объект – ; при попытке использования нулевого указателя возникнет исключение, ; но конструктор не должен вызывать исключение даже если память под объект ; отвести не удалось. ; Поэтому, конструктор получает управление только при успешном отводе памяти! ; Следовательно, функция, находящаяся до XORESI,ESI, и есть конструктор!!! ; И мы сумели надежно идентифицировать ее. movecx, eax ; готовим указатель this callConstructor ; эта функция – конструктор, т.к. вызывается только при удачном отводе памяти movesi, eax jmpshort loc_0_40105C loc_0_40105A:; CODE XREF: main+Dj xoresi, esi ; обнуляем указатель на объект, чтобы вызвать исключение при попытке его ; использования ; Внимание: конструктор никогда не вызывает исключения, поэтому, ; нижележащая функция гарантированно не является конструктором loc_0_40105C:; CODE XREF: main+18j movecx, esi ; готовим указатель this calldemo ; вызываем обычную функцию объекта testesi, esi jzshort loc_0_401070 ; проверка указателя this на NULL. Деструктор вызываться только в том случае ; если память под объект была отведена (если же она не была отведена ; освобождать особо нечего) ; таким образом, следующая функция – именно деструктор, а не что-нибудь еще push1 ; количество байт для освобождения (необходимо для delete) movecx, esi ; готовим указатель this calldestructor ; вызываем деструктор loc_0_401070:; CODE XREF: main+25j popesi retn mainendp destructorproc near; CODE XREF: main+2Bp ; функция деструктора. Обратите внимание, что деструктор обычно вызывается ; из той же функции, что и delete (хотя так бывает и не всегда, но очень часто) arg_0= byte ptr 8 pushebp movebp, esp pushesi movesi, ecx callDestructor ; вызываем функцию деструктора, определенную пользователем test[ebp+arg_0], 1 jzshort loc_0_40109A pushesi call??3@YAXPAX@Z; operator delete(void *) addesp, 4 ; освобождаем память, ранее выделенную объекту loc_0_40109A:; CODE XREF: destructor+Fj moveax, esi popesi popebp retn4 destructorendp Листинг39 ::объекты в автоматической памяти или когда конструктор/деструктор идентифицировать невозможно. Если объект размещается в стеке (автоматической памяти), то никаких проверок успешности ее выделения не выполняется и вызов конструктора становится неотличим от вызова остальных функций. Аналогичная ситуация и с деструктором – стековая память автоматически освобождается по завершению функции, а вместе с ней умирает и сам объект безо всякого вызова delete (delete применяется только для удаления объектов из кучи). Чтобы убедиться в этом, модифицируем функцию main нашего предыдущего примера следующим образом: main() { MyClass zzz; zzz.demo(); } Листинг40 Результат компиляции в общем случае должен выглядеть так: mainproc near; CODE XREF: start+AFp var_4= byte ptr -4 ; локальная переменная zzz – экземпляр объекта MyClass pushebp movebp, esp pushecx leaecx, [ebp+var_4] ; подготавливаем указатель this callconstructor ; вызываем конструктор, как и обычную функцию! ; долгаться, что это конструктор можно разве что по его содержимому ; (обычно конструктор инициализирует объект), да и то неуверенно leaecx, [ebp+var_4] calldemo ; вызываем функцию demo, - обратите внимание, ее вызов ничем не отличается ; от вызова конструктора! leaecx, [ebp+var_4] calldestructor ; вызываем деструктор – его вызов, как мы уже поняли, ничем ; характерным не отмечен movesp, ebp popebp retn mainendp Листинг41 ::идентификация конструктора/деструктора в глобальных объектах. Глобальные объекты (так же называемые статическими объектами) размешаются в сегменте данных еще на стадии компиляции. Стало быть, ошибки выделения памяти в принципе невозможны и, выходит, что по аналогии со стековыми объектами, надежно идентифицировать конструктор/деструктор и здесь нельзя? А вот и нет! Глобальный объект, в силу свой глобальности, доступен из многих мест программы, но его конструктор должен вызываться лишь однажды. Как можно это обеспечить? Конечно, возможны самые различные варианты реализации, но большинство компиляторов идут по простейшему пути, используя для этой цели глобальную переменную-флаг, изначально равную нулю, а перед первым вызовом конструктора увеличивающуюся на единицу (в более общем случае устанавливающуюся в TRUE). При повторных итерациях остается проверить – равен ли флаг нулю, и если нет – пропустить вызов конструктора. Таким образом, конструктор вновь «окольцовывается» условным переходом, что позволяет его безошибочно отличить ото всех остальных функций. С деструктором еще проще – раз объект глобальный, то он уничтожается только при завершении программы. А кто это может отследить кроме поддержки времени исполнения? Специальная функция, такая как _atexit, принимает на вход указатель на конструктор, запоминает его и затем вызывает при возникновении в этом необходимости. Интересный момент - _atexit (или что там используется в вашем конкретном случае) должна быть вызвана лишь однократно (надеюсь, понятно почему?). И, чтобы не вводить еще один флаг, она вызывается сразу же после вызова конструктора! На первый взгляд объект может показаться состоящим из одних конструктора/деструктора, но это не так! Не забывайте, что _atexit не передает немедленно управление на код деструктора, а только запоминает его указатель для дальнейшего использования! Таким образом, конструктор/деструктор глобального объекта очень просто идентифицировать, что и доказывает следующий пример: main() { static MyClass zzz; zzz.demo(); } Листинг42 Результат его компиляции в общем случае должен выглядеть так: mainproc near; CODE XREF: start+AFp movcl, byte_0_4078E0 ; флаг инициализации экземпляраобъекта moval, 1 testal, cl ; объект инициализирован? jnzshortloc_0_40106D ; –> да, инициализирован, - не вызываем конструктор movdl, cl movecx, offsetunk_0_4078E1 ; экземляр объекта ; готовим указатель this ordl, al ; устанавливаем флаг инициализации в TRUE ; и вызываем конструктор movbyte_0_4078E0, dl ; флаг инициализации экземпляраобъекта callconstructor ; Вызов конструктора. ; Обратите внимание, что если экземпляр объекта уже инициализирован ; (см. проверку выше) конструктор не вызывается. ; Таким образом, его очень легко отождествить! pushoffset thunk_destructo call_atexit addesp, 4 ; Передаем функции _atexit указатель на деструктор, ; который она должна вызвать по завершении программы loc_0_40106D:; CODE XREF: main+Aj movecx, offset unk_0_4078E1 ; экземпляробъекта ; готовим указатель this jmpdemo ; вызываем demo mainendp thunk_destructo:; DATA XREF: main+20o ; переходник к функции-деструктору movecx, offsetunk_0_4078E1 ; экземпляр объекта jmpdestructor byte_0_4078E0db 0; DATA XREF: mainr main+15w ; флаг инициализации экземпляра объекта unk_0_4078E1db 0;; DATA XREF: main+Eo main+2Do… ; экземпляр объекта Листинг 43 Аналогичный код генерирует и BorlandC++. Единственное отличие – более хитрый вызов деструктора. Вызовы всех деструкторов помещены в специальную процедуру, которая выдает себя тем, что обычно располагается перед библиотечными функциями (или в непосредственной близости от них), так что идентифицировать ее очень легко. Смотритесами: _mainproc near; DATA XREF: DATA:00407044o pushebp movebp, esp cmpds:byte_0_407074, 0 ; флаг инициализации объекта jnzshort loc_0_4010EC ; Если объект уже инициализирован – конструктор не вызывается moveax, offsetunk_0_4080B4 ; Экземпляр объекта callconstructor incds:byte_0_407074 ; флаг инициализации объекта ; Увеличиваем флаг на единицу, возводя его в TRUE loc_0_4010EC:; CODE XREF: _main+Aj moveax, offset unk_0_4080B4 ; Экземляробъекта calldemo ; Вызовфункции demo xoreax, eax popebp retn _mainendp call_destructproc near; DATA XREF: DATA:004080A4o ; Эта функция содержит в себе вызовы всех деструкторов глобальных объектов, ; поскольку, вызов каждого деструктора «окольцован» проверкой флага инициализации, ; эту функцию легко идентифицировать – только она содержит подобный «калечный код» ; (вызовы конструкторов обычно разбросаны по всей программе) pushebp movebp, esp cmpds:byte_0_407074, 0 ; флаг инициализации объекта jzshort loc_0_401117 ; объект был инициализирован? moveax, offsetunk_0_4080B4 ; Экземпляр объекта ; готовим указатель this movedx, 2 calldestructor ; вызываем деструктор loc_0_401117:; CODE XREF: call_destruct+Aj popebp retn call_destructendp Листинг44 :: виртуальный деструктор. Деструктор тоже может быть виртуальным! А почему бы и нет? Это бывает полезно, когда экземпляр производного класса удаляется через указатель на базовый объект. Поскольку, виртуальные функции связаны с классом объекта, а не с классом указателя, то вызывается виртуальный деструктор, связанный с типом объекта, а не с типом указателя. Впрочем, эти тонкости относятся к непосредственному программированию, а исследователей в первую очередь интересует: как идентифицировать виртуальный деструктор. О, это просто – виртуальный деструктор совмещает в себе свойства обычного деструктора и виртуальной функции (см. »Идентификация виртуальных функций«). ::виртуальный конструктор. Виртуальный конструктор?! А что, разве есть такой? Ничего подобного стандартный Си++ не поддерживает. Непосредственно не поддерживает. И, когда виртуальный конструктор позарез требуется программистом (впрочем, бывает это лишь в весьма экзотических случаях), они прибегают к ручной эмуляции некоторого его подобия. В специально выделенную для этих целей виртуальную функцию (не конструктор!) помещается приблизительно следующий код: »returnnewимя класса (*this)«. Этот трюк кривее, чем бумеранг, но… он работает. Разумеется, существуют и другие решения. Подробное их обсуждение далеко выходит за рамки данной книги и требует глубоко знания Си++ (гораздо более глубокого, чем у рядового разработчика), к тому же это заняло бы слишком много места… но едва ли оказалось интересно рядовому читателю. Итак, идентификация виртуального конструктора в силу отсутствия самого понятия – в принципе невозможна. Его эмуляция насчитывает десятки решений (если не больше), – попробуй-ка, перечисли их все! Впрочем, этого и не нужно делать – в большинстве случаев виртуальные конструкторы представляют собой виртуальные функции, принимающие в качестве аргумента указатель this и возвращающие указатель на новый объект. Не слишком-то надежно для идентификации, но все же лучше, чем ничего. ::конструктор раз, конструктор два… Количество конструкторов объекта может быть и более одного (и очень часто не только может, но и бывает). Однако это никак не влияет на анализ. Сколько бы конструкторов ни присутствовало, – для каждого экземпляра объекта всегда вызывается только один, выбранный компилятором в зависимости от формы объявления объекта. Единственная деталь – различные экземпляры объекта могут вызывать различные конструкторы – будьте внимательны! ::а зачем козе баян или внимание: пустой конструктор. Некоторые ограничения конструктора (в частности, отсутствие возвращаемого значения) привели к появлению стиля программирования »пустой конструктор«. Конструктор умышленно оставляется пустым, а весь код инициализации помещается в специальную функцию-член, как правило, называемую Init. Обсуждение сильных и слабых сторон такого стиля – предмет отдельного разговора, никаким боком не относящегося к данной книге. Исследователям достаточно знать – такой стиль есть и активно используется не только отдельными индивидуальными программистами, но и крупнейшими компаниями-гигантами (например, той же Microsoft). Поэтому, встретив вызов пустого конструктора, – не удивляйтесь, - это нормально, и ищите функцию инициализации среди обычных членов. ==== Идентификация объектов, структур и массивов ==== Для целого поколения Эйнштейн был глашатаем передовой науки, пророком разума и мира. А сам он в глубине своей кроткой и невозмутимой души без всякой горечи оставался скептиком… Он хотел затеряться и как бы раствориться в окружающем его мире, а оказался одним из самых разрекламированных людей нашего века, и его лицо, вдохновенное и отрешенное от всех грехов мира, стало таким же широко известным, как фотография какой-нибудь кинозвезды. Чарлз Перси Сноу «ЭЙНШТЕЙН» Внутренне представление объектов очень похоже на представление структур в языке Си (по большому счету, объекты и есть структуры), поэтому, рассмотрим их идентификацию в одной главе. Структуры очень популярны среди программистов – позволяя объединить под одной крышей родственные данные, они делают листинг программы более наглядным, упрощая его понимание. Соответственно, идентификация структур при дизассемблировании облегчает анализ кода. К великому сожалению исследователей, структуры как таковые существует только в исходном тексте программы и практически полностью «перемалываются» при ее компиляции, становясь неотличимыми от обычных, никак не связанных друг с другом переменных. Рассмотрим следующий пример: #include <stdio.h> #include <string.h> struct zzz { char s0[16]; int a; float f; }; func(struct zzz y) Понятное дело, передачи структуры по значению лучше избегать, но здесь это сделано умышленно для демонстрации скрытого создания локальной переменной { printf(»%s %x %f\n«,&y.s0[0], y.a, y.f); } main() { struct zzz y; strcpy(&y.s0[0],»Hello,Sailor!«); y.a=0x666; y.f=6.6; func(y); } Листинг 45 Пример, демонстрирующий уничтожение структур на стадии компиляции Результат его компиляции в общем случае должен выглядеть так: mainproc near; CODE XREF: start+AFp var_18= byte ptr -18h var_8= dwordptr -8 var_4= dwordptr -4 ; члены структуры неотличимы от обычных локальных переменных pushebp movebp, esp subesp, 18h ; резервирование места в стеке для структуры pushesi pushedi pushoffset aHelloSailor ; «Hello,Sailor!» leaeax, [ebp+var_18] ; Указатель на локальную переменную var_18 ; следующая за ней переменная расположена по смещению 8 ; следовательно, 0x18-0x8=0x10 – шестнадцать байт – именно столько ; занимает var_18, что намекает на то, что она – строка ; (см. »Идентификация литералов и строк«) pusheax callstrcpy ; копирование строки из сегмента данных в локальную переменную-член структуры addesp, 8 mov[ebp+var_8], 666h ; занесение в переменную типа DWORD значения 0x666 mov[ebp+var_4], 40D33333h ; а это значение в формате float равно 6.6 ; (см. »Идентификация аргументов функций«) subesp, 18h ; резервируем место для скрытой локальной переменной, которая используется ; компилятором для передачи функции экземпляра структуры по значению ; (см. »Идентификация регистровых и временных переменных«) movecx, 6 ; будет скопировано 6 двойных слов, т.е. 24 байта ; 16 – на строку и по четыре на float и int leaesi, [ebp+var_18] ; получаем указатель на копируемую структуру movedi, esp ; получаем указатель на только что созданную скрытую локальную переменную repe movsd ; копируем! callfunc ; вызываем функцию ; передачи указателя на скрытую локальную переменную не происходит – она ; и так находится на верху стека. addesp, 18h popedi popesi movesp, ebp popebp retn mainendp Листинг46 А теперь заменим структуру последовательным объявлением тех же самых переменных: main() { char s0[16]; int a; float f; strcpy(&s0[0],»Hello,Sailor!«); a=0x666; f=6.6; } Листинг 47 Пример, демонстрирующий сходство структур с обычными локальными переменными И сравним результат компиляции с предыдущим: mainproc near; CODE XREF: start+AFp var_18= dwordptr -18h var_14= byte ptr -14h var_4= dwordptr -4 ; Ага, кажется есть какое-то различие! Действительно, локальные переменные помещены ; в стек не в том порядке, в котором они были объявлены в программе, а как это ; захотелось компилятору. Напротив, члены структуры обязательно должны помещаться ; в порядке их объявления. ; Но, поскольку, при дизассемблировании оригинальный порядок следования переменных ; не известен, определить «правильно» ли они расположены или нет, увы, ; не представляется возможным pushebp movebp, esp subesp, 18h ; резервируем 0x18 байт стека (как и предыдущем примере) pushoffset aHelloSailor ; «Hello,Sailor!» leaeax, [ebp+var_14] pusheax callstrcpy addesp, 8 mov[ebp+var_4], 666h mov[ebp+var_18], 40D33333h ; смотрите: код аккуратно совпадает байт в байт! Следовательно, невозможно ; автоматически отличить структуру от простого скопища локальных переменных movesp, ebp popebp retn mainendp funcproc near; CODE XREF: main+36p var_8= qwordptr -8 arg_0= byte ptr 8 arg_10= dwordptr 18h arg_14= dwordptr 1Ch ; смотрите: хотя функции передается только один аргумент – экземпляр структуры – ; в дизассемблерном тексте он не отличим от последовательной засылки в стек ; нескольких локальных переменных! Поэтому, восстановить подлинный прототип ; функции невозможно! pushebp movebp, esp fld[ebp+arg_14] ; загрузить в стек FPU вещественное целое, находящееся по смещению ; 0x14 относительно указателя eax subesp, 8 ; зарезервировать 8 байт пол локал. перемен. fstp[esp+8+var_8] ; перепихнуть считанное вещественное значение в локальную переменную moveax, [ebp+arg_10] pusheax ; прочитать только что «перепихнутую» вещественную переменную ; и затолкать ее в стек leaecx, [ebp+arg_0] ; получить указатель на первый аргумент pushecx pushoffset aSXF; »%s %x %f\n« callprintf addesp, 14h popebp retn funcendp Листинг 48 Выходит, отличить структуру от обычных переменных невозможно? Неужто исследователю придется самостоятельно распознавать «родство» данных и связывать их «брачными узами», порой ошибаясь и неточно воспроизводя исходный текст программы? Как сказать… И да, и нет одновременно. »Да«: экземпляр структуры, использующийся в той же единице трансляции в которой он был объявлен, «развертывается» еще на стадии компиляции в самостоятельные переменные, обращение к которым происходит индивидуально по их фактическим адресам (возможно относительным). »Нет«, – если в области видимости находится один лишь указатель на экземпляр структуры. – Тогда обращение ко всем членам структуры происходит через указатель на этот экземпляр структуры (т.к. структура не присутствует в области видимости, например, передается другой функции по ссылке, вычислить фактические адреса ее членов на стадии компиляции невозможно). Постойте, но ведь точно так происходит обращение и к элементам массива, – базовый указатель указывает на начало массива, к нему добавляется смещение искомого элемента относительно начала массива (индекс элемента, умноженный на его размер), – результат вычислений и будет фактическим указателем на искомый элемент! Единственное фундаментальное отличие массивов от структур состоит в том, что массивы гомогенны (т.е. состоят из элементов одинакового типа), а структуры могут быть как гомогенными, таки гетерогенными (состоящими из элементов различных типов). Таким образом, задача идентификации структур и массивов сводится: во-первых, к выделению ячеек памяти, адресуемых через общий для всех них базовый указатель, и, во-вторых, определению типа этих переменных _(см. идентификация типов данных). Если удается выделить более одного типа – скорее всего перед нами структура, в противном случае это с равным успехом может быть и структурой, и массивом, - тут уж приходится смотреть по обстоятельствам и самой программе. С другой стороны, если программисту вздумается подсчитать зависимость выпитого пива от дня недели, он может выделить для учета либо массив day[7], либо завести структуру structweek{intMonday; int Tuesday;….}. И в том, и в другом случае сгенерированный компилятором код будет одинаков, да не только код, но и смысл! В этом контексте структура неотличима от массива и физически, и логически, - выбор той или иной конструкции – дело вкуса. Так же возьмите себе на заметку, что массивы, как правило, длинны, а обращение к их элементам часто сопровождается различными математическими операциями, совершаемыми над указателем. Далее – обработка элементов массива как правило осуществляется в цикле, а члены структуры по обыкновению «разбираются» индивидуально (хотя некоторые программисты позволяют себе вольность обращаться со структурой как с массивом). Еще неприятнее, что Си/Си++ допускают (если не сказать провоцируют) явное преобразование типов и… ой, а ведь в этом случае, при дизассемблировании не удастся установить: имеем ли мы дело с объединенными под одну крышу разнотипными данными (т.е. структуру), или же это массив, c «ручным» преобразованием типа своих элементов. Хотя, строго говоря, после подобных преобразований массив превращается в самую настоящую структуру! (Массив по определению гомогенен, и данные разных типов хранить не может). Модифицируем предыдущий пример, передав функции не саму структуру, а указатель на нее и посмотрим, что за код сгенерировал компилятор. functproc near; CODE XREF: sub_0_401029+29p var_8= qwordptr -8 arg_0= dwordptr 8 ; ага! Функция принимает только один аргумент! pushebp movebp, esp moveax, [ebp+arg_0] ; загружаем переданный функции аргумент в EAX flddword ptr [eax+14h] ; загружаем в стек FPU вещественное значение, находящееся по смещению ; 0x14 относительно указателя EAX ; Таким образом, во-первых, EAX (аргумент, переданный функции) – это указатель ; во-вторых, это не просто указатель, а базовый указатель, использующийся ; для доступа к элементам структуры или массива. ; Запомним тип первого элемента (вещественное значение) и продолжим анализ subesp, 8 ; резервируем 8 байт пол локальные переменные fstp[esp+8+var_8] ; перепихиваем считанное вещественное значение в локальную переменную var_8 movecx, [ebp+arg_0] ; Загружаем в ECX значение переданного функции указателя movedx, [ecx+10h] ; загружаем в EDX значение, лежащее по смещению 0x10 ; Ага! Это явно не вещественное значение, следовательно, мы имеем дело со ; структурой pushedx ; заталкиваем только что считанное значение в стек moveax, [ebp+arg_0] pusheax ; получаем указатель на структуру (т.е. на ее первый член) ; и запихиваем его в стек. Поскольку ближайший элемент ; находится по смещению 0x10, то первый элемент структуры по-видимому ; занимает все эти 0x10 байт, хотя это и не обязательно – возможно остальные ; члены структуры просто не используются. Установить: как все обстоит на самом ; деле можно, обратившись к вызывающей (материнской) функции, которая и ; инициализировала эту структуру, но и без этого, мы можем восстановить ; ее приблизительный вид ; structxxx{ ; char x[0x10] || int x[4] || int16[8] || int64[2]; ; int y; ; float z; ; } pushoffset aSXF; »%s %x %f\n« ; строка спецификаторов, позволяет уточнить типы данных – так, первый элемент ; это, бесспорно, charx[x010], поскольку, он выводится как строка, ; следовательно наше предварительное предположение о формате структуры – ; верное! callprintf addesp, 14h popebp retn functendp mainproc near; CODE XREF: start+AFp var_18= byte ptr -18h var_8= dwordptr -8 var_4= dwordptr -4 ; смотрите: на первый взгляд мы имеем дело с несколькими локальными переменными, ; но давайте не будем торопиться с их идентификацией! pushebp movebp, esp subesp, 18h ; Открываем кадр стека pushoffset aHelloSailor ; «Hello,Sailor!» leaeax, [ebp+var_18] pusheax callunknown_libname_1 ; unknown_libmane_1 – это strcpy и понять это можно даже не анализируя ее код. ; Функция принимает два аргумента – указатель на локальный буфер из 0x10 байт ; (размер 0x10 получен вычитанием смещения ближайшей переменной от смещения ; самой этой переменной относительно карда стека) такой же точно прототип ; и у strcmp, но это не может быть strcmp, т.к. локальный буфер ; не инициализирован, и он может быть только буфером-приемником addesp, 8 ; выталкиваем аргументы из стека mov[ebp+var_8], 666h ; инициализируем локальную переменную var_8 типа DWORD mov[ebp+var_4], 40D33333h ; инициализируем локальную переменную var_4 типа… нет, не DWORD ; (хотя она и выглядит как DWORD), - проанализировав, как эта переменная ; используется в функции funct, которой она передается, мы распознаем ; в ней вещественное значение размером 4 байта. Стало быть это float ; (подробнее см. »Идентификация аргументов функций«) leaecx, [ebp+var_18] pushecx ; Вот теперь – самое главное! Функции передается указатель на локальную ; переменную var_18, - строковой буфер размером в 0x10 байт, ; но анализ вызываемой функции позволил установить, что она обращается не ; только к первым 0x10 байтам стека материнской функции, а ко всем – 0x18! ; Следовательно, функции передается не указатель на строковой буфер, ; а указатель на структуру ; ; srtuct x{ ; char var_18[10]; ; int var_8; ; float var_4 ; } ; ; Поскольку, типы данных различны, то это – именно структура, а не массив. callfunct addesp, 4 movesp, ebp popebp retn sub_0_401029endp Листинг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 <stdio.h> 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 Результат его компиляции в общем случае должен выглядеть так: mainproc near; CODE XREF: start+AFp pushesi push8 call??2@YAPAXI@Z; operator new(uint) ; Выделяем 8 байт под экземляр некоторого объекта оператором new ; Вообще-то, вовсе не факт, что память выделяется именно под объект ; (может тут было что-то типа char *x = new char[8]), так что ; не будем считать это утверждение догмой, а примем как рабочую гипотезу - ; дальнейшие исследования покажут: что к чему movesi, eax addesp, 4 movecx, esi ; Ухо-хвост тигра! готовится указатель this который передается функции ; через регистр. Значит, ECX – ни что иное, как указатель на экземпляр объекта! ; (подробнее – см. »Идентификация this«) calldemo ; Вот мы и добрались до вызова функции demo – открываем хвост Тигре! ; Пока не ясно, что эта функция делает (символьное имя дано ей для наглядности) ; но известно, что она принадлежит экземпляру объекта, на который ; указывает ECX. Назовем этот экземпляр 'a'. Далее – поскольку ; функция, вызывающая demo (т.е. функция в которой мы сейчас находимся), не ; принадлежит к 'a' (она же его сама и создала – не мог же экземпляр объекта ; сам «вытянуть себя за волосы»), то функция demo – это public-функция. ; Неплохо для начала? movdword ptr [esi], 777h ; так, так… мы помним, что ESI указывает на экземпляр объекта, тогда ; выходит, что в объекте есть еще один public-член, это переменная ; типа int. ; По предварительным заключениям объект выглядел так: ; class myclass{ ; public: ; voiddemo(void); void –т.к. функция ничего не принимает и не возвращает ; int x; ;} popesi retn mainendp demoproc near; CODE XREF: main+Fp ; вот мы в функции demo – члене объекта A pushesi movesi, ecx ; Загружаем в ECX – указатель this, переданный функции pushoffset aMyclass; «MyClass\n» callprintf addesp, 4 ; Выводим строку на экран…это не интересно, но вот дальше… movecx, esi calldemo_private ; Опля, вот он, наш Тигра! Вызывается еще одна функция! Судя по this, ; эта функция нашего объекта, причем вероятнее всего имеющая атрибут private, ; поскольку вызывается только из функции самого объекта. movdword ptr [esi+4], 666h ; Так, в объекте есть еще одна переменная, вероятно, приватная. Тогда, ; по современным воззрениям, объект должен выглядеть так: ; class myclass{ ; void demo_provate(void); ; int y; ; public: ; voiddemo(void); void –т.к. функция ничего не принимает и не возвращает ; intx; ; } ; ; Итак, мы не только идентифицировали объект, но даже восстановили его ; структуру! Пускай, не застрахованную от ошибок (так, предположение ; о приватности «demo_private» и «y» базируется лишь на том, что они ни разу ; не вызывались извне объекта), но все же – не так ООП страшно, как его ; малюют и восстановить если не подлинный исходный текст программы, то хотя бы ; какое-то его подобие вполне возможно! popesi retn demoendp demo_privateproc near; CODE XREF: demo+12p ; приватная функция demo. – ничего интересного pushoffset aPrivate; «Private\n» callprintf popecx retn demo_privateendp Листинг 53 ::Объекты и экземпляры. В коде, сгенерированном компилятором, никаких объектов и в помине нет, – одни лишь экземпляры объектов. Вроде бы – да какая разница-то? Экземпляр объекта разве не есть сам объект? Нет, между объектом и экземпляром существует принципиальная разница. Объект – это структура, в то время как экземпляр объекта (в сгенерированном коде!) – подструктура этой структуры. Т.е. пусть имеется объект А, включающий в себя функции a1 и a2. Далее, пусть создано два его экземпляра – из одного мы вызываем функцию a1, а из другого – a2. С помощью указателя this мы сможем выяснить лишь то, что одному экземпляру принадлежит функция a1, а другому – a2. Но установить – являются ли эти экземпляры экземплярами одного объекта или экземплярами двух разных объектов – невозможно! Ситуация усугубляется тем, что в производных классах наследуемые функции не дублируются (во всяком случае, так поступают «умные» компиляторы, хотя… в жизни случается всякое). Возникает двузначность – если с одним экземпляром связаны функции a1 и a2, а с другим - a1, a2 и a3, то это могут быть либо экземпляры одного класса (просто из первого экземпляра функция a3 не вызывается), то ли второй экземпляр – экземпляр класса, производного от первого. Код, сгенерированный компилятором, в обоих случаях будет идентичным! Приходится восстанавливать иерархию классов по смыслу и назначению принадлежащих им функций… понятное дело, приблизиться к исходному коду сможет только провидец (ясновидящий). Словом, как бы там ни было, никогда не путайте экземпляр объекта с самим объектом, и не забываете, что объекты существуют только в исходном тексте и уничтожаются на стадии компиляции. ::мой адрес – не дом и не улица! Где «живут» структуры, массивы и объекты? Конечно же, в памяти! А поконкретнее? Конкретнее: существуют три типа размещения: в стеке (автоматическая память), сегменте данных (статическая память) и куче (динамическая память). И каждый тип со своим «характером». Возьмем стек – выделение памяти неявное, фактически происходящее на этапе компиляции, причем гарантированно определяется только общий объем памяти, выделенный под все локальные переменные, а определить: сколько занимает каждая из них – невозможно в принципе. Не верите? А вот скажем, пусть будет такой код: »chara1[13]; chara2[17]; chara3[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|| ::: | ::: | ::: | | WATCOMC| ::: | ::: | ::: | ::: | ::: | Таблица 1 Механизм передачи указателя this в зависимости от реализации компилятора и типа функции ==== Идентификация new и delete ==== …нет ничего случайного. Самые свободные ассоциации являются самыми надежными» тезис классического психоанализа Операторы new и delete транслируются компилятором в вызовы библиотечных функций, которые могут быть распознаны точно так, как и обычные библиотечные функции (см. «Идентификация библиотечных функций»). Автоматически распознавать библиотечные функции умеет, в частности, IDAPro, снимая эту заботу с плеч пользователя. Однако IDAPro есть не у всех, и далеко не всегда в нужный момент находится под рукой, да к тому же не все библиотечные функции она знает, а из тех, что знает не всегда узнает new и delete… Словом, причин для их ручной идентификации существует предостаточно… Реализация new и delete может быть любой, но Windows-компиляторы в большинстве своем редко реализуют функции работы с кучей самостоятельно, - зачем это, ведь намного проще обратиться к услугам операционной системы. Однако наивно ожидать вместо newвызов HeapAlloc, а вместо deleteHeapFree. Нет, компилятор не так прост! Разве он может отказать себе в удовольствии «вырезания матрешек»? Оператор new транслируется в функцию new, вызывающую для выделения памяти malloc, malloc же в свою очередь обращается к heap_alloc(или ее подобию – в зависимости от реализации библиотеки работы с памятью – см. «подходы к реализацию кучи»), – своеобразной «обертке» одноименной Win32 API-процедуры. Картина с освобождением памяти – аналогична. Углубляться в дебри вложенных вызовов – слишком утомительно. Нельзя ли new и delete идентифицировать как-нибудь иначе, с меньшими трудозатратами и без большой головной боли? Разумеется, можно! Давайте вспомним все, что мы знаем о new. - new принимает единственный аргумент – количество байт выделяемой памяти, причем этот аргумент в подавляющем большинстве случаев вычисляется еще на стадии компиляции, т.е. является константой; - если объект не содержит ни данных, ни виртуальных функций, его размер равен единице (минимальный блок памяти, выделяемый только для того, чтобы было на что указывать указателю this); отсюда – будет очень много вызовов типа PUSH 01\CALLxxx, - где xxx и есть адрес new! Вообще же, типичный размер объектов составляет менее сотни байт… - ищите часто вызываемую функцию, с аргументом-константой меньшей ста байт; - функция new – одна из самых «популярных» библиотечных функций, - ищите функцию с «толпой» перекрестных ссылок; - самое характерное: new возвращает указать this, а this очень легко идентифицировать даже при беглом просмотре кода (см. «Идентификация this»); - возвращенный new результат всегда проверяется на равенство нулю, и если он действительно равен нулю, конструктор (если он есть – см. «Идентификация конструктора и деструктора») не вызывается; «Родимых пятен» у new более чем достаточно для быстрой и надежной идентификации, - тратить время на анализ ее кода совершенно ни к чему! Единственное, о чем следует помнить: new используется не только для создания новых экземпляров объектов, но и для выделения памяти под массивы (структуры) и изредка – одиночные переменные (типа int *x = newint, - что вообще маразм, но… некоторые так делают). К счастью, отличить два этих способа очень просто – ни у массивов, ни у структур, ни у одиночных переменных нет указателя this! Давайте, для закрепления всего вышесказанного рассмотрим фрагмент кода, сгенерированного компилятором WATCOM (IDAPRO не распознает его «родную» new): main_proc near; CODE XREF: CMain+40p push10h callCHK pushebx pushedx moveax, 4 callW?$nwn_ui_pnv ; это, как мы узнаем позднее, функция new. IDA вообще-то распознала ее имя, но, ; чтобы узнать в этой «абракадабре» оператор выделения памяти – надо быть ; провидцем! ; Пока же обратим внимание, что она принимает один аргумент-константу ; очень небольшую по значению т.е. заведомо не являющуюся смещением ; (см. «Идентификация констант и смещений») ; Передача аргумента через регистр ни о чем не говорит – Watcom так поступает ; со многими библиотечными функциями, напротив, другие компиляторы всегда ; заталкивают аргумент в стек… movedx, eax testeax, eax ; Проверка результата, возвращенного функцией, на нулевое значение ; (что характерно для new) jzshort loc_41002A movdword ptr [eax], offset BASE_VTBL ; Ага, функция возвратила указатель и по нему записывается указатель на ; виртуальную таблицу (или по крайней мере – массив функций) ; EAX уже очень похож на this, но, чтобы окончательно убедиться в этом, ; требуется дополнительные признаки… loc_41002A:; CODE XREF: main_+1Aj movebx, [edx] moveax, edx calldword ptr [ebx] ; Вот теперь можно не сомневаться, что EAX – указатель this, а этот код – ; и есть вызов виртуальной функции! ; Следовательно, функция W?$nwm_ui_pnv и есть new ;(а кто бы еще мог возвратить this?) Листинг 54 Сложнее идентифицировать delete. Каких либо характерных признаков эта функция не имеет. Да, она принимает единственный аргумент – указатель на освобождаемый регион памяти, причем, в подавляющем большинстве случаев этот указатель – this. Но, помимо нее, this принимают десятки, если не сотни других функций! Правда, между ними существует одно тонкое различие – delete в большинстве случаев принимает указатель this через стек, а остальные функции – через регистр. К сожалению, некоторые компиляторы, (тот же WATCOM – не к ночи он будет упомянут) передают многим библиотечным функциям аргументы через регистры, скрывая тем самым все различия! Еще, delete ничего не возвращает, но мало ли функций поступают точно так же? Единственная зацепка – вызов delete следует за вызовом деструктора (если он есть), но, ввиду того, что конструктор как раз и идентифицируется как функция, предшествующая delete, образуется замкнутый круг! Ничего не остается, как анализировать ее содержимое – delete рано или поздно вызывает HeapFree (хотя тут возможны и варианты, так Borland содержит библиотеки, работающие с кучей на низком уровне и освобождающие память вызовом VirtualFree). К счастью, IDAPro в большинстве случаев опознает delete и самостоятельно напрягаться не приходится. ::подходы к реализации кучи. В некоторых, между прочим достаточно многих, руководствах по программированию на Си++ (например, Джефри Рихтер «Windows для профессионалов») встречаются призывы всегда выделять память именно new, а не malloc, поскольку, new опирается на эффективные средства управления памятью самой операционной системы, а malloc реализует собственный (и достаточно тормозной) менеджер кучи. Все это грубые натяжки! Стандарт вообще ничего не говорит о реализации кучи, и какая функция окажется эффективнее наперед неизвестно. Все зависит от конкретных библиотек конкретного компилятора. Рассмотрим, как происходит управление памятью в штатных библиотеках трех популярных компиляторов: MicrosoftVisualC++, BorlandC++ и WatcomC++. В MicrosoftVisualC++ и malloc, и new представляют собой переходники к одной и той же функции nh_malloc, поэтому, можно с одинаковым успехом пользоваться и той, и другой. Сама же nh_malloc вызывает heap_alloc, в свою очередь вызывающую API функцию WindowsHeapAlloc. (Стоит отметить, что в heap_alloc есть «хук» – возможность вызвать собственный менеджер куч, если по каким-то причинам системный будет недоступен, впрочем, в MicrosoftVisualC++ 6.0 от хука осталась одна лишь обертка, а собственный менеджер куч был исключен). Все не так в BorlandC++! Во-первых, этот зверь напрямую работает с виртуальной памятью Windows, реализуя собственный менеджер кучи, основанный на функциях VirtualAlloc/VirtualFree. Профилировка показывает, что он серьезно проигрывает в производительности Windows 2000 (другие системы не проверял), не говоря уже о том, что помещение лишнего кода в программу увеличивает ее размер. Второе: new вызывает функцию malloc, причем, вызывает не напрямую, а через несколько слоев «оберточного» кода! Поэтому, вопреки всем рекомендациям, под BorlandC++ вызов malloc эффективнее, чем new! Товарищ Watcom (во всяком случае, его одиннадцатая версия – последняя, до которой мне удалось дотянуться) реализует new и malloc практически идентичным образом, - обе они ссылаются на _nmalloc, - очень «толстую» обертку от LocalAlloc. Да, да – 16-разрядной функции Windows, самой являющейся переходником к HeapAlloc! Таким образом, Джефри Рихтер лопухнулся по полной программе – ни в одном из популярных компиляторов new не быстрее malloc, а вот наоборот – таки да. Уж не знаю, какой он такой редкоземельный компилятор имел ввиду (точнее, не сам компилятор, а библиотеки, поставляемые вместе с ним, но это не суть важно), или, скорее всего, просто писал не думавши. Отсюда мораль – все умозаключения, прежде чем переносить на бумагу, необходимо тщательно проверять. ==== Идентификация библиотечных функций ==== «Сегодня целый день идет снег. Он падает, тихо кружась. Ты помнишь? Тогда тоже все было засыпано снегом - это был снег наших встреч. Он лежал перед нами, белый-белый, как чистый лист бумаги, и мне казалось, что мы напишем на этом листе повесть нашей любви» Снегопад «Пламя» Читая текст программы, написанный на языке высокого уровня, мы только в исключительных случаях изучаем реализацию стандартных библиотечных функций, таких, например, как printf. Да и зачем? Ее назначение известно и без того, а если и есть какие непонятки – всегда можно заглянуть в описание… Анализ дизассемблерного листинга – дело другое. Имена функций за редкими исключениями в нем отсутствуют, и определить printf это или что-то другое «на взгляд» невозможно. Приходится вникать в алгоритм… Легко сказать! Та же printf представляет собой сложный интерпретатор строки спецификаторов – с ходу в нем не разберешься! А ведь есть и более монструозные функции. Самое обидное – алгоритм их работы не имеет никакого отношения к анализу исследуемой программы. Тот же new может выделять память и из Windows-кучи, и реализовывать собственный менеджер, но нам-то от этого что? Достаточно знать, что это именно new, - т.е. функция выделения памяти, а не free или fopen, скажем. Доля библиотечных функций в программе в среднем составляет от пятидесяти до девяноста процентов. Особенно она велика у программ, составленных в визуальных средах разработки, использующих автоматическую генерацию кода (например, MicrosoftVisualC++, 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 ebppush ebp mov ebp,espmov ebp,esp call _readcall _write pop ebppop ebp retret Листинг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\Loadfile\FLIRTsignaturefile»), но… попробуй-ка вручную определить: сигнатуры какой именно версии библиотеки требуется загружать! Наугад – слишком долго… Хорошо, если удастся визуально опознать компилятор (опытным исследователям это обычно удается, т.к. каждый из них имеет свой уникальный «почерк»). К тому же, существует принципиальная возможность использования библиотек из поставки одного компилятора, в программе, скомпилированной другим компилятором. Словом, будьте готовы к тому, что в один прекрасный момент столкнетесь с необходимостью самостоятельно опознавать библиотечные функции. Решение задачи состоит из двух этапов. Первое – определение самого факта «библиотечности» функции, второе – определение происхождения библиотеки и третье – идентификация функция по этой библиотеке. Используя тот факт, что линкер обычно располагает функции в порядке перечисления 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, либо и то, и другое разом. Остальные аргументы так же могут передаться через регистры, если оптимизатор посчитает, что так будет лучше. Механизм передачи и логика выборки аргументов у всех разная и наперед непредсказуемая, - разбирайтесь по ситуации. ::цели и задачи. При исследовании функции перед исследователем стоят следующее задачи: определить, какое соглашение используется для вызова; подсчитать количество аргументов, передаваемых функции (и/или используемых функцией); наконец, выяснить тип и назначение самих аргументов. Начнем? Тип соглашения грубо идентифицируется по способу вычистки стека. Если его очищает вызываемая функция - мы имеем ccdecl, в противном случае – либо с stdcall, либо с PASCAL. Такая неопределенность в отождествлении вызвана тем, что подлинный прототип функции неизвестен и, стало быть, порядок занесения аргументов в стек определить невозможно. Единственная зацепка: зная компилятор и предполагая, что программист использовал тип вызовов по умолчанию, можно уточнить тип вызова функции. Однако в программах под Windows широко используются оба типа вызовов: и PASCAL (он же WINAPI) и stdcall, поэтому, неопределенность по-прежнему остается. Впрочем, порядок передачи аргументов ничего не меняет – имея в наличии и вызывающую, и вызываемую функцию между передаваемыми и принимаемыми аргументами всегда можно установить взаимно однозначность. Или, проще говоря, если действительный порядок передачи аргументов известен (а он и будет известен - см. вызывающую функцию), то знать очередность расположения аргументов в прототипе функции уже ни к чему. Другое дело – библиотечные функции, прототип которых известен. Зная порядок занесения аргументов в стек, по прототипу можно автоматически восстановить тип и назначение аргументов! ::определение количества и типа передачи аргументов. Как уже было сказано выше, аргументы могут передаваться либо через стек, либо через регистры, либо и через стек, и через регистры сразу, а так же – неявно через глобальные переменные. Если бы стек использовался только для передачи аргументов, подсчитать их количество было относительно легко. Увы, стек активно используется и для временного хранения регистров с данными. Поэтому, встретив инструкцию заталкивания PUSH, не торопитесь идентифицировать ее как аргумент. Узнать количество байт, переданных функции в качестве аргументов, невозможно, но достаточно легко определить количество байт, выталкиваемых из стека после завершения функции! Если функция следует соглашению stdcall (или PASCAL) она наверняка очищает стек командой RETn, где n и есть искомое значение в байтах. Хуже с cdecl-функциями. В общем случае за их вызовом следует инструкция «ADDESP,n» – где n искомое значение в байтах, но возможны и вариации – отложенная очистка стека или выталкивание аргументов в какой-нибудь свободный регистр. Впрочем, отложим головоломки оптимизации на потом, пока ограничившись лишь кругом не оптимизирующих компиляторов. Логично предположить, что количество занесенных в стек байт равно количеству выталкиваемых – иначе после завершения функции стек окажется несбалансированным, и программа рухнет (о том, что оптимизирующие компиляторы допускают дисбаланс стека на некотором участке, мы помним, но поговорим об этом потом). Отсюда: количество аргументов равно количеству переданных байт, деленному на размер машинного слова { »> сноска Под машинным словом понимается не только два байта, но и размер операндов по умолчанию, в 32-разрядном режиме машинное слово равно четырем байтам} Верно ли это? Нет! Далеко не всякий аргумент занимает ровно один элемент стека. Взять тот же тип double, отъедающий восемь байт, или символьную строку, переданную не по ссылке, а по непосредственному значению, - она «скушает» столько байт, сколько захочет… К тому же засылаться в стек строка (как и структура данных, массив, объект) может не командой PUSH, а с помощью MOVS! (Кстати, наличие MOVS – явное свидетельство передачи аргумента по значению) Если я не успел окончательно вас запутать, то попробуем разложить по полочкам тот кавардак, что образовался в нашей голове. Итак, анализом кода вызывающей функции установить количество переданных через стек аргументов невозможно. Даже количество переданных байт определяется весьма неуверенно. С типом передачи полный мрак. Позже (см. «Идентификация констант и смещений») мы к этому еще вернемся, а пока вот пример: PUSH 0x40404040/CALLMyFuct: 0x404040 – что это: аргумент передаваемый по значению (т.е. константа 0x404040) или указатель на нечто, расположенное по смещению 0x404040 (и тогда, стало быть, передача происходит по ссылке)? Определить невозможно, не правда ли? Но не волнуйтесь, нам не пришли кранты – мы еще повоюем! Большую часть проблем решает анализ вызываемой функции. Выяснив, как она манипулирует переданными ей аргументами, мы установим и их тип и количество! Для этого нам придется познакомиться с адресацией аргументов в стеке, но прежде чем приступить к работе, рассмотрим в качестве небольшой разминки следующий пример: #include <stdio.h> #include <string.h> 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 Демонстрация механизма передачи аргументов Результат его компиляции компилятором MicrosoftVisualC++ с настройками по умолчанию выглядит так: mainproc near; CODE XREF: start+AFp var_18= byte ptr -18h var_4= dwordptr -4 pushebp movebp, esp subesp, 18h ; Первый PUSH явно относится к прологу функции, а не к передаваемым аргументам pushesi pushedi ; Отсутствие явной инициализации регистров говорит о том, что, скорее всего, ; они просто сохраняются в стеке, а не передаются как аргументы, ; однако если данной функции аргументы передавались не только через стек, ; но и через регистры ESI и EDI, то их засылка в стек вполне может ; преследовать цель передачи аргументов следующей функции pushoffset aHelloWorld ; «Hello,World!» ; Ага, а вот здесь явно имеет место передача аргумента – указателя на строку ; (строго говоря, предположительно имеет место, - см. «Идентификация констант») ; Хотя теоретически возможно временное сохранение константы в стеке для ее ; последующего выталкивания в какой-нибудь регистр, или же непосредственному ; обращению к стеку, ни один из известных мне компиляторов не способен на такие ; хитрости и засылка константы в стек всегда является передаваемым аргументом leaeax, [ebp+var_18] ; в EAX заносится указатель на локальный буфер pusheax ; EAX (указатель на локальный буфер) сохраняется в стеке. ; Поскольку, ряд аргументов непрерывен, то после распознания первого аргумента ; можно не сомневаться, что все последующие заносы чего бы то ни было в стек – ; так же аргументы callstrcpy ; Прототип функции strcpy(char *, char *) не позволяет определить порядок ; занесения аргументов, однако, поскольку все библиотечные Си-функции ; следует соглашению cdecl, то аргументы заносятся справа налево ; и исходный код выглядел так: strcpy(&buff[0],«Hello,World!») ; Но, может быть, программист использовал преобразование, скажем, в stdcall? ; Крайне маловероятно, – для этого пришлось бы перекомпилировать и саму ; strcpy – иначе откуда она бы узнала, что порядок занесения аргументов ; изменился? Хотя обычно стандартные библиотеки поставляются с исходными ; текстами их перекомпиляцией практически никто и никогда не занимается addesp, 8 ; Выталкиваем 8 байт из стека. Из этого мы заключаем, что функции передавалось ; два машинных слова аргументов и, следовательно, PUSHESI и PUSHEDI не были ; аргументами функции! mov[ebp+var_4], 777h ; Заносим в локальную переменную константу 0x777. Это явно константа, а не ; указатель, т.к. у Windows в этой области памяти не могут храниться никакие ; пользовательские данные subesp, 18h ; Резервирование памяти для временной переменной. Временные переменные ; в частности создаются при передаче аргументов по значению, поэтому, ; будем готовы к тому, что следующий «товарищ» – аргумент ; (см. «Идентификация регистровых и временных переменных») movecx, 6 ; Заносим в ECX константу 0х6. Пока еще не известно зачем. leaesi, [ebp+var_18] ; Загружаем в ESI указатель на локальный буффер, содержащий скопированную ; строку «Hello, World!» movedi, esp ; Копируем в EDI указатель на вершину стека repemovsd ; вот она – передача строки по значению. Строка целиком копируется в стек, ; отъедая от него 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] ; intx ; } ; да, функции, выходит, передается структура, а не одна строка! push401AA3D7h push0A3D70A4h ; Заносим в стек еще два аргумента. Впрочем, почему именно два? ; Это вполне может быть и один аргумент типа int64 или double ; Определить – какой именно по коду вызывающей функции не представляется ; возможным callMyFunc ; Вызов MyFunc. Прототип функции установить, увы, не удается… Ясно только, ; что первый (слева? справа?) аргумент – структура, а за ним идут либо два int ; либо один int64 или double ; Уточнить ситуацию позволяет анализ вызываемой функции, но мы это отложим ; на потом, - до того как изучим адресацию аргументов в стеке ; Пока же придется прибывать в полной неопределенности addesp, 20h ; выталкиваем 0x20 байт. Поскольку, 20 байт (0x14) приходится на структуру ; и 8 байт – на два следующих аргумента, получаем 0x14+0x8=0x20, что ; и требовалось доказать. popedi popesi movesp, ebp popebp retn sub_401022endp aHelloWorlddb 'Hello,World!',0 ; DATA XREF: sub_401022+8o align 4 Листинг 57 Результат компиляции компилятором BorlandC++ будет несколько иным и довольно поучительным. Рассмотрим и его: _mainproc near; DATA XREF: DATA:00407044o var_18= byte ptr -18h var_4= dwordptr -4 pushebp movebp, esp addesp, 0FFFFFFE8h ; Ага! Это сложение со знаком минус. Жмем в IDA ↔ и получаем ADDESP,-18h pushesi pushedi ; Пока все идет как в предыдущем случае movesi, offset aHelloWorld; «Hello,World!» ; А вот тут начинаются различия! Вызов strcpy как корова языком слизала – ; нету его и все! Причем, компилятор даже не развернул функцию, ; подставляя ее на место вызова, а просто исключил сам вызов! leaedi, [ebp+var_18] ; Заносим в EDI указатель на локальный буфер moveax, edi ; Заносим тот же самый указатель в EAX movecx, 3 repe movsd movsb ; Обратите внимание: копируется 4*3+1=13 байт. Тринадцать, а вовсе не ; двадцать, как следует из объявления структуры. Это компилятор так ; оптимизировал код, копируя в буфер лишь саму строку, и игнорируя ее ; не инициализированный «хвост» mov[ebp+var_4], 777h ; Заносим в локальную переменную константу 0x777 push401AA3D7h push0A3D70A4h ; Аналогично. Мы не может определить: чем являются эти два числа – ; одним или двумя аргументами. leaecx, [ebp+var_18] ; Заносим в ECX указатель на начало строки movedx, 5 ; Заносим в EDX константу 5 (пока не понятно зачем) loc_4010D3:; CODE XREF: _main+37j pushdword ptr [ecx+edx*4] ; Ой, что это за кошмарный код? Давайте подумаем, начав раскручивать его ; с самого конца. Прежде всего – чему равно ECX+EDX*4? ECX – указатель на ; буфер и с этим все ясно, а вот EDX*4 == 5*4 == 20. ; Ага, значит, мы получаем указатель не на начало строки, а на конец, вернее ; даже не на конец, а на переменную ebp+var_4 (0x18-0x14=0x4). ; Подумаем – если это указатель на саму var_4, то зачем его вычислять таким ; закрученным макаром? Вероятнее всего мы имеем дело со структурой… ; Далее – смотрите, команда push засылает в стек двойное слово, ; хранящееся по этому указателю decedx ; Уменьшаем EDX… Вы уже почувствовали, что мы имеем дело с циклом? jnsshort loc_4010D3 ; вот – этот переход, срабатывающий пока EDX не отрицательное число, ; подтверждает наше предположение о цикле. ; Да, такой вот извращенной конструкций Borland передает аргумент - структуру ; функции по значению! callMyFunc ; Вызов функции… смотрите – нет очистки стека! Да, это последняя вызываемая ; функция и очистки стека не требуется – Borland ее и не выполняет… xoreax, eax ; Обнуление результата, возращенного функцией. Borland так поступает с void ; функциями – они у него всегда возвращают ноль, ; точнее: не они возвращают, а помещенный за их вызовом код, обнуления EAX popedi popesi ; Восстанавливаем ранее сохраненные регистры EDI и ESI movesp, ebp ; восстанавливаем ESI, - вот почему стек не очищался после вызова последней ; функции! popebp retn _mainendp Листинг58 Обратите внимание – по умолчанию MicrosoftC++ передает аргументы справа налево, а BorlandC++ - слева направо! Среди стандартных типов вызов нет такого, который, передавая аргументы слева направо, поручал бы очистку стека вызывающей функции! Выходит, что BorlandC++ использует свой собственный, ни с чем не совместимый тип вызова! ::адресация аргументов в стеке. Базовая концепция стека включает лишь две операции – занесение элемента в стек и снятие последнего занесенного элемента со стека. Доступ к произвольному элементу – это что-то новенькое! Однако такое отступление от канонов существенно увеличивает скорость работы – если нужен, скажем, третий по счету элемент, почему бы ни вытащить из стека напрямую, не снимая первые два? Стек это не только «стопка», как учат популярные учебники по программированию, но еще и массив. А раз так, то, зная положение указателя вершины стека (а не знать его мы не можем, иначе куда прикажите класть очередной элемент?), и размер элементов, мы сможем вычислить смещению любого из элементов, после чего не составит никакого труда его прочитать. Попутно отметим один из недостатков стека – как и любой другой гомогенный массив, стек может хранить данные лишь одного типа, например, двойные слова. Если же требуется занести один байт (скажем, аргумент типа char), то приходится расширять его до двойного слова и заносить его целиком. Аналогично, если аргумент занимает четыре слова (double, int64) на его передачу расходуется два стековых элемента! Помимо передачи аргументов стек используется и для сохранения адреса возврата из функции, что требует в зависимости от типа вызова функции (ближнего или дальнего) от одного до двух элементов. Ближний (near) вызов действует в рамках одного сегмента, - в этом случае достаточно сохранить лишь смещение команды, следующей за инструкций CALL. Если же вызывающая функция находится в одном сегменте, а вызываемая в другом, то помимо смещения приходится запоминать и сам сегмент, чтобы знать в какое место вернуться. Поскольку адрес возврата заносится после аргументов, то относительно вершины стека аргументы оказываются «за» ним и их смещение варьируется в зависимости от того: один элемент занимает адрес возврата или два. К счастью, плоская модель памяти WindowsNT\9x позволяет забыть о моделях памяти как о страшном сне и всюду использовать только ближние вызовы. Не оптимизирующие компиляторы используют для адресации аргументов специальный регистр (как правило, EBP), копируя в него значение регистра-указателя вершины стека в самом начале функции. Поскольку стек растет снизу вверх, т.е. от старших адресов к младшим, смещение всех аргументов (включая адрес возврата) положительны, а смещение N-ого по счету аргумента вычисляется по следующей формуле: arg_offset = N*size_element+size_return_address где N – номер аргумента, считая от вершины стека, начиная с нуля, size_element – размер одного элемента стека, в общем случае равный разрядности сегмента (под WindowsNT\9x – четыре байта), size_return_address – размер в байтах, занимаемый адресом возврата (под WindowsNT\9x – обычно четыре байта). Часто приходится решать и обратную задачу: зная смещение элемента, определять к какому по счету аргументу происходит обращение. В этом нам поможет следующая формула, элементарно выводящаяся из предыдущей: Поскольку, перед копированием в EBP текущего значения ESP, старое значение EBP приходится сохранять в том же самом стеке, в приведенную формулу приходится вносить поправку, добавляя к размеру адреса возврата еще и размер регистра EBP (BP в 16-разрядном режиме, который все еще жив на сегодняшний день). С точки зрения хакера главное достоинства такой адресации аргументов в том, что, увидев где-то в середине кода инструкцию типа «MOVEAX,[EBP+0x10]», можно мгновенно вычислить к какому именно аргументу происходит обращение. Однако оптимизирующие компиляторы для экономии регистра EBP адресуют аргументы непосредственно через ESP. Разница принципиальна! Значение ESP не остается постоянным на протяжении выполнения функции и изменяется всякий раз при занесении и снятии данных из стека, следовательно, не остается постоянным и смещение аргументов относительно ESP. Теперь, чтобы определить к какому именно аргументу происходит обращение, необходимо знать: чему равен ESP в данной точке программы, а для выяснения этого все его изменения приходится отслеживать от самого начала функции! Подробнее о такой «хитрой» адресации мы поговорим потом (см. «Идентификация локальных стековых переменных»), а для начала вернемся к предыдущему примеру (надо ж его «добить») и разберем вызываемую функцию: MyFuncproc near; CODE XREF: main+39p arg_0= dwordptr 8 arg_4= dwordptr 0Ch arg_8= byteptr 10h arg_1C= dwordptr 24h ; IDA распознала четыре аргумента, передаваемых функции. Однако, ; не стоит безоговорочно этому доверять, – если один аргумент (например, int64) ; передается в нескольких машинных словах, то IDA ошибочно примет его не за один, ; а за несколько аргументов! ; Поэтому, результат, полученный IDA, надо трактовать так: функции передается не менее ; четырех аргументов. Впрочем, и здесь не все гладко! Ведь никто не мешает вызываемой ; функции залезать в стек материнской так далеко, как она захочет! Может быть, ; нам не передавали никаких аргументов вовсе, а мы самовольно полезли в стек и ; стянули что-то оттуда. Хотя это случается в основном вследствие программистских ; ошибок из-за путаницы с прототипами, считаться с такой возможностью необходимо. ; (Когда ни будь вы все равно с этим встретитесь, так что будьте готовы) ; Число, стоящее после 'arg', выражает смещение аргумента относительно начала ; кадра стека. ; Обратите внимание: сам кадр стека смещен на восемь байт относительно EBP - ; четыре байта занимает сохраненный адрес возврата, и еще четыре уходят на сохранение ; регистра EBP. pushebp movebp, esp leaeax, [ebp+arg_8] ; получение указателя на аргумент. ; Внимание: именно указателя на аргумент, а не аргумента-указателя! ; Теперь разберемся – на какой именно аргумент мы получаем указатель. ; IDA уже вычислила, что этот аргумент смещен на восемь байт относительно ; начала кадра стека. В оригинале выражение, заключенное в скобках выглядело ; как ebp+0x10 – так его и отображает большинство дизассемблеров. Не будь IDA ; такой умной, нам бы пришлось постоянно вручную отнимать по восемь байт от ; каждого такого адресного выражения (впрочем, с этим мы еще поупражняемся) ; ; Логично: на вершине то, что мы клали в стек в последнею очередь. ; Смотрим вызывающую функцию – что ж мы клали-то? ; (см. вариант, откомпилированный MicrosoftVisualC++) ; Ага, последними были те два непонятные аргумента, а перед ними в стек ; засылалась структура, состоящая из строки и переменной типа int ; Таким образом, EBP+ARG_8 указывает на строку pusheax ; Засылаем в стек полученный указатель. ; Похоже, что он передается очередной функции. movecx, [ebp+arg_1C] ; Заносим в ECX содержимое аргумента EBP+ARG_1C. На что он указывает? ; Вспомним, что тип int находится в структуре по смещению 0x14 байт от начала, ; а ARG_8 – и есть ее начало. Тогда, 0x8+0x14 == 0x1C. ; Т.е. в ECX заносится значение переменной типа int, члена структуры pushecx ; Заносим полученную переменную в стек, передавая ее по значению ; (по значению – потому что ECX хранит значение, а не указатель) movedx, [ebp+arg_4] ; Берем один их тех двух непонятных аргументов, занесенных последними в стек pushedx ; …и, вновь заталкиваем в стек, передавая его очередной функции. moveax, [ebp+arg_0] pusheax ; Берем второй непонятный аргумент и пихаем его в стек. pushoffset aFXS; «%f,%x,%s\n» call_printf ; Опа! Вызов printf с передачей строкой спецификаторов! Функция, printf, ; как известно, имеет переменное число аргументов, тип и количество которых ; как раз и задают спецификаторы. ; Вспомним, – сперва в стек мы заносили указатель на строку, и действительно, ; крайний правый спецификатор «%s» обозначает вывод строки. ; Затем в стек заносилась переменная типа int и второй справа спецификатор ; есть %x – вывод целого в шестнадцатеричной форме. ; А вот затем… затем идет последний спецификатор %f, в то время как в стек ; заносились два аргумента. ; Заглянув в руководство программиста по MicrosoftVisualC++, мы прочтем, ; что спецификатор %f выводит вещественное значение, которое в зависимости от ; типа может занимать и четыре байта (float), и восемь (double). ; В нашем случае оно явно занимает восемь байт, следовательно, это double ; Таким образом, мы восстановили прототип нашей функции, вот он: ; cdecl MyFunc(double a, struct B b) ; Тип вызова cdecl – т.е. стек вычищала вызывающая функция. Вот только, увы, ; подлинный порядок передачи аргументов восстановить невозможно. Вспомним, ; BorlandC++ так же вычищал стек вызывающей функцией, но самвовольно изменил ; порядок передачи параметров. ; Кажется, если программа компилилась BorlandC++, то мы просто изменяем ; порядк арументов на обратный – вот и все. Увы, это не так просто. Если имело ; место явное преобразование типа функции в cdecl, то BorlandC++ без лишней ; самодеятельности поступил бы так, как ему велели и тогда бы обращение ; порядка аргументов дало бы неверный резлуьтат! ; Впрочем, подлинный порядок следования аргументов в прототипе функции ; не играет никакой роли. Важно лишь связать передаваемые и принимаемые ; аргументы, что мы и сделали. ; Обратите внимание: это стало возможно лишь при совместом анализе и вызываемой ; и вызывающей функуий! Анализ лишь одной из них ничего бы не дал! ; Примечание: никогда не следует безоговорочно полагаться на достоверность ; строки спецификаторов. Посколкьу, спецификаторы формируются вручную самим ; программистом, тут возможны ошибки, под час весьма трудноуловимые и дающие ; после компиляции чрезвычайно загадочный код! addesp, 14h popebp retn MyFuncendp Листинг59 Так, кое-какие продвижения уже есть – мы уверенно восстановили прототип нашей первой функции. Но это только начало… Еще много миль предстоит пройти, прежде чем будет достигнут конец главы. Если вы устали – передохните. Тяпните пивка (колы), позвоните своей любимой девушке (а, что, у хакеров и любимые девушки есть?), словом, как хотите, но обеспечьте свежую голову. Мы приступаем к еще одной нудной, но важной теме – сравнительному анализу различных типов вызовов функций и их реализации в популярных компиляторах. Начнем с изучения стандартного соглашения о вызове – stdcall. Рассмотрим следующий пример: #include <stdio.h> #include <string.h> stdcall MyFunc(int a, int b, char *c) { return a+b+strlen©; } main() { printf(«%x\n»,MyFunc(0x666,0x777,«Hello,World!»)); } Листинг 60 Демонстрация stdcall Результат его компиляции MicrosoftVisualC++ с настройками по умолчанию должен выглядеть так: mainproc near; CODE XREF: start+AFp pushebp movebp, esp pushoffset aHelloWorld ; const char * ; Заносим в стек указатель на строку aHelloWorld. ; Заглянув в исходные тексты (благо они у нас есть), мы обнаружим, что ; это – самый правый аргумент, передаваемый функции. Следовательно, ; перед нами вызов типа stdcall или cdecl, но не PASCAL. ; Обратите внимание – строка передается по ссылке, но не по значению. push777h; int ; Заносим в стек еще один аргумент - константу типа int. ; (IDA начиная с версии 4.17 автоматически определяет ее тип). push666h; int ; Передаем функции последний, самый левый аргумент, – константу типа int callMyFunc ; Обратите внимание – после вызова функции отсутствуют команды очистки стека ; от занесенных в него аргументов. Если компилятор не схитрил и не прибегнул ; к отложенной очистке, то скорее всего, стек очищает сама вызываемая функция, ; значит, тип вызова – stdcall (что, собственно, и требовалось доказать) pusheax ; Передаем возвращенное функцией значение следующей функции как аргумент pushoffset asc_406040 ; «%x\n» call_printf ; ОК, эта следующая функция printf, и строка спецификаторов показывает, ; что переданный аргумент имеет тип int addesp, 8 ; Выталкивание восьми байт из стека – четыре приходятся на аргумент типа int ; остальные четыре – на указатель на строку спецификаторов popebp retn mainendp ; int cdecl MyFunc(int,int,const char *) MyFuncproc near; CODE XREF: sub_40101D+12p ; С версии 4.17 IDA автоматически восстанавливает прототипы функций, но делает это ; не всегда правильно. В данном случае она допустила грубую ошибку – тип вызова ; никак не может иметь тип cdecl, т.к. стек вычищает вызываемая функция! Сдается, что ; вообще не предпринимает никаких попыток анализа типа вызова, а берет его из настроек ; распознанного компилятора по умолчанию. ; В общем, как бы там ни было, но с результатами работы IDA следует обращаться ; очень осторожно. arg_0= dwordptr 8 arg_4= dwordptr 0Ch arg_8= dwordptr 10h pushebp movebp, esp pushesi ; Это, как видно, сохранение регистра в стеке, а не передача его функции, т.к. ; регистр явным образом не инициализировался ни вызывающей, ни вызываемой ; функцией. movesi, [ebp+arg_0] ; Заносим в регистр ESI последней занесенный в стек аргумент addesi, [ebp+arg_4] ; Складываем содержимое ESI с предпоследним занесенным в стек аргументом moveax, [ebp+arg_8] ; Заносим в в EAX пред- предпоследний аргумент и… pusheax; constchar * ; …засылаем его в стек. call_strlen ; Поскольку strlen ожидает указателя на строку, можно с уверенностью ; заключить, что пред- предпоследний аргумент – строка, переданная по ссылке. addesp, 4 ; Вычистка последнего аргумента из стека addeax, esi ; Как мы помним, в ESI хранится сумма двух первых аргументов, ; а в EAX – возвращенная длина строки. Таким образом, функция суммирует ; два своих аргумента с длиной строки. popesi popebp retn0Ch ; Стек чистит вызываемая функция, следовательно, тип вызова stdcall или PASCAL. ; Будем считать, что это stdcall, тогда прототип функции выглядит так: ; int MyFunc(int a, int b, char *c) ; ; Порядок аргументов вытекает из того, что на вершине стека были две ; переменные типа int, а под ними строка. Поскольку на верху стека лежит ; всегда то, что заносилось в него в последнюю очередь, а по stdcall ; аргументы заносятся справа налево, мы получаем именно такой порядок ; следования аргументов MyFuncendp Листинг 61 А теперь рассмотрим, как происходит вызов cdecl функции. Изменим в предыдущем примере ключевое слово stdcall на cdecl: #include <stdio.h> #include <string.h> cdecl MyFunc(int a, int b, char *c) { return a+b+strlen©; } main() { printf(«%x\n»,MyFunc(0x666,0x777,«Hello,World!»)); } Листинг 62 Демонстрация cdecl Результат компиляции должен выглядеть так: mainproc near; CODE XREF: start+AFp pushebp movebp, esp pushoffset aHelloWorld ; const char* push777h; int push666h; int ; Передаем функции аргументы через стек callMyFunc addesp, 0Ch ; Смотрите: стек вычищает вызывающая функция. Значит, тип вызова cdecl, ; поскольку, все остальные предписывают вычищать стек вызываемой функции. pusheax pushoffset asc_406040 ; «%x\n» call_printf addesp, 8 popebp retn mainendp ; int cdecl MyFunc(int,int,const char*) ; А вот сейчас IDA правильно определила тип вызова. Однако, как уже показывалось выше, ; она могла и ошибиться, поэтому полагаться на нее не стоит. MyFuncproc near; CODE XREF: main+12p arg_0= dwordptr 8 arg_4= dwordptr 0Ch arg_8= dwordptr 10h ; Поскольку, как мы уже выяснили, функция имеет тип cdecl, аргументы передаются ; справа налево и ее прототип выглядит так: MyFunc(intarg_0, intarg_4, char *arg_8) pushebp movebp, esp pushesi ; Сохраняем ESI в стеке movesi, [ebp+arg_0] ; Заносим в ESI аргумент arg_0 типа int addesi, [ebp+arg_4] ; Складываем его с arg_4 moveax, [ebp+arg_8] ; Заносим в EAX указатель на строку pusheax; constchar * ; Передаем его функции strlen через стек call_strlen addesp, 4 addeax, esi ; Добавляем к сумме arg_0 и arg_4 длину строки arg_8 popesi popebp retn MyFuncendp Листинг63 Прежде, чем перейти к вещам по настоящему серьезным, рассмотрим на закуску последний стандартный тип – PASCAL: #include <stdio.h> #include <string.h> Внимание! MicrosoftVisualC++ уже не поддерживает тип вызова PASCAL вместо этого используйте аналогичный ему тип вызова WINAPI, определенный в файле <windows.h>. #if defined(_MSC_VER) #include <windows.h> включать windows.h только если мы компилируется MicrosoftVisualC++ для остальных компиляторов более эффективное решение – использование ключевого слова PASACAL, если они, конечно, его поддерживают. (Borland поддерживает) #endif Подобный примем программирования может и делает листинг менее читабельным, но зато позволяет компилировать его не только одним компилятором! #if defined(_MSC_VER) WINAPI #else pascal #endif MyFunc(int a, int b, char *c) { return a+b+strlen©; } main() { printf(«%x\n»,MyFunc(0x666,0x777,«Hello,World!»)); } Листинг 64 Демонстрация вызова PASCAL Результат компиляции BorlandC++ должен выглядеть так: ; int cdecl main(int argc,const char argv,const char *envp) _mainproc near; DATA XREF: DATA:00407044o pushebp movebp, esp push666h; int push777h; int pushoffset aHelloWorld ; s ; Передаем функции аргументы. Заглянув в исходный текст, мы заметим, что ; аргументы передаются слева направо. Однако если исходных текстов нет, ; установить этот факт невозможно! К счастью, подлинный прототип функции ; не важен. callMyFunc ; Функция не вычищает за собой стек! Если это не результат оптимизации – ; ее тип вызова либо PASCAL, либо stdcall. Ввиду того, что PASACAL уже вышел ; из употребления, будем считать, что имеем дело с stdcall pusheax pushoffset unk_407074 ; format call_printf addesp, 8 xoreax, eax popebp retn _mainendp ; int cdecl MyFunc(const char*s,int,int) ; Ага! IDA вновь дала неправильный результат! Тип вызова явно не cdecl! ; Однако, в остальном прототип функции верен, вернее, не то что бы он верен ; (на самом деле порядок аргументов обратный), но для использования – пригоден MyFuncproc near; CODE XREF: _main+12p s= dwordptr 8 arg_4= dwordptr 0Ch arg_8= dwordptr 10h pushebp movebp, esp ; Открываем кадр стека moveax, [ebp+s] ; Заносим в EAX указатель на строку pusheax; s call_strlen ; Передаем его функции strlen popecx ; Очищаем стек от одного аргумента, выталкивая его в неиспользуемый регистр movedx, [ebp+arg_8] ; Заносим в EDX аргумент arg_8 типа int addedx, [ebp+arg_4] ; Складываем его с аргументом arg_4 addeax, edx ; Складываем сумму arg_8 и arg_4 с длиной строки popebp retn0Ch ; Стек чистит вызываемая функция. Значит, ее тип PASCAL или stdcall MyFuncendp Листинг 65 Как мы видим, идентификация базовых типов вызов и восстановление прототипов функции – занятие несложное. Единственное, что портит настроение – путаница с PASCAL и stdcall, но порядок занесения аргументов в стек не имеет никакого значения, разве что в особых случаях, один из которых перед вами: #include <stdio.h> #include <windows.h> #include <winuser.h> 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 ) { Бибикаем всеми пиками на все голоса MessageBeep7)|6 байт| 2.9*10-39…1.7*10+38| регистры CPU, стек CPU, стек FPU| Таблица 8 Основная информация о вещественных типах сопроцессоров 80×87 Аргументы типа float и double могут быть переданы функции тремя различными способами: через регистры общего назначения основного процессора, через стек основного процессора и через стек сопроцессора. Аргументы типа longdouble потребовали бы для своей передачи слишком много регистров общего назначения, поэтому, в подавляющем большинстве случаев они заталкиваются в стек основного процессора или сопроцессора. Первые два способа передачи нам уже знакомы, а вот третий – это что-то новенькое! Сопроцессор 80×87 имеет восемь восьмидесятибитных регистров, обозначаемых 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 или longdouble. Все равно, чтобы затолкать эти аргументы в стек сопроцессора, вызываемая функция будет вынуждена скопировать содержимое регистров в оперативную память. Как ни крути, от обращения к памяти не избавишься. Вот поэтому-то, регистровая передача вещественных типов крайне редка и в подавляющем большинстве случаев они, как и обычные аргументы, передаются через стек основного процессора или через стек сопроцессора. (Последнее умеют только продвинутые компиляторы, в частности WATCOM, но не MicrosoftVisualC++ и не BorlandC++). Впрочем, некоторые «избранные» значения могут загружаться и без обращений к памяти, в частности, существуют команды для заталкивания в стек сопроцессора чисел ноль, один, π и некоторые другие – полный список приведен в таблице 9. Любопытной особенностью сопроцессора является поддержка операций с целочисленными вычислениями. Мне не известно ни одного компилятора, использующего эту возможность, но такой прием иногда встречается в ассемблерных вставках, поэтому, пренебрегать изучением целочисленных команд сопроцессора все же не стоит. |Команда|Назначение| | FLD источник| Заталкивает вещественное число из источника на вершину стека сопроцессора| | FSTP приемник|Выталкивает вещественное число из вершины стека сопроцессора в приемник| | FST приемник| Копирует вещественное число из вершины стека сопроцессора в приемник| |FLDZ|Заталкивает ноль на вершину стека сопроцессора| | FLD1|Заталкивает единицу на вершину стека сопроцессора| |FLDPI|Заталкивает на вершину стека сопроцессора число π| | FLDL2T|Заталкивает на вершину стека сопроцессора двоичный логарифм десяти| | FLDL2E| Заталкивает на вершину стека сопроцессора двоичный логарифм числа e| | FLDLG2|Заталкивает на вершину стека сопроцессора десятичный логарифм двух| | FLDLN2|Заталкивает на вершину стека сопроцессора натуральный логарифм двух| | FILD источник| Заталкивает целое число из источника на вершину стека сопроцессора| | FIST приемник| Копирует целое число с вершины стека сопроцессора в приемник| | FISTP приемник| Выталкивает целое число с вершины стека сопроцессора в приемник| | FBLD источник| Заталкивает десятичное число из приемника на вершину стека сопроцессора| | FBSTP приемник| Копирует десятичное число с вершины стека сопроцессора в приемник| | FXCH ST(индекс)| Обмен значениями между вершиной стека сопроцессора и регистром ST(индекс)| Таблица 9 Основные команды сопроцессора, применяющиеся для передачи/приема аргументов Типы double и longdoubleзанимают более одного машинного слова и через стек основного процессора передаются за несколько итераций. Это приводит к тому, что анализ кода вызывающей функции не всегда позволяет установить количество и тип передаваемых вызываемой функции аргументов. Выход – в исследовании алгоритма работы вызываемой функции. Поскольку сопроцессор не может самостоятельно определить тип операнда, находящегося в памяти (т.е. не знает: сколько ячеек он занимает), за каждым типом закрепляется «своя» команда. Синтаксис ассемблера скрывает эти различия, позволяя программисту абстрагироваться от тонкостей реализации (а еще говорят, что ассемблер – язык низкого уровня), и мало кто знает, что FADD [float] и FADD [double] это разные машинные инструкции с опкодами 0xD8 ??000??? и 0xDC ??000??? соответственно. Плохая новость, помет Тигры! Анализ дизассемблерного листинга не дает никакой информации о вещественных типах – для получения этой информации приходится спускаться на машинный уровень, вгрызаясь в шестнадцатеричные дампы инструкций. В таблице 10 приведены опкоды основных команд сопроцессора, работающих с памятью. Обратите внимание, что с вещественными значениями типа longdouble непосредственные математические операции невозможны – прежде их необходимо загрузить в стек сопроцессора. |Команда|Тип||| | ::: | короткий (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 Опкоды основных команд сопроцессора. Второй байт опкода представлен в двоичном виде. Знак вопроса обозначает любой бит. Замечание о вещественных типах языка TurboPascal. Вещественные типы языка Си вследствие его машиноориентированности совпадают с вещественными типами сопроцессора, что логично. Основной же вещественный тип TurboPascal-я, - Real, занимает 6 байт и противоестественен для машины. Поэтому, при вычислениях через сопроцессор он программно переводится в Extended тип (longdouble в терминах Си). Это «съедает» львиную долю производительности, но других типов встроенная математическая библиотека, призванная заменить собой сопроцессор, увы - не поддерживает. При наличии же «живого» сопроцессора появляются чисто процессорные типы Single, Double, Extended и Comp, соответствующие float, double, longdouble и int64. Функциям математической библиотеки, обеспечивающий поддержу вычислений с плавающей запятой, вещественные аргументы передаются через регистры: в AX, BX, DX помещается первый слева аргумент, а в CX, SI, DI – второй (если он есть). Системные функции сопряжения с интерфейсом процессора (в частности, функции преобразования Real в Extended) принимают аргументы через регистры, а результат возвращают через стек сопроцессора. Наконец, прикладные функции и процедуры получают вещественные аргументы через стек основного процессора. В зависимости от настроек компилятора программа может компилироваться либо с использованием встроенной математической библиотеки (по умолчанию), либо с непосредственным вызовом команд сопроцессора (ключ N$+). В первом случае программа вообще не использует возможности сопроцессора, даже если он и установлен на машине. Во втором же: при наличии сопроцессора возлагает все вычислительные возможности на него, а если он отсутствует, попытка вызова сопроцессорных команд приводит к генерации основным процессором исключения int 0x7. Его «отлавливает» программный эмулятор сопроцессора – фактически та же самая встроенная библиотека поддержки вычислений с плавающей точкой. Что ж, теперь мы общих чертах представляем себе как происходит передача вещественных аргументов и горим нетерпением увидеть как это происходит «в живую». Для начала возьмем тривиальный пример: #include <stdio.h> 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 Демонстрация передачи функции вещественных аргументов Результат компиляции MicrosoftVisualC++ должен выглядеть так: mainproc near; CODE XREF: start+AFp var_8= qwordptr -8 ; Локальная переменная, занимающая судя по всему 8 байт pushebp movebp, esp ; Открываем кадр стека push401F147Ah ; К сожалению IDA не может представить операнд в виде числа с плавающей запятой ; К тому же у нас нет возможности определить, что это именно вещественное число ; Его тип может быть каким угодно: и int, и указателем ; (кстати, оно очень похоже на указатель). push0E147AE14h push40D51EB8h ; «Черновой» вариант прототипа выглядит так: MyFunc(inta, intb, intc) callMyFunc addesp, 4 ; Хвост Тигра! Со стека снимается только одно машинное слово, тогда как ; ложится туда три! fstp[esp+8+var_8] ; Стягиваем со стека сопроцессора какое-то вещественное число. Чтобы узнать ; какое, придется нажать <ALT-O>, выбрать в открывшемся меню пункт ; «Text representation», ивнем в окно «Number of opcode bytes» ввести ; сколько знакомест отводится под опкод команд, например, 4. ; Тут же слева от FSTP появляется ее машинное представление - DD 1C 24 ; По таблице 10 определяем тип данных с которым манипулирует эта команда. ; Это – double. Следовательно функция возвратила в через стек сопроцессора ; вещественное значение. ; Раз функция возвращает вещественные значения, вполне возможно, что она их и ; принимает в качестве аргументов. Однако, без анализа MyFunc подтвердить это ; предположение невозможно. pushoffsetaF; «%f\n» ; Передаем функции printf указатель на строку спецификаторов, предписывая ей ; вывести одно вещественное число. Но… при этом мы его не заносим в стек! ; Как же так?! Прокручиваем окно дизассемблера вверх, параллельно с этим ; обдумывая все возможные пути разрешения ситуации. ; Внимательно рассматривая команду «FSTP [ESP+8+var_8]» попытаемся вычислить ; куда же она помещает результат своей работы. ; IDA определила var_8 как «qwordptr –8», следовательно [ES+8-8] эквивалентно ; [ESP], т.е. вещественная переменная стягивается прямо на вершину стека. ; А что у нас на вершине? Два аргумента, переданных MyFunc и так и не ; вытолкнутых из стека. Какой хитрый компилятор! Он не стал создавать локальную ; переменную, а использовал аргументы функции для временного хранилища данных! call_printf addesp, 0Ch ; Выталкиваем со стека три машинных слова popebp retn mainendp MyFuncproc near; CODE XREF: sub_401011+12p var_4= dwordptr -4 arg_0= dwordptr 8 arg_4= qwordptr 0Ch ; Смотрим – IDA обнаружила только два аргумента, в то время как функции передавалось ; три машинных слова! Очень похоже, что один из аргументов занимает 8 байт… pushebp movebp, esp ; Открываем кадр стека pushecx ; Нет, это не сохранение 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); movesp, ebp popebp ; Закрываем кадр стека retn MyFuncendp Листинг 79 Поскольку результат компиляции BorlandC++ 5.x практически в точности идентичен уже рассмотренному выше примеру от MicrosoftVisualC++ 6.x, не будем терять на него время и сразу перейдем к разбору WATCOMC (как всегда – у WATCOM-а есть чему поучиться): main_proc near; CODE XREF: CMain+40p var_8= qwordptr -8 ; локальная переменная на 8 байт push10h callCHK ; Проверка стека на переполнение fldds:dbl_420008 ; Закидываем на вершину стека сопроцессора переменную типа double, ; взимаемую из сегмента данных. ; Тип переменной успешно определила сама IDA, предварив его префиксом 'dbl'. ; А если бы не определила – тогда бы мы обратились к опкоду команды FLD. fldds:flt_420010 ; Закидываем на вершину стека сопроцессора переменную типа float callMyFunc ; Вызываем MyFunc с передачей двух аргументов через стек сопроцессора, ; значит, ее прототип выглядит так: MyFunc(floata, doubleb). subesp, 8 ; Резервируем место для локальной переменной размеров в 8 байт fstp[esp+8+var_8] ; Стягиваем с вершины стека вещественное типа double ; (тип определяется размером переменной). pushoffset unk_420004 callprintf_ ; Ага, уже знакомый нам трюк передачи var_8 функции printf! addesp, 0Ch retn main_endp MyFuncproc near; CODE XREF: main_+16p var_C= qwordptr -0Ch var_4= dwordptr –4 ; IDA нашла две локальные переменные push10h callCHK subesp, 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. Вот, а еще говорят, что WATCOMC ; оптимизирующий компилятор! Трудно же с этим согласится, раз компилятор ; не знает, что от перестановки слагаемых сумма не изменяется! addesp, 0Ch ; Освобождаем память, ранее выделенную для локальных переменных retn MyFuncendp dbl_420008 dq 7.77 ; DATA XREF: main_+A↑r flt_420010 dd 6.6599998 ; DATA XREF: main_+10↑r Листинг 80 Настала очередь компилятора TurboPascalforWindows 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 Демонстрация передачи вещественных значений компилятором TurboPascalforWindows 1.0 А теперь, тяпнув с Тигрой пивка для храбрости, откомпилируем его без поддержки сопроцессора (так и происходит с настройками по умолчанию). PROGRAMproc near callINITTASK call@SystemInit$qv ; SystemInit(void) ; Инициализация модуля SYSTEM call@WINCRTInit$qv ; WINCRTInit(void) ; Инициализация модуля WINCRT pushbp movbp, sp ; Открываем кадр стека xorax, ax call@StackCheck$q4Word ; Stack overflow check (AX) ; Проверяем есть ли в стеке хотя бы ноль свободных байт movword_2030, 0EC83h movword_2032, 0B851h movword_2034, 551Eh ; Инициализируем переменную типа Real. Что это именно Real мы пока, конечно, ; знаем только лишь из исходного текста программы. ; Визуально отличить эту серию команд от трех переменных типа Word невозможно. movword_2036, 3D83h movword_2038, 0D70Ah movword_203A, 78A3h ; Инициализируем другую переменную типа Real movax, word_2030 movbx, word_2032 movdx, word_2034 movcx, word_2036 movsi, word_2038 movdi, word_203A ; Передаем через регистры две переменные типа Real call@$brplu$q4Realt1 ; Real(AX:BX:DX)+=Real(CX:SI:DI) ; К счастью, IDA «узнала» в этой функции оператор сложения и даже ; подсказала нам ее прототип. ; Без ее помощи нам вряд ли удалось понять что делает эта очень длинная и ; запутанная функция. pushdx pushbx pushax ; Передаем возращенное значение процедуре MyProc через стек, ; следовательно, ее прототип выглядит так: MyProc(a:Real). callMyProc popbp ; Закрываем кадр стека xorax, ax call@Halt$q4Word; Halt(Word) ; Прерываем выполнение программы PROGRAMendp MyProcproc near; CODE XREF: PROGRAM+5Cp arg_0= word ptr 4 arg_2= word ptr 6 arg_4= word ptr 8 ; Три аргумента, переданные процедуре, как мы уже выяснили на самом деле представляют ; собой три «дольки» одного аргумента типа Real. pushbp movbp, sp ; Открываем кадр стека xorax, ax call@StackCheck$q4Word ; Stack overflow check (AX) ; Есть ли в стеке ноль байт? movdi, offset unk_2206 pushds pushdi ; Заталкиваем в стек указатель на буфер для вывода строки push[bp+arg_4] push[bp+arg_2] push[bp+arg_0] ; Заталкиваем все три полученные аргумента в стек movax, 11h pushax ; Ширина вывода – 17 символов movax, 0FFFFh pushax ; Число точек после запятой – 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 popbp retn6 MyProcendp Листинг82 А теперь, используя ключ '/$N+' задействуем команды сопроцессора и посмотрим: как это скажется на код: PROGRAMproc near callINITTASK call@SystemInit$qv ; SystemInit(void) ; Инициализируеммодуль System call@InitEM86$qv; Initialize software emulator ; Врубаемэмуляторсопроцессора call@WINCRTInit$qv ; WINCRTInit(void) ; Инициализируем модуль WINCRT pushbp movbp, sp ; Открываем кадр стека xorax, ax call@StackCheck$q4Word ; Stack overflow check (AX) ; Проверка стека на переполнение movword_21C0, 0EC83h movword_21C2, 0B851h movword_21C4, 551Eh movword_21C6, 3D83h movword_21C8, 0D70Ah movword_21CA, 78A3h ; Пока мы не можем определить тип инициализируемых переменных. ; Это с равным успехом может быть и WORD и Real movax, word_21C0 movbx, word_21C2 movdx, word_21C4 call@Extended$q4Real ; Convert Realto Extended ; А вот теперь мы передаем word_21C0, word_21C2 и word_21C4 функции, ; преобразующий Real в Extend с загрузкой последнего в стек сопроцессора, ; значит, word_21C0 – word_21C4 это переменная типа Real. movax, word_21C6 movbx, word_21C8 movdx, word_21CA call@Extended$q4Real ; Convert Realto Extended ; Аналогично – word_21C6 – word_21CA – переменная типа Real wait ; Ждем-с пока сопроцессор не закончит свою работу faddpst(1), st ; Складываем два числа типа extended, лежащих на вершине стека сопроцессора ; с сохранением результата в том же самом стеке. call@Real$q8Extended ; Convert Extended to Real ; Преобразуем Extended в Real ; Аргумент передается через стек сопроцессора, а возвращается в ; регистрах AXBXDX. pushdx pushbx pushax ; Регистры AX, BX и DX содержат значение типа Real, ; следовательно прототип процедуры выглядит так: ; MyProc(a:Real); callMyProc popbp xorax, ax call@Halt$q4Word; Halt(Word) PROGRAMendp MyProcproc near; CODE XREF: PROGRAM+6Dp arg_0= word ptr 4 arg_2= word ptr 6 arg_4= word ptr 8 ; Как мы уже помним, эти три аргумента – на самом деле один аргумент типа Real pushbp movbp, sp ; Открываем кадр стека xorax, ax call@StackCheck$q4Word ; Stack overflow check (AX) ; Проверка стека на переполнение movdi, offset unk_2396 pushds pushdi ; Заносим в стек указатель на буфер для вывода строки movax, [bp+arg_0] movbx, [bp+arg_2] movdx, [bp+arg_4] call@Extended$q4Real ; Convert Realto Extended ; Преобразуем Real в Extended movax, 17h pushax ; Ширина вывода 0х17 знаков movax, 0FFFFh pushax ; Количество знаков после запятой – все что есть, все и выводить 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 popbp retn6 MyProcendp Листинг83 ::соглашения о вызовах thiscall и соглашения о вызове по умолчанию. В Си++ программах каждая функция объекта неявно принимает аргумент this – указатель на экземпляр объекта, из которого вызывается функция. Подробнее об этом уже рассказывалось в главе «Идентификация this», поэтому не будет здесь повторяться. По умолчанию все известные мне Си++ компиляторы используют комбинированное соглашение о вызовах – передавая явные аргументы через стек (если только функция не объявлена как fastcall), а указать this через регистр с наибольшим предпочтением (см. таблицы 2 - 7). Соглашения же cdecl и stdcall предписывают передать все аргументы через стек, включая неявный аргумент this, заносимый в стек в последнюю очередь – после всех явных аргументов (другими словами, this – самый левый аргумент). Рассмотрим следующий пример: #include <stdio.h> class MyClass{ public: void demo(int a); прототип demo в действительности выглядит так demo(this, inta) void stdcall demo_2(int a, int b); прототип demo_2 в действительности выглядит так demo_2(this, inta, intb) void cdecl demo_3(int a, int b, int c); прототип demo_2 в действительности выглядит так demo_2(this, inta, intb, intc) }; Реализзация функция demo, demo_2, demo_3 для экономии места опущена main() { MyClass *zzz = new MyClass; zzz→demo(); zzz→demo_2(); zzz→demo_3(); } Листинг 84 Демонстрация передачи неявного аргумента - this Результат компиляции этого примера компилятором MicrosoftVisualC++ 6.0 должен выглядеть так (показана лишь функция main, все остальное не представляет на данный момент никакого интереса): mainproc near; CODE XREF: start+AFp pushesi ; Сохраняем ESI в стеке push1 call??2@YAPAXI@Z; operator new(uint) ; Выделяем один байт для экземпляра объекта movesi, eax ; ESI содержит указатель на экземпляр объекта addesp, 4 ; Выталкиваем аргумент из стека movecx, esi ; Через ECX функции Demo передается указатель this. ; Как мы помним, компилятор MicrosoftVisualC++ использует регистр ECX ; для передачи самого первого аргумента функции. ; В данном случае этим аргументом и является указатель this. ; А компилятор BorlandC++ 5.x передал бы this через регистр EAX, т.к. ; он отдает ему наибольшее предпочтение (см. таблицу 4) push1 ; Заносим в стек явный аргумент функции. Значит, это не fastcall-функция, ; иначе бы данный аргумент был помещен в регистр EDX. Выходит, ; мы имеем дело с типом вызова по умолчанию. callDemo push2 ; Заталкиваем в стек первый справа аргумент push1 ; Заталкиваем в стек второй справа аргумент pushesi ; Заталкиваем в стек неявный аргумент this. ; Такая схема передачи говорит о том, что имело место явное преобразование ; типа функции в stdcall или cdecl. Прокручивая экран дизассемблера немного ; вниз, мы видим, что стек вычищает вызываемая функция, значит, она следует ; соглашению stdcall. calldemo_2 push3 push2 push1 pushesi callsub_401020 addesp, 10h ; Раз функция вычищает за собой стек сама, то она имеет либо тип по умолчанию, ; либо – cdecl. Передача указателя this через стек подсказывает, что истинно ; второе предположение. xoreax, eax popesi retn mainendp Листинг 85 ::аргументы по умолчанию. Для упрощения вызова функций с «хороводом» аргументов в язык Си++ была введена возможность задания аргументов по умолчанию. Отсюда возникает вопрос – отличается ли чем ни будь вызов функций с аргументами по умолчанию от обычных функций? И кто инициализирует опущенные аргументы вызываемая или вызывающая функция? Так вот, при вызове функций с аргументами по умолчанию, компилятор самостоятельно добавляет недостающие аргументы, и вызов такой функции ни чем не отличается от вызова обычных функций. Докажем это на следующем примере: #include <stdio.h> MyFunc(int a=1, int b=2, int c=3) { printf(«%x %x %x\n»,a,b,c); } main() { MyFunc(); } Листинг 86 Демонстрация передачи аргументов по умолчанию Результат его компиляции будет выглядеть приблизительно так (для экономии места показана только вызывающая функция): mainproc near; CODE XREF: start+AFp pushebp movebp, esp push3 push2 push1 ; Как видно, все опущенные аргументы были переданы функции ; самим компилятором callMyFunc addesp, 0Ch popebp retn mainendp Листинг87 ::техника исследования механизма передачи аргументов неизвестным компилятором. Огромное многообразие существующих компиляторов и постоянное появление новых не позволяет привести здесь всеохватывающую таблицу, расписывающую характер каждого из компиляторов. Как же быть, если вам попадается программа, откомпилированная компилятором, не освещенным данной книгой? Если компилятор удастся опознать (например, с помощью IDA или по текстовым строкам, содержащимся в файле) остается только раздобыть его экземпляр и прогнать через него серию тестовых примеров с передачей «подопытной» функции аргументов различного типа. Нелишне изучить прилагаемую к компилятору документацию, возможно, там будут хотя бы кратно описаны все поддерживаемые им механизмы передачи аргументов. Хуже, когда компилятор не опознается или же достать его копию нет никакой возможности. Тогда придется кропотливо тщательно исследовать взаимодействие вызываемой и вызывающей функций. ==== Идентификация значения, возвращаемого функцией ==== …каждый язык - это своя философия, свой взгляд на деятельность программиста, отражение определенной технологии программирования. Кауфман Традиционно под «значением, возвращаемым функцией» понимается значение, возращенное оператором return, однако, это лишь надводная часть айсберга, не раскрывающая всей картины взаимодействия функций друг с другом. В качестве наглядной демонстрации рассмотрим довольно типичный пример, кстати, позаимствованный из реального кода программы: int xdiv(int a, int b, int *c=0) { if (!b) return –1; if © 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|стек сопроцессора||| | nearpointer|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-разрядных компиляторах Оказывается, если возвращаемое значение не может быть втиснуто в регистры, компилятор скрыто от программиста передает функции неявный аргумент – ссылку на локальную переменную, в которую и записывается возвращенный результат. Таким образом, функции structmystuctMyFunc(inta, intb) и voidMyFunc(structmystryct *my, inta, intb) компилируются в идентичный (или близкий к тому) код и «вытянуть» из машинного кода подлинный прототип невозможно! Единственную зацепку дает компилятор MicrosoftVisualC++, возвращающий в этом случае указатель на возвращаемую переменную, т.е. восстановленный прототип выглядит приблизительно так:structmystruct* MyFunc(structmystruct* my, inta, intb). Согласитесь, несколько странно, чтобы программист в здравом уме да при живой теще, возвращал указатель на аргумент, который своими руками только что и передал функции? Компилятор же BorlandC++ в данной ситуации возвращает тип void, стирая различие между аргументом, возвращаемым по значению и аргументом, возвращаемым по ссылке. Впрочем, невозможность восстановления подлинного прототипа не должна огорчать. Скорее наоборот! «Истинный прототип» утверждает, что результат работы функции возвращается по значению, а в действительности он возвращается по ссылке! Так ради чего тогда называть кошку мышкой? Пару слов об определении типа возвращаемого значения. Если функция при выходе явно присваивает регистру EAX или EDX некоторое значение (AX и DX в 16-разрядном режиме), то его тип можно начерно определить по таблицам 11 и 12. Если же оставляет эти регистры неопределенными – то, скорее всего, возвращается тип void, т.е. ничто. Уточнить информацию помогает анализ вызывающей функции, а точнее то, как она обращается с регистрами EAX [EDX] (AX [DX] в 16-разрядном режиме). Например, для типов char характерно либо обращение к младшей половинке регистра EAX (AX) – регистру AL, либо обнуление старших байт операцией логического AND. Логично предположить: если вызывающая функция не использует значения, отставленного вызываемой функцией в регистрах EAX [EDX], – ее тип void. Но это предположение неверно. Частенько программисты игнорируют возвращаемое значение, вводя тем самым исследователей в заблуждение. Рассмотрим следующий пример, демонстрирующий механизм возвращения основных типов значений: #include <stdio.h> #include <malloc.h> char char_func(char a, char b) { return a+b; } int int_func(int a, int b) { return a+b; } int64 int64_func(int64 a, int64 b) { return a+b; } int* near_func(int* a, int* b) { int *c; c=(int *)malloc(sizeof(int)); c[0]=a[0]+b[0]; return c; } main() { int a; int b; a=0x666; b=0x777; printf(«%x\n», char_func(0x1,0x2)+ int_func(0x3,0x4)+ int64_func(0x5,0x6)+ near_func(&a,&b)[0]); } Листинг 89 Пример, демонстрирующий механизм возвращения основных типов значений Результат его компиляции MicrosoftVisualC++ 6.0 с настойками по умолчанию будет выглядеть так: char_funcproc near; CODE XREF: main+1Ap arg_0= byte ptr 8 arg_4= byte ptr 0Ch pushebp movebp, esp ; Открываем кадр стека movsxeax, [ebp+arg_0] ; Загружаем в EAXarg_0 тип signedchar, попутно расширяя его до int movsxecx, [ebp+arg_4] ; Загружаем в EAXarg_0 тип signedchar, попутно расширяя его до int addeax, ecx ; Складываем arg_0 и arg_4 расширенные до int, сохраняя их в регистре EAX - ; это есть значение, возвращаемое функцией. ; К сожалению, достоверно определить его тип невозможно. Он с равным успехом ; может представлять собой и int и char, причем, int даже более вероятен, ; т.к. сумма двух char по соображениям безопасности должна помещаться в int, ; иначе возможно переполнение. popebp retn char_funcendp int_funcproc near; CODE XREF: main+29p arg_0= dwordptr 8 arg_4= dwordptr 0Ch pushebp movebp, esp ; Открываем кадр стека moveax, [ebp+arg_0] ; Загружаем в EAX значение аргумента arg_0 типа int addeax, [ebp+arg_4] ; Складываем arg_0 с arg_4 и оставляем результат в регистре EAX. ; Это и есть значение, возвращаемое функцией, вероятнее всего, типа int. popebp retn int_funcendp int64_funcproc near; CODE XREF: main+40p arg_0= dwordptr 8 arg_4= dwordptr 0Ch arg_8= dwordptr 10h arg_C= dwordptr 14h pushebp movebp, esp ; открываем кадр стека moveax, [ebp+arg_0] ; Загружаем в EAX значение аргумента arg_0 addeax, [ebp+arg_8] ; Складываем arg_0 с arg_8 movedx, [ebp+arg_4] ; Загружаем в EDX значение аргумента arg_4 adcedx, [ebp+arg_C] ; Складываем arg_4 и arg_C с учетом флага переноса, оставшегося от сложения ; arg_0 с arg_8. ; Выходит, arg_0 и arg_4, как и arg_8 и arg_C это – половинки двух ; аргументов типа int64, складываемые друг с другом. ; Стало быть, результат вычислений возвращается в регистрах EDX:EAX popebp retn int64_funcendp near_funcproc near; CODE XREF: main+54p var_4= dwordptr -4 arg_0= dwordptr 8 arg_4= dwordptr 0Ch pushebp movebp, esp ; Отрываем кадр стека pushecx ; Сохраняем ECX push4; size_t call_malloc addesp, 4 ; Выделяем 4 байта из кучи mov[ebp+var_4], eax ; Заносим указатель на выделенную память в переменную var_4 moveax, [ebp+arg_0] ; Загружаем в EAX значение аргумента arg_0 movecx, [eax] ; Загружаем в ECX значение ячейки памяти типа int на которую указывает EAX. ; Таким образом, тип аргумента arg_0 – int * movedx, [ebp+arg_4] ; Загружаем в EDX значение аргумента arg_4 addecx, [edx] ; Складываем с *arg_0 значение ячейки памяти типа int на которое указывает EDX ; Следовательно, тип аргумента arg_4 – int * moveax, [ebp+var_4] ; Загружаем в EAX указатель на выделенный из кучи блок памяти mov[eax], ecx ; Копируем в кучу значение суммы *arg_0 и *arg_4 moveax, [ebp+var_4] ; Загружаем в EAX указатель на выделенный из кучи блок памяти ; Это и будет значением, возвращаемым функцией, т.е. ее прототип выглядел так: ; int* MyFunc(int *a, int *b); movesp, ebp popebp retn near_funcendp mainproc near; CODE XREF: start+AFp var_8= dwordptr -8 var_4= dwordptr -4 pushebp movebp, esp ; Открываем кадр стека subesp, 8 ; Резервируем место для локальных переменных pushesi pushedi ; Сохраняем регистры в стеке mov[ebp+var_4], 666h ; Заносим в локальную переменную var_4 типа int значение 0x666 mov[ebp+var_8], 777h ; Заносим в локальную переменную var_8 типа int значение 0x777 push2 push1 callchar_func addesp, 8 ; Вызываемфункцию char_func(1,2). Как мы помним, у нас были сомнения в типе ; возвращаемого ею значения – либо int, либо char. movsxesi, al ; Расширяем возращенное функцией значение до signedint, следовательно, она ; возвратила signed char push4 push3 callint_func addesp, 8 ; Вызываем функцию int_func(3,4), возвращающую значение типа int addeax, esi ; Прибавляем к значению, возвращенному функцией, содержимое ESI cdq ; Преобразуем двойное слово, содержащееся в регистре EAX в четверное, ; помещаемое в регистр EDX:EAX movesi, eax movedi, edx ; Копируем расширенное четверное слово в регистры EDI:ESI push0 push6 push0 push5 callint64_func addesp, 10h ; Вызываем функцию int64_func(5,6), возвращающую тип int64 ; Теперь становится понятно, чем вызвано расширение предыдущего результата addesi, eax adcedi, edx ; К четверному слову, содержащемуся в регистрах EDI:ESI добавляем результат ; возращенный функцией int64_func leaeax, [ebp+var_8] ; Загружаем в EAX указатель на переменную var_8 pusheax ; Передаем функции near_func указатель на var_8 как аргумент leaecx, [ebp+var_4] ; Загружаем в ECX указатель на переменную var_4 pushecx ; Передаем функции near_func указатель на var_4 как аргумент callnear_func addesp, 8 ; Вызываем near_func moveax, [eax] ; Как мы помним, в регистре EAX функция возвратила указатель на переменную ; типа int, - загружаем значение этой переменной в регистр EAX cdq ; Расширяем EAX до четверного слова addesi, eax adcedi, edx ; Складываем два четверных слова pushedi pushesi ; Результат сложения передаем функции printf pushoffset unk_406030 ; Передаем указатель на строку спецификаторов call_printf addesp, 0Ch popedi popesi movesp, ebp popebp retn mainendp Листинг90 Как мы видим: в идентификации типа значения, возращенного оператором return ничего хитрого нет, - все прозаично. Но не будем спешить. Рассмотрим следующий пример. Как вы думаете, что именно и в каких регистрах будет возвращаться? #include <stdio.h> #include <string.h> struct XT { char s0[4]; int x; }; struct XT MyFunc(char *a, int b) функция возвращает значение типа структура «XT» по значению { struct XT xt; strcpy(&xt.s0[0],a); xt.x=b; return xt; } main() { struct XT xt; xt=MyFunc(«Hello, Sailor!»,0x666); printf(«%s %x\n»,&xt.s0[0],xt.x); } Листинг 91 Пример демонстрирующий возвращения структуры по значению Заглянем в откомпилированный результат: MyFuncproc near; CODE XREF: sub_401026+10p var_8= dwordptr -8 var_4= dwordptr –4 ; Эти локальные переменные на самом деле элементы «расщепленной» структуры XT ; Как уже говорилось в главе «Идентификация объектов, структур и массивов», ; компилятор всегда стремится обращаться к элементам структуры по их фактическим ; адресам, а не через базовый указатель. ; Поэтому, не так-то просто отличить структуру от несвязанных между собой переменных, ; а под час это и вовсе невозможно! arg_0= dwordptr 8 arg_4= dwordptr 0Ch ; Функция принимает два аргумента pushebp movebp, esp ; Открываем кадр стека subesp, 8 ; Резервируем место для локальных переменных moveax, [ebp+arg_0] ; Загружаем в регистр EAX содержимое аргумента arg_0 pusheax ; Передаем arg_0 функции strcpy, следовательно, ; arg_0 представляет собой указатель на строку. leaecx, [ebp+var_8] ; Загружаем в ECX указатель на локальную переменную var_8 и… pushecx ;…передаем его функции strcpy ; Следовательно, var_8 – строковой буфер размером 4 байта callstrcpy addesp, 8 ; Копируем переданную через arg_0 строку в var_8 movedx, [ebp+arg_4] ; Загружаем в регистр EDX значение аргумента arg_4 mov[ebp+var_4], edx ; Копируем arg_4 в локальную переменную var_4 moveax, [ebp+var_8] ; Загружаем в EAX содержимое (не указатель!) строкового буфера movedx, [ebp+var_4] ; Загружаем в EDX значение переменной var_4 ; Столь явная загрузка регистров EDX:EAX перед выходом из функции указывает ; на то, что это и есть значение, взращаемое функцией. ; Надо же какой неожиданный сюрприз! Функция возвращает в EDX и EAX ; две переменные различного типа! А вовсе не int64, как могло бы показаться ; при беглом анализе программы. ; Второй сюрприз – возврат типа char[4] не через указатель или ссылку, а через ; регистр! ; Нам еще повезло, если бы структура была объявлена как ; structXT{shortinta, charb, charc}, в регистре EAX возвратились бы ; целых три переменные двух типов! movesp, ebp popebp retn MyFuncendp mainproc near; CODE XREF: start+AFp var_8= dwordptr -8 var_4= dwordptr –4 ; Две локальные переменные типа int ; Тип установлен путем вычисления размера каждой из них pushebp movebp, esp ; Открываем кадр стека subesp, 8 ; Резервируем восемь байт под локальные переменные push666h ; Передаем функции MyFunc аргумент типа int ; Следовательно, arg_4 имеет тип int (по коду вызываемой функции это не было ; очевидно, - arg_4 с не меньшим успехом мог оказаться и указателем). ; Значит, в регистре EDX функция возвращает тип int pushoffset aHelloSailor ; «Hello, Sailor!» ; Передаем функции MyFunc указатель на строку ; Внимание! Строка занимает более 4-х байт, поэтому, не рекомендуется ; запускать этот пример «вживую». callMyFunc addesp, 8 ; Вызываем MyFunc. Она неким образом изменяет регистры EDX и EAX ; Мы уже знаем типы возвращаемых в них значений и остается только ; удостоверится – «правильно» ли они используются вызывающей функцией. mov[ebp+var_8], eax ; Заносим в локальную переменную var_8 содержимое регистра EAX mov[ebp+var_4], edx ; Заносим в локальную переменную var_4 содержимое регистра EDX ; Согласитесь, – очень похоже на то, что функция возвращает int64 moveax, [ebp+var_4] ; Загружаем в EAX содержимое var_4 ; (т.е. регистра EDX, возвращенного функцией MyFunc) и… pusheax ; …передаем его функции printf ; Согласно строки спецификаторов, это тип int ; Следовательно, в EDX функция возвратила int или, по крайней мере, его ; старшую часть leaecx, [ebp+var_8] ; Загружаем в ECX указатель на переменную var_8, хранящую значение, ; возвращенное функцией через регистр EAX. ; Согласно строки спецификаторов, это указатель на строку ; Итак, мы подтвердили, что типы значений, возвращенных через регистры EDX:EAX ; различны! ; Немного поразмыслив, мы даже сможем восстановить подлинный прототип: ; struct X{char a[4]; int} MyFunc(char* b, int c); pushecx pushoffset aSX; «%s %x\n» call_printf addesp, 0Ch movesp, ebp popebp ; Закрываем кадр стека retn mainendp Листинг92 А теперь слегка изменим структуру XT, заменив chars0[4] на char9 s0[10], что гарантированно не влезает в регистры EDX:AX и посмотрим, как изменится от этого код: mainproc near; CODE XREF: start+AFp var_20= byte ptr -20h var_10= dwordptr -10h var_C= dwordptr -0Ch var_8= dwordptr -8 var_4= dwordptr -4 pushebp movebp, esp ; Отрываем кадр стека subesp, 20h ; Резервируем 0x20 байт под локальные переменные push666h ; Передаем функции MyFunc крайний правый аргумент – значение 0x666 типа int pushoffset aHelloSailor ; «Hello, Sailor!» ; Передаем функции MyFunc второй справа аргумент – указатель на строку leaeax, [ebp+var_20] ; Загружаем в EAX адрес локальной переменной var_20 pusheax ; Передаем функции MyFunc указатель на переменную var_20 ; Стоп! Этого аргумента не было в прототипе функции! Откуда же он взялся?! ; Верно, не было. Его вставил компилятор для возвращения структуры по значению. ; Последнюю фразу вообще-то стоило заключить в кавычки для придания ей ; ироничного оттенка – структура, возвращаемая по значению, в действительности ; возвращается по ссылке. callMyFunc addesp, 0Ch ; Вызываем MyFunc movecx, [eax] ; Функция в ECX возвратила указатель на возвращенную ей по ссылке структуру ; Этот прием характерен лишь для MicrosoftVisualC++, большинство компиляторов ; оставляют значение EAX на выходе неопределенным или равным нулю. ; Но, так или иначе, в ECX загружается первое двойное слово, ; на которое указывает указатель EAX. На первый взгляд, это элемент типа int ; Однако не будем бежать по перед косы и торопиться с выводами mov[ebp+var_10], ecx ; Сохранение ECX в локальной переменной var_10 movedx, [eax+4] ; В EDX загружаем второе двойное слово по указателю EDX mov[ebp+var_C], edx ; Копируем его в переменную var_C ; Выходит, что и второй элемент структуры – имеет тип int? ; Мы, знающие как выглядел исходный текст программы, уже начинам замечать ; подвох. Что-то здесь определенно не так… movecx, [eax+8] ; Загружаем третье двойное слово, от указателя EAX и… mov[ebp+var_8], ecx ; …копируем его в var_8. Еще один тип int? Да откуда же они берутся в таком ; количестве, когда у нас он был только один! И где, собственно, строка? movedx, [eax+0Ch] mov[ebp+var_4], edx ; И еще один тип int переносим из структуры в локальную переменную. Нет, это ; выше наших сил! moveax, [ebp+var_4] ; Загружаем в EAX содержимое переменной var_4 pusheax ; Передаем значение var_4 функции printf. ; Судя по строке спецификаторов, var_4 действительно, имеет тип int leaecx, [ebp+var_10] ; Получаем указатель на переменную var_10 и… pushecx ;…передаем его функции printf ; Судя по строке спецификаторов, тип ECX – char *, следовательно: var_10 ; и есть искомая строка. Интуиция нам подсказывает, что var_C и var_8, ; расположенные ниже ее (т.е. в более старших адресах), так же содержат ; строку. Просто компилятор вместо того чтобы вызывать srtcpy решил, что ; будет быстрее скопировать ее самостоятельно, чем и ввел нас в заблуждение. ; Поэтому, никогда не следует торопится с идентификацией типов элементов ; структур! Тщательно проверяйте каждый байт – как он инициализируется и как ; используется. Операции пересылки в локальные переменные еще ни о чем ; неговорят! pushoffset aSX; «%s %x\n» call_printf addesp, 0Ch movesp, ebp popebp ; Закрываем кадр стека retn mainendp MyFuncproc near; CODE XREF: main+14p var_10= dwordptr -10h var_C= dwordptr -0Ch var_8= dwordptr –8 var_4= dwordptr –4 arg_0= dwordptr 8 arg_4= dwordptr 0Ch arg_8= dwordptr 10h ; Обратите внимание, что функции передаются три аргумента, а не два, как было ; объявлено в прототипе pushebp movebp, esp ; Открываем кадр стека subesp, 10h ; Резервируем память для локальных переменных moveax, [ebp+arg_4] ; Загружаем а EAX указатель на второй справа аргумент pusheax ; Передаем указатель на arg_4 функции strcpy leaecx, [ebp+var_10] ; Загружаем в ECX указатель на локальную переменную var_10 pushecx ; Передаем функции strcpy указатель на локальную переменную var_10 callstrcpy addesp, 8 ; Копируем строку, переданную функции MyFunc, через аргумент arg_4 movedx, [ebp+arg_8] ; Загружаем в EDX значение самого правого аргумента, переданного MyFunc mov[ebp+var_4], edx ; Копируем arg_8 в локальную переменную var_4 moveax, [ebp+arg_0] ; Загружаем в EAX значение аргумента arg_0 ; Как мы знаем, этот аргумент функции передает сам компилятор, и передает в нем ; указатель на локальную переменную, предназначенную для возращения структуры movecx, [ebp+var_10] ; Загружаем в ECX двойное слово с локальной переменной var_10 ; Как мы помним, в локальную переменную var_10 ранее была скопирована строка, ; следовательно, сейчас мы вновь увидим ее «двухсловное» копирование! mov[eax], ecx movedx, [ebp+var_C] mov[eax+4], edx movecx, [ebp+var_8] mov[eax+8], ecx ; И точно! Из локальной переменной var_10 в локальную переменную *arg_0 ; копирование происходит «вручную», а не с помощью strcpy! ; В общей сложности сейчас было скопировано 12 байт, значит, первый элемент ; структуры выглядит так: chars0[12]. ; Да, конечно, в исходном тесте было 'chars0[10]', но компилятор, ; выравнивая элементы структуры по адресам, кратным четырем, перенес второй ; элемент – intx, по адресу base+0x12, тем самым создав «дыру» между концом ; строки и началом второго элемента. ; Анализ дизассемблерного листинга не позволяет восстановить истинный вид ; структуры, единственное, что можно сказать – длина строки s0 ; лежит в интервале [9 - 12] ; movedx, [ebp+var_4] mov[eax+0Ch], edx ; Копируем переменную var_4 (содержащую аргумент arg_8) в [eax+0C] ; Действительно, второй элемент структуры -intx- расположен по смещению ; 12 байт от ее начала. moveax, [ebp+arg_0] ; Возвращаем в EAX указатель на аргумент arg_0, содержащий указатель на ; возращенную структуру movesp, ebp popebp ; Закрываем кадр стека retn ; Итак, прототип функции выглядит так: ; struct X {char s0[12], int a} MyFunc(struct X *x, char *y, int z) ; MyFuncendp Листинг93 Возникает вопрос – а как возвращаются структуры, состоящие из сотен и тысяч байт? Ответ: они копируются в локальную переменную, неявно переданную компилятором по ссылке, инструкцией MOVS, в чем мы сейчас и убедимся, изменив в исходном тексте предыдущего примера «chars0[10]», на «chars0[0x666]». Результат перекомпиляции должен выглядеть так: MyFuncproc near; CODE XREF: main+1Cp var_66C= byte ptr -66Ch var_4= dwordptr -4 arg_0= dwordptr 8 arg_4= dwordptr 0Ch arg_8= dwordptr 10h pushebp movebp, esp ; Открываем кадр стека subesp, 66Ch ; Резервируем память для локальных переменных pushesi pushedi ; Сохраняем регистры в стеке moveax, [ebp+arg_4] pusheax leaecx, [ebp+var_66C] pushecx callstrcpy addesp, 8 ; Копируем переданную функции строку в локальную переменную var_66C movedx, [ebp+arg_8] mov[ebp+var_4], edx ; Копируем аргумент arg_8 в локальную переменную var_4 movecx, 19Bh ; Заносим в ECX значение 0x19B, пока еще не понимая, что оно выражает leaesi, [ebp+var_66C] ; Устанавливаем регистр ESI на локальную переменную var_66C movedi, [ebp+arg_0] ; Устанавливаем регистр EDI на переменную на которую указывает ; указатель, переданный в аргументе arg_0 repe movsd ; Копируем ECX двойных слов с ESI в EDI ; Переводя это в байты, получаем: 0x19B*4 = 0x66C ; Таким образом, копируется и строка var_66C, и переменная var_4 moveax, [ebp+arg_0] ; Возвращаем в EAX указатель на возвращенную структуру popedi popesi movesp, ebp popebp ; Закрываем кадр стека retn MyFuncendp Листинг 94 Следует учитывать, что многие компиляторы (например, WATCOM) передают функции указатель на буфер для возвращаемого значения не через стек, а через регистр, причем регистр по обыкновению берется не из очереди кандидатов в порядке предпочтения (см. таблицу 6), а используется особый регистр, специально предназначенный для этой цели. Например, у WATCOM-а это регистр ESI. ::возвращение вещественных значений. Соглашения cdecl и stdcall предписывают возвращать вещественные значения (float, double, longdouble) через стек сопроцессора, значение же регистров EAX и EDX на выходе из такой функции может быть любым (другими словами, функции, возвращающие вещественные значения, оставляют регистры EAX и EDX в неопределенном состоянии). fastcall-функции теоретически могут возвращать вещественные переменные и в регистрах, но на практике до этого дело обычно не доходит, поскольку, сопроцессор не может напрямую читать регистры основного процессора и их приходится проталкивать через оперативную память, что сводит на нет всю выгоду быстрого вызова. Для подтверждения сказанного исследуем следующий пример: #include <stdio.h> float MyFunc(float a, float b) { return a+b; } main() { printf(«%f\n»,MyFunc(6.66,7.77)); } Листинг 95 Пример, демонстрирующий возвращение вещественных значений Результат его компиляции MicrosoftVisualC++ должен выглядеть приблизительно так: mainproc near; CODE XREF: start+AFp var_8= qwordptr -8 pushebp movebp, esp ; Открываем кадр стека push40F8A3D7h push40D51EB8h ; Передаем функции MyFunc аргументы. Пока еще мы не можем установить их тип callMyFunc fstp[esp+8+var_8] ; Стягиваем со стека сопроцессора вещественное значение, занесенное туда ; функцией MyFunc ; Чтобы определить его тип смотрим опкод инструкции, – DD 1C 24 ; По таблице 10 определяем – он принадлежит double ; Постой, постой, как double, ведь функция должна возвращать float?! ; Так-то оно так, но здесь имеет место неявное преобразование типов ; при передаче аргумента функции printf, ожидающей double. ; Обратите внимание на то, куда стягивается возращенное функцией значение: ; [esp+8-8] == [esp], т.е. оно помещается на вершину стека, что равносильно ; его заталкиваю командами PUSH. pushoffset aF; «%f\n» ; Передаем функции printf указатель на строку спецификаторов «%f\n» call_printf addesp, 0Ch popebp retn mainendp MyFuncproc near; CODE XREF: main+Dp arg_0= dwordptr 8 arg_4= dwordptr 0Ch pushebp movebp, esp ; Открываем кадр стека fld[ebp+arg_0] ; Затягиваем на вершину стека сопроцессора аргумент arg_0 ; Чтобы определять его тип, смотрим на опкод инструкции FLD - D9 45 08 ; Раз так, это – float fadd[ebp+arg_4] ; Складываем arg_0, только что затянутый на вершину стека сопроцессора, с arg_4 ; помещая результат в тот же стек и… popebp retn ; …возвращаемся из функции, оставляя результат сложения двух float-ов ; на вершине стека сопроцессора ; Забавно, если объявить функцию как double это даст идентичный код! MyFuncendp Листинг 96 Замечание о механизме возращения значений в компиляторе WATCOMC: Компилятор WATCOMC предоставляет программисту возможность «вручную» выбирать: в каком именно регистре (регистрах) функция будет возвращать результат своей работы. Это серьезно осложняет анализ, ведь (как уже было сказано выше) по общепринятым соглашениям функция не должна портить регистры 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 Допустимые регистры для возращения значения функции в компиляторе WATOMC. Жирным шрифтом выделен регистр (регистры) используемые по умолчанию. Обратите внимание, что по используемому регистру невозможно непосредственно узнать тип возвращаемого значения, а только его размер. В частности, через регистр EAX может возвращаться и переменная типа int и структура из четырех переменных типа char (или двух char или одного shortint) Покажем, как это выглядит на практике. Рассмотрим следующий пример: #include <stdio.h> int MyFunc(int a, int b) { #pragma aux MyFunc value [ESI] Прагма AUX вкупе с ключевым словом «value» позволяет вручную задавать регистр через который будет возращен результат вычислений. В данном случае его предписывается возвращать через ESI return a+b; } main() { printf(«%x\n»,MyFunc(0x666,0x777)); } Листинг 97 Пример, демонстрирующий возвращение значения в произвольном регистре Результат компиляции этого примера должен выглядеть приблизительно так: main_proc near; CODE XREF: CMain+40p push14h callCHK ; Проверка стека на переполнение pushedx pushesi ; Сохраняем ESI и EDX ; Это говорит о том, что данный компилятор придерживается соглашения ; о сохранении ESI. Команды сохранения EDI не видно, однако, этот регистр ; не модифицируется данной функцией и, стало быть, сохранять его незачем movedx, 777h moveax, 666h ; Передаем функции MyFunc два аргумента типа int callMyFunc ; Вызываем MyFunc. По общепринятым соглашениям EAX, EDX и под час ECX ; на выходе из функции содержат либо неопределенное, ; либо возращенное функцией значение ; Остальные регистры в общем случае должны быть сохранены pushesi ; Передаем регистр ESI функции printf. Мы не можем с уверенностью сказать: ; содержит ли он значение, возращенное функцией, или был инициализирован еще ; до ее вызова pushoffsetasc_420004 ; «%x\n» callprintf_ addesp, 8 popesi popedx retn main_endp MyFuncproc near; CODE XREF: main_+16p push4 callCHK ; Проверка стека на переполнение leaesi, [eax+edx] ; А вот уже знакомый нам хитрый трюк со сложением. На первый взгляд в ESI ; загружается указатель на EAX+EBX, - фактически так оно и происходит, но ведь ; указатель на EAX+EBX в то же время является и их суммой, т.е. эта команда ; эквивалентна ADDEAX,EDX/MOVESI,EAX. ; Это и есть возвращаемое функцией значение, - ведь ESI был модифицирован, и ; не сохранен! ; Таким образом, вызывающая функция командой PUSHESI передает printf ; результат сложения 0x666 и 0x777, что и требовалось выяснить retn MyFuncendp Листинг 98 Возращение значений in-lineassembler функциями. Создать ассемблерной функции волен возвращать значения в любых регистрах, каких ему будет угодно, однако, поскольку вызывающие функции языка высокого уровня ожидают увидеть результат вычислений в строго определенных регистрах, писаные соглашения приходится соблюдать. Другое дело, «внутренние» ассемблерные функции – они могут вообще не придерживаться никаких правил, что и демонстрирует следующий пример: #include <stdio.h> naked-функция, не имеющая прототипа, - обо всем должен заботится сам программист! declspec( naked ) int MyFunc() { asm{ lea ebp, [eax+ecx]; возвращаемв EBP сумму EAX и ECX ; Такой трюк допустим лишь при условии, что эта ; функция будет вызываться из ассемблерной функции, ; знающей через какие регистры передаются аргументы ; и через какие – возвращается результат вычислений ret } } main() { int a=0x666; int b=0x777; int c; asm{ push ebp push edi mov eax,[a]; mov ecx,[b]; leaedi,c Вызываем функцию MyFunc из ассемблерной функции, передавая ей аргументы через те регистры, которые она «хочет» callMyFunc; Принимаем возращенное в EBP значение и сохраняем его в локальной переменной mov [edi],ebp pop edi pop ebp } printf(«%x\n»,c); } Листинг 99 Пример, демонстрирующий возвращение значения встроенными ассемблерными функциями Результат компиляции MicrosoftVisualC++ (а другие компиляторами этот пример откомпилировать и вовсе не удастся, ибо они не поддерживают ключевое слово naked) должен выглядеть так: MyFuncproc near; CODE XREF: main+25p leaebp, [eax+ecx] ; Принимаем аргументы через регистры EAX и ECX, возвращая через регистр EBP ; их сумму ; Кончено, пример несколько надуман, зато нагляден! retn MyFuncendp mainproc near; CODE XREF: start+AFp var_C= dwordptr -0Ch var_8= dwordptr -8 var_4= dwordptr -4 pushebp movebp, esp ; Открываем кадр стека subesp, 0Ch ; Резервируем место для локальных переменных pushebx pushesi pushedi ; Сохраняем изменяемые регистры mov[ebp+var_4], 666h mov[ebp+var_8], 777h ; Инициализируем переменные var_4 и var_8 pushebp pushedi ; Сохраняем регистры или передаем их функции? Пока нельзя ответить ; однозначно moveax, [ebp+var_4] movecx, [ebp+var_8] ; Загружаем в EAX значение переменной var_4, а в ECX – var_8 leaedi, [ebp+var_C] ; Загружаем в EDI указатель на переменную var_C callMyFunc ; Вызываем MyFunc – из анализа вызывающей функции не очень понятно как ; ей передаются аргументы. Может через стек, а может и через регистры. ; Только исследование кода MyFunc позволяет установить, что верным оказывается ; последнее предположение. Да, - аргументы передаются через регистры! mov[edi], ebp ; Что бы это значило? Анализ одной лишь вызывающей функции не может дать ; исчерпывающего ответа и только анализ вызываемой подсказывает, что ; через EBP она возвращает результат вычислений. popedi popebp ; Восстанавливаем измененные регистры ; Это говорит о том, что выше эти регистры действительно сохранялись в стеке ; а не передавались функции в качестве аргументов moveax, [ebp+var_C] ; Загружаем в EAX содержимое переменной var_C pusheax pushoffset unk_406030 call_printf addesp, 8 ; Вызываем printf popedi popesi popebx ; Восстанавливаем регистры movesp, ebp popebp ; Закрываем кадр стека retn mainendp Листинг100 ::возврат значений через аргументы, переданные по ссылке. Идентификация значений, возращенных через аргументы, переданные по ссылке, тесно переплетается с идентификацией самих аргументов (см. главу «Идентификация аргументов функций»). Выделив среди аргументов, переданных функции, указатели – заносим их в список кандидатов на возвращаемые значения. Теперь поищем: нет ли среди них указателей на неинициализированные переменные, – очевидно, их инициализирует сама вызываемая функция. Однако не стоит вычеркивать указатели на инициализированные переменные (особенно равные нулю) – они так же могут возвращать значения. Уточнить ситуацию позволит анализ вызываемой функции – нас будут интересовать все операции модификации переменных, переданных по ссылке. Только не спутайте это с модификацией переменных, переданных по значению. Последние автоматически умирают в момент завершения функции (точнее – вычистки аргументов из стека). Фактически – это локальные переменные функции и она безболезненно может изменять их как ей вздумается. #include <stdio.h> #include <string.h> Функция инвертирования строки src с ее записью в строку dst void Reverse(char *dst, const char *src) { strcpy(dst,src); _strrev( dst); } Функция инвертирования строки s (результат записывается в саму же строку s) void Reverse(char *s) { _strrev( s ); } Функция возращает сумму двух аргументов int sum(int a,int b) { Мы можем безболезненно модифицировать аргументы, переданные по значению, обращаясь с ними как с обычными локальными переменными a+=b; return a; } main() { char s0[]=«Hello,Sailor!»; chars1[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 Пример, демонстрирующий возврат значений через переменные, переданные по ссылке Результат компиляции этого примера должен выглядеть приблизительно так: mainproc near; CODE XREF: start+AFp var_74= byte ptr -74h var_10= dwordptr -10h var_C= dwordptr -0Ch var_8= dwordptr -8 var_4= word ptr -4 pushebp movebp, esp ; Открываем кадр стека subesp, 74h ; Резервируем память для локальных переменных moveax, dword ptr aHelloSailor ; «Hello,Sailor!» ; Заносим в регистр EAX четыре первых байта строки «Hello, Sailor!» ; Вероятно, компилятор копирует строку в локальную переменную таким ; хитро-тигриным способом mov[ebp+var_10], eax movecx, dword ptr aHelloSailor+4 mov[ebp+var_C], ecx movedx, dword ptr aHelloSailor+8 mov[ebp+var_8], edx movax, word ptr aHelloSailor+0Ch mov[ebp+var_4], ax ; Точно, строка «Hello,Sailor!» копируется в локальную переменную var_10 ; типа char s[0x10] ; Число 0x10 было получено подсчетом количества копируемых байт – ; четыре итерации по четыре байт в каждой – итого, шестнадцать! leaecx, [ebp+var_10] ; Загрузка в ECX указателя на локальную переменную var_10, ; содержащую строку «Hello, World!» pushecx; int ; Передача функции Reverse_1 указателя на строку «Hello, World!» ; Смотрите, - IDA неверно определила тип, - ну какой же это int, ; когда это char * ; Однако, вспомнив, как копировалась строка, мы поймем, почему ошиблась IDA leaedx, [ebp+var_74] ; Загрузка в ECX указателя на неинициализированную локальную переменную var_74 pushedx; char * ; Передача функции Reverse_1 указателя на неинициализированную переменную ; типа char s1[100] ; Число 100 было получено вычитанием смещения переменной var_74 от смещения ; следующей за ней переменной, var_10, содержащей строку «Hello, World!» ; 0x74 – 0x10 = 0x64 или в десятичном представлении - 100 ; Факт передачи указателя на неинициализированную переменную говорит о том, ; что, скорее всего, функция возвратит через нее некоторое значение – ; возьмите это себе на заметку. callReverse_1 addesp, 8 ; Вызов функции Reverse_1 leaeax, [ebp+var_74] ; Загрузка в EAX указателя на переменную var_74 pusheax ; Передача функции printf указателя на переменную var_74, - поскольку, ; вызывающая функция не инициализировала эту переменную, можно предположить, ; что вызываемая возвратила в через нее свое значение ; Возможно, функция Reverse_1 модифицировала и переменную var_10, однако, ; об этом нельзя сказать с определенностью до тех пор пока не будет ; изучен ее код pushoffsetunk_406040 call_printf addesp, 8 ; Вызов функции printf для вывода строки leaecx, [ebp+var_74] ; Загрузка в ECX указателя на переменную var_74, по-видимому, ; содержащую возращенное функцией Reverse_1 значение pushecx; char * ; Передача функции Reverse_2 указателя на переменную var_74 ; Функция Reverse_2 так же может возвратить в переменной var_74 ; свое значение, или некоторым образом, модифицировать ее ; Однако может ведь и не возвратить! ; Уточнит ситуацию позволяет анализ кода вызываемой функции. callReverse_2 addesp, 4 ; Вызов функции Reverse_2 leaedx, [ebp+var_74] ; Загрузка в EDX указателя на переменную var_74 pushedx ; Передача функции printf указателя на переменную var_74 ; Поскольку, значение, возвращенное функцией через регистры EDX:EAX ; не используется, можно предположить, что она возвращает его не через ; регистры, а в переменной var_74. Но это не более чем предположение pushoffset unk_406044 call_printf addesp, 8 ; Вызовфункции printf push777h ; Передача функции Sum значения 0x777 типа int push666h ; Передача функции Sum значения 0x666 типа int callSum addesp, 8 ; Вызовфункции Sum pusheax ; В регистре EAX содержится возращенное функцией Sum значение ; Передаем его функции printf в качестве аргумента pushoffset unk_406048 call_printf addesp, 8 ; Вызовфункции printf movesp, ebp popebp ; Закрытие кадра стека retn mainendp ; int cdecl Reverse_1(char *,int) ; Обратите внимание, что прототип функции определен неправльно! ; На самом деле, как мы уже установили из анализа вызывающей функции, он выглядит так: ; Reverse(char *dst, char *src) ; Название аргументов дано на основании того, что левый аргумент – указатель ; на неинициализированный буфер и, скорее всего, он выступает в роли приемника, ; соответственно, правый аргумент в таком случае – источник. Reverse_1proc near; CODE XREF: main+32p arg_0= dwordptr 8 arg_4= dwordptr 0Ch pushebp movebp, esp ; Открываем кадр стека moveax, [ebp+arg_4] ; Загружаем в EAX значение аргумента arg_4 pusheax ; Передаем arg_4 функции strcpy movecx, [ebp+arg_0] ; Загружаем в ECX значение аргумента arg_0 pushecx ; Передаем arg_0 функции strcpy callstrcpy addesp, 8 ; Копируем содержимое строки, на которую указывает arg_4, в буфер ; на который указывает arg_0 movedx, [ebp+arg_0] ; Загружаем в EDX содержимое аргумента arg_0, указывающего на буфер, ; содержащий только что скопированную строку pushedx; char * ; Передаем функции strrevarg_0 callstrrev addesp, 4 ; функция strrev инвертирует строку, на которую указывает arg_0 ; следовательно, функция Reverse_1 действительно возвращает свое значение ; через аргумент arg_0, переданный по ссылке. ; Напротив, строка на которую указывает arg_4, остается неизменной, поэтому, ; прототип функции Reverse_1 выглядит так: ; void Reverse_1(char *dst, const char *src); ; Никогда не пренебрегайте квалификатором const, т.к. он ясно указывает на ; то, что переменная, на которую указывает данный указатель используется ; лишь на чтение. Эта информация значительно облегчит работу с ; дизассемблерным листингом, особенно когда вы вернетесь к нему спустя ; некоторое время, основательно подзабыв алгоритм исследуемой программы popebp ; Закрываем кадр стека retn Reverse_1endp ; int cdecl Reverse_2(char *) ; А вот на этот раз прототип функции определен верно! ; (Ну, за исключением того, что возвращаемый тип void, а не int) Reverse_2proc near; CODE XREF: main+4Fp arg_0= dwordptr 8 pushebp movebp, esp ; Открываем кадр стека moveax, [ebp+arg_0] ; Загружаем в EAX содержимое аргумента arg_0 pusheax; char * ; Передаем arg_0 функции strrev callstrrev addesp, 4 ; Инвертируем строку, записывая результат на то же самое место ; Следовательно, функция Reverse_2 действительно возвращает значение ; через arg_0, и наше предварительное предположение оказалось правильным! popebp ; Закрываем кадр стека retn ; Прототип функции Reverse_2 по данным последних исследований выглядит так: ; void Reverse_2(char *s) Reverse_2endp Sumproc near; CODE XREF: main+72p arg_0= dwordptr 8 arg_4= dwordptr 0Ch pushebp movebp, esp ; Открываем кадр стека moveax, [ebp+arg_0] ; Загружаем в EAX значение аргумента arg_0 addeax, [ebp+arg_4] ; Складываем arg_0 с arg_4, записывая результат в EAX mov[ebp+arg_0], eax ; Копируем результат сложения arg_0 и arg_4 обратно в arg_0 ; Неопытные хакеры могут принять это за возращение значения через аргумент, ; однако, это предположение неверно. ; Дело в том, что аргументы, переданные функции, после ее завершения ; выталкиваются из стека и тут же «погибают». Не забывайте: ; Аргументы, переданные по значению, ведут себя так же, как и локальные ; переменные. moveax, [ebp+arg_0] ; А вот сейчас в регистр EAX действительно копируется возвращаемое значение ; Следовательно, прототип функции выглядит так: ; int Sum(int a, int b); popebp ; Закрываем кадр стека retn Sumendp Листинг102 ::возврат значений через динамическую память (кучу). Возращение значения через аргумент, переданный по ссылке, не очень-то украшает прототип функции. Он вмиг перестает быть интуитивно – понятным и требует развернутых пояснений, что с этим аргументом ничего передать не надо, напротив – будьте готовы отсюда принять. Но хвост с ней, с наглядностью и эстетикой (кто говорил, что был программистом легко?), существует и более серьезная проблема – далеко не во всех случаях размер возвращаемых данных известен наперед, - частенько он выясняется лишь в процессе работы вызываемой функции. Выделить буфер «с запасом»? Некрасиво и неэкономично – даже в системах с виртуальной памятью ее объем не безграничен. Вот если бы вызываемая функция самостоятельно выделяла для себя память, как раз по потребности, а потом возвращала на нее указатель. Сказано – сделано! Ошибка многих начинающих программистов как раз и заключается в попытке вернуть указать на локальные переменные, - увы, они «умирают» вместе с завершением функции и указатель указывает в «космос». Правильное решение заключается в выделении памяти из кучи (динамической памяти), скажем, вызовом malloc или new, - эта память «живет» вплоть до ее принудительного освобождения функцией free или delete соответственно. Для анализа программы механизм выделения памяти не существенен, - основную роль играет тип возвращаемого значения. Отличить указатель от остальных типов достаточно легко – только указатель может использоваться в качестве подадресного выражения. Разберем следующий пример: #include <stdio.h> #include <malloc.h> #include <stdlib.h> char* MyFunc(int a) { char *x; x = (char *) malloc(100); _ltoa(a,x,16); return x; } main() { char *x; x=MyFunc(0x666); printf(«0x%s\n»,x); free(x); } Листинг 103 Пример, демонстрирующий возвращения значения через кучу mainproc near; CODE XREF: start+AFp var_4= dwordptr -4 pushebp movebp, esp ; Открываем кадр стека pushecx ; Выделяем память под локальную переменную размером 4 байта (см. var_4) push666h ; Передаем функции MyFunc значение 666 типа int callMyFunc addesp, 4 ; Вызываем MyFunc – обратите внимание, что функции ни один аргумент ; не был передан по ссылке! mov[ebp+var_4], eax ; Копирование содержимого возращеного функцией значение в переменную var_4 moveax, [ebp+var_4] ; Супер! Загружаем в EAX возращенное функцией значение обратно! pusheax ; Передаем возращенное функцией значение функции printf ; Судя по спецификатору, тип возвращенного значения – char * ; Поскольку, функции MyFunc ни один из аргументов не передавался по ссылке, ; она явно выделила память самостоятельно и записала туда полученную строку. ; А если бы функции MyFunc передавались один или более аргументов по ссылке? ; Тогда – не было бы никакой уверенности, что она не возвратила один из таких ; аргументов обратно, предварительно его модифицировав. ; Впрочем, модификация необязательно, - скажем передаем функции указатели на ; две строки и она возвращает указатель на ту из них, которая, скажем, короче ; или содержит больше гласных букв. ; Поэтому, не всякое возращение указателя свидетельствует о модификации pushoffset a0xS; «0x%s\n» call_printf addesp, 8 ; Вызов printf – вывод на экран строки, возращенной функцией MyFunc movecx, [ebp+var_4] ; В ECX загружаем значение указателя, возращенного функцией MyFunc pushecx; void * ; Передаем указатель, возращенный функцией MyFunc, функции free ; Значит, MyFunc действительно самостоятельно выделяла память вызовом malloc call_free addesp, 4 ; Освобождаем память, выделенную MyFunc для возращения значения movesp, ebp popebp ; Закрываем кадр стека retn ; Таким образом, протип MyFunc выглядит так: ; char* MyFunc(int a) mainendp MyFuncproc near; CODE XREF: main+9p var_4= dwordptr -4 arg_0= dwordptr 8 pushebp movebp, esp ; Открываем кадр стека pushecx ; Резервируем память под локальные переменные push64h; size_t call_malloc addesp, 4 ; Выделяем 0x64 байта памяти из кучи либо для собственных нужд функции, либо ; для возращения результата. Поскольку из анализа кода вызывающей функции нам ; уже известно, что MyFunc возвращает указатель, очень вероятно, что вызов ; malloc выделяет память как раз для этой цели. ; Впрочем, вызовов malloc может быть и несколько, а указатель возвращается ; только на один из них mov[ebp+var_4], eax ; Запоминаем указатель в локальной переменной var_4 push10h; int ; Передаем функции ltoa аргумент 0x10 (крайний справа) – требуемая система ; исчисления для перевода числа moveax, [ebp+var_4] ; Загружаем в EAX содержимое указателя на выделенную из кучи память pusheax; char * ; Передаем функции ltoa указатель на буфер для возращения результата movecx, [ebp+arg_0] ; Загружаем в EAX значение аргумента arg_0 pushecx; int32 ; Передаем функции ltoa аргумент arg_0 – значение типа int callltoa addesp, 0Ch ; Функция ltoa переводит число в строку и записывает ее в буфер по переданному ; указателю moveax, [ebp+var_4] ; Возвращаем указатель на регион памяти, выделенный самой MyFunc из кучи, и ; содержащий результат работы ltoa movesp, ebp popebp ; Закрываем кадр стека retn MyFuncendp Листинг 104 ::Возврат значений через глобальные переменные. «Мыльную оперу» перепевов с возращением указателей продолжает серия «Возращение значений через глобальные переменные (и/или указателя на глобальные переменные)». Вообще-то глобальные переменные – плохой тон и такой стиль программирования характерен в основном для программистов с мышлением, необратимо искалеченным идеологий Бацика с его недоразвитым механизмом вызова подпрограмм. Подробнее об идентификации глобальных переменных рассказывается в одноименном разделе данной главы, здесь же мы сосредоточим наши усилия именно на изучении механизмов возвращения значений через глобальные переменные. Фактически, все глобальные переменные можно рассматривать как неявные аргументы каждой вызываемой функции и в то же время – как возвращаемые значения. Любая функция может произвольным образом читать и модифицировать их, причем, ни «передача», ни «возращение» глобальных переменных не «видны» анализом кода вызывающей функции, - для этого необходимо тщательно исследовать вызываемую – манипулирует ли она с глобальными переменными и если да, то с какими. Можно зайти и с обратной стороны, - просмотром сегмента данных найти все глобальные переменные, определить их смещение и, пройдясь контекстным поиском по всему файлу, выявить функции, которые на них ссылаются (подробнее см. «Идентификация глобальных переменных :: перекрестные ссылки»). Помимо глобальных, еще существуют и статические переменные. Они так же располагаются в сегменте данных, но непосредственно доступны только объявившей их функции. Точнее, ограничение наложено не на сами переменных, а на их имена. Чтобы предоставить другим функциям доступ к собственным статическим переменным достаточно передать указатель. К счастью, этот трюк не создает хакерам никаких проблем (хоть некоторые злопыхатели и объявляют его «прорехой в защите»), - отсутствие непосредственного доступа к «чужим» статическим переменным и необходимость взаимодействовать с функцией-владелицей через предсказуемый интерфейс (возращенный указатель), позволяет разбить программу на отдельные независимые модули, каждый из которых может быть проанализирован отдельно. Чтобы не быть голословным, продемонстрируем это на следующем примере: #include <stdio.h> char* MyFunc(int a) { static char x[7][16]={«Понедельник», «Вторник», «Среда», «Четверг», «Пятница», «Суббота», «Воскресенье»}; return &x[a-1][0]; } main() { printf(«%s\n»,MyFunc(6)); } Листинг 105 Пример, демонстрирующий возврат значения через глобальные статические переменные Результат компиляции компилятором MicrosoftVisualC++ 6.0 c настройками по умолчанию выглядит так: MyFuncproc near; CODE XREF: main+5p arg_0= dwordptr 8 pushebp movebp, esp ; Открываем кадр стека moveax, [ebp+arg_0] ; Загружаем в EAX значение аргумента arg_0 subeax, 1 ; Уменьшаем EAX на единицу. Это косвенно свидетельствует о том, что arg_0 – ; не указатель, хотя математические операции над указателями в Си разрешены ; и активно используются shleax, 4 ; Умножаем (arg_0 –1) на 16. Битовый сдвиг вправо на четыре равносилен 24 == 16 addeax, offset aPonedelNik; «Понедельник» ; Складываем полученное значение с базовым указателем на таблицу строк, ; расположенных в сегменте данных. А в сегменте данных находятся либо ; статические, либо глобальные переменные. ; Поскольку, значение аргумента arg_0 умножаемся на некоторую величину ; (в данном случае на 16), можно предположить, что мы имеем дело с ; двухмерным массивом. В данном случае – массивом строк фиксированной длины. ; Таким образом, в EAX содержится указатель на строку с индексом arg_0 – 1 ; Или, другими словами, – с индексом arg_0, считая с одного. popebp ; Закрываем кадр стека, возвращая в регистре EAX указатель на соответствующий ; элемент массива. ; Как мы видим, нет никакой принципиальной разницы между возвращением указателя ; на регион памяти, выделенный из кучи, с возращением указателя на статические ; переменные, расположенные в сегменте данных. retn MyFuncendp mainproc near; CODE XREF: start+AFp pushebp movebp, esp ; Открываем кадр стека push6 ; Передаем функции MyFunc значение типа int ; (шестой день – суббота) callMyFunc addesp, 4 ; Вызываем MyFunc pusheax ; Передаем возращенное MyFunc значение функции printf ; Судя по строке спецификаторов, это – указатель на строку pushoffset aS; «%s\n» call_printf addesp, 8 popebp ; Закрываем кадр стека retn mainendp aPonedelNikdb 'Понедельник',0,0,0,0,0 ; DATA XREF: MyFunc+Co ; Наличие перекрестной ссылки только на одну функцию, подсказывает, что тип ; этой переменной – static aVtornikdb 'Вторник',0,0,0,0,0,0,0,0,0 aSredadb 'Среда',0,0,0,0,0,0,0,0,0,0,0 aCetvergdb 'Четверг',0,0,0,0,0,0,0,0,0 aPqtnicadb 'Пятница',0,0,0,0,0,0,0,0,0 aSubbotadb 'Суббота',0,0,0,0,0,0,0,0,0 aVoskresenEdb 'Воскресенье',0,0,0,0,0 aSdb '%s',0Ah,0 ; DATA XREF: main+Eo Листинг 106 А теперь сравним предыдущий пример с настоящими глобальными переменными: #include <stdio.h> int a; int b; int c; MyFunc() { c=a+b; } main() { a=0x666; b=0x777; MyFunc(); printf(«%x\n»,c); } Листинг 107 Пример, демонстрирующий возврат значения через глобальные переменные mainproc near; CODE XREF: start+AFp pushebp movebp, esp ; Открываем кадр стека callMyFunc ; Вызываем MyFunc. Обратите внимание – функции явно ничего не передается ; и ничего не возвращается. Потому, ее прототип выглядит ; (по предварительным заключением) так: ; void MyFunc() callSum ; Вызываем функцию Sum, явно не принимающую и не возвращающую никаких значений ; Ее предварительный прототип выглядит так: voidSum() moveax, c ; Загружаем в EAX значение глобальной переменной 'c' ; Смотрим в сегмент данных, - так-так, вот она переменная 'c', равная нулю ; Однако этому значению нельзя доверять – быть может, ее уже успели изменить ; ранее вызванные функции. ; Предположение о модификации подкрепляется парой перекрестных ссылок, ; одна из которых указывает на функцию Sum. Суффикс 'w', завершающий ; перекрестную ссылку, говорит о том, что Sum записывает в переменную 'c' ; какое-то значение. Какое? Это можно узнать из анализа кода самой Sum. pusheax ; Передаем значение, возращенное функцией Sum, через глобальную переменную 'c' ; функции printf. ; Судя по строке спецификаторов, аргумент имеет тип int pushoffset asc_406030 ; «%x\n» call_printf addesp, 8 ; Выводим возвращенный Sum результат на терминал popebp ; Закрываем кадр стека retn mainendp Sumproc near; CODE XREF: main+8p ; Функция Sum не принимает через стек никаких аргументов! pushebp movebp, esp ; Открываем кадр стека moveax, a ; Загружаем в EAX значение глобальной переменной 'a' ; Находим 'a' в сегменте данных, - ага, есть перекрестная ссылка на MyFunc, ; которая что-то записывает в переменную 'a'. ; Поскольку, вызов MyFunc предшествовал вызову Sum, можно сказать, что MyFunc ; возвратила в 'a' некоторое значение addeax, b ; Складываем EAX (хранящий значение глобальной переменной 'a') с содержимым ; глобальной переменной 'b' ; (все, сказанное выше относительно 'a', справедливо и для 'b') movc, eax ; Помещаем результат сложения a+b в переменную 'c' ; Как мы уже знаем (из анализа функции main), функция Sum в переменной 'c' ; возвращает результат своих вычислений. Теперь мы узнали – каких именно. popebp ; Закрываем кадр стека retn Sumendp MyFuncproc near; CODE XREF: main+3p pushebp movebp, esp ; Открываем кадр стека mova, 666h ; Присваиваем глобальной переменной 'a' значение 0x666 movb, 777h ; Присваиваем глобальной переменной 'b' значение 0x777 ; Как мы выяснили из анализа двух предыдущих функций – функция MyFunc ; возвращает в переменных а и b результат своих вычислений ; Теперь мы определили какой именно, а вместе с тем смогли разобраться ; как три функции взаимодействуют друг с другом. ; main() вызывает MyFunc(), та инициализирует глобальные переменные 'a' и 'b', ; затем main() вызывает Sum(), помещающая сумму 'a' и 'b' в глобальную 'c', ; наконец, main() берет эту 'c' и передает ее через стек printf ; для вывода на экран. ; Уф! Как все запутано, а ведь это простейший пример из трех функций! ; Что же говорить о реальной программе, в которой этих функций тысячи, причем ; порядок вызова и поведение каждой из них далеко не так очевидны! popebp retn MyFuncendp add 0; DATA XREF: MyFunc+3wSum+3r bdd 0; DATA XREF: MyFunc+DwSum+8r cdd 0; DATA XREF: Sum+Ew main+Dr ; Судя по перекрестным ссылкам – все три переменные глобальные, т.к. к ; каждой из них имеет непосредственный доступ более одной функции. Листинг 108 ::возврат значений через флаги процессора. Для большинства ассемблерных функций характерно использование регистра флагов процессора для возвращения результата успешности выполнения функции. По общепринятому соглашению установленный флаг переноса (CF) свидетельствует об ошибке, второе место по популярности занимает флаг нуля (ZF), а остальные флаги практически вообще не используются. Установка флага переноса осуществляется командой STC или любой математической операцией, приводящей к образованию переноса (например, CMPa, b где a < b), а сброс – командой CLC или соответствующей математической операцией. Проверка флага переноса обычно осуществляется условными переходами JCxxx и JNCxxx, соответственно исполняющихся при наличии и отсутствии переноса. Условные переходы JBxxxи JNBxxx– их синтаксические синонимы, дающие при ассемблировании идентичный код. #include <stdio.h> Функция сообщения об ошибке деления Err(){ printf(«-ERR: DIV by Zero\n»);} Вывод результата деления на экран Ok(int a){printf(«%x\n»,a);} Ассемблерная функция деления. Делит EAX на EBX, возвращая частное в EAX, а остаток – в EDX При попытке деления на ноль устанавливает флаг переноса declspec(naked) MyFunc() { asm{ xor edx,edx; Обнуляем EDX, т.е. команда div ожидаетделимогов EDX:EAX testebx,ebx; Проверка делителя на равенство нулю jz _err; Если делитель равен нулю, перейти к ветке _err divebx; Делим 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' movebx,[esp+8]; Загружаем в EDX содержимое аргумента 'b' callMyFunc; Пытаемся делить a/b jnc _ok; Если флаг переноса сброшен выводим результат, иначе… callErr; …сообщение об ошибке ret; Возвращаемся _ok: pusheax; Передаем результат деления и… callOk; …выводим его на экран addesp,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, а локальные переменные «проживают» выше него. Детали технической реализации. Существует множество вариаций реализации выделения и освобождения памяти под локальные переменные. Казалось бы, чем плохо очевидное SUBESP,xxxна входе и ADDESP, xxx на выходе? А вот BorlandC++ (и некоторые другие компиляторы) в стремлении отличиться ото всех остальных резервируют память не уменьшением, а увеличением ESP… да, на отрицательное число (которое по умолчанию большинством дизассемблеров отображается как очень большое положительное). Оптимизирующие компиляторы при отводе небольшого количества памяти заменяют SUB на PUSHreg, что на несколько байт короче. Последнее создает очевидные проблемы идентификации – попробуй, разберись, то ли перед нами сохранение регистров в стеке, то ли передача аргументов, то ли резервирование памяти для локальных переменных (подробнее см. «идентификация механизма выделения памяти»). Алгоритм освобождения памяти так же неоднозначен. Помимо увеличения регистра указателя вершины стека инструкцией ADDESP, xxx (или в особо извращенных компиляторах его увеличения на отрицательное число), часто встречается конструкция «MOVESP, EBP». (Мы ведь помним, что при открытии кадра стека ESP копировался в EBP, а сам EBP в процессе исполнения функции не изменялся). Наконец, память может быть освобождена инструкцией POP, выталкивающей локальные переменные одну за другой в какой ни будь ненужный регистр (понятное дело, такой способ оправдывает себя лишь на небольшом количестве локальных переменных). |Действие|Варианты реализации||| |Резервирование памяти|SUBESP, xxx|ADD ESP,–xxx| PUSHreg| |Освобождение памяти|ADD ESP, xxx|SUB ESP,–xxx|POP reg| | ::: |MOV ESP, EBP||| Таблица 14 Наиболее распространенные варианты реализации резервирования памяти под локальные переменные и ее освобождение Идентификация механизма выделения памяти. Выделение памяти инструкциями SUB и ADD непротиворечиво и всегда интерпретируется однозначно. Если же выделение памяти осуществляется командой PUSH, а освобождение – POP, эта конструкция становится неотличима от простого освобождения/сохранения регистров в стеке. Ситуация серьезно осложняется тем, что в функции присутствуют и «настоящие» команды сохранения регистров, сливаясь с командами выделения памяти. Как узнать: сколько байт резервируется для локальных переменных, и резервируются ли они вообще (может, в функции локальных переменных и нет вовсе)? Ответить на этот вопрос позволяет поиск обращений к ячейкам памяти, лежащих «выше» регистра EBP, т.е. с отрицательными относительными смещениями. Рассмотрим два примера, приведенные на листинге 110. PUSH EBPPUSH EBP PUSH ECXPUSH ECX xxxxxx xxxMOV [EBP-4],0x666 xxxxxx POP ECXPOP ECX POP EBPPOP EBP RETRET Листинг110 В левом из них никакого обращения к локальным переменным не происходит вообще, а в правом наличествует конструкция «MOV [EBP-4],0x666», копирующая значение 0x666 в локальную переменную var_4. А раз есть локальная переменная, для нее кем-то должна быть выделена память. Поскольку, инструкций SUBESP, xxx и ADDESP, – xxxв теле функций не наблюдается – «подозрение» падает на PUSHECX, т.к. сохраненное содержимое регистра ECXрасполагается в стеке на четыре байта «выше» EBP. В данном случае «подозревается» лишь одна команда – PUSHECX, поскольку PUSHEBP на роль «резерватора» не тянет, но как быть, если «подозреваемых» несколько? Определить количество выделенной памяти можно по смещению самой «высокой» локальной переменной, которую удается обнаружить в теле функции. То есть, отыскав все выражения типа [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. Например, «ANDESP, FFFFFFF0» дает ESP кратным шестнадцати. Как было получено это значение? Переводим «0xFFFFFFF0» в двоичный вид, получаем – «11111111 11111111 11111111 11110000». Видите четыре нуля на конце? Значит, четыре младших бита любого числа будут маскированы, и оно разделиться без остатка на 24 = 16. _Как IDA идентифицирует локальные переменные. Хотя с локальными переменными мы уже неоднократно встречались при изучении прошлых примеров, не помешает это сделать это еще один раз: #include <stdio.h> #include <stdlib.h> int MyFunc(int a, int b) { intc; Локальная переменная типа int charx[50] Массив (демонстрирует схему размещения массивов в памяти_ c=a+b; Заносим в 'c' сумму аргументов 'a и 'b' ltoa(c,&x[0],0x10); Переводим сумму 'a' и 'b' в строку printf(«%x == %s == »,c,&x[0]); Выводим строку на экран return c; } main() { inta=0x666; Объявляем локальные переменные 'a' и 'b' для того, чтобы intb=0x777; продемонстрировать механизм их иницилизации компилятором intc[1]; Такие извращения понадобовились для того, чтобы запретит отимизирующему компилятору помещать локальную переменную в регистр (см. «Идентификация регистровых переменных») Т.к. функции printf передается указатель на 'c', а указатель на регистр быть передан не может, компилятор вынужен оставить переменную в памяти c[0]=MyFunc(a,b); printf(«%x\n»,&c[0]); return 0; } Листинг 111 Демонстрация идентификации локальных переменных Результат компиляции компилятора MicrosoftVisualC++6.0 с настройками по умолчанию должен выглядеть так: MyFuncproc near; CODE XREF: main+1Cp var_38= byte ptr -38h var_4= dwordptr –4 ; Локальные переменные располагаются по отрицательному смещению относительно EBP, ; а аргументы функции – по положительному. ; Заметьте также, чем «выше» расположена переменная, тем больше модуль ее смещения arg_0= dwordptr 8 arg_4= dwordptr 0Ch pushebp movebp, esp ; Открываем кадр стека subesp, 38h ; Уменьшаем значение ESP на 0x38, резервируя 0x38 байт под локальные переменные moveax, [ebp+arg_0] ; загружаем а EAX значение аргумента arg_0 ; О том, что это аргумент, а не нечто иное, говорит его положительное ; смещение относительно регистра EBP addeax, [ebp+arg_4] ; складываем EAX со значением аргумента arg_0 mov[ebp+var_4], eax ; А вот и первая локальная переменная! ; На то, что это именно локальная переменная, указывает ее отрицательное ; смещение относительно регистра EBP. Почему отрицательное? А посмотрите, ; как IDA определила «var_4» ; По моему личному мнению, было бы намного нагляднее если бы отрицательные ; смещения локальных переменных подчеркивались более явно. push10h; int ; Передаем функции ltoa значение 0x10 (тип системы исчисления) leaecx, [ebp+var_38] ; Загружаем в ECX указатель на локальную переменную var_38 ; Что это за переменная? Прокрутим экран дизассемблера немного вверх, ; там где содержится описание локальных переменных, распознанных IDA ; var_38= byte ptr -38h ; var_4= dwordptr –4 ; ; Ближайшая нижняя переменная имеет смещение –4, а var_38, соответственно, -38 ; Вычитая из первого последнее получаем размер var_38 ; Он, как нетрудно подсчитать, будет равен 0x34 ; С другой стороны, известно, что функция ltoa ожидает указатель на char* ; Таким образом, в комментарии к var_38 можно записать «chars[0x34]» ; Это делается так: в меню «Edit» открываем подменю «Functions», а в нем – ; пункт «Stackvariables» или нажимаем «горячую» комбинацию <Ctrl-K> ; Открывается окно с перечнем всех распознанных локальных переменных. ; Подводим курсор к «var_34» и нажимаем <;> для ввода повторяемого комментария ; и пишем нечто вроде «chars[0x34]». Теперь <Ctrl-Enter> для завершения ввода ; и <Esc> для закрытия окна локальных переменных. ; Все! Теперь возле всех обращений к var_34 появляется введенный нами ; комментарий ; pushecx; char * ; Передаем функции ltoa указатель на локальный буфер var_38 movedx, [ebp+var_4] ; Загружаем в EDX значение локальной переменной var_4 pushedx; int32 ; Передаем значение локальной переменной var_38 функции ltoa ; На основании прототипа этой функции IDA уже определила тип переменной – int ; Вновь нажмем <Ctrl-K> и прокомментируем var_4 callltoa addesp, 0Ch ; Переводим содержимое var_4 в шестнадцатеричную систему исчисления, ; записанную в строковой форме, возвращая ответ в локальном буфере var_38 leaeax, [ebp+var_38]; char s[0x34] ; Загружаем в EAX указатель на локальный буфер var_34 pusheax ; Передаем указатель на var_34 функции printf для вывода содержимого на экран movecx, [ebp+var_4] ; Копируем в ECX значение локальной переменной var_4 pushecx ; Передаем функции printf значение локальной переменной var_4 pushoffset aXS; «%x == %s == » call_printf addesp, 0Ch moveax, [ebp+var_4] ; Возвращаем в EAX значение локальной переменной var_4 movesp, ebp ; Освобождаем память, занятую локальными переменными popebp ; Восстанавливаем прежнее значение EBP retn MyFuncendp mainproc near; CODE XREF: start+AFp var_C= dwordptr -0Ch var_8= dwordptr -8 var_4= dwordptr –4 pushebp movebp, esp ; Открываем кадр стека subesp, 0Ch ; Резервируем 0xC байт памяти для локальных переменных mov[ebp+var_4], 666h ; Инициализируем локальную переменную var_4, присваивая ей значение 0x666 mov[ebp+var_8], 777h ; Инициализируем локальную переменную var_8, присваивая ей значение 0x777 ; Смотрите: локальные переменные расположены в памяти в обратном порядке ; их обращения к ним! Не объявления, а именно обращения! ; Вообще-то, порядок расположения не всегда бывает именно таким, - это ; зависит от компилятора, поэтому, полагаться на него никогда не стоит! moveax, [ebp+var_8] ; Копируем в регистр EAX значение локальной переменной var_8 pusheax ; Передаем функции MyFunc значение локальной переменной var_8 movecx, [ebp+var_4] ; Копируем в ECX значение локальной переменной var_4 pushecx ; Передаем MyFunc значение локальной переменной var_4 callMyFunc addesp, 8 ; Вызываем MyFunc mov[ebp+var_C], eax ; Копируем возращенное функцией значение в локальную переменную var_C leaedx, [ebp+var_C] ; Загружаем в EDX указатель на локальную переменную var_C pushedx ; Передаем функции printf указатель на локальную переменную var_C pushoffset asc_406040 ; «%x\n» call_printf addesp, 8 xoreax, eax ; Возвращаем нуль movesp, ebp ; Освобожаем память, занятую локальными переменными popebp ; Закрываем кадр стека retn mainendp Листинг112 Не очень сложно, правда? Что ж, тогда рассмотрим результат компиляции этого примера компилятором BorlandC++ 5.0 – это будет немного труднее! MyFuncproc near; CODE XREF: _main+14p var_34= byte ptr -34h ; Смотрите, - только одна локальная переменная! А ведь мы объявляли целых три… ; Куда же они подевались?! Это хитрый компилятор поместил их в регистры, а не стек ; для более быстрого к ним обращения ; (подробнее см. «Идентификация регистровых и временных переменных») pushebp movebp, esp ; Открываем кадр стека addesp, 0FFFFFFCC ; Резервируем… нажимаем ↔ в IDA, превращая число в знаковое, получаем «–34» ; Резервируем 0x34 байта под локальные переменные ; Обратите внимание: на этот раз выделение памяти осуществляется не SUB, а ADD! pushebx ; Сохраняем EBX в стеке или выделяем память локальным переменным? ; Поскольку память уже выделена инструкцией ADD, то в данном случае ; команда PUSH действительно сохраняет регистр в стеке leaebx, [edx+eax] ; А этим хитрым сложением мы получаем сумму EDX и EAX ; Поскольку, EAX и EDX не инициализировались явно, очевидно, через них ; были переданы аргументы (см. «Идентификация аргументов функций») push10h ; Передаем функции ltoa выбранную систему исчисления leaeax, [ebp+var_34] ; Загружаем в EAX указатель на локальный буфер var_34 pusheax ; Передаем функции ltoa указатель на буфер для записи результата pushebx ; Передаем сумму (не указатель!) двух аргументов функции MyFunc call_ltoa addesp, 0Ch leaedx, [ebp+var_34] ; Загружаем в EDX указатель на локальный буфер var_34 pushedx ; Передаем функции printf указатель на локальный буфер var_34, содержащий ; результат преобразования суммы аргументов MyFunc в строку pushebx ; Передаем сумму аргументов функции MyFunc pushoffset aXS; format call_printf addesp, 0Ch moveax, ebx ; Возвращаем сумму аргументов в EAX popebx ; Выталкиваем EBX из стека, восстанавливая его прежнее значение movesp, ebp ; Освобождаем память, занятную локальными переменными popebp ; Закрываем кадр стека retn MyFuncendp ; int cdecl main(int argc,const char argv,const char *envp) _mainproc near; DATA XREF: DATA:00407044o var_4= dwordptr –4 ; IDA распознала по крайней мере одну локальную переменную – ; возьмем это себе на заметку. argc= dwordptr 8 argv= dwordptr 0Ch envp= dwordptr 10h pushebp movebp, esp ; Открываем кадр стека pushecx pushebx pushesi ; Сохраняем регистры в стеке movesi, 777h ; Помещаем в регистр ESI значение 0x777 movebx, 666h ; Помещаем в регистр EBX значение 0x666 movedx, esi moveax, ebx ; Передаем функции MyFunc аргументы через регистры callMyFunc ; Вызываем MyFunc mov[ebp+var_4], eax ; Копируем результат, возвращенный функцией MyFunc в локальную переменную var_4 ; Стоп! Какую такую локальную переменную?! А кто под нее выделял память?! ; Не иначе – как из одна команд PUSH. Только вот какая? ; Смотрим на смещение переменной – она лежит на четыре байта выше EBP, а эта ; область памяти занята содержимым регистра, сохраненного первым PUSH, ; следующим за открытием кадра стека. ; (Соответственно, второй PUSH кладет значение регистра по смещению –8 и т.д.) ; А первой была команда PUSHECX, - следовательно, это не никакое не сохранение ; регистра в стеке, а резервирование памяти под локальную переменную ; Поскольку, обращений к локальным переменным var_8 и var_C не наблюдается, ; команды PUSHEBX и PUSHESI, по-видимому, действительно сохраняют регистры leaecx, [ebp+var_4] ; Загружаем в ECX указатель на локальную переменную var_4 pushecx ; Передаем указатель на var_4 функции printf pushoffset asc_407081 ; format call_printf addesp, 8 xoreax, eax ; Возвращаем в EAX нуль popesi popebx ; Восстанавливаем значения регистров ESI и EBX popecx ; Освобождаем память, выделенную локальной переменной var_4 popebp ; Закрываем кадр стека retn _mainendp Листинг113 дописать модификация локальной переменной из другого потока FPO - FramePointerOmissionТрадиционно для адресации локальных переменных используется регистр 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 Выделяем память для локальных переменных. Обратите внимание – теперь уже нет команд PUSHEBP\MOVEBP,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» Заносим в стек смещение строки «Enterpassword». Внимание!Регистр 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 Вывод строки «Enterpassword» на экран. Внимание!Аргументы все еще не вытолкнуты из стека! 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», - они-то и подскажут назначение переменной! С регистровыми переменными этот номер не пройдет! Положим, нам встретилась инструкция «MOVEAX,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'. Компилятор сгенерирует приблизительно следующий код «MOVREG, [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_8int 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 Пример, демонстрирующий идентификацию регистровых переменных Результат компиляции BorlandC++ 5.x должен выглядеть приблизительно так: ; int cdecl main(int argc,const char argv,const char *envp) _mainproc near; DATA XREF: DATA:00407044o argc= dwordptr 8 argv= dwordptr 0Ch envp= dwordptr 10h ; Обратите внимание – IDA не распознала ни одной стековой переменной, ; хотя они объявлялись в программе. ; Выходит, компилятор разместил их в регистрах pushebp movebp, esp ; Открываем кадр стека pushebx pushesi ; Сохраняем регистры в стеке или выделяем память для стековых переменных? ; Поскольку, IDA не обнаружила ни одной стековой переменной, вероятнее всего, ; этот код сохраняет регистры movebx, 666h ; Смотрите: инициализируем регистр! Сравните это с примером 112, приведенным в ; главе «Идентификация локальных стековых переменных». Помните, там было: ; mov[ebp+var_4], 666h ; Следовательно, можно заподозрить, что EBX – это регистровая переменная ; Существование переменной доказывает тот факт, что если бы значение 0x666 ; непосредственно передавалось функции т.е. так – printf(«%x %x %x\n», 0x666) ; Компилятор бы и поместил в код инструкцию «PUSH 0x666» ; А раз не так, следовательно: значение 0x666 передавалось через переменную ; Реконструируя исходный тест пишем: ; 1. int a=0x666 movesi, 777h ; Аналогично, ESI скорее всего представляет собой регистровую переменную ; 2. int b=0x777 leaeax, [esi+ebx] ; Загружаем в EAX сумму ESI и EBX ; Нет, EAX – не указатель, это просто сложение такое хитрое pusheax ; Передаем функции printf сумму регистровых переменных ESI и EBX ; А вот, что такое EAX – уже интересно. Ее можно представить и самостоятельной ; переменной и непосредственной передачей суммы переменных a и b функции ; printf. Исходя из соображений удобочитаемости, выбираем последний вариант ; 3. printf (,,,,a+b) pushesi ; Передаем функции printf регистровую переменную ESI, выше обозначенную нами ; как 'b' ; 3. printf(,,,b,a+b) pushebx ; Передаем функции printf регистровую переменную EBX, выше обозначенную как 'a' ; 3. printf(,,a,b,a+b) pushoffset aXXX; «%x + %x = %x» ; Передаем функции printf указатель на строку спецификаторов, судя по которой ; все три переменные имеют тип int ; 3. printf(«%x + %x = %x», a, b, a + b) call_printf addesp, 10h moveax, esi ; Копируем в EAX значение регистровой переменной ESI, обозначенную нами 'b' ; 4. int c=b subeax, ebx ; Вычитаем от регистровой переменной EAX ('c') значение переменной EBX ('a') ; 5. c=c-a pusheax ; Передаем функции printf разницу значений переменных EAX и EBX ; Ага! Мы видим, что от переменной 'c' можно отказаться, непосредственно ; передав функции printf разницу значений 'b' и 'a'. Вычеркиваем строку '5.' ; (совершаем откат), а вместо '4.' пишем следующее: ; 4. printf(,,,,b-a) pushesi ; Передаем функции printf значение регистровой переменной ESI ('b') ; 4. printf(,,,b, b-a) pushebx ; Передаем функции printf значение регистровой переменной EBX ('a') ; 4. printf(,,a, b, b-a) pushoffset aXXX_0; «%x + %x = %x» ; Передаем функции printf указатель на строку спецификаторов, судя по которой ; все трое имеют тип int ; 4. printf(«%x + %x = %x»,a, b, b-a) call_printf addesp, 10h xoreax, eax ; Возвращаем в EAX нулевое значение ; return 0 popesi popebx ; Восстанавливаем регистры popebp ; Закрываем кадр стека 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'. Это решение, кстати, ; имеет тот плюс, что не придется делать «отката» – переписывать уже ; реконструированные строки для удаления их них лишней переменной _mainendp Листинг117 …когда же лебедь ушел от нас, мы его имя оставили себе, поскольку мы считали, что оно лебедю больше не понадобится Алан Александр Милн. «Дом в медвежьем углу» (пер.Руднев, Т.Михайлова) Временные переменные. Временными переменными мы будем называть локальные переменные, внедряемые в код программы самим компилятором. Для чего они нужны? Рассмотрим следующий пример: «intb=a». Если 'a' и 'b' – стековые переменные, то непосредственное присвоение невозможно, поскольку, в микропроцессорах серии 80×86 отсутствует адресация «память – память». Вот и приходится выполнять эту операцию в два этапа: «память  регистр» + «регистр  память». Фактически компилятор генерирует следующий код: register int tmp=a;mov eax, [ebp+var_4] int b=tmp;mov [ebp+var_8], eax где «tmp» – и есть временная переменная, создавая лишь на время выполнения операции «b=a», а затем уничтожаемая за ненадобностью. Компиляторы (особенно оптимизирующие) всегда стремятся размещать временные переменные в регистрах, и только в крайних случаях заталкивают их в стек. Механизмы выделения памяти и способы чтения/записи временных переменных довольно разнообразны. Сохранение переменных в стеке – обычная реакция компилятора на острый недостаток регистров. Целочисленные переменные чаще всего закидываются на вершину стека командой PUSH, а стягиваются оттуда командой POP. Встретив в тексте программы «тянитолкая» (инструкцию PUSH в паре с соответствующей ей POP), сохраняющего содержимое инициализированного регистра, но не стековый аргумент функции (см. «Идентификация аргументов функции»), можно достаточно уверенно утверждать, что мы имеем дело с целочисленной временной переменной. Выделение памяти под вещественные переменные и их инициализация в большинстве случаев происходят раздельно. Причина в том, что команды, позволяющей перебрасывать числа с вершины стека сопроцессора на вершину стека основного процессора, не существует и эту операцию приходится осуществлять вручную. Первым делом «приподнимается» регистр указатель вершины стека (обычно «SUBESP, xxx»), затем в выделенные ячейки памяти записывается вещественное значение (обычно «FSTP [ESP]»), наконец, когда временная переменная становится не нужна, она удаляется из стека командой «ADDESP, xxx» или подобной ей («SUB, ESP, - xxx»). Подвинутые компиляторы (например, MicrosoftVisualC++) умеют располагать временные переменные в аргументах, оставшихся на вершине стека после завершения последней вызванной функции. Разумеется, этот трюк применим исключительно к cdecl-, но не stdcall-функциям, ибо последние самостоятельно вычищают свои аргументы из стека (подробнее см. «Идентификация аргументов функций»). Мы уже сталкивались с таким приемом при исследовании механизма возврата значений функцией в главе «Идентификация значения, возвращаемого функцией». Временные переменные размером свыше восьми байт (строки, массивы, структуры, объекты) практически всегда размешаются в стеке, заметно выделясь среди прочих типов своим механизмом инициализации – вместо традиционного MOV, здесь используется одна из команд циклической пересылки MOVSx, при необходимости предваренная префиксом повторения REP (MicrosoftVisualC++, BorlandC++), или несколько команд MOVSx к ряду (WATCOMC). Механизм выделения памяти под временные переменные практически идентичен механизму выделения памяти стековым локальным переменным, однако, никаких проблем идентификации не возникает. Во-первых, выделение памяти стековым переменным происходит сразу же после открытия кадра стека, а временным переменными – в любой точке функции. Во-вторых, временные переменные адресуются не через регистр указатель кадра стека, а через указатель вершины стека. |действие|методы||| | ::: |1й|2й|3й| |резервирование памяти|PUSH|SUB ESP, xxx|использовать стековые аргументы »>#| |освобождение памяти|POP|ADD ESP, xxx|| |запись переменной|PUSH|MOV [ESP+xxx],| MOVS| |чтение переменной|POP|MOV , [ESP+xxx]|передача вызываемой функции| Таблица 15 Основные механизмы манипуляция со временными переменными »># Только в cdecl! В каких же случаях компилятором создаются временные переменные? Вообще-то, это зависит от «нрава» самого компилятора (чужая душа – всегда потемки, а уж тем более – душа компилятора). Однако можно выделить по крайней мере два случая, когда без создания временных переменных ну никак не обойтись: 1) при операциях присвоения, сложения, умножения; 2) в тех случаях, когда аргумент функции или член выражения – другая функция. Рассмотри оба случая подробнее. ::Создание временных переменных при пересылках данных и вычислении выражений. Как уже отмечалось выше, микропроцессоры серии 80×86 не поддерживают непосредственную пересылку данных из памяти в память, поэтому, присвоение одной переменной значения другой требует ввода временной регистровой переменной (при условии, что остальные переменные не регистровые). Вычисление выражений (особенно сложных) так же требует временных переменных для хранения промежуточных результатов. Вот, например, сколько по-вашему требуется временных переменных для вычисления следующего выражения? int a=0x1;int b=0x2; int с= 1/8); Начнемсоскобок, переписавихкак: 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 ; Инициализация локальных переменных moveax, 1 ; Вот вводится первая временная переменная ; В нее записывается непосредственное значение, т.к. команда, вычитания SUB, ; в силу архитектурных особенностей микропроцессоров серии 80×86 всегда ; записывает результат вычисления на место уменьшаемого и потому ; уменьшаемое не может быть непосредственным значением, вот и приходится ; вводить временную переменную subeax, [ebp+var_4] ; tEAX := 1 – var_4 ; в регистре EAX теперь хранится вычисленное значение (1-a) movecx, 1 ; Вводится еще одна временная переменная, поскольку EAX трогать нельзя – ; он занят subecx, [ebp+var_8] ; tECX := 1- var_8 ; В регистре ECX теперь хранится вычисленное значение (1-b) cdq ; Преобразуем двойное слово, лежащее в EAX в четверное слово, ; помещаемое в EDX:EAX ; (машинная команда idiv всегда ожидает увидеть делимое именно в этих регистрах) idivecx ; Делим (1-a) на (1-b), помещая частое в tEAX ; Прежнее значение временной переменной при этом неизбежно затирается, однако, ; для дальнейших вычислений оно и не нужно ; Вот и пускай себе затирается – не беда! movecx, eax ; Копируем значение (1-a) / (1-b) в регистр ECX. ; Фактически, это новая временная переменная t2ECX, но в том же самом регистре ; (старое содержимое ECX нам так же уже не нужно) ; Индекс «2» после префикса «t» дан для того, чтобы показать, что t2ECX - ; вовсе не то же самое, что tECX, хотя обе эти временные переменные хранится ; в одном регистре moveax, 1 ; Заносим в EAX непосредственное значение 1 ; Это еще одна временная переменная – t2EAX cdq ; Обнуляем EDX idivecx ; Делим 1 на 9) ; Частое помещается в EAX mov[ebp+var_10], eax ; c := 1 / 10) ; Итак, для вычисления данного выражения потребовалось четыре временных ; переменных и всего два регистра общего назначения Листинг 118 ::Создание временных переменных для сохранения значения, возращенного функцией, и результатов вычисления выражений. Большинство языков высокого уровня (в том числе и Си/Си++) допускают подстановку функций и выражений в качестве непосредственных аргументов. Например: «myfunc(a+b, myfunc_2©)» Прежде, чем вызвать myfunc, компилятор должен вычислить значение выражения «a+b». Это легко, но возникает вопрос – во что записать результат сложения? Посмотрим, как с этим справится компилятор: moveax, [ebp+var_C] ; Создается временная переменная tEAX и в нее копируется значение ; локальной переменной var_C pusheax ; Временная переменная tEAX сохраняется в стеке, передавая функции myfunc ; в качестве аргумента значение локальной переменной var_C ; Хотя, локальная переменная var_C в принципе могла бы быть непосредственно ; передана функции – PUSH [ebp+var_4] и никаких временных переменных! callmyfunc addesp, 4 ; Функция myfunc возвращает свое значение в регистре EAX ; Его можно рассматривать как своего рода еще одну временную переменную pusheax ; Передаем функции myfunc_2 результат, возвращенный функцией myfunc movecx, [ebp+var_4] ; Копируем в ECX значение локальной переменной var_4 ; ECX – еще одна временная переменная ; Правда, не совсем понятно почему компилятор не использовал регистр EAX, ; ведь предыдущая временная переменная ушла из области видимости и, ; стало быть, занимаемый ею регистр EAX освободился… addecx, [ebp+var_8] ; ECX := var_4 + var_8 pushecx ; Передаем функции myfunc_2 сумму двух локальных переменных call_myfunc_2 Листинг119 Область видимости временных переменных. Временные переменные – это, в некотором роде, очень локальные переменные. Область их видимости в большинстве случаев ограничена несколькими строками кода, вне контекста которых временная переменная не имеет никакого смысла. По большому счету, временная переменная не имеет смысла вообще и только загромождает код. В самом деле, myfunc(a+b) намного короче и понятнее, чем inttmp=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! Идентифицировать глобальные переменные очень просто, гораздо проще, чем все остальные конструкции языков высокого уровня. Глобальные переменные сразу же выдают себя непосредственной адресаций памяти, т.е. обращение к ним выглядит приблизительно так: «MOVEAX,[401066]», где 0x401066 и есть адрес глобальной переменной. Сложнее понять: для чего эта переменная, собственно, нужна и каково ее содержимое на данный момент. В отличие от локальных переменных, глобальные – контекстно-зависимы. В самом деле, каждая локальная переменная инициализируется «своей» функцией и не зависит от того, какие функции были вызваны до нее. Напротив, глобальные переменные может модифицировать кто угодно и когда угодно, - значение глобальной переменной в произвольной точке программы не определено. Чтобы его выяснить, необходимо проанализировать все, манипулирующие с ней функции, и – более того – восстановить порядок из вызова. Подробнее этот вопрос будет рассмотрен в главе «_Построение дерева вызовов», - пока же разберемся с техникой восстановления перекрестных ссылок. Техника восстановления перекрестных ссылок. В большинстве случаев с восстановлением перекрестных ссылок сполна справляется автоматический анализатор IDA, и делать это «вручную» практически никогда не придется. Однако бывает, что IDA ошибается, да и не всегда (и не у всех!) она бывает под рукой. Поэтому, совсем нелишне уметь справляться с глобальными переменными самому. Отслеживание обращений к глобальным переменным контекстным поиском их смещения в сегменте кода [данных]. Непосредственная адресация глобальных переменных чрезвычайно облегчает поиск манипулирующих с ними машинных команд. Рассмотрим, например, такую конструкцию: «MOVEA,[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, и немного существует констант, выражаемых таким большим числом. Замечание:минимальный адрес загрузки WindowsNT равен 0x10000, однако, чтобы программа могла успешно работать и под NT, и под 9x, она должна грузиться не ниже 0x400000. Кошмары 16-разрядного режима. В 16-разрядном режиме отличить константу от указателя не так-то просто, как в 32-разрядном режиме! В 16-разрядном режиме под данные отводится один (или несколько) сегментов размером 0x10000 байт и допустимые значения смещений заключены в узком интервале [0x0, 0xFFFF], причем у большинства переменных смещения очень невелики и визуально неотличимы от констант. Другая проблема – один сегмент чаще всего не вмещает в себя всех данных и приходится заводить еще один (а то и больше). Два сегмента – это еще ничего: один адресуется через регистр DS, другой – через ES и никаких трудностей в определении «это указатель на переменную какого сегмента» не возникает. Например, если нас интересуют все обращения к глобальной переменной X, расположенной в основном сегменте по смещению 0x666, то команду MOVAX, ES:[0x666], мы сразу же откинем в мусорную корзину, т.к. основной сегмент адресуется через DS (по умолчанию), а здесь – ES. Правда, обращение может происходить и в два этапа. Например: «MOVBX,0x666/xxx—xxx/MOVAX,ES:[BX]», увидев «MOVBX,0x666» мы не только не можем определить сегмент, но и даже сказать – смещение ли это вообще? Впрочем, это не сильно затрудняет анализ… Хуже, если сегментов данных в программе добрый десяток (а, что, может же потребоваться порядка 640 килобайт статической памяти?). Никаких сегментных регистров на это не хватит, и их переназначения будут происходить многократно. Тогда, чтобы узнать к какому именно сегменту происходит обращение, потребуется определить значение сегментного регистра. А как его определить? Самое простое – прокрутить экран дизассемблера немного вверх, ища глазами инициализацию данного сегментного регистра, помня то том, что она может осуществляться не только командой MOVsegREG, REG, но довольно частенько и POP! Например, PUSHES/POPDS равносильно MOVDS, ES – правда, команды MOVsegREG, segREG в «языке» микропроцессоров 80×86, увы, нет. Как нет команды MOVsegREG, CONST, и ее приходится эмулировать вручную либо так: MOVAX, 0x666/MOVES,AX, либо так: PUSH 0x666/POPES. Как хорошо, что 16-разрядный режим практически полностью ушел в прошлое, унося в песок истории все свои проблемы. Не только программисты, но и хакеры с переходом на 32-разрядный режим вздыхают с облегчением. Косвенная адресация глобальных переменных. Довольно часто приходится слышать утверждение, что глобальные переменные всегда адресуются непосредственно (исключая, конечно, ассемблерные вставки, - на ассемблере программист может обращаться к переменным как захочет). На самом же деле все далеко не так… Если глобальная переменная передается функции по ссылке (а почему бы программисту ни передать глобальную переменную по ссылке?), она будет адресоваться косвенно – через указатель. Мне могут возразить – а зачем вообще явно передавать глобальную переменную функции? Любая функция и без этого может к ней обратится. Не спорю. Да, может, но только если знает об этом заранее. Вот, скажем, есть у нас функция xchg, обменивающая свои аргументы местами, и есть две глобальные переменные, которые позарез приспичило обменять. Функции xchg доступны все глобальные переменные, но она «не знает» какие из них необходимо обменивать (и необходимо ли это вообще?), вот и приходится ей явно передавать глобальные переменные как аргументы. А это значит, что всех обращений к глобальным переменным простым контекстным поиском мы не нейдем. Самое печальное – не найдет их и IDAPro (да и как бы она их могла найти? для этого ей потребовался бы полноценный эмулятор процессора или хотя бы основных команд), на чем мы и убедимся в следующем примере: #include <stdio.h> inta; intb; Глобальные переменные a и b Функция, обменивающая значения аргументов xchg(int *a, int *b) { int c; c=*a; *b=*a; *b=c; ^^^^^^^^^^^^^^^^^^ косвенное обращение к аругментам по указателю если аргументы функции – глобальные переменные, то они будут адресоваться не прямо, а косвенно } main() { a=0x666; b=0x777; Здесь – непосредственное обращение к глобальным переменным xchg(&a, &b); Передача глобальной переменной по ссылке } Листинг 121 Явная передача глобальных переменных Результат компиляции компилятором MicrosoftVisualC++ должен выглядеть так: mainproc near; CODE XREF: start+AFp pushebp movebp, esp ; Открываем кадр стека movdword_405428, 666h ; Инициализируем глобальную переменную dword_405428 ; На то, что это действительно глобальная переменная указывает непосредственная ; адресация movdword_40542C, 777h ; Инициализируем глобальную переменную dword_40542C pushoffset dword_40542C ; Смотрите! Передаем функции смещение глобальной переменной dword_40542C как ; аргумент (т.е. другими словами, передаем ее по ссылке) ; Это значит, что вызываемая функция будет обращаться к переменной косвенно, ; через указатель – точно так, как она обращается с локальными переменными pushoffset dword_405428 ; Передаем функции смещение глобальной переменной dword_405428 callxchg addesp, 8 popebp retn mainendp xchgproc near; CODE XREF: main+21p var_4= dwordptr -4 arg_0= dwordptr 8 arg_4= dwordptr 0Ch pushebp movebp, esp ; Открываем кадр стека pushecx ; Выделяем память для локальной переменной var_4 moveax, [ebp+arg_0] ; Загружаем а EAX содержимое аргумента arg_0 movecx, [eax] ; Смотрите! Косвенное обращение к глобальной переменной! ; А еще говорят – будто бы таких не бывает! ; Разумеется, определить, что обращение происходит именно к глобальной ; переменной (и какой именно глобальной переменной) можно только анализом ; кода вызывающей функции mov[ebp+var_4], ecx ; Копируем значение *arg_0 в локальную переменную var_4 movedx, [ebp+arg_4] ; Загружаем в EDX содержимое аргумента arg_4 moveax, [ebp+arg_0] ; Загружаем в EAX содержимое аргумента arg_0 movecx, [eax] ; Копируем в ECX значение аргумента *arg_0 mov[edx], ecx ; Копируем в [arg_4] значение arg_0[0] movedx, [ebp+arg_4] ; Загружаем в EDX значение arg_4 moveax, [ebp+var_4] ; Загружаем в EAX значение локальной переменной var_4 (хранит *arg_0) mov[edx], eax ; Загружаем в *arg_4 значение *arg_0 movesp, ebp popebp retn xchgendp dword_405428dd 0; DATA XREF: main+3w main+1Co ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ dword_40542Cdd 0; DATA XREF: main+Dw main+17o ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ; IDA нашла все ссылки на обе глобальные переменные ; Первые две: main+3w и main+Dw на код инициализации ; ('w' – от «write» – т.е. в обращение на запись) : Вторые две: main+1Co и main+17o ; ('o' – от «offset» – т.е. получение смещения глобальной переменной) Листинг 122 Если среди перекрестных ссылок на глобальную переменную присутствуют ссылки с суффиксом 'o', обозначающие взятие смещения (аналог ассемблерной директивы offset), то сразу же вскидывайте свои ушки на макушку – раз offset, значит, имеет место передача глобальной переменной по ссылке. А ссылка – это косвенная адресация. А косвенная адресация – это ласты: утомительный ручной анализ и никаких чудес прогресса. Статические переменные. Статические переменные – это разновидность глобальных переменных, но с ограниченной областью видимости – они доступны только из той функции, в которой и были объявлены. Во всем остальном статические и глобальные переменные полностью совпадают – обои размещаются в сегменте данных, обои непосредственно адресуются (исключая случаи обращения через ссылку), обои… …есть лишь одна существенная разница – к глобальной переменной могут обращаться любые функции, а к статической – только одна. А как насчет глобальных переменных, используемых лишь одной функций? Да какие же это глобальные переменные?! Это – не глобальность, это – кривость исходного кода программы. Если переменная используется лишь одной функцией, нет никакой необходимости объявлять ее глобальной! Всякая непосредственно адресуемая ячейка памяти – глобальная (статическая) переменная (см. исключения ниже), но не всякая глобальная (глобальная) переменная всегда адресуется непосредственно. ==== Идентификация констант и смещений ==== «То, что для одного человека константа, для другого - переменная» Алан Перлис «Афоризмы программирования» Микропроцессоры серии 80×86 поддерживают операнды трех типов: регистр, непосредственное значение, непосредственныйуказатель. Тип операнда явно задается в специальном поле машинной инструкции, именуемом «mod», поэтому никаких проблем в идентификации типов операндов не возникает. Регистр – ну, все мы знаем, как выглядят регистры; указатель по общепринятому соглашению заключается в угловые скобки, а непосредственное значение записывается без них. Например: MOVECX, EAX; регистровый операнды MOVECX, 0x666;  левый операнд регистровый, правый – непосредственный MOV [0x401020], EAX левый операнд – указатель, правый – регистр Кроме этого микропроцессоры серии 80×86 поддерживают два вида адресации памяти: непосредственную и косвенную. Тип адресации определяется типом указателя. Если операнд – непосредственный указатель, то и адресация непосредственна. Если же операнд-указатель – регистр, – такая адресация называется косвенной. Например: MOVECX,[0x401020]  непосредственная адресация MOVECX, [EAX] косвенная адресация Для инициализации регистрового указателя разработчики микропроцессора ввели специальную команду – «LEAREG, [addr]» – вычисляющую значение адресного выражения addr и присваивающую его регистру REG. Например: LEAEAX, [0x401020]; регистру EAX присваивается значение указателя 0x401020 MOVECX, [EAX]; косвенная адресация – загрузка в ECX двойного слова, ; расположенного по смещению 0x401020 Правый операнд команды LEA всегда представляет собой ближний (near) указатель. (Исключение составляют случаи использования LEA для сложения констант – подробнее об этом смотри в одноименном пункте). И все было бы хорошо…. да вот, оказывается, внутреннее представление ближнего указателя эквивалентно константе того же значения. Отсюда – «LEAEAX, [0x401020]» равносильно «MOVEAX,0x401020». В силу определенных причин MOV значительно обогнал в популярности «LEA», практически вытеснив последнюю инструкцию из употребления. Изгнание «LEA» породило фундаментальную проблему ассемблирования - «проблему OFFSETа». В общих чертах ее суть заключается в синтаксической неразличимости констант и смещений (ближних указателей). Конструкция «MOVEAX, 0x401020» может грузить в EAX и константу, равную 0x401020 (пример соответствующего Си-кода: a=0x401020), и указатель на ячейку памяти, расположенную по смещению 0x401020 (пример соответствующего Си-кода: a=&x). Согласитесь, a=0x401020совсем не одно и тоже, что a=&x! А теперь представьте, что произойдет, если в заново ассемблированной программе переменная «x» в силу некоторых обстоятельств окажется расположена по иному смещению, а не 0x401020? Правильно, - программа рухнет, ибо указатель «a» по-прежнему указывает на ячейку памяти 0x401020, но здесь теперь «проживает» совсем другая переменная! Почему переменная может изменить свое смещение? Основных причин тому две. Во-первых, язык ассемблера неоднозначен и допускает двоякую интерпретацию. Например, конструкции «ADDEAX, 0x66» соответствуют две машинные инструкции: «83 C0 66» и «05 66 00 00 00» длиной три и пять байт соответственно. Транслятор может выбрать любую из них и не факт, что ту же самую, которая была в исходной программе (до дизассемблирования). Неверно «угаданный» размер вызовет уплывание всех остальных инструкций, а вместе с ними и данных. Во-вторых, уплывание не замедлит вызвать модификация программы (разумеется, речь идет не о замене JZ на JNZ, а настоящей адоптации или модернизации) и все указатели тут же «посыпаться». Вернуть работоспособность программы помогает директива «offset». Если «MOVEAX, 0x401020» действительно загружает в EAX указатель, а не константу, по смещению 0x401020 следует создать метку, именуемую, скажем, «loc_401020», и «MOVEAX, 0x401020» заменить на «MOVEAX, offsetloc_401020». Теперь указатель EAX связан не с фиксированным смещением, а с меткой! А что произойдет, если предварить директивой offset константу, ошибочно приняв ее за указатель? Программа откажет в работе или станет работать некорректно. Допустим, число 0x401020 выражало собой объем бассейна через одну трубу в который что-то втекает, а через другую – вытекает. Если заменить константу указателем, то объем бассейна станет равен… смещению метки в заново ассемблированной программе и все расчеты полетят к черту. Рисунок 18 0х010 Типы операндов Рисунок 19 0х011 Типы адресаций Таким образом, очень важно определить типы всех непосредственных операндов, и еще важнее определить их правильно. Одна ошибка может стоить программе жизни (в смысле работоспособности), а в типичной программе тысячи и десятки тысяч операндов! Отсюда возникает два вопроса: а) как вообще определяют типы операндов? б) можно ли их определять автоматически (или на худой конец хотя бы полуавтоматически)? Определение типа непосредственного операнда. Непосредственный операнд команды LEA – всегда указатель (исключение составляют ассемблерные «извращения»: чтобы сбить хакеров с толку в некоторых защитах LEA используется для загрузки константы). Непосредственные операнды команд MOV и PUSH могут быть как константами, так и указателями. Чтобы определить тип непосредственного операнда, необходимо проанализировать: как используется его значение в программе. Если для косвенной адресации памяти – это указатель, в противном случае – константа. Например, встретили мы в тексте программы команду «MOVEAX, 0x401020» (см. рис 19), - что это такое: константа или указатель? Ответ на вопрос дает строка «MOV ECX, [EAX]», подсказывающая, что значение «0x401020» используется для косвенной адресации памяти, следовательно, непосредственный операнд – ни что иное, как указатель. Существует два типа указателей – указатели на данные и указатели на функцию. Указатели на данные используются для извлечения значения ячейки памяти и встречаются в арифметических командах и командах пересылки (например – MOV, ADD, SUB). Указатели на функцию используются в командах косвенного вызова и, реже, в командах косвенного перехода – CALLи JMPсоответственно. Рассмотрим следующий пример: main() { static int a=0x777; int *b = &a; int c=b[0]; } Листинг 123 Константы и указатели Результат его компиляции должен выглядеть приблизительно так: mainproc near var_8= dwordptr -8 var_4= dwordptr -4 pushebp movebp, esp subesp, 8 ; Открываем кадр стека mov[ebp+var_4], 410000h ; Загружаем в локальную переменную var_4 значение 0x410000 ; Пока мы не можем определить его тип – константа это или указатель moveax, [ebp+var_4] ; Загружаем содержимое локальной переменной var_4 в регистр EAX movecx, [eax] ; Загружаем в ECX содержимое ячейки памяти на которую указывает указатель EAX ; Ага! Значит, EAX все-таки указатель. Тогда локальная переменная var_4, ; откуда он был загружен, тоже указатель ; И непосредственный операнд 0x410000 – указатель, а не константа! ; Следовательно, чтобы сохранить работоспособность программы, создадим по ; смещению 0x410000 метку loc_410000, ячейку памяти, расположенную по этому ; адресу преобразует в двойное слово, и MOV [ebp+var_4], 410000h заменим на: ; MOV [ebp+var_4], offset loc_410000 mov[ebp+var_8], ecx ; Присваиваем локальной переменной var_8 значение *var_4 ([offsetloc_41000]) movesp, ebp popebp ; Закрываем кадр стека retn mainendp Листинг124 Рассмотрим теперь пример с косвенным вызовом процедуры: func(int a, int b) { return a+b; }; main() { int (*zzz) (int a, int b) = func; Вызов функции происходит косвенно – по указателю zzz zzz(0x666,0x777); } Листинг 125 Пример, демонстрирующий косвенный вызов процедуры Результат компиляции должен выглядеть приблизительно так: .text:0040100B mainproc near ; CODE XREF: start+AFp .text:0040100B .text:0040100B var_4dword ptr -4 .text:0040100B .text:0040100Bpush ebp .text:0040100Cmov ebp, esp .text:0040100C; Открываем кадр стека .text:0040100C .text:0040100Epush ecx .text:0040100E; Выделяем память для локальной переменной var_4 .text:0040100E .text:0040100Fmov [ebp+var_4], 401000h .text:0040100F; Присваиваем локальной переменной значение 0x401000 .text:0040100F; Пока еще мы не можем сказать – константа это или смещение .text:0040100F .text:00401016push 777h .text:00401016; Заносим значение 0x777 в стек. Константа это или указатель? .text:00401016; Пока сказать невозможно – необходимо проанализировать .text:00401016; вызываемую функцию .text:00401016 .text:0040101Bpush 666h .text:0040101B; Заносим в стек непосредственное значение 0x666 .text:0040101B .text:00401020call [ebp+var_4] .text:00401020; Смотрите: косвенный вызов функции! .text:00401020; Значит, переменная var_4 – указатель, раз так, то и .text:00401020; присваиваемое ей непосредственное знаечние .text:00401020; 0x401000 – тоже указатель! .text:00401020; А по адресу 0x401000 расположена вызываемая функция! .text:00401020; Окрестим ее каким-нибудь именем, например, MyFunc и .text:00401020; заменим mov [ebp+var_4], 401000h на .text:00401020; mov [ebp+var_4], offset MyFunc .text:00401020; после чего можно будет смело модифицировать программу .text:00401020; теперь-то она уже не «развалится»! .text:00401020 .text:00401023add esp, 8 .text:00401023 .text:00401026mov esp, ebp .text:00401028pop ebp .text:00401028; Закрываем кадр стека .text:00401028 .text:00401029 retn .text:00401029 main endp .text:00401000 MyFunc proc near .text:00401000 ; А вот и косвенно вызываемая функция MyFunc .text:00401000 ; Исследуем ее, чтобы определить тип передаваемых ей .text:00401000 ; непосредственных значений .text:00401000 .text:00401000 arg_0 = dword ptr 8 .text:00401000 arg_4 = dword ptr 0Ch .text:00401000 ; Ага, вот они, наши аргументы! .text:00401000 .text:00401000push ebp .text:00401001mov ebp, esp .text:00401001; Открываем кадр стека .text:00401001 .text:00401003mov eax, [ebp+arg_0] .text:00401003; Загружаем в EAX значение аргумента arg_0 .text:00401003 .text:00401006add eax, [ebp+arg_4] .text:00401006; Складываем EAX (arg_0) со значением аргумента arg_0 .text:00401006; Операция сложения намекает, что по крайней мере один из .text:00401006; двух аргументов не указатель, т.к. сложение двух указателей .text:00401006; бессмысленно (см. «Сложные случаи адресации») .text:00401006 .text:00401009pop ebp .text:00401009; Закрываем кадр стека .text:00401009 .text:0040100Aretn .text:0040100A; Выходим, возвращая в EAX сумму двух аргументов .text:0040100A; Как мы видим, ни здесь, ни в вызывающей функции, .text:0040100A; непосредственные значения 0x666 и 0x777 не использовались .text:0040100A; для адресации памяти – значит, это константы .text:0040100A .text:0040100A MyFunc endp .text:0040100A Листинг126 Сложные случаи адресации или математические операции с указателями. Си/Си++ и некоторые языки программирования допускают выполнение над указателями различных арифметических операций, чем серьезно затрудняют идентификацию типов непосредственных операндов. В самом деле, если бы такие операции с указателями были запрещены, то любая математическая инструкция, манипулирующая с непосредственным операндом, однозначно указывала на его константный тип. К счастью, даже в тех языках, где это разрешено, над указателями выполняется ограниченное число математических операций. Так, совершенно бессмысленно сложение двух указателей, а уж тем более умножение или деление их друг на друга. Вычитание – дело другое. Используя тот факт, что компилятор располагает функции в памяти согласно порядку их объявления в программе, можно вычислить размер функции, отнимая ее указатель от указателя на следующую функцию (см. рис. 20). Такой трюк встречается в упаковщиках (распаковщиках) исполняемых файлов, защитах с самомодифицирующимся кодом, но в прикладных программах используется редко. Рисунок 20 0х012 Использования вычитания указателей для вычисления размера функции [структуры данных]. Сказанное выше относилось к случаям «указатель» + «указатель», между тем указатель может сочетаться и с константой. Причем, такое сочетание настолько популярно, что микропроцессоры серии 80×86 даже поддерживают для этого специальную адресацию – базовую. Пусть, к примеру, имеется указатель на массив и индекс некоторого элемента массива. Очевидно, чтобы получить значение этого элемента, необходимо сложить указатель с индексом, умноженным на размер элемента. Вычитание константы из указателя встречается гораздо реже, - этому не только соответствует меньший круг задач, но и сами программисты избегают вычитания, поскольку оно нередко приводит к серьезным проблемам. Среди начинающих популярен следующий примем – если им требуется массив, начинающийся с единицы, они, объявив обычный массив, получают на него указатель и… уменьшают его на единицу! Элегантно, не правда ли? Нет, не правда, - подумайте, что произойдет, если указатель на массив будет равен нулю. Правильно, - «змея укусит» свой хвост, и указатель станет оч-чень большим положительным числом. Вообще-то, под Windows 9x\NT массив гарантированно не может быть размещен по нулевому смещению, но не стоит привыкать к трюкам, привязанным к одной платформе, и не работающим на других. «Нормальные» языки программирования запрещают смешение типов, и – правильно! Иначе такая чехарда получается, не чехарда даже, а еще одна фундаментальная проблема дизассемблирования – определение типов в комбинированных выражениях. Рассмотрим следующий пример: MOV EAX,0x… MOV EBX,0x… ADD EAX,EBX MOV ECX,[EAX] Летающий Слонопотам! Сумма двух непосредственных значений используется для косвенной адресации. Ну, положим, оба они указателями быть не могут, - исходя из самых общих соображений, – никак не должны. Наверняка одно из непосредственных значений – указатель на массив (структуру данных, объект), а другое – индекс в этом массиве. Для сохранения работоспособности программы указатель необходимо заменить смещением метки, а вот индекс оставить без изменений (ведь индекс – это константа). Как же различить: что есть что? Увы, - нет универсального ответа, а в контексте приведенного выше примера – это и вовсе невозможно! Рассмотрим следующий пример: MyFunc(char *a, int i) { a[i]='\n'; a[i+1]=0; } main() { static char buff[]=«Hello,Sailor!»; MyFunc(&buff[0], 5); } Листинг 127 Пример, демонстрирующий определение типов в комбинированных выражениях Результат компиляции MicrosoftVisualC++ должен выглядеть так: mainproc near; CODE XREF: start+AFp pushebp movebp, esp ; Открываем кадр стека push5 ; Передаем функции MyFunc непосредственное значение 0x5 push405030h ; Передаем функции MyFunc непосредственное значение 0x405030 callMyFunc addesp, 8 ; Вызываем MyFunc(0x405030, 0x5) popebp ; Закрываем кадр стека retn mainendp MyFuncproc near; CODE XREF: main+Ap arg_0= dwordptr 8 arg_4= dwordptr 0Ch pushebp movebp, esp ; Открываем кадр стека moveax, [ebp+arg_0] ; Загружаем в EAX значение аргумента arg_0 ; (arg_0 содержит непосредственное значение 0x405030) addeax, [ebp+arg_4] ; Складываем EAX со значением аргумента arg_4 (он содержит значение 0x5) ; Операция сложения указывает на то, что, по крайней мере, один из них ; константа, а другой – либо константа, либо указатель movbyte ptr [eax], 0Ah ; Ага! Сумма непосредственных значений используется для косвенной адресации ; памяти, значит, это константа и указатель. Но кто есть кто? ; Для ответа на этот вопрос нам необходимо понять смыл кода программы - ; чего же добивался программист сложением указателей? ; Предположим, что значение 0x5 – указатель. Логично? ; Да, вот не очень-то логично, - если это указатель, то указатель на что? ; Первые 64 килобайта адресного пространства WindowsNT заблокированы для ; «отлавливания» нулевых и неинициализированных указателей ; Ясно, что равным пяти указатель быть никак не может. Разве что программки ; использовал какой ни будь очень извращенный трюк. ; А если указатель – 0x401000? Выглядит правдоподобным легальным смещением… ; Кстати, что там у нас расположено? Секундочку… ; 00401000 db 'Hello,Sailor!',0 ; ; Теперь все сходится – функции передан указатель на строку «Hello, Sailor!» ; (значение 0x401000) и индекс символа этой строки (значение 0x5), ; функция сложила указатель со строкой и записала в полученную ячейку символ \n movecx, [ebp+arg_0] ; В ECX заносится значение аргумента arg_0 ; (как мы уже установили это – указатель) addecx, [ebp+arg_4] ; Складываем arg_0 с arg_4 (как мы установили arg_4 – индекс) movbyte ptr [ecx+1], 0 ; Сумма ECX используется для косвенной адресации памяти, точнее ковенно-базовой ; т.к. к сумме указателя и индекса прибавляется еще и единица и в эту ячейку ; памяти заносится ноль ; Наши выводы подтверждаются – функции передается указатель на строку и ; индекс первого «отсекаемого» символа строки ; Следовательно для сохранения работоспособности программы по смещению 0x401000 ; необходимо создать метку «loc_s0», а PUSH 0x401000 в вызывающей функции ; заменитьна PUSH offset loc_s0 popebp retn MyFuncendp Листинг128 А теперь откомпилируем тот же самый пример компилятором BorlandC++ 5.0 и сравним, чем он отличается от MicrosoftVisualC++ (ниже для экономии места приведен код одной лишь функции MyFunc, функция main – практически идентична предыдущему примеру): MyFuncproc near; CODE XREF: _main+Dp pushebp ; Отрываем пустой кадр стека – нет локальных переменных movbyte ptr [eax+edx], 0Ah ; Ага, BorlandC++ сразу сложил указатель с константой непосредственно в ; адресном выражении! ; Как определить какой из регистров константа, а какой указатель? ; Как и в предыдущем случае необходимо проанализировать их значение. movbyte ptr [eax+edx+1], 0 movebp, esp popebp ; Закрытие кадра стека retn MyFuncendp Листинг 129 Порядок индексов и указателей. Открою маленький секрет – при сложении указателя с константой большинство компиляторов на первое место помещают указатель, а на второе – константу, каким бы ни было их расположение в исходной программе. То есть, выражения «a[i]», «(a+i)[0]», «*(a+i)» и «*(i+a)» компилируются в один и тот же код! Даже если извратиться и написать так: «(0)[i+a]», компилятор все равно выдвинет 'a' на первое место. Что это – ослиная упрямость, игра случая или фича? Ответ до смешного прост – сложение указателя с константой дает указатель! Поэтому – результат вычислений всегда записывается в переменную типа «указатель». Вернемся к последнему рассмотренному примеру, применив для анализа наше новое правило: moveax, [ebp+arg_0] ; Загружаем в EAX значение аргумента arg_0 ; (arg_0 содержит непосредственное значение 0x405030) addeax, [ebp+arg_4] ; Складываем EAX со значением аргумента arg_4 (он содержит значение 0x5) ; Операция сложения указывает на то, что, по крайней мере, один из них ; константа, а другой – либо константа, либо указатель movbyte ptr [eax], 0Ah ; Ага! Сумма непосредственных значений используется для косвенной адресации ; памяти, значит, это константа и указатель. Но кто из них кто? ; С большой степенью вероятности EAX – указатель, т.к. он стоит на первом ; месте, а var_4 – индекс, т.к. он стоит на втором Листинг 130 Использование LEA для сложения констант. Инструкция LEA широко используется компиляторами не только для инициализации указателей, но и сложения констант. Поскольку, внутренне представление констант и указателей идентично, результат сложения двух указателей идентичен сумме тождественных им констант. Т.е. «LEAEBX, [EBX+0x666] == ADDEBX, 0x666», однако по своим функциональным возможностям LEA значительно обгоняет ADD. Вот, например, «LEAESI, [EAX*4+EBP-0x20]», - попробуйте то же самое «скормить» инструкции ADD! Встретив в тексте программы команду LEA, не торопитесь навешивать на возвращенное ею значение ярлык «указатель», - с не меньшим успехом он может оказаться и константой! Если «подозреваемый» ни разу не используется в выражении косвенной адресации – никакой это не указатель, а самая настоящая константа! «Визуальная» идентификация констант и указателей. Вот несколько приемов, помогающих отличить указатели от констант. 1) В 32-разрядных Windows программах указатели могут принимать ограниченный диапазон значений. Доступный процессорам регион адресного пространства начинается со смещения 0x1.00.00 и простирается до смещения 0х80.00.00.00, а Windows 9x/Me и того меньше – от 0x40.00.00 до 0х80.00.00.00. Поэтому, все непосредственные значения, меньшие 0x1.00.00 и больше 0x80.00.00 представляют собой константы, а не указатели. Исключение составляет число ноль, обозначающее нулевой указатель. {»> сноска некоторые защитные механизмы непосредственно обращаются к коду операционной системы, расположенному выше адреса 0x80.00.00}. 2) Если непосредственное значение смахивает на указатель – посмотрите, на что он указывает. Если по данному смещению находится пролог функции или осмысленная текстовая строка – скорее всего мы имеем дело с указателем, хотя может быть, это – всего лишь совпадение. 3) Загляните в таблицу перемещаемых элементов (см. «Шаг четвертый Знакомство с отладчиком :: Способ 0 Бряк на оригинальный пароль»). Если адрес «подследственного» непосредственного значения есть в таблице – это, несомненно, указатель. Беда в том, что большинство исполняемых файлов – неперемещаемы, и такой прием актуален лишь для исследования DLL (а DLL перемещаемы по определению). К слову сказать, дизассемблер IDAPro использует все три описанных способа для автоматического опознавания указателей. Подробнее об этом рассказывается в моей книге «Образ мышления – дизассемблер IDA» (глава «Настройки», стр. 408). _Идентификация нулевых указателей. Нулевой указатель – это указатель, который ни на что не указывает. Чаще…. В языке Си/Си++ нулевые указатели выражаются константой 0, а в Паскале – ключевым словом nil, однако, внутреннее представление нулевого указателя не обязательно должно быть нулевым. _Индекс – тоже указатель! Рассмотри _16-разярднй код. не должно быть нераспознанных непосредсенных типов ==== Идентификация литералов и строк ==== Уже давно Утихло поле боя, Но сорок тысяч Воинов Китая Погибли здесь, Пожертвовав собою… Ду Фо «Оплакиваю поражение при Чэньтао» Казалось бы, что может быть сложного в идентификации строк? Если то, на что ссылается указатель (см. «Идентификация констант и смещений») выглядит как строка, - это и есть строка! Более того, в подавляющем большинстве случаев строки обнаруживаются и идентифицируются тривиальным просмотром дампа программы (при условии, конечно, что они не зашифрованы, но шифровка – тема отдельного разговора). Так-то, оно так, да не все столь просто! Задача «номер один» – автоматизированное выявление строк в программе, - ведь не пролистывать же мегабайтовые дампы вручную? Существует множество алгоритмов идентификации строк. Самый простой (но не самый надежный) основан на двух следующих тезисах: 1) строка состоит из ограниченного ассортимента символов. В грубом приближении это – цифры, буквы алфавита (включая проблел), знаки препинания и служебные символы наподобие табуляции или возврата каретки; 2) строка должна состоять по крайней мере из нескольких символов. Условимся считать минимальную длину строки равной N байтам, тогда для автоматического выявления всех строк достаточно отыскать все последовательности из N и более «строковых» символов. Весь вопрос в том, чему должна быть равна N, и какие символы включать в «строковые». Если N мало, порядка трех-четырех байт, то мы получим очень большое количество ложных срабатываний. Напротив, когда N велико, порядка шести-восьми байт, число ложных срабатываний близко к нулю и ими можно пренебречь, но все короткие строки, например «OK», «YES», «NO» окажутся нераспознаны! Другая проблема – помимо знакоцифровых символов в строках встречаются и элементы псевдографики (особенно часты они в консольных приложениях), и всякие там «мордашки», «стрелки», «карапузики» – словом почти вся таблица ASCII. Чем же тогда строка отличается от случайной последовательности байт? Частотный анализ бессилен – ему для нормальной работы требуется как минимум сотня байт текста, а мы говорим о строках из двух-трех символов! Зайдем с другого конца – если в программе есть строка, значит, на нее кто-нибудь да ссылается. А раз так – можно поискать среди непосредственных значений указатель на распознанную строку. И, если он будет найден, шансы на то, что это действительно именно строка, а не случайная последовательность байт резко возрастают. Все просто, не так ли? Просто, да не совсем! Рассмотрим следующим пример: BEGIN WriteLn('Hello, Sailor!'); END. Листинг 131 Откомпилирует его любым подходящим Pascal-компилятором (например, Delphi или FreePascal) и, загрузив откомпилированный файл в дизассемблер, пройдемся вдоль сегмента данных. Вскоре на глаза попадется следующее: .data:00404040 unk_404040 db 0Eh ; .data:00404041 db 48h ; H .data:00404042 db 65h ; e .data:00404043 db 6Ch ; l .data:00404044 db 6Ch ; l .data:00404045 db 6Fh ; o .data:00404046 db 2Ch ; , .data:00404047 db 20h ; .data:00404048 db 53h ; S .data:00404049 db 61h ; a .data:0040404A db 69h ; i .data:0040404B db 6Ch ; l .data:0040404C db 6Fh ; o .data:0040404D db 72h ; r .data:0040404E db 21h ; ! .data:0040404F db 0 ; .data:00404050 word_404050 dw 1332h Листинг 132 Вот она, искомая строка! (В том, что это строка – у нас никаких сомнений нет). Попробуем найти: кто на нее ссылается? В IDAPro для этого следует нажать <ALT-I> и в поле поиска ввести смещение начала строки – «0x404041»… Как это «ничего не найдено – SearchFailed»? А что же тогда передается функции WriteLn? Может быть, это глюк IDA? Просматриваем дизассемблерный текст вручную – результат вновь нулевой. Причина нашей неудачи в том, что в начале Pascal-строк идет байт, содержащий длину этой строки. Действительно, в дампе по смещению 0x404040 находится значение 0xE (четырнадцать в десятичной системе исчисления). А сколько символов строке «Hello, Sailor!»? Считаем: один, два, три… четырнадцать! Вновь нажимаем <ALT-I> и ищем непосредственный операнд, равный 0x404040. И, в самом деле, находим: .text:00401033 push 404040h .text:00401038 push [ebp+var_4] .text:0040103B push 0 .text:0040103D call FPC_WRITE_TEXT_SHORTSTR .text:00401042 push [ebp+var_4] .text:00401045 call FPC_WRITELN_END .text:0040104A push offset loc_40102A .text:0040104F call FPC_IOCHECK .text:00401054 call FPC_DO_EXIT .text:00401059 leave .text:0040105A retn Листинг 133 Отказывается, мало идентифицировать строку – еще, как минимум, требуется определить ее границы. Наиболее популярны следующие типы строк: Си-строки, завершающиеся нулем; DOS-строки, завершающиеся символом «$»;Pascal-строки, предваряемые одним-, двух- или четырехбайтным полем, содержащим длину строки. Рассмотрим каждый из этих типов подробнее: ::Си-строки, так же именуемые ASCIIZ-строками (от Zero– нуль на конце) – весьма распространенный тип строк, широко использующийся в операционных системах семейств Windows и UNIX. Символ «\0» (не путать с «0») имеет специальное предназначение и трактуется по-особому – как завершитель строки. Длина ASCIIZ-строк практически ничем не ограничена – ну разве что размером адресного пространства, выделенного процессу или протяженностью сегмента. Соответственно, в Windows 9x\NT максимальный размер ASCIIZ-строки лишь немногим менее 2 гигабайт, а в Windows 3.1 и MS-DOS – около 64 килобайт. Фактическая длина ASCIIZ-строк лишь на байт длиннее исходной ASCII-строки. Несмотря на перечисленные выше достоинства, Си-строкам присущи и некоторые недостатки. Во-первых, ASCIIZ-строка не может содержать нулевых байт, и поэтому, она не пригодна для обработки бинарных данных. Во-вторых, операции копирования, сравнения и контакции Си-строк сопряжены со значительными накладными расходами – современным процессорам не выгодно работать с отдельными байтами, – им желательно иметь дело с двойными словами. Но, увы, длина ASCIIZ-строк наперед неизвестна и ее приходится вычислять «на лету», проверяя каждый байт на символ завершения. Правда, разработчики некоторых компиляторов идут на хитрость – они завершают строку семьюнулями, - что позволяет работать с двойными словами, а это на порядок быстрее. Почему семью, а не четырьмя? Ведь в двойном слове байтов четыре! Да, верно, четыре, но подумайте, что произойдет, если последний значимый символ строки придется на первый байт двойного слова? Верно, его конец заполнят три нулевых байта, но двойное слово из-за вмешательства первого символа уже не будет равно нулю! Вот поэтому, следующему двойному слову надо предоставить еще четыре нулевых байта, тогда оно гарантировано будет равно нулю. Впрочем, семь служебных байт на каждую строку – это уже перебор! ::DOS-строки. В MS-DOS функция вывода строки воспринимает знак '$' как символ завершения, поэтому в программистских кулуарах такие строки называют «DOS-строками». Термин не совсем корректен – все остальные функции MS-DOS работают исключительно с ASCIIZ-строками! Причина выбора столь странного выбора символа-разделителя восходит к тем древнейшим временам, когда никакого графического интерфейса еще и в помине не существовало, а консольный терминал считался весьма продвинутой системой взаимодействия с пользователем. Клавиша <Enter> не могла служить завершителем строки, т.к. под час приходилось вводить в программу несколько строк сразу. Комбинации <Ctrl-Z>, или <Alt-000> так же не годились – на многих клавиатурах тех лет отсутствовали такие регистры! С другой стороны, компьютеры использовались главным образом для инженерных, а не бухгалтерских расчетов, и символ «бакса» был самым мало употребляемым символом – вот и решили использовать его для сигнализации о завершении пользователем ввода и как символ-завершитель строки. (Да, символ завершитель вводился пользователем, а не добавлялся программой, как это происходит с ASCIIZ-строками). В настоящее время DOS-строки практически вышли из употребления и читатель вряд ли с ними столкнется… ::Pascal-строки.Pascal-строки не имеют завершающего символа, - вместо этого они предваряются специальным полем, содержащим длину этой строки. Достоинства этого подхода: – возможность хранения любых символов в строке (в том числе и нулевых байт!) и высокая скорость обработки строковых переменных. Вместо постоянной проверки каждого байта на завершающий символ, происходит лишь одно обращение к памяти – загрузка длины строки. Ну, а раз длина строки известна, можно работать не с байтами, а двойными словами – «родным» типом данных 32-разрядных процессоров. Весь вопрос в том – сколько байт отвести под поле размера. Один? Что ж, экономно, но тогда максимальная длина строки будет ограничена 255 символами, что во многих случаях оказывается явно недостаточно! Этот тип строк используют практически все Pascal-компиляторы (например, BorlandTurboPascal, FreePascal), поэтому-то такие строки и называют «Pascal-строками» или, если более точно, «короткими Pascal-строками». ::Delphi-строки. Осознавая очевидную смехотворность ограничения длины Pascal-строк 255 символами, разработчики Delphi расширили поле размера до двух байт, увеличив, тем самым максимально возможную длину до 65.535 символов. Хотя, такой тип строк поддерживают и другие компиляторы (тот же FreePascal к примеру), в силу сложившейся традиции их принято именовать Delphi-строками или «Pascal-строками с двухбайтным полем размера – двухбайтными Pascal-строками». Ограничение в шестьдесят с гаком килобайт и «ограничением» язык назвать не поворачивается. Большинство строк имеют гораздо меньшую длину, а для обработки больших массивов данных (текстовых файлов, к примеру) если куча (динамическая память) и ряд специализированных функций. Накладные же расходы (два служебных байта на каждую строковую переменную) не столь велики, чтобы их брать в расчет. Словом, Delphi-строки, сочетая в себе лучше стороны Си- и Pascal-строк (практически неограниченную длину и высокую скорость обработки соответственно), представляются самым удобным и практичным типом. ::Wide-Pascal строки.«Широкие» Pascal-строки отводят на поле размера аж четыре байта, «ограничивая» максимально возможную длину 4.294.967.295 символами или 4 гигабайтами, что даже больше того количества памяти, которое WindowsNT\9x выделяют в «личное пользование» прикладному процессу! Однако за эту роскошь приходится дорого платить, отдавая каждой строке четыре «лишние» байта, три из которых в большинстве случаев будут попросту пустовать. Накладные расходы на коротких строках становятся весьма велики, поэтому, тип Wide-Pascal практически не используется. ::Комбинированные типы. Некоторые компиляторы используют комбинированный Си+Pascal тип, что позволяет им с одной стороны, достичь высокой скорости обработки строк и хранить в строках любые символы, а с другой – обеспечить совместимость с огромным количеством Си-библиотек, «заточенных» под ASCIIZ-строки. Каждая комбинированная строка принудительно завершается нулем, но этот нуль в саму строку не входит и штатные библиотеки (операторы) языка работают с ней как с Pascal-строкой. При вызове же функций Си-библиотек, компилятор передает им указатель не на истинное начало строки, а на первый символ строки. ::Другие завершающие символы. Рисунок 21 0х014 Осиновые типы строк ::Определение типа строк. По внешнему виду строки определить ее тип весьма затруднительно. Наличие завершающего нуля в конце строки еще не повод считать ее ASCIIZ-строкой (Pascal-компиляторы в конец строк частенько дописывают один или несколько нулей для выравнивания данных по кратным адресам), а совпадение предшествующего строке байта с ее длинной может действительно быть лишь случайным совпадением. Грубо тип строки определяется по роду компилятора (Си или Pascal), а точно – по алгоритму обработки этой строки (т.е. анализом манипулирующего с ней кода). Рассмотрим следующий пример: VAR s0, s1 : String; BEGIN s0 :='Hello, Sailor!'; s1 :='Hello, World!'; IF s0=s1 THEN WriteLN('OK') ELSE Writeln('Woozl'); END. Листинг 134 Пример, демонстрирующий идентификацию типа строк Откомпилировав его компилятором FreePascal, заглянем в сегмент данных. Там мы найдем следующую строку: .data:00404050 aHelloWorld db 0Dh,'Hello, World!',0 ; DATA XREF: _main+2B↑o Не правда ли, она очень похожа на ASCIIZ-строку? Кому не известен используемый компилятор, тому и на ум не придет, что 0xD– это поле длины, а не символ переноса! Чтобы проверить нашу гипотезу на счет типа, перейдем по перекрестной ссылке, любезно обнаруженной IDAPro, или самостоятельно найдем в дизассемблированном тексте непосредственный операнд 0x404050 (смещение строки). pushoffset _S1; Передаем указатель на строку-приемник pushoffset aHelloWorld ;«\rHello, World!» Передаем указатель на строку-источник push0FFh; Макс. длина строки callFPC_SHORTSTR_COPY Так-с, указатель на строку передается функции FPC_SHORTSTR_COPY. Из прилагаемой к FreePascal документации можно узнать, что эта функция работает с короткими Pascal - строками, стало быть, байт 0xD никакой не символ переноса, а длина строки. А чтобы мы делали, если бы у нас отсутствовала документация на FreePascal? (В самом же деле, невозможно раздобыть все-все-все компиляторы!). Кстати, штатная поставка IDAPro, вплоть до версии 4.17 включительно, не содержит сигнатур FPP-библиотек и их приходится создавать самостоятельно. В тех случаях, когда строковая функция неопознана или отсутствует ее описание, путь один – исследовать код на предмет выяснения алгоритма его работы. Ну что, засучим рукава и приступим? FPC_SHORTSTR_COPYproc near; CODE XREF: sub_401018+21p arg_0= dwordptr 8; Макс. длина строки arg_4= dwordptr 0Ch; Исходная строка arg_8= dwordptr 10h; Целевая строка pushebp movebp, esp ; Открываем кадр стека pusheax pushecx ; Сохраняем регистры cld ; Сбрасываем флаг направления ; т.е. заставляем команды LODS, STOS, MOVS инкрементировать регистр-указатель movedi, [ebp+arg_8] ; Загружаем в регистр EDI значение аргумента arg_8 (смещение целевого буфера) movesi, [ebp+arg_4] ; Загружаем в регистр ESI значение аргумента arg_4 (смещение исходной строки) xoreax, eax ; Обнуляем регистр EAX movecx, [ebp+arg_0] ; Загружаем в ECX значение аргумента arg_0 (макс. допустимая длина строки) lodsb ; Загружаем в AL первый байт исходной строки, на которую указывает регистр ESI ; и увеличиваем ESI на единицу cmpeax, ecx ; Сравниваем первый символ строки с макс. возможной длиной строки ; Уже ясно, что первой символ строки – длина, однако, притворимся, что мы ; не знаем назначения аргумента arg_0, и продолжим анализ jbeshort loc_401168 ; if (ESI[0] ⇐ arg_0) goto loc_401168 moveax, ecx ; Копируем в EAX значение ECX loc_401168:; CODE XREF: sub_401150+14j stosb ; Записываем первый байт исходной строки в целевой буфер ; и увеличиваем EDI на единицу cmpeax, 7 ; Сравниваем длину строки с константой 0x7 jlshort loc_401183 ; Длина строки меньше семи байт? ; Тогда и копируем ее побайтно! movecx, edi ; Загружаем в ECX значение указателя на целевой буфер, увеличенный на единицу ; (его увеличила команда STOSB при записи байта) negecx ; Дополняем ECX до нуля, NEG(0xFFFF) = 1; ; ECX :=1 andecx, 3 ; Оставляем в ECX три младший бита, остальные – сбрасываем ; ECX :=1 subeax, ecx ; Отнимаем от EAX (содержит первый байт строки) «кастрированный» ECX repemovsb ; Копируем ECX байт из исходной строки в целевой буфер, передвигая ESI и EDI ; В нашем случае мы копируем 1 байт movecx, eax ; Теперь ECX содержит значение первого байта строки, уменьшенное на единицу andeax, 3 ; Оставляем в EAX три младший бита, остальные – сбрасываем shrecx, 2 ; Циклическим сдвигом, делим ECX на четыре (22=4) repemovsd ; Копируем ECX двойных байтов из ESI в EDI ; Теперь становится ясно, что ECX – содержит длину строки, а, поскольку, ; в ECX загружается значение первого байта строки, можно с полной уверенностью ; сказать, что первый байт строки (причем именно, байт, а не слово) содержит ; длину этой строки ; Таким образом, это – короткая Pascal - строка ; loc_401183:; CODE XREF: sub_401150+1Cj movecx, eax ; Если длина строки менее семи байт, то EAX содержит длину строки для ее ; побайтного копирования (см. условный переход jbeshortloc_401168) ; В противном случае EAX содержит остаток «хвоста» строки, который не смог ; заполнить собой последнее двойное слово ; В общем, так или иначе, в ECX загружается количество байт для копирования repemovsb ; Копируем ECX байт из ESI в EDI popecx popeax ; Восстанавливаем регистры leave ; Закрываем кадр стека retn0Ch FPC_SHORTSTR_COPYendp Листинг 135 А теперь познакомимся с Си-строками, для чего нам пригодится следующий пример: #include <stdio.h> #include <string.h> main() { char s0[]=«Hello, World!»; char s1[]=«Hello, Sailor!»; if (strcmp(&s0[0],&s1[0])) printf(«Woozl\n»); else printf(«OK\n»); } Листинг 136 Откомпилируем его любым подходящим Си-компилятором, например, BorlandC++ 5.0 (внимание – MicrosoftVisualC++ для этой цели не подходит, см. «Turbo-инициализация строковых переменных»), и поищем наши строки в сегменте данных. Долго искать не приходится – вот они: DATA:00407074 aHelloWorld db 'Hello, World!',0 ; DATA XREF: _main+16↑o DATA:00407082 aHelloSailor db 'Hello, Sailor!',0 ; DATA XREF: _main+22↑o DATA:00407091 aWoozl db 'Woozl',0Ah,0 ; DATA XREF: _main+4F↑o DATA:00407098 aOk db 'OK',0Ah,0 ; DATA XREF: _main+5C↑o Обратите внимание: строки следуют вплотную друг к другу – каждая из них завершается символом нуля, и значение первого байта строки не совпадает с ее длиной. Несомненно, перед нами ASCIIZ-строки, однако, не мешает лишний раз убедиться в этом, тщательно проанализировав манипулирующий с ними код: _mainproc near; DATA XREF: DATA:00407044o var_20= byte ptr -20h var_10= byte ptr -10h pushebp movebp, esp ; Открываем кадр стека addesp, 0FFFFFFE0h ; Резервируем место для локальных переменных movecx, 3 ; Заносим в регистр ECX значение 0x3 leaeax, [ebp+var_10] ; Загружаем в EAX указатель на локальный буфер var_10 leaedx, [ebp+var_20] ; Загружаем в EDX указатель на локальный буфер var_20 pushesi ; Сохраняем регистр ESI ; Именно сохраняем, а не передаем функции, т.к. ESI еще не был инициализирован! pushedi ; Сохраняем регистр EDI leaedi, [ebp+var_10] ; Загружаем в EDI указатель на локальный буфер var_10 movesi, offset aHelloWorld; «Hello, World!» ; IDA распознала в непосредственном операнде смещение строки «Hello,World!» ; А если бы и не распознала – это бы сделали мы сами, основываясь на том, что: ; 1) непосредственный операнд совпадает со смещением строки ; 2) следующая команда неявно использует ESI для косвенной адресации памяти, ; следовательно, в ESI загружается указатель repemovsd ; Копируем ECX двойных слов из ESI в EDI ; Чему равно ECX? Оно равно 0x3 ; Для перевода из двойных слов в байты умножаем 0x3 на 0x4 и получаем 0xC, ; что на байт короче копируемой строки «Hello,World!», на которую указывает ESI movsw ; Копируем последний байт строки «Hello, World!» вместе с завершающим нулем leaedi, [ebp+var_20] ; Загружаем в регистр EDI указатель на локальный буфер var_20 movesi, offset aHelloSailor ; «Hello, Sailor!» ; Загружаем в регистр ESI указатель на строку «Hello, Sailor!» movecx, 3 ; Загружаем в ECX количество полных двойных слов в строке «Hello, Sailor!» repemovsd ; Копируем 0x3 двойных слова movsw ; Копируем слово movsb ; Копируем последний завершающий байт ; Функция сравнения строк loc_4010AD:; CODEXREF: _main+4Bj movcl, [eax] ; Загружаем в CL содержимое очередного байта строки «Hello, World!» cmpcl, [edx] ; CL равен содержимому очередного байта строки «Hello, Sailor!»? jnzshort loc_4010C9 ; Если символы обоих строк не равны, переходим к метке loc_4010C9 testcl, cl jzshort loc_4010D8 ; Регистр CL равен нулю? (В строке встретился нулевой символ?) ; если так, то прыгаем на loc_4010D8 ; Теперь мы можем безошибочно определить тип строки – ; во-первых, первый байт строки содержит первый символ строки, ; а не хранит ее длину, ; во-вторых, каждый байт строки проверяется на завершающий нулевой символ ; Значит, это ASCIIZ-строки! movcl, [eax+1] ; Загружаем в CL следующий символ строки «Hello, World!» cmpcl, [edx+1] ; Сравниваем его со следующим символом «Hello, Sailor!» jnzshort loc_4010C9 ; Если символы не равны – закончить сравнение addeax, 2 ; Переместить указатель строки «Hello, World!» на два символа вперед addedx, 2 ; Переместить указатель строки «Hello, Sailor!» на два символа вперед testcl, cl jnzshort loc_4010AD ; Повторять сравнение пока не будет достигнут символ-завершитель строки loc_4010C9:; CODE XREF: _main+35j_main+41j jzshort loc_4010D8 ; см. «Идентификация ifthen - else» ; Вывод строки «Woozl» pushoffset aWoozl; format call_printf popecx jmpshort loc_4010E3 loc_4010D8:; CODE XREF: _main+39j_main+4Dj ; Вывод строки «OK» pushoffset aOk; format call_printf popecx loc_4010E3:; CODE XREF: _main+5Aj xoreax, eax ; Функция возвращает ноль popedi popesi ; Восстанавливаем регистры movesp, ebp popebp ; Закрываем кадр стека retn _mainendp Листинг137 _строки одного типа Turbo-инициализация строковых переменных. Не всегда, однако, различить строки так просто. Чтобы убедиться в этом, достаточно откомпилировать предыдущий пример компилятором MicrosoftVisualC++, и заглянуть в полученный файл любым подходящим дизассемблером, скажем IDAPro. Так, переходим в секцию данных, прокручиваем ее вниз то тех пор, пока не устанет рука (а когда устанет – кирпич на PageDown!) и… Woozl! – никаких следов присутствия строк «Hello, Sailor!» и «Hello, World!». Зато обращает на себя внимание какая-то странная гряда двойных слов – смотрите: .data:00406030 dword_406030 dd 6C6C6548h ; DATA XREF: main+6↑r .data:00406034 dword_406034 dd 57202C6Fh ; DATA XREF: main +E↑r .data:00406038 dword_406038 dd 646C726Fh ; DATA XREF: main +17↑r .data:0040603C word_40603C dw 21h ; DATA XREF: main +20↑r .data:0040603E align 4 .data:00406040 dword_406040 dd 6C6C6548h ; DATA XREF: main +2A↑r .data:00406044 dword_406044 dd 53202C6Fh ; DATA XREF: main +33↑r .data:00406048 dword_406048 dd 6F6C6961h ; DATA XREF: main +3C↑r .data:0040604C word_40604C dw 2172h ; DATA XREF: main +44↑r .data:0040604E byte_40604E db 0 ; DATA XREF: main +4F↑r Чтобы это значило? Это не указатели – они никуда не указывают, это не переменные типа int – мы не объявляли таких в программе. Жмем <F4> для перехода в hex-режим и что мы видим? Вот они наши строки, вот они родимые: .data:00406030 48 65 6C 6C 6F 2C 20 57-6F 72 6C 64 21 00 00 00 «Hello, World!…» .data:00406040 48 65 6C 6C 6F 2C 20 53-61 69 6C 6F 72 21 00 00 «Hello, Sailor!..» .data:00406050 57 6F 6F 7A 6C 0A 00 00-4F 4B 0A 00 00 00 00 00 «Woozl◙..OK◙…..» Хм, почему же тогда IDAPro их посчитала двойными словами? Ответить на вопрос поможет анализ манипулирующего со строкой кода, но прежде чем приступить к его исследованию, превратим эти двойные слова в нормальную ASCIIZ - строку. (<U> для преобразования двойных слов в цепочку бестиповых байт и <A> для преобразования ее в строку). Затем подведем курсор к первой перекрестной ссылке и, нажмем <Enter>: mainproc near; CODE XREF: start+AFp var_20= byte ptr -20h var_1C= dwordptr -1Ch var_18= dwordptr -18h var_14= word ptr -14h var_12= byte ptr -12h var_10= byte ptr -10h var_C= dwordptr -0Ch var_8= dwordptr -8 var_4= word ptr -4 ; Откуда взялось столько локальных переменных?! pushebp movebp, esp ; Открываем кадр стека subesp, 20h ; Резервируем память для локальных переменных moveax, dword ptr aHelloWorld ; «Hello, World!» ; Загружаем в EAX… нет, не указатель на строку «Hello, World!», а ; четыре первых байта этой строки! Теперь понятно, почему ошиблась IDAPro ; и оригинальный код (до преобразования строки в строку) выглядел так: ; moveax, dword_406030 ; Не правда ли, не очень наглядно? И если бы, мы изучали не свою, а чужую ; программу, этот трюк дизассемблера ввел бы нас в заблуждение! movdword ptr [ebp+var_10],eax ; Копируем четыре первых байта строки в локальную переменную var_10 movecx, dword ptr aHelloWorld+4 ; Загружаем байты с четвертого по восьмой строки «Hello, World!» в ECX mov[ebp+var_C], ecx ; Копируем их в локальную переменную var_C. Но мы-то уже знаем, что это ; никакая не переменная var_C, а часть строкового буфера movedx, dword ptr aHelloWorld+8 ; Загружаем байты с восьмого по двенадцатый строки «Hello, World!» в EDX mov[ebp+var_8], edx ; Копируем их в локальную переменную var_8, точнее – в строковой буфер movax, word ptr aHelloWorld+0Ch ; Загружаем оставшийся двух-байтовый хвост строки в AX mov[ebp+var_4], ax ; Записываем его в локальную переменную var_4 ; Итак, строка копируется по частям в следующие локальные переменные: ; int var_10; int var_0C; int var_8; short int var_4 ; следовательно, на самом деле есть только одна локальная переменная – ; char var_10[14] movecx, dword ptr aHelloSailor ; «Hello, Sailor!» ; Проделываем ту же самую операцию копирования над строкой «Hello, Sailor!» movdword ptr [ebp+var_20],ecx movedx, dword ptr aHelloSailor+4 mov[ebp+var_1C], edx moveax, dword ptr aHelloSailor+8 mov[ebp+var_18], eax movcx, word ptr aHelloSailor+0Ch mov[ebp+var_14], cx movdl, byte_40604E mov[ebp+var_12], dl ; Копируем строку «Hello, Sailor!» в локальную переменную charvar_20[14] leaeax, [ebp+var_20] ; Загружаем в регистр EAX указатель на локальную переменную var_20 ; которая (как мы помним) содержит строку «Hello, Sailor!» pusheax; constchar * ; Передаем ее функции strcmp ; Из этого можно заключить, что var_20 – действительно хранит строку, ; а не значение типа int leaecx, [ebp+var_10] ; Загружаем в регистр ECX указатель на локальную переменную var_10, ; хранящую строку «Hello, World!» pushecx; constchar * ; Передаем ее функции srtcmp call_strcmp addesp, 8 ; strcmp(«Hello, World!», «Hello, Sailor!») testeax, eax jzshort loc_40107B ; Строки равны? ; Вывод на экран строки «Woozl» pushoffset aWoozl; «Woozl\n» call_printf addesp, 4 jmpshort loc_401088 ; Вывод на экран строки «OK» loc_40107B:; CODE XREF: sub_401000+6Aj pushoffset aOk; «OK\n» call_printf addesp, 4 loc_401088:; CODE XREF: sub_401000+79j movesp, ebp popebp ; Закрываем кадр стека retn mainendp Листинг138 _о поддержке строк IDA _«\r\n\a\v\b\t\x1B» « !\»#$%&'()*+,-./0123456789:;⇔?« »@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_« «`abcdefghijklmnopqrstuvwxyz{|}~» «АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ» «абвгдежзийклмноп░▒▓│┤╡╢╖╕╣║╗╝╜╛┐» «└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀» «рстуфхцчшщъыьэюя»; _обработка строк операторами и функцими _строки фиксированной длины _паскль пихает строки в сегмента кода ==== Идентификация if – then – else ==== Значение каждого элемента текста определяется контекстом его употребления. Текст не описывает мир, а вступает в сложные взаимоотношения с миром. Тезис аналитической философии Существует два вида алгоритмов – безусловные и условные. Порядок действий безусловного алгоритма всегда постоянен и не зависит от входных данных. Например: «a=b+c». Порядок действий условных алгоритмов, напротив, зависит от входных данных. Например: «еслиc не равно нулю, то: a=b/c; иначе: вывести сообщение об ошибке». Обратите внимание на выделенные жирным шрифтом ключевые слова «если», «то» и «иначе», называемые операторами условия или условными операторами. Без них не обходится ни одна программа (вырожденные примеры наподобие «Hello, World!» – не в счет). Условные операторы – сердце любого языка программирования. Поэтому, чрезвычайно важно уметь их правильно идентифицировать. В общем виде (не углубляясь в синтаксические подробности отдельных языков программирования) оператор условия схематично изображается так: IF (условие) THEN { оператор1; оператор2;} ELSE { операторa; операторb;} Задача компилятора – преобразовать эту конструкцию в последовательность машинных команд, выполняющих оператор1, оператор2, если условие истинно и, соответственно - операторa, операторb; если оно ложно. Однако микропроцессоры серии 80×86 поддерживают весьма скромный набор условных команд, ограниченный фактически одними условными переходами (касательно исключений см. »Оптимизация ветвлений«). Программистам, знакомым лишь с IBMPC, такое ограничение не покажется чем-то неестественным, между тем, существует масса процессоров, поддерживающих префикс условного выполнения инструкции. Т.е. вместо того, чтобы писать: «TESTECX,ECX/JNZxxx/MOVEAX,0x666», там поступают так: «TEST ECX,ECX/IFZ MOV EAX,0x666». «IFZ» – и есть префикс условного выполнения, разрешающий выполнение следующей команды только в том случае, если установлен флаг нуля. В этом смысле микропроцессоры 80×86 можно сравнить с ранними диалектами языка Бейсика, не разрешающими использовать в условных выражениях никакой другой оператор кроме «GOTO». Сравните: IF A=B THEN PRINT «A=B»10 IF A=B THEN GOTO 30 20 GOTO 40 30 PRINT «A=B» 40 … прочий код программы Листинг 139Новый диалект «Бейсика»Старый диалект «Бейсика» Если вы когда-нибудь программировали на старых диалектах Бейсика, то, вероятно, помните, что гораздо выгоднее выполнять GOTO если условие ложно, а в противном случае продолжать нормальное выполнение программы. (Как видите, вопреки расхожему мнению, навыки программирования на Бейсике отнюдь не бесполезны, особенно – в дизассемблировании программ). Большинство компиляторов (даже не оптимизирующих) инвертируют истинность условия, транслируя конструкцию «IF (условие) THEN {оператор1; оператор2}» в следующий псевдокод: IF (NOTусловие) THENcontinue оператор1; оператор2; continue: … Листинг 140 Следовательно, для восстановления исходного текста программы, нам придется вновь инвертировать условие и «подцепить» блок операторов {оператор1; оператор2} к ключевому слову THEN. Т.е. если откомпилированный код выглядит так: 10 IF A<>B THEN 30 20 PRINT «A=B» 30 … прочий код программы Листинг 141 Можно с уверенностью утверждать, что в исходном тексте присутствовали следующие строки: «IFA=BTHENPRINT «A=B»». А если, программист, наоборот, проверял переменные A и B на неравенство, т.е. «IFA<>BTHENPRINT «A<>B»»? Все равно компилятор инвертирует истинность условия и сгенерирует следующий код: 10 IF A=B THEN 30 20 PRINT «A<>B» 30 … прочий код программы Листинг 142 Конечно, встречаются и дебильные компиляторы, страдающие многословием. Их легко распознать по безусловному переходу, следующему сразу же после условного оператора: IF (условие) THENdo GOTO continue do: оператор1; оператор2; continue: Листинг 143 В таком случае инвертировать условие не нужно. Впрочем, если это сделать, ничего страшного не произойдет, разве что код программы станет менее понятным, да и то не всегда. Рассмотрим теперь как транслируется полная конструкция «IF (условие) THEN { оператор1; оператор2;} ELSE { операторa; операторb;}». Одни компиляторы поступают так: IF (условие) THEN do_it Ветка ELSE операторa; операторb GOTO continue do_it: Ветка IF оператор1; оператор2; continue: А другие так: IF (NOTусловие) THEN else Ветка IF оператор1; оператор2; GOTO continue else: Ветка ELSE операторa; операторb continue: Листинг 144 Разница межу ними в том, что вторые инвертируют истинность условия, а первые – нет. Поэтому, не зная «нрава» компилятора, определить: как выглядел подлинный исходный текст программы – невозможно! Однако это не создает проблем, ибо условие всегда можно записать так, как это удобно. Допустим, не нравится вам конструкция «IF (c<>0) THENa=b/cELSEPRINT «Ошибка!»» пишите ее так: «IF (c==0) THENPRINT «Ошибка!» ELSEa=b/c» и – ни каких гвоздей! Типы условий: Условия делятся на простые (элементарные) и сложные (составные). Примерпервых – «if (a==b)…», вторых «if 11)…». Очевидно, что любое сложное условие можно разложить на ряд простых условий. Вот с простых условий мы и начнем. Существуют два основных типа элементарных условий: условия отношенийменьше«, »равно«, »больше«, »меньше или равно«, »не равно«, »больше или равно«, соответственно обозначаемые как: »<«, »==«, »>«, »«, »!=«, »>=«) и логические условия («И», «ИЛИ», «НЕ», «И исключающее ИЛИ», в Си-нотации соответственно обозначаемые так: »&«, »|«, »!«, »^«). Известный хакерский авторитет Мэтт Питрек приплетает сюда и проверку битов, однако несколько некорректно смешивать в одну кучу людей и коней, даже если они чем-то и взаимосвязаны. Поэтому, о битовых операциях мы поговорим отдельно в одноименной главе. Если условие истинно, оно возвращает булево значение TRUE, соответственно, если ложно – FALSE. Внутренне (физическое) представление булевых переменных зависит от конкретной реализации и может быть любым. По общепринятому соглашению, FALSEравно нулю, а TRUEне равно нулю. Часто (но не всегда) TRUE равно единице, но на это нельзя полагаться! Так, код «IF 12)…». При трансляции программы компилятор всегда выполняют развертку составных условий в простые. В данном случае это происходит так: «IFa==bTHENIFa=0 THEN…» На втором этапе выполняется замена условных операторов на оператор GOTO: IF a!=b THEN continue IF a==0 THEN continue … код условия :continue … прочий код Листинг 145 Порядок вычисления элементарных условий в сложном выражении зависит от прихотей компилятора, гарантируется лишь, что условия, «связанные» операцией логического «И» проверяются слева направо в порядке их объявления в программе. Причем, если первое условие ложно, то следующее за ним вычислено не будет! Это дает возможность писать код наподобие следующего: «if 13))…« – если указатель filename указывает на невыделенную область памяти (т.е. попросту говоря содержит нуль – логическое FALSE), функция fopen не вызывается и ее краха не происходит. Такой способ вычислений получил название »быстрых булевых операций« (теперь-то вы знаете, что подразумевается под «быстротой»). Перейдем теперь к вопросу идентификации логических условий и анализу сложных выражений. Вернемся к уже облюбованному нами выражению «if 14)…» и вглядимся в результат его трансляции: IF a!=b THEN continue ——-! IF a==0 THEN continue —! ! … код условия ! ! :continue ←-! ←– … прочий код Листинг 146 Легко видеть – он выдает себя серией условных переходов к одной и той же метке, причем, - обратите внимание, - выполняется проверка на неравенство каждого из элементарных условий, а сама метка расположена позади кода условия. Идентификация логической операции «ИЛИ» намного сложнее в силу неоднозначности ее трансляции. Рассмотрим это на примере выражения «if 15)…». Его можно разбить на элементарные операции и так: IF a==b THEN do_it ———–! IF a!=0 THEN do_it ––-! ! goto continue –––! ! ! :do_it ! ←-! ←—-! … код условия ! :continue ←! … прочий код Листинг 147 итак: IF a==b THEN do_it ———–! IF a==0 THEN continue–! ! :do_it ! ←—-! … кодусловия ! :continue ←———-! … прочий код Листинг 148 Первый вариант обладает весьма запоминающийся внешностью – серия проверок (без инверсии условия) на одну и ту же метку, расположенную перед кодом условия, а в конце этой серии – безусловный переход на метку, расположенную позади кода условия. Однако оптимизирующие компиляторы выкидывают безусловный переход, инвертируя проверку последнего условия в цепочке и, соответственно меняя адрес перехода. По неопытности эту конструкцию часто принимают за смесь OR и AND. Кстати, о смещенных операциях – рассмотрим результат трансляции следующего выражения: «if 16)…»: IF a==b THEN check_null IF a!=c THEN continue check_null: IF a==0 THEN continue … код условия continue: … прочий код Листинг 149 Как из непроходимого леса элементарных условий получить одно удобочитаемое составное условие? Начинаем плясать от печки, т.е. от первой операции сравнения. Смотрите, если условие a==b окажется истинно, оно «выводит из игры» проверку условия a!=c. Такая конструкция характерна для операции OR – т.е. достаточно выполнения хотя бы одного условия из двух для «срабатывания» кода. Пишем в уме или карандашом: «if 17)». Ура! У нас получилось! Впрочем, как любил поговаривать Дмитрий Николаевич, не обольщайтесь – то, что мы рассмотрели – это простейший пример. В реальной жизни оптимизирующие компиляторы такого понаворочают…. _Впрочем, для ломания головы вполне хватит и не оптимизирующих, но прежде, чем перейти к изучению конкретных реализаций, рассмотрим на последок две «редкоземельные» операции NOT и XOR. NOT – одноместная операция, поэтому, она не может использоваться для связывания, однако, Наглядное представление сложных условий в виде дерева. Конструкцию, состоящую из трех – четырех элементарных условий, можно проанализировать и в уме (да и то, если есть соответствующие навыки), но хитросплетения пяти и более условий образуют самый настоящий лабиринт – его с лету не возьмешь. Неоднозначность трансляции сложных условий порождает неоднозначность интерпретации, что приводит к многовариантному анализу, причем с каждым шагом в голове приходится держать все больше и больше информации. Так недолго и крышей поехать или окончательно запутаться и получить неверных результат. Выход – в использовании двухуровневой системы ретрансляции. На первом этапе элементарные условия преобразуются к некоторой промежуточной форме записи, наглядно и непротиворечиво отображающей взаимосвязь элементарных операций. Затем осуществляется окончательная трансляция в любую подходящую нотацию (например, Си, Бейсик или Pascal). Единственная проблема – выбрать удачную промежуточную форму. Существует множество решений, но в книге по соображениям экономии бумажного пространства, мы рассмотрим только одно – деревья. Изобразим каждое элементарное условие в виде узла, с двумя ветвями, соответствующим состояниям: условие истинно и условие ложно. Для наглядности обозначим «ложь» равнобедренным треугольником, а «истину» – квадратом и условимся всегда располагать ложь на левой, а истину на правой ветке. Получившуюся конструкцию назовем »гнездом« (nest). Рисунок 22 0х015 Схематическое представление гнезда (nest). Гнезда могут объединяться в деревья, соединясь узлами с ветками другого узла. Причем, каждый узел может соединяться только с одним гнездом, но всякое гнездо может соединяться с несколькими узлами. Непонятно? Не волнуйтесь, сейчас со всем этим мы самым внимательным образом разберемся. Рассмотрим объединение двух элементарных условий логической операцией «AND» на примере выражения »18)«. Извлекаем первое слева условие (a==b), «усаживаем» его в гнездо с двумя ветвями: левая соответствует случаю, когда a!=b (т.е. условие a==b – ложно), а правая, соответственно, – наоборот. Затем, то же самое делаем и со вторым условием (a!=0). У нас получаются два очень симпатичных гнездышка, – остается лишь связать их меж собой операцией логического «AND». Как известно, «AND» выполняет второе условие только в том случае, если истинно первое. Значит, гнездо (a!=0) следует прицепить к правой ветке гнезда (a==b). Тогда – правая ветка гнезда (a!=0) будет соответствовать истинности выражения »19)«, а обе левые ветки – его ложности. Обозначим первую ситуацию меткой «do_it», а вторую – «continue». В результате дерево должно принять вид, изображенный на рис. 23. Для наглядности отметим маршрут из вершины дерева к метке «do_it» жирной красной стрелкой. Как видите, в пункт «do_it» можно попасть только одним путем. Вот так графически выглядит операция «AND». Рисунок 23 0х016 Графическое представление операции AND в виде двоичного дерева. Обратите внимание – в пункт do_it можно попасть только одним путем! Перейдем теперь к операции логического «OR». Рассмотрим конструкцию »20)«. Если условие »(a==b)« истинно, то и все выражение считается истинным. Следовательно, правая ветка гнезда »(a==b)« связана с меткой «do_it». Если же условие же »(a==b)« ложно, то выполняется проверка следующего условия. Значит, левая ветка гнезда »(a==b)« связана с гнездом »(a!=b)«. Очевидно, если условие »(a!=b)« истинно, то истинно и все выражение »21)«, напротив, если условие »(a!=b)« ложно, то ложно и все выражение, т.к. проверка условия »(a!=b)« выполняется только в том случае, если условие »(a==b)« ложно. Отсюда мы заключаем, что левая ветка гнезда »(a!=b)« связана с меткой «continue», а правая – с «do_it». (см. рис. 24). Обратите внимание – в пункт «do_it» можно попасть двумя различными путями! Вот так графически выглядит операция «OR». Рисунок 24 0х017 Графическое представление операции OR в виде двоичного дерева. Обратите внимание – в пункт do_it можно попасть двумя различными путями! До сих пор мы отображали логические операции на деревья, но ведь деревья создавались как раз для противоположной цели – преобразованию последовательности элементарных условий к интуитивно понятному представлению. Займемся этим? Пусть в тексте программы встретится следующий код: IF a==b THEN check_null IF a!=c THEN continue check_null: IF a==0 THEN continue … код условия continue: … прочий код Листинг 150 Извлекаем условие (a==b) и сажаем его в «гнездо», - смотрим: если оно ложно, то выполняется проверка (a!=c), значит, гнездо (a!=c) связано с левой веткой гнезда (a==b). Если же условие (a==b) истинно, то управление передается метке check_null, проверяющей истинность условия (a==0), следовательно, гнездо (a==0) связано с правой веткой гнезда (a==b). В свою очередь, если условие (a!=с) истинно, управление получает метка «continue», в противном случае – «check_null». Значит, гнездо (a!=0) связано одновременно и с правой веткой гнезда (a==b) и с левой веткой гнезда (a!=c). Конечно, это проще рисовать, чем описывать! Если вы все правильно зарисовали, у вас должно получится дерево очень похожее на изображенное на рисунке 25. Смотрите: к гнезду »(a==0)« можно попасть двумя путями – либо через гнездо (a==b), либо через цепочку двух гнезд (a==b)  (a!=c). Следовательно, эти гнезда связаны операцией OR. Записываем: «if ( (a==b) || !(a!=c)….)». Откуда взялся NOT? Так ведь гнездо (a==0) связано с левой веткой гнезда (a!=с), т.е. проверяется ложность его истинности! (Кстати, «ложность истинности» – очень хорошо звучит). Избавляемся от NOT, инвертируя условие: «if ( (a==b) || (a==c)….)…». Далее – из гнезда (a==0) до пункта do_it можно добраться только одним путем, значит, оно связано операцией AND. Записываем: «if 22) && !(a==0))…». Теперь избавляемся от лишних скобок и операции NOT. В результате получается: «if 23) { Код условия}» Не правда ли все просто? Причем вовсе необязательно строить деревья вручную, - при желании можно написать программу, берущую эту работу на себя. Рисунок 25 0х018 Графическое представление сложного выражения Исследование конкретных реализаций. Прежде чем приступать к отображению конструкции «IF (сложное условие) THEN оператор1:оперратор2ELSE оператора:операторb» на машинный язык, вспомним, что, во-первых, агрегат «IF – THEN – ELSE» можно выразить через «IF – THEN», во-вторых, «THENоператор1:оперратор2» можно выразить через «THENGOTOdo_it», в-третьих, любое сложное условие можно свести к последовательности элементарных условий отношения. Таким образом, на низком уровне мы будем иметь дело лишь с конструкциями «IF(простое условие отношения) THENGOTOdo_it», а уже из них, как из кирпичиков, можно сложить что угодно. Итак, условия отношения, или другими словами, результат операции сравнения двух чисел. В микропроцессорах Intel 80×86 сравнение целочисленных значений осуществляется командой CMP, а вещественных – одной из следующих инструкций сопроцессора: FCOM, FCOMP, FCOMPP, FCOMI, FCOMIP, FUCOMI, FUCOMIP. Предполагается, что читатель уже знаком с языком ассемблера, поэтому не будем подробно останавливаться на этих инструкциях и рассмотрим их лишь вкратце. ::CMP. Команда CMPэквивалентна операции целочисленного вычитания SUB, за одним исключением – в отличие от SUB, CMP не изменяет операндов, а воздействует лишь на флаги основного процессора: флаг нуля, флаг переноса, флаг знака и флаг переполнения. Флаг нуля устанавливается в единицу, если результат вычитания равен нулю, т.е. операнды равны друг другу. Флаг переноса устанавливается в единицу, если в процессе вычитания произошел заем из старшего бита уменьшаемого операнда, т.е. уменьшаемое меньше вычитаемого. Флаг знака равен старшему – знаковому – биту результата вычислений, т.е. результат вычислений – отрицательное число. Флаг переполнения устанавливается в единицу, если в результате вычислений «залез» в старший бит, приводя к потере знака числа. Для проверки состояния флагов существует множество команд условных переходов, выполняющихся в случае, если определенный флаг (набор флагов) установлен (сброшен). Инструкции, использующиеся для анализа результата сравнения целых чисел, перечислены в таблице 16. В общем случае конструкция «IF (элементарное условие отношения) THENdo_it» транслируется в следующие команды процессора: CMPA,B Jxxdo_it continue: Однакошаблон «CMP/Jxx» не Между инструкциями «CMP» и «Jxx» могут находиться и другие команды, не изменяющие флагов процессора, например «MOV», «LEA». синонимы |условие|состояние флагов|инструкция||| | ::: | ::: | Zeroflag| Carry Flag| Sing Flag| ::: | ::: | ::: | |a == b|1|?|?|JZ|JE| | |a != b|0|?|?|JNZ|JNE| | |a < b|беззнаковое|?|1|?|JC|JB|JNAE| | ::: |знаковое|?|?|!=OF|JL|JNGE| | |a > b|беззнаковое|0|0|?|JA|JNBE| | | ::: |знаковое|0|?|==OF|JG|JNLE| | |a >=b |беззнаковое|?|0|?|JAE|JNB|JNC| | ::: |знаковое|?|?|==OF|JGE|JNL| | |a ⇐ b|беззнаковое|(ZF == 1) || (CF == 1)|?|JBE|JNA| | | ::: |знаковое|1|?|!=OF|JLE|JNG| | Таблица 16 Соответствие операций отношения командам процессора ::сравнение вещественных чисел. Команды сравнения вещественных чисел FCOMxx (см. таблицу 18) в отличие от команд целочисленного сравнения воздействуют на регистры сопроцессора, а не основного процессора. На первый взгляд – логично, но весь камень преткновения в том, что инструкций условного перехода, управляемых флагами сопроцессора, не существует! К тому же, флаги сопроцессора непосредственно недоступны, - чтобы прочитать их статус необходимо выгрузить регистр состояния сопроцессора SW в память или регистр общего назначения основного процессора. Хуже всего – анализировать флаги вручную! Если при сравнении целых чисел можно и не задумываться: какими именно флагами управляется условный переход, достаточно написать, скажем: «CMPA,B; JGEdo_it». (»Jump [if] Great [or] Equal« – прыжок, если A больше или равно B), то теперь этот номер не пройдет! Правда, можно схитрить и скопировать флаги сопроцессора в регистр флагов основного процессора, а затем использовать «родные» инструкции условного перехода из серии Jxx. Конечно, непосредственно скопировать флаги из сопроцессора в основной процессор нельзя и эту операцию приходится осуществлять в два этапа. Сначала флаги FPU выгружать в память или регистр общего назначения, а уже оттуда заталкивать в регистр флагов CPU. Непосредственно модифицировать регистр флагов CPU умеет только одна команда – POPF. Остается только выяснить – каким флагам сопроцессора, какие флаги процессора соответствуют. И вот что удивительно – флаги 8й, 10й и 14й сопроцессора совпадают с 0ым, 2ым и 6ым флагами процессора – CF, PF и ZF соответственно (см. таблицу 17). То есть – старшей байт регистра флагов сопроцессора можно безо всяких преобразований затолкать в младший байт регистра флагов процессора и это будет работать, но… при этом исказятся 1й, 3й и 5й биты флагов CPU, никак не используемые в текущих версиях процессора, но зарезервированные на будущее. Менять значение зарезервированных битов нельзя! Кто знает, вдруг завтра один из них будут отвечать за самоуничтожение процессора? Шутка, конечно, но в ней есть своя доля истины. К счастью, никаких сложных манипуляций нам проделывать не придется – разработчики процессора предусмотрели специальную команду – SAHF, копирующую 8й, 10й, 12й, 14й и 15й бит регистра AX в 0й, 2й, 4й, 6й и 7й бит регистра флагов CPU соответственно. Сверяясь по таблице 17 мы видим, что 7й бит регистра флагов CPU содержит флаг знака, а соответствующий ему флаг FPU – признак занятости сопроцессора! Отсюда следует, что для анализа результата сравнения вещественных чисел использовать знаковые условные переходы(JL, JG, JLE, JNL, JNLE, JGE, JNGE) нельзя! Они работают с флагами знака и переполнения, – естественно, если вместо флага знака им подсовывают флаг занятости сопроцессора, а флаг переполнения оставляют в «подвешенном» состоянии, условный переход будет срабатывать не так, как вам бы этого хотелось! Применяйте лишь беззнаковые инструкции перехода – JE, JB, JA и др. (см. таблицу 16) Разумеется, это не означает, что сравнивать знаковые вещественные значения нельзя, - можно, еще как! Но для анализа результатов сравнения обязательно всегда использовать только беззнаковые условные переходы! |CPU|7|6|5|4|3|2|1|0| | ::: |SF|ZF|–|AF|–|PC|–|CF| |FPU|15|14|13|12|11|10|9|8| | ::: |Busy!| C3(ZF)|TOP| C2(PF)|C1| C0(CF)| Таблица 17 Соответствие флагов CPU и FPU Таким образом, вещественная конструкция «IF (элементарное условие отношения) THENdo_it» транслируется в одну из двух следующих последовательностей инструкций процессора: fld[a]fld[a] fcomp[b]fcomp[b] fnstswaxfnstswax sahftestah, bit_mask jxxdo_itjnzdo_it Листинг 151 Первый вариант более нагляден, зато второй работает быстрее. Однако, такой код (из всех известных мне компиляторов) умеет генерировать один лишь MicrosoftVisualC++. BorlandC++ и хваленый WATCOMC испытывают неопределимую тягу к инструкции SAHF, чем вызывают небольшие тормоза, но чрезвычайно упрощают анализ кода, - ибо, встретив команду наподобие JNA, мы и спросонок скажем, что переход выполняется когда a ⇐ b, а вот проверка битвой маски «TESTAH, 0x41/JNZdo_it» заставит нас крепко задуматься или машинально потянуться к справочнику за разъяснениями (см. таблицу 16) Команды семейства FUCOMIxx в этом смысле гораздо удобнее в обращении, т.к. возвращают результат сравнения непосредственно в регистры основного процессора, но – увы – их «понимает» только PentiumPro, а в более ранних микропроцессорах они отсутствуют. Поэтому, вряд ли читателю доведется встретиться с ними в реальных программах, так что не имеет никакого смысла останавливаться на этом вопросе. Во всяком случае, всегда можно обратится к странице 3-112 руководства «Instruction Set Reference», где эти команды подробно описаны. |инструкция|назначение|результат| |FCOM|Сравнивает вещественное значение, находящееся на вершине стека сопроцессора, с операндом, находящимся в памяти или стеке FPU|флаги FPU| |FCOMP|То же самое, что и FCOM, но с выталкиванием вещественного значения с вершины стека| ::: | |FCOMPP|Сравнивает два вещественных значения, лежащих на вершине стека сопроцессора, затем выталкивает их из стека| ::: | |FCOMI|Сравнивает вещественное значение, находящееся на вершине стека сопроцессора с другим вещественным значением, находящимся в стеке FPU|флаги CPU| |FCOMIP|Сравнивает вещественное значение, находящееся на вершине стека сопроцессора с другим вещественным значением, находящимся в стеке FPU, затем выталкивает верхнее значение из стека| ::: | |FUCOMI|Неупорядоченно сравнивает вещественное значение, находящееся на вершине стека сопроцессора с другим вещественным значением, находящимся в стеке FPU| ::: | |FUCOMIP|Неупорядоченно сравнивает вещественное значение, находящееся на вершине стека сопроцессора с другим вещественным значением, находящимся в стеке FPU, затем выталкивает верхнее значение из стека| ::: | Таблица 18 Команды сравнения вещественных значений |флаги FPU|назначение|битовая маска| |OE|Флаг переполнения | OverfullFlag| #0x0008| | C0|Флаг переноса|Carry Flag|#0x0100| |C1|—| |#0x0200| |C2|Флагчетности|Partite Flag| #0x0400| | C3|Флаг нуля|Zero Flag|#0x4000| Таблица 19 Назначение и битовые маски флагов сопроцессора |отношение|состояние флагов FPU|SAHF|битовая маска| |a<b|C0 == 1|JB|#0x0100 == 1| |a>b|C0 == 0|C3 == 0|JNBE|#0x4100 == 0| |a==b|C3 == 1|JZ|#0x4000 == 1| |a!=b|C3 == 0|JNZ|#0x4000 == 0| |a>=b|C0 == 0|JNB|#0x0100 == 0| |a⇐b|C0 == 1|C3 === 1|JNA|#0x4100 == 1| Таблица 20 Состояние регистров флагов для различных операций отношения. 'a' – левый, а 'b' правый операнд команды сравнения вещественных значений |компилятор|алгоритм анализа флагов FPU| | BorlandC++|копирует флаги сопроцессора в регистр флагов основного процессора| |Microsoft Visual C++|тест битовой маски| | WATCOMC|копирует флаги сопроцессора в регистр флагов основного процессора| | FreePascal|копирует флаги сопроцессора в регистр флагов основного процессора| Таблица 21 «Характер» некоторых компиляторов Условные команды булевой установки. Начиная с 80386 чипа, язык микропроцессоров Intel обогатился командой условной установки байта – SETxx, устанавливающей свой единственный операнд в единицу (булево TRUE), если условие «xx» равно и, соответственно, сбрасывающую его в нуль (булево FALSE), если условие «xx» – ложно. Команда «SETxx» широко используются оптимизирующими компиляторами для устранения ветвлений, т.е. избавления от условных переходов, т.к. последние очищают конвейер процессора, чем серьезно снижают производительность программы. Подробнее об этом рассказывается в главе »Оптимизация ветвлений«, здесь же мы не будем останавливаться на этом сложном вопросе. (см. там же »Булевы сравнения« и »Идентификация условного оператора (условие)?do_it:continue«). |команда|отношение|условие| |SETA|SETNBE| |a>b|беззнаковое| CF == 0 && ZF == 0| |SETG|SETNLE| |знаковое|ZF == 0 && SF == OF| |SETAE|SETNC|SETNB| a>=b|беззнаковое|CF == 0| |SETGE|SETNL| |знаковое| SF == OF| |SETB|SETC|SETNAE| a<b|беззнаковое|CF == 1| |SETL|SETNGE| |знаковое| SF != OF| |SETBE|SETNA| |a⇐b|беззнаковое| CF == 1 || ZF == 1| |SETLE|SETNG| |знаковое|ZF == 1 || SF != OF| |SETE|SETZ| |a==b|–––|ZF == 1| |SETNE|SETNZ| |a!=0|–––| ZF == 0| Таблица 22 Условные команды булевой установки Прочие условные команды. Микропроцессоры серии 80×86 поддерживают множество условных команд, в общем случае не отображающихся на операции отношения, а потому и редко использующиеся компиляторами (можно даже сказать – вообще не использующиеся), но зато часто встречающиеся в ассемблерных вставках. Словом, они заслуживают хотя бы беглого упоминания. ::Команды условного перехода. Помимо описанных в таблице 16, существует еще восемь других условных переходов – JCXZ, JECXZ, JO, JNO, JP (он же JPE), JNP (он же JPO), JS и JNS. Из них только JCXZ и JECXZ имеют непосредственное отношение к операциям сравнения. Оптимизирующие компиляторы могут заменять конструкцию «CMP [E]CX, 0\JZdo_it» на более короткий эквивалент «J[E]CXdo_it», однако, чаще всего они (в силу ограниченности интеллекта и лени своих разработчиков) этого не делают. Условные переходы JO и JNS используются в основном в математических библиотеках для обработки чисел большой разрядности (например, 1024 битых целых). Условные переходы JS и JNS помимо основного своего предназначения часто используются для быстрой проверки значения старшего бита. Условные переходы JP и JNP вообще практически не используются, ну разве что в экзотичных ассемблерных вставках. |команда|переход, если…|флаги| |JCXZ|регистр CX равен нулю| CX == 0| |JECXZ|регистр ECX равен нулю| ECX == 0| |JO|переполнение| OF == 1| |JNO|нет переполнения| OF == 0| |JP|JPE|число бит младшего байта результата четно|PF == 1| |JNP|JPO|число бит младшего байта результата нечетно| PF == 0| |JS|знаковый бит установлен| SF == 1| |JNS|знаковый бит сброшен| S