From vulnerability to exploit
Баг memory-safety — ещё не эксплойт. Мост между ними — перехват потока управления, а ASLR, DEP и stack canary убирают по одной опоре, поэтому современному эксплойту приходится обходить сразу несколько защит, а не один баг.
На авторизованной CTF-машине твой фаззер подаёт 600 байт в программу, которая зарезервировала 64. Она падает. Отладчик останавливается на ошибке, и ты замечаешь нечто тихо катастрофическое: указатель инструкций теперь читается как 0x4141414141414141 — это hex для AAAA…. Отправленные байты не просто испортили данные; они попали в ячейку, которую CPU читает, чтобы решить, что выполнять дальше. В этот момент программа больше не исполняет свою логику — она вот-вот исполнит твою. Никто не передавал тебе это управление. Программа сама его утекла, по байту за раз, потому что ни разу не проверила, сколько она копирует. Расстояние от «оно упало» до «оно выполняет мой код» — это всё ремесло эксплуатации, и почти вся современная защита состоит в том, чтобы сделать это расстояние неподъёмно большим.
К концу урока ты будешь понимать, как баг memory-safety превращается в контроль над указателем инструкций и как ASLR, DEP и stack canary каждый убирают одно из условий, нужных эксплойту, — поэтому одного бага уже редко достаточно.
Сначала авторизация — это механизм, а не оружие
Всё ниже — это теория того, как формируется эксплойт, изложенная так, чтобы защитник мог рассуждать, зачем существуют митигации и почему «низкоопасный» баг памяти всё равно может оказаться game-over. Здесь нет рабочего кода эксплойта, и его не должно быть: чтобы понять механизм, payload не нужен, и его нельзя наводить на систему, которой ты не владеешь или которую тебе явно не разрешили тестировать. Легитимные места, где можно реально наблюдать превращение бага в контроль, — это CTF-машина, намеренно уязвимая VM, развёрнутая тобой, или engagement в рамках письменной авторизации. В терминах MITRE ATT&CK то, что мы описываем, — превращение изъяна в способность выполнять выбранные атакующим инструкции — лежит на границе Initial Access и Execution, а плацдарм, который это покупает, — Persistence (TA0003): как только код запущен, следующая цель атакующего — добиться, чтобы он продолжал работать. Мы изучаем это, чтобы разорвать цепочку, а не выковать её.
Уязвимость — это потенциальная энергия; эксплойт — её высвобождение
Уязвимость — это свойство кода: где-то программа сделает не то с входными данными, которые не провалидировала. Эксплойт — это конкретный вход, превращающий это свойство в выбранное атакующим поведение. Это не одно и то же, и их смешение — самая частая ошибка джуна при оценке серьёзности. Переполнение буфера, которое только роняет процесс, — это баг отказа в обслуживании (DoS). То же самое переполнение, если оно может направить указатель инструкций, — это удалённое выполнение кода (RCE). Одна корневая причина, две радикально разные степени опасности — и какая из них у тебя, целиком зависит от того, чем управляет испорченная память.
Классический пример — переполнение буфера на стеке. Функция резервирует буфер фиксированного размера на стеке — скажем, 64 байта под имя пользователя. Прямо рядом с этим буфером, по соглашению о вызовах архитектуры, лежит сохранённый адрес возврата: место, куда CPU прыгнет, когда функция завершится. Если программа копирует вход в 64-байтовый буфер без проверки длины, вход длиннее 64 байт продолжает писать — за буфер, в соседние ячейки стека и в итоге поверх того сохранённого адреса возврата. CPU понятия не имеет, что что-то не так. Когда функция возвращается, он загружает то, что теперь лежит в этой ячейке, в указатель инструкций и прыгает туда. Если атакующий контролирует эти байты, атакующий контролирует, куда пойдёт исполнение дальше. В этом весь трюк: memory-safety — это то, что не даёт «данным, которые программа читает» и «адресам, которым программа доверяет» быть одними и теми же байтами, а баг memory-safety стирает эту стену.
Контроль над указателем необходим, но недостаточен
Владение указателем инструкций отвечает на вопрос куда идёт исполнение. Атакующему всё ещё нужно, чтобы там было нечто, на что стоит прыгать. Исторически ответ был простым и грубым: положи вредоносные инструкции (shellcode) в тот самый буфер, который ты только что переполнил, и направь адрес возврата обратно на буфер. Твои данные и есть твой код; стек — и то, и другое. Это работало, потому что на ранних системах стек был одновременно записываемым и исполняемым, а адреса предсказуемыми — буфер лежал на одном и том же месте при каждом запуске, так что можно было захардкодить, куда прыгать. Два предположения, и каждое из них следующие двадцать лет митигаций взялись уничтожить.
Это правильная ментальная модель для всей гонки вооружений в эксплуатации. Эксплойт — не одна вещь; это короткий список предусловий, которые все должны выполняться одновременно:
| Эксплойту нужно… | Почему | Митигация, убирающая это | Контр-ход атакующего |
|---|---|---|---|
| Дотянуться до адреса возврата | Затереть ячейку незаметно | Stack canary (случайное значение-страж) | Сначала утечь или угадать canary |
| Выполнить внедрённый код | Исполнить байты в буфере | DEP / NX (неисполняемые страницы) | Переиспользовать имеющийся код (ROP) |
| Знать, куда прыгать | Захардкодить целевой адрес | ASLR (рандомизация раскладки) | Утечь адрес для дерандомизации |
| Вызвать функцию как цель | Прыгнуть в середину кода | CFI (контроль целостности потока) | Найти валидную цель вызова для абьюза |
DEP и ASLR: две опоры, изменившие игру
DEP (Data Execution Prevention), также NX (no-execute), бьёт по предположению «твои данные — это твой код» напрямую. Страницы памяти получают флаг: записываемая или исполняемая, но не обе сразу. Стек и куча — где приземляется вход атакующего — помечаются неисполняемыми. Теперь, когда перехваченный указатель инструкций прыгает в буфер с shellcode, CPU падает с ошибкой: эти байты — данные, а данные не исполняются. DEP почти бесплатен (это аппаратный бит) и убил простейший класс эксплойтов наповал. Ответом атакующих стало return-oriented programming (ROP): вместо внедрения нового кода связывают воедино крошечные фрагменты собственного уже-исполняемого кода программы — короткие последовательности, заканчивающиеся инструкцией ret, называемые «гаджетами», — чтобы собрать нужное поведение из легитимных исполняемых кусков. ROP не нарушает DEP, потому что никогда не исполняет данные; он только запускает код, который уже был помечен исполняемым. Баг сдвинул стоимость эксплуатации, но не покончил с ней.
ASLR (Address Space Layout Randomization) бьёт по другому предположению: предсказуемым адресам. Каждый раз при загрузке программы ОС перемешивает, где живут стек, куча и библиотеки в памяти, — рандомизируя старшие биты их базовых адресов. ROP нужно знать точный адрес каждого гаджета; ASLR означает, что атакующий больше не знает, где что лежит. Захардкоженная цель прыжка, верная вчера, сегодня указывает на мусор, а неверная догадка роняет процесс вместо эксплуатации. Поэтому современным эксплойтам почти всегда нужна утечка информации как первая стадия: отдельный баг (или тот же баг, используемый для чтения, а не записи), раскрывающий один настоящий рантайм-адрес. Из этого единственного утёкшего адреса атакующий вычисляет базу и дерандомизирует весь модуль — ASLR рушится в тот миг, когда утекает один адрес. В этом глубокий урок: ASLR не делает эксплуатацию невозможной, он делает её требующей второго бага, а «нужно два бага, а не один» — это огромный практический рост сложности.
▸Почему это работает
Почему 64-битный ASLR настолько сильнее 32-битного при том же алгоритме? Энтропия. На 32-битных системах рандомизируемая область вмещала лишь примерно 8–16 бит случайности в базовом адресе — от нескольких тысяч до десятков тысяч вариантов. Атакующий, который может повторять обращения к сетевому сервису, просто брутфорсит это: засыпает догадками, пока одна не сядет, — за секунды-минуты. У 64-битных систем гораздо больше адресного пространства для рандомизации — обычно 28+ бит энтропии, — что превращает посильный брутфорс в сотни миллионов попыток, безнадёжных против сервиса, падающего при ошибке. Та же митигация, но именно размер адресного пространства заставляет её реально кусаться. Это чистый пример того, как настоящая сила защиты часто живёт в параметре, а не в идее.
Почему это важно защитнику
Ты редко будешь писать эксплойт переполнения стека. Ты постоянно будешь принимать решения, зависящие от понимания этой цепочки. Когда зависимость выпускает CVE, описанную как «heap buffer overflow», вопрос, задающий твою срочность, — ровно тот, который тренирует этот урок: может ли эта порча дотянуться до потока управления или только роняет? Когда ты выбираешь язык, ты выбираешь, чья работа memory-safety — компилятора (Rust, Go, управляемые рантаймы ограничивают каждый доступ) или твоя (C и C++ доверяют тебе считать байты, и единственный просчёт — это вся история выше). Когда ты ужесточаешь сборку, флаги, включающие stack canary, DEP, ASLR и CFI, — не галочки: каждый удаляет конкретную опору из таблицы выше, и ценность «глубоко эшелонированной защиты» здесь буквальна: атакующему теперь приходится обойти несколько независимых механизмов за один заход, и промах по любому из них превращает его RCE обратно в падение.
В C-сервисе есть переполнение буфера на стеке. Сборка уже включает DEP/NX. Атакующий хочет надёжного выполнения кода. Какое одно добавление в сборку сильнее всего поднимает планку против именно этого бага?
Переполнение буфера в сервисе может портить память, но на этой сборке способно лишь уронить процесс — перенаправить исполнение оно не может. Как его классифицировать?
Почему современным эксплойтам против цели с включённым ASLR так часто нужна утечка информации как первая стадия?
Упорядочь стадии классической цепочки эксплойта переполнения стека — от бага до запуска кода атакующего:
- 1 Вход длиннее буфера копируется без проверки длины
- 2 Переполнение пишет за буфер поверх сохранённого адреса возврата
- 3 Функция возвращается, загружая байты атакующего в указатель инструкций
- 4 Исполнение прыгает на выбранный атакующим код (внедрённый shellcode или цепочка ROP)
- 01Проследи, как переполнение буфера на стеке превращается из падения в контроль над указателем инструкций, и назови один контроль, рвущий цепочку.
- 02Объясни, как DEP и ASLR каждый убирают разное предусловие, нужное эксплойту, и как атакующие ответили на каждый.
Уязвимость — это свойство кода: где-то оно неправильно обработает невалидированный вход, тогда как эксплойт — это конкретный вход, превращающий это свойство в выбранное атакующим поведение. Мост между ними для бага memory-safety — перехват потока управления: переполнение буфера на стеке пишет за свой буфер поверх соседнего сохранённого адреса возврата, и при возврате из функции CPU загружает байты атакующего в указатель инструкций и прыгает туда. Будет ли данное переполнение всего лишь падением (DoS) или удалённым выполнением кода, целиком зависит от того, дотягивается ли порча до потока управления. Три митигации убирают по одному предусловию: DEP/NX делает внедрённые данные неисполняемыми (ответ — ROP, переиспользующий имеющиеся исполняемые гаджеты), ASLR рандомизирует адреса, чтобы атакующий не мог захардкодить цели (ответ — утечка информации, раскрывающая один настоящий адрес и дерандомизирующая остальное), а stack canary стережёт ячейку возврата, чтобы непрерывная перезапись была поймана до возврата. Поскольку каждая митигация убирает свою опору, современному эксплойту приходится обойти несколько независимых механизмов за один заход — вот почему одного бага памяти редко достаточно и почему настоящий рычаг защитника — выбирать memory-safe языки, включать все флаги ужесточения и читать каждый новый CVE о порче памяти через единственный вопрос, задающий его серьёзность: может ли эта порча дотянуться до потока управления или только роняет?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.