Привет, Хабр!
Если вы держали в руках любой крупный 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 — «Основы работы с памятью в языке Си»
Кроме того, приглашаем вас ознакомиться с отзывами по курсу «Программист С». Это позволит понять, как обучение проходит на практике и какие впечатления оставляет у участников.
ссылка на оригинал статьи https://habr.com/ru/articles/939340/
Добавить комментарий