당신의 스마트폰은 프로덕션 환경에서 memory corruption을 감지할 수 있다. CI에서 실행하는 전체 인스트루먼테이션으로도, 모든 할당에 대해서도 아니다. 하지만 당신의 주머니 속 하드웨어는 이미 수 년 전부터 필요한 프리미티브를 탑재해 출시되고 있으며, 점점 더 많은 프로덕션 앱이 조용히 이를 켜고 있다.

요약하면: AddressSanitizer는 프로덕션에 비용이 너무 높다. ARM Memory Tagging Extension은 그렇지 않다. 아직 MTE를 사용할 수 없다면, GWP-ASan은 거의 오버헤드 없이 확률적 커버리지를 제공한다. 이들이 함께 예전에는 낙담스러운 답변밖에 없던 질문에 답을 내놓는다.

memory corruption은 출하하는 버그다

use-after-free, buffer overflow, heap corruption은 모든 테스트 스위트를 통과하는 버그다. 네이티브 코드에 살고, 재현성이 낮으며, 실제 실수와는 먼 곳에서 크래시가 나는 경우가 많다. 사용자가 크래시를 보고할 때쯤이면 원래 손상은 이미 발생했고, 유일한 증거는 망가진 스택 트레이스나 무작위로 보이는 주소에서의 SIGSEGV뿐이다.

전통적인 도구는 이를 일찍 잡는다. Valgrind는 정확하지만 느리다. ASan은 더 빠르지만 여전히 약 2~3배의 메모리 오버헤드와 2배의 CPU 오버헤드를 추가한다. 퍼징이나 유닛 테스트에는 괜찮다. Instagram을 실행하는 배터리 구동 기기에는 괜찮지 않다.

프로덕션은 역사적으로 사각지대였다. 버그를 로컬에서 재현하거나 추측하는 수밖에 없었다.

ARM MTE가 하드웨어에서 메모리에 태그를 다는 방식

ARMv8.5-A에 도입된 ARM Memory Tagging Extension은 CPU가 모든 memory access 시 포인터 유효성을 저렴하게 검사할 수 있게 한다. 메모리의 16바이트 입자마다 4비트 태그를 할당하고, 포인터의 최상위 바이트에 일치하는 태그를 저장하는 방식으로 작동한다.

모든 load와 store에서 CPU는 포인터 태그와 메모리 태그를 비교한다. 다르면 하드웨어가 폴트를 발생시킨다. 이는 소프트웨어 검사가 아니다. 메모리 서브시스템에서 일어나며, 오버헤드는 일반적으로 5% 미만이다.

64비트 포인터의 최상위 바이트는 하드웨어가 이미 무시하고 있었다. MTE는 이 비트들을 재사용한다. 0xb7f0_0000_1234_5678 같은 포인터는 태그 0xb7을 지닌다. 해당 주소의 메모리도 같은 태그를 가져야 하며, 그렇지 않으면 접근이 폴트한다.

MTE는 ARMv8.5-A 이상의 기기에서 사용 가능하다. Google Pixel 8 이상, iPhone 15 Pro 이상, 그리고 점점 늘어나는 중급 Android 기기가 포함된다. 보편적이지는 않지만 더 이상 이례적인 것도 아니다.

커널은 prctl 플래그를 통해 MTE를 노출한다. 애플리케이션은 동기 또는 비동기 검사를 요청할 수 있다. 동기 모드는 태그 불일치 시 즉시 폴트한다. 비동기 모드는 폴트를 큐잉하여 나중에 전달하므로 비용은 더 낮지만 탐지가 약간 지연된다.

GWP-ASan: 모든 것에 태그를 달 수 없을 때

MTE는 훌륭하지만, 호환되는 하드웨어와 코드베이스 전체에서 태그가 달린 할당이 필요하다. 이전 기기에 출하하거나, 대상이 제한된 보호만 원한다면 GWP-ASan이 현실적인 대안이다.

GWP-ASan은 Guarded Write Protection AddressSanitizer의 약자다. 이는 힙 할당의 극히 일부를 독이 든 redzone으로 둘러싼 가드 페이지에 배치하는 샘플링 할당자다. use-after-free나 buffer overflow이 그 redzone에 닿으면, 하드웨어 MMU가 즉시 폴트를 발생시킨다.

핵심 통찰은 확률이다. GWP-ASan은 1만 건당 1건의 할당을 가드할 수 있다. 버그가 있는 앱이 크래시하기 전에 메모리를 수천 번 손상시킨다는 점을 깨달을 때까지 이는 쓸모없어 보인다. 충분한 사용자 세션이 주어지면, 0.01%의 샘플링률조차 프로덕션에서 실제 버그를 잡아낸다.

Google은 수년간 Chrome과 Android 시스템 서비스에서 GWP-ASan을 운용해왔다. 테스트와 퍼징 모두를 빠져나간 use-after-free 버그를 수백 개 발견했다. 가드 경로를 통과하는 할당이 극히 일부이므로 CPU 오버헤드는 무시할 수 있다. 가드 풀은 작고 할당은 결국 재활용되므로 메모리 오버헤드도 제한된다.

아무도 말하고 싶어 하지 않는 트레이드오프

MTE와 GWP-ASan은 공짜가 아니다. 다만 쓸 만큼 충분히 저렴할 뿐이다.

MTE는 모든 포인터의 최상위 바이트를 필요로 하므로, 코드베이스를 -fsanitize=memtag-march=armv8.5-a+memtag로 컴파일해야 한다. 포인터를 마스킹하거나, 해싱하거나, 적절한 태깅 없이 JNI를 통해 전달하는 코드가 있다면 거짓 양성이 나타난다. Android NDK와 현대적인 libc 구현체는 이를 올바르게 처리하지만, 커스텀 할당자나 포인터 패킹 방식은 깨진다.

MTE는 명시적으로 태그를 단 힙 영역과 스택 영역 내의 버그만 잡는다. 태그가 달리지 않은 mmap 영역으로 뛰어가는 wild pointer는 감지되지 않는다. 없는 것보단 낫지만, 전체 커버리지는 아니다.

GWP-ASan은 반대 문제를 가진다. 우연히 가드 풀에 들어간 할당만 잡는다. 가드되지 않은 할당을 손상시키는 버그는 unnoticed로 남는다. 샘플링된 집합에 대해 할당과 해제 시 약간의 지연 비용도 지불하며, 크래시를 텔레메트리 파이프라인에서 우아하게 처리해야 한다.

어느 쪽도 CI 파이프라인의 ASan을 대체하지 않는다. 보완한다. ASan은 테스트 중 결정론적이고 고커버리지의 탐지를 제공한다. MTE와 GWP-ASan은 현장에서 안전망을 제공한다.

최신 Android 기기에서 MTE 활성화

Pixel 8 이상을 가지고 있다면 오늘 MTE를 테스트할 수 있다. Android NDK는 단일 컴파일러 플래그로 이를 지원한다.

먼저 기기가 MTE를 지원하는지 확인한다:

#include <sys/prctl.h>
#include <linux/prctl.h>
#include <sys/auxv.h>
#include <asm/hwcap.h>

bool mte_supported() {
    unsigned long hwcap2 = getauxval(AT_HWCAP2);
    return (hwcap2 & HWCAP2_MTE) != 0;
}

그런 다음 CMakeLists.txt에서 MTE를 활성화하여 네이티브 라이브러리를 빌드한다:

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=memtag")

프로덕션에서 일반적으로 원하는 동기 폴팅을 원한다면, 시작 시 요청한다:

#include <sys/prctl.h>

void enable_mte() {
    if (mte_supported()) {
        prctl(PR_SET_TAGGED_ADDR_CTRL,
              PR_TAGGED_ADDR_ENABLE | PR_MTE_TCF_SYNC | (0xfffe << PR_MTE_TAG_SHIFT),
              0, 0, 0);
    }
}

PR_MTE_TCF_SYNC 플래그는 커널에게 태그 불일치 시 즉시 폴트하도록 지시한다. 태그 마스크 0xfffe는 일부 시스템 코드가 여전히 사용하는 제로 태그를 제외한다.

프로덕션을 위한 GWP-ASan 설정

GWP-ASan은 특수한 하드웨어가 필요 없어 활성화가 더 쉽다. Android에서는 GWP-ASan 할당자 래퍼에 링크하거나, 더 새로운 API 레벨에서 시스템 할당자를 통해 활성화할 수 있다.

최소한의 통합을 위해 할당자 진입점을 래핑한다:

extern "C" void* malloc(size_t size) {
    if (__gwp_asan_sample()) {
        return __gwp_asan_guarded_malloc(size);
    }
    return __libc_malloc(size);
}

실제로는 Android GWP-ASan 런타임이나 LLVM compiler-rt 구현체를 사용할 것이다. 핵심 설정 항목은 샘플링률이다. 보수적으로 시작한다:

// 1만 건당 1건의 가드 할당
__gwp_asan_set_sample_rate(10000);

결과로 발생하는 크래시는 기존 텔레메트리를 통해 수집한다. GWP-ASan 크래시는 표준 SIGSEGV처럼 보이지만, 폴팅 주소는 가드 페이지에 위치한다. 크래시 리포터는 폴팅 주소가 GWP-ASan 풀 내에 있는지 확인하여 이를 감지할 수 있다.

실제로 해야 할 일

모바일에서 네이티브 코드를 출하한다면, 이미 CI와 퍼징에서 ASan을 실행하고 있어야 한다. 문제는 출하 후에 무슨 일이 일어나는가다.

지원하는 최소 기기에 ARMv8.5-A 칩이 포함된다면, 동기 모드에서 MTE를 활성화한다. 오버헤드는 사용자가 눈치채지 못할 정도로 낮으며, 얻게 되는 크래시는 무작위한 힙 손상 대신 정확한 태그 불일치 정보를 담고 있을 것이다.

이전 기기를 지원한다면, 보수적인 샘플링률로 GWP-ASan을 활성화한다. 모든 버그를 잡지는 못하지만, 다른 어떤 것도 잡지 못하는 버그를 잡는다. 수백만 세션에 걸쳐, 이는 이론이 아니다. 작년 Chrome이 이미지 디코더에서 use-after-free을 발견한 방식이기도 하다.

당신의 스마트폰 하드웨어는 이미 가장 두려워하는 메모리 안전성 버그를 잡을 수 있는 능력을 갖추고 있다. 유일한 문제는 켰는지 여부뿐이다.