Привет, Хабр!

Если вы держали в руках любой крупный C или C++ код, то знаете, что память частенько ошибается. На ARM‑системах давно есть TBI, возможность прятать биты в верхнем байте адреса. На этой базе появился Memory Tagging Extension, коротко MTE. Это аппаратная проверка того, что указатель действительно свой для конкретного куска памяти. Часть механизмов есть в ядре Linux, часть в libc, часть в компиляторах, и из этого можно собрать жёсткую память, строгую к факапам и терпимую к продакшен‑нагрузке.

MTE добавляет к каждой 16-байтной грануле памяти 4-битный allocation tag. А в верхние биты адреса (59…56) вы добавляете logical tag указателя. Процессор на каждом доступе сравнивает тег указателя и тег памяти. Если не совпало, прилетит fault по выбранному режиму проверки. Гранула фиксированная — 16 байт. Тегов 16. Это значит, что при рандомизации тегов теоретический шанс пронести левый указатель без ошибки — 1 из 16, если атакующий не знает тег. Этот шанс можно давить паттернами тэгирования и дисциплиной работы аллокатора.

Режимы проверки три: sync, async и asymmetric. Sync — точный fault на инструкции доступа. Async — отчёт позже, адрес неизвестен, но накладные расходы меньше. Asymmetric — чтения синхронно, записи асинхронно.

Включаем MTE в Linux user-space: детект, prctl, mmap, сигналы

Чтобы вообще иметь право на теги, процессор должен уметь MTE, а ядро экспортировать фичу в user‑space через HWCAP2_MTE. Проверяем так и сразу настраиваем режимы:

// build: clang -std=c11 -O2 -Wall -Wextra -target aarch64-linux-gnu -march=armv8.5-a+memtag mte_boot.c
#define _GNU_SOURCE
#include <errno.h>
#include <inttypes.h>
#include <signal.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/auxv.h>
#include <sys/mman.h>
#include <sys/prctl.h>
#include <unistd.h>

#ifndef HWCAP2_MTE
#define HWCAP2_MTE (1u << 18)
#endif

#ifndef PR_SET_TAGGED_ADDR_CTRL
#define PR_SET_TAGGED_ADDR_CTRL 55
#define PR_GET_TAGGED_ADDR_CTRL 56
#define PR_TAGGED_ADDR_ENABLE   (1UL << 0)
#define PR_MTE_TCF_SHIFT        1
#define PR_MTE_TCF_NONE         (0UL << PR_MTE_TCF_SHIFT)
#define PR_MTE_TCF_SYNC         (1UL << PR_MTE_TCF_SHIFT)
#define PR_MTE_TCF_ASYNC        (2UL << PR_MTE_TCF_SHIFT)
#define PR_MTE_TCF_MASK         (3UL << PR_MTE_TCF_SHIFT)
#define PR_MTE_TAG_SHIFT        3
#endif

static void install_sigsegv_handler(void);

static void enable_mte_thread(void) {
    unsigned long hwcap2 = getauxval(AT_HWCAP2);
    if (!(hwcap2 & HWCAP2_MTE)) {
        fprintf(stderr, "MTE not supported on this CPU/kernel\n");
        exit(2);
    }
    // Разрешаем tagged pointers в ABI и сразу просим sync+async ядро выберет предпочтительный режим CPU.
    unsigned long tag_include_mask = 0xfffeUL << PR_MTE_TAG_SHIFT; // исключаем zero-tag из случайной генерации
    unsigned long flags = PR_TAGGED_ADDR_ENABLE | PR_MTE_TCF_SYNC | PR_MTE_TCF_ASYNC | tag_include_mask;
    if (prctl(PR_SET_TAGGED_ADDR_CTRL, flags, 0, 0, 0) != 0) {
        perror("prctl(PR_SET_TAGGED_ADDR_CTRL)");
        exit(1);
    }
    install_sigsegv_handler();
}

static void segv_handler(int sig, siginfo_t* si, void* ctx) {
    (void)ctx;
    // si_code: SEGV_MTESERR для sync, SEGV_MTEAERR для async
    fprintf(stderr, "SIGSEGV: si_code=%d, addr=%p\n", si->si_code, si->si_addr);
    _Exit(128 + SIGSEGV);
}

static void install_sigsegv_handler(void) {
    struct sigaction sa = {0};
    sa.sa_flags = SA_SIGINFO;
    sa.sa_sigaction = segv_handler;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGSEGV, &sa, NULL);
}

int main(void) {
    enable_mte_thread();
    // код
    return 0;
}

Константы PR_* и HWCAP2_MTE задокументированы в кернел‑доках. Режимы и коды si_code для SIGSEGV такие же, как в примере ядра.

Теперь нужна память с атрибутом PROT_MTE. Его можно задать сразу в mmap либо потом через mprotect. PROT_MTE работает только для анонимных и RAM‑бэкендов вроде tmpfs/memfd. На файловых маппингах обычных файлов ядро вернёт EINVAL.

Пример выделения страницы под теги:

// build: clang -std=c11 -O2 -target aarch64-linux-gnu -march=armv8.5-a+memtag mte_map.c
#include <sys/mman.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

void* mte_map_page(void) {
    size_t ps = (size_t)sysconf(_SC_PAGESIZE);
    void* p = mmap(NULL, ps, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
    if (p == MAP_FAILED) { perror("mmap"); exit(1); }
    if (mprotect(p, ps, PROT_READ|PROT_WRITE|PROT_MTE) != 0) { perror("mprotect PROT_MTE"); exit(1); }
    return p;
}

int main(void) {
    void* p = mte_map_page();
    printf("page: %p\n", p);
    return 0;
}

Ядро также разрешает задавать маску разрешённых для рандома тегов через PR_MTE_TAG_MASK. Обычно исключают 0.

Присваиваем и проверяем теги: intrinsics вместо inline asm

Теги надо не только включить, но и поставить. Есть ACLE‑intrinsics для MTE, заголовок <arm_acle.h>. Функции:

  • __arm_mte_create_random_tag(ptr, mask) возвращает указатель с рандомным logical tag, исключая маской запретные.

  • __arm_mte_set_tag(tagged_addr) записывает allocation tag по адресу гранулы, на которую указывает tagged_addr.

  • __arm_mops_memset_tag(tagged_addr, val, size) если есть ещё и MOPS, можно тегировать блоки пачкой, как memset с установкой тегов.

Соберём утилиту тэгирования диапазона и демонстрацию доступа с fault:

// build: clang -std=c11 -O2 -target aarch64-linux-gnu -march=armv8.5-a+memtag mte_tag_demo.c
#include <arm_acle.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <sys/mman.h>
#include <unistd.h>

static inline void* tag_range(void* base, size_t len, uint16_t exclude_mask) {
    // Выбираем один тег на объект и ставим его на все гранулы
    void* tagged = __arm_mte_create_random_tag(base, exclude_mask);
#if defined(__ARM_FEATURE_MOPS) && defined(__ARM_FEATURE_MEMORY_TAGGING)
    size_t g = 16;
    size_t n = (len + g - 1) / g * g;
    __arm_mops_memset_tag(tagged, 0, n);
#else
    // Fallback — проставляем тег на каждую гранулу
    uintptr_t p = (uintptr_t)tagged;
    uintptr_t start = p & ~((uintptr_t)0xF);
    uintptr_t end   = start + ((len + 15) & ~((size_t)0xF));
    for (uintptr_t a = start; a < end; a += 16) {
        __arm_mte_set_tag((void*)a);
    }
#endif
    return tagged;
}

int main(void) {
    size_t ps = (size_t)sysconf(_SC_PAGESIZE);
    void* raw = mmap(NULL, ps, PROT_READ|PROT_WRITE|PROT_MTE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
    if (raw == MAP_FAILED) return 1;

    char* buf = (char*)tag_range(raw, 64, /*exclude tag 0*/ 0x1);
    buf[0] = 0x42;          // ок — адрес с логическим тегом совпадает с аллок-тегом
    printf("ok: %p\n", buf);
    char* bad = (char*)((uintptr_t)buf & ~((uintptr_t)0xFF << 56)); // убираем логический тег
    bad[0] = 1;             // здесь прилетит SIGSEGV в sync/addr=bad, либо async без addr
    return 0;
}

Ссигнальный путь ядра в разных режимах ведёт себя по‑разному. В sync вы получите SIGSEGV с si_code = SEGV_MTESERR и валидным .si_addr. В async — SEGV_MTEAERR и .si_addr = 0.

Готовые интеграции: glibc, аллокаторы, компиляторы, Android

Если вы не пишете свой аллокатор, а используете glibc, то базовая интеграция с MTE есть из коробки через tunable. Достаточно запустить процесс с переменной окружения, и malloc начнёт выдавать тэгированную память, а стартовый код libc включит нужные режимы в ядре.

Ключ: GLIBC_TUNABLES=glibc.mem.tagging=<mask>, где бит 0 включает тэгирование аллокаций, бит 1 — precise faulting, бит 2 — «предпочтение системы» между sync/async. Дефолтное прод‑значение 5, то есть биты 0 и 2.

Пример запуска без переписывания кода:

# включаем тэгирование в malloc и даём системе выбрать режим с приоритетом производительности
GLIBC_TUNABLES=glibc.mem.tagging=5 ./your_binary

Производственные браузерные и мобильные стеки тоже двигаются в эту сторону. Chromium поддерживает MTE через PartitionAlloc и метит страницы/чанки, Android документирует режимы и позицию MTE в стекe безопасности. Для продакшена рекомендуют asymmetric или async с минимальной ценой. На новых Pixel включение MTE возможно в dev‑режиме через Options, но это не режим для обычного пользователя.

Компиляторы. Сторона инструментов — два направления:

  • HWAddressSanitizer в Clang. Это инструмент на базе top‑byte‑ignore и MTE. Включается -fsanitize=hwaddress, на машинах с MTE он может использовать железо. Документация LLVM и Android это подтверждают.

  • MemTagSanitizer/stack tagging. Есть sanitizer «memtag» и опции для тэгирования стека. В LLVM есть страница MemTagSanitizer, в GCC развивают -fsanitize=memtag-stack.

Свой жёсткий malloc: ретэг на free, паддинги по 16, красные зоны

Если вы контролируете аллокатор или хотите защитить отдельный пул вручную, то минимальная дисциплина такая: один тег на объект при alloc, агрессивный ретэг на free, паддинг до 16 байт и расположение подструктур на границах гранул. Пример простого обёртчика с безопасной ретэггацией и красной зоной:

// build: clang -std=c11 -O2 -fno-omit-frame-pointer -target aarch64-linux-gnu -march=armv8.5-a+memtag tagged_alloc.c
#include <arm_acle.h>
#include <stdalign.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <unistd.h>

typedef struct {
    size_t usable;
    void*  raw;   // адрес без логического тега, для munmap
} header_t;

static inline size_t round_up16(size_t n) { return (n + 15) & ~((size_t)15); }

// Выделяем память через mmap под заголовок+данные с PROT_MTE
static void* mte_alloc_raw(size_t bytes_with_hdr) {
    size_t ps = (size_t)sysconf(_SC_PAGESIZE);
    size_t total = ((bytes_with_hdr + ps - 1)/ps)*ps;
    void* raw = mmap(NULL, total, PROT_READ|PROT_WRITE|PROT_MTE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
    if (raw == MAP_FAILED) return NULL;
    return raw;
}

void* tagged_alloc(size_t n) {
    size_t user = round_up16(n);
    size_t pad  = 16; // красная зона после объекта
    size_t need = sizeof(header_t) + user + pad;
    void* raw = mte_alloc_raw(need);
    if (!raw) return NULL;

    // Заголовок делаем нетэгированным указателем.
    header_t* h = (header_t*)raw;
    h->usable = user;
    h->raw    = raw;

    // Данные начинаются после заголовка, выровненного до 16
    uintptr_t base = ((uintptr_t)raw + sizeof(header_t) + 15) & ~((uintptr_t)15);
    void* data = (void*)base;

    // Выбираем не-нулевой тег для объекта и ставим его на все гранулы user+pad
    uint16_t exclude0 = 0x1;
    void* tagged = __arm_mte_create_random_tag(data, exclude0);
    size_t total = user + pad;
#if defined(__ARM_FEATURE_MOPS) && defined(__ARM_FEATURE_MEMORY_TAGGING)
    __arm_mops_memset_tag(tagged, 0, total);
#else
    uintptr_t start = (uintptr_t)tagged & ~((uintptr_t)0xF);
    uintptr_t end   = start + round_up16(total);
    for (uintptr_t p = start; p < end; p += 16) __arm_mte_set_tag((void*)p);
#endif
    return tagged;
}

void tagged_free(void* tagged_ptr) {
    if (!tagged_ptr) return;

    // Ретэг перед освобождением: «ломаем» старые дэнглинги
    void* retagged = __arm_mte_create_random_tag(tagged_ptr, 0);
#if defined(__ARM_FEATURE_MEMORY_TAGGING)
    __arm_mte_set_tag(retagged); // достаточно сбить первую гранулу, аллокатор всё равно сейчас вернёт память ядру
#endif

    // Находим заголовок. У него top-byte = 0
    uintptr_t uptr = (uintptr_t)tagged_ptr & ~(((uintptr_t)0xFF) << 56);
    header_t* h = (header_t*)((uptr - sizeof(header_t)) & ~((uintptr_t)0xF));
    // munmap по raw
    // Здесь нужен реальный размер маппинга, в проде храните его в заголовке, упрощаем:
    size_t ps = (size_t)sysconf(_SC_PAGESIZE);
    munmap(h->raw, ps); // демонстрационно
}

Ретэг на free даёт отказоустойчивость к use‑after‑free. Старый указатель логически «не подходит» к памяти при следующем доступе и падает.

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

PROT_MTE обязателен для диапазона, иначе тег‑доступы не работают.

Как выбрать и что измерять

Базовые варианты:

  • Разработка и CI — sync. Точные адреса, понятные отчёты, дороже по времени. Ядро шлёт SIGSEGV с SEGV_MTESERR и адресом.

  • Прод — asymmetric или async. На типичных рабочих нагрузках это около 1–2% к цене, при этом вы всё ещё убираете класс багов из эксплуатации.

Сырой sync может замедлять тяжёлые memset на десятки процентов. Это ожидаемо. В приложениях вклад меньше за счёт того, что не весь код — сплошные записи.

Ограничения

Важно держать в голове три вещи.

Первое. Гранула 16 байт. Переполнения внутри гранулы не детектятся. Алгоритмически это лечится выравниванием полей структур и паддингами. Второе. Тегов всего 16. Если злоумышленник может вытаскивать теги сайд‑чаннелом, он может подбирать валидные комбинации. Современные работы показывают, что через спекулятивные гаджеты можно утекать теги и подрывать вероятность защиты. Это не отменяет пользы MTE, но требует совмещения с другими приёмами и рандомизацией.

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

Сценарии внедрения

Сценарий 1. Вы ничего не трогаете в коде, но хотите быстрый выигрыш на современных ARM‑серверах или ноутбуках. Собираете обычный релиз, запускаете сервис с GLIBC_TUNABLES=glibc.mem.tagging=5. В инфраструктуре включаете preferred режимы на CPU, чтобы ядро могло выбрать более строгий вариант при том же бюджете. Если система поддерживает, kernel‑docs описывают «preferred mode» на CPU.

Сценарий 2. Компонент с собственной ареной/пулом. Оборачиваете mmap‑пулы в PROT_MTE, добавляете tag_range() на alloc и ретэг на free. Выравниваете буферы по 16. По возможности добавляете красные зоны по 16 байт с каждой стороны.

Сценарий 3. Android/мобильный прод. Следуем гайдлайнам платформы: включаем asymmetric там, где доступно, либо async. Тестируем совместимость. Учитываем, что системные приложения уже могут жить с MTE, но это не означает, что каждый сторонний апповый аллокатор корректно тэгирует память.

Сценарий 4. Инструментирование. Включаем HWASan или memtag‑stack в CI, а в релизах — только heap‑тэгирование через glibc tunable. Это даёт хороший баланс.

Вывод

MTE это непанцея, но это максимально практичная жёсткая память для C на ARM. Включается с умеренной ценой и хорошо ловит то, что раньше уезжало в эксплуатацию: UAF, межобъектные выходы за границы. На Linux путь понятный: prctl, PROT_MTE, ACLE‑intrinsics, glibc‑tunable. На Android — выбор режима под профиль нагрузки.


ARM MTE не решает всех проблем, но помогает значительно повысить надёжность работы программ на C за счёт аппаратной проверки корректности использования памяти. Однако само понимание того, как устроена память и какие приёмы позволяют писать более безопасный код, требует системного изучения языка.

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

Кроме того, приглашаем вас ознакомиться с отзывами по курсу «Программист С». Это позволит понять, как обучение проходит на практике и какие впечатления оставляет у участников.

Комментарии (0)