Привет, Хабр!
Если вы держали в руках любой крупный 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 за счёт аппаратной проверки корректности использования памяти. Однако само понимание того, как устроена память и какие приёмы позволяют писать более безопасный код, требует системного изучения языка.
Для этого мы приглашаем вас на курс «Программист С». В рамках курса предусмотрены бесплатные открытые уроки, где можно познакомиться с ключевыми темами:
25 августа в 20:00 — «Обзор стандарта С23»
4 сентября в 20:00 — «Работа с I/O в Си. Изучаем подсистему ввод‑вывода»
15 сентября в 20:00 — «Основы работы с памятью в языке Си»
Кроме того, приглашаем вас ознакомиться с отзывами по курсу «Программист С». Это позволит понять, как обучение проходит на практике и какие впечатления оставляет у участников.