ROP-цепочки и egghunter'ы: почему один mitigation почти всегда обходится
Каждая техника обхода закрывает ровно один mitigation — оценивать нужно их комбинацию.
Коротко: DEP, ASLR и CFG — это не переключатель «защищено / не защищено», а три независимых барьера, каждый из которых закрывает конкретный класс техник. Аудит бинаря должен проверять, какие защиты реально включены и достаточно ли их в комбинации — отсутствие одной из них меняет, какая техника эксплуатации становится рабочей, а не превращает цель в «полностью безопасную» или «полностью уязвимую».

Проблема

DEP (Data Execution Prevention) помечает страницы стека и кучи как неисполняемые — это должно было закрыть классический stack buffer overflow, где atacker кладёт shellcode прямо в переполняемый буфер и передаёт на него управление. DEP действительно закрывает именно эту технику.

Но DEP не проверяет, ЧТО исполняется — только ГДЕ. Если можно передать управление на уже загруженный, исполняемый код, DEP не мешает. Отсюда ROP (Return-Oriented Programming): вместо инъекции нового кода атакующий склеивает цепочку из коротких, уже существующих в памяти инструкций, каждая из которых заканчивается на ret («гаджет»), — и вызывает их последовательно через стек. Новый исполняемый код не появляется вообще, поэтому DEP формально не нарушается.

Похожая логика с ASLR (Address Space Layout Randomization): рандомизация базовых адресов модулей должна не дать атакующему знать, где находится нужный ему код. Но ASLR применяется к модулю целиком — если хотя бы один загруженный в процесс модуль скомпилирован без /DYNAMICBASE (или отключил ASLR явно), его адрес предсказуем при каждом запуске, и всех гаджетов для ROP-цепочки из этого одного модуля обычно достаточно.

Egghunter: когда мало места, а не мало прав

Отдельный класс проблем — ограниченный размер controlled-буфера. Например, при SEH-based переполнении атакующему может быть доступно всего несколько десятков байт под непосредственный payload, тогда как полный shellcode занимает сотни. Egghunter решает это разделением: в ограниченное место кладётся крошечный стаб (обычно 30-40 байт), который при исполнении сам ищет в адресном пространстве процесса заранее известную метку («egg», обычно 4-8 байт), а настоящий payload с той же меткой кладётся туда, где места достаточно — в другом месте буфера или кучи.

Технически egghunter обязан проверять доступность каждой страницы памяти перед чтением (через безопасные syscall вроде NtDisplayString или NtAccessCheckAndAuditAlarm, которые возвращают код ошибки вместо падения на невалидном адресе) — иначе он сам обвалит процесс раньше, чем найдёт payload. Отсюда практический вывод для защитника: маленький доступный буфер не гарантирует, что эксплуатация невозможна — он просто требует другой техники, а не останавливает атаку.

Что проверить руками

  • DEP/NX: включён ли на бинаре и на всех загружаемых DLL, а не только на основном exe.
  • ASLR: у КАЖДОГО модуля в процессе, а не только у главного бинаря — один нерандомизированный модуль обесценивает ASLR для всего процесса.
  • CFG (Control Flow Guard) / CET: ограничивают допустимые цели косвенных вызовов, что резко сужает пригодные для ROP гаджеты, даже если ASLR обойдён.
  • Stack cookies (/GS): защищают от классического overwrite return address, но не от SEH-based или heap-based техник.
  • Комбинация защит на конкретном модуле — не факт «DEP=on», а полный список одновременно включённых mitigations.

Безопасный пример кода

Read-only аудит PE-файла на включённые mitigations через pefile: проверяет DEP (IMAGE_DLLCHARACTERISTICS_NX_COMPAT), ASLR (IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE) и CFG (IMAGE_DLLCHARACTERISTICS_GUARD_CF) в заголовке. Скрипт ничего не исполняет и не эксплуатирует — только читает метаданные заголовка.

import pefile
import sys

FLAGS = {
    0x0040: "DYNAMIC_BASE (ASLR)",
    0x0100: "NX_COMPAT (DEP)",
    0x4000: "GUARD_CF (Control Flow Guard)",
    0x0400: "NO_SEH",
    0x8000: "TERMINAL_SERVER_AWARE",
}

def audit(path: str) -> None:
    pe = pefile.PE(path, fast_load=True)
    characteristics = pe.OPTIONAL_HEADER.DllCharacteristics
    print(f"{path}:")
    for bit, name in FLAGS.items():
        status = "ON" if characteristics & bit else "OFF"
        print(f"  {name}: {status}")
    if not (characteristics & 0x0040) or not (characteristics & 0x0100):
        print("  WARNING: DEP or ASLR disabled - review before shipping")

if __name__ == "__main__":
    audit(sys.argv[1])

Как это должно попасть в отчёт

  • Signals: модуль без DYNAMIC_BASE/NX_COMPAT среди зависимостей процесса, отсутствие CFG на бинарях, обрабатывающих недоверенный ввод.
  • Controls: пересборка со всеми актуальными compiler-флагами, CFG/CET на новых сборках, инвентаризация сторонних DLL на предмет устаревших mitigations.
  • Retest: те же флаги PE-заголовка после пересборки, плюс подтверждение, что рантайм-поведение (crash triage) не изменилось непредвиденно.
Этическая рамка: материал объясняет, зачем нужны конкретные mitigations и как проверять их наличие в собственных бинарях. Он не содержит рабочих ROP-цепочек, шеллкода или инструкций по эксплуатации конкретной уязвимости.