У вас есть codebase на C, которая слишком велика для rewrite на Rust и слишком критична, чтобы оставлять её уязвимой для переполнений буфера. Capability-оборудование CHERI обещает отлавливать нарушения безопасности памяти на уровне ЦПУ, но в интернете вам постоянно твердят, что указатели CHERI занимают 128 бит, а ваш код рассчитан на 64.

Хорошая новость в том, что CHERI не требует миграции «всё или ничего». Гибридный ABI позволяет скомпилировать существующий код на C с минимальными изменениями, запустить его на реальном оборудовании CHERI и постепенно усиливать безопасность там, где это важнее всего. Вам не нужно портировать миллион строк кода, прежде чем вы получите свой первый защищённый указатель.

Почему инкрементальный портинг стал возможен сейчас

Ранние capability-архитектуры были бескомпромиссными. iAPX 432 требовал, чтобы каждый указатель был capability, и точка. Это сломало все существующие компиляторы и операционные системы, и проект умер.

CHERI извлекла уроки из этого провала. Она поддерживает три режима компиляции: baseline (только унаследованные целочисленные указатели), hybrid (целочисленные и capability-указатели сосуществуют) и purecap (каждый указатель — capability). Гибридный ABI — это мост. Он позволяет сохранить существующий код почти нетронутым, избирательно внедряя capability там, где они дают наибольшую пользу.

В гибридном режиме void* по-прежнему занимает 64 бита. Capability-указатель — это отдельный тип, void* __capability, который вы явно выбираете. Ваши структуры, связные списки и хеш-таблицы не меняют размер, пока вы сами этого не попросите. Это не слой совместимости. Это полноценный ABI, поддерживаемый оборудованием и компилятором.

Что реально ломается при компиляции для гибридного CHERI

Первый шаг — попытаться скомпилировать свой код с помощью тулчейна CHERI и посмотреть, что взорвётся. Большая часть корректного кода на C компилируется без изменений. Проблемы группируются вокруг горстки паттернов, которые CHERI делает недопустимыми, — а в этом и смысл.

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

Предположения о размере указателя. Код, который жёстко закодировал sizeof(void*) == 8 или сериализует указатели как 8-байтовые значения, сломается в режиме purecap. В гибридном режиме это в основном работает, но становится проблемой, как только вы смешиваете capability и целочисленные указатели в одной структуре.

Арифметика указателей через несвязанные объекты. Программисты на C иногда вычисляют расстояние между двумя произвольными указателями или сравнивают указатели из разных выделений памяти. Capability CHERI несут метаданные границ, и вычитание capability из разных выделений не определено. Компилятор отвергнет это или оборудование вызовет ловушку.

Встроенный ассемблер. Любой ручной ассемблер, который перемещает указатели в целочисленных регистрах, потребует обновления. У CHERI есть выделенные capability-регистры, и компилятору нужно знать, к каким из них вы обращаетесь.

Тулчейн CHERI LLVM выдаёт отличную диагностику. Вы не получаете загадочных ошибок компоновщика. Вы получаете чёткие сообщения вроде «cast from capability to integer is not allowed» или «arithmetic on capabilities from different allocations». Исправьте первую ошибку, перекомпилируйте и идите к следующей.

Конкретный пример: обёртка парсера с capability

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

Сначала скомпилируйте парсер в гибридном режиме. Тулчейн CHERI — это стандартный LLVM с таргетом CHERI:

# Компилируем один файл с гибридным ABI
$ clang --target=riscv64-unknown-freebsd \
    -march=rv64imafdcxcheri \
    -mabi=lp64d \
    -mno-relax \
    -c parser.c -o parser.o

Флаг -mabi=lp64d сохраняет целочисленные указатели 64-битными. Ваши существующие типы void* и char* остаются неизменными. Парсер компилируется как есть.

Теперь добавьте обёртку, учитывающую capability, в отдельном файле:

#include <cheriintrin.h>
#include <stddef.h>
#include <stdint.h>

// Существующий парсер из parser.c
extern int parse_packet(const char *data, size_t len);

// Точка входа с поддержкой capability
int parse_packet_safe(const char * __capability data, size_t len) {
    // Сужаем capability точно до длины буфера.
    // Даже если вызывающий передал большее выделение,
    // парсер не сможет прочитать дальше 'len' байт.
    const char * __capability narrowed =
        cheri_bounds_set(data, len);

    // В гибридном режиме мы передаём обычный указатель
    // унаследованному парсеру. Компилятор вставляет здесь
    // преобразование capability в целое число. Если capability
    // не помещается в 64 бита (а для large-capability адресов
    // так и будет), это вызовет предупреждение или ошибку.
    //
    // Для миграции на purecap вы бы вместо этого изменили
    // parse_packet, чтобы он принимал capability-указатель.
    return parse_packet((const char *)narrowed, len);
}

В этом примере parse_packet по-прежнему использует унаследованные целочисленные указатели. Обёртка сужает capability вызывающего до точной длины входных данных, а затем преобразует его обратно. Шаг сужения означает, что даже если вызывающий случайно передаёт буфер в 4 КБ, хотя имел в виду 64 байта, оборудование обеспечит соблюдение 64-байтовой границы на суженном capability.

Это не финальное состояние. Это промежуточный этап. Сегодня вы получаете bounds enforcement на границе API, а в последующем refactor можете мигрировать parse_packet на purecap.

Путь миграции от hybrid к purecap

Гибридный режим — это отправная точка, а не пункт назначения. Он даёт совместимость, но не полную выгоду от безопасности capability. Долгосрочная цель — purecap, где каждый указатель несёт границы.

Практическая миграция выглядит так:

  1. Скомпилируйте в гибридном режиме и исправьте ошибки сборки. Обычно это означает исправление приведений указателя к целому числу и предположений о sizeof(void*). Пока не меняйте свои структуры данных. Просто добейтесь чистой сборки.

  2. Определите высокоценные цели. Сетевые парсеры, декодеры форматов файлов и процедуры десериализации — лучшие кандидаты для capability enforcement. Они обрабатывают недоверенный ввод и именно там обитает большинство багов безопасности памяти.

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

  4. Мигрируйте leaf-функции на purecap. Начните со служебных функций, которые выделяют и возвращают указатели. Измените их сигнатуры, чтобы использовать __capability, и скомпилируйте их с -mabi=purecap. Продвигайтесь вверх по стеку вызовов.

  5. В конечном итоге переведите весь module на purecap. Когда в единице компиляции не останется целочисленных указателей, скомпилируйте её с -mabi=purecap и слинкуйте с остальным гибридным кодом. Тулчейн CHERI поддерживает линковку смешанных ABI.

Для большой codebase это не проект на выходные. Но это и не rewrite. Вы можете защитить свои наиболее уязвимые пути выполнения за дни и мигрировать остальное постепенно, по мере работы с ним.

Как на самом деле выглядят компромиссы

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

Во-первых, смешанные ABI усложняют сборку. Теперь в одном бинарнике у вас есть объектные файлы, скомпилированные с разными размерами указателей. Компоновщик должен обрабатывать relocation’ы как для capability, так и для целых чисел. Тулчейн CHERI поддерживает это, но ваша система сборки, скорее всего, пока об этом не знает. Вам придётся её обучить.

Во-вторых, преобразование capability в целое число на гибридных границах приводит к потерям. Если вы сузили capability, а затем привели его к обычному указателю, вы теряете границы. Базовая память по-прежнему защищена исходным capability, но унаследованная функция не получает hardware enforcement. Вот почему purecap — это цель: каждый указатель в цепочке вызовов несёт свои собственные границы.

В-третьих, меняется отладка. GDB на CHERI понимает capability-регистры и может выводить метаданные границ. Поддержка LLDB улучшается. Если ваш текущий рабочий процесс отладки завязан на просмотр сырых значений указателей, вам придётся выучить имена регистров CHERI.

Накладные расходы производительности гибридного режима обычно незначительны для кода, который в основном использует целочисленные указатели. Накладные расходы purecap обычно составляют однозначные проценты для большинства нагрузок, достигая 10–15 % для структур данных с интенсивным использованием указателей. Это сопоставимо с программными смягчениями вроде ASAN, и, в отличие от ASAN, CHERI работает на полной скорости в продакшене.

Можно начать уже сегодня с QEMU

Вам не нужна плата Morello для экспериментов. Проект CHERI поддерживает QEMU и образы Docker с предустановленным тулчейном LLVM.

# Скачиваем Docker-образ с тулчейном CHERI
$ docker run --rm -it ctsrd/cheri-sdk:latest

# Внутри контейнера компилируем свой код для RISC-V CHERI
$ clang --target=riscv64-unknown-freebsd \
    -march=rv64imafdcxcheri \
    -mabi=lp64d \
    -o myapp myapp.c

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

Исправляйте ошибки в таком порядке: сначала приведения целых чисел к указателям, затем арифметика указателей через разные выделения, потом встроенный ассемблер. У каждой категории есть механическое исправление. Проект CHERI публикует руководство по портированию с примерами «до и после» для наиболее распространённых паттернов.

Capability-оборудование — это не теоретическое будущее. Это рабочий тулчейн, поддерживаемый ABI и путь миграции, который не требует сжечь вашу codebase дотла. Начните с одного файла, одной функции, одного суженного capability. Оборудование сделает всё остальное.