«Фундаментальные основы хакерства»
Перед тобой уже во второй раз обновленная версия цикла «Фундаментальные основы хакерства». В 2018 году текст Криса Касперски был переделан для соответствия новым версиям Windows и Visual Studio, а теперь обновлен с учетом отладки программ для 64-разрядной архитектуры.
Читай также улучшенные версии прошлых статей цикла:
- Учимся анализировать программы для x86-64 с нуля
- Используем отладчик для анализа 64-разрядных программ в Windows
Все новые версии статей доступны без платной подписки.
Цикл «Фундаментальные основы хакерства» со всеми обновлениями опубликован в виде книги, купить ее по выгодной цене ты можешь на сайте издательства «Солон‑пресс».
Конечно, у нас есть дизассемблер, с помощью которого мы нашли эти адреса (подробнее об этом в статье «Учимся анализировать программы для x86-64 с нуля»). Это удалось во многом благодаря тому, что препарируемая нами программа очень маленькая и нам не составило труда разобраться в ее дизассемблированном листинге. А если бы исследуемая программа весила сотни мегабайт?
Также мы выяснили, что PE-файл может быть загружен по адресу, отличному от того, для которого он был создан (это свойство называется перемещаемостью), при этом система автоматически корректирует все ссылки на абсолютные адреса, заменяя их новыми значениями. В результате образ файла в памяти не будет соответствовать тому, что записано на диске. И это происходит после каждой перезагрузки системы, а порой даже перезапуска приложения. Всякий раз PE-файл помещается по новому адресу.
Вдобавок к этому если раньше (до «Висты») системный загрузчик мог перемещать только DLL (в то же время, если ему не удавалось разместить в памяти по заданным адресам .exe, Windows выдавала ошибку загрузки модуля), то теперь исполняемые файлы тоже подвержены перемещению.
Между тем ошибка загрузки модуля происходила довольно редко, потому что, как мы прекрасно знаем, для каждого процесса Windows выделяет независимое виртуальное адресное пространство. Во времена 32-битной Windows это было 2 Гбайт ядерного пространства и 2 Гбайт пользовательского. То есть по факту для процесса выделялось только 2 Гбайт, а 2 Гбайт ядерного пространства были общими для всех процессов, к которым код из пользовательского режима доступа не имел.
При включении режима PAE пользовательскому пространству доставалось 3 Гбайт и, соответственно, 1 Гбайт — ядерному. PAE в процессорах x86 стал нужен для работы системы DEP, препятствующей выполнению кода в секции данных. DEP автоматически включена во всех более поздних процессорах. Если пользовательское пространство обособлено для конкретного процесса, то пространство ядра общее для всех привилегированных механизмов, выполняющихся в нулевом кольце.
Для x86-64 картина в целом аналогична. Адресное пространство заметно увеличилось, теоретически до 16 Эбайт. Но так как современные процессоры фактически используют только 48 бит для адресации пространства, реально используется лишь малая часть: 8 Тбайт для пользовательского режима и 248 Тбайт для ядерного. Конечно, пока эти размеры кажутся заоблачными — примерно как 4 Гбайт в конце 1980-х!
Теперь, когда в общих чертах картина обрисована, можно двигаться дальше. Чтобы найти адрес нужной инструкции на диске, вкратце повторим последовательность действий из прошлой статьи. За прошедшее время ты наверняка перезагрузил компьютер, поэтому адреса в памяти изменились.
Сначала воспользуемся утилитой dumpbin из штатной поставки Visual Studio, на этот раз с ее помощью найдем базовый адрес модуля — тот, с которым работают Hiew (или другой шестнадцатеричный редактор) и дизассемблер:
dumpbin /headers passcompare1.exe
OPTIONAL HEADER VALUES
...
140000000 image base (0000000140000000 to 0000000140007FFF)
…
Натравим отладчик на подопытную программу. Определим адрес загрузки модуля приложения в памяти (в твоем случае результаты будут другими):
0:000> lmf m passcompare1
start end module name
00007ff7`159b0000 00007ff7`159b8000 passCompare1 passCompare1.exe
Далее нам нужно найти адрес инструкции, которую надо изменить. Для этого первым делом ищем расположение эталонного пароля (он находится в секции .
), поэтому воспользуемся командой !
, которая выведет сведения о секциях. Сложим адрес загрузки модуля и виртуальный адрес секции .
.
Таким образом, в моем случае секция .
начинается с адреса 0x7FF7159B2000
. Немного прокрутив вывод отладчика вниз, я вижу, что пароль располагается по адресу 0x7ff7159b2280
. Теперь нам нужен адрес расположения инструкции в памяти. Не напрягая мозг, легким движением руки поставим бряк на пароль: ba
. Продолжим отладку и введем любой пароль, после всплытия отладчика по команде gu
сделаем выход из текущей функции. Мы попадаем на сравнивающую инструкцию TEST
, которую нам надо заломить, а слева в первом столбце видим ее адрес: 0x7ff7159b10c3
. Если попробовать найти его в файле, то Hiew скажет, что такой адрес отсутствует.
Но теперь, когда есть все необходимые значения, нетрудно посчитать, что адрес 0x7ff7159b10c3
будет соответствовать адресу
адрес инструкции в файле на диске == адрес инструкции в памяти – (адрес загрузки модуля – базовый адрес модуля):
0x7ff7159b10c3 – (0x7ff7159b0000 – 0x140000000) == 0x7ff7159b10c3 – 7FF5D59B0000 == 1400010C3
Для проверки заглянем в дизассемблерный листинг и с удовлетворением обнаружим, что это как раз тот адрес, инструкцию по которому мы правили:
00000001400010C3: 85 C0 test eax,eax
00000001400010C5: 74 58 je 000000014000111F
Все верно, посмотри, как хорошо это совпадает с дампом отладчика:
00007ff7`159b10c3 85c0 test eax, eax
00007ff7`159b10c5 7458 je passCompare1!main+0xaf (7ff7159b111f)
Следующим действием заломим программу. Это мы уже проходили в третьем шаге первой статьи. Ничего нового непосредственно во взломе не появилось, мы нашли адрес, а процедура кряка такая же: запускаем Hiew — и в бой.
Перемещаемость DLL
Под занавес прошлой статьи мы упомянули, что в старых версиях Windows можно было загрузить один и тот же модуль .exe два раза, представив его в виде DLL. Однако сейчас этот трюк не прокатывает, собственно, он и не нужен, поскольку, как мы увидели в предыдущем разделе, Windows свободно перемещает в памяти загруженный модуль .exe относительно заранее определенных адресов. Теперь давай разберемся, как обстоят дела с динамическими библиотеками.
Для разнообразия следующие примеры откомпилируем для платформы IA-32. На самом деле 32-битных приложений разработано так много, что их придется анализировать еще много лет.
В том случае, когда адрес загрузки DLL заранее неизвестен, системный загрузчик корректирует непосредственные смещения в соответствии с выбранным базовым адресом загрузки. Это несколько замедляет загрузку приложения, но зато не ухудшает быстродействие самой программы.
Единственная проблема — как отличить действительные непосредственные смещения от констант, совпадающих с ними по значению? Не дизассемблировать же, в самом деле, DLL, чтобы разобраться, какие именно ячейки в ней необходимо «подкрутить»? Верно, куда проще перечислить их адреса в специальной таблице, расположенной непосредственно в загружаемом файле и носящей гордое имя «таблицы перемещаемых элементов». За ее формирование отвечает компоновщик.
Чтобы познакомиться с ней поближе, откомпилируем и изучим следующий пример:
fixupdemo.c:__declspec(dllexport) void meme(int x){ static int a=0x666; a=x;}
Откомпилируем командой cl
и тут же дизассемблируем его:
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 [
и указывает на самый старший байт непосредственного смещения. Вот это самое смещение и корректирует загрузчик в ходе подключения динамической библиотеки (разумеется, если в этом есть необходимость).
Желаешь проверить? Пожалуйста, создадим две копии одной DLL (например, с помощью команды copy
) и загрузим их поочередно следующей программой:
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
.
Поскольку по одному и тому же адресу две различные DLL не загрузишь (откуда же системе знать, что это одна и та же DLL!), загрузчику приходится прибегать к ее перемещению. Загрузим откомпилированную программу в отладчик и установим точку останова на функцию LoadLibraryA
командой bp
.
К слову, команда 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.
. Не забудь переставить палец с клавиши 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
, в чем легко убедиться, произведя контекстный поиск. Допустим, вознамерились бы мы затереть эту команду NOP’ами. Как найти это место в оригинальной DLL?
Обратим свой взор выше, на команды, заведомо не содержащие перемещаемых элементов:
001d1000 55 push ebp
001d1001 8bec mov ebp, esp
001d1003 8b4508 mov eax, dword ptr [ebp+8]
Отчего бы не поискать последовательность 55
? В данном случае это сработает, смотри, как хорошо совпадает:
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
в машинном коде должна выглядеть так: A3
. Ищем ее в Hiew, и чудо — она есть!