Странные машины: как хакеры собирают процессор из данных

от автора

«Для программистов слово „хакер“ означает мастерство в самом буквальном смысле: это тот, кто может заставить компьютер делать то, что он хочет — хочет этого сам компьютер или нет» — Пол Грэм, «Хакеры и художники».

Как часто нам приходится читать в бюллетенях безопасности «Уязвимость… позволяющая нарушителю выполнить произвольный код с помощью специально сформированного запроса». Но что на самом деле скрывается за этой фразой? Что это за специальные запросы и как наша программа может выполнять чужой код, если мы досконально знаем в ней каждую строчку и каждую библиотеку? И почему Apple выстроила многоуровневую аппаратную защиту и платит до двух миллионов долларов за одну найденную уязвимость, но айфоны все равно взламывают по нажатию одной кнопки?

Дело в том, что сами атаки стали другими. Когда инженеры перекрыли большинство очевидных ходов, хакерам пришлось изменить сам подход к взлому. Вместо поиска лазеек они научились заставлять программу работать против самой себя. Теперь атакующие берут легитимные вычисления программы и строят поверх них… виртуальный процессор.

Хакеры умудрились обернуть против нас даже банальную функцию printf, превратив её в универсальный интерпретатор языка Brainfuck. Она обладает Тьюринг‑полнотой — а значит, внутри неё можно реализовать любой зловредный алгоритм.

Перед нами — Data‑Only атаки, где наш собственный код становится оружием, а взлом превращается в программирование на «невидимом» процессоре. Процессоре, команды которого — лишь побочный эффект работы нашей собственной программы.

Но обо всем по порядку. Как же мы докатились до жизни такой?

Гонка вооружений в двоичном коде

В основе любой низкоуровневой атаки лежит простое правило: необходимо перехватить контроль над указателем инструкций процессора (регистром EIP/RIP или PC), который показывает, какую инструкцию нужно выполнить в следующий процессорный такт. Вся история безопасности — это хроника того, как инженеры строили заборы вокруг этого счетчика, а хакеры учились их перелезать.

Веха 1. Дикий Запад | Эра Shellcode

До середины 2000-х годов взламывать программы было относительно просто. Разработчики редко проверяли, сколько данных пользователь передает в программу, поэтому главным оружием было классическое переполнение буфера в стеке.

В оперативной памяти переменные программы лежат вплотную к служебным командам. Когда функция завершает работу, процессор должен понять, куда ему двигаться дальше. Для этого он смотрит на адрес возврата — сохраненные координаты места, откуда эту функцию вызвали. По сути, это адрес следующей инструкции.

Если программа выделяла под имя пользователя массив в 64 байта, а хакер отправлял туда 100 байт, лишние данные физически выходили за границы и затирали все, что лежало дальше. В том числе и адрес возврата.

Чтобы взломать программу, достаточно было двух шагов:

  1. В начало строки хакер записывал шеллкод (shellcode) — короткий набор машинных команд, который запускал командную строку (shell, от этого и пошло его название).

  2. Конец строки подбирался так, чтобы затереть адрес возврата и выставить в его значение — адрес самого буфера, в котором находился произвольный код.

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

Память тех лет была архитектурно «слепой»: процессор физически не отличал текст безобидного сообщения от бинарного кода вируса. В системах действовало опасное правило: если программа могла прочитать данные из памяти, значит, процессор мог их выполнить.

Веха 2. Принцип исключающего ИЛИ | W⊕X

Раз процессор читал любые данные в памяти как команды, главной угрозой эпохи стало внедрение кода (code injection). Чтобы исправить этот архитектурный дефект, инженерам пришлось пересмотреть базовые правила работы с памятью.
Ответом стала политика безопасности: W⊕X (Write XOR Execute), или закон исключающего ИЛИ. Её суть проста: если в область памяти можно записывать данные, она никогда не должна быть исполняемой. Это попутно положило конец эпохе самомодифицирующегося кода — впрочем, к тому времени его использовали преимущественно создатели вирусов. (RWX‑память по‑прежнему нужна JIT‑компиляторам вроде V8 для генерации кода на лету, но как они с этим живут — отдельная история.)

Как раз в это время происходит массовый переход с 32-битной архитектуры x86 на 64-битные процессоры. Смена поколений позволила разработчикам железа, не опасаясь сломать обратную совместимость, обновить структуры управления памятью и наконец добавить туда выделенный флаг выполнения — NX‑бит (No‑Execute). Операционные системы наконец‑то получили инструмент для корректного разделения адресного пространства:

  • Страницы данных (стек и куча): сохранили права на запись переменных, но получили статус неисполняемых. Попытка направить туда указатель инструкций вызывала аппаратное исключение и аварийное завершение программы.

  • Секции с кодом программы (например, сегмент .text): остались исполняемыми, но перешли в режим «только для чтения». Изменить их в памяти стало невозможно.

Исторический курьез

Сама идея не была новой — её реализовали еще в 1965 году в мейнфреймах системы Multics. Но при переходе к массовым персональным компьютерам в 70–80-х годах от отдельного бита исполнения отказались ради экономии кремния и упрощения процессоров. Более того, даже когда в некоторых чипах (вроде SPARC или PowerPC) этот бит физически присутствовал, а в DEC Alpha вообще можно было даже запретить чтение, оставив запись или выполнение, операционные системы упорно настраивали режим «разрешено всё» ради переносимости и для совместимости со старым софтом. Потребовалось сорок лет и миллиардные убытки от эпидемий сетевых червей, чтобы индустрия вернулась к очевидному правилу. Не находите пугающих параллелей с сегодняшним диким миром умных устройств и IoT? 😉

С этого момента классический сценарий взлома ломался в самом конце. Как только хакер пытался направить выполнение на шеллкод, процессор проверял права доступа и генерировал исключение, а программа аварийно завершалась с Segmentation Fault.

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

Именно на это впоследствии сделает ставку Apple: в экосистеме iOS невозможно запустить код, не имеющий доверенной цифровой подписи. Это убило массовые вирусы под платформу. Впрочем, как показала история Pegasus, от целевых атак спецслужб не спасает даже это.

Веха 3. Подвижная мишень | ASLR

Лишенные возможности внедрять свой код, хакеры быстро нашли альтернативу: раз нельзя принести свои инструкции, нужно использовать те, которые разработчики сами загрузили в память. Из этой идеи выросла концепция переиспользования кода (Code Reuse), а её первым массовым воплощением стала атака return‑to‑libc.

Практически любое приложение на C/C++ использует стандартную библиотеку Си (libc). В ней содержатся базовые функции для работы с системой — например, команда system(), которая умеет запускать другие программы и выполнять терминальные команды. В то время все библиотеки загружались в оперативную память по жестко прописанным, постоянным адресам.

Больше не требовался шеллкод. Хакер просто переполнял буфер и перезаписывал адрес возврата известными координатами функции system(), передавая ей нужный аргумент. При этом закон W⊕X формально соблюдался: код библиотеки был легитимным, подписанным и изначально имел права на исполнение.

Чтобы защитить библиотеки, инженеры внедрили технологию Address Space Layout Randomization (ASLR). Это был первый пример вероятностной защиты, когда безопасность стала зависеть не от явных запретов, а от фактора случайности.

Теперь при каждом новом запуске программы операционная система полностью перемешивала её карту памяти. Стек, куча, исполняемый код приложения и все внешние библиотеки каждый раз оказывались по новым, случайным адресам.

Запуск №1: [ Код: 0x00400000 ] [ libc: 0x7f800000 ] [ Стек: 0x7fff1000 ] Запуск №2: [ Код: 0x56012000 ] [ libc: 0x7f31c000 ] [ Стек: 0x7ffe55aa ]

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

Теперь взлом разделился на два обязательных шага. Чтобы обойти ASLR, хакерам пришлось научиться читать чужую память. Теперь перед проведением атаки им требовалось найти дополнительную уязвимость утечки памяти (Information Disclosure). Она использовалась как инструмент разведки: с её помощью хакеры считывали из памяти хотя бы один рабочий служебный указатель. Зная его актуальную координату и расстояние до нужной функции в библиотеке, они могли «на лету» вычислить всю текущую карту памяти процесса и скорректировать вектор атаки.

Веха 4. Конструктор LEGO | Атаки переиспользования кода

Утечки памяти вернули хакерам карту адресного пространства, и return‑to‑libc снова заработал. Но вызов одной готовой функции — грубый инструмент. Запустить shell через system() — пожалуйста, но выстроить сложную многоступенчатую атаку одним вызовом уже не получится. Хакерам нужен был способ выполнять произвольные вычисления, не внедряя ни байта своего кода. И они его нашли: программу можно разобрать на атомарные фрагменты и собрать из них любой алгоритм. Эту технику назвали Return‑Oriented Programming (ROP) — возвратно‑ориентированное программирование.
Аналогия хорошо демонстрируется конструктором LEGO. Представьте, что у вас есть готовый собранный замок из кубиков (это легитимный код приложения). Вы не можете принести свои кубики извне из‑за защиты W⊕X. Но вы можете аккуратно отламывать от замка отдельные детали и соединять их в нужном вам порядке, чтобы построить, например, танк.
Роль таких «деталей» в коде играют гаджеты (gadgets) — ультракороткие обрывки легитимных инструкций программы, которые оканчиваются ассемблерной командой возврата ret. Каждый такой гаджет выполняет одно простейшее действие: например, складывает два числа, сдвигает значение в регистре процессора или записывает байт в память. Сам по себе он безобиден и одобрен всеми системами защиты.

[ Гаджет 1: pop eax; ret ][ Гаджет 2: pop ebx; ret ][ Гаджет 3: add eax, 5; ret ][ Гаджет 4: mov [ebx], eax; ret ]

Атака превращается в своего рода программирование ассемблерными кусочками:

  1. Хакер находит в коде программы сотни таких разрозненных гаджетов и выписывает их адреса.

  2. С помощью переполнения буфера он выстраивает на стеке ROP‑цепочку — последовательность адресов этих гаджетов, перемешанную с нужными данными.

  3. Процессор выполняет первый гаджет, доходит до команды ret, считывает со стека адрес следующего гаджета, прыгает туда, выполняет его, снова упирается в ret — и так далее.

Стек приложения превращается в ленту инструкций для нового, теневого компьютера. При этом процессор не делает ничего противозаконного с точки зрения операционной системы: он просто с бешеной скоростью прыгает по кусочкам оригинального кода самой программы. ROP имел Тьюринг‑полноту: из этих обрезков можно собрать полноценный алгоритм любой сложности. А если инженеры пытались блокировать команду ret, хакеры переключались на Jump‑Oriented Programming (JOP), собирая цепочки из гаджетов, оканчивающихся командами перехода jmp.

Веха 5. Замкнутый контур | Control Flow Integrity

Атаки переиспользования кода выполняют нормальный код программы — каждый вызов формально оставался легальным, поэтому старые методы защиты зашли в тупик. Тогда инженеры решили формализовать допустимое поведение программы целиком: зафиксировать на этапе компиляции каждый легальный переход и блокировать всё, что выходит за рамки. Эту задачу взяла на себя концепция Control Flow Integrity (CFI) — контроля целостности потока управления.

Компилятор строит граф вызовов — полную карту допустимых переходов между функциями. В рантайме каждый косвенный вызов и каждый возврат сверяются с этим графом. Любое отклонение — аварийная остановка.

На практике задача разделилась на два фронта:

  • Прямые переходы (Forward‑edge) — контроль косвенных вызовов. Первые попытки решить задачу в железе (Intel CET с технологией IBT) оказались слишком грубыми (coarse‑grained): процессор лишь проверял, стоит ли в начале целевого адреса аппаратная метка ENDBR64, — и хакеры быстро научились прыгать на любую помеченную функцию. Строгий софтверный CFI на уровне компилятора решил задачу иначе: он сверяет сигнатуру и типы аргументов в каждом месте вызова (call‑site). Вызвать функцию по указателю теперь можно только если её тип точно совпадает с тем, что заложил компилятор.

  • Обратные переходы (Backward‑edge) — контроль адресов возврата. Здесь железо сработало: технология теневого стека (Shadow Stack) дублирует каждый адрес возврата в изолированную память процессора и сверяет оба значения при выходе из функции. Не совпали — программа останавливается.

Связка строгого CFI и аппаратного теневого стека сжала пространство для атак до минимума. Каждый переход — и прямой, и обратный — теперь проверялся на соответствие графу.

Инженеры праздновали победу: код, казалось, стал неприступным. Пусть в программе есть ошибки — перехватить управление через них больше нельзя.

Рождение Странной Машины

«В начале был Код, и Код был у Машины, и Код был самой Машиной»

Вся история, которую мы только что рассказали, — пять раундов борьбы за один регистр процессора: указатель инструкций. Инженеры строили стены, хакеры строили лестницы — и CFI поставил в этом соревновании точку. Перенаправить выполнение больше нельзя.

Но что на самом деле делали все эти шеллкоды, ROP‑цепочки и return‑to‑libc, когда получали управление? Они считали. Вычисляли адреса, читали секреты из памяти, дёргали системные вызовы в нужном порядке. Техника менялась от вехи к вехе, а суть — нет: любая атака — это вычисление, нужное хакеру. Перехват потока управления был лишь способом это вычисление запустить.

А обязательно ли для этого перехватывать управление?

В атаках на кучу ответ нашёлся задолго до появления CFI. Менеджер динамической памяти (malloc/free) внутри устроен как конечный автомат со своей внутренней логикой и структурой: связные списки свободных блоков, размеры, указатели на соседей и так далее

Хакеры, эксплуатируя переполнение буфера в куче, оперировали тремя примитивными действиями: выделить блок, освободить блок и перезаписать несколько байт служебных метаданных. Набор скудный — но каждое из этих действий переводит автомат аллокатора из одного внутреннего состояния в другое. Подбирая правильную последовательность, хакер фактически программирует аллокатор — пишет программу на языке, где действия malloc, free и изменение метаданных — инструкции управления аллокатором, а финальное состояние менеджера памяти — результат вычисления этой трех‑командной программы. Нужным хакеру результатом может оказаться, например, возврат указателя на произвольный адрес. Код аллокатора при этом не менялся ни на байт.

Скрытый текст

Стоит отметить, что такой результат возможен именно благодаря операции, которая никогда не должна возникать в программе, и является следствием ошибки — это «перезапись метаданных». Без этого важного условия, конечно же менеджер памяти никогда не сможет вернуть указатель на произвольный адрес.

Раз атака — это программа, встаёт вопрос о выразительности: что вообще можно сделать, имея в распоряжении только malloc, free и перезапись нескольких байт? И шире — что можно выразить из произвольного набора простых операций?

Ответ оказался обескураживающим. Примерно в это время исследователи начали демонстрировать, что вещи, которые никто и никогда не проектировал для вычислений, на деле оказываются Тьюринг‑полными — то есть на них можно буквально реализовать любой алгоритм.

Загрузчик ELF‑файлов при старте программы должен выполнить, так называемый, механизм релокаций — расставить адреса функций и переменных из используемых библиотек. Работа чисто техническая, никаких вычислений «общего назначения» в ней не предполагалось. Тем не менее исследователи показали, что этот механизм Тьюринг‑полон, и в качестве демонстрации взяли стандартную системную утилиту ping (которая в Linux работает с правами SUID root) и внедрили в нее бэкдор, используя исключительно манипуляции с релокациями. Передавая параметр --type с именем любой программы, ping запускал её с правами root. При этом, ни секция кода, ни данные ping не менялись. С точки зрения любого антивируса — там было ноль инструкций бекдора.

Одной‑единственной инструкции x86 — mov, простой пересылки данных из ячейки в ячейку — хватило, чтобы выразить любые вычисления. Без арифметики, без условных переходов, без циклов. Даже написали компилятор M/o/Vfuscator — он для программы на Си выдавал исполняемый файл, в котором нет ничего, кроме mov. Файл запускался и работал.

Потом избавились и от инструкции целиком. На x86 запустили вычисления, где процессор не выполнял ни одной команды — он обращался к памяти, получал page fault, при обработке получал следующий page fault, и в этой цепочке управляемых аппаратных исключений, где вместо регистров работали внутренние состояния процессора, выполнялся произвольный алгоритм.

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

Такой скрытый вычислитель, сидящий внутри другой логики, назвали странными машинами (weird machines).

Далеко ходить не надо: одного printf хватит

Если атакующий ищет скрытый вычислитель внутри чужой программы, что конкретно он ищет? Набор операций, на котором можно собрать произвольный алгоритм — то, что в теории вычислений называется Тьюринг‑полнотой. Если из доступных операций можно собрать интерпретатор языка, который сам Тьюринг‑полон, — дело сделано: любой алгоритм транслируется в этот язык и исполняется.

Для этой роли подходит Brainfuck — минималистичный язык из восьми команд. Выглядит как шутка, но он математически эквивалентен Си или Python — на нём можно реализовать что угодно. Задача атакующего становится инженерной: найти в коде жертвы достаточно кирпичиков, чтобы сложить из них интерпретатор.

Далеко ходить не пришлось: все кирпичики нашлись в printf.

Но сначала — как устроен этот язык? Представьте длинный ряд пронумерованных ячеек, в каждой лежит число. Указатель показывает на одну из них — текущую. Все восемь команд записываются одним символом: > и < двигают указатель вправо и влево, + и - прибавляют и вычитают единицу в текущей ячейке, . и , печатают содержимое ячейки и читают в неё ввод, а [ и ] задают цикл — тело между ними повторяется, пока значение текущей ячейки не станет нулём.

brainfuck.net

Выглядит примитивно — но этих операций доказано хватает для реализации любого алгоритма.

Теперь к printf. У неё есть малоизвестный спецификатор %n. Все остальные — %d%s и прочие — читают данные из аргументов и выводят на экран. %n делает обратное: берёт адрес из аргумента и записывает туда число символов, которые printf уже вывела к этому моменту. Да, функция вывода текста умеет писать в произвольную память.

При этом printf ведёт внутренний счётчик напечатанных символов, и этим счётчиком можно управлять. %100d добивает число пробелами до ста символов — счётчик вырастает на сто. Спецификатор %.*d берёт ширину из аргумента — загружает значение из памяти в счётчик. А конструкция %3$ обращается к третьему аргументу напрямую, давая доступ к разным адресам в произвольном порядке.

Вот как из этого собирается команда + — «прибавить единицу к текущей ячейке». Вся инструкция — пятнадцать символов форматной строки: %3$.*3$d %4$hhn.

  • %3$.*3$d берёт третий аргумент (значение текущей ячейки) и использует его как ширину поля, печатая столько пробелов. Счётчик printf теперь равен значению ячейки.

  • — один пробел. Счётчик вырос на единицу.

  • %4$hhn записывает младший байт счётчика по адресу в четвёртом аргументе — адресу текущей ячейки.

Три действия — прочитать, прибавить, записать обратно = одна «инструкция» виртуального процессора, выраженного через printf.

Вычитание устроено через переполнение однобайтовой арифметики: %3$255d добавляет к счётчику 255, а при записи через %hhn это эквивалентно вычитанию единицы. Сдвиг указателя — та же арифметика, но над адресом ячейки, а не над её содержимым.

C циклом интереснее. Интерпретатор хранит собственный счётчик команд — указатель на текущую позицию в массиве инструкций, аналог регистра PC в настоящем процессоре. После каждого шага он сдвигается к следующей инструкции. Команда [ проверяет текущую ячейку: если в ней ноль — через тот же %n перезаписывает счётчик команд адресом парной ], перепрыгивая тело цикла целиком. Команда ] безусловно записывает в счётчик адрес парной [, возвращая выполнение к началу. Для printf нет никакой разницы между ячейкой данных и счётчиком команд — и то и другое адрес в памяти, и %n пишет в них одинаково.

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

Кому интересно попробовать, в репозитории лежат примеры: числа Фибоначчи, фрактал Серпинского, игра «Жизнь» Конвея и интерактивные крестики‑нолики.

Таким вот образом, атакующие начали смотреть на атаку уже не как на перезапись адресов возврата, а на поиск вот таких примитивных, но универсальных, вычислителей. Это и есть Data‑Only атаки.

FORCEDENTRY

Интерпретатор внутри printf — элегантная академическая демонстрация принципа, только между лабораторным опытом и реальностью — может лежать пропасть. Но в 2021 году мы все увидели это в реальности: zero‑click эксплойт FORCEDENTRY взламывающий iOS через iMessage.

На телефон приходит сообщение с PDF‑файлом, iMessage обрабатывает вложение автоматически — жертва ничего не нажимает. PDF содержит поток JBIG2, и декомпрессор этого формата при восстановлении пикселей выполняет логические операции над битовыми масками: AND, OR, XOR, XNOR. Уязвимостью не переписывают указатели на код или ещё куда‑то, только снимают ограничения с буфера, куда записывался результат, и логические операции декомпрессора начинают работать со всей памятью процесса.

Здесь и проявилась эмерджентность: из AND и XOR собирается логический вентиль NAND, а из NAND строится любая логическая схема. Авторы эксплойта закодировали в PDF более 70 000 команд JBIG2, из которых собрали процессор с регистрами, 64-битным сумматором и компаратором.

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

Google Project Zero назвали FORCEDENTRY «одним из самых технически изощрённых эксплойтов, которые мы когда‑либо видели». И всё же порог входа ниже, чем кажется: простой процессор описывается на HDL, логические схемы синтезирует Yosys, а M/o/Vfuscator уже показал, что компиляция обычного Си в набор команд нестандартного процессора — это инженерная задача.

Эпилог: Как с этим жить?

В эпоху шеллкода одно переполнение буфера давало полный контроль. Сегодня за рабочий эксплойт платят миллионы. Mitigation’ы не сделали взлом невозможным, но сделали его дорогим, и это огромный прогресс.

Но странные машины доказывают: хоть пространство для атак сужается, но оно не схлопывается до нуля. Атака строится из штатной логики самой программы, и запретить программе делать то, что она должна делать, нельзя. Mitigation’ы и другие формы Hardening необходимы, но это обезболивающее, не лечение.

Лечение требует другого подхода: проектировать системы так, чтобы целые классы уязвимостей отсутствовали по построению:

  • Микроядерные ОС выносят из привилегированного режима всё лишнее. Чем меньше кода с высокими привилегиями, тем меньше кубиков, из которых можно собрать танк.

  • Компартментализация дробит приложение на изолированные модули и ограничивает радиус поражения: странная машина, собранная внутри одного из них, заперта в его границах.

  • Языки, безопасно работающие с памятью (Rust, Zig), убирают повреждение памяти, через которое начинается большинство атак.

  • Формальная верификация доказывает, что код ведёт себя ровно так, как задумано.

Безопасность — не броня, навешенная поверх готового кода. Это свойство конструкции. До самого фундамента — до единичек и ноликов (Telegram‑канал автора).

Статья написана как размышления после круглого стола на тему «Методики оценки степени защищенности операционных систем» в Институте системного программирования РАН им. В.П. Иванникова. Автор выражает благодарность коллегам за поддержу и творческую атмосферу.

ссылка на оригинал статьи https://habr.com/ru/articles/1059800/