포인터 산술과 공용체 등을 유지한 채 기존 C/C++ 코드를 수정 없이 안전하게 실행하도록 설계된 메모리 안전 컴파일러 Fil-C를 소개하고, InvisiCaps 포인터, 동시 가비지 컬렉터, 시그널 처리, LFS 기반 메모리 안전 사용자 공간 구축 등 기능과 성능을 살펴본다.
LWN.net에 오신 것을 환영합니다
아래 구독자 전용 콘텐츠는 한 LWN 구독자의 배려로 공개되었습니다. 수천 명의 구독자가 리눅스와 자유 소프트웨어 커뮤니티의 최고의 뉴스를 위해 LWN에 의존하고 있습니다. 이 기사를 즐기셨다면 LWN 구독을 고려해 주세요. 방문해 주셔서 감사합니다!
Fil-C는 C와 C++의 메모리 안전한 구현으로, 포인터 산술, 공용체(union), 기타 메모리 안전 언어에서 흔히 문제로 지목되는 기능들을 그대로 유지한 채 C 코드를 수정 없이 안전하게 실행하는 것을 목표로 한다. “광적으로(fanatically) 호환성에 집착”한다는 프로젝트의 지향점은 기존 애플리케이션에 메모리 안전성을 레트로핏하는 용도로 매력적인 선택지로 만들고 있다. 프로젝트가 비교적 젊고 활동 중인 기여자가 한 명뿐임에도 불구하고, Fil-C는 일부 복잡한 프로그램에 약간의 수정이 필요하긴 하지만, 전체 메모리 안전한 리눅스 사용자 공간(Linux From Scratch 기반)을 컴파일할 수 있다. 또한 메모리 안전한 시그널 처리와 동시 가비지 컬렉터를 제공한다.
Fil-C는 Clang의 포크다. 런타임에는 LLVM 예외가 포함된 Apache v2.0 라이선스로 제공된다. 업스트림 컴파일러의 변경 사항은 가끔 병합되며, 현재 Fil-C는 2025년 7월의 20.1.8 버전을 기반으로 한다. 이 프로젝트는 Java, JavaScript를 포함한 여러 관리형 언어의 런타임 작업을 해온 Filip Pizlo의 개인적인 열정에서 시작되었다. 그는 프로젝트를 시작할 때조차 이게 가능한지 확신하지 못했다. 초기 구현은 다양한 안전 검사를 대거 삽입해야 했기 때문에 실행 속도가 사실상 사용할 수 없을 정도로 느렸다. 그 여파로 Fil-C는 느리다는 평판을 얻었다. 그러나 초기 구현이 실현 가능하다는 것이 입증된 이후, Pizlo는 많은 일반적인 경우를 최적화해, Fil-C가 생성한 코드는 Clang이 생성한 코드보다 몇 배 정도만 느리게 만들었다. 다만 정확한 느려짐의 정도는 벤치마크 대상 프로그램의 구조에 크게 의존한다.
신뢰할 수 있는 벤치마킹은 악명 높게 까다롭지만, 대략적인 감을 잡고자 Fil-C로 Bash 5.2.32를 컴파일해 셸로 사용해 보았다. Bash는 자체 코드보다 외부 프로그램 실행에 더 많은 시간을 쓰기 때문에 Fil-C에 거의 최적 사례에 속한다. 그럼에도 성능 차이가 느껴질 것이라 예상했지만, 그렇지 않았다. 적어도 일부 프로그램에 대해서는, Fil-C의 성능 오버헤드는 실제 사용에서 문제가 되지 않는 것으로 보인다.
각종 런타임 안전 검사를 지원하기 위해 Fil-C는 Clang과는 다른 내부 ABI를 사용한다. 그 결과, Fil-C로 컴파일한 오브젝트는 다른 컴파일러가 생성한 오브젝트와 올바르게 링크되지 않는다. 그러나 Fil-C는 소스 코드 수준에서 C와 C++을 완전히 구현하므로, 실무적으로는 모든 것을 Fil-C로 다시 컴파일하면 된다. 러스트 등과의 언어 간 링크는 현재 지원되지 않는다.
C를 메모리 안전하게 만드는 데 있어 가장 큰 과제는 당연히 포인터 처리다. 이는 특히 CHERI 호환성으로 가는 긴 여정이 보여준 것처럼, 많은 프로그램이 아키텍처에 따라 포인터가 32비트나 64비트일 것이라 가정한다는 점에서 복잡해진다. Fil-C는 2023년 프로젝트 시작 이후 포인터를 표현하는 여러 방식을 시도했다. Fil-C의 초기 포인터는 256비트였고, 스레드 안전하지 않았으며, use-after-free(UAF) 버그를 막지도 못했다. 현재 구현인 “InvisiCaps”는 포인터가 아키텍처의 자연 포인터 크기와 겉보기에는 일치하게 하면서(이를 위해 별도의 보조 정보를 다른 곳에 저장해야 하긴 한다) 동시성을 완전히 지원하고 UAF 버그를 잡아낸다. 대가로 일정한 런타임 오버헤드가 따른다.
Fil-C의 문서는 InvisiCaps를 소프트웨어로 구현한 CHERI에 비유한다. 포인터를 신뢰된 “capability” 조각과 비신뢰 “address” 조각으로 분리한다. Fil-C가 프로그램의 컴파일 방식을 통제하므로, 프로그램이 어떤 포인터의 capability에 직접 접근하지 못하게 보장할 수 있고, 따라서 런타임은 capability가 손상되지 않았음을 신뢰할 수 있다. 구현의 까다로운 부분은 이 두 정보를 프로그램 입장에서는 64비트처럼 보이는 공간에 어떻게 저장하느냐에 있다.
Fil-C가 힙에 객체를 할당할 때, 할당된 객체의 시작 전에 두 개의 메타데이터 워드를 추가한다. 하나는 객체의 크기를 기준으로 접근을 검사하는 데 쓰이는 상한(upper bound)이며, 다른 하나는 추가 포인터 메타데이터를 저장하는 데 쓰이는 “보조 워드(aux word)”다. 프로그램이 어떤 객체에 처음으로 포인터 값을 기록할 때, 런타임은 기록 대상 객체와 같은 크기의 새로운 보조 할당을 만들고, 그 보조 할당을 가리키는 실제 하드웨어 수준 포인터(즉, capability가 붙지 않은 포인터)를 그 객체의 보조 워드에 저장한다. 프로그램에는 보이지 않는 이 보조 할당은 저장되는 포인터에 대한 연관된 capability 정보를 저장하는 데 쓰이며(이후 그 객체에 저장되는 추가 포인터에도 재사용된다), 주소 값은 평소처럼 객체 안에 저장된다. 따라서 저장된 포인터 값을 직접 들여다보는 각종 C의 비트 조작 기법도 기대한 대로 동작한다.
이 접근법은 포인터를 포함하는 구조체가 결과적으로 두 배의 메모리를 사용하게 만들고, 포인터를 로드할 때마다 보조 워드를 통한 한 번의 간접 참조가 추가된다는 뜻이기도 하다. 실무적으로, 문서는 이 방식의 오버헤드로 인해 대부분의 프로그램이 약 네 배 정도 느려진다고 주장하지만, 그 수치도 프로그램이 포인터를 얼마나 많이 쓰는지에 따라 달라진다. 그럼에도 그는 성능 오버헤드를 시간이 지남에 따라 줄일 수 있으리라 기대되는 여러 최적화 아이디어를 가지고 있다.
이 방식의 한 가지 난점은 포인터에 대한 원자적 접근, 즉 _Atomic이나 volatile을 사용하는 경우다. 다행히 포인터 간접 참조를 한층 더 추가하면 해결된다. 프로그램이 포인터 값을 원자적으로 로드/스토어해야 할 때에는, 보조 할당이 capability 정보를 직접 담는 대신, capability와 포인터 값을 함께 저장하는 128비트 크기의 제3의 할당을 가리키게 한다. 플랫폼이 지원하면 128비트 원자 명령으로 그 할당을 갱신할 수 있고, 아니면 새 할당을 만들어 그 포인터를 원자적으로 교체하는 방식으로 처리한다.
보조 워드에 포인터 값이 저장되므로, Fil-C는 여기에 포인터 태깅을 적용해 추가 정보를 함께 담을 수 있다. 예컨대 함수, 스레드, mmap() 기반 할당처럼 특별 취급이 필요한 객체 유형을 표시한다. 또한 해제된 객체를 표시해, 그 객체에 대한 어떤 접근도 오류 메시지와 충돌(crash)로 이어지게 한다.
객체가 해제되면, 그 보조 워드는 해당 객체가 해제되었음을 표시하고, 이로써 보조 할당은 즉시 회수될 수 있다. 그러나 원래 객체 자체의 메모리는 즉시 반납할 수 없다. 그렇지 않으면 프로그램이 어떤 객체를 해제한 뒤 같은 위치에 새 객체를 할당해 UAF 버그를 은폐할 수 있기 때문이다. 대신 Fil-C는 가비지 컬렉터를 사용하여, 그 객체를 가리키는 모든 포인터가 사라졌을 때에만 백킹 메모리를 해제한다. Boehm-Demers-Weiser 가비지 컬렉터 같은 C용 다른 가비지 컬렉터와 달리, Fil-C는 보조 capability 정보를 이용해 살아 있는 객체를 정밀하게 추적할 수 있다.
Fil-C의 가비지 컬렉터는 병렬적(코어가 많을수록 수거가 빨라짐)이고 동시적(프로그램을 멈추지 않고 수거가 진행됨)이다. 기술적으로, 가비지 컬렉터는 스레드가 때때로 잠깐 멈춰 스택에 포인터가 어디 있는지 알려주기를 요구한다. 하지만 이는 오직 특별한 “안전 지점(safe point)”에서만 발생한다. 그 외에는 프로그램이 포인터를 로드하고 조작하는 데 컬렉터에 알릴 필요가 없다. 안전 지점은 동기화 장벽으로 쓰인다. 마킹이 끝난 시점 이후 모든 스레드가 최소 한 번씩 안전 지점을 통과하기 전까지는 컬렉터가 어떤 객체가 정말 가비지인지 확신할 수 없다. 이 동기화는 원자 명령으로 수행되므로, 실제로 스레드가 몇 개의 명령보다 오래 멈춰 있을 일은 없다.
예외는 fork()의 구현이다. 포크 과정에서 경쟁 상태를 막기 위해, 프로그램의 모든 스레드를 일시 정지시키는 데 가비지 컬렉터가 필요로 하는 안전 지점을 활용한다. Fil-C는 모든 역방향 제어 흐름 에지, 즉 코드가 루프에서 실행될 수 있는 지점마다 안전 지점을 삽입한다. 일반적인 경우 삽입된 코드는 플래그 레지스터를 로드해 가비지 컬렉터가 아무 작업도 요구하지 않았음을 확인하는 정도면 된다. 만약 컬렉터에 스레드에 대한 요청이 있다면, 스레드는 필요한 동기화를 수행하는 콜백을 실행한다.
Fil-C는 동일한 안전 지점 메커니즘을 시그널 처리에도 사용한다. 시그널 핸들러는 인터럽트된 스레드가 안전 지점에 도달했을 때만 실행된다. 그 결과 시그널 핸들러가 가비지 컬렉터의 동작을 방해하지 않고 메모리를 할당·해제할 수 있다. Fil-C의 malloc()는 시그널 안전하다.
Linux From Scratch(LFS)는 완전한 리눅스 사용자 공간을 직접 컴파일하는 과정을 다루는 튜토리얼이다. chroot() 환경에서 일반적인 리눅스 사용자 공간에 필요한 핵심 소프트웨어를 컴파일하고 설치하는 과정을 단계별로 안내한다. Pizlo는 Fil-C로 LFS 과정을 완주하여 메모리 안전한 버전을 만들어내는 데 성공했다. 다만 Fil-C의 자체 런타임, GNU C 라이브러리, 커널처럼 근본적인 구성 요소를 빌드하려면 여전히 Fil-C가 아닌 컴파일러가 필요하다. (Fil-C의 런타임은 시스템 콜을 수행하기 위해 일반 GNU C 라이브러리에 의존하지만, Fil-C가 컴파일한 프로그램은 Fil-C로 컴파일된 버전의 라이브러리를 사용한다.)
과정은 7장 말까지는 대체로 LFS와 동일하다. 그 시점 전까지는 chroot() 환경에서 동작하는 컴파일러를 얻기 위해 크로스 빌드 도구를 사용하는 단계로만 이뤄지기 때문이다. 한 가지 차이는 크로스 빌드 도구를 Fil-C와 충돌하지 않도록 다른 prefix로 구성해 빌드한다는 점이다. 그 시점이 되면 Fil-C를 빌드해 기존 컴파일러를 대부분 대체할 수 있다. 이후 LFS의 나머지 단계는 변경되지 않는다.
과정을 자동화하는 스크립트가 Fil-C Git 저장소에 포함되어 있으며, Beyond Linux From Scratch의 일부 단계도 포함되어 있어, 동작하는 그래픽 사용자 인터페이스와 Emacs 같은 좀 더 복잡한 애플리케이션 몇 가지까지 구성할 수 있다.
종합하면, Fil-C는 기존 C 프로그램을 메모리 안전하게 만드는 데 놀라울 만큼 완결성 높은 해법을 제공한다. 메모리 안전과 무관한 정의되지 않은 동작에 대해서는 아무것도 하지 못하지만, C 프로그램에서 가장 악성이고 방지하기 어려운 보안 취약점은 대개 메모리 안전하지 않은 동작을 악용한다. 초기의 성능 문제로 Fil-C를 검토했다가 거절했던 독자라면 재검토해 볼 만하다. 다만 프로젝트가 비교적 미성숙한 만큼, 안정성을 바란다면 다른 이들의 도전을 지켜본 뒤에 도입하는 편이 나을 수 있다. 그럼에도, 성능 하락이 exploitable한 취약점보다 덜 고통스러운 기존 애플리케이션이라면, Fil-C는 훌륭한 선택지다.