второй. Знакомство с дизассемблером
Надо ли милостивого бога все время просить о пощаде?
Велимир
О'кей, пароль мы узнали. Но как же утомительно вводить его каждый раз с клавиатуры перед запуском программы! Хорошо бы ее хакнуть так, чтобы никакой пароль вообще не запрашивался или любой введенный пароль программа воспринимала бы как правильный.
Хакнуть говорите?! Что ж, это не сложно! Куда проблематичнее определиться – чем именно ее хакать. Инструментарий хакеров чрезвычайно разнообразен – чего тут только нет: и дизассемблеры, и отладчики, и API-, и message- шпионы, и мониторы обращений к файлам (портам, реестру), и распаковщики исполняемых файлов, и… Попробуй-ка, начинающему кодокопателю со всем этих хозяйством разобраться!
Впрочем, шпионы, мониторы, распаковщики – второстепенные утилиты заднего плана, а основное оружие взломщика – отладчик и дизассемблер. Рассмотрим их поближе.
Как и следует из его названия, диз-ассемблер, предназначен для диз-ассемблирования или "раз-ассемблирования" если перейти с латыни на русский {ДИС…, ДИЗ… [лат. dis, ге. dys] – приставка, обозначающая разделение отделение, отрицание; соответствует русским "раз…", "не…", сообщает понятию, к которому прилагается, отрицательный или противоположный смысл, напр. дизассоциация, дисгармония – "словарь иностранных слов"}. То есть если ассемблирование – перевод ассемблерных команд в машинный код, то дизассемблирование, напротив, перевод машинного кода в ассемблерные команды.
Но пусть название не вводит вас в заблуждение: дизассемблер пригоден для изучения не только тех программ, что были написаны на ассемблере, – круг его применения очень широк, хотя и не безграничен. Спрашиваете – где же пролегает эта граница? Отвечаю.
Грубо говоря, все реализации языков программирования делятся на компиляторы и интерпретаторы.
::Интерпретаторы исполняют программу в том виде, в каком она была набрана программистом. Другими словами говоря – интерпретаторы "пережевывают" исходный текст, при этом код программы доступен для непосредственного изучения безо всяких дополнительных средств.
Примером могут служить приложения, написанные на Бацике или Перле. Как известно, для их запуска требуется помимо исходного текста программы требуется иметь еще и сам интерпретатор, что неудобно ни пользователям (для исполнения программы в 10 килобайт приходится устанавливать интерпретатор в 10 мегабайт), ни разработчикам (в здравом уме и трезвой памяти раздавать всем исходные тексты своей программы!), к тому же синтаксический разбор отнимает много времени и ни один интерпретатор не может похвастаться производительностью.
::Компиляторы
ведут себя иначе – при первом запуске они "перемалывают" программу в машинный код, исполняемый непосредственно самим процессором без обращений к исходным текстам или самому компилятору. С человеческой точки зрения человека откомпилированная программа представляет бессмысленную мешанину шестнадцатеричных байт, разобраться в которой неспециалисту абсолютно невозможно. Это облегчает разработку защитных механизмов – не зная алгоритма, вслепую защиту не сломаешь, ну разве что она будет совсем простая.
Можно ли из машинного кода получить исходный текст программы? Нет,!
Компиляция – процесс однонаправленный. И дело тут не только в том, что безвозвратно удаляются метки и комментарии (ррразберемся и без комментариев – хакеры мы или нет?!), основной камень преткновения – неоднозначность соответствия машинных инструкций конструкциям языков высокого уровня. Более того, ассемблирование так же являет собой однонаправленный процесс и автоматическое дизассемблирование принципиально невозможно. Впрочем, не будем сейчас забивать голову начинающих кодокопателей такими тонкостями и оставим эту проблему на потом.
::Ряд систем разработки занимает промежуточное положение между компиляторами и интерпретаторами, – исходная программа преобразуется не в машинный код, а в некоторый другой интерпретируемый язык, для исполнения которого к "откомпилированному" файлу дописывается собственный интерпретатор. Именно по такой схеме функционируют FoxPro, Clipper, многочисленные диалекты Бацика и некоторые другие языки.
Да, код программы по-прежнему исполняется в режиме интерпретации, но теперь из него удалена вся избыточная информация – метки, имена переменных, комментарии, а осмысленные названия операторов заменены их цифровыми кодами. Этот "выстрел" укладывает сразу двух зайцев: а) язык, на который переведена программа, заранее "заточен" под быструю интерпретацию и оптимизирован по размеру; б) код программы теперь недоступен для непосредственного изучения (и/или модификации).
Дизассемблирование таких программ невозможно – дизассемблер нацелен именно на машинный код, а неизвестный ему интерпретируемый язык (так же называемый p-кодом) он "не переваривает". Разумеется, p-код не переваривает и процессор!,
Его исполняет интерпретатор, дописанный к программе. Вот интерпретатор-то дизассемблер и "возьмет"! Изучая алгоритм его работы, можно понять "устройство" p-кода и выяснить назначение всех его команд. Это очень трудоемкий процесс! Интерпретаторы порой так сложны и занимают столько много мегабайт, что их анализ растягивается на многие месяцы, а то и годы. К счастью, нет нужны анализировать каждую программу – ведь интерпретаторы одной версии идентичны, а сам p-код обычно мало меняется от версии к версии, во всяком случае его ядро не переписывается каждый день. Поэтому, вполне возможно создать программу, занимающуюся переводом p-кода обратно в исходный язык. Конечно, символьные имена восстановить не удастся, но в остальном листинг будет выглядеть вполне читабельно.
Итак, дизассемблер применим для исследования откомпилированных программ и частично пригоден для анализа "псевдокомпилированного" кода. Раз так – он должен подойти для вскрытия парольной защиты simple.exe. Весь вопрос в том, – какой дизассемблер выбрать.
Не все дизассемблеры одинаковы. Есть среди них и "интеллектуалы", автоматически распознающие многие конструкции как-то: прологи и эпилоги функций, локальные переменные, перекрестные ссылки и т.д., а есть и "простаки" чьи способности ограничены одним лишь переводом машинных команд в ассемблерные инструкции.
Логичнее всего воспользоваться услугами дизассемблера - интеллектуала (если он есть), но… давайте не будем спешить, а попробуем выполнить весь анализ вручную. Техника, понятное дело, –
штука хорошая, на то она и придумана, чтобы решать проблемы, а не создавать новые, да вот только не всегда она оказывается под рукой и неплохо бы заранее научиться работе "в полевых условиях". на том, что всегда есть под рукой. К тому же, общение с плохим дизассемблером как нельзя лучше подчеркивает "вкусности" хорошего.
Воспользуемся уже знакомой нам утилитой DUMPBIN, настоящим "Швейцарским ножиком" со множеством полезных функций, среди которых притаился и дизассемблер. Дизассемблируем секцию кода (как мы помним, носящую имя ".text"), перенаправив вывод в файл, т.к. на экран он, очевидно, не помститься.
> dumpbin /SECTION:.text /DISASM simple.exe
>.code
Так, менее чем через секунду образовался файл ".code" с размером… с размером в целых триста с четвертью килобайт. Да исходная программа была на два порядка короче! Это же сколько времени потребуется, чтобы со всей этой шаманской грамотой разобраться?! Самое обидное – подавляющая масса кода никакого отношения к защитному механизму не имеет и представляет собой функции стандартных библиотек компилятора, анализировать которые нам ни к чему. Но как же их отличить от "полезного" кода?
Давайте подумаем. Мы не знаем, где именно расположена процедура сравнения паролей и нам неизвестно ее устройство, но можно с уверенностью утверждать, что один из ее аргументов – указатель на эталонный пароль. Остается только выяснить – по какому адресу расположен этот пароль в памяти – он-то и будет искомым значением указателя.
Заглянем еще раз в секцию данных (или в другую – в зависимости от того,
где хранится пароль):
> dumpbin /SECTION:.data /RAWDATA simple.exe >.data
RAW DATA #3
00406000: 00 00 00 00 00 00 00 00 00 00 00 00 7B 11 40 00 ............{.@.
00406010: E4 40 40 00 00 00 00 00 00 00 00 00 20 12 40 00 ф@@......... .@.
00406020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00406030: 45 6E 74 65 72 20 70 61 73 73 77 6F 72 64 3A 00 Enter password:.
00406040: 6D 79 47 4F 4F 44 70 61 73 73 77 6F 72 64 0A 00 myGOODpassword..
^^^^^^^^^ ^^^^^^^^^^^^^^^
00406050: 57 72 6F 6E 67 20 70 61 73 73 77 6F 72 64 0A 00 Wrong password..
00406060: 50 61 73 73 77 6F 72 64 20 4F 4B 0A 00 00 00 00 Password OK.....
Ага, пароль расположен по смещению 0x406040
(левая колонка чисел), стало быть и указатель на него равен 0x406040. Попробуем найти это число в дизассемблированном листинге тривиальным контекстным поиском в любом текстовом редакторе.
Нашли? Вот оно (в тексте выделено жирным шрифтом):
00401045: 68 40 60 40 00 push 406040h
0040104A: 8D 55 98 lea edx,[ebp-68h]
0040104D: 52 push edx
0040104E: E8 4D 00 00 00 call 004010A0
00401053: 83 C4 08 add esp,8
00401056: 85 C0 test eax,eax
00401058: 74 0F je 00401069
Это один из двух аргументов функции 0х04010A0,
заносимых в стек машинной командой push. Второй аргумент – указатель на локальный буфер, вероятно, содержащий введенный пользователем пароль.
Тут нам придется немного отклониться от темы разговора и подробно рассмотреть передачу параметров. Наиболее распространенны всего два следующие способаы
передачи аргументов функции – через регистры и через стек.
Передача параметров через регистры наиболее быстра, но не лишена недостатков – во-первых, количество регистров весьма ограничено, а во-вторых, это затрудняет реализацию рекурсии – вызова функции из самой себя. Прежде чем заносить в регистры новые аргументы, необходимо предварительно сохранить старые в оперативной памяти. А раз так – не проще ли сразу передать аргументы через оперативную память, не мучаясь с регистрами?
Подавляющее большинство компиляторов передает аргументы через стек. Единого мнения по вопросам передачи у разработчиков компиляторов нет и встречаются по крайней мере два различных механизма, именуемые соглашениями "Си" и "Паскаль".
::Си-соглашение
предписывает заталкивать в стек аргументы справа на лево, т.е. самый первый аргумент функции заносится в стек последним и оказывается на его верхушке. Удаление аргументов из стека возложено не на саму функцию, а на вызываемый ее код. Это довольно расточительное решение, т.к. каждый вызов функции утяжеляет программу на несколько байт кода, но зато оно это позволяет создавать функции с переменным числом аргументов – ведь удаляет-то их из стека не сама функция, а вызывающий ее код, который наверняка знает точное количество переданных аргументов.
Очистка стека обычно выполняется командой "ADD ESP,xxx" – где 'xxx'
количество удаляемых байт. Поскольку, в 32-разрядном режиме каждый аргумент, как правило, занимает четыре байта, количество аргументов функции вычисляется так: . Оптимизирующие компиляторы могут использовать более хитрый код – для очистки стека от нескольких аргументов они частенько из "выталкивают" в неиспользуемые регистры командой "POP" или и вовсе очищают стек не сразу же после выхода из функции, а совсем в другом месте – где это удобнее компилятору.
::Паскаль-соглашение предписывает заносить аргументы в стек слева на право, т.е. самый первый аргумент функции заносится в стек в первую очередь и оказывается в самом его "низу". Удаление аргументов из функции возложено на саму функцию, и обычно осуществляется командой "RET xxx" – т.е. возврат из подпрограммы со снятием xxx байт со стека.
Возвращаемое функцией значение в обоих соглашениях передается через регистр EAX (или EDX:EAX при возвращении 64-разрядных переменных).
Поскольку, исследуемая нами программа написана на Си и, стало быть, заносит аргументы справа налево, ее исходный текст выглядел приблизительно так:
(*0x4010A0) (ebp-68, "myGOODpassword")
В том, что аргументов именно два, а не, скажем, четные или десять, нас убеждает команда "ADD ESP,8", расположенная вслед за CALL.
0040104E: E8 4D 00 00 00 call 004010A0
00401053: 83 C4 08 add esp,8
Остается выяснить назначение функции 0x4010A0, хотя… если поднапрячь свою интуицию этого можно и не делать.!
И так ясно – это функция сравнивает пароль, иначе, зачем бы ей его передавали? Как она это делает – вопрос десятый, а вот что нас действительно интересует – возвращенное ею значение. Так, опускаемся на одну строчку ниже:
0040104E: E8 4D 00 00 00 call 004010A0
00401053: 83 C4 08 add esp,8
00401056: 85 C0 test eax,eax
00401058: 74 0F je 00401069
Что мы видим? Команда TEST EAX,EAX проверяет возвращенное функцией значение на равенство нулю, и если оно действительно равно нулю следующая за ней команда JE совершает прыжок на 0x401096
строку.
В противном же случае (т.е. если EAX !=0)…
0040105A: 68 50 60 40 00 push 406050h
Похоже еще на один указатель. Не правда ли? Проверим это предположение, заглянув в сегмент данных:
00406050: 57 72 6F 6E 67 20 70 61 73 73 77 6F 72 64 0A 00 Wrong password..
Уже теплее! Указатель вывел нас на строку "Wrong password", очевидно выводимую следующей функцией на экран. Значит, ненулевое значение EAX свидетельствует о ложном пароле, а нуль – об истинном.
О'кей, тогда переходим к анализу валидной ветви программы…
0040105F: E8 D0 01 00 00 call 00401234
00401064: 83 C4 04 add esp,4
00401067: EB 02 jmp 0040106B
00401069: EB 16 jmp 00401081
…
00401081: 68 60 60 40 00 push 406060h
00401086: E8 A9 01 00 00 call 00401234
Так еще, один указатель.Ну, а с функцией 0x401234 мы уже встречались выше – она (предположительно) служит для вывода строк на экран. Ну а сами строки можно отыскать в сегменте данных. На этот раз там притаилась "Password OK"
Оперативные соображения следующие: если заменить команду JE на JNE, то программа отвергнет истинный пароль, как неправильный, а любой неправильный пароль воспримет как истинный. А если заменить "TEST EAX,EAX" на "XOR EAX,EAX", то после исполнения этой команды регистр EAX будет всегда
равен нулю, какой бы пароль не вводился.
Дело за малым – найти эти самые байтики в исполняемом файле и малость поправить их.