- Баллы
- 173
#4: Защищаемся от инжектов через APC
С основными методами инжекта разобрались, начинаются более сложные.
Пусть инжект через APC используется достаточно редко, однако, он реализован в таких инжекторах, как ExtremeInjector и Xenos - и потому, его нельзя игнорировать.
Для начала разберёмся, что такое APC.
APC - Asynchronous Procedure Call - механизм асинхронного вызова процедур, реализованный в ядре Windows. Основное его предназначение - межпроцессное и межпоточное взаимодействие (RPC), организация очереди задач для пула потоков, а также, исходя из названия, поддержка асинхронных вызовов функций.
У каждого потока есть своя собственная очередь APC, которую тот опустошает, исполняя накопившиеся APC, когда переходит в специальное Alertable-состояние. APC не могут быть доставлены потоку, пока тот не находится в Alertable-состоянии, и накапливаются в очереди.
Чтобы перейти в Alertable-состояние, поток должен уйти в ожидание с помощью функций SleepEx, WaitForSingleObjectEx, WaitForMultipleObjectsEx или MsgWaitForMultipleObjectsEx, передав TRUE в параметр bAlertable. В этом случае поток уходит в Alertable-ожидание и исполняет накопленные APC, пока ожидаемый объект (в случае [Msg]WaitFor***-функций) не перейдёт в сигнальное состояние, после чего поток выходит из Alertable-состояния и продолжает исполнение в обычном режиме.
Ядро может форсировать доставку юзермодных APC потокам, которые НЕ находятся в Alertable-состоянии. Пример такого кода в драйвере: ЗДЕСЬ.
Также, поток может сам очистить свою очередь APC, выполнив их специальной функцией NtTestAlert.
Чтобы добавить APC в очередь конкретного потока, существует функция QueueUserAPC в библиотеке kernel32.dll, которая сводится к сервису NtQueueApcThread в ntdll.dll.
Пример инжекта через APC похож на способ с CreateRemoteThread(LoadLibrary), только, вместо создания нового потока, мы используем уже существующий, заставляя его подгружать нашу библиотеку:
Этим кодом мы добавили в очередь потока APC с точкой входа на функции LoadLibrary, в качестве аргумента которой задали путь к библиотеке, которую хотим подгрузить. Осталось дождаться, пока поток перейдёт в Alertable-состояние и выполнит нашу LoadLibrary. Однако, стоит помнить, что поток может вообще не переходить в Alertable-состояние (тогда наш APC не выполнится никогда), поэтому нужно специально искать потоки, которые вызывают SleepEx или [Msg]WaitFor***Ex-функции.
Чтобы понять, как защищаться от этого типа инжектов, рассмотрим, как происходит доставка APC потоку. Напишем простой код доставки APC самому себе и посмотрим, кто и откуда вызывает нашу процедуру.
Посмотрим трейс потока:
Нас интересует процедура KiUserApcDispatcher. Это юзермодный каллбэк, который вызывает ядро для доставки APC нашему потоку.

Рассмотрим подробнее, что происходило с потоком:
1. Добавили APC с точкой входа на нашей функции ApcRoutine в очередь APC нашего потока с помощью функции NtQueueApcThread
1. Ушли в ядро на сервисе NtTestAlert, переведя поток в Alertable-состояние
2. Ядро стало доставлять накопившиеся APC нашему потоку (в данном случае, в очереди стоит только APC с точкой входа на нашей функции ApcRoutine)
3. Для доставки APC наш поток из ядра вызывает юзермодный каллбэк KiUserApcDispatcher, в котором происходит вызов заданной нами функции
4. APC отрабатывает и поток возвращается на то место, где прервал работу, с помощью сервиса NtContinue - таким образом, выходим из NtTestAlert.
Для фильтрации APC перехватим KiUserApcDispatcher, чтобы анализировать точки входа и отсекать недоверенные. Здесь нас ждёт небольшая трудность: так как KiUserApcDispatcher - не обычная функция, а каллбэк из ядра, все аргументы передаются в стеке, что противоречит принятому в х64 соглашению fastcall. На чистом C/C++ мы не сможем реализовать корректный обработчик, т.к. компилятор не знает, как ему обрабатывать переданные аргументы, поэтому напишем прослойку на ассемблере, оборачивающую аргументы в понятный сишному коду вид с последующим вызовом обработчика на C/C++. Прототипы KiUserApcDispatcher на х32 и х64 отличаются:
На x64 адрес APC хранится в поле Context.P4Home; аргумент - в поле Context.P1Home.
На х32 под адрес APC и аргумент выделены специальные аргументы - NormalRoutine и SystemArgument соответственно.
Всё остальное содержимое структуры Context - контекст, который следует восстановить, чтобы вернуть поток на место, где его прервали для обработки APC.
Таким образом, если хотим отменить вызов APC и сразу вернуться на продолжение оригинального кода, достаточно вызвать NtContinue(&Context).
Итак, напишем ассемблерную прослойку, приводящую аргументы на стеке в понятный сишному коду вид и вызовем сишный обработчик, в котором безусловно отменим все APC:
И сишный обработчик:
Всё, что остаётся - перехватить KiUserApcDispatcher любой библиотекой для хуков, в качестве хука задать ассемблерный KiApcStub и записать адрес оригинального диспетчера в переменную OriginalApcDispatcher.
KiApcStub вызовет наш ApcHandler, в котором мы можем провести полноценную фильтрацию и, при необходимости, отменить вызов APC, вернув контекст потока обратно функцией NtContinue(&Context).
С основными методами инжекта разобрались, начинаются более сложные.
Пусть инжект через APC используется достаточно редко, однако, он реализован в таких инжекторах, как ExtremeInjector и Xenos - и потому, его нельзя игнорировать.
Для начала разберёмся, что такое APC.
APC - Asynchronous Procedure Call - механизм асинхронного вызова процедур, реализованный в ядре Windows. Основное его предназначение - межпроцессное и межпоточное взаимодействие (RPC), организация очереди задач для пула потоков, а также, исходя из названия, поддержка асинхронных вызовов функций.
У каждого потока есть своя собственная очередь APC, которую тот опустошает, исполняя накопившиеся APC, когда переходит в специальное Alertable-состояние. APC не могут быть доставлены потоку, пока тот не находится в Alertable-состоянии, и накапливаются в очереди.
Чтобы перейти в Alertable-состояние, поток должен уйти в ожидание с помощью функций SleepEx, WaitForSingleObjectEx, WaitForMultipleObjectsEx или MsgWaitForMultipleObjectsEx, передав TRUE в параметр bAlertable. В этом случае поток уходит в Alertable-ожидание и исполняет накопленные APC, пока ожидаемый объект (в случае [Msg]WaitFor***-функций) не перейдёт в сигнальное состояние, после чего поток выходит из Alertable-состояния и продолжает исполнение в обычном режиме.
Ядро может форсировать доставку юзермодных APC потокам, которые НЕ находятся в Alertable-состоянии. Пример такого кода в драйвере: ЗДЕСЬ.
Также, поток может сам очистить свою очередь APC, выполнив их специальной функцией NtTestAlert.
Чтобы добавить APC в очередь конкретного потока, существует функция QueueUserAPC в библиотеке kernel32.dll, которая сводится к сервису NtQueueApcThread в ntdll.dll.
Пример инжекта через APC похож на способ с CreateRemoteThread(LoadLibrary), только, вместо создания нового потока, мы используем уже существующий, заставляя его подгружать нашу библиотеку:
C++:
#include <Windows.h>
int main()
{
CHAR LibPath[] = "C:\\Folder\\Library.dll"; // Путь к дллке, которую хотим подгрузить
DWORD ProcessId = 1234; // ID целевого процесса
DWORD ThreadId = 5678; // ID целевого потока в целевом процессе
// Открываем целевой процесс:
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, ProcessId);
// Открываем целевой поток:
HANDLE hThread = OpenThread(THREAD_ALL_ACCESS, FALSE, ThreadId);
// Получаем адрес функции LoadLibrary, которую выполним в чужом процессе:
PVOID pLoadLibrary = GetProcAddress(GetModuleHandle(L"kernel32"), "LoadLibraryA");
// Выделяем в целевом процессе память под путь к дллке:
PVOID pRemoteLibPath = VirtualAllocEx(
hProcess,
NULL,
sizeof(LibPath),
MEM_RESERVE | MEM_COMMIT,
PAGE_READWRITE
);
// Записываем путь в память процесса:
DWORD Written = 0;
WriteProcessMemory(hProcess, pRemoteLibPath, LibPath, sizeof(LibPath), &Written);
// Добавляем APC в очередь потока:
QueueUserAPC((PAPCFUNC)pLoadLibrary, hThread, (ULONG_PTR)pRemoteLibPath);
CloseHandle(hThread);
CloseHandle(hProcess);
}
Чтобы понять, как защищаться от этого типа инжектов, рассмотрим, как происходит доставка APC потоку. Напишем простой код доставки APC самому себе и посмотрим, кто и откуда вызывает нашу процедуру.
C++:
#include <Windows.h>
#include <winternl.h>
#pragma comment(lib, "ntdll.lib")
#define NtCurrentThread() ((HANDLE)-2)
extern "C" NTSYSAPI NTSTATUS NTAPI NtQueueApcThread(
IN HANDLE ThreadHandle,
IN PIO_APC_ROUTINE ApcRoutine,
IN OPTIONAL PVOID ApcRoutineContext,
IN OPTIONAL PIO_STATUS_BLOCK ApcStatusBlock,
IN ULONG ApcReserved
);
extern "C" NTSYSAPI NTSTATUS NTAPI NtTestAlert();
static VOID NTAPI ApcRoutine(
IN PVOID ApcContext,
IN PIO_STATUS_BLOCK IoStatusBlock,
IN ULONG Reserved
) {
__debugbreak(); // Остановимся здесь
}
int main()
{
// Ставим APC в очередь сами себе:
NtQueueApcThread(
NtCurrentThread(),
ApcRoutine,
NULL,
NULL,
0
);
// Очищаем очередь APC:
NtTestAlert();
return 0;
}
Нас интересует процедура KiUserApcDispatcher. Это юзермодный каллбэк, который вызывает ядро для доставки APC нашему потоку.

Рассмотрим подробнее, что происходило с потоком:
1. Добавили APC с точкой входа на нашей функции ApcRoutine в очередь APC нашего потока с помощью функции NtQueueApcThread
1. Ушли в ядро на сервисе NtTestAlert, переведя поток в Alertable-состояние
2. Ядро стало доставлять накопившиеся APC нашему потоку (в данном случае, в очереди стоит только APC с точкой входа на нашей функции ApcRoutine)
3. Для доставки APC наш поток из ядра вызывает юзермодный каллбэк KiUserApcDispatcher, в котором происходит вызов заданной нами функции
4. APC отрабатывает и поток возвращается на то место, где прервал работу, с помощью сервиса NtContinue - таким образом, выходим из NtTestAlert.
Для фильтрации APC перехватим KiUserApcDispatcher, чтобы анализировать точки входа и отсекать недоверенные. Здесь нас ждёт небольшая трудность: так как KiUserApcDispatcher - не обычная функция, а каллбэк из ядра, все аргументы передаются в стеке, что противоречит принятому в х64 соглашению fastcall. На чистом C/C++ мы не сможем реализовать корректный обработчик, т.к. компилятор не знает, как ему обрабатывать переданные аргументы, поэтому напишем прослойку на ассемблере, оборачивающую аргументы в понятный сишному коду вид с последующим вызовом обработчика на C/C++. Прототипы KiUserApcDispatcher на х32 и х64 отличаются:
C++:
// Для х64:
__declspec(noreturn)
VOID NTAPI KiUserApcDispatcher(CONTEXT Context);
// Для x32:
__declspec(noreturn)
VOID NTAPI KiUserApcDispatcher(
PVOID NormalRoutine,
PVOID SystemArgument1,
PVOID SystemArgument2,
CONTEXT Context
);
На х32 под адрес APC и аргумент выделены специальные аргументы - NormalRoutine и SystemArgument соответственно.
Всё остальное содержимое структуры Context - контекст, который следует восстановить, чтобы вернуть поток на место, где его прервали для обработки APC.
Таким образом, если хотим отменить вызов APC и сразу вернуться на продолжение оригинального кода, достаточно вызвать NtContinue(&Context).
Итак, напишем ассемблерную прослойку, приводящую аргументы на стеке в понятный сишному коду вид и вызовем сишный обработчик, в котором безусловно отменим все APC:
И сишный обработчик:
C++:
static BOOL IsApcAllowed(PVOID ApcRoutine)
{
// Здесь должны быть проверки, но для примера запретим все APC:
return FALSE;
}
[[noreturn]]
static inline VOID DiscardApc(PCONTEXT Context)
{
NtContinue(Context, FALSE);
}
extern "C" {
VOID NTAPI KiApcStub(); // Импортируем прослойку из ассемблерного кода
#ifdef _AMD64_
// Адрес оригинального диспетчера, полученный после хука:
VOID (NTAPI *OriginalApcDispatcher)(CONTEXT Context) = NULL;
VOID NTAPI ApcHandler(PCONTEXT Context)
{
// ApcRoutine = Context->P4Home, Arg = Context->P1Home:
if (!IsApcAllowed(reinterpret_cast<PVOID>(Context->P4Home)))
DiscardApc(Context);
}
#else
// Адрес оригинального диспетчера, полученный после хука:
VOID(NTAPI* OriginalApcDispatcher)(
PVOID NormalRoutine, // ApcProc
PVOID SystemArgument1, // Argument
PVOID SystemArgument2,
CONTEXT Context
) = NULL;
VOID NTAPI ApcHandler(PVOID ApcRoutine, PVOID Arg, PCONTEXT Context)
{
if (!IsApcAllowed(ApcRoutine))
DiscardApc(Context);
}
#endif
KiApcStub вызовет наш ApcHandler, в котором мы можем провести полноценную фильтрацию и, при необходимости, отменить вызов APC, вернув контекст потока обратно функцией NtContinue(&Context).

. А то ни разу ж не интересно.
