February 15, 2019

Фундаментальные основы хакерства. Продолжаем осваивать отладчик

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

Перемещаемость EXE

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

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

Также мы выяснили, что PE-файл может быть загружен по адресу, отличному от того, для которого он был создан (это свойство называется перемещаемостью), при этом система автоматически корректирует все ссылки на абсолютные адреса, заменяя их новыми значениями. В результате образ файла в памяти не будет соответствовать тому, что записано на диске. И это происходит после каждой перезагрузки системы, а порой даже перезапуска приложения, всякий раз PE-файл размещается по новому адресу. Вдобавок к этому если раньше (до «Висты») системный загрузчик мог перемещать только DLL (в то же время, если ему не удавалось разместить в памяти по заданным адресам EXE, Windows выдавала ошибку загрузки модуля), то теперь исполняемые файлы тоже подвержены перемещению.

Между тем ошибка загрузки модуля происходила довольно редко, потому что, как мы прекрасно знаем, для каждого процесса Windows выделяет независимое виртуальное адресное пространство. Во времена 32-битной Windows это было 2 Гбайт ядерного пространства и 2 Гбайт пользовательского. То есть по факту для процесса выделялось только 2 Гбайт, а 2 Гбайт ядерного пространства были общими для всех процессов, к которым код из пользовательского режима доступа не имел. При включении режима PAE пользовательскому пространству доставалось 3 Гбайт и, соответственно, 1 Гбайт — ядерному. PAE в x86-процессорах стал нужен для работы DEP, препятствующей выполнению кода в секции данных. Он автоматически включен во всех более поздних процессорах. Если пользовательское пространство обособлено для конкретного процесса, то пространство ядра общее для всех привилегированных механизмов, выполняющихся в 0-м кольце.

Для x64 картина в целом аналогична. Адресное пространство заметно увеличилось, теоретически до 16 Эбайт. Но, так как современные процессоры фактически используют только 48 бит для адресации пространства, реально используется только малая часть: 8 Тбайт для пользовательского режима и 248 Тбайт для ядерного. Конечно, пока эти размеры кажутся заоблачными — примерно как 4 Гбайт в конце 1980-х.

Теперь, когда в общих чертах картина обрисована, можно двигаться дальше. Наше приложение passCompare1 откомпилировано 32-битным компилятором. Это позволит нам избавиться от лишних циферок, сохранив при этом смысл происходящего. Итак, чтобы найти адрес нужной инструкции на диске, вкратце повторим последовательность действий из предыдущей статьи, так как за прошедшее время ты наверняка перезагрузил компьютер, поэтому адреса в памяти изменились.

Сначала воспользуемся утилитой dumpbin из штатной поставки Visual Studio, на этот раз с ее помощью найдем базовый адрес модуля — тот, с которым работают HIEW (или другой шестнадцатеричный редактор) и дизассемблер:

>dumpbin /headers passcompare1.exe

OPTIONAL HEADER VALUES

400000 image base (00400000 to 00405FFF)

Натравим отладчик на подопытную программу. Определим адрес загрузки модуля приложения в памяти (в твоем случае результаты будут другими):

0:004> lmf m passcompare1

start end module name

00d30000 00d36000 passCompare1 passCompare1.exe

Далее нам нужно найти адрес инструкции, которую требуется изменить. Для этого первым делом надо найти расположение эталонного пароля (он находится в секции .rdata), поэтому воспользуемся командой !dh passCompare1, которая выведет сведения о секциях. Сложим адрес загрузки модуля и виртуальный адрес секции .rdata.

Таким образом, в моем случае секция .rdata начинается с адреса 0xD32000. Немного прокрутив вывод отладчика вниз, я вижу, что пароль располагается по адресу 0xD32108. Теперь нам нужен адрес расположения инструкции в памяти. Не напрягая мозг, легким движением рук поставим бряк на пароль: ba r4 d32108. Продолжим отладку и введем любой пароль, после всплытия отладчика по команде t сделаем шаг вперед. И двумя строками выше в дизассемблерном листинге отладчика мы видим сравнивающую инструкцию test eax, eax, которую нам надо отломать, а слева в первом столбце — ее адрес: 0xD310A7.

Если попробовать найти его в файле, то HIEW скажет, что такой адрес отсутствует. Но теперь, когда есть все необходимые значения, нетрудно посчитать, что адрес 0xD310A7 будет соответствовать адресу

адрес инструкции в файле на диске == адрес инструкции в памяти – (адрес загрузки модуля – базовый адрес модуля):
0xD310A7 – (0xD30000 – 0x400000) == D310A7 – 0x930000 == 0x4010A7

Для проверки заглянем в дизассемблерный листинг (или первую статью) и с удовлетворением обнаружим, что это как раз тот адрес, инструкцию по которому мы правили:

004010A7: 85 C0 test eax,eax

004010A9: 74 63 je 0040110E

Все верно, посмотри, как хорошо это совпадает с дампом отладчика:

00d310a7 85c0 test eax, eax

00d310a9 7463 je passCompare1!main+0xce (00d3110e)

Следующим действием отломаем программу. Это мы уже проходили в третьем шаге первой статьи. Ничего нового непосредственно во взломе не появилось, мы нашли адрес, а процедура кряка такая же: запускаем HIEW — и в бой.

Перемещаемость DLL

Под занавес прошлой статьи мы упомянули, что в старых версиях Windows можно было загрузить один и тот же exe-модуль два раза, представив его в виде DLL. Однако сейчас этот трюк не прокатывает, собственно, он и не нужен, поскольку, как мы увидели в предыдущем разделе, Windows свободно перемещает в памяти загруженный exe-модуль относительно заранее определенных адресов. Теперь давай разберемся, как обстоят дела с динамическими библиотеками.

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

Единственная проблема — как отличить действительные непосредственные смещения от констант, совпадающих с ними по значению? Не дизассемблировать же, в самом деле, DLL, чтобы разобраться, какие именно ячейки в ней необходимо «подкрутить»? Верно, куда проще перечислить их адреса в специальной таблице, расположенной непосредственно в загружаемом файле и носящей гордое имя «Таблицы перемещаемых элементов». За ее формирование отвечает компоновщик.

Чтобы познакомиться с ней поближе, откомпилируем и изучим следующий пример:

fixupdemo.c:
__declspec(dllexport) void meme(int x)
{
    static int a=0x666;
    a=x;
}

Откомпилируем командой cl fixupdemo.c /LD и тут же дизассемблируем его:

DUMPBIN /DISASM fixupdemo.dll > fixupdemo-disasm.txt
DUMPBIN /SECTION:.data /RAWDATA fixupdemo.dll > fixupdemo-data.txt

10001000: 55 push ebp

10001001: 8B EC mov ebp,esp

10001003: 8B 45 08 mov eax,dword ptr [ebp+8]

10001006: A3 30 60 00 10 mov dword ptr ds:[10006030h],eax

1000100B: 5D pop ebp

1000100C: C3 ret

RAW DATA #3

10006000: 00 00 00 00 00 00 00 00 00 00 00 00 63 28 00 10 …………c(..

10006010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 …………….

10006020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 …………….

10006030: 66 06 00 00 E3 11 00 10 FF FF FF FF 00 00 00 00 f…a…yyyy….

Судя по коду, запись содержимого EAX всегда происходит в ячейку 0x10006030. Но не торопись с выводами! Выведем содержимое таблицы перемещаемых элементов:

DUMPBIN /RELOCATIONS fixupdemo.dll > fixupdemo-relocations.txt

BASE RELOCATIONS #4

1000 RVA, 164 SizeOfBlock

7 HIGHLOW 10006030

1C HIGHLOW 10005004

23 HIGHLOW 10008A50

32 HIGHLOW 10008A50

3A HIGHLOW 10008A51

Таблица перемещаемых элементов-то не пуста! И первая же ее запись указывает на ячейку 0x100001007, полученную алгебраическим сложением смещения 0x7 с RVA-адресом 0x1000 и базовым адресом загрузки 0x10000000 (получи его с помощью DUMPBIN самостоятельно). Смотрим — ячейка 0x100001007 принадлежит инструкции MOV [0x10006030],EAX и указывает на самый старший байт непосредственного смещения. Вот это самое смещение и корректирует загрузчик в ходе подключения динамической библиотеки (разумеется, если в этом есть необходимость).

Желаешь проверить? Пожалуйста, создадим две копии одной DLL (например, с помощью команды copy fixupdemo.dll fixupdemo2.dll) и загрузим их поочередно следующей программой:

fixupload.c:

#include <windows.h>
main()
{
    void (*demo) (int a);
    HMODULE h;
    if ((h=LoadLibrary("fixupdemo.dll")) &&
        (h=LoadLibrary("fixupdemo2.dll")) &&
        (demo=(void (*)(int a))GetProcAddress(h,"meme")))
        demo(0x777);
}

Сразу же из командной строки откомпилируем:

cl fixupload.c

Поскольку по одному и тому же адресу две различные DLL не загрузишь (откуда же системе знать, что это одна и та же DLL!), загрузчику приходится прибегать к ее перемещению. Загрузим откомпилированную программу в отладчик и установим точку останова на функцию LoadLibraryA командой bp KernelBase!LoadLibraryA. К слову, команда bp позволяет установить точку останова по адресу, определенному функцией.

Установка точки останова на первую команду необходима, чтобы пропустить Startup-код и попасть в тело функции main. Как легко убедиться, исполнение программы начинается отнюдь не с main, а со служебного кода, в котором очень легко утонуть. Но откуда взялась загадочная буква А на конце имени функции? Ее происхождение тесно связано с введением в Windows поддержки уникода.

Применительно к LoadLibrary — теперь имя библиотеки может быть написано на любом языке. Звучит заманчиво, но не ухудшает ли это производительность? Разумеется, ухудшает, еще как! В подавляющем большинстве случаев вполне достаточно старой доброй кодировки ASCII. Так какой же смысл бросать драгоценные такты процессора на ветер?

Ради производительности было решено поступиться размером, создав отдельные варианты функций для работы с уникодом и ASCII-символами. Первые получили суффикс W (от Wide — широкий), а вторые — А (от ASCII). Эта тонкость скрыта от прикладных программистов. Какую именно функцию вызывать — W или А, решает компилятор, но при работе с отладчиком необходимо указывать точное имя функции — самостоятельно определить суффикс он не в состоянии. Камень преткновения в том, что некоторые функции, например ShowWindows, вообще не имеют суффиксов — ни А, ни W, и их библиотечное имя совпадает с каноническим. Как же быть?

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

>DUMPBIN /IMPORTS fixupload.exe > fixupload-imports.exe

175 GetVersionExA

1C2 LoadLibraryA

CA GetCommandLineA

174 GetVersion

7D ExitProcess

29E TerminateProcess

F7 GetCurrentProcess

Из приведенного выше фрагмента видно, что LoadLibrary все-таки имеет суффикс А, а вот функции ExitProcess и TerminateProcess не имеют суффиксов, поскольку вообще не работают со строками.

Но вернемся к нашим баранам, от которых нам пришлось так далеко отойти. Итак, мы поставили бряк на LoadLibraryA и продолжили выполнение программы, она моментально снова останавливается на точке останова.

Нажимаем сочетание Shift + F11 для выхода из LoadLibraryA (анализировать ее, в самом деле, ни к чему) и оказываемся в легко узнаваемом теле функции main:

0040100b ff1504504000 call dword ptr [fixupload+0x5004 (00405004)]

00401011 8945f8 mov dword ptr [ebp-8], eax ss:002b:0019ff38=00401055

00401014 837df800 cmp dword ptr [ebp-8], 0

00401018 7437 je fixupload+0x1051 (00401051)

0040101a 6840604000 push offset fixupload+0x6040 (00406040)

0040101f ff1504504000 call dword ptr [fixupload+0x5004 (00405004)]

00401046 6877070000 push 777h

0040104b ff55fc call dword ptr [ebp-4]

Только после возвращения из LoadLibraryA отладчик не подставил в место вызова функции ее символьное имя строкой выше выделенной:

call dword ptr [fixupload+0x5004 (00405004)]

Запомним ее как вызов LoadLibraryA.

Обрати внимание на содержимое регистра EAX (для этого служит команда r <имя регистра>) — функция возвратила в нем адрес загрузки (на моем компьютере равный 0x10000000). Продолжая трассировку (клавиша F10), дождись выполнения второго вызова LoadLibraryA. Не правда ли, на этот раз адрес загрузки изменился? На моем компьютере он равен 0x001d0000.

Приближаемся к вызову функции demo. В отладчике это выглядит так.

push 777h
call dword ptr [ebp-4]

Вторая инструкция ни о чем не говорит, но вот аргумент 0x777 в первой инструкции определенно что-то нам напоминает. См. исходный текст fixupload.c. Не забудь переставить палец с клавиши F10 на клавишу F8, чтобы войти внутрь функции.

001d1000 55 push ebp

001d1001 8bec mov ebp, esp

001d1003 8b4508 mov eax, dword ptr [ebp+8]

001d1006 a330601d00 mov dword ptr [fixupdemo2!meme+0x5030 (001d6030)], eax

001d100b 5d pop ebp

001d100c c3 ret

Вот оно! Системный загрузчик скорректировал адрес ячейки согласно базовому адресу загрузки самой DLL. Это, конечно, хорошо, да вот проблема — в оригинальной DLL нет ни такой ячейки, ни даже последовательности A3 30 60 1D 00, в чем легко убедиться, произведя контекстный поиск. Допустим, вознамерились бы мы затереть эту команду NOP’ами. Как найти это место в оригинальной DLL?

Обратим свой взор выше, на команды, заведомо не содержащие перемещаемых элементов:

001d1000 55 push ebp

001d1001 8bec mov ebp, esp

001d1003 8b4508 mov eax, dword ptr [ebp+8]

Отчего бы не поискать последовательность 55 8B EC 8B 45 08 A3? В данном случае это сработает, смотри, как хорошо совпадает:

10001000: 55 push ebp

10001001: 8B EC mov ebp,esp

10001003: 8B 45 08 mov eax,dword ptr [ebp+8]

10001006: A3 30 60 00 10 mov dword ptr ds:[10006030h],eax

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

модифицированный загрузчиком адрес – (базовый адрес загрузки – рекомендуемый адрес загрузки):
0x1d6030 – (0x001d0000 – 0x10000000) == 0x1d6030 – FFFFFFFFF01D0000 == 0x10006030

Учитывая обратный порядок следования байтов, получаем, что инструкция

mov dword ptr ds:[10006030h],eax

в машинном коде должна выглядеть так: A3 30 60 00 10. Ищем ее HIEW’ом, и чудо — она есть!

Заключение

Вот и подошла к концу третья статья цикла, в котором мы переосмысливаем фундаментальный труд Криса Касперски и перекладываем его на современные рельсы — новые оси, процессоры и компиляторы. Оставайся на связи и не забывай отписываться в комментах о своих чаяниях.

Примечание к шагу № 2 («Знакомство с дизассемблером») из первой статьи

После очередного обновления визуальной студии я решил испробовать, как работает штатный дизассемблер, натравив его на passCompare1.exe, не претерпевшего перекомпиляции. С удивлением я обнаружил, что, как в старые добрые времена, в секции кода в качестве указателей на инициализированные переменные используются шестнадцатеричные смещения. Впрочем, от этого ситуация не меняется: вместо того чтобы в секции кода искать эталонный пароль, прежде надо заглянуть в секцию данных и узнать, по какому смещению он там находится.

00402100: 73 73 77 6F 72 64 3A 00 6D 79 47 4F 4F 44 70 61  ssword:.myGOODpa

Так как нам надо выровнять смещение, чтобы пароль начинался с начала строки, прибавим к смещению 8 — число символов, на которые надо сместить ssword:.. В результате будем искать итоговое смещение 402108 в секции кода и найдем ту же самую инструкцию, что и прежде:

0040107D: B9 08 21 40 00     mov         ecx,402108h

Только вместо строки мы обнаруживаем ее адрес в секции данных. А дальше следуем описанному в первой статье алгоритму.