Издательский дом ООО "Гейм Лэнд"ЖУРНАЛ ХАКЕР #108, ДЕКАБРЬ 2007 г.

Низкоуровневый .Net

nim

Хакер, номер #108, стр. 108-118-1

Хакерский подход: юзаем ассемблер в сишарповых программах

Как известно, ассемблеру предрекали полное и безоговорочное забвение еще в стародавние времена, когда появился С++, поразивший программистов своей ООП-архитектурой. Всего несколько лет назад вышли платформа .Net Framework и язык программирования C#. Казалось бы, низкоуровневому программированию в таких условиях существовать просто не суждено. Однако и сегодня встречаются задачи, для решения которых нужно приносить жертвы древнему богу программирования, имя которому - Assembler. Остатки старой элиты cpp'шников приводят массу аргументов против C#, и первым в списке называемых ими недостатков обычно выступает тот факт, что C# не поддерживает ассемблер. Но есть одно но. Дело в том, что мы с тобой совершенно с этим не согласны! Есть у русских тайные погреба, и вам их не засыпать! Способ юзать ассемблер в сишарповых прогах у нас тоже есть, и рассказывать мы о нем будем, комментируя код во врезке, поэтому срочно посмотри на нее, испей бутылочку темного пива и приготовь свой разум к восприятию нижеследующего текста.

Вначале обрати внимание, что наши классы содержат флаг unsafe. Этот флаг необходим при использовании указателей C#. Для того чтобы они работали, нужно изменить свойства проекта, установив опцию «Properties -> Build -> Allow unsafe code».

Наша программа-консоль начинается со строки 3, где мы вызываем универсальную функцию CallAssembler, в которую передается ссылка на массив байт, содержащий машинные команды процессора. Универсальность этой функции заключается в том, что она одинаково подходит для любого вызываемого кода. В начале этой функции (строка 22) идет, казалось бы, совершенно бесполезная (а на самом деле имеющая ключевое значение) операция - объявление переменной типа int. Когда мы объявляем эту переменную, она ложится в стек на самый низ выделенного для функции фрейма. Исходя из этого, мы можем узнать адрес верхушки стека, подсмотрев в отладчике, по какому смещению находится эта переменная относительно верхушки фрейма.

Для того что бы посмотреть на выполнение программы в ассемблере, нужно зайти в меню «Debug -> Windows -> Disassembly». Здесь мы можем наблюдать инициализацию этой переменной: «dword ptr [ebp-40h],4». Из кода видно, что переменная инициализируется по смещению 0x40, представляя собой важную константу, которую мы используем в строке 24. Получив указатель на переменную t (строка 23), мы меняем его так, чтобы он указывал на обратный адрес (как известно, для команды ret он нужен, поскольку автоматически записывается при вызове функций командой call).

Так вот этот самый адрес находится выше адреса фрейма стека вызванной функции на четыре байта. И вычисляем мы его в строке 24, используя данную константу. В строке 26 мы рассчитываем адрес первого байта массива asm (машинных команд), которые мы должны запустить, а в строке 28 - перезаписываем обратный адрес функции CallAssembler на адрес первого байта массива asm. Таким образом, когда работа функции CallAssembler закончится и будет вызвана команда ret, управление передастся нашему машинному коду. А как же вернуть управление вызывающему коду? Об этом должен позаботиться код, на который мы передали управление. Я сделал просто «mov dword ptr[esp], eax», перезаписав обратный адрес на значение из eax, поскольку CallAssembler возвращает обратный адрес, а перед выходом из CallAssembler он записывается в eax.

В принципе мы могли бы на этом остановиться, но лично мне не нравятся все эти вызовы CallAssembler каждый раз, когда необходимо выполнить asm-код. Поэтому далее мы поступим хитро, изменив команду call, которая вызвала CallAssembler, в результате чего вызываться теперь будет только код из массива asm. Для этого мы модифицируем адресный операнд команды call. Вот пример бинарного представления команды call: E8 3F 10 44 79. Она состоит из пяти байтов. Первый байт (E8) указывает процессору тип команды, а другие четыре байта – это и есть адрес, который необходимо поменять. Адрес этого операнда мы узнаем в строке 29 - он равен адресу ret минус четыре байта.

Да, кстати. Перед модификацией мы должны сделать несколько шаманских действий. Дело в том, что мы не можем просто так менять код в памяти, ведь страницы памяти, содержащей код, имеют атрибут PAGE_EXECUTE_READ. Этот атрибут формально запрещает изменения кода, но обычно от этой великой майкрософтовской защиты бывает мало пользы. Кроме того, в .Net-массивы располагаются на страницах памяти, которые содержат атрибут PAGE_READWRITE. Другими словами, выполнение кода в массиве запрещено, поэтому мы, используя API VirtualProtect, выставим необходимые атрибуты PAGE_EXECUTE_READWRITE. Теперь можно спокойно изменять код в памяти (строка 33) и делать выход из CallAssembler.

В одной из программ я использовал этот метод, чтобы сортировать большой массив unicod-строк методом шелла. Такое решение работало в 15-18 раз быстрее стандартной сортировки Array.Sort.

Массивы

Для того чтобы ты ощутил всю полноту низкоуровневого программирования, я расскажу о структуре массивов в .Net. В общем случае массивы имеют следующий формат: по смещению 0 находится четырехбайтный TypeHandle, указывающий на тип элементов массива. По смещению 4 - длина массива. Далее идут его элементы. Ниже представлены функции для изменения типа массива и изменения размера массива:

unsafe class ArrayUtility

{

public static void ChangeType(Array ar, Type newType)

{

uint* ptr = (uint*)Marshal.UnsafeAddrOfPinnedArrayElement(ar, 0).ToPointer();

ptr -= 2;

*ptr = (uint)newType.TypeHandle.Value.ToInt32();

}

public static void ResizeUnsafe(Array ar, uint newSize)

{

uint* ptr = (uint*)Marshal.UnsafeAddrOfPinnedArrayElement(ar, 0).ToPointer();

*--ptr = newSize;

}

}

Функция ChangeType позволяет быстро получать различные представления одних и тех же данных. Если скорость ее работы сравнивать с классом Convert, то она будет выше на несколько порядков. Кроме того, Convert в процессе преобразования создает новый массив, таким образом затрачивая дополнительную память.

В заключение

Честно признаться, ассемблер мало используется в прикладных программах. Но ведь когда-то же он требуется? :) Я надеюсь, что эта статья поможет тем, кто ищет, а также даст возможность приверженцам cpp и asm стать сторонниками cs и asm.

01 unsafe class test

02 {

03 static void Main(string[] args)

04 {

05 int b = AsmHelper.CallAssembler(asm);

06 Console.WriteLine(b);

07 Console.ReadLine();

08 }

09

10 static byte[] asm = new byte[]

11 {

12 0x89, 0x04, 0x24, // mov dword ptr[esp], eax

13 0xB8, 0x77, 0x07, 0, 0, // mov eax, 777h

14 0xC3 // ret

15 };

16 }

17

18 unsafe public static class AsmHelper

19 {

20 public static int CallAssembler(byte[] asm)

21 {

22 int t = 4;

23 int* p = &t;

24 p += 0x40 / 4 + 1;

25 t = *p;

26 fixed (byte* b = &asm[0])

27 {

28 *p = (int)b;

29 int* p2 = (int*)new IntPtr(*(p - 1)).ToPointer();

30 uint last = 0;

31 bool flug = VirtualProtect(p2 - 1, 32, 0x40, &last);

32 flug = VirtualProtect((int*)new IntPtr(b).ToPointer(), 32, 0x40, &last);

33 *p2 = (int)(b + 3);

34 }

35 return *p;

36 }

37

38 [DllImport("kernel32.dll")]

39 static extern bool VirtualProtect(int* lpAddress, uint dwSize,

40 uint flNewProtect, uint* lpflOldProtect);

41 }

Содержание