Я тут услышал краем уха, что мне за техническую подготовку кто-то влепил трояк. Что ж, я согласен, до семинара я полтора часа слонялся по аудитории/универу, а мог бы лишний раз вспомнить, как распаковываются подобные упаковщики. Так как же всё-таки распаковать тот com-файл?
Загружаем его в IDA:
seg000:0100 public start
seg000:0100 start proc near
seg000:0100 pop bx
seg000:0101 inc bp
seg000:0102 push bx
seg000:0103 push ax
seg000:0104 pop bp
seg000:0105 mov ch, 78h
seg000:0107 mov di, cx
seg000:0109 mov bp, cx
seg000:010B mov si, 112h
seg000:010E push di
seg000:010F rep movsb
seg000:0111 retn
seg000:0111 start endp ; sp = -2
Как думаете, зачем нужны первые 5 строчек? Сначала мы поднимаем с вершины стека bx, затем увеличиваем bp на 1, затем кладем в вершину стека bx (зачем мы тогда его поднимали? Очевидно, чтобы bx присвоить то значение, которое раньше лежало на вершине стека), затем мы кладем в стек ax, и поднимаем со стека bp (т.е. проще говоря присваиваем bp значение ax). Но зачем мы тогда увеличивали bp, если мы потом всё равно затираем это значение? И почему нам тогда вместо push ax/pop bp не воспользоваться инструкцией mov bp, ax, которая занимает те же 2 байта, но не использует оперативную память, а потому работает гораздо быстрее (раза в 2 быстрее на Pentium и в 13 (!) раз быстрее на так называемых 186)? Зачем нам вообще нужно это bp, если мы через две команды опять затираем старое значение, на этот раз значением cx? Ответ очевиден – незачем. Все выделенные строчки не делают ровным счетом ничего, кроме инициализации bx. Но зачем-то же они нужны? Посмотрим в шестнадцатеричном виде:
5B 45 53 50 5D B5 78 8B F9 8B E9 BE 12 01 57 F3 [ESP]µx‹ù‹é¾..Wó
Соответствующие им байты я выделил жирным. Интересно, да? Сначала идет 5 вполне осмысленных и даже, если можно так сказать, оформленных байт, а затем во всем остальном файле не встречается ничего подобного, весь остальной код выглядит, как и положено коду, абсолютно бессмысленно. Так вот это, оказывается, “логотип” команды, которая разработала упаковщик. Но погодите, как же быть с bx? В него ведь что-то записалось? Для понимания, что именно, надо знать, как работает загрузчик в ДОСе. Исследование загрузчика ДОСа однако немного не вписывается в общую тему, просто скажу, что в ДОСе при загрузке com-программ на вершину стека (cs:FFFEh) кладется 0. Двигаемся дальше.
Следующей инструкцией после выделенных жирным в ch помещается 78h. cx, соответственно, принимает вид 78xxh. Регистр cx в общем случае при загрузке программы не определен, поэтому мы не можем утверждать, что будет находиться в младшем байте, но давайте для простоты скажем, что он равен FFh, и округлим cx до 7900h. На принципе работы программы это, как будет показано ниже, не отразится, а на понимании логики работы программы отразится исключительно положительно, т.к. не будет страшных “xx”. Затем это значение помещается в регистры di и bp, а в si помещается значение 112h. После этого значение di (7900h) помещается в стек. Остановимся подробней на команде rep movsb. Префикс rep означает, что команда movsb будет повторяться до тех пор, пока cx не станет равным нулю, т.е. 7900h раз. movsb выполняет копирование байта по адресу ds:si в байт по адресу es:di. В случае com-файла cs = ds = es = ss, т.е. все сегментные регистры инициализируются номером того сегмента, в который была загружена программа. Проще всего действие команды rep movsb можно себе представить как копирование части 64-килобайтного массива:
for i:=0 to 78FFh do memory[7900h+i]:=memory[112h+i];
После этого копирования выполняется команда retn. Эта команда снимает с вершины стека адрес возврата и выполняет по нему переход. А что лежит на вершине стека? Старое значение di, т.е. 7900h. Зачем это делается? Зачем сначала переписывать 30 с лишним килобайт из одной области памяти в другую (а это далеко не быстрая операция, на старых машинах она займет 800 с лишним тысяч (!) тактов), а затем выполнять копию? Почему бы нам не выполнить тот код, который лежит по адресу 112h? Понятно, почему. Потому что исходная программа (точнее, её автор) рассчитывает на то, что загрузчик поместит её в какой-то сегмент по адресу 100h. А раз она на это рассчитывает, значит, её надо туда и поместить. В противном случае нам пришлось бы менять все адреса в исходной программе, а про трудность автоматического различения констант и адресов я уже рассказывал на семинаре. Гораздо проще переписать распаковщик подальше от младших адресов сегмента, освободив тем самым место для распаковываемой программы.
Итак, мы перешли от начального, подготовительного кода к коду распаковщика. Вот он:
seg000:0112 mov di, 100h
seg000:0115 push di
seg000:0116
seg000:0116 loc_10116: ; CODE XREF: seg000:012Aj
seg000:0116 ; seg000:0149j
seg000:0116 xor ax, ax
seg000:0118 cwd
seg000:0119 cmp di, 1BAh
seg000:011D jnb short locret_1015C
seg000:011F call sub_10158
seg000:0122 jb short loc_1012C
seg000:0124 mov cl, 8
seg000:0126 call sub_1016B
seg000:0129 stosb
seg000:012A jmp short loc_10116
seg000:012C ;
seg000:012C
seg000:012C loc_1012C: ; CODE XREF: seg000:0122j
seg000:012C mov dl, 4
seg000:012E
seg000:012E loc_1012E: ; CODE XREF: seg000:0135j
seg000:012E call sub_10158
seg000:0131 jnb short loc_1013B
seg000:0133 inc cx
seg000:0134 dec dx
seg000:0135 jnz short loc_1012E
seg000:0137 call sub_1015D
seg000:013A xchg ax, cx
seg000:013B
seg000:013B loc_1013B: ; CODE XREF: seg000:0131j
seg000:013B jcxz short loc_1014B
seg000:013D push cx
seg000:013E call sub_1015D
seg000:0141 pop cx
seg000:0142
seg000:0142 loc_10142: ; CODE XREF: seg000:0150j
seg000:0142 mov si, di
seg000:0144 sub si, ax
seg000:0146 inc cx
seg000:0147 rep movsb
seg000:0149 jmp short loc_10116
seg000:014B ;
seg000:014B
seg000:014B loc_1014B: ; CODE XREF: seg000:loc_1013Bj
seg000:014B mov cl, 4
seg000:014D call sub_10169
seg000:0150 jmp short loc_10142
seg000:0152 ;
seg000:0152 ; START OF FUNCTION CHUNK FOR sub_10158
seg000:0152
seg000:0152 loc_10152: ; CODE XREF: sub_10158+2j
seg000:0152 mov bl, [bp+63h]
seg000:0155 inc bp
seg000:0156 mov bh, 1
seg000:0156 ; END OF FUNCTION CHUNK FOR sub_10158
seg000:0158
seg000:0158 ;
S U B R O U T I N E
seg000:0158
seg000:0158
seg000:0158 sub_10158 proc near ; CODE XREF: seg000:011Fp
seg000:0158 ; seg000:loc_1012Ep ...
seg000:0158
seg000:0158 ; FUNCTION CHUNK AT seg000:0152 SIZE 00000006 BYTES
seg000:0158
seg000:0158 shr bx, 1
seg000:015A jz short loc_10152
seg000:015C
seg000:015C locret_1015C: ; CODE XREF: seg000:011Dj
seg000:015C retn
seg000:015C sub_10158 endp
seg000:015C
seg000:015D
seg000:015D ;
S U B R O U T I N E
seg000:015D
seg000:015D
seg000:015D sub_1015D proc near ; CODE XREF: seg000:0137p
seg000:015D ; seg000:013Ep
seg000:015D mov cx, 6
seg000:0160 mov dl, 9
seg000:0162 call sub_10158
seg000:0165 jb short sub_1016B
seg000:0167 mov cl, 3
seg000:0167 sub_1015D endp
seg000:0167
seg000:0169
seg000:0169 ;
S U B R O U T I N E
seg000:0169
seg000:0169
seg000:0169 sub_10169 proc near ; CODE XREF: seg000:014Dp
seg000:0169 mov dl, 1
seg000:0169 sub_10169 endp
seg000:0169
seg000:016B
seg000:016B ;
S U B R O U T I N E
seg000:016B
seg000:016B
seg000:016B sub_1016B proc near ; CODE XREF: seg000:0126p
seg000:016B ; sub_1015D+8j ...
seg000:016B call sub_10158
seg000:016E rcl ax, 1
seg000:0170 loop sub_1016B
seg000:0172 add ax, dx
seg000:0174 retn
seg000:0174 sub_1016B endp
Ужасно, не правда ли? Чтобы понять, как он работает (а он, без сомнения, оптимизирован по размеру, а от того еще более непонятен), понадобится не час и не два. Но так ли оно нам нужно? Еще на семинаре я сказал, что достаточно дать этому коду отработать, и мы получим распакованную программу в памяти. Надо всего лишь поймать момент перехода от кода распаковщика к коду распакованной программы. Так ведь это очень просто! Мы только что выяснили, что подготовительный код специально освободил нижние адреса сегмента, чтобы записать туда распакованную программу. Значит, надо остановиться тогда, когда распаковка завершится и управление передастся на адрес cs:100h. На семинаре я пытался это сделать, но безуспешно. Сейчас же я предложу целых два способа.
Первый способ – аналитический. Ищем 100h в коде распаковщика. Поиски не заняли много времени: 100h используется всего один раз и в самой первой инструкции – mov di, 100h. Если вспомнить недавно упомянутую команду movsb, возникает смутное подозрение, что di – это указатель-приемник, да? А если еще учесть, что 100h – это как раз “то, что нужно”, можно почти достоверно утверждать, не глядя на весь остальной код, что di в процессе распаковки будет указывать на текущую позицию в распакованном коде. На что же будет указывать di, когда распаковка завершится? На тот байт, который идет за последним байтом распакованной программы. А как, кстати, определить, что распаковка завершилась? Для этого надо либо знать длину запакованных данных, либо длину исходных данных. Длина запакованных данных – это размер программы – размер кода = FEh – 75h = 89h = 137 байт. Длина исходных данных, понятно, должна быть еще больше. Чисел такого порядка в коде распаковки, прямо скажем, не много. Всего одно. cmp di, 1BAh – эта команда сравнивает di (которое, напомню, как мы догадались, после завершения распаковки будет равно (100h + размер исходного файла)) и 1BAh. Подозрительно, не так ли? Итак, мы нашли и условие прерывания распаковки, и длину исходных данных (BAh = 186 байт).
Следующий способ – брутальный. Для остановки на определенном адресе (а мы, напомню, хотим остановиться, когда начнет выполняться исходная программа), дебаггеры включают в себя возможность установки так называемых точек останова – брейкпойнтов. Так всё просто оказывается, да?
Запускаем программу под дебаггером (на семинаре мы использовали Turbo Debugger?), ставим брейкпойнт (клавишей F2) на cs:100h, жмем F9 (выполнение программы с учетом брейкпойнтов)… и видим на экране строчку “Type password:”. Что-то не то. Не может же распаковщик одновременно и распаковывать и выполнять программу. Не может. Значит, мы должны были прерваться на начале распакованной программы, как планировали. Но не прервались. На этом шаге валится большинство начинающих исследователей. Они просто не понимают, как работают брейкпойнты. И действительно, как заставить процессор прерываться на нужных адресах? В современных процессорах есть специальные “отладочные” регистры, но в старых моделях (до 386) их не было. Кроме того, в процессоре всего 4 ячейки под адреса брейкпойнтов, а в дебаггере вы их можете натыкать хоть 100. Поэтому брейкпойнты в старых отладчиках реализуются исключительно программно. Но как, как заставить процессор остановиться посередине чужого кода и просигнализировать об этом дебаггеру? Это реализуется очень просто, при помощи прерываний.
Прерывания – это такой замечательный механизм, суть которого заключается в следующем – как только прерывание генерируется, управление моментально передается обработчику прерывания. Зачем это надо? Приведу банальный пример. Работает у нас прога, работает. Число пи считает, например, с бесконечной точностью, до тех пор, пока пользователь не нажмет эскейп. Мы не знаем, когда он нажмет эскейп. Он и сам, в принципе, не знает. А тут взял и нажал. Так вот обработать этот самый эскейп можно двумя способами. Либо периодически опрашивать контроллер клавиатуры, либо использовать прерывание. Понятно, что первый способ приведет либо к захламлению кода, либо к слишком медленному времени реакции на нажатие клавиши. Второй способ таких недостатков не имеет. Еще прерывания хороши тем, что программисту дана возможность вызывать прерывания “по желанию”, с помощью ассемблерной команды int xxh. Есть всего два прерывания, вызов которых занимает 1 байт – это прерывание переполнения (int 0) и прерывание отладки (int 3), вызовы остальных прерываний занимают 2 байта.
Ну замечательно, есть третье прерывание. И что с того? Как заставить процессор прерваться по нужному адресу? Ну понятно, надо записать по тому адресу вызов третьего прерывания. Один байт этот вызов, кстати, занимает не случайно, а чтобы можно было любую инструкцию заменить. Если бы вызов этого прерывания занимал, например, 2 байта, ситуация с двумя брейкпойнтами на двух соседних байтах обрабатывалась бы крайне некорректно, т.к. один из вызовов затирал бы половину второго.
Так вот, когда вы жмете F2 в дебаггере, дебаггер запоминает адрес и байт, который лежал по этому адресу, и записывает вместо этого байта CCh (это машинный код команды int 3). Вам он этого, естественно, не покажет. Чтобы не пугать. Когда он останавливается на этом прерывании, он подменяет байт обратно и делает вид, что ничего и не трогал. Теперь разберемся, что сделали мы. Мы поставили брейкпойнт на адрес cs:100h, туда записался байт CCh, потом мы запустили распаковщик… А что сделал распаковщик? Он про наш брейкпойнт ничего не знал и байт по адресу cs:100h снова перезаписал. На первый байт исходной программы. А раз там больше не CCh, процессор там не прервется.
Выводов отсюда два:
1. не всегда простой способ “в две кнопки” оказывается действительно простым.
2. надо знать хотя бы базовые теоретические основы.
Так как же заставить программу прерваться на нужном нам адресе? Надо убедиться, что распаковщик туда уже ничего писать не будет. Для этого надо просто пошагово выполнять распаковку, и следить при этом за регистром di. Как только он перестанет быть равным 100h – можно идти и ставить брейкпойнт.
В общем, мы, так или иначе, первым способом или вторым, остановились как раз в тот момент, когда программа распаковалась, но выполняться еще не начала. Хотелось бы эту распакованную программу заиметь в виде com-файла, не так ли? IDA всё-таки имеет более дружелюбный интерфейс для изучения программ, чем Turbo Debugger?. Процесс сохранения части оперативной памяти на диск называется дампингом. Так вот нам надо сдампить область памяти cs:100h – cs:1B9h. Для этого ищем начало этой области в окошке данных, после чего выделяем нужную область, щелкаем правой кнопкой, выбираем “Вlock -> Write…”, указываем имя файла, и получаем распакованный com-файл.
Теперь, когда мы уловили общий принцип работы распаковщика, стоит, пожалуй, подробней остановиться на загадочном числе 78xxh (мы же исследователи как-никак) и том, почему вместо него я писал 7900h. Если внимательно изучить код распаковщика, видно, что его работа не зависит от того адреса, по которому он расположен. Вызовы функций и переходы используют относительные адреса (относительно места вызова), а обращение к запакованным данным происходит при помощи регистра bp (см. loc_10152), который в подготовительном коде инициализируется адресом первой инструкции кода распаковки. Именно поэтому 78xxh можно заменить на 7900h. Логика работы распаковщика от этого не исказится.
Почему же 78h? Потому что так решил автор программы-упаковщика. Если поразмыслить, вместо 78h можно записать любое число, которое удовлетворяет следующим условиям:
Если выразить эти условия формулами, получится:
, где n – максимальная глубина стека при распаковке.
Последние два условия, понятно, можно объединить. Кроме того, изучение кода показывает, что программа не рекурсивная, поэтому стек вряд ли будет глубже 4 слов (иными словами, sp не опустится ниже, чем FFF6). Поэтому верхняя граница на x = 7Fh. Нижняя граница на x = 1+max(75h+packed size;unpacked size)/256, отсюда самое минимальное значение minx = 2. Нетрудно также выяснить максимальный размер исходных данных – 7E00h = 31,5кб.
Из этого небольшого исследования можно сделать вывод, что автор неправильно делает, что жестко прописывает 78h в код. В нашем случае вполне хватило бы x = 2, при котором копирование распаковщика можно было осуществить в 60 раз быстрее без потери функциональности и без увеличения размера исходной программы. Кроме того, значение 78h не позволит корректно запаковывать и распаковывать файлы, длиннее, чем 7700h = 29,75кб, поэтому автор программы размером 30кб будет сильно разочарован этим упаковщиком. =)