buffer overflow은 스무 해 동안 CWE Top 25에 올라 있었다. 스택 카나리아, ASLR, DEP, 제어 흐름 무결성, 메모리 안전 언어 등이 있음에도 불구하고 중요한 코드에서 끊임없이 발생하고 있다. 이유는 단순하다. 이 모든 완화책은 소프트웨어에서 실행되며, 소프트웨어는 우회되거나 잘못 구성되거나 아예 사용되지 않을 수 있다.

하드웨어 자체가 배열의 끝을 넘어 읽기를 거부한다면 어떨까?

소프트웨어 완화책이 계속 실패하는 이유

buffer overflow은 할당된 메모리 영역의 경계를 넘어 프로그램이 쓰기를 시도할 때 발생한다. C 언어에서는 포인터가 단지 주소일 뿐이므로 이는 자명하다. 컴파일러와 CPU는 프로그래머가 자신의 행위를 알고 있다고 신뢰한다. 육십사 바이트를 할당하고 팔십 오프셋에 쓰려고 하면 CPU는 그대로 실행한다. CPU는 버퍼가 어디서 끝나는지 전혀 모른다.

소프트웨어 완화책은 사후적으로 이를 포착하려 한다. 스택 카나리아는 리턴 주소 앞에 알려진 값을 배치하고 복귀 전에 검증한다. ASLR은 메모리 배치를 무작위화하여 익스플로잇 구성을 어렵게 한다. 제어 흐름 무결성은 간접 분기가 도달할 수 있는 위치를 제한한다. 이는 도움이 되지만 확률적이거나 불완전하다. 충분한 시간과 정보 유출을 가진 완고한 공격자는 보통 이를 우회할 수 있다.

Rust와 같은 메모리 안전 언어는 언어 수준에서 문제를 해결하지만, 이미 운영 환경에서 실행되는 수십억 줄의 C와 C++ 코드를 돕지는 못한다. Linux 커널, OpenSSL, 또는 레거시 백엔드를 Rust로 다시 작성하는 일은 이번 십년간 일어나지 않을 것이다. 다시 작성하지 않고도 기존 코드를 보호하는 방어 수단이 필요하다.

CHERI가 포인터에 대해 실제로 수행하는 것

CHERI(Capability Hardware Enhanced RISC Instructions의 약자)는 케임브리지 대학교에서 개발되었으며 현재 Arm이 Morello 형태로 지원하는 ISA 확장이다. 이는 포인터의 본질을 바꾼다.

기존의 육십사 비트 시스템에서 포인터는 육십사 비트, 즉 단순한 주소이다. CHERI 시스템에서는 포인터가 백이십팔 비트 또는 이백오십육 비트의 capability가 된다. 추가 비트는 메타데이터를 저장한다. 할당의 기준 주소, 경계(얼마나 멀리 확장되는가), 그리고 권한(읽기, 쓰기, 실행)이다. 이 capability는 hardware integrity tag로 보호되어 metadata를 변조하면 무효화된다.

CHERI용으로 코드를 컴파일할 때, malloc은 단순히 주소를 반환하지 않는다. 요청한 크기와 정확히 일치하는 경계를 가진 capability를 반환한다. 해당 포인터를 증가시키면 하드웨어는 모든 접근을 그 경계에 대해 검사한다. 경계 밖에서 읽기나 쓰기를 시도하면 CPU는 동기 예외를 발생시킨다. 더 넓은 경계를 가진 capability를 위조할 방법은 없다. 하드웨어는 단순히 허용하지 않는다.

이것이 핵심적인 차이점이다. 소프트웨어 경계 검사는 메모리 연산 주위에 검사를 삽입하는데, 이는 컴파일러가 최적화하여 제거할 수도 있고 프로그래머가 잊을 수도 있으며 공격자가 우회할 수도 있다. 하드웨어 경계 검사는 모든 로드 및 스토어에서 무조건적으로 실행되며 프로그램에 추가 명령을 전혀 삽입하지 않는다.

명령어 수준에서 경계 강제가 작동하는 방식

C 언어에서 전형적인 buffer overflow은 다음과 같이 나타난다:

#include <string.h>

void vulnerable(char *input) {
    char buf[64];
    strcpy(buf, input);  // No bounds check. Classic overflow.
}

일반 아키텍처에서는 이것이 스택을 손상시킨다. CHERI에서는 buf가 원시 주소가 아니다. 경계가 정확히 육십사 바이트인 capability이다. strcpy가 육십사 바이트를 넘어 쓰려고 하면 하드웨어는 Capability Bounds Violation 예외를 발생시킨다. 프로그램은 정확히 잘못된 명령어에서 즉시 크래시한다.

동일한 보호는 힙 할당에도 적용된다:

#include <stdlib.h>
#include <cheri.h>  // CHERI intrinsics header

void heap_example(void) {
    char *buf = malloc(32);
    // buf is a capability with base=buf, length=32

    // This works fine
    buf[0] = 'A';
    buf[31] = 'Z';

    // This traps at the hardware level
    buf[32] = 'X';  // Capability bounds violation
}

크래시는 정밀하다. 정확한 명령어, 정확한 capability, 그리고 위반된 정확한 경계를 얻는다. 이를 디버깅하는 것은 삼 프레임 뒤의 손상된 스택 프레임을 추적하는 것보다 훨씬 쉽다.

CHERI는 포인터 위조도 방지한다. 정수를 포인터로 캐스트하여 임의의 메모리를 역참조할 수 없다. 하드웨어는 malloc, stack allocation, 또는 명시적인 capability 파생과 같은 적법한 명령어에 의해 생성된 capability만 인식한다. 정수에서 포인터로의 캐스트는 태그가 없는 값을 생성하여 사용 시 트랩한다.

호환성 문제는 실재한다

CHERI가 이토록 뛰어나다면 왜 모든 서버가 이를 실행하지 않는가? 포인터의 본질을 변경하는 것은 수십 년간의 C 코드가 의존하는 가정을 깨뜨리기 때문이다.

첫 번째 문제는 크기이다. capability는 원시 포인터보다 크다. Morello에서 capability는 백이십팔 비트에 하드웨어가 별도로 저장하는 일 비트 태그를 더한 것이다. 이는 포인터가 많은 데이터 구조에서 메모리 사용량을 증가시킨다. 포인터로 가득 찬 연결 리스트나 트리는 눈에 띄게 커진다. 많은 응용 프로그램에서 오버헤드는 한 자리수 퍼센트이지만, 포인터 추적 워크로드에서는 더 나빠질 수 있다.

두 번째 문제는 캐스팅이다. C 코드는 포인터를 uintptr_t로 캐스트하고 연산을 수행한 뒤 다시 캐스트한다. CHERI에서 uintptr_t는 사실 정수형이 아니라 capability형이다. 포인터 값에 대해 임의의 정수 연산을 할 수 있다고 가정하는 코드는 컴파일에 실패하거나 실행 시 트랩한다. 해결책은 보통 오프셋에 ptrdiff_t를 사용하고 cheri_address_set으로 적용하는 것이지만, 이는 소스 변경을 필요로 한다.

세 번째 문제는 생태계이다. CHERI 하드웨어는 희귀하다. Arm Morello 보드는 존재하지만 상용 서버는 아니다. CheriBSD와 CHERI 지원 Linux는 실제 소프트웨어를 실행하기에 충분히 성숙했지만, 대부분의 Linux 배포판은 CHERI 패키지를 제공하지 않는다. 컨테이너 레지스트리, CI 러너, 클라우드 공급자는 아직 이를 지원하지 않는다.

맞춤 실리콘 없이 오늘 시험해 볼 수 있다

CHERI를 실험하기 위해 Morello 보드가 필요하지 않다. CHERI 프로젝트는 QEMU 지원을 유지하고 있어 모든 Linux 또는 macOS 호스트에서 CHERI 사용자 공간을 실행할 수 있다.

CHERI 툴체인을 확보하고 QEMU에서 간단한 예제를 실행하는 방법은 다음과 같다:

# Clone the CHERI SDK setup
$ git clone https://github.com/CTSRD-CHERI/cheribuild.git
$ cd cheribuild

# Build the CheriBSD disk image and QEMU
$ ./cheribuild.py cheribsd-riscv64-purecap -d
$ ./cheribuild.py qemu -d

# Boot CheriBSD in QEMU
$ ./cheribuild.py run-cheribsd-riscv64

CheriBSD 내부에 들어가면 컴파일러는 CHERI 타겟 지원을 갖춘 clang이다. 기본적으로 경계 검사가 강제된 상태로 컴파일한다:

$ clang -o test test.c
$ ./test
# Buffer overflows trap immediately with a clear message

기존 소프트웨어를 테스트하고 싶다면 cheribuild는 FreeBSD ports에서 인기 있는 패키지를 CHERI 지원으로 컴파일할 수 있다. CHERI 팀은 OpenSSH, nginx, PostgreSQL, 그리고 FreeBSD 베이스 시스템의 상당 부분을 포팅했다. 많은 프로그램은 변경 없이 작동한다. 깨지는 프로그램은 보통 안전하지 않은 포인터 캐스트나 포인터 크기에 대한 가정 때문이다.

QEMU 없이 더 빠르게 시작하려면 케임브리지 대학교는 CHERI LLVM 툴체인이 사전 설치된 Docker 이미지도 공개한다. 완전한 OS를 부팅하지 않고도 CHERI 바이너리를 컴파일하고 생성된 capability 인식 어셈블리를 검사할 수 있다.

기존 코드베이스에 하드웨어 메모리 안전이 의미하는 바

CHERI는 안전한 코드 작성의 대안이 아니다. 이미 보유한 코드에 대한 안전망이다. 레거시 C를 실행하는 CHERI 시스템은 여전히 논리 버그, 경쟁 조건, use-after-free 오류를 가질 것이다. 하지만 리턴 주소를 덮어쓰거나, 인접한 힙 메타데이터를 손상시키거나, 할당 경계를 넘어 기밀을 유출하는 buffer overflow은 발생하지 않을 것이다.

발전은 이미 눈에 보인다. Arm은 CHERI에서 파생된 기능을 Memory Tagging Extension(MTE)에 통합했으며, 이는 현재 생산 환경의 Android 기기에 탑재되어 출하된다. MTE는 CHERI보다 거친 입자이다(개별 할당이 아닌 십육 바이트 입자에 태그를 부여하지만), 더 낮은 오버헤드로 동일한 많은 버그를 잡아낸다. 생태계가 성숙하면 풀 CHERI가 뒤따를 가능성이 높다.

신뢰할 수 없는 입력을 처리하는 C나 C++ 코드를 유지보수한다면, 물어야 할 질문은 하드웨어 메모리 안전이 도래할 것인가가 아니다. 도래했을 때 준비되어 있을 것인가이다. 포인터에서 정수로의 캐스트, 관련 없는 객체에 대한 포인터 연산, 그리고 sizeof(void*)에 대한 가정에 대해 코드 감사를 시작하라. 이것들은 CHERI 하에서 깨지는 패턴이며, 이를 수정하면 기존 하드웨어에서도 코드를 더 깔끔하게 만든다.

하드웨어가 마침내 거절할 준비가 되었다. 우리는 이를 허용해야 한다.