ARM64 MTE(Memory Tagging Extension) — 하드웨어 메모리 태깅으로 KASAN을 대체하는 방법

KASAN이나 KFENCE 같은 소프트웨어 메모리 안전성 도구는 결국 소프트웨어다 — 섀도 메모리를 따로 관리하거나(KASAN), 확률적으로 일부 할당만 감시하거나(KFENCE), 어느 쪽이든 CPU 사이클을 써서 검사 로직을 흉내낸다. MTE(Memory Tagging Extension)는 이 검사를 아예 실리콘 레벨로 끌어올린다. ARMv8.5부터 하드웨어가 16바이트 메모리 단위마다 4비트 태그를 붙여두고, 포인터에 실린 태그와 실제 메모리의 태그가 다르면 CPU가 직접 예외를 낸다. 이 글에서는 MTE의 동작 원리와 커널 인터페이스를 실제 커널 소스(arch/arm64/kernel/mte.c 등) 기준으로 정리한다. 테스트 환경이 ARMv8.5 하드웨어가 아닌 x86 VM이라 직접 실행 검증은 하지 못했고, 커널 소스와 공식 문서를 교차 확인한 내용임을 먼저 밝혀둔다.

동작 원리 — 16바이트 단위 태그

메모리는 16바이트(MTE_GRANULE_SIZE) 단위로 나뉘고, 각 단위마다 4비트짜리 할당 태그(allocation tag)가 물리 메모리 어딘가에(전용 태그 저장 공간) 별도로 저장된다. 포인터 쪽에는 원래 쓰이지 않던 가상주소 상위 비트(59~56번 비트)에 논리 태그(logical tag)를 싣는다. 이 두 태그가 메모리 접근 시점에 일치하지 않으면 CPU가 태그 검사 실패(tag check fault)를 일으킨다. 커널이 메모리를 새로 할당할 때마다 태그를 무작위로 바꿔가며 붙이면, use-after-free나 buffer overflow로 옆 블록을 건드리는 순간 태그가 안 맞아 바로 걸리는 구조다.

커널이 태그를 다루는 방법 — IRG 명령어

새 태그를 무작위로 뽑는 것 자체가 전용 명령어(IRG, Insert Random tag into Generated address)로 하드웨어에 위임되어 있다.

static inline u8 mte_get_random_tag(void)
{
	void *addr;

	asm(__MTE_PREAMBLE "irg %0, %0"
		: "=r" (addr));

	return mte_get_ptr_tag(addr);
}

소프트웨어가 난수 생성기를 돌려 태그를 고르는 게 아니라, CPU가 직접 무작위 태그를 실어 새 포인터를 만들어준다. 이런 식으로 태그 생성·검사가 명령어 수준에서 처리되기 때문에 KASAN 대비 오버헤드가 훨씬 낮다.

유저스페이스에서 MTE 켜기 — PROT_MTE

유저 프로세스는 mmap()/mprotect()PROT_MTE 플래그를 줘서 특정 메모리 영역의 태그 검사를 켠다. 새로 매핑된 페이지의 태그는 0으로 시작하고, COW(Copy-On-Write) 시에도 보존된다. 다만 제약이 있다 — PROT_MTEMAP_ANONYMOUS와 tmpfs/memfd 같은 RAM 기반 매핑에만 쓸 수 있고 다른 파일 매핑에 주면 -EINVAL이 난다. 한 번 PROT_MTE로 매핑한 영역은 mprotect()로 다시 끌 수도 없다.

Tag Check Fault 모드 — Sync/Async/Asymmetric

태그가 안 맞을 때 커널이 어떻게 반응할지는 스레드 단위로 prctl(PR_SET_TAGGED_ADDR_CTRL, flags, 0, 0, 0)으로 고른다. KASAN의 sync/async 리포트 모드와 똑같은 트레이드오프(정확한 위치 vs 속도)가 여기도 그대로 있다.

모드플래그동작
Ignore(기본값)PR_MTE_TCF_NONE태그 검사 실패를 무시
SynchronousPR_MTE_TCF_SYNC접근 즉시 SIGSEGV(si_code=SEGV_MTESERR), 정확한 si_addr 제공, 그 메모리 접근 자체는 수행되지 않음
AsynchronousPR_MTE_TCF_ASYNC한 번 이상의 태그 검사 실패 이후 비동기로 SIGSEGV, si_addr=0(정확한 위치 불명)
AsymmetricSYNC+ASYNC 조합 시 선택 가능읽기는 동기, 쓰기는 비동기로 처리

Synchronous는 정확한 실패 위치를 즉시 알 수 있는 대신 매 접근마다 검사를 기다려야 해서 느리고, Asynchronous는 거의 공짜에 가까운 대신 “무언가 잘못됐다”는 것만 알려줄 뿐 정확히 어느 접근이었는지는 알 수 없다. 디버깅 중에는 Synchronous, 프로덕션에서 낮은 오버헤드로 상시 감시하려면 Asynchronous를 쓰는 식으로 KFENCE의 샘플링 철학과 비슷한 선택지를 제공한다.

커널 자신도 MTE를 쓴다

MTE는 유저스페이스 전용이 아니다. mte.c에는 커널 자체 메모리 접근에 태그 검사를 켜는 mte_enable_kernel_sync()/mte_enable_kernel_async()/mte_enable_kernel_asymm()이 따로 있다. 이건 KASAN의 하드웨어 태그 기반 백엔드(HW_TAGS 모드)가 실제로 이 인터페이스 위에서 동작하는 구조다 — 즉 ARMv8.5 하드웨어에서는 KASAN 자체가 MTE를 활용하는 얇은 레이어가 된다. CPU마다 선호하는 태그 검사 모드는 /sys/devices/system/cpu/cpu*/mte_tcf_preferred sysfs 속성으로 조회·설정할 수 있다.

주의사항

  • MTE는 ARMv8.5 이상 하드웨어와 CONFIG_ARM64_MTE가 필요하다. 지원 여부는 HWCAP2_MTE로 확인한다. 이 글에서 다룬 커널 소스는 실제 하드웨어 없이 소스 코드로만 검증했다는 점을 다시 한번 밝힌다.
  • MADV_DONTNEED/MADV_FREE로 페이지를 해제 예약하면 태그가 임의 시점에 0으로 초기화될 수 있다. 태그 값에 의존하는 로직을 짠다면 이 타이밍을 고려해야 한다.
  • 시그널 핸들러는 인터럽트된 컨텍스트의 PSTATE.TCO 값과 무관하게 항상 TCO=0(태그 검사 강제 활성)으로 진입하고, sigreturn() 시 원래 값으로 복원된다. 핸들러 안에서 의도치 않게 태그 검사에 걸릴 수 있다는 뜻이다.
  • 유저 애플리케이션이 사용할 수 있는 “만능(match-all)” 논리 태그는 없다. 모든 태그를 통과시키고 싶다면 애초에 PROT_MTE를 안 쓰거나 Ignore 모드를 써야 한다.

마무리

KFENCE가 “감시 대상을 확률적으로 줄여서” 오버헤드를 낮췄다면, MTE는 애초에 검사 자체를 하드웨어로 넘겨서 같은 문제를 다른 방향으로 풀었다. 두 접근 모두 “메모리 안전성 검사를 프로덕션에 상시 켜두고 싶다”는 같은 동기에서 나왔다는 점이 흥미롭다. 실리콘 차원의 지원이 늘어날수록 소프트웨어 도구들이 점점 이런 하드웨어 기능의 얇은 래퍼로 수렴해가는 흐름(KASAN의 HW_TAGS 모드가 이미 그렇듯)은 앞으로도 계속될 가능성이 크다.

참고

답글 남기기