Содержание статьи
...в проге обнаружился баг. Первое обращение к базе отваливалось по таймауту, но следующие шли нормально. Выяснилось, что индусы наколбасили метод в 75000 строк, и подключение к бд отваливалось за то время, пока шла jit-компиляция метода... По-моему, метод в 75к строк на С# скорее призовет дьявола, чем заработает.
Помню, несколько лет назад мы с друзьями язвили по поводу того, что скоро мобильные телефоны уже не будут отставать от компьютеров по мощности микропроцессора. И вот, пожалуйста — в том месте, где я работаю, 20% компов УЖЕ уступают по мощности современному GalaxyS.
Аналогичная ситуация складывается и с разработкой программного обеспечения — пару лет назад. Прогрессивное человечество было уверено, что перехват вызова .NET функций — удел извращенцев. И я бы с ними согласился.
О перехватах API-функций написано море книг и статей, снята куча видеоуроков. Если раньше эта тема казалась уделом гурупрограммистов, то теперь написать код, который будет перехватывать системные вызовы, не составит проблемы даже для новичка — там и на самом деле нет ничего сложного. Разумеется, только если речь идет о стандартном WinAPI-интерфейсе. Идея перехвата вызовов функций в .NET поначалу вызывала лишь улыбки программистов. Улыбали и попытки написания вирусов под .NET Framework.
Времена изменились, и вместе с ними изменились требования, которые потенциальный заказчик ставит для разработчика. Теперь перехват вызовов в .NET-прогах — не такая уж никчемная и безумная задача, как казалось раньше. И теперь после прочтения этой статьи ты легко сможешь взять под контроль свои .NETприложения!
Собираем мозаику
Теперь можно со всей смелостью утверждать, что ответ Майкрософта вездесущей Java удался — разработчик на .NET на сегодняшний день так же востребован, как и Java-программист. С внедрением .NET Framework принципиально поменялась схема «язык программирования — ОС». Если раньше речь шла об адаптации одного языка для разных платформ, то сейчас — об адаптации разных языков для одной платформы. Давай вкратце вспомним, что такое .NET, и что нам нужно знать, если мы хотим научиться основам перехвата в его среде. Дело в том, что в нашем случае мало будет просто знать язык программирования среды .NET, нужно еще иметь четкое представление о том, как он работает.
Платформа .NET содержит общеязыковую среду выполнения (Common Language Runtime — CLR). Общеязыковая среда выполнения CLR поддерживает управляемое выполнение, которое характеризуется рядом преимуществ. Совместно с общей системой типов, общеязыковая среда выполнения CLR поддерживает возможность взаимодействия языков платформы .NET. Кроме того, платформа .NET предоставляет большую полнофункциональную библиотеку классов .NET Framework. Ну и конечно же, метаданные (Metadata) — это информация о сборках, модулях и типах, составляющих программы в .NET.
Компилятор генерирует метаданные, а CLR и наши программы их используют. Когда загружаются сборка и связанные с ней модули и типы, метаданные подгружаются вместе с ними.
Metadata
На метаданных, как на одной из самых важных и принципиальных тем, мы сейчас и остановимся.
Итак, в них хранятся все классы, типы, константы и строки, используемые .NET-приложением. Metadata, в свою очередь, делится на несколько отдельных куч (heaps) или потоков. В Microsoft .NET предусмотрены пять куч: #US, #Strings, #Blob #GUID и #~.
- #US-куча хранит все строки, которые программист «заготовил» в своем коде. К примеру, если программа выводит на экран строку функцией Print("hello"), то hello будет храниться в #US-куче.
- #Strings-куча хранит в себе такие вещи, как имена методов и имена файлов.
- #Blob-куча содержит в себе бинарные данные, на которые ссылается сборка, такие как, например, сигнатура методов.
- #~-куча содержит набор таблиц, которые определяют важное содержимое .NET-сборки. Например, там содержатся таблицы AssemblyRef, MethodRef, MethodDef, и таблицы Param. Таблица AssemblyRef включает набор внешних сборок, от которых зависит сама сборка.
Таблица MethodRef включает в себя лист внешних методов, которые используются сборкой. Таблица MethodDef содержит все методы, которые определены в сборке.
Param, в свою очередь, содержит все параметры, которые используются методами, определенными в таблице MethodDef. «К чему эта скукота?», — спросишь ты. Спокойно! Сверни ковер нетерпения и положи его в сундук ожидания, ведь без понимания того, «как же эта хрень работает», смысл статьи до тебя может и не дойти :).
Поговорим поподробнее о таблице MethodDef. Для перехвата методов .NET-приложений это крайне нужная вещь. Каждая запись в таблице методов содержит RVA (relative virtual address) метода, флаги метода, имя метода, смещение в куче #Blob на сигнатуру метода и индекс в таблице Param, которая содержит первый параметр, передаваемый функции. RVA метода указывает на тело метода (который содержит IL-код) в секции .TEXT.
Сигнатура метода определяет порядок передачи параметров (calling convention), какой тип будет на возврате из метода и т.д. Чтобы ты смог понять тему на уровне Дао, на сайте rsdn.ru выложены аж целых три статьи на тему метаданных в .NET, которые рекомендуются к обязательному изучению (http://www.rsdn.ru/article/dotnet/refl.xml, …/phmetadata.xml, и …/dne.xml).
Как далеко можно зайти в лес?
До его середины — дальше лес уже кончается. Спешу обрадовать: половину леса мы уже прошли и постепенно продвигаемся к нашей главной цели — научиться перехватывать .NET-вызовы.
Рассмотрим вопрос исполнения .NET-приложений чисто практически:
- Mscoree.dll (исполнительный движок среды .NET)
- Mscorwks.dll (where most of the stuff happens)
- Mscorjit.dll (та самая JIT)
- Mscorsn.dll (обрабатывает верификацию «строгих» имен)
- Mscorlib.dll (Base Class Library — библиотека базовых классов)
- Fushion.dll (assembly binding)
Любое .NET-приложение на точке входа имеет всего одну инструкцию. Эта инструкция реализует джамп на функцию _CorExeMain, которая располагается в таблице импорта. _CorExeMain, в свою очередь, ссылается на mscoree.dll, которая и начинает процесс загрузки и исполнения .NET-приложения.
Mscoree.dll вызывает _CorExeMain из mscorwks.dll. Mscorwks.dll — это довольно большая библиотека, которая контролирует и обрабатывает процесс загрузки. Она загружает библиотеки базовых классов (BCL) и только затем вызывает точку входа Main() твоего .NET-приложения. Так как Main() в этом моменте все еще не декомпилировано, код Main() будет переброшен обратно в mscorwks.dll для компиляции. Mscorwks.dll вызовет JITFunction, которая загрузит среду JIT из mscorjit.dll.
Как только сгенерированный IL-код будет скомпилирован в native-код, контроль будет передан обратно в Main(), которая и начнет непосредственное исполнение.
Перехват? Легко!
Ну, наконец-то! Поговорим непосредственно о перехвате вызовов в .NET. Мы привыкли воспринимать перехват классически, то есть таким образом, когда с целью его реализации или пишутся прокси-обертки, или же функция просто сплайсится. В случае с .NET все происходит по-другому.
Первое, что надо уяснить для осуществления перехвата — это то, что методы, которые мы хотим перехватить, хранятся в конце секции .TEXT. Это сделано, потому что секция .TEXT .NET’овской сборки довольно компактна — там недостаточно места для хранения всех перехваченных функций.
Кто-то может спросить, почему нельзя просто изменить стандартным общеизвестными способом (инструкции «CALL» и «JUMP» RVA-метода) на перехватываемый код, а затем просто перехватить код всех оригинальных функций? Причина проста — инструкции «CALL» и «JUMP» в MSIL-коде используют токены (сигнатуры) методов, а не смещения на них. Таким образом, если я хочу получить ссылку на код, который нужно перехватить, это нужно будет сделать путем поиска токена метода. Итак, для решения нашей задачи перехвата нам нужно будет раздвинуть секцию .TEXT кода.
Представляется, что единственный способ вызвать оригинальный код — создать другой метод. Есть две причины, по которым такой подход трудно, но все же осуществим. Во-первых, это требует включения новой записи в таблицу методов. И во-вторых, в таблице методов дополнительного места для этого просто нет.
Второе — нам нужно найти RVA метода в таблице MethodDef и перезаписать его так, чтобы он указывал на место расположения нового перехваченного метода. Для совершения этой операции нужно увеличить размер секции .TEXT для того, чтобы она смогла вместить все это хозяйство. При этом должны быть учтены и виртуальный, и raw-размер секции. Виртуальный размер — это актуальный, действительный размер секции, rawразмер — это размер, округленный до выравнивания секции.
Виртуальные адреса и размеры нужны для того, чтобы знать, как исполняемый файл загружается в память. Если, к примеру, секция .TEXT имеет виртуальный адрес 0x1000, то по этому смещению в памяти запущенного процесса мы и найдем эту самую секцию .TEXT, которая туда была спроецирована. Вместе с тем, raw-адрес секции может быть 0x200, а это значит, что секция .TEXT в самом файле расположена по смещению 0x200.
Те секции, которые идут за .TEXT (секции данных и релоки), тоже нужно будет выровнять, потому что расширение секции .TEXT «наедет» на начало следующей секции, в результате чего файл просто не запустится. В конце всего этого действа обновляются PE-заголовки. Все, теперь наш перехваченный код вшит прямо в сборку, а другие методы остались нетронутыми.
Колбасим код
Как ты, наверное, уже догадался, один из основных моментов, который позволит нам взять под контроль .NET-программулину, заключается в получении указателя на заголовок CLIHeader, который, в свою очередь, содержит такое поле, как Metadata. Оно-то нам и нужно:
Получим указатель на Cliheader C#
FileReader Input = new FileReader(AssemblyPath);
byte[] Buffer = Input.Read();
[skip...]
ImageBase = Marshal.AllocHGlobal(Buffer.Length * 2);
HeaderOffset = *((UInt32 *)(ImageBase + 60));
PE = (PEHeader *)(ImageBase + HeaderOffset);
HeaderOffset += (UInt32)sizeof(PEHeader);
StandardHeader = (PEStandardHeader *)(ImageBase + HeaderOffset);
RVA *CLIHeaderRVA = (RVA *)((byte *) StandardHeader + 208);
SectionOffset = GetSectionOffset(CLIHeaderRVA-> Address);
CLI = (CLIHeader *)(ImageBase + CLIHeaderRVA->Address - SectionOffset);
MetaDataHeader = (MetaDataHeader *)(ImageBase + CLI->MetaData.Address - SectionOffset);
metadata = new MetaData(Function, ImageBase, (Int32)CLI->MetaData.Address, MetaDataHeader, CLI->MetaData.Size);
Далее будет ненамного сложнее, особенно для тех, кто знаком со способом внедрения в PE-файлы путем расширения его секций.
Нам нужно будет записать в конец секции .TEXT перехваченные функции и пересчитать все необходимые поля, связанные с секцией, чтобы дать ей возможность нормально выполниться, после чего обновить необходимые PE-заголовки:
VirtualSize = TextSectionHeader->VirtualSize + HookSize;
RawDataSize = VirtualSize;
if ((RawDataSize % FileAlignment) != 0)
RawDataSize += (FileAlignment (RawDataSize % FileAlignment));
StandardHeader->CodeSize = RawDataSize;
HookAddress = TextSectionHeader->VirtualAddress + TextSectionHeader->VirtualSize;
TextSectionHeader->VirtualSize = VirtualSize;
TextSectionHeader->RawDataSize = RawDataSize;
[skip...]
StandardHeader->DataBase = DataSectionHeader->
VirtualAddress;
StandardHeader->ImageSize = SectionHeader->
VirtualAddress + SectionHeader->VirtualSize;
if ((StandardHeader->ImageSize % SectionAlignment) != 0)
StandardHeader->ImageSize += (SectionAlignment (StandardHeader->ImageSize % SectionAlignment));
Вот и все. К сожалению, те 75 тысяч строк кода, которые мне хотелось бы выложить на страницы журнала, в него просто не влезут. Шутка :). Полностью рабочий код ты, как и всегда, сможешь найти на диске.
Заключение
Так уж получилось, что большая часть статьи — это описание принципов действия .NET’овской среды, о которых ты наверняка наслышан. Но, как говорится, RTFM — и будет тебе счастье. Для того, чтобы полностью овладеть этой техникой и прослыть шаманом, тебе придется хорошенько потрудиться. Но это не страшно, ведь перехват .NET-приложений в твоем исполнении того стоит.
Links
http://reflector.red-gate.com — сайт программы .NET Reflector, который позволяет восстановить исходный код .NETпрограмм, даже если их сорцы недоступны. Рекомендуется к пользованию!