CrackMe: crackme_vm by _Great_
Цель: найти ключ попутно закейгенить
Сложность: средне
link:
ссылка
Давно я не писал на ачате, пора нарушить обет молчания. Да и есть хороший повод поболтать - крякми с вм.
Кто не знает, почему так существенно упоминание о ВМ, послушайте кратенький эпос, в чем сложность взлома таких программ: када вы ломаете обычные программы, вы видите в отладчике команды процессора(мнемокод), видите панель регистров, окно дампа памяти, брекпоинты итп. Вы хорошо знаете набор команд данного процессора и можете легко понять алгоритм по диизасм листингу, отбрасывая кучи полиморфа и мусора. Да, вы профи, и противник знает это, поэтому он идет ва-банк. Единственный способ лишить вас преимущества - играть на другом поле (читай работать на другом процессоре). Чем это грозит - незнакомая структура команд, незнакомые сами команды. Никакой мнемоники - только машкоды виртуального процессора. Никаких доков об архитектуре процессора - только рабочий интерпритатор и кучка пикода. Это все равно что велогонщику дать велосипед с квадратными колесами. Чтобы взломать такую программу вам придется сначала разобраться со структурой команд вм, дальше декодировать опкоды вм, дальше декомпилировать пикод программу, чтобы получить последовательность команд вм в понятном для осознания виде, чтобы из них составить алгоритм работы пикода. А чтобы сделать это, вы должны не просто знать реальный процессор, вы должны еще хорошо соображать и быть самым что ни на есть реверсером. Поэтому защита программ с помощью вм - серьезное препятстсвие для многих крякеров. Кстати мой крякми с вм поломали только пара человек, и то не с ачата =) Поэтому мне эта тема близка, и я покажу, что вм - это не приговор, просто хороший повод понапрягать свой мозг.
Ну а теперь к делу. У нас имеется ехе небольшого веса (всего 6кб). Не пожат. Видим создание диалога. Топаем на оконную процедуру - PUSH crackme_.00402534 ; |DlgProc = crackme_.00402534
Убираем анализ кода, т.к. криво вставленные байты сильно пугают анализатор олли.
Сюда бряк и поехали. Вводим name - Ra$cal , key - lalala. Заходим в пару колов и стоим тут
Код:
0040259E CALL NEAR DWORD PTR DS:[<&NTDLL.sscanf>] ; ntdll.sscanf
Это есть функция, которая рабирает строку на переменные по строке-формату. Стмотрим в стек
Код:
0006FBB0 00401569 |s = "lalala"
0006FBB4 00402596 |format = "%x"
0006FBB8 0040157D OFFSET <crackme_.iKey>
%x - значит будет попытка считать из строки s 16тиричное число и записать его по адресу iKey. Значит ключ - DWORD. Есть вариант его набрутить. Но наша цель - вм. Поэтому отпускаем программу по F9 и вводим нормальное число - BAADF00D. По адресу iKey видим это число. Продолжаем.
Код:
004024C3 |. MOV DWORD PTR DS:[<vm_regs>], EAX ; <crackme_.name>
004024C8 |. PUSH EAX ; /String
004024C9 |. CALL NEAR DWORD PTR DS:[<&KERNEL32.lstrlenA>>; \lstrlenA
004024CF |. MOV DWORD PTR DS:[<uname_len>], EAX
Здесь кладется в секцию данных указатель на имя. Видно мой лейбл. Но об этом позже. Дальше кладется длина имени.
Код:
004024D4 |. MOV EAX, DWORD PTR SS:[EBP+C]
004024D7 |. MOV DWORD PTR DS:[<iKey2>], EAX
Здесь сохраняется числовой ключ.
Код:
004024DC |. MOV DWORD PTR DS:[401404], E98FB720
004024E6 |. MOV DWORD PTR DS:[401420], 21525253
И две странных константы.
Код:
004024F0 |. PUSH crackme_.004014BB
004024F5 |. CALL crackme_.00402474
Спуск в вм.
Код:
0040248D |. MOV DWORD PTR DS:[<vm_eip>], EAX ; p_code_addr
00402492 |. MOV DWORD PTR DS:[40142C], OFFSET <crackme_.vm_regs>
0040249C |> /CALL <crackme_.vm_proc>
004024A1 |. |TEST AL, AL
004024A3 |.^ \JE SHORT crackme_.0040249C
Это цикл обработки пикода. Как видим, он продолжается, пока vm_proc не вернет число, отличное от 0. Ну а теперь самое веселое - погружение в интерпритатор вм.
Поясню, как я пришел к выводу о назначении переменной vm_eip.
Код:
004020F2 . MOV ESI, DWORD PTR DS:[<vm_eip>] ; crackme_.004014BB
004020F8 . LODS BYTE PTR DS:[ESI] ; первый байт - опкод
Здесь идет чтение байта по адресу, взятому из переменной. Дальше будет считан следующий байт. Чисто логически понимаем, что тут нечего больше считывать, кроме как пикод. Посмотрим на дамп памяти.
Код:
004014BB 06 44 00 00 00 00 00 00
004014C3 00 00 03 05 00 00 00 00
004014CB 00 00 00 00 04 25 00 00
004014D3 00 00 00 00 00 00 43 03
004014DB 00 00 00 00 00 00 00 00
004014E3 06 13 00 00 00 00 00 00
004014EB 00 00 04 34 00 00 00 00
004014F3 00 00 00 00 0A 00 00 00
Можете увидеть закономерности? Идут 2 ненулевых байта, затем 8 нулеых. Закономерность устойчива. Далее будут ненулевые байты на месте нулевых, это вполне логично, иначе бы зачем в командах держать пустые поля. Чисто на удачу можно сделать предположение о длине команды - 10 байт. А пока изучим команды, которые обрабатывают считанный пикод:
Код:
004020F8 . LODS BYTE PTR DS:[ESI] ; первый байт - опкод. esi += 1
004020F9 . MOV BL, AL
004020FB . MOV CL, AL
004020FD . AND AL, 3F ; сохраняются 6 млабших бит
004020FF . MOV BYTE PTR SS:[EBP-14], AL
00402102 . SHR BL, 7 ; сохраняется старший бит номер 8
00402105 . MOV BYTE PTR SS:[EBP-13], BL ; 8ой бит
00402108 . SHR CL, 6 ; сохраняется бит номер 7
0040210B . AND CL, 1
0040210E . MOV BYTE PTR SS:[EBP-F], CL ; 7ой бит
00402111 . LODS BYTE PTR DS:[ESI] ; второй байт. esi += 1
00402112 . MOV BL, AL
00402114 . AND BL, 0F ; осталяем 4 бита младших
00402117 . SHR AL, 4 ; переходим к следующим 4м битам
0040211A . MOV BYTE PTR SS:[EBP-B], BL ; byte2_4_bits_low
0040211D . MOV BYTE PTR SS:[EBP-A], AL ; byte2_4_bits_high
00402120 . MOV ECX, DWORD PTR DS:[ESI+4] ; следующие 4 байта пропускаются (vm_command+6)
00402123 . PUSH DWORD PTR SS:[EBP-F]
00402126 . CALL <crackme_.decode_cmd_argument>
0040212B . MOV DWORD PTR SS:[EBP-5], EAX ; загрузить в ebp-5 содержимое регистра вм high
0040212E . MOV AL, BYTE PTR SS:[EBP-B]
00402131 . MOV ECX, DWORD PTR DS:[ESI] ; считать дворд по адресу vm_command+2
00402133 . PUSH DWORD PTR SS:[EBP-13]
00402136 . CALL <crackme_.decode_cmd_argument>
0040213B . MOV DWORD PTR SS:[EBP-9], EAX ; загрузить в ebp-9 содержимое регистра вм low
BYTE opcode = (*(BYTE*)vm_eip) & 0x3F; // 0x3F = 111111b - сохраняем младшие 6 бит, старшие обнуляем
BYTE bit_13 = (*(BYTE*)vm_eip) >> 7; // остался только восьмой бит
BYTE bit_f = ((*(BYTE*)vm_eip) >> 6) & 1; // сохранили седьмой бит и сбросили восьмой
Визуально первый байт команды раскладывается так:
X X XXXXXX
| | |_ opcode
| |___ bit_f
|_____ bit_13
Смотрим обработку второго считанного байта
BYTE field_b = (*(BYTE*)vm_eip+1) & 0x0F; // 0x0F = 1111b - сохраняем младшие 4 бита
BYTE field_a = (*(BYTE*)vm_eip+1) >> 4; // сохраняем старшие 4 бита
итого второй байт делится на биты так:
XXXX XXXX
| |_ field_b
|______ field_a
Причем если смотреть на байт, то здесь выполняется отделение первого и второго символов в байте. Например если байт будет равен 0xF5, то разложение даст 0x0F и 0x05. Это упростит нам наблюдение.
Так же видим чтение двух двордов здесь 00402120 и здесь 00402131.
DWORD dw_1 = *(DWORD*)((BYTE*)vm_eip+6);
DWORD dw_2 = *(DWORD*)((BYTE*)vm_eip+2);
Теперь к функции decode_cmd_argument
Код:
0040203A |. CMP AL, 9
0040203C |. JLE SHORT crackme_.00402055
0040203E |. CMP AL, 0A
00402040 |. JE SHORT crackme_.00402065
00402042 |. CMP AL, 0B
00402044 |. JE SHORT crackme_.0040206C
00402046 |. CMP AL, 0C
00402048 |. JE SHORT crackme_.00402073
0040204A |. PUSH 1
0040204C |. CALL crackme_.00402000
00402051 |. LEAVE
00402052 |. RETN 4
00402055 |> PUSH ECX
00402056 |. MOVZX ECX, AL
00402059 |. MOV EAX, DWORD PTR DS:[ECX*4+401400]
00402060 |. POP ECX
00402061 |. ADD EAX, ECX
00402063 |. JMP SHORT crackme_.00402075
00402065 |> MOV EAX, DWORD PTR DS:[<vm_eip>]
0040206A |. JMP SHORT crackme_.00402075
0040206C |> MOV EAX, DWORD PTR DS:[40142C]
00402071 |. JMP SHORT crackme_.00402075
00402073 |> MOV EAX, ECX
00402075 |> CMP BYTE PTR SS:[EBP+8], 1
00402079 |. JNZ SHORT crackme_.0040207D
0040207B |. MOV EAX, DWORD PTR DS:[EAX]
0040207D |> LEAVE
0040207E \. RETN 4
В al лежит field_a. Проверяется на 9. Если меньше либо равно - идем к получению адреса, используя al как индекс. Итого имеем границу в 10 элементов. На другие джампы пока не смотрим. Дальше к считанному адресу прибавляется содержимое ecx. Ecx задается перед колом - это как раз считанный дворд из команды - dw_1.
Код:
00402075 |> CMP BYTE PTR SS:[EBP+8], 1
00402079 |. JNZ SHORT crackme_.0040207D
0040207B |. MOV EAX, DWORD PTR DS:[EAX]
Здесь идет проверка байта, который так же передается функции, но через стек (bit_f). Если он = 1 - то идет разыменовывание eax. Иначе eax остается без изменения. Таким образом эта функция использует field_a для вычисления адреса элемента в массиве, и бит bit_f для чтения содержимого этого адреса. Вполне похоже на работу с регистрами вм. Пока мы можем только предполагать - в этом сложность исследований вм. Чтобы выявить закономерность нужно выполнить несколько пикод команд.
На выходе из функции имеем следующий момент
Код:
0040212B . MOV DWORD PTR SS:[EBP-5], EAX
Результат декодирования кладется в переменную. Дальше все то же самое, но для второй половины пикод команды. Результат декодирования пишется сюда
Код:
0040213B . MOV DWORD PTR SS:[EBP-9], EAX
Для понятливости назовем ebp-5 как r1, а ebp-9 как r2. Дальше начинается кутерьма с проверкой байта ebp-14, который есть 6 бит первого байта пикод команды. Это нам говорит о назначении этих битов - выбор операции. Значит это - код операции. Это можно утверждать достаточно уверенно. У нас код равен шести. Доходим до обработчика
Код:
00402231 > CMP BYTE PTR SS:[EBP-14], 6
00402235 . JNZ SHORT crackme_.0040225F
00402237 . MOV EDX, DWORD PTR SS:[EBP-9]
0040223A . XOR EDX, DWORD PTR SS:[EBP-5]
0040223D . MOV ECX, DWORD PTR DS:[ESI]
0040223F . MOV AL, BYTE PTR SS:[EBP-B]
00402242 . PUSH DWORD PTR SS:[EBP-13]
00402245 . CALL <crackme_.save_to_vm_register>
Что мы здесь видим. Берется r2, и ксорится с r1. В ecx кладется dw_2(используется при вычислении r2), в al field_b(индекс регистра, адрес которого возвращается в r2), в стек bit_13, то поле, которое используется при вычислении r2 из индекса регистра. Пока все сходится - все поля относятся к одному логическому полю.
Код:
00402084 |. PUSH EDI
00402085 |. CMP AL, 9
00402087 |. JLE SHORT crackme_.004020A1
00402089 |. CMP AL, 0A
0040208B |. JE SHORT crackme_.004020B7
0040208D |. CMP AL, 0B
0040208F |. JE SHORT crackme_.004020BF
00402091 |. CMP AL, 0C
00402093 |. JE SHORT crackme_.004020C7
004020A1 |> PUSH ECX
004020A2 |. MOVZX ECX, AL
004020A5 |. LEA EDI, DWORD PTR DS:[ECX*4+401400]
004020AC |. POP ECX
004020AD |. CMP BYTE PTR SS:[EBP+8], 1
004020B1 |. JNZ SHORT crackme_.004020B5
004020B3 |. ADD EDI, ECX
004020B5 |> JMP SHORT crackme_.004020DD
004020B7 |> LEA EDI, DWORD PTR DS:[<vm_eip>]
004020BD |. JMP SHORT crackme_.004020DD
004020BF |> LEA EDI, DWORD PTR DS:[40142C]
004020C5 |. JMP SHORT crackme_.004020DD
004020C7 |> CMP BYTE PTR SS:[EBP+8], 1
004020CB |. JE SHORT crackme_.004020D9
004020D9 |> MOV EDI, ECX
004020DB |. JMP SHORT crackme_.004020E5
004020DD |> CMP BYTE PTR SS:[EBP+8], 1
004020E1 |. JNZ SHORT crackme_.004020E5
004020E3 |. MOV EDI, DWORD PTR DS:[EDI]
004020E5 |> MOV DWORD PTR DS:[EDI], EDX
004020E7 |. POP EDI
004020E8 |. LEAVE
004020E9 \. RETN 4
код похож на предыдующую функцию. так же выборка адреса регистра по индексу. только добавление к адресу поля dw_2 происходит по биту bit_13, так же через этот бит опять идет управление разыменовыванием. Ну а главная строка - 004020E5 MOV DWORD PTR DS:[EDI], EDX. Здесь значение регистра edx пишется по адресу, полученному из индекса и базового адреса. Вобщем, это запись в регистр вм.
Все. Выходим, проходим, и вот мы вышли из обработчика. Одна команда выполнена. Теперь надо бы закрепить наши предположения. Начинаем рассматривать вторую команду.
код следующий - 03 05. Если наша теория верна, то имеем следующее: 03 - opcode = 3. Биты обнулены. Индекс первого регистра - 0, второй индекс - 5.
Это считывание первого регистра. Там лежит наше имя.
DS:[00401400]=00401555 (<crackme_.name>), ASCII "Ra$cal"
EAX=00000000
Теперь должно будет считывать значение регистра 5. Так ли?
Код:
DS:[00401414]=0040155B (crackme_.0040155B)
EAX=00401505 (crackme_.00401505)
00401400 - базовый адрес регистров. 5*4 - 20 -> 14h. DS:[00401414] - да, наши предположения оправдываются. Теперь ищем обработчик для опкода 3.
Код:
004021B0 . MOV EDX, DWORD PTR SS:[EBP-5]
004021B3 . MOV ECX, DWORD PTR DS:[ESI]
004021B5 . MOV AL, BYTE PTR SS:[EBP-B]
004021B8 . PUSH DWORD PTR SS:[EBP-13]
004021BB . CALL <crackme_.save_to_vm_register>
В edx кладется r1. А в r1 мы считывали имя. Дальше в al кладется индекс регистра из второго поля - 5. Проверим, запишется ли туда указатель на имя.
004020E5 |> MOV DWORD PTR DS:[EDI], EDX
EDX=00401555 (<crackme_.name>), ASCII "Ra$cal"
DS:[00401414]=0040155B (crackme_.0040155B)
Все верно. Итак, теперь можно заключить о структуре команд
Код:
1 23 4 5 6 7 8
XXXXXXXX XX XXXXXX XXXX XXXX XXXXXXXX XXXXXXXX
Вот структура команды. Теперь объединим поля логически
1 - пока нам не известно, так что пропускаем
2 - бит, отвечающий за разыменовывание поля 6. типа mod r/m в архитектуре ia32 - modrm2
3 - бит, отвечающий за разыменовывание поля 5. типа mod r/m в архитектуре ia32 - modrm1
4 - код опеации - opcode
5 - индекс регистра, который будет помещен в r1 - rivm1 (register index vm 1)
6 - индекс регистра, который будет помещен в r2 - rivm2
7 - поле, используемое c битом 3 и полем 6 - data2
8 - поле, используемое с битом 2 и полем 5 - data1
Вот мы разобрали команды. Теперь нада разобрать все используемые опкоды. Пока картина следующая
06 - xor
03 - mov
Еще важный момент. Результат всегда кладется в r2, т.е. поле rivm2 является указанием как источника операнда, так и указанием получателя результата.
Итого мы можем преобразовать пикод уже в уме
004014BB: 06 44 -> xor rvm4, rvm4 ;// обнуляем rvm4
004014C5: 03 05 -> mov rvm5, rvm0 ;// rvm5 теперь содержит указатель на имя
Теперь нада упомянуть, что часть из регистров проинициализированны вне интерпритатора вм. Например rvm0 содержит изначально имя. Пока продолжим. Дальше я буду давать дизасм и останавливаться лишь на тех моментах, которые еще не были продебажины (использование modrm1 и modrm2, и использование data1 и data2). Остальные просто приводятся листинг обработчика опкода и декомпилированная команда
004014CF: 04 25 -> add rvm5, rvm2 ;// в rvm2 лежит длина имени. таким образом мы переходим к концу имени - видимо будет использована как проверка конца цикла обработки имени
Код:
004021DB . MOV EDX, DWORD PTR SS:[EBP-9] ;edx = r2
004021DE . ADD EDX, DWORD PTR SS:[EBP-5] ;r1 + r2
004014D9: 43 03 -> mov rvm3, [rvm0] ;// в rvm3 лежит 4 байта - букв из имени
Вот здесь modrm1 = 1. Смотрим, что изменится при выполнении декодирования rivm1.
Код:
00402075 |> CMP BYTE PTR SS:[EBP+8], 1
00402079 |. JNZ SHORT crackme_.0040207D
0040207B |. MOV EAX, DWORD PTR DS:[EAX]
DS:[00401555]=63246152
EAX=00401555 (<crackme_.name>), ASCII "Ra$cal"
Угу, считываются символы из имени, а не адрес имени. Так что все так, как мы и предполагали. Опкод - 3, то есть mov
mov rvm3, [rvm0] ;// в rvm3 лежит 4 байта - букв из имени
Код:
004021C0 . CMP BYTE PTR SS:[EBP-13], 1
004021C4 . JE SHORT crackme_.004021D0
004021C6 . CMP BYTE PTR SS:[EBP-B], 0A
004021CA . JE crackme_.00402470
Обсудим эту проверку. Если modrm1 = 0, т.е. мы изменили регистр вм, а не память, на которую указывал регистр, то проверяется регистр. Если индекс регистра = 0x0A, то мы выходим не трогая vm_eip, иначе выполняется переход к следующей команде. Тут следует упомянуть, что у данной вм использованн естественный порядок следования команд, как и у ia32, адрес следующей команды получается добавлением к vm_eip длины команды. Но для реализации условных переходов приходится нарушать это следование, или указывая процессору адрес, на который надо перейти (ia32 не позволяет писать в регистр eip), в данной же вм запись в регистр указатель команды очевидно возможна, и чтобы не испортить адрес перехода, естественное следование предотвращается при записи в vm_eip. Кароче, это просто проверка на случай команд вида jmp или mov vm_eip, чтобы не убить правильный адрес суммированием - пропускается вот этот код
00402467 > > ADD ESI, 8
0040246A . MOV DWORD PTR DS:[<vm_eip>], ESI
004014E3: 06 13 -> xor rvm3, rvm1
В rvm1 лежит константа - E98FB720
004024DC |. MOV DWORD PTR DS:[401404], E98FB720
004014ED: 04 34 -> add rvm4, rvm3 ;// rvm4 обнулили в самом начале, добавляем результат хэша
004014F7: 0A 00 -> inc rvm0 ;// к следующей букве в имени
Код:
0040230D . MOV EDX, DWORD PTR SS:[EBP-9]
00402310 . INC EDX
Просто инкремент
00401501: 0F 50 cmp rvm0, rvm5 ;// сравнить текущий указатель в имени и указатель на конец имени
Код:
00402365 . MOV EAX, DWORD PTR SS:[EBP-9]
00402368 . MOV EBX, DWORD PTR SS:[EBP-5]
0040236B . CMP EAX, EBX
А это cmp
0040150B: 14 00 -> jg vm_eip+20
Код:
004023C5 > CMP BYTE PTR SS:[EBP-14], 14
004023C9 . JNZ SHORT crackme_.004023DC
004023CB . CMP BYTE PTR DS:[<LE_F>], 1
004023D2 . JE SHORT crackme_.004023D7
004023D4 . ADD ESI, 0A
004023D7 > JMP <crackme_.lbl_end_command>
Jump is taken
004023D7=crackme_.004023D7
Тут проверяется флаг LE. Если он установлен, значит текущий адрес в имени меньше либо равен конца имени. Поэтому выполнится следующая команда. Когда адрес в имени уйдет за конец, выполнится изменение esi, проскочив следующую команду. Т.е. в этой вм условный переход возможен только на 1 команду вперед. Посмотрим, что произойдет дальше.
00401515: 03 CA 00000000 004014D9 mov vm_eip, 004014D9
А вот и использование поля data1. Посмотрим, что произойдет при декодировании первого rivm1
Код:
00402046 |. CMP AL, 0C
00402048 |. JE SHORT crackme_.00402073
00402073 |> MOV EAX, ECX
Получается следующая картина. Когда rivm1 = 0x0C, на выход поступит содержимое ecx. А ecx - это есть data1. Итого на eax будет 004014D9
Поле 2 декдируется так тоже не по индексу
Код:
0040203E |. CMP AL, 0A
00402040 |. JE SHORT crackme_.00402065
00402065 |> MOV EAX, DWORD PTR DS:[<vm_eip>]