
Детектор пользовательского режима, который перехватывает косвенные системные вызовы. Ловит Hell's Hall, Tartarus' Gate, RecycledGate, VEH-системные вызовы и многие другие.
Copyright (C) 2026 Adam Zypherion <[email protected]> — распространяется под лицензией GPL-3.0.
Непрямые системные вызовы (indirect syscalls) — это просто, когда хоть раз их увидишь. Загрузчик проходит по ntdll, читает SSN из пролога функции Nt*, находит два байта 0F 05 чуть дальше, сам устанавливает r10/rax/rdx/r8/r9 и выполняет jmp прямо на эти два байта. Системный вызов происходит внутри ntdll. Ваш хук на экспорт kernel32 не срабатывает. Ваш хук на экспорт ntdll не срабатывает. [RSP] в момент SYSCALL указывает обратно на RWX-страницу загрузчика, но со стороны вызов выглядит обычно.
HellHall proc
mov r10, rcx
mov eax, dwSSN
jmp qword ptr [qAddr]
ret
HellHall endp
У этих вариаций есть названия. Tartarus' Gate и RecycledGate используют инструкцию syscall, принадлежащую одному стабу, но SSN — от другого, так что даже если вы логируете имя стаба, которое сообщает ваш хук, это ложь. VEH-системный вызов намеренно вызывает нарушение доступа и использует собственный VEH для перезаписи контекста, так что RIP оказывается на инструкции syscall из ntdll с уже подготовленными регистрами. Hell's Gate вообще не использует ntdll. Загрузчик записывает 0F 05 в свою собственную RWX-страницу и выполняет оттуда.
Ответ со стороны режима ядра (KM) — это драйвер. Возможно, я когда-нибудь доберусь до этого и выпущу проект, но в ближайшее время не случится :D
PAGE_GUARD выглядит так, будто он должен работать. Помечаем страницу, содержащую байты системного вызова, флагом PAGE_GUARD, ловим STATUS_GUARD_PAGE_VIOLATION в VEH, проверяем, перенаправляем на приватный трамплин, устанавливаем флаг трассировки, выходим из шага, возвращаем страницу. Одна ловушка на каждый Nt-вызов, независимо от того, как загрузчик попал туда. Это работает против всего, кроме Hell's Gate.
Проблема в том, что ОС не сотрудничает. PAGE_GUARD — одноразовый. Что это значит? Каждый раз при срабатывании бит сбрасывается, и его приходится возвращать обратно. Обработчик выполняет системные вызовы (NtProtect для повторной установки защиты, NtContinue для возобновления), и у этих системных вызовов есть стабы, а эти стабы находятся на той же странице, которую вы только что защитили. Я обошёл это большей частью, создав приватные стабы системных вызовов на отдельной странице, которую мы контролируем (выделяем RWX, пишем mov r10,rcx; mov eax,SSN; syscall; ret, блокируем в RX, никогда не трогаем ntdll), но это было ненадёжно. Каждый минорный выпуск Windows менял тайминги. Каждый новый поток в демонстрационном образце приводил к очередной гонке с фоновым потоком целостности, который восстанавливал защиту. Успешность демонстрации составляла от 30 до 50 процентов на десяти запусках на одной машине.
В конце концов я перестал пытаться убедить Windows, что PAGE_GUARD должен вести себя так, как я хочу, и вместо этого попробовал перезаписывать байт.
https://github.com/user-attachments/assets/0cb670fd-e51b-413e-bf00-08f9297888ed
Байт на инструкции системного вызова — это 0F 05. Первый байт (0F) сам по себе является префиксом для семейства двухбайтовых опкодов, включающих SYSCALL, CPUID и RDTSC. Сам по себе он не исполняем; ЦП нуждается во втором байте для декодирования. Если заменить первый байт на 0xCC (INT3), пара становится CC 05, что ЦП декодирует как INT3, за которым следует случайный байт, который никогда не достигается. Любой путь выполнения, который попадает на этот адрес, вызывает EXCEPTION_BREAKPOINT.
В этом и заключается весь механизм. Наш VEH ловит точку останова, выясняет, какому стабу принадлежит адрес (мы строим карту при инициализации, перечисляя экспорты ntdll), выполняет три проверки для того, кто появился, и устанавливает Context->Rip на приватный трамплин, который выполняет реальный системный вызов и возвращается. Байт остаётся CC. Следующий вызывающий попадает на него так же, так что никакого переключения защиты страниц туда-сюда.
Чтобы было ясно: да, это перехват (hook) путём перезаписи байта. Просто это делается не там, где обычно понимают под «хуком ntdll». Классический EDR-хук перезаписывает первые байты стаба командой JMP <my_func>:
ntdll!NtAllocateVirtualMemory:
E9 ?? ?? ?? ?? jmp my_hook ; перезаписывает "mov r10, rcx"
...
0F 05 syscall
C3 ret
Именно это и обходят непрямые системные вызовы. Загрузчик сам считывает SSN и прыгает прямо на 0F 05, пролог (и ваш jmp) никогда не выполняются, хук не срабатывает. Мы же перезаписываем сам байт системного вызова:
ntdll!NtAllocateVirtualMemory:
4C 8B D1 mov r10, rcx ; нетронуто
B8 18 00 00 00 mov eax, 18h ; нетронуто
CC 05 int3 / 05 ; было 0F 05, мы записали CC поверх 0F
C3 ret
Теперь не имеет значения, как вы попали на этот адрес: через пролог, через косвенный прыжок, пропускающий пролог, через перезапись контекста VEH-сисвызова, кладущую туда RIP — всё равно. Если ЦП выполняет этот байт, он ловит исключение. Та же техника, что и классический хук, но в другом месте и с совершенно другим покрытием.
Трамплин выглядит так:
F3 0F 1E FA endbr64
49 89 CA mov r10, rcx
B8 <SSN стаба> mov eax, ssn
0F 05 syscall
C3 ret
Три проверки — те же, что были в версии с PAGE_GUARD, потому что они были правильными; им просто нужно было надёжное место для работы.
Обратный адрес. [RSP] — это то, куда должен был вернуться системный вызов. Для реального вызова он находится внутри ntdll, kernel32, kernelbase или одной из связанных DLL времени выполнения. Для Hell's Hall он находится внутри какой-то RWX-страницы загрузчика. У нас есть короткий список доверенных целей возврата, построенный при инициализации с помощью вызова GetModuleHandle для этих имён и чтения диапазонов .text из их PE-заголовков.
SSN. Пролог стаба уже выполнился до того, как мы попали в байт системного вызова, поэтому eax содержит то значение, которое в него загрузили. Если загрузчик выполнил свап по Tartarus, это значение не будет совпадать с SSN, который мы прочитали для этого же стаба при перечислении. Мы логируем несоответствие, а трамплин записывает правильный SSN перед своим собственным syscall, так что выполняется функция ядра, принадлежащая байту, а не та, которую хотел загрузчик. Техника логируется и нейтрализуется за один шаг.
Обход стека. RtlVirtualUnwind от текущего контекста, до пяти фреймов вверх. RIP каждого фрейма должен находиться внутри известного модуля и иметь запись RUNTIME_FUNCTION. Шеллкод и ROP-гаджеты не проходят эту проверку, даже если сам [RSP] выглядит доверенным — загрузчик может его подделать (он может примерно предсказать, где будет находиться его вызов в памяти, и сфабриковать похожий обратный адрес).
Если любая проверка не пройдена, мы помещаем небольшую структуру в lock-free-кольцо, и поток-сборщик выводит её при следующем пробуждении:
[!! hallwatch !!] indirect syscall (nенадёжный вызывающий, неверный SSN для этого стаба)
syscall : NtAllocateVirtualMemory
syscall rip : 0x00007FF827660372
return addr : 0x00007FF7EED719D6
rax (ssn) : 0x0000000F (стаб кодирует 0x00000018)
thread : 26388
Hell's Gate — это то место, где INT3 перестаёт помогать. Мы никогда не патчили RWX-страницу загрузчика, потому что не знали о её существовании.
Вместо этого мы используем самый простой сканер, который только мог работать. Каждые 250 мс (в принципе, слишком часто, тратит ресурсы ЦП, но для PoC сойдёт) фоновый поток целостности проходит по адресному пространству с помощью VirtualQuery, просматривая любую область MEM_COMMIT с исполняемой защитой страницы. Если область находится внутри загруженного модуля — пропускаем. Если внутри нашего собственного пула трамплинов — пропускаем. Всё остальное — чужой исполняемый код. Мы сканируем до 64 КБ такой памяти на наличие пар байтов 0F 05, дедуплицируем по адресу, логируем каждое уникальное попадание один раз. Знаю, это глупо и тратит циклы ЦП, но это PoC, что не должно умалять ситуации, но пока так.
Это ловит Hell's Gate в свежем RWX-выделении, а также теневая ntdll (когда загрузчик делает NtMapViewOfSection на ntdll.dll, чтобы получить приватную копию по новому адресу). Случай с тенью забавен. Это отображение не входит в наш снимок модулей, поэтому его инструкции syscall отображаются как чужеродные исполняемые байты, хотя они взяты из совершенно легитимной подписанной DLL на диске.
Другой вариант: злоумышленник встраивает 0F 05 непосредственно в .text своего бинарника, так что системный вызов живёт внутри загруженного модуля, а не на RWX-странице. Сканер чужеродных RWX пропускает загруженные модули, поэтому раньше это проскальзывало. Поэтому у нас есть ещё один сканер, который проходит по исполняемым байтам каждого модуля, пропускает ntdll, win32u и wow64cpu (три модуля, в которых законно присутствуют инструкции syscall), и сообщает о 0F 05, только если в течение следующих трёх байтов есть C3. Проверка на ret делает это применимым. 0F 05 сам по себе часто встречается в выводе компилятора как часть больших инструкций, но никогда не соседствует с C3. Настоящие стабы системных вызовов всегда содержат C3. Лог:
[hallwatch] scan: module-text syscall stub at 0x00007FF7FFC9227C in HellsHall-Demo.exe -- Hell's Gate inside loaded module .text
У фонового потока целостности есть ещё две небольшие задачи. Он читает байт на каждом пропатченном стабе каждый тик и записывает CC обратно, если там не CC. Это самый простой способ противостоять снятию хука: сэмпл читает исходный 0F откуда-нибудь (свежая карта ntdll, PE на диске, известная таблица правильных байтов) и записывает его поверх нашей заплатки. Мы замечаем это на следующем тике и снова ставим заплатку.
Каждый пятидесятый тик (примерно 12,5 секунд) поток заново устанавливает наш VEH в начало цепочки и удаляет предыдущий хендл. Сэмпл может обойти LdrpVectorHandlerList и удалить нас из регистрации; поток ограничивает время, в течение которого эта лазейка остаётся открытой. Двенадцать с половиной секунд — это большой промежуток, если вас атакуют, и короткий, если вы запускаете вредоносное ПО в песочнице. Вероятно, я сокращу его, когда измерю затраты на более частые установки.
Поток целостности работает с установленным TLS-флагом, который проверяет VEH. Собственные системные вызовы потока (NtProtect для патчинга, NtQuery для сканирования) вызывают наши точки останова, как и любые другие, но флаг сообщает VEH пропустить логику обнаружения и молча перенаправить через трамплин.
Критическая секция инициализируется при первом обращении к ней, используя трёхсостояние compare-exchange (0 = не инициализировано, 2 = занято, 1 = готово). Это уродливо, но позволяет избежать статического конструктора внутри DLL, что в Windows порождает отдельный набор проблем, связанных с блокировкой загрузчика и т.д.
Одна вещь, касающаяся ABI, которую стоит отметить. INT3 — это ловушка, что означает, что когда наш VEH вызывается, Context->Rip указывает на следующую инструкцию, а не на саму ловушку. Если мы заплатили байт по адресу 0x7FF827660372, то Context->Rip приходит в VEH как 0x7FF827660373. Record->ExceptionAddress указывает на ловушку, но мы должны сбросить Context->Rip = ExceptionAddress перед перенаправлением на трамплин, иначе трамплин начнёт на один байт позже, и системный вызов не сделает ничего полезного. Говорю это, потому что это была ошибка, на поиск которой у меня ушла куча времени.
Есть четыре экспорта. IscInitialize включает детектор; DllMain вызывает его автоматически, но вы можете вызвать его из хост-процесса, если хотите получить возвращаемое значение. IscGetDetectionCount возвращает монотонно увеличивающийся счётчик. IscShutdown ожидает завершения активных обработчиков и восстанавливает байты 0F. IscFlush синхронно опустошает кольцо, полезно, если вы встраиваете детектор в песочницу, которой нужно получить события до завершения сэмпла.
Минимальная интеграция — это LoadLibrary. DllMain обрабатывает инициализацию и запускает оба фоновых потока оттуда.
Isc = Indirect Syscalls
Что мы пока не ловим:
INT3 — это то, что мы в итоге выбрали, но сам механизм ловушки не является интересной частью. Причина, по которой это работает лучше, чем PAGE_GUARD, в том, что ловушка не требует постоянного согласования с ОС. Байт — это CC. Он остаётся CC. У ОС нет мнения о том, какие байты находятся в ntdll.