Skip to content
KitploitKITPLOIT
ИнструментыБлог
Отправить
ИнструментыБлог
Отправить

Инструменты для хакинга, пентеста и кибербезопасности — ваш арсенал защиты!

Kitploit — это каталог инструментов для хакинга, кибербезопасности и пентестинга. Находите последние обновления проектов для поиска уязвимостей, анализа систем, автоматизации тестирования и усиления вашей безопасности.

··Ленты·Контакты·Конфиденциальность·© 2026 Kitploit

Каталог инструментов

Категории

Все категории
Loading categories
DeathSleep — Реализация PoC для техники уклонения, которая завершает текущий поток и восстанавливает его перед возобновлением выполнения, одновременно применяя изменения защиты страниц во время отсутствия выполнения. | Kitploit
Инструменты/GitHubGitHub/janoglezcampos/deathsleep
ЭксплуатацияАнализ вредоносных программRed TeamingРазработка Полезной Нагрузки
GitHubjanoglezcampos/deathsleep

DeathSleep

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

Репозиторий
538774 лет назадПроверено Kitploit

Популярное

Смотреть все →

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

Изучить все инструменты

Просмотрите нашу коллекцию инструментов

Смотреть все инструменты →
Поделиться

██████╗ ███████╗ █████╗ ████████╗██╗ ██╗███████╗██╗ ███████╗███████╗██████╗ ██╔══██╗██╔════╝██╔══██╗╚══██╔══╝██║ ██║██╔════╝██║ ██╔════╝██╔════╝██╔══██╗ ██║ ██║█████╗ ███████║ ██║ ███████║███████╗██║ █████╗ █████╗ ██████╔╝ ██║ ██║██╔══╝ ██╔══██║ ██║ ██╔══██║╚════██║██║ ██╔══╝ ██╔══╝ ██╔═══╝ ██████╔╝███████╗██║ ██║ ██║ ██║ ██║███████║███████╗███████╗███████╗██║
╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚══════╝╚═╝

Реализация PoC для техники уклонения, завершающей текущий поток и восстанавливающей его перед возобновлением выполнения, с одновременным изменением защиты страниц во время отсутствия выполнения.

Введение

Методы сна и обфускации хорошо известны в сообществе maldev, существуют разные реализации, цель которых — скрыться от сканеров памяти во время сна, обычно меняя защиту страниц и даже добавляя такие функции, как шифрование шеллкода, но есть ещё один важный момент для сокрытия нашего шеллкода — это сокрытие текущего потока выполнения. Спуфинг стека — это круто, но после некоторых размышлений я пришёл к выводу, что нет необходимости спуфить стек... если стека нет :)

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

Основная реализация, показанная здесь, хранит всё, что нам нужно извлечь из стека, в секции данных в виде глобальных переменных, но скоро будет опубликована реализация, переносящая всё в кучу. Она призвана продемонстрировать некоторые ключевые изменения, которые необходимо внести, чтобы сделать этот код PIC и инъецируемым.

Этот репозиторий зеркалируется между GitHub и GitLab.


Что происходит?

Прежде всего

Всё, изложенное здесь, основано на моём понимании различных затронутых тем, полученном из чтения или опыта во время разработки. Я осознаю, что я не эксперт, и последнее, чего я хочу — это распространять дезинформацию, поэтому, если вы считаете, что что-то не так, я буду рад, если вы укажете мне на это; вы можете связаться со мной в Twitter или открыть issue в этом репозитории. Большое спасибо за понимание. :)

Основы

Основная цель этой техники ясна: завершить текущий поток и восстановить его перед возобновлением выполнения, но что именно это означает и какие новые ограничения это накладывает?

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

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

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

Компоненты DeathSleep:

В этой PoC можно выделить 4 основные функции:

  • Основная программа: Здесь вы будете писать код своего агента, и это та часть кода, которая будет использовать DeathSleep.
  • Функция Awake: это точка входа всех наших потоков, она отвечает за сохранение начальной точки стека, который мы будем восстанавливать. Также она отвечает за восстановление стека и контекста ЦП, когда это необходимо, или просто за запуск нашей основной программы.
  • DeathSleep: это основная функция данной техники, она отвечает за резервное копирование контекста потока и стека, а также за настройку всего для выполнения «магии».
  • Rebirth: Простая функция, отвечающая только за запуск наших новых потоков.

Сохранение стека.

Когда мы собираемся сохранить стек, возникает вопрос: какую часть стека нужно сохранить?

Давайте сначала посмотрим, что находится в стеке после вызова функции DeathSleep (это функция, которая сохраняет контекст, стек и подготавливает всё для обфускации и восстановления).

Как мы видим, каждая функция состоит из трёх частей:

  • Теневое пространство: это 32 байта, выделенные вызывающей стороной, но используемые вызываемой. Насколько мне известно и видно, его основная функция — при необходимости хранить аргументы, переданные вызываемой функции в регистрах, но он может использоваться для любых целей, которые решит вызываемая функция.
  • Адрес возврата: это адрес следующей инструкции, которая будет выполнена в вызывающей функции, помещённый инструкцией CALL, так что инструкция RET в вызываемой функции просто берёт этот адрес и «перепрыгивает» на него при завершении.
  • Пространство стека функции: это пространство, зарезервированное вызываемой функцией для хранения значений регистров, которые необходимо восстановить, и значений её локальных переменных.

Минимальная часть стека, которую нам, очевидно, нужно сохранить, — это всё, что находится внутри нашей основной программы, то есть её теневое пространство, адрес возврата и всё вплоть до функции DeathSleep. Всё, что находится перед этим, не является обязательным (сохранение стека, используемого входной функцией, имеет свои преимущества, но мы обсудим это позже), так как это стек, используемый процедурами Windows для запуска нашего нового потока. Кроме того, я решил также сохранить теневое пространство функции DeathSleep (на самом деле это не обязательно, но облегчает вычисление Rsp в момент пробуждения).

Итак, в итоге мы сохраняем вот это:

Как найти наши адреса стека:

Каждая функция при стандартной компиляции должна состоять из трёх частей: пролога, кода функции и эпилога.

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

Это означает, что указатель стека внутри кода функции всегда должен указывать на конец теневого пространства (фиолетовая область на изображении выше), а сумму размера стека функции и теневого пространства можно найти, вычислив, насколько пролог увеличивает указатель стека. Это значение можно легко вычислить, используя информацию из таблиц размотки (unwind tables); объяснение их использования выходит за рамки этой статьи, но вкратце: эти таблицы используются, чтобы любой другой поток или процесс могли правильно перемещаться по стеку для просмотра его содержимого, обработки исключений или анализа.

Захват и подготовка контекста для восстановления.

Захват контекста, вероятно, одна из самых простых задач, так как мы можем просто вызвать RtlCaptureContext() в первой строке DeathSleep, до любых изменений неэнергозависимых регистров. Нам всё же нужно внести два изменения в контекст, в котором мы будем восстанавливать выполнение.

Первое изменение — изменить его Rip; как вы помните, это регистр, содержащий следующую инструкцию для выполнения, и если мы просто оставим его без изменений, выполнение возобновится внутри функции DeathSleep. Мы изменим Rip так, чтобы он содержал адрес возврата из DeathSleep, на который указывает текущий Rsp, смещённый на размер, увеличенный в эпилоге (зелёная + фиолетовая области на изображениях выше).

Второе изменение будет сделано при восстановлении потока, и оно подразумевает установку Rsp так, чтобы он указывал на вершину нашего восстановленного стека; это будет сделано на этапе восстановления, так как мы не знаем, где будет размещён наш новый стек. Значение будет просто конечным адресом нашего восстановленного стека, поскольку, как мы обсудим позже, мы также скопировали теневое пространство, зарезервированное вызывающей стороной DeathSleep, и это как раз значение RSP до вызова DeathSleep.

Восстановление нашего стека:

Как только мы доходим до момента пробуждения, прямо перед возобновлением выполнения, нам нужно разместить наш сохранённый стек. Как мы уже знаем, наш сохранённый стек начинается с адреса, захваченного функцией awake, поэтому новый захваченный адрес будет начальной точкой, куда мы будем помещать наш сохранённый стек, но это вызывает проблему: любой вызов функции после того, как мы разместили наш старый стек, изменит его и сломает, а очистка здесь очень удобна, особенно освобождение кучи, используемой для хранения резервной копии стека. Это означает, что нам нужно переместить наш текущий Rsp, а также части стека, которые мы сейчас используем, в место, находящееся вне области, куда мы будем помещать восстановленный стек. Попробую прояснить ситуацию: вот проблема:

А вот моё решение — просто переместить всё в сторону:

Восстановление контекста:

После того как мы проделали всю тяжёлую работу, последнее, что нужно сделать, — это использовать NtContinue. Эта функция позволяет изменить текущий контекст на наш ранее захваченный и изменённый контекст, устанавливая RIP сразу после вызова DeathSleep, все регистры должны иметь те же значения, что и при вызове DeathSleep, а RSP должен указывать на вершину стека.

Планирование процесса восстановления: использование Thread Pool API.

Итак, мы знаем основы того, что нужно сделать для сохранения и восстановления текущего потока, но нам нужно как-то выполнить всё это, даже когда у нас нет потоков. Здесь на помощь приходит наш любимый Thread Pool API — инструмент, предоставляемый Windows, который позволяет нам ставить в очередь задачи (функции с максимум одним аргументом) в группу потоков (пул), полностью управляемую операционной системой. Если вы видели Ekko, то замечали, что он использует этот API, так что... давайте реализуем это так же.

Всё работало нормально, но была одна проблема: рабочий поток (worker) оставался активным даже после завершения выполнения своей поставленной задачи. Это было проблемой, так как я хотел уничтожить все потоки, которые может создать наша программа, так что это было не лучшим решением.

Покопавшись, я обнаружил, что Thread Pool API, используемый в Ekko, был старой версией, и появилась новая с дополнительными возможностями, и среди них — функция, которая решит нашу проблему: CloseThreadPool(). Этот новый API позволяет нам создавать собственный пул и уничтожать его после использования, завершая все задействованные рабочие потоки. Он даёт ещё два преимущества: установка максимального числа потоков и группы очистки (cleaning groups). Установка максимального числа потоков позволит нам выполнять все задачи последовательно, если они ставятся в очередь с какой-либо разницей во времени. Группы очистки полезны для упрощения очистки после завершения всего.

Итак... всё сделано? Ну, на этом этапе поток завершён, и мы ставим в очередь функцию rebirth, которая создаёт новый поток с awake в качестве точки входа, восстанавливает предыдущее состояние и закрывает пул, пока всё хорошо!

Изменение прав доступа к памяти, перенаправление выполнения.

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

Основная проблема в том, что нам нужно выгрузить это за пределы нашего кода, так как мы меняем защиту памяти на RW (чтение-запись); если мы вызовем VirtualProtect(), то при возврате функции наш процесс упадёт (мы не можем выполнять инструкции на страницах RW), поэтому нам нужно найти способ выполнить это откуда-то ещё и вернуться также на страницы RX (чтение-исполнение) (и то же самое происходит при возврате). Очевидно, мы снова будем использовать Thread Pool API, но есть проблема: мы можем передавать только один аргумент нашим задачам, а VirtualProtect() принимает 4.

Для этого мы снова воспользуемся NtContinue(); первый раз я увидел использование этой функции для такой цели в Foliage, но она также используется в Ekko. NtContinue(), как мы видели ранее, позволяет установить некоторый контекст для потока, который её вызывает, и с помощью небольшой хитрости она может «вызвать» функцию с несколькими аргументами, используя только один (очень удобно для Thread Pool API). Основная идея — установить RIP на начальный адрес функции, и, поскольку соглашение о вызовах Windows x64 передаёт первые четыре аргумента в регистрах (rcx, rdx, r8, r9, в этом порядке), просто поместите свои аргументы в структуру контекста, которую вы передадите NtContinue, и она фактически смоделирует вызов функции. Последнее, о чём нужно позаботиться при использовании NtContinue, — это Rsp, поскольку, как мы видели, этот адрес должен содержать адрес возврата при вызове функции.

Итак, первое, что нам нужно для работы NtContinue, — это получить контекст; мы могли бы создать его вручную, но столкнулись бы с проблемой поиска значения Rsp, которое при передаче нашей функции будет указывать на адрес, используемый RET для возврата. Наши задачи будут работать в другом потоке, поэтому мы не знаем, где будет размещён его стек. Решение (аккуратно украденное из Ekko, большое спасибо :P) — сделать копию контекста внутри рабочего потока с помощью RtlCaptureContext(), и увеличить указатель стека полученного контекста на 8, чтобы он указывал на адрес, помещённый в стек вызовом CALL RtlCaptureContext(), который является адресом возврата этой последней функции, и мы можем использовать его как адрес возврата для всех наших функций.

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

Цепочки ROP

Итак, мы не можем изменить полученный контекст, но это не значит, что он бесполезен; на самом деле мы его будем использовать, но иначе. Если мы просто восстановим этот контекст с помощью NtContinue(), не изменяя его Rip, он просто перенаправит выполнение на следующую инструкцию после вызова RtlCaptureContext() и с правильным Rsp, так что мы сможем использовать его после наших вызовов NtContinue() с изменёнными контекстами, чтобы правильно завершить выполнение наших задач. Для этого мы будем использовать цепочку ROP, установив Rsp нашего первого контекста так, чтобы он указывал на вручную созданный стек, который будет содержать всё необходимое для перенаправления выполнения до второго вызова NtContinue(), который установит правильный контекст для завершения.

Вот как должен выглядеть наш созданный стек:

Мы используем 2 ROP-гаджета: один для исправления или «перепрыгивания» через теневое пространство нашей функции, а второй отвечает за помещение аргумента для NtContinue в rcx, а затем возврат к нему.

Найти эти 2 ROP-гаджета довольно легко: первый — это эпилог практически любой функции (я нашёл более 500 совпадений только в Ntdll), поскольку, как мы видели, эпилоги предназначены в основном для уменьшения Rsp; второй — это pop rcx; ret;, который занимает 2 байта, и также нашёлся пара экземпляров между Ntdll и Kernel32.

Немного реверс-инжиниринга Thread Pool API

Как мы видели, использование NtContinue требует только заполнения первого аргумента, чтобы работать, и это идеально подходит для старого Thread Pool API, но в новом Thread Pool API аргументы передаются во второй позиции, так что да, это само по себе не сработает.

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

Для старого API мы используем CreateTimerQueueTimer() для постановки задач в очередь, а в новом нам нужны две функции для того же самого: CreateThreadpoolTimer(), которая принимает функцию обратного вызова и аргумент для передачи ей, и возвращает указатель на структуру TP_TIMER, описывающую задачу, и вторую функцию для постановки задачи в очередь: SetThreadpoolTimer(), которая принимает предыдущий указатель и указатель на структуру FILETIME, описывающую время выполнения задачи.

Если мы реверсим эти функции, то найдём следующее:

Итак, как мы видим, CreateThreadpoolTimer() — это просто навороченная обёртка для TpAllocTimer(), а SetThreadpoolTimer() — просто перенаправитель на TpSetTimer().

Теперь давайте проверим внутренности CreateTimerQueueTimer(). Сначала это ещё одна навороченная обёртка для функции из Ntdll, RtlCreateTimer(), и вот где происходит магия. Это более крупная функция, но вот золото, которое мы искали:

Как вы видите, внутри этой функции фактически есть вызов TpAllocTimer() и TpSetTimer(), что аналогично тому, как если бы внутри вызывались CreateThreadpoolTimer() и SetThreadpoolTimer(). Как мы видим, функция, которую мы ставим в очередь, — это не непосредственно заданный нами обратный вызов, а RtlpTpTimerCallback() устанавливается как обратный вызов. Если вы ещё не поняли, что всё это означает, то это означает, что мы используем CreateThreadpoolTimer() для постановки в очередь функции, которая получает свои аргументы во второй позиции, RtlpTpTimerCallback(), которая выполнит другую функцию с её аргументами в первой позиции.

Итак, единственное, что нам ещё нужно понять, — это как информация об обратном вызове передаётся в RtlpTpTimerCallback(), и после некоторого реверса я получил следующую структуру, которая, сюрприз-сюрприз, РАБОТАЕТ!

Теперь мы можем вызывать функции, которые получают свои аргументы в первой позиции, и в то же время можем закрывать свои пулы, не оставляя работающих потоков — выигрыш. Важно отметить, что эта функция не экспортируется в Ntdll, поэтому я решил найти её по байтовой сигнатуре внутри dll.

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

Надеюсь, вам понравилось :)


Соображения по тестированию

Этот код тестировался только с компилятором и компоновщиком MSCV, поскольку эта PoC сильно зависит от того, как она скомпилирована; я рекомендую использовать этот же инструмент и не гарантирую, что он будет работать с другими компиляторами «из коробки».


Скачать инструмент