Блог → Антиотладочные приёмы для среды Windows. Часть 1

В очередной заметке посвящённой безопасности программ, мы с вами попробуем разобраться, как работают отладчики защищённого режима, и какие меры может предпринять прикладной код, исполняющийся под Windows 95/98/Me/NT/2000, чтобы помешать себя исследовать. Также я кратко рассмотрю вопросы стойкости защит и постарюсь дать пару советов по улучшению качества их реализации. Замечу, что эта заметка рассчитана на программистов, работающих на C++, и хотя бы в общих чертах знакомых с ассемблером.

В защите программ можно выделить по крайней мере две "линии обороны" - противодействие дизассемблеру и противодействие отладчику. Ослепить дизассемблер просто, достаточно прибегнуть к шифровке, самомодифицирующемуся коду и другим подобным хитростям. Справиться же с отладчиком, не вызвав никаких проблем у легального пользователя, на порядок сложнее. За время существования MS-DOS разработчики накопили множество трюков, осложняющих хакерам жизнь, но, увы, подавляющее большинство из них работает только в реальном режиме и не может быть перенесено в Windows 9x/NT.

Антиотладочные приёмы в современных продуктах очень редки и встречаются разве что в системных драйверах, взаимодействующих с электронными ключами (такими, например, как "HASP") и других привилегированных программах, исполняющихся в нулевом кольце. Между тем, стойкую к взлому защиту можно реализовать и на прикладном уровне - используя только документированные возможности операционной системы и минимум ассемблерных вставок (а можно и вовсе без них). Приведенные ниже примеры построения таких защит системно независимы и одинаково хорошо работают на любой версии Windows: 95/98/Me/NT/2000.

Как работает отладчик? Для борьбы с отладчиком полезно представлять себе, как он работает, поэтому для начала рассмотрим базовые принципы, лежащие в его основе. Это изложение не претендует на полноту, но позволит читателю составить общее представление о вопросе. Технические подробности исчерпывающе изложены в главе "Debugging and Performance Monitoring" технического руководства "Intel Architecture Software Developer’s Manual Volume 3: System Programming Guide", бесплатно распространяемого фирмой Intel (скачать его можно на официальном сайте).

Итак, все существующие отладчики можно разделить на две категории - первые используют отладочные средства процессора, а вторые самостоятельно эмулируют процессор, полностью контролируя выполнение "подопытной" программы. Качественный эмулирующий отладчик отлаживаемому коду ни обнаружить, ни обойти невозможно, но полноценных эмуляторов Pentium-процессоров на сегодняшний день нет, и вряд ли они появятся в обозримом будущем. Да и есть ли смысл их создавать? Микропроцессоры Pentium предоставляют разработчику богатейшие отладочные возможности, позволяющие контролировать даже привилегированный код! Они поддерживают пошаговое исполнение программы, отслеживают выполнение инструкции по заданному адресу, контролируют обращения к заданным ячейкам памяти (или портам ввода-вывода), сигнализируют о переключениях задач и т.д.

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

Обратите внимание на важное обстоятельство: после выполнения команды, модифицирующей значение регистра SS, отладочное исключение не генерируется! Отладчик должен уметь распознавать такую ситуацию и самостоятельно устанавливать точку останова на следующую инструкцию. В противном случае, войти в процедуру, предваренную инструкцией POP SS (например, так: PUSH SS; POP SS; CALL MySecretProc), автоматический трассировщик не сможет. Не все современные отладчики учитывают эту тонкость, и такой приём, несмотря на свою архаичность, может оказаться очень даже полезным.

Четыре отладочных регистра DR0-DR3 хранят линейные адреса четырех контрольных точек, а управляющий регистр DR7 содержит для каждой из них условие, при выполнении которого процессор генерирует исключение INT1, передавая управление отладчику. Всего существует четыре различных условия прерывания: при выполнении команды, при модификации ячейки памяти, при чтении или модификации (но не исполнении) ячейки памяти и при обращении к порту ввода-вывода.

Установкой специального бита можно добиться генерации отладочного исключения при всяком обращении к отладочным регистрам, которое возникает даже в том случае, если их пытается прочесть (модифицировать) привилегированный код. Грамотно спроектированный отладчик может скрыть факт своего присутствия, не позволяя отлаживаемому коду себя обнаружить, какие бы ни были у него привилегии (правда, если "подопытный" код отлаживает сам себя, задействовав все четыре контрольные точки, отладчик не сможет работать).

Если бит Т в TSS отлаживаемой задачи установлен, то при каждом переключении на нее будет генерироваться отладочное исключение до выполнения первой команды задачи. Чтобы предотвратить собственное обнаружение, отладчик может отслеживать всякие обращения к TSS и возвращать программе подложные данные. Необходимо заметить - Windows NT по соображениям производительности не использует TSS (точнее использует, но всего один), и эта отладочная возможность для нее совершенно бесполезна. Программная точка останова - единственное, что нельзя замаскировать, не прибегая к написанию полноценного эмулятора процессора. Она представляет собой однобайтовый код 0хCC, который, будучи помещенным в начало инструкции, вызывает исключение INT3 при попытке ее выполнения. Отлаживаемой программе достаточно подсчитать свою контрольную сумму, чтобы выяснить: была ли установлена хоть одна точка останова или нет.

Для достижения этой цели она может воспользоваться командами MOV, MOVS, LODS, POP, CMP, CMPS или любыми другими - никакому отладчику невозможно отследить и проэмулировать их все. Настоятельно рекомендуется использовать программные точки останова только в тех случаях, когда аппаратных уже не хватает. Однако, практически все современные отладчики (в том числе и SoftIce) всегда устанавливают программные, а не аппаратные точки останова. Это обстоятельство может быть с успехом использовано в защитных механизмах, примеры которых приведены дальше.

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

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

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

Сказанное справедливо для Windows NT, но неприменимо и к Windows 9х - эта операционная система не использует должным образом всех преимуществ защищенного режима, и всегда "замусоривает" стек отлаживаемой задачи, независимо от того, находится ли она под отладкой или нет.

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

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

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

Чтобы обезопасить легального пользователя от ошибочного ввода пароля, достаточно в одном месте явно проверить его контрольную сумму, которая не даёт взломщику никакой информации об истинном значении пароля. Таким образом, защита как бы "размазывается" по всей программе, буфера с ключевыми данными многократно дублируются, и на отслеживание обращений у взломщика не хватит ни контрольных точек, ни терпения для анализа огромного объема манипулирующего ключевыми данными кода. Ещё лучше, если после выполнения проверки ключевой информации эти же самые буфера используются для хранения служебных данных, обращения к которым происходят максимально часто. Это не позволит взломщику быстро отделить защитный механизм от прочего прикладного кода.

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

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