Rust로 rewrite하기에는 너무 크고, 버퍼 오버플로우에 노출된 채로 두기에는 너무 중요한 C codebase가 있다고 하자. CHERI capability 하드웨어는 CPU 수준에서 메모리 안전성 위반을 잡아낼 수 있지만, 인터넷에서는 CHERI 포인터가 128비트이고 기존 코드가 64비트를 가정한다고 계속해서 말한다.
다행히 CHERI는 모두 아니면 없음(all-or-nothing) 방식의 마이그레이션을 강요하지 않는다. 하이브리드 ABI를 사용하면 최소한의 변경만으로 기존 C 코드를 컴파일하고, 실제 CHERI 하드웨어에서 실행하며, 보안이 가장 중요한 부분부터 점진적으로 강화할 수 있다. 첫 번째 보호된 포인터를 얻기 위해 백만 줄의 코드를 모두 이식할 필요는 없다.
왜 이제 점진적 이식이 가능한가
초기 capability 아키텍처는 타협을 허용하지 않았다. iAPX 432는 모든 포인터가 capability여야 했다. 그것만이 전부였다. 이는 기존의 모든 컴파일러와 운영 체제를 망가뜨렸고, 프로젝트는 실패했다.
CHERI는 그 실패로부터 배웠다. 세 가지 컴파일 모드를 지원한다: baseline(레거시 정수 포인터만), hybrid(정수 포인터와 capability 포인터가 공존), purecap(모든 포인터가 capability). 하이브리드 ABI는 그 다리 역할을 한다. 기존 코드를 대부분 그대로 유지하면서, capability가 가장 큰 가치를 더하는 부분에 선택적으로 도입할 수 있게 해준다.
하이브리드 모드에서 void*는 여전히 64비트다. capability 포인터는 별도의 타입인 void* __capability이며, 명시적으로 선택해야 한다. 구조체, 연결 리스트, 해시 테이블은 요청하지 않는 한 크기가 변하지 않는다. 이것은 호환성 레이어가 아니다. 하드웨어와 컴파일러가 지원하는 일급 ABI다.
CHERI 하이브리드로 컴파일할 때 실제로 깨지는 것
첫 번째 단계는 CHERI 툴체인으로 코드를 컴파일해 보고 무엇이 터지는지 보는 것이다. 대부분의 잘 짜인 C 코드는 변경 없이 컴파일된다. 문제는 CHERI가 불법으로 만드는 소수의 패턴 주변에 집중되는데, 이것이 바로 핵심이다.
포인터를 정수로 캐스팅. 포인터를 uintptr_t로 캐스팅하고 몇 비트를 마스킹한 뒤 다시 캐스팅하는 코드는 실패할 것이다. CHERI에서 uintptr_t는 일반 정수가 아닌 capability 타입이다. capability에 대한 비트 연산은 태그 비트를 제거하고, 그 결과 값은 역참조 시 트랩된다.
포인터 크기에 대한 가정. sizeof(void*) == 8을 하드코딩하거나 포인터를 8바이트 값으로 직렬화하는 코드는 purecap 모드에서 깨진다. 하이브리드 모드에서는 대부분 작동하지만, 같은 구조체 내에서 capability 포인터와 정수 포인터를 섞는 순간 문제가 된다.
관련 없는 객체 간의 포인터 연산. C 프로그래머는 때때로 두 임의의 포인터 사이의 거리를 계산하거나, 서로 다른 할당에서 나온 포인터를 비교한다. CHERI capability는 경계 메타데이터를 포함하며, 서로 다른 할당에서 나온 capability를 빼는 것은 정의되지 않은 동작이다. 컴파일러는 이를 거부하거나 하드웨어가 트랩할 것이다.
인라인 어셈블리. 정수 레지스터에서 포인터를 이동시키는 모든 수작업 어셈블리는 업데이트가 필요하다. CHERI에는 전용 capability 레지스터가 있으며, 컴파일러는 어떤 레지스터를 건드리는지 알아야 한다.
CHERI LLVM 툴체인은 훌륭한 진단 메시지를 제공한다. 난해한 링커 오류가 아니라, “capability에서 정수로의 캐스트는 허용되지 않음”이나 “서로 다른 할당에서 나온 capability에 대한 연산”과 같은 명확한 메시지를 얻는다. 첫 번째 오류를 고치고, 다시 컴파일하고, 다음 오류를 추적하면 된다.
구체적인 예: 파서를 capability로 감싸기
신뢰할 수 없는 입력을 버퍼에 읽어들인 뒤 헤더를 파싱하는 네트워크 패킷 파서가 있다고 상상해 보자. 이것은 하드웨어 경계 검사의 이점을 얻기에 완벽한 종류의 코드다. 파서 자체를 rewrite하지 않고 감싸는 방법은 다음과 같다.
먼저, 파서를 하이브리드 모드로 컴파일한다. CHERI 툴체인은 CHERI 타겟이 추가된 표준 LLVM이다:
# 하이브리드 ABI로 단일 파일 컴파일
$ clang --target=riscv64-unknown-freebsd \
-march=rv64imafdcxcheri \
-mabi=lp64d \
-mno-relax \
-c parser.c -o parser.o
-mabi=lp64d 플래그는 정수 포인터를 64비트로 유지한다. 기존의 void*와 char* 타입은 변경되지 않는다. 파서는 그대로 컴파일된다.
이제 별도의 파일에 capability 인식 래퍼를 추가한다:
#include <cheriintrin.h>
#include <stddef.h>
#include <stdint.h>
// parser.c의 기존 파서
extern int parse_packet(const char *data, size_t len);
// capability 인식 진입점
int parse_packet_safe(const char * __capability data, size_t len) {
// capability를 정확히 버퍼 길이로 축소.
// 호출자가 더 큰 할당을 전달했더라도,
// 파서는 'len' 바이트를 넘어 읽을 수 없다.
const char * __capability narrowed =
cheri_bounds_set(data, len);
// 하이브리드 모드에서는 레거시 파서에 일반 포인터를 전달한다.
// 컴파일러가 여기서 capability-to-integer 변환을 삽입한다.
// capability가 64비트에 맞지 않으면(대형 capability 주소의 경우
// 맞지 않을 것이다), 경고나 오류가 발생한다.
//
// purecap 마이그레이션을 위해서는 parse_packet을
// capability 포인터를 받도록 수정하면 된다.
return parse_packet((const char *)narrowed, len);
}
이 예제에서 parse_packet은 여전히 레거시 정수 포인터를 사용한다. 래퍼는 호출자의 capability를 입력의 정확한 길이로 축소한 뒤 다시 변환한다. 축소 단계는 호출자가 실수로 64바이트를 전달하려 했는데 4KB 버퍼를 전달했더라도, 축소된 capability에 대해 하드웨어가 64바이트 경계를 강제한다는 것을 의미한다.
이것이 최종 상태는 아니다. 그것은 디딤돌이다. 오늘 API 경계에서 bounds enforcement를 얻고, 나중의 refactor에서 parse_packet을 purecap으로 이식할 수 있다.
하이브리드에서 purecap으로의 마이그레이션 경로
하이브리드 모드는 목적지가 아닌 출발점이다. 호환성을 제공하지만 capability의 전체 보안 이점은 주지 않는다. 장기적인 목표는 purecap이다. 모든 포인터가 경계를 포함하는 상태이다.
실제 마이그레이션은 다음과 같이 진행된다:
-
하이브리드 모드로 컴파일하고 빌드 오류를 고친다. 이는 보통 포인터-투-정수 캐스트와
sizeof(void*)가정을 고치는 것을 의미한다. 아직 데이터 구조를 변경하지 마라. 깨끗한 빌드를 만드는 것만이 목표다. -
높은 가치의 타겟을 식별한다. 네트워크 파서, 파일 포맷 디코더, 역직렬화 루틴이 capability enforcement의 최적 후보이다. 이들은 신뢰할 수 없는 입력을 처리하고, 대부분의 메모리 안전성 버그가 서식지는 곳이다.
-
그 타겟들을 축소된 capability로 감싼다. 신뢰 경계에서
cheri_bounds_set을 사용해 제한된 capability를 생성한다. 기존 코드에 전달한다. -
리프 함수를 purecap으로 이식한다. 포인터를 할당하고 반환하는 유틸리티 함수부터 시작한다. 서명을
__capability를 사용하도록 변경하고-mabi=purecap으로 컴파일한다. 호출 스택을 따라 위로 올라가며 작업한다. -
결국 전체 module을 purecap으로 전환한다. 컴파일 단위에 정수 포인터가 하나도 남지 않았을 때,
-mabi=purecap으로 컴파일하고 나머지 하이브리드 코드와 링크한다. CHERI 툴체인은 혼합 ABI 링킹을 지원한다.
대형 codebase에겐 주말 프로젝트는 아니다. 하지만 rewrite도 아니다. 가장 취약한 코드 경로를 며칠 만에 보호할 수 있고, 나머지는 필요할 때 점진적으로 이식하면 된다.
실제 트레이드오프
하이브리드 ABI에는 실제 비용이 있으며, 약속하기 전에 알아야 한다.
첫째, 혼합 ABI는 빌드를 복잡하게 만든다. 이제 같은 바이너리 내에 서로 다른 포인터 크기로 컴파일된 오브젝트 파일이 있다. 링커는 capability와 정수 재배치를 모두 처리해야 한다. CHERI 툴체인은 이를 지원하지만, 빌드 시스템은 아직 이를 알지 못할 가능성이 높다. 빌드 시스템에게 이를 가르쳐야 할 것이다.
둘째, 하이브리드 경계의 capability-to-integer 변환은 정보를 잃는다. capability를 축소한 뒤 일반 포인터로 캐스팅하면 경계가 사라진다. 기본 메모리는 여전히 시작 capability로 보호되지만, 레거시 함수는 hardware enforcement를 받지 않는다. 이것이 purecap이 목표인 이유이다: 호출 체인의 모든 포인터가 자신의 경계를 포함한다.
셋째, 디버깅 방식이 바뀐다. CHERI용 GDB는 capability 레지스터를 이해하고 경계 메타데이터를 출력할 수 있다. LLDB 지원도 개선되고 있다. 현재 디버깅 워크플로우가 원시 포인터 값을 검사하는 데 의존한다면, CHERI 레지스터 이름을 배워야 할 것이다.
하이브리드 모드의 성능 오버헤드는 대부분 정수 포인터를 사용하는 코드에서는 보통 무시할 수준이다. purecap 오버헤드는 대부분의 워크로드에서 한 자릿수 퍼센트이며, 포인터 중심의 데이터 구조에서는 10-15%까지 올라간다. 이는 ASAN과 같은 소프트웨어 완화책과 경쟁력이 있으며, ASAN과 달리 CHERI는 프로덕션에서 전속도로 실행된다.
오늘 QEMU로 시작할 수 있다
실험을 위해 Morello 보드가 필요한 것은 아니다. CHERI 프로젝트는 QEMU 지원과 LLVM 툴체인이 사전 설치된 Docker 이미지를 유지보수하고 있다.
# CHERI 툴체인 Docker 이미지 가져오기
$ docker run --rm -it ctsrd/cheri-sdk:latest
# 컨테이너 내부에서 RISC-V CHERI용으로 코드 컴파일
$ clang --target=riscv64-unknown-freebsd \
-march=rv64imafdcxcheri \
-mabi=lp64d \
-o myapp myapp.c
프로젝트를 하이브리드 모드로 컴파일하고 오류 개수를 세는 것부터 시작하라. 그 숫자가 앞으로의 작업량을 알려줄 것이다. 첫 시도에 깨끗한 빌드가 나오는 것은 드물지만 불가능하지는 않다. 수많은 포인터 캐스트가 있는 낡은 codebase에서는 보통 수백 개의 오류가 발생한다.
오류를 다음 순서로 고쳐라: 먼저 정수-투-포인터 캐스트, 그 다음 할당 간 포인터 연산, 마지막으로 인라인 어셈블리. 각 범주에는 기계적인 수정 방법이 있다. CHERI 프로젝트는 가장 일반적인 패턴에 대한 전후 예시를 담은 포팅 가이드를 발행하고 있다.
Capability 하드웨어는 이론적인 미래가 아니다. 그것은 작동하는 툴체인이고, 지원되는 ABI이며, codebase를 불태울 필요 없는 마이그레이션 경로이다. 한 파일, 한 함수, 한 축소된 capability부터 시작하라. 하드웨어가 나머지를 처리할 것이다.