Современные эксплойты любят подменять указатели: адрес возврата в стеке, указатель функции в таблице виртуальных методов, указатель в куче. Классические атаки ROP и JOP строят цепочки из фрагментов кода, перепрыгивая через подменённые адреса возврата. ASLR и NX-бит затрудняют жизнь атакующему, но не решают проблему корнево: если есть запись в памяти и известен адрес, подмена указателя работает. Защита указателей стала критически важной задачей для безопасности серверного ПО, и ARM предложила решение на уровне архитектуры процессора.

ARM Pointer Authentication, PAC, добавляет в указатель криптографическую подпись из нескольких бит, вычисленную по секретному ключу процессора и контексту, и любая подмена указателя без знания ключа ломает подпись и приводит к трапу при проверке. Механизм появился в ARMv8.3, в процессорах Neoverse и Cortex-A работает в железе, а Linux с ядра 5.0 поддерживает его для пользовательского пространства и ядра. В 5.7 и позже добавлялись улучшения и расширенная поддержка ядра.

Разберём архитектуру ключей, инструкции подписи и проверки, включение в Linux, влияние на производительность, совместимость с указателями и отладку, работу с JIT-компиляторами, а также практику внедрения.

Архитектура ключей и контекста

PAC использует пять 128-битных ключей: APIA, APIB для данных, APDA, APDB для инструкций, и общий APGA. Ключи изолированы между уровнями исключений: EL0 пользовательское пространство, EL1 ядро, EL2 гипервизор, EL3 прошивка. Подпись вычисляется как QARMA-подобная функция от указателя, контекста 64 бита и ключа, результат обрезается до PAC-битов в старших разрядах. В AArch64 при 48-битном адресном пространстве старшие 16 бит свободны, PAC занимает 12-14 бит. Реализация QARMA обеспечивает защиту от атак на основе временных задержек и сторонних каналов, так как вычисление подписи выполняется в константном времени внутри процессора. Длина PAC в битах настраивается через регистр SCTLR_ELx, биты TBI0 и TBI1 определяют, какие биты в указателе используются для тега. Важно, что ключи процессора уникальны для каждого физического чипа и генерируются при производстве из аппаратного генератора случайных чисел, что делает невозможным предсказание или извлечение ключей программными средствами.

Контекст для подписи данных - значение регистра контекста, например SP для адресов возврата, или определённый программой регистр для указателей в куче. Для кода контекст задаётся программно. Ключи генерируются прошивкой при загрузке из аппаратного источника энтропии и не читаются программно. Переключение контекста при смене потока делается ядром через запись новых ключей в системные регистры. Контекст для адресов возврата - SP, поэтому каждый кадр стека имеет уникальную подпись. Для указателей в куче контекстом может служить адрес самого выделения или специально определённый регистр, что позволяет защитить данные, не связанные со стеком. Стоит помнить, что PAC не шифрует указатель, а добавляет аутентификационный тег, поэтому указатель остаётся читаемым, но любая модификация обнаруживается при проверке. Влияние на адресное пространство минимально: PAC-биты располагаются в битах, которые ранее использовались для Tagged Address ABI, поэтому совместимость с существующим кодом сохраняется полностью. Для 52-битного адресного пространства (ARMv8.4+) длина PAC уменьшается соответствующим образом, но механизм работает так же. При виртуализации ключи EL1 изолированы от гостевых систем: гипервизор управляет ключами EL2 для своих задач, обеспечивая изоляцию PAC между VM.

Инструкции PAC и AUT

Набор инструкций делится на пары подписи и проверки. PACIA и PACIB подписывают указатель с ключом A или B и контекстом из регистра, PACDA и PACDB - для ключей данных. AUTIA и AUTIB проверяют подпись и восстанавливают исходный указатель, иначе устанавливают бит ошибки, гарантируя крах при разыменовании. XPACI и XPACD удаляют подпись без проверки. Инструкции работают в пользовательском и privilegрованном режимах одинаково.

Пример ассемблерной вставки для подписи адреса возврата в прологе функции:

pacia x30, x29
autia x30, x29

Здесь x30 - LR, адрес возврата, x29 - FP, контекст. Компиляторы GCC и Clang генерируют эти инструкции автоматически при включении флагов защиты. Для косвенных вызовов через таблицы виртуальных функций компилятор вставляет autia перед вызовом. Для указателей на функции в структурах данных компилятор генерирует проверку перед каждым косвенным вызовом, что обеспечивает защиту от подмены vtable-указателей и function-pointers. Инструкции PACGA используют общий ключ APGA и не требуют контекста, полезны для подписи указателей без привязки к стеку. Дополнительно существует инструкция AUTIA1716 для проверки адреса возврата с ключом A и контекстом SP, а также AUTIB1716 для ключа B, оптимизированные для стандартного пролога функции. На уровне микрокода эти инструкции выполняются за 1-2 такта, и их можно комбинировать с другими операциями в конвейере процессора. Для указателей на код существует отдельная группа инструкций PACIZA, PACIZB, AUTIZA, AUTIZB с нулевым контекстом, используемых для подписи указателей на функции. Все PAC-инструкции атомарны и не могут быть прерваны, что исключает гонки при одновременном доступе к указателям из разных потоков.

Включение PAC в Linux

Поддержка в ядре включается конфигурацией CONFIG_ARM64_PTR_AUTH. Для пользовательского пространства достаточно, чтобы ядро обнаружило возможность в ID_AA64ISAR1_EL1 и включило её для процессов. Статус виден в:

grep -i pac /proc/cpuinfo
lscpu | grep -i pointer

Если в cpuinfo есть флаги paca, pacg, pacf - поддержка есть и ядро их использует. Для ядра отдельно включается CONFIG_DEBUG_PTR_AUTH, подписывающая указатели ядра: адреса возврата, file_operations, timer_list. Это добавляет накладные расходы, но ловит подмену указателей внутри ядра. Ядро также использует PAC для защиты структур cred, task_struct, и сетевых сокетов, что существенно повышает барьер для эксплойтов ядра. При загрузке ядро проверяет ID_AA64ISAR1_EL1.PACA и PACG биты, и если они установлены, автоматически активирует PAC для пользовательского пространства.

GCC и Clang управляют генерацией кода флагами: -mbranch-protection=standard включает PAC для адресов возврата и косвенных вызовов, -mbranch-protection=pac-ret - только для адресов возврата, -mbranch-protection=none отключает. В Fedora и Ubuntu пакеты собираются с standard по умолчанию. Для приложений без PAC ядро не принудительно включает проверку, указатели без PAC проходят как есть. Флаг standard также включает BTI для защищённых косвенных переходов, комбинация PAC+BTI даёт полную защиту потока управления. Для отдельных объектных файлов можно управлять защитой через атрибуты функции: __attribute__((branch_protection("pac-ret"))). В Clang флаг -mllvm -arm-force-pac принудительно включает PAC для всех функций, игнорируя эвристики компилятора.

Производительность и накладные расходы

Накладные расходы PAC делятся на два компонента. Инструкции подписи и проверки: pacia и autia стоят 1-2 такта на современных ядрах. Увеличение размера кода: каждая защищённая функция получает пару инструкций в прологе и эпилоге, раздувая текст на 2-5%. Для серверных нагрузок потери 1-3%. На ядрах с PAuth_LR расходы ещё ниже: подпись и проверка LR свёрнуты в микрокод. PAuth_LR - аппаратная оптимизация, объединяющая pacia/autia для LR в одну микрокоманду, доступна в Neoverse V1, Cortex-A78 и новее. На бенчмарках SPEC CPU 2017 накладные расходы составляют 1.2% для integer и 1.8% для floating-point нагрузок при использовании standard защиты.

Измерение на конкретной нагрузке делается простым сравнением:

perf stat -e cycles,instructions ./app

Сборка с -mbranch-protection=standard и без, разница в cycles даёт реальную цену. Для JIT-компиляторов V8 или LuaJIT поддержка PAC требует генерации инструкций в прологах сгенерированных функций. В V8 PAC появилась в ветке 10.5, флаг --enable-pointer-authentication.

Работа с JIT-компиляторами и динамическим кодом

JIT-компиляторы создают код во время выполнения, и для PAC это означает необходимость подписывать сгенерированные прологи и эпилоги. В V8 поддержка PAC появилась в ветке 10.5, включается флагом --enable-pointer-authentication. LuaJIT добавил поддержку в версии 2.1. Компилятор должен генерировать pacia в прологе и autia в эпилоге каждой сгенерированной функции, а также autia перед каждым косвенным вызовом. Страницы кода должны быть executable и не writable одновременно - W^X политика обеспечивается через mprotect после генерации кода. Для WebAssembly рантаймов вроде Wasmtime поддержка PAC требует аналогичной генерации инструкций в прологах wasm-функций. Отсутствие PAC в JIT-коде создаёт дыру в защите, через которую атакующий может перепрыгнуть в неподписанный код. В Java JVM поддержка PAC находится в стадии разработки, и в настоящее время рекомендуется использовать интерпретатор или отключать JIT для критических сервисов. В .NET Core поддержка PAC планируется в версии 9.0.

Совместимость, отладка и ловушки

Главная ловушка: указатель с PAC нельзя копировать в память и потом использовать как есть, если контекст изменился. Подпись привязана к контексту, при смене SP проверка не пройдёт. Это ломает трюки вроде сохранения указателя в глобальной переменной для последующего вызова. Отладка в GDB требует поддержки PAC: современные версии понимают PAC-биты и показывают исходный адрес, старые видят мусор в старших битах. LLDB и perf также обновлены. GDB 12+ автоматически распаковывает PAC-указатели в backtrace, команда set pointer-authentication on включает эту функцию.

Для программ со своими аллокаторами есть API ядра: prctl с PR_SET_PTR_AUTH_MASK задаёт маску PAC-битов для процесса, PR_GET_PTR_AUTH_MASK читает текущую. Полезно для рантаймов Go и Rust. В Go PAC появилась в 1.21, в Rust в 1.70, обе включают по умолчанию. Go рантайм защищает указатели планировщика, Rust вставляет PAC-инструкции в прологах при цели aarch64-unknown-linux-gnu. В C++ PAC работает прозрачно при использовании стандартных контейнеров и умных указателей, но для raw-указателей программист должен самостоятельно следить за контекстом. Для языков с ручным управлением памятью вроде Zig или C PAC требует явного указания контекста при подписи указателей в куче.

Отдельная тема: взаимодействие с указателями на код. PAC для кода защищает косвенные вызовы через таблицы виртуальных функций и указатели функций. Компилятор генерирует autia перед косвенным вызовом, подмена указателя в vtable ловится. Для JIT сгенерированный код должен быть подписан, страницы кода - executable и не writable. Особенность стоит помнить: PAC не защищает от атак на данные, только от подмены указателей, поэтому переполнение буфера с переписыванием данных остаётся возможным и требует FORTIFY_SOURCE и стековые канарейки. Важно: при использовании PAC вместе с AddressSanitizer или MemorySanitizer инструменты могут ложно срабатывать на PAC-инструкциях, требуется добавить исключения в конфигурацию санитайзеров.

Практический порядок внедрения PAC в продакшн среде

Внедрение PAC в продакшн укладывается в шесть шагов:

  1. проверить поддержку в железе через /proc/cpuinfo и версию ядра 5.7 или новее;
  2. пересобрать критичные пакеты с -mbranch-protection=standard, начиная с glibc, openssl, systemd;
  3. включить CONFIG_DEBUG_PTR_AUTH в ядре для защиты указателей ядра, пересобрать и загрузить;
  4. прогнать регрессионные тесты и нагрузочные бенчмарки, зафиксировать накладные расходы;
  5. обновить инструментарий отладки: GDB 12+, LLDB 15+, perf с поддержкой PAC;
  6. зафиксировать политику сборки в CI: флаг -mbranch-protection=standard для всех релизных билдов, мониторинг SIGSEGV от PAC-фальшификаций в алертинге. Код 139 с SIGSEGV на autia - признак попытки подмены указателя, события в SIEM.

Итог у Pointer Authentication прагматичный: механизм даёт сильную защиту от подмены указателей ценой 1-3% производительности, работает прозрачно для совместимого кода, ловит классы эксплойтов ROP и JOP, на современных ARM-серверах стандарт де-факто. Ключ к успеху - в пересборке экосистеме с определёнными флагами и обновлении отладочного инструментария. На Neoverse V1/V2 и Cortex-A78/A510 поддержка PAC полная, накладные расходы минимальны благодаря PAuth_LR.