Анализ приложения защищенного виртуальной машиной
В данной статье будет рассмотрено построение защиты приложения с использованием различных программных «трюков» таких как: сброс точки входа в ноль, шифрование тела файла и декриптор накрытый мусорным полиморфом, сокрытие логики исполнения алгоритма приложения в теле виртуальной машины.К сожалению, статья будет достаточно тяжелая для обычного прикладного программиста, не интересующегося тематикой защиты ПО, но тут уж ничего не поделать.
Для более или менее адекватного восприятия статьи потребуется минимальные знания ассемблера (его будет много), а так-же навыков работы с отладчиком.
Но и тем, кто надеется что здесь будут даны какие-то простые шаги по реализации такого типа защиты, придется разочароваться. В статье будет рассмотрен уже реализованный функционал, но… с точки зрения его взлома и полного реверса алгоритма.
Основные цели, которые я ставил перед собой, это дать общее понятие как вообще работает такая защита ПО, но самое главное — как к этому будет подходить человек, который будет снимать вашу защиту, ибо есть старое правило — нельзя реализовать грамотный алгоритм ядра защиты, не представляя себе методы его анализа и взлома.
В качестве реципиента, по совету одного достаточно компетентного товарища, я выбрал немножко старый (но не потерявший актуальности, в силу качества исполнения) keygenme от небезызвестного Ms-Rem.
Вот первоначальная ссылка, где он появился: http://exelab.ru/f/index.php? action=vthread&forum=1&topic=4732А потом он попал вот сюда: http://www.crackmes.de/users/ms_rem/keygenme_by_ms_rem/Где данному keygenme был выставлена сложность 8 из 10 (*VERY VERY* hard).Хотя, если честно, это слегка завышенная оценка — я бы поставил в районе 5–6 баллов.
Пожалуй, начнем.
0. Требования По хорошему, для полноценной отладки данного keygenme, самым удобной площадкой будет Windows XP 32 бита, она вообще является самой оптимальной средой, поэтому постоянно развернута у меня на рабочей станции в виде виртуалки.Под Windows 7 — 32 бита (на которой собственно и производилась отладка в процессе написания данной статьи) будут небольшие затруднения, но они решаемые (об этом будет упоминание в четвертой главе статьи).
На 64 битных OC начнутся серьезные трудности ввиду того, что используемый в качестве основного инструмент (OllyDebug) при отладке данного Keygenme будет выдавать ошибки еще на этапе работы загрузчика. Этими ошибками не будет сыпать OllyDebug версии 2, но здесь есть еще одно затруднение, для нее пока что нет необходимых плагинов (а может я плохо искал).
Сам keygenme необходимо скачать по этой ссылке: http://exelab.ru/f/files/3635_03.05.2006_CRACKLAB.rU.tgz (потребуется регистрация).
Для полноценной работы с текстом статьи, если вы решите самостоятельно пройти все шаги, описанные в ней, вам потребуется небольшой набор инструментов.Я не буду здесь подробно расписывать про них, все что нужно сделать описано в файле «used_tools.txt», размещенном в корне архива с примерами к статье: http://rouse.drkb.ru/blog/vm_analize.zip
Еще необходимо запомнить правильную пару логина и серийного номера, предоставленную по самой первой ссылке, а именно «Ms-Rem» и «C38FB7A0CF38F73B1159». Эти данные очень сильно помогут в процессе разбора keygenme.
Как только все будет установлено — можно начинать :)
1. Первичный анализ Для начала стоит определиться, с чем именно мы имеем дело.Запускаем PEiD и открываем в нем keygenme.exe.Нажимаем самую правую нижнюю кнопку и в меню выберем тип сканирования «Hardcore Scan». Сразу начинаются неприятности, во первых точка входа «Entrypoint» выставлена в ноль, что в нормальном исполняемом файле быть не может, во вторых сканирование показало что присутствует «UPolyX v0.5 *».Второе как раз не страшно — вот было бы там что-то наподобие «EXECryptor» или «Themida» — какой-то из коммерческих протекторов, тогда да, а тут просто видимо нашлась какая-то подходящая сигнатура.
Нажимаем вторую справа нижнюю кнопку и в появившемся диалоге три кнопки справа.
Говорит что файл упакован и энтропия аж 7.56.Ну допустим, хотя это еще ни о чем не говорит. Большая энтропия бывает не только у запакованных, но и у зашифрованных файлов.Закрываем диалог и щелкаем на кнопку справа от «Subsystem:»
Помимо точки входа убиты базы кода и данных, база загрузки стандартная 4000000.Ну что же, ладно — на руках у нас файл который немного поправили ручками.Попробуем пощупать все это в отладчике.2. Анализируем поведение приложения при Entrypoint = 0 Открываем OllyDebug, заходим в меню «Options», там выбираем «Debugging options» и на вкладке «Events» выставляем галку «Make first pause at: → System breakpoint».Таким образом мы заставим отладчик прерваться при получении первого отладочного сообщения до передачи управления в тело отлаживаемого приложения.Это делается из-за скинутой в ноль точки входа.Открываем сам keygenme.exe и сразу прерываемся где-то внутри ntdll.dll
Что есть точка входа (для приложения) — это смещение от его базы загрузки (hInstance), на которое загрузчик передает управления сразу после инициализации процесса.База загрузки всегда содержит в себе PE заголовок, где самой первой идет структура _IMAGE_DOS_HEADER.
Т.к. точка входа у keygenme равна нулю, значит управление будет передано непосредственно на его hInstance.
Зная это, давайте посмотрим что там у нас находится.Нажимаем «Ctrl+G» и вбиваем адрес базы »400000», должно получится как-то так:
Вполне себе приличный код вместо стандартного заголовка, но заголовок должен быть на месте, иначе приложение не запустилось бы, значит были внесены правки непосредственно в _IMAGE_DOS_HEADER.Смотрим что именно поменялось:
_IMAGE_DOS_HEADER = record { DOS .EXE header } e_magic: Word; { Magic number } e_cblp: Word; { Bytes on last page of file } e_cp: Word; { Pages in file } e_crlc: Word; { Relocations } e_cparhdr: Word; { Size of header in paragraphs } e_minalloc: Word; { Minimum extra paragraphs needed } Поле e_magic — его трогать нельзя и оно всегда должно содержать инициалы Марка Збиковски 'MZ' (0×4D, 0×5A).Собственно оно и не тронуто, а оба этих символа трактуются как инструкции: DEC EBP // уменьшаем указатель стекового фрейма POP EDX // читаем значение со стека в регистр EDX Значение второго поля e_cblp изменено на 0×45, 0×52, что в результате отменяет изменения сделанные первыми двумя инструкциями, восстанавливая правильное состояние стека.Остальные 4 поля используются для реализации команд MOV + JMP.Вот на этой картинке это показано более наглядно.
Весь смысл таких манипуляций с _IMAGE_DOS_HEADER и сброшенной точкой входа, это передача управления куда-то внутрь тела приложения по адресу 4053B6.Т.е. в принципе мы можем уже прямо сейчас открыть keygenme.exe и в соответствующем поле в качестве точки входа указать 53B6 (игнорируя правки в заголовке файла), но правильная ли это точка входа?
3. Разбираем код декриптора тела приложения и распаковываем приложение Идем по адресу перехода «Ctrl+G» 4056B6 и там видим вот такое: Вообще сплошной мусор. Что ни строчка, то мусорная инструкция.К примеру все операторы условных переходов (JG/JPE/JCXZ/JE) являются полным мусором, т.к. не важно выполнится ли условие или нет, переход всегда будет осуществлен на следующую строчку (обратите внимание на адреса прыжков).Инструкции LEA, MOV, XCNG работают с одним и тем-же регистром не внося никаких изменений в его состояние — мусор.Инструкции работы с матсопроцессором (FCLEX/FFREE) сбрасывают исключения (которых нет, т.к. работа с матсопроцессором еще не проводилась) освобождают регистры (которые собственно и не заняты) — мусор.Пролистаем код до конца, чтобы посмотреть, где эта каша из мусора заканчивается.Просто скролим вниз, пока не доберемся до кода, состоящего из одних нулей:
Ага, а вот похоже и нужный нам адрес 401000, на который идет прыжок, который теоретически может являться оригинальной точкой входа.Давайте посмотрим что там:
А там у нас код, которого явно не должно быть в Win32 приложении, о чем явно говорят инструкции IN и OUT, которые сгенерируют исключение при их выполнении.Значит получается, что код на оригинальной точке входа (OEP — Original Entry Point) зашифрован и код в процедуре 53B6 должен его расшифровать перед тем как выполнить финальный прыжок.
Но!!! Но в процедуре 53B6, как было показано ранее, мусор.
На самом деле там должен быть не только мусор. По всей видимости мы имеем дело с так называемым мусорным полиморфиком, причем в самой простейшей его реализации.
Задача полиморфного движка преобразовать изначальный код заменой оригинальных инструкций на их аналоги (или группы аналогов). С целью затруднения анализа результирующего кода как правило добавляются мусорные блоки инструкций.Здесь же блоков не наблюдается, генерируются просто мусорные инструкции, плюс в итоге даже если и была замена инструкций на аналоги, то я заметил это только в одном случае. Вполне возможно что тут был применен просто генератор мусора, обильно напихавший его между полезными инструкциями, как знать…
Впрочем, появилась задача — надо среди всего этого мусора от адреса 4053B6 по 406839 (5251 байт — однако) найти полезные инструкции, которые осуществляют декрипт тела приложения.
Сделать это можно двумя способами.Первый — просмотреть весь код глазками и попытаться найти такие инструкции. Я даже ради интереса попробовал и потратил около 7 минут, в результате даже нашел две таких инструкции, не являющихся мусором. Правда, как оказалось в последствии, одну между ними пропустил, да и после второй найденной дальше искать как-то расхотелось — слишком уж утомительное занятие:)
Поэтому пойдем вторым путем, и напишем небольшой скрипт, который поможет убрать весь мусор и оставит только полезную нагрузку.
Сам скрипт расположен в архиве, идущем со статьей по следующему пути:».\scripts\fill_trash_by_nop.txt».Для его запуска должен быть установлен плагин OllyScript.
Запускается скрипт так: необходимо перезапустить keygenme в отладчике и дождаться срабатывания первого ВР внутри NTDLL, после чего в меню «Plugins» выбрать пункт «ODbgScript→Run Script…», в диалоге выбрать файл со скриптом (путь указан выше) и запустить его.
Как только скрипт начнет свою работу можно сходить приготовить себе чай, минут пять свободного времени у вас будет.
Логика работы скрипта проста: Т.к. мусорные инструкции не изменяют значений регистров (за исключением EIP), то детектирование мусорной инструкции происходит сверкой состояния регистров до и после ее выполнения, если регистры изменились — инструкция выполняет что-то полезное, в противном случае инструкция считается мусорной и вместо нее размещается NOP.
Когда скрипт завершит свою работу и выведет сообщение, можно просмотреть результаты его работы (сам отладчик не останавливайте — он еще нужен).
Весь мусор будет заменен на NOP и на руках у нас останутся только следующие инструкции (нужно пробежаться от 4053B6 по 406839 и выписать в блокнотик все что не NOP): Первыми двумя строчками будет немного мусора (вызывается sleep с нулевой задержкой).
0040548B PUSH 0 0040548D CALL DWORD PTR DS:[<&kernel32.Sleep>] ; kernel32.Sleep Ну точнее как — это не совсем мусор, эта строчка заставляет загрузчик при старте процесса подгружать kernel32.dll в адресное пространство процесса, в пару к уже загруженной ntdll.dll, т.к. эта библиотека заявлена в таблице импорта keygenme (как раз в виде одной единственной функции sleep).Далее пойдет сам код декриптора:
004054B4 MOV ESI, keygenme.00401000 // в ESI помещаем указатель на зашифрованный буфер 0040559D MOV EDI, ESI // в EDI на результирующий // они равны т.е. расшиврованные данные помещаются туда же 00405677 MOV ECX,1058 // устанавливаем количество итераций цикла. // Всего расшифруется 16736 байт, // т.к. зачитываем блоками по 4 байта ($1058×4) 004057FE LODS DWORD PTR DS:[ESI] // читаем 4 байта 00405904 NEG EAX // умножаем на -1 00405B69 NOT EAX // выполняем операцию NOT, // в результате просто уменьшаем EAX на 1 // (NEG + NOT = DEC) 00405D3A BSWAP EAX // инвертируем байты 00405E90 SUB EAX,4FE62125 // отнимаем 0×4FE62125 00406121 XOR EAX,12345 // ксорим на 0×12345 00406256 STOS DWORD PTR ES:[EDI] // результат помещаем обратно 00406442 DEC ECX // уменьшаем значение счетчика итераций 004065C8 JNZ keygenme.004057D9 // переходим в начало цикла (на инструкцию 004057FE LODS) и непосредственно переход на OEP на котором сейчас остановился отладчик. 00406839 JMP keygenme.00401000 Грубо говоря, если посмотреть инструкции декриптора, тело keygenme расшифровывается вот таким простым алгоритмом: uses Classes, Winsock; var I, A: Integer; M: TMemoryStream; begin M:= TMemoryStream.Create; try M.LoadFromFile ('keygenme.exe'); M.Position:= 512; // после выравнивания позиция данных байтов будет $1000 for I:= 0 to $1058 — 1 do begin M.ReadBuffer (A, 4); // LODS Dec (A); // NEG + NOT A:= htonl (A); // BSWAP Dec (A, $4FE62125); // SUB A:= A xor $12345; // XOR M.Position:= M.Position — 4; M.WriteBuffer (A, 4); // STOS end; M.SaveToFile ('keygenme.exe'); finally M.Free; end; end. Теперь, чтобы каждый раз не ждать расшифровки файла, необходимо сдампить полученный результат (должен быть установлен плагин OllyDump).Для этого необходимо перейти на OEP («Ctrl+G» 401000) и поставить там брякпойнт, после чего продолжить выполнение программы.Как только отладчик остановится на установленном ВР, идем в меню «Plugins», там выбираем «OllyDump→Dump debugged process», откроется вот такой диалог:
Ориентируясь на колонку «Virtual Offset» выставляем базу кода равную 1000, а базу данных равную 7000, снимаем галку «Rebuil import» и нажимаем кнопку «Dump».В появившемся диалоге указываем новое имя «keygen_unpacked.exe».Собственно все — вот мы и сняли первый конверт.
Небольшая хитрость:
Вообще сдампить можно было гораздо проще, без рассмотрения исходного кода декриптора и прочего, но раз уж я решил рассматривать все досконально, поэтому на нем тоже нужно было остановиться.
Второй вариант распаковки выглядит следующим образом.1. Запускаем отладчик и ждем срабатывания первого ВР внутри NTDLL.2. Переходим на вкладку карты памяти «Alt+M» и на адресе 401000 ставим MBP на запись:
3. Запускаем программу на выполнение, как только прервались на операции записи (это будет инструкция STOS DWORD), опять идем на карту памяти и снимаем MBP, после чего идем на OEP (401000) и там ставим обычный брякпойнт.4. Ну, а как только прервемся на нем — нужно выполнить уже описанные шаги по дампу процесса.Кстати, если хотите, можете проверить получившийся файл под PEiD, энтропия волшебным образом стала 6.95 —, а всего-то просто расшифровали блок данных.
4. Первичный анализ распакованного файла и обход проблемы запуска под Vista и выше. Теперь будем работать с уже распакованным файлом.Так как точка входа в нем выставлена правильная, то, чтобы не совершать лишних телодвижений, нужно настроить Olly сделать первую остановку уже не в NTDLL, а непосредственно на точке входа.Открываем OllyDebug, заходим в меню «Options», там выбираем «Debugging options» и на вкладке «Events» выставляем галку «Make first pause at: → Entry point of main module».Открываем keygenme_unpacked.exe и смотрим во что превратился ранее зашифрованный код:
Сразу видим первую «неприятность», первый же вызов CALL идет внутрь самого себя (вызывается адрес 401004, в то время как следующая инструкция начинается только с 4010005).О таких прыжках я уже рассказывал ранее в этой статье: «Изучаем отладчик, часть вторая»
Суть такого трюка — запутать дизассемблер и заставить его отобразить не тот код, который будет выполнятся на самом деле. Ничего страшного в таком «трюке нет», просто нажимаем F7 выполняя этот CALL и сразу видим правильный код:
Можно прямо сейчас еще раз сдампить процесс + убрать инструкцию POP EBX для отключения такого «фокуса», но т.к. мешать он не будет — оставим как есть и начинаем анализировать.Сперва идет блок из пяти инструкций, зачитывающий некие данные из PEB (Process Environment Block), адрес которого всегда расположен в FS:[$30].Если открыть структуру PEB и посмотреть что означают показанные в коде оффсеты, то получим на руки примерно следующее:
0040100A MOV EAX, DWORD PTR FS:[30] // получаем указатель на PEB 00401010 MOV EAX, DWORD PTR DS:[EAX+C] // читаем значение на PEB→LoaderData 00401013 MOV EAX, DWORD PTR DS:[EAX+1C] // читаем LoaderData→InInitOrder 00401016 MOV EAX, DWORD PTR DS:[EAX] // переходим на структуру _LDR_DATA_TABLE_ENTRY 00401018 MOV EAX, DWORD PTR DS:[EAX+8] // читаем поле DllBase Таким образом эти пять инструкций ищут hInstance «kernel32.dll», которая будет располагаться по данному адресу, правда под Vista и выше по данному адресу будет расположен hInstance «kernelbase.dll» и с этим будет связана одна неприятная ошибочка.Инструкция LEA ESI, помещает в ESI указатель на небольшой массив из Ansi строк, размещенных по адресу 004012DE. Это три строки, разделенные нулями: «LoadLibraryA», «ExitProcess» и «VirtualAlloc».
Кстати, совершенно забыл об этом упомянуть ранее, если вы посмотрите на таблицу импорта keygenme.exe то увидите что он импортирует одну единственную функцию kernel32.sleep, остальные отсутствуют. Значит адреса остальных, необходимых для работы, приложение должно найти самостоятельно.
Следующая инструкция LEA EDI, помещает в EDI указатель на буфер, в который будет помещаться адреса найденных функций (это будет виртуальная таблица импорта для kernel32), после чего происходит вызов процедуры по адресу 401198.
На самом деле оба вызова LEA EDI/ESI являются мусором, т.к. эти регистры перезатрутся при вызове процедуры по адресу 401198, но использоваться в ней они будут как раз таким образом, как я описал выше (EDI, точнее EBP+305, в итоге будет содержать адреса функций).
Вкратце, задача процедуры 401198 подготовить регистр ESI, в котором помещается указатель на имя искомой функции, а также регистр EDI в котором размещается указатель на таблицу экспорта библиотеки, hInstance которой мы получили, считав данные из PEB, после чего вызвать функцию по адресу 4011E2, которая и будет производить поиск по имени.
И вот тут-то нас ждет вторая неприятность, гораздо более серьезная чем трюк с CALL в самом начале.Самой первой будет искаться «LoadLibraryA», которую «kernelbase.dll» не экспортирует.Это означает то, что под Windows Vista и выше данный keygenme работать не будет и будет падать при старте.
Можете проверить, исключение поднимется вот тут:
004011EB CMPS BYTE PTR DS:[ESI], BYTE PTR ES:[EDI] Обойти это достаточно просто, достаточно после старта keygenme, поставить ВР на инструкцию: 0040101B LEA ESI, DWORD PTR SS:[EBP+2DE] т.е. сразу после получения адреса загрузки библиотеки, и подменить значение в регистре EAX на hInstance библиотеки «kernel32.dll» (правильный адрес можно подсмотреть в карте памяти процесса Alt+M).После таких манипуляций keygenme запустится штатным образом.
Чтобы такого не делать каждый раз при старте приложения, достаточно будет запустить скрипт из папки ».\scripts\run_at_vista.txt», который будет в автоматическом режиме каждый раз при старте подменять значение EAX на правильное и запускать программу без ошибок.
5. Чтение логина и серийного номера Теперь пришло время посмотреть, каким образом производится чтение логина и серийного номера в память приложения и какие модификации над ними производятся.Обычно чтение данных из EDIT происходит посредством функции «GetWindowsText» или «GetDlgItemText», но т.к. вторая функция в итоге все равно вызывает первую, то ставить брякпойнт мы будем именно на «GetWindowsText».
Для этого, после того как keygenme запустился (а также подгрузилась библиотека user32.dll) и появилось его диалоговое окно, переходим в отладчик и ищем все доступные функции во всех модулях:
В появившемся диалоге ищем имя экспортируемой функции «GetWindowsTextA», и в меню выбираем пункт «Follow in Disassembler»: После этого ставим ВР, переходим в диалог с keygenme и в соответствующие поля вбиваем известные нам логин «Ms-Rem» и серийный номер «C38FB7A0CF38F73B1159». Нажимаем кнопку «Check» и… останавливаемся на бряке внутри user32 как раз на начале функции «GetWindowsTextA».Теперь необходимо вернуться к месту ее вызова.
Нажимаем:1. Ctrl+Shift+F9 — прыгая в конец функции «GetWindowsTextA»2. F8 — выходим наверх в функцию «GetDlgItemText»3. Ctrl+Shift+F9 — прыгая в конец функции «GetDlgItemText»4. F8 — выходим наверх в место вызова
В синей рамке содержится только что произведенный вызов «GetDlgItemText», со следующими параметрами: hDlg = ESInIDDlgItem = 65lpString = EAXnMaxCount = $10
Это мы прочитали значение логина в буфер, на который указывал регистр EAX размером в 16 байт, а в красной рамке выделен вызов чтения серийного номера в буфер, на который будет указывать EDI (EBP+414E) размером в 32 байта.
Самое интересное начинается сразу после чтения серийного номера.За ним идет интересный цикл из 10 итераций, при этом регистр ESI указывает на буфер с только что считанным серийным номером, а EDI на буфер, куда помещается результат:
00401111 MOV ECX,0A // выставляем счетчик цикла 00401116 LODS WORD PTR DS:[ESI] // читаем два символа серийного номера 00401118 CALL keygenme.0040117B // вызываем функцию 0040111D STOS BYTE PTR ES:[EDI] // сохраняем 1 байт 0040111E LOOPD SHORT keygenme.00401116 // переход на следующую итерацию Т.е. над серийным номером в функции 40117B производятся какие то преобразования, результат которых помещается в EDI.Данная функция выглядит следующим образом:
Это что-то типа преобразования двух символов из строкового HEX представление в байт.Если грубо то это аналог Result:= StrToInt ('$' + Value); Возьмем к примеру изначальный известный серийный номер: «C38FB7A0CF38F73B1159«После 10 итераций он будет преобразован в массив с таким содержимым:
var sn: array [0…9] of Byte = ($C3, $8F, $B7, $A0, $CF, $38, $F7, $3B, $11, $59); Но т.к. в функции не производится проверки на границы, за которые не должны выходить HEX значения в строковом формате, то есть очень большой диапазон допустимых значений, которые после такого приведения дадут одно и то же число.К примеру что «C3» что «s1» в результате такого преобразования будут равны 195 (или $C3). Поэтому вот это тоже будет вполне себе валидным серийным номером: «s18FB7A0CF38F73B1159».
Таким образом делаем вывод: введенный логин зачитывается как есть, а серийный номер после чтения преобразовывается в байтовое представление, причем, т.к. итераций всего 10, то длина серийного номера не должна превышать 20 HEX символов (остальное не будет учитываться).
В принципе эта вся информация, которая нам потребуется, теперь пришло время посмотреть на исходный код keygenme немного под другим углом.
6. Анализируем keygenme под IDA Pro, разбираем VM и получаем ее PiCode Запускаем IDA Pro и открываем в нем keygenme_unpacked.exe, сразу после открытия идем на вкладку «Functions».Всего-то 8 штук:
Причем практически все нам известные:401000 — это OEP, не интересно401096 —, а это похоже изначальная точка входа, которая была до того, как навесили всякое там шифрование и создание виртуальных IAT, но, впрочем, теперь она нам уже не нужна.
40117B — это HexToInt, видели…401198 — заполнение виртуальной IAT, видели…4011E2 — поиск адреса функции по имени, видели…sub_401204 — что-то интересное (судя по графу), с ней пожалуй и начнем, кстати она вызывает две оставшиеся функции sub_401257 и sub_401276.
А вызовы функций чтения из EDIT-ов по адресам 004010F5 и 00401107, а также цикла по адресу 00401111, IDA Pro не распознала как процедуры из-за мусорных инструкций идущих перед ними (да и не столь важно).
Итак, смотрим граф функции sub_401204:
Видели когда нибудь как выглядит граф VM в IDA? Если нет, то смотрите — это и есть тело виртуальной машины, причем достаточно простой.Что есть виртуальная машина? Грубо… обычный процессор выполняет набор известных ему инструкций (машкод, который можно дизассемблировать).Виртуальная машина — по сути это тоже процессор, только выполняет она свой набор инструкций, которые генерируются и размещаются где-то в доступной ей области памяти в виде так называемого PiCode.Совсем не обязательно что эти инструкции совпадут с теми, что может исполнить реальный процессор (точнее наоборот — в большинстве случаев они как раз не будут совпадать).
В процессе защиты, коммерческие протекторы дизассемблируют защищаемые блоки кода, генерируют для каждого из них свою виртуальную машину с уникальным набором логики и набором инструкций, переводят дизассемблированный код в пикод, который может выполнить конкретная виртуальная машина и сохраняют результат в виде буфера где-то в теле приложения. Каждая VM, при получении управления в процессе выполнения программы, последовательно зачитывает инструкции пикода, предназначенные только для нее, и исполняет их.
Таким образом от взломщика прячется логика защищаемого алгоритма, которую он мог бы разобрать под отладчиком. После таких модификаций придется сначала проанализировать каждую VM, и только после ее полного анализа вытащить алгоритм из исполняемого ей пикода, преобразовав его обратно в понятный обычному процессору машкод. Работенка та еще…
На картинке выше мы видим как раз одну из реализаций VM, которая может выполнить всего 8 известных ей инструкций.
Давайте рассмотрим поподробнее:
Все начинается с инициализации регистра EBX, который указывает на начало буфера с пикодом для виртуальной машины, а так-же регистра EAX, который является курсором (индексом исполняемой инструкции).Работа VM начинается с процедуры loc_40120D.
Ее задача, сначала получить опкод выполняемой инструкции, вызовом функции sub_401276, код которой приведен в хинте.
Судя по этому коду можно понять, что сам пикод тоже зашифрован и сразу после считывания каждого байта происходит его декрипт примерно вот таким алгоритмом:
var A, B: Byte; … A:= PicodeBuff[I]; B:= A; B:= B shr 4; A:= A xor B; Result:= A and 7; После получения опкода происходит его проверка, если он равен нулю, то передается управление вот сюда: Где просто прибавляется единица какой-то переменной (назовем ее как есть arg_4)И в итоге дальше управление перелается на финализирующий блок, который запускает исполнение следующего опкода, инкрементируя регистр EAX.
Причем в нем же мы можем узнать общий размер пикода, он равен $3DA2 (15778 байт).Итак, разберем все инструкции по порядку:
0. (loc_4012CA): увеличивает значение переменной «arg_4»1. (loc_4012C5): уменьшает значение переменной «arg_4»2. (loc_4012BE): увеличивает значение, на которое указывает «arg_4»3. (loc_4012B7): уменьшает значение, на которое указывает «arg_4»4. (loc_4012A8): помещает значение, на которое указывает «arg_4» в память на которую указывает «arg_8», после чего увеличивает значение переменной «arg_8»5 (loc_401299):. помещает значение, на которое указывает «arg_С» в память на которую указывает «arg_4», после чего увеличивает значение переменной «arg_С»6. (loc_401284): проверяет значение, на которое указывает «arg_4», и если оно равно нулю, запускает процедуру «sub_401257» передавая в EDX значение 17. (0040123E): проверяет значение, на которое указывает «arg_4», и если оно не равно нулю, запускает процедуру «sub_401257» передавая в EDX значение -1
А в процедуре «sub_401257» происходит следующее, EDX является направлением сканирования пикода, в случае положительного значения ищется опкод номер 7, соответствующий опкоду номер 6 с учетом вложенности (т.е. если идут 066770 то при вызове из первого опкода 6 курсор EAX установится на нуле, а при вызове из второго опкода 6, EAX будет указывать на вторую семерку).А в случае если EDX = -1, то сканирование идет в обратную сторону так-же с учетом вложенности.
Вот собственно и вся VM.Ничего не напоминает?
Угу — это собственно Brainfuck как он есть.http://ru.wikipedia.org/wiki/Brainfuck
Т.е. получается что внутри keygenme расположен зашифрованный PiCode, который должен быть исполнен на интерпретаторе Brainfuck, который собственно и выступает в качестве виртуальной машины (ну, а почему бы и нет?)Кстати, если вы обратите внимание на описание BF в википедии и реализацию обработчиков в данном варианте интерпретатора, то увидите что четвертый и пятый опкоды (чтение и запись) перепутаны местами.
Ну, а раз так, все что нам осталось, это вытащить из тела keygenme сам пикод и больше нам keygenme не нужен, дальше мы сами.
Для того чтобы определить расположение буфера с пикодом, поставим ВР на начале VM и посмотрим на адрес, которым инициализируется EBX.
Запускаем OllyDebug и в нем ставим ВР на адресе 401208.
После срабатывания BP смотрим на значение EBX, это адрес 401380.Переходим на него в окне дампа и смотрим на HEX значения, первые 8 байт равны «CE44 4E53101708DD».Теперь открываем keygenme_unpacked.exe в любом HEX редакторе и ищем эти 8 байт.Размер VM, как мы выяснили ранее 15778 байт.Копируем 15778 байт начиная с найденных в файл «vm.mem».Отлично, теперь у нас на руках есть PiCode виртуальной машины, с которым нам предстоит долгая и упорная работа, а сам keygenme вместе с OllyDebug и IDA Pro нам больше не нужны, они свое отработали.
ЗЫ: уже скопированный файл «vm.mem» доступен в архиве с примерами к статье и расположен в папке ».\data\vm.mem».
Кстати.В процессе вычитки статьи мне указали на такой момент: в реальном боевом приложении количество функций будет на несколько порядков больше, а как в этом случае определить тело виртуальной машины? В данной ситуации достаточно будет установить ВР на зачитку внешних данных (логина и серийного номера), от которого будет достаточно легко отследить переход на один из хэндлеров виртуальной машины, либо те же действия выполнить с выходным буфером (ведь что-то VM должна делать и иметь взаимодействие с внешней средой).Но все это, конечно, зависит от конкретной реализации VM и не всегда этот подход применим.
7. Пишем собственный интерпретатор Brainfuck Для начала декодируем полученный «vm.mem» в нормальное представление примерно вот таким кодом: const BrainFuckOpcode: array [0…7] of AnsiChar = ('>', '<', '+', '-', ',', '.', '[', ']'); const PicodeBuffSize = 15778; var PicodeBuff: array [0..PicodeBuffSize - 1] of Byte; M: TMemoryStream; I: Integer; A, B: Byte; begin M := TMemoryStream.Create; try M.LoadFromFile('..\..\data\vm.mem'); M.ReadBuffer(PicodeBuff[0], PicodeBuffSize); for I := 0 to PicodeBuffSize - 1 do begin A := PicodeBuff[I]; B := A; B := B shr 4; A := A xor B; PicodeBuff[I] := Byte(BrainFuckOpcode[A and 7]); end; M.Clear; M.WriteBuffer(PicodeBuff[0], PicodeBuffSize); M.SaveToFile('..\..\data\vm.brainfuck'); finally M.Free; end; Получится файл «vm.brainfuck» содержащий код BF в том виде, в котором он обычно и записывается, причем тут уже учтено что инструкции "." и "," перепутаны местами.Этот файл в принципе уже можно скармливать любому интерпретатору BF и если подсунуть правильный буфер с логином и серийным номером — он даже выполнится :)Кстати про буфер с логином и серийным номером — совсем забыл про это упомянуть. Он идет на вход виртуальной машине в виде блока из 20 байт, где первые 10 байт заполнены символами логина (если логин меньше 10 байт, остальные байты равны нулю), а сразу за ними идут 10 байт серийного номера преобразованные из строкового HEX представления в байт.
Т.е. для известных нам «Ms-Rem» и «C38FB7A0CF38F73B1159» буфер будет вот таким:
('M', 's', '-', 'R', 'e', 'm', 0, 0, 0, 0, $C3, $8F, $B7, $A0, $CF, $38, $F7, $3B, $11, $59)
Это можно было увидеть при старте виртуальной машины под отладчиком, подсмотрев данные, расположенные по адресу, на который указывала переменная «arg_С».
Для работы интерпретатора BF требуется буфер в 300000 байт (по условиям, описанным в вики), но на самом деле данный вариант кода использует всего 221 байт из 300000.
Собственно пишем код.
Нам нужны 4 буфера, для пикода, для рабочего пространства VM, входной и выходной буфера. const PicodeBuffSize = 15778; var // Буфер с пикодом PicodeBuff: array [0…PicodeBuffSize — 1] of Byte; PicodeIndex: Integer; // Буфер для работы VM WorkBuff: array [0…220] of Byte; WorkBuffIndex: Integer; // Выходной буфер OutputBuff: array [0…39] of AnsiChar; OutputBuffIndex: Integer; // Буфер с логином и серийным номером LoginAndPwd: array [0…29] of AnsiChar; LoginAndPwdIndex: Integer; При старте необходимо загрузить пикод и правильно инициализировать буфер с логином и серийным номером: procedure InitVM; var M: TMemoryStream; begin M:= TMemoryStream.Create; try M.LoadFromFile ('…\…\data\vm.brainfuck'); M.Read (PicodeBuff[0], PicodeBuffSize); finally M.Free; end; end; procedure InitLoginAndPwd (const Login, Password: AnsiString); var I: Integer; A, B: Byte; begin // Колируем логин Move (Login[1], LoginAndPwd[0], Length (Login)); Move (Password[1], LoginAndPwd[10], Min (Length (Password), 20)); // подготавливаем буфер с серийным номером for I:= 0 to 9 do begin A:= Byte (LoginAndPwd[10 + I * 2]); B:= Byte (LoginAndPwd[11 + I * 2]); if A > $39 then Dec (A, $37) else Dec (A, $30); if B > $39 then Dec (B, $37) else Dec (B, $30); A:= a shl 4; A:= A or B; LoginAndPwd[10 + I] := AnsiChar (A); end; end; После чего необходимо написать сам код интепретатора: procedure RunVM; var I: Integer; Count: Integer; begin repeat case PicodeBuff[PicodeIndex] of Byte ('>'): Inc (WorkBuffIndex); Byte ('<'): Dec(WorkBuffIndex); Byte('+'): Inc(WorkBuff[WorkBuffIndex]); Byte('-'): Dec(WorkBuff[WorkBuffIndex]); Byte('.'): begin OutputBuff[OutputBuffIndex] := AnsiChar(WorkBuff[WorkBuffIndex]); Inc(OutputBuffIndex); end; Byte(','): begin WorkBuff[WorkBuffIndex] := Byte(LoginAndPwd[LoginAndPwdIndex]); Inc(LoginAndPwdIndex); end; Byte('['): begin if WorkBuff[WorkBuffIndex] <> 0 then begin Inc (PicodeIndex); Continue; end; Count:= 1; for I:= PicodeIndex + 1 to PicodeBuffSize — 1 do begin if PicodeBuff[I] = Byte ('[') then begin Inc (Count); Continue; end; if PicodeBuff[I] = Byte (']') then begin Dec (Count); if Count = 0 then begin PicodeIndex:= I; Break; end; end; end; end; Byte (']'): begin if WorkBuff[WorkBuffIndex] = 0 then begin Inc (PicodeIndex); Continue; end; Count:= 1; for I:= PicodeIndex — 1 downto 0 do begin if PicodeBuff[I] = Byte (']') then begin Inc (Count); Continue; end; if PicodeBuff[I] = Byte ('[') then begin Dec (Count); if Count = 0 then begin PicodeIndex:= I; Break; end; end; end; end; end; Inc (PicodeIndex); until PicodeIndex = PicodeBuffSize; end; Осталось только все это запустить: InitVM; InitLoginAndPwd ('Ms-Rem', 'C38FB7A0CF38F73B1159'); RunVM; Writeln (PAnsiChar (@OutputBuff[0])); Readln; Запускаем и смотрим на результат: Ну что ж, похоже все сделано правильно и работает так как надо.(Исходный код интерпретатора в архиве c примерами '.\tools\bf_execute\')Таким образом — второй конверт снят.
Но что теперь нам со всем этим делать? Анализировать в лоб пикод не получится — нет инструментов, единственно что можно подсмотреть, это номера ячеек в которые заносится логин и пароль и из каких выводится результат.
Ставим ВР на процедурах чтения и записи и смотрим что у нас выйдет…
Да ничего хорошего, в тот момент когда читается данные логина и серийного номера, каждый байт читается всегда в ячейку номер шесть рабочего буфера.То же происходит и с выводом данных из VM, очередной символ забирается так же из ячейки номер шесть.
Единственное, что может нам хоть как-то прояснить картинку, это дамп рабочего буфера в момент вывода данных, посмотрите на него:
Тут хотя бы видно, что в рабочем буфере VM находится логин и пароль, а так-же чуть ниже него две уже подготовленных строки с «хорошим сообщением» и «плохим».А в шестой ячейке рабочего буфера (вторая справа сверху) уже сидит подготовленный символ «С» из выводимого сообщения «Congratulations!!! It is valid serial! «Вот такая засада.
8. Пишем декомпилятор Brainfuck Что есть декомпилятор? По сути, это утилита, преобразующая набор машкодов для процессора, в понятный программисту набор ассемблерных инструкций. Для каждого процессора это будет свой ассемблер (32/64/ARM и т.д.). Ну, а виртуальная машина (как говорилось ранее) тот же процессор со своим набором инструкций, выраженных в виде пикода.Сейчас стоит задача, написать декомпилятор пикода в 32-битный ассемблер, чтобы с результатом можно было хоть как-то работать, благо в этом случае инструмент для анализа у нас уже есть — это отладчик.Причем задача то по сути простая, не нужно учитывать префиксы, парсить ModRM/SIB — всего-то восемь инструкций, но для начала нужно разобраться как это будет выглядеть.
Дизассемблировать в лоб будет не удачной затеей, нужно сворачивать блоки повторяющихся инструкций пикода BF
К примеру у нас есть brainfuck скрипт такого вида:»>>>+++<<----"
Здесь происходит переход к четвертой ячейке, увеличение ее значения на три, п