• XSS.stack #1 – первый литературный журнал от юзеров форума

Статья Тактики Red Team: использование системных вызовов в C#

yashechka

Генератор контента.Фанат Ильфака и Рикардо Нарвахи
Эксперт
Регистрация
24.11.2012
Сообщения
2 344
Реакции
3 563
За последний год сообщество специалистов по безопасности, особенно Red Team Operators и Blue Team Defenders, стало свидетелем значительного роста как публичного, так и частного использования системных вызовов во вредоносных программах Windows для действий после эксплуатации, а также для обхода EDR или Endpoint Detection and Response.

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

Хороший пример того, как можно использовать эти методы системного вызова, был представлен в нескольких сообщениях в блогах, таких как - Bypass EDR’s Memory Protection, Introduction to Hooking by Hoang Bui и лучший из них - Red Team Tactics: Combining Direct System Calls and sRDI to bypass AV/EDR by Cneelis который изначально был ориентирован на использование системных вызовов для незаметного дампа LSASS. Как Red Teamer, использование этих методов было критически важно для тайных операций - так как это позволяло нам проводить постэксплуатационные действия в сетях, оставаясь в поле зрения.

Реализация этих методов была в основном сделана на C++, чтобы легко взаимодействовать с Win32 API и системой. Но при написании инструментов на C++ всегда было одно предостережение: когда наш код компилировался, у нас был EXE. Теперь, чтобы скрытые операции были успешными, мы, как операторы, всегда хотели избежать "прикосновения к диску" - это означает, что мы не хотели слепо копировать и выполнять файлы в системе. Что нам было нужно, так это найти способ внедрить эти инструменты в память, которые были бы более безопасными для OPSEC (Operational Security).

Хотя C++ - отличный язык для всего, что связано с вредоносным ПО, я серьезно задумался о попытках интегрировать системные вызовы в C#, поскольку некоторые из моих инструментов для постэксплуатации начали переходить в этом направлении. Это достижение стало для меня более желанным после того, как FuzzySec и The Wover выпустили свой доклад BlueHatIL 2020 - Staying # and Bringing Covert Injection Tradecraft to .NET.

После кропотливого исследования, неудачных пробных попыток, долгих бессонных ночей и большого количества кофе - мне наконец удалось заставить системные вызовы работать на C#. Хотя сам метод был полезен для тайных операций, сам код был несколько громоздким - вы поймете почему позже.

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

Но прежде чем мы сможем начать писать код для этого, мы должны сначала понять некоторые основные концепции. Например, как работают системные вызовы и некоторые внутренние компоненты .NET - в частности, управляемый и неуправляемый код, P/Invoke и делегаты. Понимание этих основ действительно поможет нам понять, как и почему работает наш код C#.

Ладно, хватит моей болтовни - перейдем к основам!

Понимание системных вызовов

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

1.png


Режим ядра конкретно относится к режиму выполнения в процессоре, который предоставляет доступ ко всей системной памяти и всем инструкциям ЦП. Некоторые процессоры x86 и x64 различают эти режимы с помощью другого термина, известного как уровни кольца.

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

2.png

Windows использует только два из этих колец - Ring 0 для режима ядра и Ring 3 для пользовательского режима. Теперь во время нормальной работы процессора он будет переключаться между этими двумя режимами в зависимости от того, какой тип кода выполняется на процессоре.

Так в чем же причина такого "кольцевого уровня" безопасности? Что ж, когда вы запускаете приложение пользовательского режима, Windows создаст новый процесс для приложения и предоставит этому приложению частное виртуальное адресное пространство и частную таблицу дескрипторов.

Эта "таблица дескрипторов" представляет собой объект ядра, содержащий дескрипторы. Дескрипторы - это просто абстрактное значение ссылки на определенные системные ресурсы, такие как область или расположение памяти, открытый файл или канал. Первоначальная цель - скрыть реальный адрес памяти от пользователя API, тем самым позволяя системе выполнять определенные функции управления, такие как реорганизация физической памяти.

В целом, работа дескрипторов обрабатывать внутренние структуры такие как токены, процессы, потоки и так далее. Пример дескриптора можно увидеть ниже.
3.png



Поскольку виртуальное адресное пространство приложения является частным, одно приложение не может изменять данные, принадлежащие другому приложению - если только процесс не делает часть своего частного адресного пространства доступной в виде разделяемой общей памяти через сопоставление файлов или с помощью функции VirtualProtect, или если один процесс имеет право открыть другой процесс для использования межпроцессорных функций памяти, таких как ReadProcessMemory и WriteProcessMemory.


4.png


Теперь, в отличие от пользовательского режима, весь код, выполняемый в режиме ядра, использует единое виртуальное адресное пространство, называемое системным пространством. Это означает, что драйверы режима ядра не изолированы от других драйверов и самой операционной системы. Таким образом, если драйвер случайно пишет в неправильное адресное пространство или делает что-то злонамеренное, он может поставить под угрозу систему или другие драйверы. Хотя существуют средства защиты, предотвращающие вмешательство в работу ОС, например Kernel Patch Protection или Patch Guard, но давайте не будем об этом беспокоиться.

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

Для того чтобы пользовательское приложение могло получить доступ к этим структурам данных в режиме ядра, процесс использует специальный триггер инструкции процессора, называемый "системным вызовом". Эта инструкция запускает переход между режимами доступа процессора и позволяет процессору получить доступ к коду диспетчеризации системных служб в ядре. Это, в свою очередь, вызывает соответствующую внутреннюю функцию в Ntoskrnl.exe или Win32k.sys, которая содержит логику уровня приложения ядра и ОС.

Пример этого "переключателя" можно наблюдать в любом приложении. Например, используя Process Monitor в Notepad, мы можем просматривать определенные свойства операции чтения/записи и их стек вызовов.

5.jpg


На изображении выше мы видим переключение из пользовательского режима в режим ядра. Обратите внимание на то, что вызов функции Win32 API CreateFile следует непосредственно перед вызовом NtCreateFile собственного API.

Но если внимательно присмотреться, мы увидим нечто странное. Обратите внимание на два разных вызова функции NtCreateFile. Один из модуля ntdll.dll и один из модуля ntoskrnl.exe. Это почему?

Что ж, ответ довольно прост. DLL ntdll.dll экспортирует собственный API Windows. Эти собственные API от ntdll реализованы в ntoskrnl - вы можете рассматривать их как "API ядра". Ntdll специально поддерживает функции и заглушки диспетчеризации системных служб, которые используются для исполнительных функций.

Проще говоря, они содержат логику "syscall", которая позволяет нам переводить наш процессор из пользовательского режима в режим ядра!

Так как же на самом деле эта команда системного вызова процессора выглядит в ntdll? Что ж, чтобы это проверить, мы можем использовать WinDBG для дизассемблирования и проверки функций вызова в ntdll.


Давайте начнем с запуска WinDBG и открытия такого процесса, как блокнот или cmd. После этого в командном окне введите следующее:

x ntdll!NtCreateFile

Это просто сообщает WinDBG, что мы хотим проверить (x) символ NtCreateFile в загруженном модуле ntdll. После выполнения команды вы должны увидеть следующий результат.

00007ffd`7885cb50 ntdll!NtCreateFile (NtCreateFile)

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

u 00007ffd`7885cb50

Эта команда сообщает WinDBG, что мы хотим дизассемблировать (u) инструкции в начале указанного диапазона памяти. Если все прошло правильно, мы должны увидеть следующий результат.

6.jpg



В целом функция NtCreateFile из ntdll в первую очередь отвечает за настройку аргументов вызова функций в стеке. После этого функция должна переместить соответствующий номер системного вызова в eax, как видно из второй инструкции mov eax, 55. В этом случае номер системного вызова для NtCreateFile — 0x55.

Каждая нативная функция имеет определенный номер системного вызова. Теперь эти числа, как правило, меняются при каждом обновлении, поэтому порой очень трудно за ними поспевать. Но благодаря j00ru из Google Project Zero он постоянно обновляет свою таблицу системных вызовов Windows X86-64, так что вы можете использовать ее в качестве справки в любое время, когда выходит новое обновление.

После того, как номер системного вызова был перемещен в eax, вызывается инструкция системного вызова. Здесь ЦП перейдет в режим ядра и выполнит указанную привилегированную операцию.

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

Использование прямых системных вызовов

Итак, мы знаем, как работают системные вызовы и как они структурированы, но теперь вы можете спросить себя ... Как мы выполняем эти системные вызовы?

Это действительно просто. Чтобы напрямую вызвать системный вызов, мы создадим системный вызов, используя ассемблер, и выполним его в пространстве памяти нашего приложения! Это позволит нам обойти любую перехваченную функцию, которая отслеживается EDR или Антивирусом. Конечно, системные вызовы по-прежнему можно отслеживать, и выполнение системных вызовов через C# по-прежнему дает несколько подсказок, но давайте не будем об этом беспокоиться, поскольку это не входит в объем данной статьи.

Например, если мы хотим написать программу, использующую системный вызов NtCreateFile, мы можем создать такой простой ассемблерный код:

mov r10, rcx
mov eax, 0x55 <-- NtCreateFile Syscall Identifier
syscall
ret

Хорошо, у нас есть ассемблерный код нашего системного вызова… Что теперь? Как выполнить это на C# ?

Что ж, в C ++ это было бы так же просто, как добавить это в новый файл .asm, включить зависимость сборки masm, определить прототип функции C и просто инициализировать переменные и структуры, необходимые для вызова системного вызова.

Как бы просто это ни звучало, в C# все не так просто. Почему? Два слова - управляемый код.

Понимание C# и .NET Framework

Прежде чем мы углубимся в понимание того, что такое "управляемый код" и почему он вызовет у нас головную боль, нам нужно понять, что такое C# и как он работает в .NET Framework.

Проще говоря, C# - это объектно-ориентированный язык, который позволяет разработчикам создавать множество безопасных и надежных приложений. Его синтаксис упрощает многие сложности C++ и предоставляет мощные функции, такие как типы, допускающие значение NULL, перечисления, делегаты, лямбда-выражения и прямой доступ к памяти. C# также работает на .NET Framework, который является неотъемлемым компонентом Windows, который включает в себя виртуальную исполнительную систему, называемую Common Language Runtime или CLR, и унифицированный набор библиотек классов. CLR - это коммерческая реализация Microsoft Common Language Infrastructure, известной как CLI.


Исходный код, написанный на C#, компилируется на промежуточном языке (IL), который соответствует спецификации CLI. Код и ресурсы IL, такие как растровые изображения и строки, хранятся на диске в исполняемом файле, называемом сборкой, обычно с расширением .exe или .dll.

Когда выполняется программа C#, сборка загружается в среду CLR, затем среда CLR выполняет JIT-компиляцию для преобразования кода IL в собственные машинные инструкции. CLR также предоставляет другие службы, такие как автоматическая сборка мусора, обработка исключений и управление ресурсами. Код, выполняемый CLR, иногда называют "управляемым кодом", в отличие от "неуправляемого кода", который компилируется непосредственно в машинный код конкретной системы.

Проще говоря, управляемый код - это просто код, выполнение которого управляется средой выполнения. В этом случае среда выполнения - это среда CLR.

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

Хороший визуальный пример структуры .NET Framework и то, как она компилирует C# в IL, а затем в машинный код, можно увидеть ниже.


7.png


Теперь, если вы действительно прочитали все это, вы бы заметили, что я упомянул, что CLR предоставляет другие услуги, такие как "сборка мусора". В среде CLR сборщик мусора, также известный как GC, служит автоматическим диспетчером памяти, по сути… вы знаете, "освобождая мусор", который является используемой вами памятью. Это также дает преимущество, выделяя объекты в управляемой куче, освобождая объекты, очищая память обеспечивая безопасность памяти, предотвращая известные проблемы повреждения памяти, такие как Use After Free.

Теперь, несмотря на то, что C# - отличный язык, он обеспечивает некоторые удивительные функции и возможность взаимодействия с Windows - например, выполнение в памяти и как таковое - у него есть несколько предостережений и недостатков, когда дело доходит до кодирования вредоносных программ или попыток взаимодействия с системой. Вот некоторые из этих проблем:
- Легко дизассемблировать и реконструировать сборки C# с помощью таких инструментов, как dnSpy, потому что они скомпилированы в IL, а не в собственный код.
- Для его выполнения в системе должен присутствовать .NET.
- В .NET выполнять анти-отладочные трюки труднее, чем в машинном коде.
- Требуется больше работы и кода для взаимодействия (interop) между управляемым и неуправляемым кодом.

В случае с этим сообщением в блоге, #4 - это то, что доставит нам больше всего боли при кодинге системных вызовов на C#.

Все, что мы делаем на C#, «управляемо» - так как же нам эффективно взаимодействовать с системой и процессором Windows?

Этот вопрос особенно важен для нас, поскольку мы хотим выполнить ассемблерный код, и, к сожалению для нас, в C# нет встроенного ассемблера, как в C ++, с зависимостями сборки masm.

Что ж, к счастью для нас, Microsoft предоставила нам возможность сделать это! И все это благодаря CLR! Благодаря тому, как была построена среда CLR, она фактически позволяет нам преодолевать границы между управляемым и неуправляемым миром. Этот процесс известен как взаимодействие или interop для краткости. Благодаря interop C# поддерживает указатели и концепцию "небезопасного" кода для тех случаев, когда прямой доступ к памяти критичен.

В целом это означает, что теперь мы можем делать то же самое, что и C++, и мы также можем использовать те же функции Windows API ... но с некоторыми серьезными - я имею в виду ... незначительными головными болями и неудобствами …

Конечно, важно отметить, что как только код выходит за пределы среды выполнения, фактическое управление выполнением снова находится в руках неуправляемого кода и, таким образом, попадает под те же ограничения, что и при кодинге на C++. Таким образом, нам нужно быть осторожными при выделении, освобождении и управлении памятью, а также другими объектами.

Итак, зная это, как мы можем включить эту совместимость в C# ? Что ж, позвольте мне представить вам - P/Invoke (сокращение от Platform Invoke)!

Понимание Native Interop через P/Invoke

P/Invoke - это технология, которая позволяет вам получать доступ к структурам, обратным вызовам и функциям в неуправляемых библиотеках (то есть библиотеках DLL и так далее) из вашего управляемого кода. Большая часть API P/Invoke, обеспечивающего эту совместимость, содержится в двух пространствах имен, а именно в System и System.Runtime.InteropServices.

Итак, давайте посмотрим на простой пример. Допустим, вы хотели использовать функцию MessageBox в своем коде C#, которую обычно нельзя вызывать, если вы не создаете приложение UWP.

Для начала давайте создадим новый файл .cs и включим два пространства имен P/Invoke.

C#:
using System;
using System.Runtime.InteropServices;

public class Program
{
    public static void Main(string[] args)
    {
        // TODO
    }
}

Теперь давайте кратко рассмотрим синтаксис C MessageBox, который мы хотим использовать.

C#:
int MessageBox(
  HWND    hWnd,
  LPCTSTR lpText,
  LPCTSTR lpCaption,
  UINT    uType
);

Теперь для начала вы должны знать, что типы данных в C++ не соответствуют тем, которые используются в C#. Это означает, что такие типы данных, как HWND (дескриптор окна) и LPCTSTR (длинный указатель на постоянную строку TCHAR), недопустимы в C#.

Сейчас мы кратко расскажем о преобразовании этих типов данных для MessageBox, чтобы вы получили краткое представление, но если вы хотите узнать больше, я предлагаю вам прочитать о типах и переменных C#.

Таким образом, для любых объектов дескрипторов, связанных с C++, таких как HWND, эквивалентом этого типа данных (и любого указателя в C++) в C# является IntPtr Struct, который является типом, зависящим от платформы, который используется для представления указателя или дескриптора.

Любые строки или указатели на строковые типы данных в C++ могут быть установлены на эквивалент C#, который просто является строкой. А для UINT или целого числа без знака это остается неизменным в C#.

Хорошо, теперь, когда мы знаем разные типы данных, давайте продолжим и вызовем неуправляемую функцию MessageBox в нашем коде.

Теперь наш код должен выглядеть примерно так.


C#:
using System;
using System.Runtime.InteropServices;

public class Program
{
    [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern int MessageBox(IntPtr hWnd, string lpText, string lpCaption, uint uType);

    public static void Main(string[] args)
    {
        // TODO
    }
}

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

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

Наконец, вы видите, что мы создаем частную статическую функцию MessageBox с ключевым словом extern. Этот модификатор extern используется для объявления метода, который реализуется извне. Это просто говорит среде выполнения, что при вызове этой функции среда выполнения должна найти ее в DLL, указанной в атрибуте DllImport, который в нашем случае будет в user32.dll.

Когда у нас есть все это, мы, наконец, можем продолжить и вызвать функцию MessageBox в нашей основной программе.

C#:
using System;
using System.Runtime.InteropServices;

public class Program
{
    [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern int MessageBox(IntPtr hWnd, string lpText, string lpCaption, uint uType);

    public static void Main(string[] args)
    {
        MessageBox(IntPtr.Zero, "Hello from unmanaged code!", "Test!", 0);
    }
}

Если все сделано правильно, теперь должно появиться новое окно сообщения с заголовком "Test!" и сообщение "Hello from unmanaged code!".

Замечательно, поэтому мы только что узнали, как импортировать и вызывать неуправляемый код из C#! На самом деле это довольно просто, если вы посмотрите на это ... но не позволяйте этому обмануть вас!

Это была простая функция - что произойдет, если функция, которую мы хотим вызвать, будет немного более сложной, например, функция CreateFileA?

Давайте быстро рассмотрим синтаксис C для этой функции.

C#:
HANDLE CreateFileA(
  LPCSTR                lpFileName,
  DWORD                 dwDesiredAccess,
  DWORD                 dwShareMode,
  LPSECURITY_ATTRIBUTES lpSecurityAttributes,
  DWORD                 dwCreationDisposition,
  DWORD                 dwFlagsAndAttributes,
  HANDLE                hTemplateFile
);

Давайте посмотрим на параметр dwDesiredAccess, который определяет права доступа к файлу, который мы создали, с использованием общих значений, таких как GENERIC_READ и GENERIC__WRITE. В C++ мы могли бы просто использовать эти значения, и система будет знать, что мы имеем в виду, но не в C#.

Изучив документацию, мы увидим, что общие права доступа, используемые для параметра dwDesiredAccess, используют своего рода формат маски доступа, чтобы указать, какие привилегии мы должны предоставить файлу. Теперь, поскольку этот параметр принимает DWORD, которое является 32-битным целым числом без знака, мы быстро узнаем, что константы GENERIC-* на самом деле являются флагами, которые соответствуют константе определенному битовому значению маски доступа.

В случае C#, чтобы сделать то же самое, нам нужно было бы создать новый тип структуры с атрибутом перечисления FLAGS, который будет содержать те же константы и значения, которые есть в C ++ для правильной работы этой функции.

Вы, наверное, спросите меня - откуда мне такие подробности? Что ж, лучший ресурс для вас в этом случае - и в любом случае, когда вам приходится иметь дело с неуправляемым кодом в .NET, - это использовать PInvoke Wiki. Здесь вы найдете практически все, что вам нужно.

Если бы мы вызывали эту неуправляемую функцию в C# и заставляли ее работать правильно, образец кода выглядел бы примерно так:


C#:
using System;
using System.Runtime.InteropServices;

public class Program
{
    [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    public static extern IntPtr CreateFile(
        string lpFileName,
        EFileAccess dwDesiredAccess,
        EFileShare dwShareMode,
        IntPtr lpSecurityAttributes,
        ECreationDisposition dwCreationDisposition,
        EFileAttributes dwFlagsAndAttributes,
        IntPtr hTemplateFile);

    [Flags]
    enum EFileAccess : uint
    {
        Generic_Read = 0x80000000,
        Generic_Write = 0x40000000,
        Generic_Execute = 0x20000000,
        Generic_All = 0x10000000
    }

    public static void Main(string[] args)
    {
        // TODO Code Here for CreateFile
    }
}

Теперь вы понимаете, что я имел в виду, когда сказал, что использование неуправляемого кода на C# может быть громоздким и неудобным? Хорошо, теперь мы на одной волне.

Хорошо, мы уже рассмотрели много материала. Мы понимаем, как работают системные вызовы, мы знаем, как C# и платформа .NET работают на более низком уровне, и теперь мы знаем, как вызывать неуправляемый код и API Win32 из C#.

Но нам по-прежнему не хватает важной информации. Что это могло быть ...

О, верно! Несмотря на то, что мы можем вызывать функции Win32 API на C#, мы все еще не знаем, как выполнить наш ассемблерный код.

Ну, знаете, что говорят: «Если есть желание, значит, есть выход»! И благодаря C#, хотя мы не можем выполнять встроенный ассемблер, как в C++, мы можем делать нечто подобное благодаря чему-то прекрасному под названием Delegates!

Понимание делегатов и обратных вызовов в собственном коде

Можем ли мы просто остановиться на секунду и действительно полюбоваться, насколько крутой CLR на самом деле? Я имею в виду управлять кодом и разрешить взаимодействие между GC и Windows API на самом деле довольно круто.

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

Просто делегаты используются для передачи методов в качестве аргументов другим методам. Теперь использование этой функции аналогично тому, как переходить от управляемого к неуправляемому коду. Хороший пример этого можно увидеть в Microsoft.


C#:
using System;
using System.Runtime.InteropServices;

namespace ConsoleApplication1
{
    public static class Program
    {
        // Define a delegate that corresponds to the unmanaged function.
        private delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam);

        // Import user32.dll (containing the function we need) and define
        // the method corresponding to the native function.
        [DllImport("user32.dll")]
        private static extern int EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);

        // Define the implementation of the delegate; here, we simply output the window handle.
        private static bool OutputWindow(IntPtr hwnd, IntPtr lParam)
        {
            Console.WriteLine(hwnd.ToInt64());
            return true;
        }

        public static void Main(string[] args)
        {
            // Invoke the method; note the delegate as a first parameter.
            EnumWindows(OutputWindow, IntPtr.Zero);
        }
    }
}

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

Как видите, мы импортируем функцию собственного кода EnumWindows, которая перечисляет все окна верхнего уровня на экране, передавая дескриптор каждому окну и, в свою очередь, передавая его в определяемую приложением функцию обратного вызова.

Если мы взглянем на синтаксис C для типа функции, мы увидим следующее:


C#:
BOOL EnumWindows(
  WNDENUMPROC lpEnumFunc,
  LPARAM      lParam
);

Если мы посмотрим на параметр lpEnumFunc в документации, мы увидим, что он принимает указатель на определяемый приложением обратный вызов, который должен иметь ту же структуру, что и функция обратного вызова EnumWindowsProc. Этот обратный вызов - просто имя-заполнитель для определяемой приложением функции. Это означает, что мы можем называть это как угодно в приложении.

Если мы взглянем на синтаксис этой функции C, мы увидим следующее.

C#:
BOOL CALLBACK EnumWindowsProc(
  _In_ HWND   hwnd,
  _In_ LPARAM lParam
);

Как видите, параметры этой функции принимают HWND или указатель на дескриптор окна, а также LPARAM или длинный указатель. И возвращаемое значение для этого обратного вызова является логическим - true или false, чтобы указать, когда перечисление остановлено.

Теперь, если мы вернемся к нашему коду, в строке № 9 мы определим нашего делегата, который соответствует сигнатуре обратного вызова из неуправляемого кода. Поскольку мы делаем это в C#, мы заменили указатели C++ на IntPtr, который является эквивалентом указателей в C#.

В строках #13 и #14 мы представляем функцию EnumWindows из user32.dll.

Далее в строках 17-20 мы реализуем делегат. Именно здесь мы фактически сообщаем C#, что мы хотим делать с данными, которые возвращаются нам из неуправляемого кода. Просто здесь мы говорим просто распечатать возвращенные значения в консоли.

И, наконец, в строке #24 мы просто вызываем наш импортированный собственный метод и передаем наш определенный и реализованный делегат для обработки возвращаемых данных.

Просто!

Хорошо, это довольно круто. И я знаю ... вы можете спросить меня прямо сейчас: Джек, какое это имеет отношение к выполнению нашего собственного кода на C# ? Мы до сих пор не знаем, как этого добиться! "

И все, что я могу добавить от себя, это показать этот мем ...

8.jpg


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

Теперь мы узнали, что делегаты похожи на указатели функций C++, но делегаты полностью объектно-ориентированы, и в отличие от указателей C++ на функции-члены, делегаты инкапсулируют как экземпляр объекта, так и метод. Мы также знаем, что они позволяют передавать методы в качестве параметров, а также могут использоваться для определения методов обратного вызова.

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

Например, предположим, что мы выполняем встроенную функцию Windows, такую как VirtualAlloc, которая позволяет нам зарезервировать, зафиксировать или изменить состояние области страниц в виртуальном адресном пространстве вызывающего процесса. Эта функция вернет нам базовый адрес выделенной области памяти.

Предположим, для этого примера мы выделили какой-то… ну, вы знаете… шелл-код скажем, - видите, к чему я клоню? Нет !? Хорошо… позвольте мне объяснить.

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

Итак, с этой общей идеей, давайте углубимся в это!

Маршалинг типов и небезопасный код и указатели

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

По умолчанию подсистема P/Invoke пытается выполнить маршалинг типов на основе поведения по умолчанию. Но в тех ситуациях, когда вам нужен дополнительный контроль с помощью неуправляемого кода, вы можете использовать класс Marshal для таких вещей, как выделение неуправляемой памяти, копирование блоков неуправляемой памяти и преобразование управляемых в неуправляемые типы, а также другие разные методы, используемые при взаимодействии с неуправляемыми код.

Краткий пример того, как работает этот маршалинг, можно увидеть ниже.


9.png

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

Теперь есть масса других типов, к которым и от которых вы можете переходить, и я настоятельно рекомендую вам прочитать о них, поскольку они являются неотъемлемой частью .NET framework и пригодятся всякий раз, когда вы пишете инструменты red team или даже защитные инструменты, если вы защитник.

Итак, мы знаем, что можем маршалировать указатели памяти для делегатов, но теперь вопрос в том, как мы можем создать указатель памяти на наши данные ассемблерного кода? На самом деле, это довольно просто. Мы можем выполнить простую арифметику с указателями, чтобы получить адрес памяти нашего ассемблерного кода.

Поскольку C # не поддерживает арифметику указателей, по умолчанию мы можем объявить часть нашего кода небезопасной. Это просто обозначает небезопасный контекст, который требуется для любой операции с указателями. В целом это позволяет нам выполнять операции с указателями, такие как разыменование указателя.

Единственное предостережение: для компиляции небезопасного кода необходимо указать параметр компилятора -unsafe.

Итак, зная это, давайте рассмотрим небольшой пример.

Если бы мы хотели, скажем, выполнить системный вызов для NtOpenProcess, мы бы начали с записи ассемблерного кода в такой массив байтов.

C#:
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;

namespace SharpCall
{
    class Syscalls
    {

        static byte[] bNtOpenProcess =
        {
            0x4C, 0x8B, 0xD1,               // mov r10, rcx
            0xB8, 0x26, 0x00, 0x00, 0x00,   // mov eax, 0x26 (NtOpenProcess Syscall)
            0x0F, 0x05,                     // syscall
            0xC3                            // ret
        };
    }
}

Как только у нас будет завершен массив байтов для нашего системного вызова, мы перейдем к вызову ключевого слова unsafe и обозначим область кода, в которой возникнет небезопасный контекст.

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

Без фиксированного контекста сборка мусора может непредсказуемо перемещать переменные и вызывать ошибки позже во время выполнения.

После этого мы просто преобразуем указатель байтового массива в C # IntPtr с именем memoryAddress. Это позволит нам получить место в памяти, где находится массив байтов системных вызовов.

Отсюда мы можем делать несколько вещей, например, использовать эту область памяти в вызове собственного API, или мы можем передать ее другим управляемым функциям C#, или мы можем даже использовать ее в делегатах!

Пример того, что я объяснил выше, можно увидеть ниже.

C#:
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;

namespace SharpCall
{
    class Syscalls
    {
        // NtOpenProcess Syscall ASM
        static byte[] bNtOpenProcess =
        {
            0x4C, 0x8B, 0xD1,               // mov r10, rcx
            0xB8, 0x26, 0x00, 0x00, 0x00,   // mov eax, 0x26 (NtOpenProcess Syscall)
            0x0F, 0x05,                     // syscall
            0xC3                            // ret
        };

        public static NTSTATUS NtOpenProcess(
            // Fill NtOpenProcess Paramters
            )
        {
            // set byte array of bNtOpenProcess to new byte array called syscall
            byte[] syscall = bNtOpenProcess;

            // specify unsafe context
            unsafe
            {
                // create new byte pointer and set value to our syscall byte array
                fixed (byte* ptr = syscall)
                {
                    // cast the byte array pointer into a C# IntPtr called memoryAddress
                    IntPtr memoryAddress = (IntPtr)ptr;
                }
            }
        }
    }
}

Теперь мы знаем, как взять шелл-код из массива байтов и выполнить его в нашем приложении C#, используя неуправляемый код, небезопасный контекст, делегаты, маршалинг и многое другое!

Я знаю, что информации было много, и, честно говоря, сначала это было немного сложно, поэтому не торопитесь, чтобы прочитать и убедиться, что вы понимаете концепции.

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


Источник: https://jhalon.github.io/utilizing-syscalls-in-csharp-1/
Автор перевода: yashechka
Переведено специально для портала xss.pro (c)
 


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх