Содержание статьи
«Фундаментальные основы хакерства»
Перед тобой уже во второй раз обновленная версия цикла «Фундаментальные основы хакерства». В 2018 году Юрий Язев изменил текст Криса Касперски для соответствия новым версиям Windows и Visual Studio, а теперь внес правки с учетом отладки программ для 64-разрядной архитектуры.
Читай также улучшенные версии прошлых статей цикла:
- Учимся анализировать программы для x86-64 с нуля
- Используем отладчик для анализа 64-разрядных программ в Windows
- Находим реальные адреса инструкций в исполняемых файлах x86-64
Все новые версии статей доступны без платной подписки.
Цикл «Фундаментальные основы хакерства» со всеми обновлениями опубликован в виде книги, купить ее по выгодной цене ты можешь на сайте издательства «Солон‑пресс».
В прошлой статье цикла мы узнали, как соотнести адреса байтов в виртуальной памяти с их реальным расположением на носителе. Это потребовало от нас напрячь мозг и применить математику. Между тем, как мы увидели из предыдущих статей, непосредственный взлом, когда известно месторасположение защитного механизма, представляет собой элементарную задачу, которую легко решить с помощью Hiew или другого редактора PE-файлов.
Способ 1. Прямой поиск введенного пароля в памяти
Пароль, хранящийся в теле программы открытым текстом, — скорее из ряда вон выходящее исключение, чем правило. К чему услуги хакера, если пароль и без того виден невооруженным глазом? Поэтому разработчики защиты всячески пытаются скрыть его (о том, как именно они это делают, мы поговорим позже).
Впрочем, учитывая размер современных дистрибутивов, программист может без особого труда поместить пароль в любом завалящем файле, попутно снабдив его «крякушами» — строками, выглядящими как пароль, но паролем не являющимися. Попробуй разберись, где тут липа, а где нет, тем более что подходящих на эту роль строк в проекте средней величины может быть несколько сотен, а то и тысяч!
Давай подойдем к решению проблемы от обратного — будем искать не исходный пароль, который нам неизвестен, а ту строку, которую мы скормили программе в качестве пароля. А найдя, установим на нее бряк, и дальше всё точно так же, как и раньше. Бряк всплывает на обращение по сравнению, мы выходим из сравнивающей процедуры, корректируем JMP
и...
Взглянем еще раз на исходный текст ломаемого нами примера passCompare1.
:
for(;;){ printf("Enter password:"); fgets(&buff[0],PASSWORD_SIZE,stdin); if (strcmp(&buff[0],PASSWORD)) printf("Wrong password\n"); else break; if (++count>2) return -1;}
Обрати внимание — в buff
читается введенный пользователем пароль, сравнивается с оригиналом, затем (при неудачном сравнении) запрашивается еще раз, но (!) при этом buff
не очищается! Отсюда следует, что, если после выдачи ругательства «Wrong password» вызвать отладчик и пройтись по памяти контекстным поиском, можно обнаружить тот заветный buff
, а остальное уже дело техники!
Итак, приступим (мы еще не знаем, во что мы ввязываемся, — но, увы, в жизни все сложнее, чем в теории). На этот раз запустим passCompare1.
отдельно от отладчика. Затем подключимся к процессу из отладчика («Attach to process» в WinDbg). Обрати внимание: в окне выбора процесса отображаются все запущенные процессы и для каждого из них выводится его разрядность в столбце Platform. Вводим любой пришедший на ум пароль (например, KPNC
), пропускаем возмущенный вопль Wrong мимо ушей и в отладчике нажимаем Break (сочетание клавиш Alt-Del).
Попробуем отыскать в памяти введенный пароль:
0:004> s -a 0x0 L? 0x7FFFFFFFFFF "KPNC Kaspersky"
Пояснения
Первый параметр после команды s
— флаг -a
— определяет цель поиска как набор ASCII-символов. Второй параметр — смещение, по которому начать искать. Вообще‑то начинать поиск с нулевого смещения — идея глупая. Судя по карте памяти, здесь расположен служебный код и искомого пароля быть не может. Впрочем, это ничему не вредит, и так гораздо быстрее, чем разбираться, с какого адреса загружена программа и откуда именно начинать поиск.
Третий параметр — верхний предел поиска, то есть докуда надо искать. Так как в 64-битной Windows адресное пространство процесса ограничено 8 Тбайт, верхний лимит составляет 0x7FFFFFFFFFF
. Последний параметр — собственно искомая строка. Обрати внимание, что мы ищем не всю строку, а только ее часть (KPNC
против KPNC
). Это позволяет избавиться от ложных срабатываний, возникающих из‑за ссылок на внутренние буфера.
Результат
0000002f10effe30 4b 50 4e 43 20 4b 61 73-70 65 72 73 6b 79 2b 2b KPNC Kaspersky++
000001dcd30f2580 4b 50 4e 43 20 4b 61 73-70 65 72 73 6b 79 2b 2b KPNC Kaspersky++
Целых два вхождения! Почему два? Предположим, что при чтении ввода с клавиатуры символы сперва попадают в системный буфер, который и дает ложное срабатывание. Тем не менее не ставить же, не разобравшись, сразу обе точки останова. В данном случае четырех отладочных регистров процессора хватит, а как быть, если бы мы нашли десяток вхождений? Да и в двух бряках немудрено заблудиться с непривычки! Как отфильтровать помехи?
Начинаем думать головой
На помощь приходит карта памяти — зная владельца региона, которому принадлежит буфер, можно очень многое сказать об этом буфере. Наскоро набив уже знакомую команду !
, мы получим приблизительно следующее (выбраны сведения только о секциях .
и .
):
SECTION HEADER #2
.rdata name
101C virtual size
2000 virtual address
1200 size of raw data
1400 file pointer to raw data
0 file pointer to relocation table
0 file pointer to line numbers
0 number of relocations
0 number of line numbers
40000040 flags
Initialized Data
(no align specified)
Read Only
SECTION HEADER #3
.data name
638 virtual size
4000 virtual address
200 size of raw data
2600 file pointer to raw data
0 file pointer to relocation table
0 file pointer to line numbers
0 number of relocations
0 number of line numbers
C0000040 flags
Initialized Data
(no align specified)
Read Write
Заодно определим базовый адрес модуля приложения: lmf
(в моем конкретном случае он равен 0x7ff7d78f0000
, а у тебя значение, скорее всего, будет другим). Узнаем, куда в памяти загружена секция .
:
0x7ff7d78f0000 + 0x2000 == 0x7ff7d78f2000
И куда загружена секция .
:
0x7ff7d78f0000 + 0x4000 == 0x7ff7d78f4000
Это гораздо выше найденных адресов расположения буферов с введенным паролем. Следовательно, найденные адреса не указывают в области .
и .
.
Думаем дальше. Адрес 0x1dcd30f2580
выходит далеко за пределы ломаемого приложения, и вообще непонятно, чему он принадлежит. Почесав затылок, мы вспомним о такой «вкусности» Windows, как куча (heap). С помощью команды !
посмотрим, где она начинается:
0:004> !heap
Heap Address NT/Segment Heap
1dcd30e0000 NT Heap
1dcd2fd0000 NT Heap
Из этого заключаем, что адрес 0x1dcd30f2580
явно находится в куче.
Разбираемся дальше. Поскольку стек растет сверху вниз (то есть от старших адресов к младшим), адрес 0x2f10effe30
явно находится в стеке. Уверенность подогревает тот факт, что большинство программистов размещает буфера в локальных переменных, ну а локальные переменные, в свою очередь, размещаются компилятором в стеке.
Ну что, попробуем поставить бряк по первому адресу?
0:004> ba r4 0x2f10effe30
0:004> g
На втором запросе пароля снова вводим KPNC
. Жмем Enter и дожидаемся сиюминутной активации отладчика. Бряк произошел на второй из этих строк:
00007ffb`5d3c4ffc 8806 mov byte ptr [rsi], al
00007ffb`5d3c4ffe 48ffc6 inc rsi
00007ffb`5d3c5001 4889742428 mov qword ptr [rsp+28h], rsi
Смотрим, что находится в регистре rsi
:
dc rsi
0000002f`10effe30 434e504b 73614b20 73726570 2b2b796b KPNC Kaspersky++
Впрочем, этого и следовало ожидать. Попробуем выйти из текущей функции по Shift-F11. И мы снова попадем на эту же строку. Вновь посмотрим содержимое этого регистра:
dc rsi
0000002f`10effe31 20434e50 7073614b 6b737265 0a2b2b79 PNC Kaspersky++
Ага, один символ откусан. Следовательно, мы находимся в сравнивающей процедуре. Выйдем из нее нажатием на F5, так как при нажатии на Shift-F11 мы перейдем на следующую итерацию перебора символов.
00007ffb`5d37566e 77c8 ja ucrtbase!strcmp+0x8 (7ffb5d375638)
00007ffb`5d375670 488b01 mov rax, qword ptr [rcx]
00007ffb`5d375673 483b040a cmp rax, qword ptr [rdx+rcx]
00007ffb`5d375677 75bf jne ucrtbase!strcmp+0x8 (7ffb5d375638)
И вот мы в теле уже хорошо нам знакомой (развивай зрительную память!) процедуры сравнения оригинального и введенного пользователем паролей. На всякий случай для пущей убежденности выведем значение указателей [
и RCX
, чтобы узнать, что с чем сравнивается:
0:000> dc [RDX+RCX]
00007ff7`d78f2280 4f47796d 6170444f 6f777373 000a6472 myGOODpassword..
0:000> dc RCX
0000002f`10effe30 434e504b 73614b20 73726570 2b2b796b KPNC Kaspersky++
Как раз то, что мы ищем!
Ну а остальное мы уже проходили. Записываем адрес условного перехода (ключевую последовательность для поиска), с помощью сведений из прошлой статьи находим на носителе адрес инструкции, соответствующей спроецированной в памяти, правим исполняемый файл, и всё окей.
Выводы
Итак, мы познакомились с одним более или менее универсальным способом взлома защит, основанных на сравнении пароля (позже мы увидим, что он подходит и для защит, основанных на регистрационных номерах). Его основное достоинство — простота. А недостатки... недостатков у него много:
- если программист очистит буфера после сравнения, поиск введенного пароля ничего не даст, разве что останутся системные буфера, которые так просто не затрешь, но отследить перемещения пароля из системных буферов в локальные не так‑то легко;
- служебных буферов много, и очень трудно определить, какой из них «настоящий». Программист же может располагать буфер и в сегменте данных (статический буфер), и в стеке (локальный буфер), и в куче, и даже выделять память низкоуровневыми вызовами типа VirtualAlloc или... да мало ли как разыграется его фантазия. В результате подчас приходится просеивать все найденные вхождения тупым перебором.
Способ 2. Бряк на функции ввода пароля
Взлом приложения с GUI
Настала пора разнообразить наш объект взлома. Теперь попробуем заломить приложение с графическим интерфейсом. В качестве тренировки разберем passCompare3
. Это то же самое, что и passCompare1.
, только с графическим интерфейсом на основе MFC Dialog Based App (ищи в скачиваемых материалах к статье).
Также обрати внимание на то, что работа с текстом в этом примере организована по‑другому. Если раньше мы работали с базовым типом char, то здесь используется обертка — класс CString, что, скорее всего, при взломе профессиональных приложений будет встречаться нам чаще. Кроме двух кнопок, идущих в заготовке по умолчанию, добавь на форму элемент Edit Control. Свяжи его с переменной m_password
и создай событие обработки нажатия на кнопке OK. Это и будет ключевая процедура приложения, проверяющая введенный пароль на равенство эталонному:
const CString PASSWORD = _T("myGOODpassword");…void CpassCompare3Dlg::OnBnClickedOk(){ CString str = NULL;m_password.GetWindowText(str);if (PASSWORD.Compare(str)){ MessageBox(_T("Wrong password")); m_password.SetSel(0, -1, 0); return;}else{ MessageBox(_T("Password OK"));}CDialogEx::OnOK();}
Кажется, никаких сюрпризов не предвидится.
При всем желании метод прямого поиска пароля в памяти элегантным назвать нельзя, да и практичным тоже. А собственно, зачем искать сам пароль, спотыкаясь о беспорядочно разбросанные буфера, когда можно поставить бряк непосредственно на функцию, его считывающую? Можно и так... да вот угадать, какой именно функцией разработчик вздумал читать пароль, вряд ли будет намного проще.
На самом деле одно и то же действие может быть выполнено всего лишь несколькими функциями и их перебор не займет много времени. В частности, содержимое окна редактирования обычно добывается при помощи либо функции GetWindowTextW
(чаще всего), либо функции GetDlgItemTextW
(а это значительно реже). Все версии Windows NT предпочитают работать с юникодом, поэтому на конце функций работы с текстом W
(wide), а не A
(ASCII).
Раз уж речь зашла об окнах, запустим наш GUI «крякмис» и установим точку останова на функцию GetWindowTextW
— bp
. Хотя эта функция системная, точка останова не будет глобальной и не затронет все приложения в системе, а будет функционировать только в контексте данного приложения.
Вводим какой‑нибудь пароль (KPNC
, по обыкновению), нажимаем клавишу Enter, и отладчик незамедлительно всплывает:
USER32!GetWindowTextW:
00007ffc`a2d7c2f0 48895c2408 mov qword ptr [rsp+8], rbx
00007ffc`a2d7c2f5 4889742418 mov qword ptr [rsp+18h], rsi
00007ffc`a2d7c2fa 57 push rdi
00007ffc`a2d7c2fb 4156 push r14
00007ffc`a2d7c2fd 4157 push r15
00007ffc`a2d7c2ff 4883ec60 sub rsp, 60h
00007ffc`a2d7c303 4d63f0 movsxd r14, r8d
00007ffc`a2d7c306 488bf2 mov rsi, rdx
00007ffc`a2d7c309 4c8bc9 mov r9, rcx
00007ffc`a2d7c30c 4885d2 test rdx, rdx
Как видно, мы попали в функцию USER32!
. Из нее надо выйти на более высокий уровень, нажав Shift-F11. Теперь мы попали в функцию mfc140u!
:
00007ffc`3da173ca ff1550ca0300 call qword ptr [mfc140u!__imp_GetWindowTextW (7ffc3da53e20)]
00007ffc`3da173d0 488b0b mov rcx, qword ptr [rString{->m_pszData} (rbx)]
00007ffc`3da173d3 4885c9 test psz (rcx), psz (rcx)
Теперь надо еще потрассировать эту функцию нажатиями Shift-F11. Наконец, мы попадем в функцию, которая является обработчиком нажатия кнопки OK на форме или Enter на клавиатуре:
passCompare3!CpassCompare3Dlg::OnBnClickedOk
00007ff6`daae1746 488d8b78010000 lea rcx, [this->m_password(??) (rbx+178h)]
00007ff6`daae174d 488d542420 lea rdx, [str{.m_pszData} (rsp+20h)]
00007ff6`daae1752 ff15581c0000 call qword ptr [passCompare3!__imp_?GetWindowTextW@CWnd@@QEBAXAEAV?$CStringT@_WV?$StrTraitMFC_DLL@_WV?$ChTraitsCRT@_W@ATL@@@@@ATL@@@Z (7ff6daae33b0)]
00007ff6`daae1758 488b542420 mov rdx, qword ptr [str{.m_pszData} (rsp+20h)]
00007ff6`daae175d 488d0d14630000 lea rcx, [passCompare3!PASSWORD{.m_pszData} (7ff6daae7a78)]
00007ff6`daae1764 ff154e1c0000 call qword ptr [passCompare3!__imp_?Compare@?$CStringT@_WV?$StrTraitMFC_DLL@_WV?$ChTraitsCRT@_W@ATL@@@@@ATL@@QEBAHPEB_W@Z (7ff6daae33b8)]
Сейчас мы можем узнать значение в регистре RAX:
0:000> dc rax
000001a3`6e4405b8 0050004b 0043004e 004b0020 00730061 K.P.N.C. .K.a.s.
000001a3`6e4405c8 00650070 00730072 0079006b 002b002b p.e.r.s.k.y.+.+.
Хорошо, видим введенный пароль. Есть контакт! Только почему после каждого символа стоит точка? Думаю, ты уже догадался, что она означает двухбайтовую природу символа перед ней. Отхлебнув пивка, кваса или лимонада (по желанию), вспоминаем, что, хоть класс CString и может работать с типами char (однобайтовое представление символов) и wchar_t (многобайтовое представление до четырех байт, то есть юникод в UTF-8, -16 или -32), это зависит от настроек компилятора. А именно от того, какой символ включен: MBCS — char, UNICODE — wchar_t. Чаще всего используется второй набор символов, так как по умолчанию включены именно широкие символы.
Сейчас надо аккуратно трассировать программу, по F8 зайти внутрь следующей функции. По дороге мы обнаружим, что наш пароль занял дополнительные буфера, непонятно зачем. А следующая функция, куда мы провалимся, сравнивает строки:
mfc140u!ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t> > >::Compare:
00007ffc`3d78dcf0 4883ec28 sub rsp, 28h
00007ffc`3d78dcf4 4885d2 test psz (rdx), psz (rdx)
00007ffc`3d78dcf7 740e je mfc140u!ATL::CStringT<wchar_t, StrTraitMFC_DLL<wchar_t, ATL::ChTraitsCRT<wchar_t> > >::Compare+0x17 (7ffc3d78dd07)
00007ffc`3d78dcf9 488b09 mov this (rcx), qword ptr [this (rcx)]
00007ffc`3d78dcfc 4883c428 add rsp, 28h
00007ffc`3d78dd00 48ff25a16a2c00 jmp qword ptr [mfc140u!__imp_wcscmp (7ffc3da547a8)]
00007ffc`3d78dd07 b905400080 mov ecx, 80004005h
00007ffc`3d78dd0c e8bb56ffff call mfc140u!ATL::AtlThrowImpl (7ffc3d7833cc)
…………
Обрати внимание вот на этот оператор из листинга:
mov this (rcx), qword ptr [this (rcx)]
После его выполнения значение в регистре RCX будет указывать на буфер с эталонным паролем:
0:000> dc rcx
00000247`160d3ae8 0079006d 004f0047 0044004f 00610070 m.y.G.O.O.D.p.a.
00000247`160d3af8 00730073 006f0077 00640072 00610000 s.s.w.o.r.d...a.
И правда! Интуиция нас не подвела, эталонный пароль тут как тут.
Введенная пользователем строка и эталонный пароль — как на блюдечке с голубой каемочкой! Замечательно! Вот так, безо всяких ложных срабатываний, элегантно, быстро и красиво, мы победили защиту!
Этот способ универсален, и впоследствии мы еще не раз им воспользуемся. Вся соль — определить ключевую функцию защиты и поставить на нее бряк. В Windows все поползновения (обращения к ключевому файлу, реестру и прочее) сводятся к вызову функций API, перечень которых хоть и велик, но все же конечен и известен заранее.