Разработчики инструментов из категории offensive постепенно отказываются от зубодробительных «плюсов» и геройского ассемблера и переходят к более приятным, добрым, отзывчивым и миловидным языкам. Конечно, опрометчиво называть C# легким. В нем есть и сложные концепции — попробуй человеку с улицы понятным языком объяснить отличия IEnumerable от IEnumerator, IComparer от IComparable, рассказать о плюсах TPL и PLINQ, растолковать рефлексию, а уж про маршалинг или внутреннее устройство JIT-компилятора я даже не заикаюсь (впрочем, последний относится к платформе CLR).
При разработке программ для Windows сложно не использовать WinAPI. Конечно, многие методы уже успешно портированы в отдельные сборки .NET. Например, вместо GetUserName(
достаточно обращаться вот к такой функции:
System.Security.Principal.WindowsIdentity.GetCurrent().Name
Впрочем, если ты захочешь копнуть чуть глубже, то придешь к выводу, что намного лучше (и стабильнее) будет обращаться напрямую к методу WinAPI, чем доверять свой код непонятным, давно не поддерживаемым опенсорсным сборкам от симпатяги индуса с GitHub.
Просто так WinAPI не вызвать. И приходится искать ухищрения, чтобы научить управляемый код обращаться к нативным методам. Давай посмотрим, что это за методы.
Platform Invoke
Platform Invoke (он же Static Invoke) — единственный «легальный» вызов методов WinAPI из C#. Он отлично описан в официальной документации Microsoft.
Использовать его просто — все завязано на директиве DllImport
. Сначала в нашем коде на C# идет объявление целевой функции для вызова, указание DLL, из которой эту функцию брать, а после можно смело обращаться к ней из управляемого кода.
Например, так может выглядеть стандартный шелл‑код‑раннер через VirtualAlloc(
, memcpy(
, CreateThread(
и WaitForSingleObject(
:
[DllImport("kernel32.dll")]public static extern IntPtr VirtualAlloc(IntPtr lpAddress, int dwSize, uint flAllocationType, uint flProtect);[DllImport("kernel32.dll")]public static extern IntPtr CreateThread(IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, out uint lpThreadId);[DllImport("kernel32.dll")]public static extern UInt32 WaitForSingleObject(IntPtr hHandle, UInt32 dwMilliseconds);public static void StartShellcode(byte[] shellcode){ uint threadId; IntPtr alloc = VirtualAlloc(IntPtr.Zero, shellcode.Length, (uint)(AllocationType.Commit | AllocationType.Reserve), (uint)MemoryProtection.ExecuteReadWrite); if (alloc == IntPtr.Zero) { return; } Marshal.Copy(shellcode, 0, alloc, shellcode.Length); IntPtr threadHandle = CreateThread(IntPtr.Zero, 0, alloc, IntPtr.Zero, 0, out threadId); WaitForSingleObject(threadHandle, 0xFFFFFFFF);}
В данном случае создаются полноценные прототипы используемых нативных функций. Отдельно обращу внимание на указание целевой библиотеки, из которой они вызываются. Такой механизм очень похож на вызов этих же самых функций из кода на C++.
При таком способе вызова описанные разработчиком функции добавляются в специальный раздел импорта, который потом резолвится, и появляются адреса, по которым передается поток управления для вызова метода.
Не стоит путать этот раздел со стандартным разделом импортов. Нативные функции, объявленные через Platform Invoke, оказываются в таблице ImplMap
, в разделе с метаданными CLR-сборки. Этот самый раздел автоматически добавляется в PE-файл.
К счастью, сигнатуры (это строка DllImport
плюс прототип функции) самостоятельно писать не нужно. Достаточно заглянуть на один из сайтов с готовыми вариантами:
- pinvoke.net — самый популярный вариант, который, увы, иногда не открывается;
- pinvoke.dev — тьфу‑тьфу, пока работает;
- p-invoke.net — тоже выключился.
Как ты видишь, в наше суровое время полагаться только на веб‑страницы может быть ошибочно, поэтому качай генератор P/Invoke-сигнатур, написанный в Microsoft. Он уж никуда не пропадет... скорее всего.
Что ж, любая простота и абстракция при разработке инструментов атаки работает как в плюс (упрощает жизнь редтимерам), так и в минус (усложняет жизнь редтимерам). Ведь распарсить таблицу ImplMap
и понять, какие методы WinAPI используются, не составит труда. Это можно сделать даже на PowerShell.
$assembly = "C:\File.exe"$stream = [System.IO.File]::OpenRead($assembly)$peReader = [System.Reflection.PortableExecutable.PEReader]::new($stream, [System.Reflection.PortableExecutable.PEStreamOptions]::LeaveOpen -bor [System.Reflection.PortableExecutable.PEStreamOptions]::PrefetchMetadata)$metadataReader = [System.Reflection.Metadata.PEReaderExtensions]::GetMetadataReader($peReader)$assemblyDefinition = $metadataReader.GetAssemblyDefinition()foreach($typeHandler in $metadataReader.TypeDefinitions) { $typeDef = $metadataReader.GetTypeDefinition($typeHandler) foreach($methodHandler in $typeDef.GetMethods()) { $methodDef = $metadataReader.GetMethodDefinition($methodHandler) $import = $methodDef.GetImport() if ($import.Module.IsNil) { continue } $dllImportFuncName = $metadataReader.GetString($import.Name) $dllImportParameters = $import.Attributes.ToString() $dllImportPath = $metadataReader.GetString($metadataReader.GetModuleReference($import.Module).Name) Write-Host "$dllImportPath, $dllImportParameters`n$dllImportFuncName`n" }}
Импорты можно глянуть и через более специализированное ПО, такое как monodis или известный многим dnSpy.
Поэтому люди стали придумывать иные методы вызова неуправляемого кода из C#, чтобы как минимум скрыть импорты.
Dynamic Invoke
Этот механизм использует делегаты, чтобы получить доступ к методам из неуправляемого кода. Делегат, если упростить, можно считать указателем на функцию. В C# не как в C++ — здесь имя функции не равно адресу функции. Чтобы, например, передать функцию как колбэк, потребуется использовать делегат. Ниже пример простейшего делегата.
using System;using System.Diagnostics;using System.Runtime.InteropServices;namespace Template{ public delegate int Operation(int x, int y); class Program { static void Main() { var a = 2; var b = 3; Operation op = Add; Console.WriteLine(op(a, b)); // Вызывается Add => 5 op = Multiply; Console.WriteLine(op(a, b)); // Вызывается Multiply => 6 } static int Add(int x, int y) => x + y; static int Multiply(int x, int y) => x * y; }}
Как видишь, идет объявление делегата: возвращаемое значение, принимаемые аргументы, все‑все данные. Затем можно инициализировать этот делегат конкретным методом и, обратившись к делегату, вызвать функцию.
Продолжение доступно только участникам
Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
Вариант 2. Открой один материал
Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.
Я уже участник «Xakep.ru»