Создание и уничтожение диалогов



CreateDialog, CreateDialogIndirect, CreateDialogIndirectParam, CreateDialogParam (эти функции только создают диалог)

DialogBox, DialogBoxIndirect, DialogBoxIndirectParam, DialogBoxParam (эта группа функций позволяет создавать модальные диалоги; управление не возвращается программе, пока диалог не будет закрыт)

EndDialog, DestroyWindow

 

Чтение и изменение текстов окон

GetWindowText, GetDlgItemText (чтение текста окна или элемента диалога)

GetWindowTextLength (чтение длины текста окна)

GetDlgItemInt (чтение текста элемента диалога как 32-битного числа)

SetWindowText, SetDlgItemText, SetDlgItemInt (установка нового текста окна или элемента диалога)

 

Изменение видимости, позиции и прочих подобных свойств окна

EnableWindow (активация/деактивация окна)

ShowWindow, ShowWindowAsync (изменение видимости и состояния окна, в частности – позволяет минимизировать или наоборот развернуть окно)

SetWindowPos, MoveWindow (изменение положения окна)

SetWindowWord (устаревшая и практически не используемая функция), SetWindowLong – две функции, позволяющие модифицировать весьма широкий спектр параметров окна. GetWindowWord, GetWindowLong – функции чтения этих параметров.

 

Загрузка ресурсов

LoadImage (универсальная функция загрузки изображений, иконок и курсоров), LoadBitmap, LoadIcon, LoadCursor

FindResource, FindResourceEx, LoadResource (функции загрузки ресурсов любого типа в память)

 

Отображение изображений и текстов на экране

BitBlt, StretchBlt, MaskBlt (функции копирования BITMAP’ов на экран)

DrawText, TextOut, TabbedTextOut (обычный вывод текста)

GrayString (редко используемая функция, выводит на экран строку со стилем надписи на неактивном управляющем элементе)

 

Работа с файлами

OpenFile (устаревшая функция), CreateFile (основная функция открытия файлов; несмотря на свое название способна открывать файлы и даже директории)

ReadFile, ReadFileEx (функции чтения из файлов)

WriteFile, WriteFileEx (функции записи в файл)

SetFilePointer (перемещение по файлу)

GetFileTime, GetFileAttributes, SetFileTime, SetFileAttributes (чтение и модификация времени создания и атрибутов файлов/директорий)

SetEndOfFile (изменение размеров файла)

 

Операции с реестром

RegOpenKey, RegOpenKeyEx, RegCreateKey, RegCreateKeyEx (открытие и создание ключей реестра)

RegQueryInfoKey (запрос информации о ключе, в частности – для проверки факта существования подключа)

RegQueryValue,RegQueryValueEx (чтение значений из реестра)

RegSetValue, RegSetValueEx (запись ключей в реестр)

RegCloseKey (закрытие ключа реестра)

 

Чтение и запись INI -файлов

GetProfileSection, WriteProfileSection, GetProfileInt, GetProfileString, WriteProfileString, WriteProfileInt (функции для работы с файлом Win.ini, в настоящее время считаются устаревшими, но иногда используются)

GetPrivateProfileSection, GetPrivateProfileSectionNames, WritePrivateProfileSection, GetPrivateProfileInt, GetPrivateProfileString, GetPrivateProfileStruct, WritePrivateProfileString, WritePrivateProfileInt, WritePrivateProfileStruct (функции работы с областью реестра, отведенной для хранения настроек программ, либо с произвольным INI-файлом; эта группа функций считается устаревшей)

 

Работа с датой и временем

GetSystemTime, GetLocalTime, GetSystemTimeAsFileTime (чтение текущего времени)

SetSystemTime, SetLocalTime (установка нового времени)

LocalTimeToFileTime, FileTimeToLocalTime, FileTimeToSystemTime, SystemTimeToFileTime (преобразование формата времени)

CompareFileTime (сравнение двух переменных, хранящих время)

GetFileTime, SetFileTime (запись и чтение времени создания, последней модификации и последнего доступа к файлу)

 

Процессы и потоки: создание и управление

WinExec (устаревшая функция запуска исполняемых файлов), CreateProcess (функция, обычно используемая для запуска исполняемых файлов), ShellExecute, ShellExecuteEx (пара «альтернативных» функций для запуска приложений (применительно к исполняемым файлам) или открытия, печати и т.п. папок и документов).

 ExitProcess («стандартное» завершение процесса, эта функция способна завершить только тот процесс, внутри которого она вызвана), TerminateProcess (принудительное завершение процесса; эта функция способна «убить» любой процесс (в NT – при наличии соответствующих привилегий), что иногда используется защитами для подавления крэкерского софта)

CreateThread (штатная функция создания нового потока), CreateRemoteThread (эта функция «живет» только под NT-подобными и на самом деле в защитах практически не используется. Зато очень, очень (я не забыл сказать «очень»?) широко используется самими крэкерами для внедрения в чужой процесс. Так что обойти ее вниманием в этом поминальнике было бы несправедливо)

ExitThread, TerminateThread (штатное завершение и аварийное уничтожение потоков соответственно)

 

Загрузка и выгрузка DLL

LoadLibrary, LoadLibraryEx (функции загрузки динамических библиотек)

LoadModule (устаревшая функция загрузки DLL)

GetProcAddress (функция, возвращающая адрес функции или переменной, объявленной в экспорте DLL, по имени этой функции/переменной (разумеется, соответствующая DLL должна быть подгружена текущим процессом). Эта функция широко используется как в защитах чтобы вызов какой-либо функции не «светился» в дизассемблированном листинге, а также для приемов типа push <желаемый адрес возврата>; jmp <адрес функции, полученный через GetProcAddress>, используемых для сокрытия точки, откуда была вызвана функция)

FreeLibrary (функция принудительной выгрузки DLL)

 

Надо сказать, список этот отнюдь не полный: в нем нет, к примеру, функций выделения блоков памяти (извлечь практическую пользу из информации о распределении памяти удается довольно редко, и те, области, где это актуально, отнюдь не относятся к «основам» крэкинга) и функций работы со строками (эти функции почти всегда дублируются в коде программы ради повышения быстродействия). Нет в этом списке и специфических библиотечных функций Microsoft’овской MFC и Borland’овских VCL/RTL – если включить в список и их, «поминальник» стал бы совершенно неподъемным. Все это мы оставим за рамками – тем более, что средства разработки не стоят на месте, и, возможно, через пару лет будут актуальны совершенно другие API. Попробуйте понять, из чего я исходил, составляя этот «поминальник» - и, поняв это, Вам станет заметно легче найти подход к ранее неизвестной системе программных интерфейсов. Я выбрал WinAPI лишь по той причине, что это – «основа основ» программирования под платформу Win32, которая в настоящее время наиболее распространена. Однако даже к изложенному выше стоит относиться с известной долей осторожности – поскольку линейка Windows 9x угасает, в ближайшем будущем вполне могут стать актуальными специфические для линейки NT системные вызовы, а Вы будете искать правды среди стандартных функций Win32 – и не найдете ее. Прецедент уже имел место – в многих крэкерских руководствах пятилетней давности рекомендовалось для гарантированного «отлова» считывания текста из окна устанавливать брейкпойнт на функцию hmemcpy. И это работало – но только под Windows 9x, поскольку в линейке NT вместо вызова этой функции для повышения скорости использовались inline-вставки. До тех пор, пока все семейство NT-подобных ограничивалось доисторической NT 3.51 и неудобоваримой NT 4, проблемы как бы не существовало. Но вот на наши десктопы пришли более симпатичные Windows 2000 и Windows XP – и противоречие между старыми руководствами и наблюдаемой реальностью встало в полный рост.

 

Как Вы можете видеть, имена функций являются сокращенным описанием тех действий, которые эти функции выполняют. Таким образом, если Вы предполагаете существование некой функции WinAPI и хотите поставить на нее брейкпойнт, но не знаете (или просто забыли), как она называется, Вы можете легко это узнать, если владеете принятой программистами под Win32 терминологией и английским языком (или хотя бы русско-английским словарем). Кроме того, если всем другим отладчикам Вы предпочитаете SoftIce, у Вас есть возможность воспользоваться командой EXP <текстовая_строка>, которая позволяет вывести на консоль отладчика все функции, имена которых начинаются с указанной Вами строки. Например, команда EXP MessageBox покажет Вам все MessageBox’ы, какие только встречаются в символьной информации, импортированной из DLL (список DLL для импортирования информации должен быть заранее прописан в инициализационном файле отладчика). Надо отметить, что команда EXP имеет несколько более широкие возможности, чем простой вывод списка функций, начинающихся с определенных символов – Вы можете также просмотреть список модулей, по которым имеется символьная информация, проверить, присутствует ли нужная функция в некотором модуле и многое другое, о чем можно прочесть в руководстве по этому отладчику.

 

Вы наверняка заметили, что в «поминальнике» отсутствуют функции MessageBoxA и MessageBoxExA, о которых я упоминал в начале статьи, а вместо них описаны лишь MessageBox, MessageBoxEx. Разумеется, это не опечатка. Если Вы уже попробовали «на вкус» SoftIce’овую команду EXP и приложили ее к нашему измученному брейкпойнтами (то ли еще будет!) MessageBox’у, то наверняка заметили, что в списке функций, экспортируемых из USER32.DLL присутствуют MessageBoxA и MessageBoxW (а вот просто MessageBox’а нет и в помине). Откуда же взялись буквы A и W в именах функций и что они вообще значат?

 

Расшифровка этих букв проста: A – это ANSI, W – это WIDE. А появились эти буквы после того, как в Microsoft взяли курс на внедрение кодировки UNICODE, и возникла необходимость как-то отличать «старые» варианты функций, работающие с традиционными текстовыми строками, от «новых», использующих кодировку UNICODE. А символы в UNICODE имеют тип WСHAR длиной в 16 бит против обычных восьми – поэтому к именам функций добавили букву W, а не U, как можно было бы предположить. Вот и получается, что если функция принимает или передает строковые параметры, программист должен указать, какой из вариантов функции нужно использовать. Разумеется, если функция WinAPI не получает и не возвращает никаких символьных параметров, буквы A и W ей совершенно ни к чему. Так что единственная причина того, что в моем «поминальнике» начисто отсутствуют упоминания об ANSI- и UNICODE-вариантах одной и той же функции весьма банальна. Если бы я подробно расписывал оба варианта имени каждой функции, мне бы пришлось набирать этот список почти в два раза дольше, а Вам – во столько же раз дольше его читать, и при этом Вы бы не получили никакой новой информации. Поэтому я и решил не упражняться в крючкотворстве, а вместо этого объяснить причины использования окончаний A и W. Надеюсь, что после моих объяснений Вы сможете подставить нужные буквы в имена функций самостоятельно.

 

Вряд ли Вам пригодится на практике эта информация, однако стремление к истине обязывает меня сообщить Вам великую тайну. Знайте, что все-таки они существуют – я имею ввиду MessageBox’ы без буквы A или W. Ибо давным-давно, когда на Земле жили 16-разрядные динозавры Windows 3.1 и Windows for Workgroups, скрывавшиеся в недрах этих ОС функции ничего не ведали ни об ANSI, ни об UNICODE – а потому не нуждались в буквах A и W. И от тех древних времен в include-файлах все еще сохранились строчки вроде lstrcmp equ <lstrcmpA> - в целях совместимости и упрощения переноса старых исходников под новые компиляторы.

 

Практическое использование этого списка функций выглядит очень просто. Предположим, что у Вас есть некая программа, и на этапе первоначального сбора информации Вы узнали, что она работает 30 дней, выводит при запуске MessageBox со счетчиком дней до окончания триального срока, и, если триальный срок закончился, после нажатия кнопки OK завершает программу. Дальше Вы можете рассуждать примерно следующим образом:

 - Если программа проверяет, сколько дней она работает – весьма вероятно, что она считывает текущую дату. Поэтому смотрим в раздел «Работа с датой и временем» и выбираем оттуда функции, предназначенные для чтения времени.

- Программа показывает MessageBox – следовательно, есть смысл поставить точки останова на функции создания MessageBox’ов.

- При истечении триального срока программа завершается – значит можно попытаться отловить вызов функции ExitProcess.

После расстановки брейкпойнтов на упомянутые функции и запуска программы отладчик скорее всего всплывет где-то внутри той ветки программы, которая ответственна за появление nag screen’а и проверку триала. После этого Вы можете ликвидировать сам nag screen (просто забив nop’ами все, что относится к вызову функции, создающей MessageBox) а затем методом обратной трассировки в уме (о сути этого метода я подробно расскажу чуть позже) добраться от точки вызова ExitProcess до условия, по которому программа определяет, что триальный срок еще не кончился (и продолжает работать) или уже истек (и тогда вызывается ветка с ExitProcess). После этого, внеся исправления в исполняемый файл, Вы сможете организовать себе «вечный триал».

 

Практика показала, что «отловить» появление nag screen’ов проще всего, отслеживая при помощи точек останова следующие типы событий:

  1. Появление MessageBox’ов любых типов
  2. Создание диалогов на основе ресурсов
  3. Вызовы функций отображения окон (успешно работает только в том случае, если окон сравнительно немного – иначе становится сложно отследить среди всех вызовов соответствующих функций те, которые создают нужное окно)
  4. Считывание текущего времени (в тех случаях, когда программа имеет ограничение по времени использования, и nag screen содержит информацию о том, когда истечет испытательный срок)
  5. Создание таймеров (например, для активации управляющего элемента)
  6. Изменение состояния окон Windows (например, активация кнопки или помещение в заголовок окна какой-либо надписи)
  7. Вызовы вспомогательных функций, использующихся для изменения текста окон (к примеру, если в заголовке nag screen’а присутствует надпись «Осталось N дней пробного периода», можно предположить, что для подстановки числа дней в надпись может использоваться функция wsprintf).

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

 

Теперь Вам, пожалуй, известно о функциях WinAPI достаточно, чтобы приступить к рассмотрению примеров, демонстрирующих, как эти знания применить для решения практическим задачам крэкинга. Так что в порядке эксперимента давайте немного взломаем какую-нибудь распространенную программу, например, Notepad. Это будет довольно несложный, но весьма поучительный эксперимент, демонстрирующий одну из техник ликвидации nag screen’ов в «настоящих» программах. Итак, возьмем обыкновенный Блокнот (я взял тот, который входит в состав Windows XP), скопируем его в надежное место, чтобы в случае чего можно было восстановить файл из резервной копии (вообще, возьмите себе за правило каждый успешный шаг отмечать резервными копиями файлов, дампами успешно модифицированных кусков и т.д. – очень неприятно бывает терять из-за ошибок результаты длительной и кропотливой работы) и запустим его. Теперь нажмите в Блокноте Ctrl-F (оно же «Правка|Найти…»), легонько стукните по клавиатуре левой пяткой (или просто наберите любой текст) и нажмите кнопку «Найти далее». Поскольку нашего «любого» текста в поле редактирования нет (потому что там вообще ничего нет), Блокнот ругнется сообщением вроде «Не удается найти "340t80543t"». Будем считать, что это и есть nag screen, который нам предстоит ликвидировать.

 

Очевидно, что этот «nag screen» очень сильно похож на MessageBox – поэтому вполне логичным было бы начать поиск нашего окошка с установки брейкпойнта на функции вывода стандартных окон с сообщениями (MessageBoxA, MessageBoxW, MessageBoxExA – и далее по списку). Грузим подопытную программу в отладчик (я в качестве отладчика буду использовать OllyDebug, но те, кто предпочитает SoftIce, тоже смогут без всяких сложностей повторить этот эксперимент) и устанавливаем точки останова. В SoftIce это делается очень просто: «BPX имя_функции» - и так много-много раз, для всех подходящих функций из «поминальника». В OllyDebug эта операция выполняется несколько сложнее, зато Вы получите весьма неожиданный, но очень приятный сюрприз. Но приятные сюрпризы будут позже, а пока щелкните правой кнопкой мыши в окне кода и вызовите всплывающее меню. В этом меню вызовите пункт «Search for > Name (label) in current module» - этот пункт заставляет отладчик пробежаться по загруженному модулю (в нашем случае – по EXE’шнику Блокнота) и найти в нем все ссылки на «именованные» адреса (в частности, такими адресами являются вызовы WinAPI). Отладчик покажет окно с весьма немаленьким списком функций, на которые удалось найти ссылки в коде программы, и теперь в этом списке нам нужно найти наши МessageBox’ы. А вот и наступило время для обещанного приятного сюрприза: в списке нашлась только одна подходящая функция из «поминальника» - MessageBoxW. Так что в то время, как счастливые обладатели SoftIce сбивали в кровь руки, разбрасывая брейкпойнты на функции, которые вообще не используются в программе, пользователи OllyDebug без всяких усилий получили информацию о том, с какого вызова API лучше всего начать подбираться к нашему nag screen’у. Пользователям же SoftIce чтобы получить список вызываемых программой функций API придется воспользоваться каким-нибудь дополнительным софтом, позволяющим просмотреть список импортируемых функций (благо таких программ немало).

 

В SoftIce брейкпойнт на функцию ставится элементарно: BPX MessageBoxW и никаких гвоздей; в OllyDebug эта операция немного сложнее: найдя нужную строчку в списке импортированных функций, щелкните правой кнопкой мыши и в всплывшем меню выберите пункт «Follow import in disassembler». После этого отладчик мгновенно перенесет Вас в глубины одной из важнейших системных библиотек операционной системы прямо к первому байту функции MessageBoxW. После этого останется лишь нажать клавишу F2 и увидеть, как начальный адрес этой функции окрасится в красный цвет. На этом подготовительные операции закончены – пора отпустить программу на волю и начать охоту на наш «nag screen».

 

Попробовав опять заставить Блокнот искать «то, не знаю что», мы с радостью видим, что наша точка останова, о необходимости которой я так долго говорил, наконец-то выполнила свою задачу и «тормознула» программу, как только та попыталась выполнить функцию MessageBoxW. В окне отладчика Вы увидите что-то вроде этого:

 

77D70956 > 6A 00    push 0

77D70958 . FF7424 14 push dword ptr ss:[esp+14]

77D7095C . FF7424 14 push dword ptr ss:[esp+14]       ; |Title = "????"

77D70960 . FF7424 14 push dword ptr ss:[esp+14]       ; |Text = "????"

77D70964 . FF7424 14 push dword ptr ss:[esp+14]       ; |hOwner = 00010015

77D70968 . E8 03000000 call USER32.MessageBoxExW        ; \MessageBoxExW

77D7096D . C2 1000  retn 10

 

И это есть ни что иное, как код функции MessageBoxW с комментариями, которые в него подставил OllyDebug (поскольку SoftIce никаких комментариев, кроме заранее определенных пользователем, подставлять не умеет, пользователям Айса придется немного напрячь воображение). При помощи кнопки F8 начинаем трассировать этот код («Ура! Мы отлаживаем ядро!») без захода в процедуры, и, дотрассировав до адреса 77D70968 с удивлением видим, что отладчик остановился, зато на экране появился наш MessageBox. А это значит, что наше исходное предположение насчет MessageBox’овой сущности «nag screen’а» было верно. Закроем этот MessageBox и продолжим трассировать до тех пор, пока не выйдем из процедуры по команде retn 10.

 

Пока Вы продавливаете до пола несчастную клавишу F8, я сделаю небольшое лирическое отступление. Вы наверняка уже прочувствовали, что много раз подряд нажимать F8 довольно скучно. А ведь MessageBoxW - это далеко не самая длинная процедура, нередко встречаются шедевры, в дизассемблированном виде занимающие десятки и сотни строк! И у Вас возник естественный вопрос – «неужели в таком большом и сложном отладчике нет какой-нибудь маленькой кнопки, чтобы сразу добраться до точки выхода из процедуры». Не подумайте о разработчиках плохого - они позаботились о нас, поэтому столь нужная кнопка в обеих отладчиках есть! В OllyDebug эта «кнопка» (она находится не только на панели инструментов, но и продублирована соответствующим пунктом меню) называется «Execute till return» и вызывается комбинацией Ctrl-F9. Заодно обратите внимание и на пункт меню «Execute till user code»: если Вы когда-нибудь заплутаете в недрах чужой DLL и захотите одним махом выполнить весь код до того знаменательного момента, когда управление вернется в основную программу – смело жмите Alt-F9, и OllyDebug Вам поможет. В SoftIce для того, чтобы добраться до точки выхода из функции, служит команда p ret , по умолчанию «подвешенная» на клавишу F12. Вы можете подумать, что если есть команда p ret, которая заставляет программу работать до первого ret’а, то должны существовать команды p mov, p push и т.п. Увы, это не так – разработчики SoftIce сочли, что и одного отслеживания ret’ов более чем достаточно.

 

Но вернемся к нашему Блокноту. После того, как Вы успешно выйдете из процедуры, OllyDebug покажет примерно следующее:

 

01001F8C |. FF75 14  push [arg.4]                     ; notepad.01009800

01001F8F |. FF75 10    push [arg.3]

01001F92 |. E8 58FFFFFF call notepad.01001EEF

01001F97 |. FF75 18  push [arg.5]                     ; /Style = MB_OK|MB_ICONASTERISK|MB_APPLMODAL

01001F9A |. FF75 0C  push [arg.2]                     ; |Title = "Блокнот"

01001F9D |. 56       push esi                         ; |Text = "Не удается найти "oierg""

01001F9E |. FF75 08  push [arg.1]                     ; |hOwner = 000D063A ('Найти',class='#32770',parent=004C0360)

01001FA1 |. FF15 4C120001 call dword ptr ds:[<&USER32.MessageBo>; \MessageBoxW

01001FA7 |. 56       push esi                         ; /hMemory = 0009BCB0

01001FA8 |. 8BF8     mov edi, eax                    ; |

01001FAA |. FF15 F4100001 call dword ptr ds:[<&KERNEL32.LocalFr>; \LocalFree

 

Если у Вас вместо OllyDebug установлен SoftIce, Вы все равно увидите нечто подобное, но уже без подсказок о параметрах функций MessageBox и LocalFree – SoftIce излишней дружелюбностью не страдает, а потому услужливо выискивать и расшифровывать для Вас параметры системных вызовов не станет. А жаль. Один из способов сделать так, чтобы MessageBox не больше появлялся, заключается в забивании nop’ами «лишних» команд – а именно вызова call dword ptr ds:[<&USER32.MessageBoxW>]. Однако мало ликвидировать лишь сам CALL – нельзя забывать и о параметрах вызываемой функции. Потому что, если эти параметры не прибить тоже – они попадут в стек и так там и останутся до тех пор, пока процедуре, внутри которой эти параметры были положены на стек, не вздумается выполнить команду ret (или retn). Вот тут-то и начнется самое веселое: по-хорошему в момент выполнения команды ret на вершине стека должен находиться адрес возврата, а в действительности окажется мусор, который должен был «уйти» в функцию MessageBoxW и благополучно исчезнуть в ее недрах. В зависимости от Вашей удачливости, настроения программы и текущей фазы Луны Вы получите либо банальный GPF (почти наверняка), либо какой-нибудь экзотический спецэффект вплоть до форматирования винчестера. Насчет форматирования - это, конечно, шутка, но шутка с долей правды – представьте, что случится, если на стеке в качестве адреса возврата окажется адрес какого-нибудь осмысленного куска кода. Так что после успешной ликвидации системного вызова не поленитесь разобраться и с PUSH’ами – пусть волшебная команда XCHG EAX,EAX (более известная как NOP) станет Вашим лучшим другом. Если же Вы питаете суеверный страх перед командой NOP или Вам просто лень много-много раз набирать код 90 – просто пропишите по адресу 01001F97 команду jmp 01001FA7; результат будет тот же самый, а пальцы устанут значительно меньше.

 

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

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

 

На практике такую проверку можно выполнить следующим образом: засечь значение регистра ESP на входе в процедуру и на выходе из нее, вычислить разность между этими значениями (тут полезно помнить, что соглашения вызова PASCAL и STDCALL предусматривают удаление параметров процедуры со стека внутри процедуры, а CDECL – коррекцию стека после завершения процедуры, уже в теле программы). Если после внесения модификаций разность между значениями ESP не изменилась – значит, все сделано правильно, если изменилась – ищите ошибку в своих действиях.

 

Разумеется, совершенно необязательно замерять разность между значениями ESP именно в начале и в конце процедуры. К примеру, если процедура, внутри которой мы «стираем» обращение к другой процедуре, вызывается при помощи обычного CALL, можно промерить значения ESP непосредственно перед выполнением этого CALL и сразу после него. Чтобы корректно проверить, не «застряло» ли чего-нибудь в стеке, или наоборот, не удалили ли мы чего-нибудь лишнего, необходимо соблюсти следующие условия:

 

1. При любом ходе исполнения кода (т.е. независимо от того, какие ветки срабатывают между точками, в которых проверяется положение стека) изменение величины ESP должно быть одинаковым.

2. Вы должны быть уверены, что все операции по помещению параметров на стек, относящиеся к проверяемому вызову, находятся между точками замеров. И вот здесь-то Вас могут подстерегать довольно неприятные неожиданности. Языки высокого уровня не позволяют по-настоящему изощренно работать со стеком (компиляторы С\C++, правда, позволяет динамически зарезервировать на стеке некоторую область и даже соорудить в этой области объект – но это максимум, на что может рассчитывать программист). Даже «очень оптимизирующие» компиляторы обычно генерируют помещение параметров на стек в непосредственной близости от вызова функции, которая эти параметры принимает – поэтому найти их не так уж сложно. Но вот ассемблер… Да, ассемблер способен изменить ситуацию коренным образом. Вместо банального MyFunction (my_param) Вы вольны написать что-то вроде

 

push my_param

<сотня строчек кода, не относящегося к делу>

call MyFunction

 

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

 

Раз уж зашла речь об антидизассемблерных приемах (хотя на самом деле эти приемы направлены не столько на то, чтобы сбить с толку дизассемблер, сколько на то, чтобы запутать крэкера, пытающегося осмыслить код), еще немного уклонюсь от основной темы главы и расскажу о паре хитростей, иногда использующихся для помещения параметров на стек. Основаны эти приемы на том, что регистр ESP мало чем отличается от регистров общего назначения, а область стека – от всех прочих областей памяти. Зная, что над регистром ESP вполне возможно производить арифметические операции сложения и вычитания, а также обращаться к содержимому стека при помощи косвенной адресации командами вроде mov [ESP+8],eax, нетрудно догадаться, что команду push eax можно заменить, к примеру, последовательностью

sub esp,4

mov [esp+4],eax

 

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

 

Надеюсь, что я не слишком напугал Вас живописаниями тех ужасов, которые Вы можете встретить (а можете и не встретить) на своем пути. С функциями WinAPI, как правило, все бывает гораздо проще – число параметров в любой момент можно посмотреть в документации, «ленивые» компиляторы складируют эти параметры непосредственно перед вызовом, и удаление «лишнего» вызова не представляет собой совершенно никакой сложности – примерно как убрать MessageBox из Блокнота.

 

А напоследок я расскажу об одном очень-очень простом способе, в некоторых случаях позволяющем найти место, в котором локализован вызов nag screen’а. Суть способы очень проста: Вы загружаете программу и начинаете трассировать ее без захода внутрь функций, при каждом нажатии клавиши F8 запоминая текущий адрес (на практике почти всегда достаточно запоминать только адреса выполняемых call’ов). При выполнении одной из таких функции появится наш nag screen. Снова загружаем программу, вспоминаем, каким был адрес того call’а, который вызвал появление окна и «прогоняем» программу до этого адреса в ускоренном режиме.

 

Перейдем ко второму шагу: зная, что создание nag screen’а сидит где-то в глубинах вызываемой функции, войдем внутрь этой функции при помощи команды трассировки с заходом в функцию. Теперь начинаем трассировать содержимое этой функции, все так же запоминая адреса исполняемых команд. Рано или поздно мы наткнемся на очередной вызов, после которого выскочит nag screen. Этот процесс постепенного погружения в код можно продолжать до тех пор, пока Вы не доберетесь до вызова WinAPI или другой библиотечной функции, создающей окно; не попадете в цикл обработки сообщений или ожидания закрытия окна nag screen’а (что будет означать «перелет», но из этого результата тоже можно извлечь определенную пользу) либо не придете к выводу, что данная техника в Вашем случае неприменима.

 

Вообще говоря, эта методика применима не только для поиска вызовов nag screen’ов, выводимых сразу после запуска. Если Вы знакомы с численными методами решения уравнений, Вы наверняка заметили некоторую аналогию с методом Ньютона: последовательно двигаясь вглубь кода и проверяя эффекты от вызовов подпрограмм, мы констатируем «недолет» либо «перелет» и постепенно сужаем область, в которой находится интересующий нас блок команд. Метод Ньютона изначально был предназначен для поиска решения на некотором промежутке значений, и, по аналогии, предлагаемый метод также может быть использован для поиска некоей функции «на промежутке кода». В качестве границ промежутка удобно использовать «знаковые» и легко отслеживаемые события, такие, как чтение текста из управляющего элемента, обращение к ячейке памяти, получение информации из реестра или из файла и отображение результатов этих действий в виде nag screen’а, сообщения о неверном серийном номере и т.п. Проще говоря, если Вы ввели серийный номер, нажали кнопку «ОК» и программа в ответ сказала что-то вроде «Неправильно ты, дядя Федор, серийники вводишь» - значит, проверка правильности серийного номера лежит где-то между считыванием введенного серийника и выводом сообщения. И если Вам удастся зафиксировать оба этих события при помощи брейкпойнтов, трассируя и изучая код, лежащий между этими бряками Вы с большой вероятностью обретете желаемый адрес процедуры проверки серийника. А уж что Вы с этим адресом сделаете – зависит лишь от Ваших исходных целей и изобретательности.

 

Предлагаемый мной способ имеет несколько существенных ограничений: во-первых, трассируемый код должен исполняться последовательно (т.е. создание окна по таймеру или какому-либо иному событию таким способом отследить не получится или, по крайней мере, будет достаточно сложно). Во-вторых, очень желательно, чтобы создание и отображение nag screen’а происходило в главном потоке программы (если nag screen отображается в отдельном потоке, придется сначала выискивать место создания этого потока). И, в-третьих, если Вы активно практикуете дзен-крэкинг (то бишь Ваш любимый modus operandi – «я не знаю, как это работает, но я все равно это сломаю»), Вы можете совершить следующую ошибку: «отключая» nag screen, можно копнуть слишком глубоко и случайно «вынести» не процедуру отображения nag screen’а, а более универсальную процедуру отображения окна вообще, которая по сути ни в чем не виновна. Для того, чтобы Вам было понятнее, о чем идет речь, продемонстрирую идею следующим псевдокодом:

 

ShowNagScreen proc

invoke ShowAnyWindow, nag_screen_handle

ShowNagScreen endp

 

ShowAnyWindow proc hwnd:DWORD

invoke ShowWindow, hwnd, SW_SHOWNORMAL

ShowAnyWindow endp

 

Предотвратить создание nag screen’а в этом случае можно тремя способами:

  1. Убрать вызов самой процедуры ShowNagScreen
  2. В коде процедуры ShowNagScreen убрать вызов ShowAnyWindow
  3. Удалить вызов WinAPI’шной функции ShowWindow внутри ShowAnyWindow

Первый и второй способы, в принципе, вполне корректны (причем второй даже несколько лучше – в реальной процедуре создания nag screen’а могут выполняться какие-нибудь дополнительные операции), а вот третий… Да, своего мы бы, конечно, добились – но вместе с nag screen’ом исчезли бы и все другие окна, которые отображаются процедурой ShowAnyWindow. А если учесть, что в современных программах вызовы WinAPI нередко упрятаны глубоко в недра всевозможных библиотек, вопрос о том, как бы случайно не перестараться с поиском «корня зла» и от избытка чувств не пропатчить библиотечную функцию – отнюдь не праздный. И универсального решения этого вопроса, по-видимому, не существует (если, конечно, не считать таким решением доскональный анализ кода программы).

 

Однако я могу предложить Вашему вниманию два подхода, которые с достаточно высокой вероятностью позволяют определить, используется ли функция исключительно для единственной цели (в частности – для отображения nag screen’а), или же является универсальной в рамках приложения. Первый подход основывается на том, что библиотечные и просто широко используемые функции, как правило, вызываются многократно из разных областей исполняемого кода, в то время, как ссылки на узкоспециализированные процедуры (такие, как проверка серийного номера на валидность или вывод сообщения об ограничениях в программе) обычно присутствуют лишь в двух-трех экземплярах на всю программу, и во время исполнения кода приложения срабатывают считанные разы. Выполнить проверку подозрительной функции на количество вызовов можно как при помощи дизассемблера, так и прямо в процессе отладки. Поскольку большинство дизассемблеров позволяют получить список ссылок на процедуру (а также точек, в которых эти ссылки находятся), анализ при помощи дизассемблера сводится к простому поиску нужной процедуры в выходном листинге дизассемблера и визуальной оценке количества ссылок. Не могу еще раз не проагитировать Вас за использование OllyDebug: этот отладчик содержит несколько весьма удобных инструментов, скрывающихся внутри пункта меню «Find references to…». В данном контексте нам, несомненно, особенно интересен подпункт «Selected address», позволяющий найти все ссылки на команду под курсором. То есть, для выполнения описанной проверки Вы можете даже обойтись без дизассемблера; чтобы найти все прямые ссылки на некий адрес (например, на адрес первого байта функции), достаточно установить курсор на этот адрес и нажать Crtl-R. Если ссылок немного – значит, Вы нашли то, что искали. А вот если их количество перевалит за 5-8 штук – у Вас есть веские основания подозревать, что проверяемая функция выполняет некие общие функции, и потому Вам нужно подняться по дереву вызовов на уровень выше либо вообще искать нужный код в другой области. Раз уж речь зашла о дереве вызовов, добавлю еще следующее: даже удостоверившись в том, что проверяемая функция A вызывается один-единственный раз из функции B, не поленитесь посмотреть, что «растет» на дереве вызовов сверху – может оказаться, что сама функция B вызывается в программе десятки раз. В этом случае вывод очевиден – Вы слишком увлеклись «глубоким бурением». Выполнить подобную проверку при помощи отладчика ничуть не сложнее – нужно всего лишь поставить точку останова на подозрительную функцию и запустить программу «с нуля». По количеству срабатываний точки останова, а также соотнося эти срабатывания с всплыванием nag screen’а, Вы сможете сделать вывод о том, является функция «знаковой» для защиты или же просто исполняет некие более общие функции, прямого отношения к защитным механизмам не имеющие.

 

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

 

Вот и подошла к концу очередная глава. Надеюсь, что Вы узнали об основах дебаггинга достаточно, чтобы попробовать самостоятельно что-нибудь взломать. Раз уж мы начали с Блокнота – попробуйте сотворить что-нибудь эдакое с Калькулятором (операция деления на ноль – достаточно интересная область для экспериментов), а уж затем можно перейти от «учебных целей» и к «настоящим» задачам. Возможно, исследование «большого» приложения у Вас тоже пройдет как по маслу – но вполне может быть, что защита окажется достаточно серьезной, и в процессе анализа кода у Вас возникнут сложности. И потому следующая глава как раз и будет посвящена различным тонкостям и хитростям отладки, а также борьбе с некоторыми антиотладочными приемами.


Глава 9.

Если бряк оказался вдруг…

 

Наверное, Вы уже попытались что-нибудь взломать. Может быть даже, Вам это удалось – за счет знаний и способностей к анализу, благодаря интуиции, или же в силу Вашего трудолюбия и настойчивости. Возможно также, что Вам просто очень повезло с первой в жизни программой, и защита оказалась слабее, чем в большинстве других программ. Однако тех, кто не смог с первой попытки одержать победу над мегабайтами кода, гораздо больше. Кто-то споткнулся об антиотладочные приемы, кому-то «повезло» встретиться с запакованной программой, кто-то принял близко к сердцу огромнейшие возможности, предоставляемые OllyDebug и SoftIce, и погрузился в изучение этих инструментов, отложив до времени собственно копание в коде. Некоторые отступили, не добравшись до подходящей зацепки, с которой можно было бы начать «раскручивать» защиту. Свежие ощущения, новые знания, предвкушение будущих побед – все, что знаменовало рождение крэкера, осталось в светлом прошлом, куда Вы сможете вернуться лишь в мечтах. В общем, одни радуются своей первой победе, другие – переводят дыхание и с тоской глядят на заоблачные выси, которые не удалось достичь. Если Вы попали в число «других», значит, у нас есть кое-что общее – свою первую программу я взломал далеко не с первой попытки. Надеюсь, после этих слов у Вас появился повод для оптимизма – возможно, именно Вам в будущем суждено написать свои собственные «Теоретические основы…». Однако сейчас Вас, наверняка, больше интересует другой вопрос – «почему мне не удалось сломать программу?» Причем нередко этот вопрос обретает еще более конкретную формы – «почему я ставлю брейкпойнты, а они не срабатывают?» и «как отлаживаемая программа может обнаружить мои точки останова?» И вопросы о неработающих (или «странно» работающих) брейкпойнтах – это отнюдь не повод упрекнуть в невнимательности начинающего крэкера, но основание для подробного разговора об особенностях Win32 API, тонкостях работы точек останова и антиотладочных приемах.

 

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

 

Самой простой (и, к сожалению, отнюдь не самой редкой) причиной такого поведения наших верных друзей являются ошибки в коде отладчиков. Да-да, вы не ослышались, крэкерам нередко приходится тратить часы на поиски несуществующих защит именно из-за недоработок в используемом инструментарии. «SoftIce не ставит бряк на функции», «Symbol loader не останавливает программу после загрузки» и другие подобные проблемы, с которыми сталкивался едва ли не каждый пользователь этого отладчика, уже который год отравляют жизнь крэкерам. При некотором упорстве и настойчивости эти проблемы иногда удается обойти разными «шаманскими» приемами, например, использованием аппаратного брейкпойнта вместо обычного или указанием адресов в явном виде, но даже такие «танцы с бубном» не всегда оказываются эффективны против сущностей, скрывающихся по ту сторону отладчика. Никакие конкретные рекомендации тут, понятное дело, дать невозможно – программные глюки бесконечно разнообразны, и без точного знания причины с ними можно бороться разве что методом терпеливого перебора всех «обходных путей», какие только придут Вам в голову. Если Вас не прельщает сей метод – есть смысл поискать другую версию продукта (поскольку глюки, присущие одной версии программы, могут полностью отсутствовать в другой, пусть даже более старой), либо вообще подумать об обновлении инструментария.

 

Ненамного отстают по популярности среди авторов защит всевозможные приемы определения присутствия отладчиков, от откровенно примитивной проверки наличия определенных файлов/ключей реестра (отдельные разработчики защит даже удаляют эти ключи, нимало не утруждая свой беспросветно могучий интеллект мыслями о том, что SoftIce можно приобрести легально и использовать не для взлома их поделок) до довольно изощренных антиотладочных приемов, использующих особенности конкретных отладчиков. Примерами таких особенностей могут служить «черные ходы» в SoftIce для взаимодействия с Bounds Checker’ом или нездоровая реакция на вызов IsDebuggerPresent в OllyDebug и всех остальных отладчиках, использующих Debug API. Кстати, признаки наличия отладчика могут быть не только информационными, но и физическими: программа может «догадаться» о том, что ее ломают, по ненормально большому времени выполнения тех или иных процедур. Задумайтесь над тем, сколько времени уходит на выполнение десятка команд в «ручном режиме», когда Вы исступленно давите кнопку F8 в OllyDebug - и Вы сразу поймете, что я имею в виду. К этой же группе можно отнести использование в защитных механизмах отладочных регистров процессора: поскольку эти регистры используются отладчиком для установки брейкпойнтов, одновременная их эксплуатация программой и отладчиком невозможна, если попытаться проделать такое, либо отладчик «забудет» аппаратные точки останова, либо защитные процедуры выдадут некорректный результат со всеми вытекающими из этого последствиями. Большинство антиотладочных приемов, разумеется, давно и хорошо известны, и их описание несложно найти в руководствах по крэкингу и написанию защит. Впрочем, авторы защит на такие приемы обычно всерьез не рассчитывают, поскольку идентифицировать (а часто – и обойти) антиотладочный код в дизассемблерном листинге обычно несложно (например, если прикладная программа пытается оперировать с отладочными регистрами, это очевидный признак того, что в коде «что-то нечисто»), а некоторые крэкерские инструменты среди своих функций имеют отслеживание популярных антиотладочных приемов (примером может служить старая утилита FrogsIce, которая умела выявлять и побеждать множество защитных трюков, направленных против SoftIce).

 

Наиболее популярным среди начинающих крэкеров, по-видимому, еще долго будет оставаться вопрос: «я поставил брейкпойнты на GetWindowText’ы и GetDlgItemText’ы, и все равно не могу поймать момент чтения серийника из окна». Действительно, формально все вроде бы сделано правильно, и все подходящие функции из «поминальника» обвешаны точками останова, как новогодняя елка – игрушками, но отладчик все равно не подает ни малейших признаков активности. При этом все точки останова находятся на своих местах и вполне успешно срабатывают – но, увы, не по тому поводу, который Вам интересен. В общем, у неопытного кодокопателя может сложиться впечатление, что серийный номер считывается при помощи телепатии или, как минимум, весьма недокументированным способом, чтобы обнаружить который нужно иметь не меньше семи пядей во лбу. Однако в действительности никаких телепатических датчиков в Вашем компьютере нет (а если даже и есть, то вряд ли они используются для чтения текста из диалоговых окон), да и подозревать недокументированные приемы я бы тоже не торопился, поскольку существует куда более простое объяснение этого явления. В Windows с давних пор сосуществуют два различных механизма, позволяющих управлять окнами и некоторыми другими объектами. Об одном из этих механизмов – системных вызовах Windows API я уже говорил, и даже дал в предыдущей главе небольшой список наиболее употребительных функций с комментариями по поводу области их применения. Другая же сторона Windows до настоящего момента как-то оставалась в тени, за исключением эпизодических упоминаний «по поводу». Вы, наверное, уже догадались, что это за «другая сторона Windows»: я говорю о широко используемых в нашей любимой ОС сообщениях (хотя, если быть до конца точным, сообщения в том или ином виде присутствуют в большинстве современных операционных систем).

 

Если функции WinAPI безраздельно властвуют в темном и мрачном царстве невизуальных объектов, таких, как файлы, процессы, средства синхронизации и прочее, то в «оконной» области ситуация отличается разительным образом. Сравнительно небольшой набор системных вызовов общего назначения («создать-включить-удалить окно») с лихвой компенсируется огромным разнообразием системных сообщений (в англоязычной документации – «messages»; общее число документированных сообщений уж перевалило за тысячу), подчас дублирующих функции WinAPI (например, сообщение WM_GETTEXT, которое способно читать текст окна не хуже, чем уже известная Вам функция GetWindowText). Некоторые типы управляющих элементов, такие, как обычные или выпадающие списки, вообще не имеют полноценной «обвязки» функциями Win32 API и управляются с ними именно при помощи сообщений. Вы не сможете добавить в такой список строчку или перейти к нужной позиции, вызвав WinAPI’шную функцию с названием вроде ComboBoxAddString или ComboBoxSetPos – таких функций в системных библиотеках Windows просто нет. Зато есть сообщения CB_ADDSTRING и CB_SETCURSEL соответственно, воспользовавшись которыми, Вы легко выполните задуманное. То есть, сообщения играют роль параллельного механизма управления объектами ОС, на работу которого совершенно не влияют традиционные брейкпойнты, которые мы щедрой рукой сеяли в предыдущей главе.

 

Поскольку сообщение – не функция, брейкпойнт на него поставить нельзя. Но очень хочется. А если очень хочется – значит, все-таки можно, хотя и не так просто, как хотелось бы. Прежде всего, Вам нужно определиться, что именно Вы хотите отловить – момент и точку отправки сообщения, либо подпрограмму обработки этого сообщения. Если Ваc интересует второй вариант и Вы являетесь поклонником SoftIce – считайте, что о Вас уже позаботилась фирма NuMega (ныне - Compuware). Встроенная в SoftIce команда BMSG как раз для этого и предназначена, но чтобы успешно ее использовать, Вам понадобится узнать хэндл окна, которому предназначено сообщение. Если нужные данные у Вас имеются – просто набирайте BMSG <хэндл_окна> <код_сообщения>, и ждите, когда «всплывет» отладчик. Разумеется, команда BMSG, как и любая другая команда установки брейкпойнтов, позволяет создавать условные точки останова, срабатывающие, например, при поступлении сообщений только с определенными значениями wParam и lParam.

 

А что делать тем, кто пользуется другими отладчиками, в которых аналог BMSG отсутствует? Ответ на этот вопрос находится, как ни странно, именно в руководстве по SoftIce. В частности, там написано, что действие команды BMSG может быть воспроизведено установкой условного брейкпойнта на оконную процедуру, причем в качестве условия нужно указать следующее: IF ( esp ->8)==<имя_сообщения>; адаптация этого условия под синтаксис, принятый в конкретном отладчике, обычно сложности не представляет, хотя вместо символьного имени сообщения скорее всего придется подставить его код (коды сообщений можно найти в файлах windows.inc, winnt.h или Messages.pas – в зависимости от того, компилятор какого языка у Вас есть под рукой; те, кто не обзавелся подходящим компилятором и не планируют им обзаводиться в ближайшем будущем, могут заглянуть в файл messages.lst из состава InqSoft Window Scanner).

 

Такой подход несколько сложнее, чем вызвать команду BMSG с нужными параметрами, но зато он дает одно немаловажное преимущество. В предыдущем абзаце я сделал замечание насчет необходимости знать хэндл окна для успешного применения этой команды. Однако хэндл окна может быть Вам известен далеко не во всех случаях. Рассмотрим, к примеру, ситуацию, когда Вам нужно оттрассировать обработчик сообщения WM_INITDIALOG. Вы не сможете просто взять и посмотреть нужный Вам хэндл, поскольку окно только-только создано и могло еще даже не появиться на экране. Конечно, можно «заморозить» все исследуемое приложение и затем предпринять поиск среди всех окон в системе, но не кажется ли Вам, что это несколько сложнее, чем хотелось бы?

 

Кроме того, при каждом запуске программы хэндлы меняются, что тоже отнюдь не упрощает отладку. А вот оконная процедура всегда находится на одном и том же месте (справедливости ради надо отметить, что создание «плавающей» по адресному пространству от запуска к запуску процедуры теоретически возможно, хотя я такое и не встречал). И потому адрес этой процедуры можно просто записать на бумажке, чтобы затем восстанавливать соответствующий бряк без каких-либо сложностей. Нам осталось только раздобыть адрес этой самой процедуры. Тут тоже, в принципе, ничего сложного нет – разумеется, если под рукой имеются соответствующие инструменты (к примеру, Microsoft Spy++ или все тот же InqSoft Window Scanner). Наведите «прицел» программы на интересующее Вас окно и прочитайте желанный адрес оконной процедуры собственно окна (обычно этот адрес обозначается как WndProc) или оконной процедуры, сопоставленной классу окна.

 

Иной путь получения адреса оконной процедуры заключается в том, чтобы при помощи API-шпионов обнаружить системный вызов, при помощи которого производится регистрация класса окна (функции WinAPI RegisterClass и RegisterClassEx) либо непосредственно создание окна (список соответствующих функций я приводил в предыдущей главе). Операция эта выполняется в три этапа:

  1. Запускаем под API-шпионом, настроенным на отслеживание процедур создания окон, и ждем появления нужного окна.
  2. Как только окно появится – останавливаем работу шпиона и при помощи любого сканера окон получаем хэндл этого окна.
  3. Если адрес оконной процедуры находится среди параметров функции создания окна - ищем в логе, сгенерированном API-шпионом, функцию, которая возвращает значение нашего хэндла, и считываем ее параметры, среди которых находим искомый адрес оконной процедуры.
  4. Если адрес оконной процедуры находится в структуре, указатель на которую передается в функцию регистрации классов – считываем адрес, откуда был произведен вызов этой функции. Затем загружаем программу в отладчик, ставим точку останова на этот адрес (или чуть выше – на том месте, где происходит запись в стек указателя на структуру, это уж как Вам больше понравится) и запускаем программу. Как только исполнение программы прервется на нашем брейкпойнте – находим в памяти структуру, указатель на которую передается в RegisterClass[Ex] и аккуратно переписываем содержимое поля этой структуры, содержащее адрес оконной процедуры для регистрируемого класса.

 

Вас может смутить сложность четвертого пункта, который выполняет весьма несложные функции, но при этом его описание едва ли не длиннее предыдущих трех. Казалось бы, что нам мешает просто вытащить из лога API-шпиона значение указателя на структуру, по-быстрому снять дамп нужной области и прочитать желанный адрес? В принципе, ничего не мешает – но вот истинное содержимое структуры WNDCLASSEX Вы таким способом вряд ли прочитаете – потому что скорее всего в момент снятия дампа эта структура уже давно будет затерта другими данными. Дело в том, что регистрация класса – событие разовое, и потому память под структуру, описывающую класс, редко выделяют статически; обычно же программист обходится для этих целей куском стека. Так что когда Вы заберетесь своим дампером в адресное пространство процесса, в том месте, где находилась желанная структура, давно уже будут лежать другие данные. И единственным решением в данном случае мог бы быть интеллектуальный API-шпион, которому можно было бы объяснить правила извлечения полей структур из памяти. К сожалению, на данный момент API-шпионы с такими свойствами мне не известны.

 

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

 

Я уже приводил пример того, как программа считывала дату своей установки под видом поиска плагинов в своей директории, и, разумеется, этим список возможных приемов маскировки одних действий под другие не исчерпывается. Программа eXeScope, например, в качестве сообщения об ограничении в незарегистрированной версии выдает окно, внешним видом точь-в-точь повторяющее стандартный MessageBox, но в действительности нарисованное визуальными средствами в Delphi. Отображение файла в память вместо обычного чтения в буфер – прием известный, и, тем не менее, чтение файла лицензии таким способом вполне может поставить в тупик начинающего крэкера. Я уж не говорю о таких изощренных техниках, как парсинг ini-файлов «вручную» (после чего можно очень долго возиться с точками останова на GetPrivateProfile* - разумеется, с нулевым результатом) или экспорт кусков реестра при помощи утилиты regedit во временный файл с последующим анализом этого файла (что позволяет обойтись без вызова функций работы с реестром внутри программы).

 

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

 

В свое время самым популярным отладчиком для «Спектрума» был MONS (впрочем, некоторые люди, включая меня, предпочитали MON) – восьмикилобайтное порождение программистской мысли, способное загружаться в ОЗУ с любого адреса и управляемое из командной строки (прямо как SoftIce – внешнее сходство этих двух отладчиков вообще сложно не заметить). И, разумеется, MONS позволял ставить брейкпойнты – еще бы, не имея в своем арсенале такой возможности, этот отладчик вряд ли стал бы столь популярен. Но поскольку процессор Z80, на основе которого был сделан «Спектрум», никаких отладочных средств не предоставлял, авторам MONS пришлось реализовывать точки останова чисто программными средствами. Реализация эта красотой отнюдь не блистала – «установка брейкпойнта» по-Спектрумовски заключалась в подстановке в нужное место кода трехбайтной команды CALL xxxx, которая передавала исполнение в недра самого отладчика и таким образом приостанавливала исполнение пользовательского кода. Старые команды, код которых затирался брейкпойнтом, копировались в специальный буфер и дополнялись командой JP (аналог jmp из набора команд x86) для возврата к следующей команде, не испорченной CALL’ом. Исполнение в пошаговом режиме выглядело не менее оригинальным – исполняемая команда перебрасывалась в отдельный буфер, дополнялась все тем же JP, после чего отладчик передавал управление в этот буфер. Если еще вспомнить, что в Z80 существовали недокументированные команды, которые были известны далеко не всем отладчикам (и потому могли обрабатываться некорректно), отлаживаемая программа даже при абсолютно корректной работе могла испортить код отладчика, а под сам отладчик могло элементарно не хватить свободной памяти, и потому его загружали на место «ненужных» данных – Вы поймете, что представляла собой отладка в старые добрые времена.

 

Разработчики линейки x86 проявили больше заботы о программистах. В этой линейке процессоров вместо самодельной «затычки» в виде команды вызова подпрограммы для отладочных целей ввели отдельное прерывание с номером 3, которое вызывалось однобайтной командой (опкод команды int 3 – СС), в отличие от всех прочих прерываний, которые менее чем двумя байтами вызвать не получится. Другим полезным нововведением стала возможность исполнять код в пошаговом режиме через управление флагом трассировки (эта возможность, впрочем, мало актуальна для отладчиков пользовательского уровня под современные ОС). Однако, несмотря на такой, казалось бы, очевидный прогресс в развитии средств отладки, обыкновенные точки останова все так же, как и десятилетия назад, модифицируют исполняемый код, а потому легко обнаруживаются даже простейшими способами, например, проверкой контрольной суммы всех байтов (не говоря уже о CRC32 и использовании иных, еще более сложных и надежных хэш-функций). Самостоятельно убедиться в том, что точки останова модифицируют код, Вы можете за считанные секунды: откомпилируйте при помощи любого ассемблера следующие две строчки, возьмите OllyDebug и загрузите в него откомпилированный код.

 

addr1: mov eax,addr1

mov al, byte ptr [eax]

 

Если Вы просто выполните этот код в пошаговом режиме, то в регистре AL окажется число 0B8h.В этом нет ничего удивительного, B8 – это опкод команды mov eax, <число>. А теперь попробуйте поставить брейкпойнт на команду mov eax,addr1 и снова оттрассируйте этот код. После выполнения второй команды Вы увидите, что в регистре AL находится число 0CCh, хотя код в окне отладчика внешне совершенно не изменился (если, конечно, не считать изменением подсветку адреса, на который поставлен брейкпойнт). Самое интересное, что отладчики могут «приукрашать реальность» не только в окне кода, но и при просмотре данных.

 

Давайте проделаем еще один весьма поучительный в этом смысле эксперимент: загрузим наш пример из двух команд, поставим точку останова на первую и запишите адрес этой точки останова. Затем берем InqSoft Window Scanner и читаем байт по записанному адресу. Получаем, разумеется, 0CCh. А теперь взглянем на ту же область глазами отладчика (в OllyDebug это пункт меню Follow in dump|Selection) – и очень сильно удивляемся. Отладчик показывает нам совсем не то, что реально читается из памяти в регистр AL, a то, что должно было бы находиться по указанному адресу, если бы мы не поставили точку останова. Но и это еще не все! Посмотрите на динамические подсказки под окном кода – там-то как раз содержимое памяти отображается как надо.

 

Вот так «умные» отладчики помогают самообманываться начинающим крэкерам: отсутствие видимых изменений в коде наводит человека, не знакомого с тайнами устройства брейкпойнтов, на мысль о том, что прерывание исполнения программы в точке останова происходит по воле неких таинственных сил, с которыми отладчик находится в телепатической связи. Хотя на самом деле «классические» точки останова – это ни что иное, как обычные memory patch’и – а потому и обнаруживаются теми же самыми способами, что и любые другие исправления в коде.

 

Кстати, из того, что обычный (не аппаратный) брейкпойнт является ничем иным, как исправлением программы, есть одно интересное следствие. Дело в том, что SoftIce’у в общем-то без разницы, каким образом в программе появилась команда int 3 – главное, что он может на этой команде остановиться не хуже, чем на настоящем брейкпойнте. А после того, как отладчик остановится, можно внести любые поправки в содержимое регистра EIP и код программы, после чего продолжить исполнение как ни в чем не бывало (собственно, в OllyDebug тоже можно проделать такую операцию при помощи пункта New origin here из всплывающего меню). Польза от такого эрзац-брейкпойнта (после срабатывания которого, к тому же, нужно вручную восстанавливать код, который находился на месте int 3 и править EIP), на первый взгляд кажется весьма сомнительной, но она есть. Я уже упоминал глюк в SoftIce, когда отлаживаемая программа после загрузки Symbol loader’ом начинает немедленно выполняться, хотя крэкеру хотелось бы ее в этот момент притормозить. Так вот, если в Entry point исполняемого файла воткнуть опкод 0CCh, у подопытной программы не будет ни единого шанса избегнуть процесса отладки – поскольку первой командой окажется наш int 3, принудительно активирующий отладчик.

 

Теперь, когда мы знаем, что точки останова обнаружить можно (и даже знаем, как их можно обнаружить), можно вернуться к основному вопросу этой главы – «почему точки останова не срабатывают». В нашем случае этот вопрос можно даже конкретизировать – «какими способами подопытная программа может удалить из себя точку останова». В различных источниках мне неоднократно встречалось предложение использовать для этой цели коды коррекции ошибок, предваряя все «критичные ко взлому» участки программы вызовом функции проверки и восстановления кода. В случае изменения кода из-за появления точек останова процедура восстановления должна откорректировать «неправильные» байты. Теоретически такая схема вполне возможна, но на практике алгоритмы коррекции ошибок довольно сложны в реализации и не слишком производительны, так что народные массы эту идею не приняли. А вот более простой вариант восстановления кода из «резервной копии», расположенной в другом конце программы (или прямого вызова этой резервной процедуры вместо основной), таки имел место во времена ДОСа; впрочем я не удивлюсь, если выяснится, что такой прием до сих пор в ходу – реализация очень проста, а какой-никакой эффект все-таки имеется.

 

На практике дело обстоит еще хуже – для удаления некоторых точек останова не нужны ни коды коррекции ошибок, ни резервные копии. И именно к таким точкам останова относятся всеми нами любимые BPX’ы на вызовы функций WinAPI (и, если смотреть шире, на вызовы практически любых функций). Поскольку «брейкпойнт на функцию» - это на самом деле всего лишь брейкпойнт на первый байт этой функции, самый простой из приемов, удаляющих точки останова, выглядит следующим образом: заранее узнать адреса нужных функций при помощи GetProcAddress, прочитать их первые байты (если речь идет о внутренних функциях программы – то просто прочитать содержимое соответствующей ячейки) и сохранить значения этих эталонных байтов. Затем перед особо критичными вызовами нужно лишь сравнивать первые байты процедур с эталонным, и, если обнаружится несоответствие, восстанавливать их. Сам факт того, что процедура начинается с опкода 0CCh говорит о том, что на эту процедуру поставлена точка останова, что может побудить программу предпринять некоторые действия по самозащите. Если учитывать, что многие процедуры начинаются стандартной последовательностью команд push ebp; mov ebp, esp (в шестнадцатиричном редакторе эти команды выглядят как последовательность 55 8B EC), то «действия по самозащите» могут быть простой записью в первые три байта процедуры той самой стандартной последовательности 55 8B EC. После этой операции точка останова, разумеется, исчезнет. Разумеется, выявить защиту от брейкпойнтов, основанную на проверке содержимого неких адресов в памяти, не слишком сложно – нужно лишь поставить аппаратную точку останова на чтение/запись первого байта функции и посмотреть, где этот «капкан на защиту» сработает.

 

Другой способ постановки бряков на импортированные из DLL функции основан на том, что вызов импортированной функции почти всегда выполняется не напрямую, а через «переходник». На практике вызовы через «переходник» обычно выполняются одним из двух способов.

 

Первый способ:

call <переходник_к_MyFunc> ; Вызов функции API

переходник_к_MyFunc: jmp MyFunc

(этот способ вызова функций наиболее распространен; переходники вида «jmp истинный_адрес_функции» обычно собраны в конце программы)

 

Второй способ:

mov edi, dword ptr ds:[элемент_в_таблице]

call edi

элемент_в_таблице: dd <истинный_адрес_функции_MyFunc>

 (данная техника вызова функций обычно встречается в продуктах Microsoft)

 

Идея заключается в том, что в первом случае точку останова можно поставить не на первый байт функции, а на переходник, через который вызывается эта функция. В первом случае это будет обычный BPX на адрес команды jmp MyFunc, во втором случае придется прибегнуть к аппаратной точке останова на чтение двойного слова по адресу «элемент_в_таблице». Поскольку это не будут брейкпойнты на функцию в прямом смысле слова, этот метод имеет одно существенное ограничение: если нужная функция вызывается не через «переходник», а непосредственно по значению ее адреса (получаемому, например, при помощи GetProcAddress), то такой брейкпойнт, понятное дело, не сработает. Разумеется, также существует возможность, что программа попытается проверить целостность «переходников», но как такие попытки обнаруживать, Вы уже знаете.

 

Если некая процедура вызывается внутри программы стандартным образом, то большинство современных компиляторов генерирует последовательность команд push для помещения параметров функции на стек, собственно переход к процедуре выполняется при помощи команды CALL, а после того, как функция отработает, управление возвращается на команду, следующую за CALL. Однако если программист имеет достаточно высокую квалификацию, он может внести заметное разнообразие в эту картину при помощи «ручного» вызова функций средствами ассемблера. Хотя великий Intel завещал нам вызывать процедуры и функции при помощи специально для этого придуманной команды CALL, отдельным гражданам закон не писан (надо отметить, в число этих граждан входят не только авторы защит, но и любители предельной оптимизации, а также фанаты нетрадиционного программирования). И вот эти странные граждане сочинили несколько имитаций несчастной команды CALL, и эти имитации давно и прочно вошли в арсенал разработчиков защит. Из универсальных нетрадиционных средств вызова подпрограмм прежде всего нужно назвать следующие:

push <адрес возврата>

jmp <адрес процедуры>

 

или

 

push <адрес возврата>

push <адрес процедуры>

ret

 

Более сложные техники неявной передачи управления основаны на умышленном создании и обработке исключительных ситуаций, вызове прерываний и эксплуатации особенностей конкретных ОС. Эти техники сами по себе представляют весьма значительный интерес – с точки зрения как крэкера, так и программиста, однако их количество практически бесконечно, а сложность нередко выходит далеко за пределами «основ». Чтобы Вы имели представление о том, насколько обширна эта тема, сообщу, что любые функции WinAPI (да и вообще любого другого API), в параметрах которых фигурирует callback-функция, могут служить инструментом неочевидного вызова пользовательского кода.

 

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

 

push <адрес возврата>

push ebp

mov ebp, esp

jmp MyProc+3 ; (1)

 

MyProc:

push ebp

mov ebp,esp

… ; При вызове процедуры в точке (1) будет выполнен переход в эту точку

 

Как видите, хотя приведенный кусок кода по функциональности полностью аналогичен тривиальному call MyProc, явным образом процедура MyProc нигде не вызывается. Причем при помощи макросов можно добиться того, что все вызовы процедуры MyProc в программе будут выглядеть именно таким образом! Так что, сколько бы Вы ни ставили брейкпойнтов на адрес MyProc, ни один из них никогда не сработает – по той простой причине, что управление на этот адрес просто никогда не передается, хотя все внешние признаки могут говорить о том, что процедура MyProc успешно отработала. Впрочем, обмануть такой защитный прием обычно не составляет никакой сложности – нужно лишь ставить точку останова не на первую команду в процедуре, а где-нибудь подальше, например, после третьей (можно даже на команду выхода из процедуры, но при этом следует помнить, что процедура может содержать несколько точек выхода) или вообще в какой-нибудь подпрограмме, вызываемой этой процедурой (вторым способом я нередко пользуюсь, когда ставлю точки останова на функции WinAPI).

 

Другая проблема, которую порождают нетрадиционные способы вызова процедур, заключается в том, что адрес возврата может быть совершенно любым, а не только адресом команды, следующей за командой вызова. Этот прием встречается довольно часто, когда автор защиты хочет скрыть адрес какой-либо функции, к примеру, проверки корректности введенного серийного номера. Рассуждая логически, он понимает, что необходимо максимально осложнить крэкеру нахождение связи между появлением сообщения о неверном серийнике и процедурой, этот серийник проверяющей. А поскольку традиционным приемом поиска такой процедуры является BPX MessageBox с последующим наблюдением, куда вернется программа из MessageBox’а – программист делает вывод, что хорошо бы сделать так, чтобы программа вернулась «не туда», т.е. как можно дальше от процедуры проверки серийника. В этом случае даже поставив брейкпойнт «куда надо», мы не узнаем, по какому поводу был произведен вызов функции – на стеке будет лежать совершенно другой адрес возврата. И особенно неприятно для начинающего крэкера, когда в качестве адреса возврата оказывается что-нибудь вроде адреса функции ExitProcess.

 

 В общем случае «победить» такой прием можно либо через долгую медитацию с массированным применением шестнадцатиричного редактора/дизассемблера для поиска всех точек, в которых программа явно или неявно оперирует адресами нужных функций WinAPI, либо через поиск «модифицированным методом Ньютона», описанным в предыдущей главе, либо применив средства трассировки кода, имеющиеся в SoftIce или OllyDebug. Собственно, трассировка в таких случаях является орудием, по свойствам приближающимся к термоядерной бомбе: редкий код способен выдержать такой удар, но чтобы получить желаемый эффект, требуется весьма серьезное техническое обеспечение (процесс трассировки требует немалых объемов памяти и достаточно быстрого процессора) и грамотный выбор области применения. Так что, прежде чем пускать в ход «тяжелое вооружение», есть смысл подумать о решении проблемы более простыми средствами.

 

Одним из таких более простых средств является исследование содержимого стека на предмет «застрявших» в нем полезных данных и адресов. Суть метода заключается в следующем: любой кусок кода в программе существуют не сами по себе, но находятся во взаимодействии с чем-то, и каждая из процедур может быть как вызываемой (из процедуры более высокого уровня), так и вызывающей «подчиненные» ей процедуры. И даже когда программист скрыл точку вызова конкретной процедуры (что, как я продемонстрировал, не так уж сложно), то спрятать от пытливого взора следы, оставленные выше- и нижележащими процедурами, ему могло и не удаться. А если «могло не удаться» - есть смысл попробовать отыскать эти следы.

 

По традиции, для начала разберемся, что эти следы из себя представляют. Представьте себе следующую широко распространенную ситуацию: A=>B=>C, где «A=>B» расшифровывается как «процедура A вызывает процедуру B». При вызове процедуры B стандартными средствами, т.е. командой CALL, в момент начала исполнения процедуры B на вершине стека будет лежать адрес возврата из процедуры B в процедуру A. Аналогичный процесс происходит при вызове процедуры C процедурой B. Получается, что если мы поставим брейкпойнт на точку входа в процедуру C, мы в этот момент сможем наблюдать в стеке следующую картину (вершина стека - вверху):

  • Адрес возврата из процедуры C в процедуру B
  • Адрес возврата из процедуры B в процедуру A

 

Немного усложним картину, и допустим, что в процедуры B и C передаются некие параметры (порядок передачи параметров для нас в данном примере несущественен). Стек в этом случае будет выглядеть следующим образом:

  • Адрес возврата из процедуры C в процедуру B
  • Параметры, переданные в процедуру C
  • Адрес возврата из процедуры B в процедуру A
  • Параметры, переданные в процедуру B

 

 

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

  • Адрес возврата из процедуры C в процедуру B
  • Параметры, переданные в процедуру C
  • Область локальных переменных процедуры B
  • Адрес возврата из процедуры B в процедуру A
  • Параметры, переданные в процедуру B
  • Область локальных переменных процедуры A

 

А теперь представим, что автор защиты на этапе B=>С подменил адрес возврата из процедуры С в процедуру B своим собственным значением, и возврат теперь происходит не в B, а некую процедуру D. Что от этого изменится? Да только одна, самая верхняя строчка! А вот адрес возврата в процедуру A, локальные переменные и параметры вызова какими были, такими и останутся, и это можно использовать в качестве зацепки, позволяющей выявить все этапы пути от процедуры A к процедуре C. Другое дело, что информация, лежащая в стеке, никак не структурирована, поэтому Вам придется самому угадывать, что там – локальные переменные, что – параметры вызовов, а что – адреса возврата. И хотя процесс проверки этих догадок может быть весьма трудоемким, лучше иметь хотя бы такую беспорядочную информацию, чем не иметь никакой. Иногда бывает полезно посмотреть, что находится выше вершины стека – там нередко тоже удается обнаружить следы деятельности процедур, отработавших перед тем, как мы остановили программу.


Глава 10.


Дата добавления: 2019-08-30; просмотров: 177; Мы поможем в написании вашей работы!

Поделиться с друзьями:






Мы поможем в написании ваших работ!