Теоретические основы крэкинга: Глава 12. Патчить или не патчить?

Дата публикации 27 июн 2005

Теоретические основы крэкинга: Глава 12. Патчить или не патчить? — Архив WASM.RU

Быть или не быть? Вот в чем вопрос.
У. Шекспир

Собственно, вопрос, вынесенный в заглавие этой главы, существует разве что в умах ревнителей идейной чистоты крэкинга, с недоверием относящихся к самой идее модификации исполняемого кода. Для всех остальных ответ очевиден - патчить надо и патчить надо много, поскольку модификация исполняемого кода - самый прямой, простой и быстрый путь заставить код делать то, чего Вы от него хотите. Вряд ли вообще возможно найти хотя бы одного крэкера, который не изготовил бы ни одного патча, не дописал бы в программу десяток-другой байт, не экспериментировал с "отклонением" системных вызовов на свои собственные функции. Хотя обычно патчинг представляют как исправление пары-тройки байт (обычно - в командах условного перехода), на самом деле эта техника применяется не только для банального "бит-хака", но и в таких областях, как перехват системных вызовов, внедрение собственного кода в чужие программы, дописывание недостающей функциональности в программы, а также для многих других не менее интересных вещей. За время чтения предыдущих глав Вы наверняка уже приобрели навыки, необходимые для успешного исправления опкода 75 на опкод EB, научились выкидывать из программ целые процедуры, не порушив при этом стек, и разобрались в основах устройства той операционной системы, под которой Вы планируете развернуть свою крэкерскую деятельность. Стало быть, пришло время рассказа о тонкостях и нетривиальных подходах к модификации программного кода, которая есть альфа и омега всего крэкинга.

Исправлять переходы или содержимое констант - дело нехитрое, но рано или поздно перед Вами встанет в полный рост жестокая необходимость вклинить пару-тройку собственных команд туда, где для этих команд место не предусмотрено, и вставить так, чтобы ничего при этом не испортить. Решение достаточно очевидно - поместить нужный код в свободную область памяти, при помощи команд условного перехода сделать "отвод" в эту область, а потом вернуть управление обратно. Но вот где эту свободную область взять? Смею Вас заверить, таких свободных областей в исполняемых файлах обычно более чем достаточно и искать их долго не придется. В зависимости от требуемого объема памяти Вы можете воспользоваться одной из следующих пяти стратегий:

  1. Использование пространства между процедурами или массивами. Во многих компиляторах для повышения быстродействия по умолчанию включены опции выравнивания начала процедур и массивов. К примеру, если процедуры выровнены по границам 16-байтных областей, Вы можете обнаружить до 15 байт свободного места перед или после каждой процедуры. Найти такие промежутки несложно - они инициализированы последовательностью одинаковых байт, обычно с кодом 0 или 90h (этому коду соответствует команда nop), реже - 0ССh (команда int3) или 0FFh. В такую область вполне возможно вписать пару-тройку команд для загрузки данных в регистры или изменения значения переменной. Поскольку процедуры встречаются достаточно часто, передать управление на такой блок и затем вернуться обратно, скорее всего, получится при помощи двухбайтного варианта команды jmp, что сведет к минимуму расход памяти на переходы туда-обратно. Кроме того, при помощи все тех же двухбайтных переходов можно связать воедино несколько таких свободных кусков и таким образом увеличить количество команд. Недостатки этого метода: без ухищрений невозможно вписать в программу достаточно длинную последовательность команд; связывать вручную свободные куски довольно неудобно; нет никакой гарантии, что поблизости окажется достаточно длинный кусок свободной памяти; при включенной оптимизации по размеру компилятор может размещать процедуры вплотную, что сделает данный метод неприменимым. Кроме того, практически невозможен вызов функции, не импортируемой явным образом: Вам просто надоест вручную сооружать "обвязку" для вызова GetProcAddress, параллельно размазывая ее по щелям между процедурами.
  2. Использование места, высвободившегося после "вывода из обращения" защитных процедур или ненужных тестовых строк. В процессе взлома в программах нередко обнаруживаются "лишние" защитные процедуры, которые крэкеру приходится просто отключать, а также всевозможные надписи, предлагающие обменять некоторую сумму денежных единиц на серийный номер или ключевой файл. При необходимости память, занимаемая этими процедурами и надписями, может быть использована для размещения небольших блоков кода. Возможен и другой вариант использования этого метода - исключить из программы какую-нибудь функцию, которая Вам заведомо не понадобится, а на занимаемое ей место поместить собственный код. Кроме того, есть смысл исследовать подфункции вызываемой функции: некоторые из них могут использоваться исключительно внутри удаляемой функции, и потому тоже могут послужить источником дополнительных байтов. Недостатки метода: не всегда возможно получить достаточный объем памяти; нужна крайняя аккуратность при переписывании команд, содержащих явное указание адреса в памяти. Метод в принципе неприменим, если Вы используете крэкинг "в мирных целях", то есть Ваши задачи предполагают не выламывание из программы защитных процедур, а только лишь усовершенствование программы за счет добавления в нее собственного кода.
  3. Размещение кода в неиспользуемых областях в концах секций. Программисты на ассемблере под Win32 наверняка хорошо знакомы со следующим эффектом: при добавлении в программу новых строк ее размер до поры-до времени не меняется, а потом вдруг скачкообразно увеличивается на полкилобайта, а то и больше (конкретное число зависит от настроек линковщика). Причина этого эффекта в том, что код и данные в программах под Win32 (да и не только под Win32) расположены внутри исполняемого файла в секциях, и размеры этих секций должны быть кратны определенному (и довольно большому) числу байт. Нередки ситуации, когда реальный размер кода программы или ее инициализированных данных на несколько десятков, а то и сотен байт меньше, чем размер выделенной для этого кода секции, и кусок памяти в "хвосте" секции ничем полезным не занят. А если в программе есть "лишние" байты, то почему бы не использовать их для своих целей? Недостатки метода: объем неиспользованного пространства в конце секции совершенно непредсказуем и может колебаться от нуля до нескольких килобайт; следует быть крайне осторожным при записи данных в конец секции инициализированных данных, чтобы случайно не испортить эти самые данные: к примеру, свободное место и длинный массив байтов, инициализированный нулями, внешне выглядят совершенно одинаково.
  4. Расширение существующих секций или создание новых. При таком подходе в принципе невозможно случайно разрушить полезные данные - весь Ваш код будет размещаться в областях, которых в непатченой программе даже не существовало. Более того, таким способом можно внедрить собственный код даже в упакованную программу, если распаковщик не содержит специальных средств для контроля целостности исходного файла (в этом случае лучше создать отдельную секцию и размещать свой код в ней). Размер внедряемого кода при использовании этого метода ограничен разве что здравым смыслом и объемом памяти ЭВМ. Основной недостаток заключается в том, что создание или расширение секций предполагает модификацию PE-заголовка и изменение размеров файла, из-за чего антивирусные программы могут не слишком благосклонно отнестись к таким переменам. Кроме того, поскольку размер файла изменяется за счет вставок в середину, возможны проблемы с созданием исполняемых файлов, выполняющих автоматический патчинг (такие файлы в просторечии называются "крэками"). Большинство утилит, создающих такие файлы, ограничивается побайтным сравнением с оригиналом и потому "не переваривают" вставку даже одного-единственного байта в середину программы. Кроме того, если Вам потребуется вызвать функцию API, которая отсутствует в таблице импорта, Вам придется либо править эту таблицу, либо получать адрес функции при помощи вызова GetProcAddress, что не слишком удобно. Этот недостаток в полной свойственен и трем предыдущим стратегиям патчинга, причем в еще большей мере - по причине ограниченного объема памяти, который эти стратегии позволяют получить. Кроме того, операции по расширению и вставке секций технически очень сложно выполнить в памяти над загруженной программой, поскольку это требует очень глубоких знаний системы и весьма значительных трудозатрат, совершенно не адекватных получаемому результату.
  5. Подгрузка кода, размещенного во внешних модулях. Это наименее "жесткий" по отношению к модифицируемой программе метод внедрения кода. Идея метода заключается в том, чтобы вынести весь добавляемый в программу код во внешний модуль (если речь идет про Windows - то в DLL), а потом "попросить" программу загружать этот внешний модуль вместо одной из библиотек, например, исправив один-единственный байт в имени загружаемой DLL. "Подменная" DLL с нашим кодом вклинивается между программой и "настоящей" библиотекой, причем в функции инициализации DLL можно разместить что-нибудь полезное, например, код патчинга основного процесса. Более подробно методы внедрения модулей мы рассмотрим чуть позже, а пока отметим, что при использовании данного метода Вы получаете наиболее широкий выбор инструментария для написания внедряемого кода: от шестнадцатеричного редактора до самых современных RAD-средств (в отличие от предыдущих четырех стратегий, где Вашим основным инструментом будет ассемблер, встроенный внутрь редактора или отладчика). Главный недостаток заключается в необходимости держать этот самый внешний модуль рядом с программой, что не всегда удобно.

Рассказывая об использовании пространства, занимаемого "ненужными" процедурами, для размещения своего кода, я упомянул о проблеме, связанной с затиранием команд условных переходов, вызова подпрограмм и чтения содержимого фиксированных адресов. Изучая ассемблер, Вы наверняка заметили, что одним и те же мнемоникам "call" и "jmp" соответствует множество разных опкодов. Также Вы могли заметить, что в зависимости от опкода может варьироваться не только длина операнда, но и сам способ указания адреса, на который будет выполнен переход: адрес может указываться либо явным образом, либо в виде смещения относительно начала следующей команды. В современных версиях Windows исполняемые файлы всегда загружаются с адреса, указанного в заголовке исполняемого файла. Однако это правило распространяется только на исполняемые файлы, а вот DLL должны уметь загружаться с любого адреса (традиционно свойство программ или библиотек загружаться с любого адреса называется "перемещаемость"). Если учесть, что Windows - не единственная, а лишь одна из множества ОС с разными моделями памяти и механизмами загрузки приложений и библиотек, а задача обеспечения перемещаемости программных модулей возникает не так уж редко, реализация относительных переходов и вызовов "в железе" сильно облегчило жизнь разработчикам-первопроходцам.

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

Но все-таки, как решается проблема использования абсолютных адресов в перемещаемых программах? Наиболее распространенный подход - коррекция всех абсолютных адресов переходов и переменных во время загрузки. Но чтобы откорректировать абсолютные адреса, где-то должен храниться список всех точек программы, где используется абсолютная адресация. Для этого в исполняемый файл включается специальная таблица перемещаемости (она обычно называется relocation table), в которой указываются смещения всех точек программы, которые нужно откорректировать. Сам процесс пересчета адресов осуществляется весьма просто: вычисляется разность между адресом, начиная с которого был загружен программный модуль и "желательным" адресом загрузки который был в заголовок модуля прописан линковщиком; затем полученная разность прибавляется к содержимому, находящемуся по смещениям, перечисленным в таблице перемещаемости.

Этот пересчет адресов можно рассматривать как акт патчинга со стороны операционной системы, осуществляемый во время загрузки. Теперь представьте, что произойдет, если Вы, ни о чем не подозревая, впишете вместо команды, обращающейся к какой-либо ячейке памяти, свой код. Загрузчику в общем-то без разницы, какие именно байты он исправляет - поэтому он заглянет в таблицу перемещаемости, выберет оттуда адреса ячеек, которые нужно исправить, а потом честно попытается откорректировать содержимое этих ячеек. Однако мы наполнили эти ячейки совершенно иным содержанием, и потому там лежит не адрес, который надо пересчитать, а нечто совершенно иное - написанные нами коды команд. Если алгоритмы работы загрузчика предполагают проверку корректности полученных при пересчете адресов, Вы можете получить сообщение об ошибке уже на этапе пересчета, если не предполагает - программа почти наверняка "рухнет" при попытке исполнить такой дважды исправленный кусок кода. Выходов - два: либо перетрясти таблицу перемещаемости и исключить из нее ненужные нам ссылки, либо при патчинге не затрагивать последовательности байт, похожие на абсолютные адреса в памяти, если таковые встретятся на Вашем пути. Надо отметить, что практически все программные "полуфабрикаты" - библиотеки, объектные файлы, предварительно откомпилированные модули Delphi (то есть файлы с расширением .dcu), компоненты - изначально рассчитаны на внедрение в неизвестное место будущей программы и потому являются перемещаемыми.

Другим интересным вопросом, непосредственно связанным с патчингом, является вызов функций API из внедряемого в программу кода. Как я уже упоминал в девятой главе, при статической загрузке библиотек (то есть когда библиотеки подгружаются уже в процессе загрузки программы на основе информации из таблицы импорта) вызов внешних функций осуществляется не прямым переходом по нужному адресу, а через "переходники", которые и передают управление на библиотечные функции. Понятное дело, что если такие "переходники" есть, то ими можно воспользоваться. Но как быть, если таких "переходников" нет (хотя заведомо известно, что нужная функция все-таки вызывается) или использовать нельзя их по каким-либо причинам? Есть как минимум два пути, позволяющих вызвать функции API даже в таких стесненных условиях. Обычный программистский подход к решению проблемы - вызвать функцию LoadLibrary , а вслед за ней - GetProcAddress и получить адрес искомой функции, после чего можно смело помещать параметры на стек и затем выполнить call [eax]. Для этого, разумеется, нужно, чтобы программа импортировала функции LoadLibrary и GetProcAddress, но тут обычно проблем не возникает: подавляющее большинство программ эти функции импортирует, а те, которые не импортируют, можно заставить это делать редактированием таблицы импорта.

Однако я предпочитаю использовать другой способ, как мне кажется, более удобный и безопасный. Основа метода заключается в том, чтобы вызывать функции не напрямую, а "одалживать" нужные вызовы у исследуемой программы. Если программа импортирует функцию MyFunc статически, это почти наверняка означает, что в коде программы есть команда call MyFunc. А если в программе есть команда call MyFunc, значит, когда мы хотим вызвать MyFunc, нам нужно всего лишь прочитать четыре байта (которые представляют собой адрес нашей функции) в регистр eax, поместить на стек параметры и выполнить вызов. На практике эта операция выглядит примерно так:

Код (Text):
  1.  
  2. FAddr:  call ExitProcess    ; Нужная нам функция
  3.  
  4. OurPatch:
  5. mov eax, dword ptr [FAddr+2]    ; Читаем из памяти адрес функции
  6. push 0  ; Помещаем на стек параметр вызова
  7. call [eax]  ; Вызываем "одалживаемую" функцию</code></pre>
  8. <p>
  9. Однако вызовами статически импортированных функций API возможности метода "функций взаймы" отнюдь не ограничиваются. "Заимствовать" из исследуемой программы можно совершенно любые функции - от элементарного сравнения строк до функций извлечения файлов из архива неизвестного формата или генерации серийных номеров (изредка встречаются программы с такой ошибкой в защите). Единственное, что для этого необходимо - выяснить тип передаваемых функции параметров и используемое соглашение вызова. Однако нужно понимать, что заимствование функций из программы или из несистемных DLL не вполне известного назначения - операция более опасная, чем использование импортированных функций документированных API. Дело в том, что работа функций API не привязана ни к каким переменным и функциям внутри программы (если, конечно, не считать callback-функции), а вот к функциям программы это в общем случае не относится. Их работа может зависеть от состояния локальных или глобальных переменных, а также других объектов, созданных в процессе работы программы; более того - сами программные вызовы тоже вполне могут изменять состояние переменных, критически важных для работы программы. Поэтому если какой-либо объект в момент вызова окажется в "неправильном" состоянии, результат вызова будет совершенно непредсказуем. Точно таким же образом никто не может гарантировать, что "несвоевременный" вызов не нарушит функционирование всей программы. Хотя, с другой стороны, встроенные процедуры, как правило, достаточно корректно обращаются с переменными и объектами, созданными программой. Так что если Вам вдруг понадобится форсировать загрузку какого-либо плагина, лучше попытаться это сделать это "родными" для программы средствами, и лишь если это у Вас не получится, прибегнуть к средствам API. В любом случае, при заимствовании из основной программы функций в качестве постоянного решения следует быть очень осторожным, хотя сам по себе этот метод нередко бывает полезен.
  10. <p>
  11. Патчинг позволяет решать и обратную задачу: "отклонение" вызовов из исследуемой программы на внедряемый крэкером код. Цели этой операции могут быть различными:
  12. <ul type="disc">
  13.   <li>Предварительный анализ параметров, передаваемых в функцию (например, для того, чтобы предотвратить выполнение нежелательных вызовов)
  14.   <li>Коррекция результата, возвращаемого функцией
  15.   <li>Выполнение каких-либо действий помимо тех, которые предполагались в программе (например, усовершенствование процедуры, создающей nag screen таким образом, чтобы это окно после исполнения функции сразу же закрывалось)
  16.   <li>Ведение и сохранение журнала вызовов функций, снятие статистики по выполнению тех или иных действий и частоте появления на стеке тех или иных значений
  17. </ul>
  18. <p>
  19. Как Вы можете видеть, приведенный список возможных целей покрывает весьма широкую область - от предотвращения нежелательных вызовов до наблюдения за вызовами функций (частным случаем этого являются API-шпионы). Собственно, некоторые техники, используемые в API-шпионах, и основаны на патчинге "переходников" к функциям API, осуществляемом сразу после загрузки программного модуля. Принцип действия большинства API-шпионов следующий:
  20. <ol type="1">
  21.   <li>Загрузить процесс в память, не запуская его (например, создав процесс с флагом CREATE_SUSPENDED, если речь идет о Win32).
  22.   <li>Внедрить "шпионский" модуль в адресное пространство исследуемого процесса, например при помощи хуков или функции CreateRemoteThread (только под Windwos линейки NT).
  23.   <li>Пропатчить в памяти процесса "переходники" к функциям API таким образом, чтобы они указывали на обработчики соответствующих функций внутри "шпионского" модуля.
  24.   <li>Аналогичным образом перебросить переходник к функции GetProcAddress на собственный обработчик. Эта операция очень важна, поскольку только таким образом можно перехватить вызовы функций, адреса которых программа динамически запрашивает в процессе работы.
  25.   <li>"Разморозить" процесс, после чего программа начнет исполняться.
  26. </ol>
  27. <p>
  28. Однако патчинг и перехват системных вызовов - отнюдь не прерогатива одних лишь крэкеров. Разработчики защит, хотя и с изрядным опозданием, тоже взяли на вооружение идею перенаправления системных вызовов на собственный код. Как я уже упоминал, сравнительно недавно в большом количестве появились навесные защиты, которые позволяют упрятывать любую программу и все необходимые для ее работы файлы в упакованном и зашифрованном виде внутрь одного-единственного EXE. При этом сама программа может обращаться к своим файлам как средствами Win32 API, так и при помощи высокоуровневых функций (которые по сути являются "обертками" для все тех же системных вызовов). О том, каким образом работают такие защиты, Вы уже наверняка догадались: в исполняемый файл дописывается секция, где хранится код, обрабатывающий вызовы системных функций для работы с файлами. Необходимые для работы программы файлы упаковываются и цепляются в "хвост" программы, а таблица импорта дорабатывается таким образом, чтобы вызовы функций работы с файлами перенаправлялись на обработчики, находящиеся во внедренной секции. Когда защищенная программа попытается обратиться к файлу, задача обработчиков заключается в том, чтобы проверить, к какому именно файлу происходит обращение, и либо передать это обращение операционной системе в неизменном виде (если программа обращается к файлу вне "хранилища"), либо имитировать работу системного вызова, но в действительности считывание данных осуществлять из упакованного "хранилища". Однако ирония судьбы заключается в том, что метод взлома полностью аналогичен методу защиты. Действительно, если разработчик "отклонил" вызовы функций работы с файлами на собственный код, то и почему бы и крэкеру не проделать ту же самую операцию? То есть вклинить между программой и обработчиком свой собственный "обработчик обработчика", который будет сбрасывать все "спрятанные" файлы в надежное место. Основную проблему составляет поиск входных и выходных точек этих обработчиков, но здесь могут помочь "особые приметы", которые способны выдать чужеродный код:
  29. <ol type="1">
  30.   <li>Вклинить "левый" код в середину практически невозможно, поэтому остаются варианты с расширением секций (тогда нужный код окажется перед началом или в самом конце программы), созданием новой секции (что иногда заметно по "странным" адресам, заметно отличающимся от адресов основной программы) или динамическим выделением куска памяти и размещением там обработчика (самый сложный для реализации способ - а потому самый маловероятный).
  31.   <li>Если защищенная программа читает упакованные файлы "по требованию", каждый вызов функций чтения файлов внутри программы будет сопровождаться обращениями программы к "хвосту" своего EXE-файла. Если программа читает все упакованные файлы в память сразу, такое обращение будет выполнено во время запуска программы. В обеих случаях эти вызовы вполне "уязвимы" для точек останова.
  32.   <li>Наиболее уязвимы те вызовы, которые передаются в систему в неизменном виде (т.е. обращения к файлам, не находящимся внутри упакованного "хранилища") - установка брейкпойнтов на функции работы с файлами позволяет найти их без особого труда, после чего можно добраться и до точек входа и выхода в защитные процедуры-обработчики системных вызовов.
  33.   <li>В памяти почти наверняка будут застревать куски спрятанных файлов, и если Вы знаете, какая информация в этих файлах может оказаться, Вы можете попытаться найти эти куски памяти и попытаться выяснить, каким образом эти  куски там появляются (например, при помощи поиска ссылок на начала таких кусков или установкой брейкпойнтов на запись в память).
  34. </ol>
  35. <p>
  36. Все эти внедрения в чужой процесс, поиски переходников и прочее вполне способны повергнуть в шок начинающего. Это не страшно - даже если Вы никогда не напишете собственный API-шпион, знания о том, как они работают, вполне могут Вам пригодиться в дальнейшем. Как Вы уже догадались, написание приложений, перехватывающих системные вызовы - занятие далеко не самое простое и требующее определенных знаний и навыков. С другой стороны, большинство API-шпионов ограничиваются лишь ведением журнала системных вызовов и не позволяют активно вмешиваться в работу программы. Для начинающего крэкера, у которого есть желание перехватить какой-нибудь системный вызов, но нет опыта в системном программировании, это звучит как приговор. Но ведь так хочется иногда не только подсмотреть, откуда взялись те или иные параметры, но еще и поменять их "на лету", если они Вам чем-то не понравились…
  37. <p>
  38. Я уже говорил, из любой безвыходной ситуации существует как минимум два выхода. Этот афоризм верен и в нашем случае, однако от Вас все же потребуются определенные навыки в программировании. Итак, наша задача - вклинить свой код между системной DLL и программой, которая ее вызывает. Одна из первых идей, которые приходят в голову, заключается в том, чтобы вместо "родной" DLL подсунуть свою собственную, которая содержала бы функции с такими же именами, что и "настоящая". Эта библиотека должна помимо вызова "родных" функций из оригинальной библиотеки выполнять еще и те операции, которые Вы в нее заложите. А уж в собственной DLL Вы вольны запрограммировать все, что угодно - от сбора статистики вызовов до анализа и подмены параметров функций. Такой подход к перехвату вызовов из DLL, основанный на подмене оригинальных библиотек, называется DLL wrapping'ом. Чисто технически создание подменной DLL выполняется следующим образом:
  39. <ol type="1">
  40.   <li>Необходимо получить список всех функций (в том числе и  тех, которые не имеют имен, а только ординалы), экспортируемых той DLL, которую Вы планируете подменить. Если Вы уверены, что программа не импортирует функции этой DLL при помощи GetProcAddress или каким-либо более изощренным способом, Вы можете обойтись и списком функций, импортируемых подопытной программой из DLL. Получить такой  список можно, к примеру, при помощи утилиты DUMPBIN.
  41.   <li>Изготовить на основе этого списка болванку будущей подменной DLL. В простейшем случае эта болванка будет выглядеть как набор одинаковых кусков. Поскольку болванка имеет регулярную структуру, ее можно сгенерировать автоматически. Для простейшего случая, когда нужно перехватить одну лишь функцию MessageBoxA из user32.dll, наша подменная библиотека, написанная на MASM, будет выглядеть примерно так:
  42. </ol>
  43. <p><code><pre>
  44. PUBLIC MessageBoxA
  45.  
  46. .data:
  47. IsLoaded dd FALSE
  48. aMessageBoxA dd 0
  49. huser32 dd 0
  50. u32 db "e:\Windows\System32\user32.dll",0
  51. nMessageBoxA db "MessageBoxA",0
  52.  
  53. .code:
  54.  
  55. DllEntry proc hInstance:HINSTANCE, reason:DWORD, reserved1:DWORD
  56.         mov  eax,TRUE
  57.         ret
  58. DllEntry Endp
  59.  
  60. CheckAndImport proc
  61.    
  62.     .if !IsLoaded
  63.         mov IsLoaded,TRUE
  64.         invoke LoadLibrary,ADDR u32
  65.         mov huser32,eax
  66.         invoke GetProcAddress,huser32,ADDR nMessageBoxA
  67.         mov aMessageBoxA,eax
  68.     .endif
  69.     ret
  70.  
  71. CheckAndImport endp
  72.  
  73. MessageBoxA::
  74. ; Адрес метки MessageBoxA и прочих аналогичных нужно включить в таблицу экспорта
  75. ; при помощи директив
  76. ; LIBRARY user32
  77. ; EXPORTS MessageBoxA,
  78. ; помещенных в def-файл
  79.  
  80.     invoke CheckAndImport
  81.    
  82. ; Здесь Вы можете разместить код, исполняемый перед вызовом перехватываемой функции
  83.     ; Например, поменять местами заголовок и текст сообщения,
  84. ; как это сделано ниже
  85. mov eax,[esp+8]
  86. xchg [esp+12],eax
  87. xchg [esp+8],eax
  88.  
  89.     mov eax, MessageBoxA_
  90.     jmp eax

Процедура CheckAndImport выполняет важную функцию - во время первого вызова любой из функций она подгружает настоящую user32.dll, получает адрес функции MessageBoxA и помещает этот адрес в переменную аMessageBoxA. Если Вам известно, какая из функций DLL будет вызвана первой, можно сократить код, разместив вызов функции CheckAndImport только в этой функции. Вообще, внешне этот код выглядит довольно тяжеловесно - экспорт меток, специфический способ обращения к параметрам вызова через смещение относительно значения ESP, динамическая загрузка библиотек - и тут же отсутствие возможности выполнить собственный код после вызова MessageBox. Причина тяжеловесности проста: этот код представляет собой максимально упрощенную адаптацию реально использовавшейся мной подменной DLL. Что же касается мнимой невозможности выполнить собственный код после вызова MessageBoxA, то эту проблему проще всего решить при помощи подмены лежащего на стеке адреса возврата: нужно подправить его таким образом, чтобы после выполнения команды jmp <имя_функции> возврат выполнялся не в программу, а на следующую за jmp команду. Разумеется, старый адрес возврата тоже надо где-то сохранять - он Вам понадобится, чтобы вернуть управление программе. Если Вам известно количество параметров, помещаемых на стек при вызове, и их размерность, никаких сложностей с подменой адреса возврата не возникнет. Но вот если в процедуру передается неизвестное заранее количество параметров (такое, в частности, возможно в программах, написанных на C), общего решения этой проблемы не существует, так что Вам придется действовать по обстоятельствам и изобретать метод определения количества параметров на стеке самостоятельно.

Но почему я вдруг отклонился от темы этой главы и уделил столько времени технике подмены DLL? Причина в том, что DLL wrapping нередко используется совместно с патчингом исполняемого файла. Внимательно посмотрев на текст подменной DLL, Вы увидите, что загрузка user32.dll производится с явным указанием расположения этой библиотеки. Однако даже если скомпилировать эту DLL с именем "user32.dll" и положить рядом с подопытной программой, единственным результатом которой является вывод MessageBox'а с неким сообщением, Вы все равно не добьетесь желаемого результата - программа вызовет эту функцию прямиком из системной библиотеки, проигнорировав Вашу приманку. Что же делать?

Самый простой и доступный выход заключается в том, чтобы переименовать нашу подменную библиотеку в user33.dll, а потом забраться в исполняемый файл шестнадцатеричным редактором и там поменять имя импортируемой библиотеки аналогичным же образом. Теперь наша программа будет вместо стандартной библиотеки Windows подгружать нашу user33.dll и вызывать MessageBoxA именно из нее. Теоретически можно было поступить и несколько иначе - переименовать системную библиотеку user32.dll в user33.dll, а на место user32.dll положить нашу подменную библиотеку (разумеется, исправив путь к "настоящей" динамически загружаемой библиотеке), но на практике проделывать фокусы с заменой системных библиотек крайне нежелательно. Хотя если речь идет не о системной библиотеке, то действительно можно обойтись одним лишь переименованием "настоящей" DLL и помещением на ее место "поддельной".

Изучая перехват системных вызовов, мы вторглись в высокие сферы патчинга процессов, и теперь настало время всерьез углубиться эту тему. Хотя "в общее пользование" всевозможные launcher'ы, правящие файлы прямо в памяти, попадают сравнительно редко, для домашнего пользования техники патчинга процессов более чем актуальны. Если у Вас есть сжатая упаковщиком EXE программа, но нет желания заниматься ее распаковкой либо Вы просто хотите поэкспериментировать над программой, проверяющей контрольную сумму собственного файла, патчинг кода "на лету" - это весьма эффективный способ добиться желаемого с минимальными усилиями.

Модификация кода программы в памяти может выполняться двумя путями: записью данных в адресное пространство процесса извне или же внедрением в адресное пространство процесса собственного кода, который уже будет работать внутри подопытного процесса и выполнять необходимые действия по патчингу. В подавляющем большинстве случаев вторжение в чужое адресное пространство извне осуществляется при помощи последовательности вызовов VirtualProtectEx-WriteProcessMemory, хотя возможны и более сложные варианты с выходом в нулевое кольцо (правда, после того, как линейка Windows 9x начала утрачивать актуальность, простые в осуществлении способы выхода в Ring0 остались не у дел). Для того, чтобы добраться до процесса, Вам почти наверняка потребуется его дескриптор (он же "хэндл" - "handle"). Если запуск программы и патчинг ее процесса выполняется одной и той же программой, особых сложностей не возникает: функция CreateProcess[Ex] возвращает хэндл порождаемого ей процесса. Но это не единственный подход к добыче желанного дескриптора - до него также можно добраться через получение идентификатора процесса, породившего окно (при помощи функции GetWindowThreadProcessId) либо через анализ "снимка" всех процессов, полученных при помощи функций CreateToolhelp32Snapshot, Process32First и Process32Next.

Правка кода путем предварительного внедрения в адресное пространство подопытного процесса применяется значительно реже, однако эти тоже знания могут Вам понадобиться. Наиболее распространенными способами попасть в чужое адресное пространство являются: использование хуков, запуск программы в режиме отладки и создание потока при помощи функции CreateRemoteThread (этот прием применим только под Windows линейки NT). Однако ничто не ограничивает Ваше творчество - Вы вольны комбинировать различные приемы для достижения наилучшего эффекта, например, пропатчить программу в районе точки входа так, чтобы происходила загрузка некой DLL и вызов функции, которая восстановит испорченные патчингом байты, создаст дополнительный поток и затем вернет управление основной программе.

Выше мы рассматривали всевозможные приемы, позволяющие, образно говоря, залезать в чужое адресное пространство через окно. Но иногда разработчики программ предоставляют бесценную возможность попасть внутрь их детища с "парадного входа", не таясь. И возможность эта - "родимое пятно" практически всех программ, использующих широко распространенный механизм расширения возможностей программы за счет плагинов. Плагин обычно представляет собой самую обычную DLL, построенную в соответствии с теми или иными стандартами, задаваемыми разработчиком программы. Однако что именно делает плагин, программу обычно не волнует - он может как выполнять штатные функции по обработке изображения, звука или чего-нибудь еще, так и быть "троянским конем", созданным с одной лишь целью - в момент первого же обращения к нему исправить внутри программы-"хозяина" несколько байт. Поскольку Ваш плагин будет "жить" внутри подопытной программы, для записи данных в адресное пространство Вам не нужны будут никакие ухищрения, достаточно всего лишь поместить правильные значения по известным адресам при помощи самой обычной команды mov. Однако как добиться, чтобы Ваш код выполнился как можно быстрее? Точные сведения могут быть получены лишь через эксперименты, но приблизительное направление поиска следующее: многие программы после загрузки плагина первым делом "спрашивают" свежезагруженный модуль о его названии, назначении, поддерживаемых функциях и прочих подобных параметрах. А поскольку поиск и загрузка плагинов нередко выполняется сразу после старта программы (а если очень повезет - и до срабатывания защитных механизмов или параллельно с их работой), у Вас есть хорошие шансы пропатчить программу уже на первых стадиях ее работы.

Общим требованием при патчинге программы в памяти является полная "неподвижность" программы, чего проще всего достигнуть, отправив все потоки модифицируемого процесса в состояние "замороженности" вызовом функции SuspendThread или при помощи старого фокуса "MySelf: jmp MySelf". Связано это с тем, что модификация кода с точки зрения программы должна производиться одномоментно, чтобы во время патчинга программа случайно не попыталась выполнить не до конца модифицированный код (результатом чего почти наверняка будет сбой). Из этого правила есть важное следствие: если патчинг выполняется одной ассемблерной командой (а одной командой можно переписать один, два, четыре, а при большом желании - 8 или 10 байт), то такая модификация будет одномоментной и потому вполне допустимой. К примеру, "волшебная" комбинация MySelf: jmp MySelf (в шестнадцатеричном виде этот код выглядит как EB FE) как паз является двухбайтной. Однако если Вы выполняете патчинг чужого процесса при помощи функций WinAPI, Вы не можете наверняка знать, блоками какого размера будет осуществляться запись в чужое адресное пространство, а потому пытаться переписывать более одного байта без "заморозки" процесса не стоит.

Самым старым подходом патчинга процессов под Win32 была загрузка программы с флагом CREATE_SUSPENDED с последующей записью в ее адресное пространство и "размораживанием" главного потока, однако применение его ограничено - очевидно, что этот метод практически неприменим в том случае, если программа упакована. Но что делать, если Вы все же столкнулись с упакованной программой? Очевидно, что в момент запуска патчить ее бесполезно, поскольку тело программы все еще не декодировано. Стало быть, патчер должен дождаться, пока программа будет распакована, и, как только этот знаменательный момент настанет, обрушиться на программу и привести в надлежащее состояние все "неправильные" байты. Осталось лишь выяснить, каким образом патчер может узнать о наступлении этого самого момента.

Самым оригинальным подходом к проблеме отличился, пожалуй, довольно старый патчер процессов, выпущенный T3X: все обязанности по отслеживанию загрузки эта утилитка возлагала на конечного пользователя. Созданный при помощи этой утилиты launcher просто запускал нужную программу, а затем выводил MessageBox с сообщением вроде "Нажми ОК, когда программа загрузится". От пользователя, соответственно, требовалось кликнуть по кнопке ОК, когда он сочтет, что подопытная программа уже загрузилась и распаковалась. Несмотря на откровенный примитивизм, этот патчер вполне соответствовал духу популярных защит того времени и позволял решать насущные задачи.

Естественно, следующим шагом в определении наилучшего момента для патчинга стала привязка к появлению на экране какого-либо окна, создаваемого модифицируемой программой. Поскольку большинство Windows-приложений являются оконными и при запуске создают окно, в котором предстоит работать пользователю, появление такого окна может служить недвусмысленным указанием на то, что распаковка программы уже завершилась и потому можно приступать к патчингу. Одним из первых такой патчер выпустил yoda, причем его патчер позволял не только обнаруживать появление окна, но и принудительно его закрывать (что было полезным для борьбы с nag screen'ами). Однако этот инструмент имел ряд недостатков, в частности - требовал указания полного заголовка окна, что не позволяло работать с окнами с изменяющимся заголовком. А поскольку окна со счетчиками оставшихся дней "испытательного срока" встречались в программах все чаще и чаще, такая ситуация сподвигла меня на написание собственного инструмента - интерпретатора скриптов с механизмом поиска окон по содержащейся в заголовке окна подстроке, возможностью имитировать нажатие кнопок в окне, активировать отключенные управляющие элементы и т.п. Однако и этот инструмент не во всех случаях способен решить проблему своевременного патчинга: защита может быть устроена таким образом, что в момент появления окна будет уже поздно что-либо предпринимать. Кроме того, нельзя сбрасывать со счетов безоконные программы, которые хоть и редко, но все же встречаются в мире Win32-приложений. И тогда приходится применять старый, весьма расточительный с точки зрения расхода ресурсов процессора, но проверенный временем и практически безотказный прием.

Традиционно проблема определения момента распаковки программы решается следующим образом: подопытная программа запускается при помощи launcher'а с флагом CREATE_SUSPENDED, этому процессу и его главному потоку назначается наименьший возможный приоритет, после чего главный поток "размораживается" функцией ResumeThread. Программа начинает распаковываться, но делает это очень медленно: во-первых, по причине минимального приоритета, а, во-вторых, потому что launcher в это время постоянно считывает содержимое байтов, которые предполагается пропатчить и сравнивает их с эталонными значениями, заранее добытыми из "живой" программы при помощи дампера или отладчика вроде SoftIce. Цель этих действий заключается в следующем: исправление необходимо провести как можно раньше после того, как подвергаемые патчингу участки будут распакованы. Как только содержимое нужных ячеек памяти совпадет с эталонами, программу можно считать готовой к внесению модификаций. В этот момент патчер стопорит исполнение программы и модифицирует код, а затем возвращает в исходное состояние приоритеты, размораживает все потоки и позволить программе выполняться дальше как ни в чем не бывало.

Блок, выполняющий постоянное сканирование, желательно оптимизировать по скорости настолько, насколько это возможно: если наш launcher не успеет внести в программу изменения до того, как управление будет передано на изменяемые участки, всю операция по патчингу можно считать проваленной. Именно для того, чтобы снизить вероятность "слишком быстрого" запуска программы и увеличить промежуток времени, пригодный для патчинга, мы и понижаем приоритет программы, отбирая у нее кванты процессорного времени в пользу нашего launcher'а и других программ. Из всего вышесказанного следует интересный практический вывод, который, к сожалению, не учитывается в большинстве существующих патчеров процессов: если патч предполагается использовать на мультипроцессорной системе, Ваш launcher и подопытную программу (или хотя бы ее главный поток) лучше исполнять на одном и том же процессоре. Добиться этого под Windows NT можно при помощи функций SetProcessAffinityMask, SetThreadAffinityMask и GetProcessAffinityMask.

Дочитав предыдущий абзац, Вы могли задаться вопросом, насколько вообще эффективно использование API'шных функций для чтения-записи в адресное пространство чужого процесса для наших целей и нельзя ли воспользоваться каким-либо более "прямым" методом работы в чужом адресном пространстве. Такие вопросы вполне уместны: операции по "залезанию" в чужое адресное пространство вообще требуют значительных накладных расходов, из-за чего функция ReadProcessMemory действительно работает не слишком быстро (хотя обычно и такой скорости бывает более чем достаточно).

На этом наш экскурс в теорию и методологию патчинга можно считать законченным. Думаю, Вы достаточно разобрались в основах самой важной и самой употребительной крэкерской операции. А стало быть, подходит к логическому завершению и эта работа. И единственная область, которая до настоящего момента мною почти не затрагивалась - это технологии будущего, "крэкинг завтрашнего дня". А потому темой заключительной главы будут подходы, только-только утверждающиеся на крэкинг-сцене, а также идеи, которые пока еще никто не осуществил. © CyberManiac


0 1.797
archive

archive
New Member

Регистрация:
27 фев 2017
Публикаций:
532