Rust와 C/C++에서 메모리 안전성과 관련된 CVE를 어떻게 다르게 다루는지, 라이브러리 API 오용과 soundness 버그의 차이를 중심으로 설명합니다.
CVE는 소프트웨어의 보안 취약점을 분류하고 보고하는 데 사용되는 데이터베이스입니다. 보고될 수 있는 취약점에는 여러 종류가 있습니다. 그중 일부는 단순히 프로그램 로직의 버그 때문에 발생합니다(Cargo에서 최근 보고된 CVE처럼). 하지만 가장 골치 아픈 것들 중 일부는 메모리 비안전성 때문에 발생하며, 이는 쉽게 익스플로잇으로 이어질 수 있습니다. 이 글에서는 후자의 종류의 CVE, 특히 라이브러리에서 그것이 어떻게 보고되는지, 그리고 Rust와 C 또는 C++에서 그것이 어떻게 다른지에 초점을 맞추고 싶습니다.
가끔 온라인에서 Rust와 C/C++ 소프트웨어의 CVE 개수를 비교하는 사람들을 봅니다. 이런 비교에는 대개 Rust가 진짜로 메모리 안전하지 않다거나, CVE가 여전히 존재할 수 있다면 도입할 가치가 없다는 주장도 따라옵니다. 그리고 Rust를 C나 C++에 익숙한 프로그래머들에게 가르칠 때도 비슷한 관점을 종종 보게 됩니다.
물론 누구나 이런 비교를 할 자유가 있고, 그에 기반해 자신만의 결론을 내릴 수도 있습니다. 하지만 메모리 안전성과 관련된 잠재적 취약점을 Rust와 C/C++에서 다루는 방식에는 중요한 차이가 있다고 생각합니다. 특히 Rust가 어떻게 동작하는지 모르면 이 차이는 처음에는 분명하지 않을 수 있습니다. 이 글에서 그것을 설명해 보겠습니다.
하지만 먼저 분명히 해야 할 점이 있습니다. Rust에서도 메모리 비안전성 버그와 정의되지 않은 동작을 일으키는 것은 얼마든지 가능합니다. 대다수의 경우1에는 이런 일이 일어나려면 unsafe 키워드가 필요하지만, Rust 프로그램에서는 UB가 전혀 발생할 수 없다고 주장하는 사람은 단순히 틀렸습니다. Rust에서 일반적인 취약점(즉 메모리 비안전성과 무관한 취약점)을 만드는 것도 완전히 가능합니다. 예를 들어 관리자 대시보드에 관리자만 접근할 수 있도록 하는 검사를 빼먹는 일은 결국 어느 언어에서나 일어날 수 있습니다.
그럼에도 Rust와 C 또는 C++의 잠재적 취약점 사이에는 매우 다른 점이 하나 있습니다. 이것은 Rust가 실제로 현실에서 C나 C++보다 훨씬 더 메모리 안전한 핵심 이유와 관련이 있습니다. 이를 C로 작성된 curl 네트워킹 라이브러리를 통해 보여드리겠습니다.
(lib)curl은 세계에서 가장 널리 사용되고 가장 잘 관리되는 오픈 소스 라이브러리 중 하나입니다. 주 개발자인 Daniel Stenberg는 우리 시대의 가장 왕성한 오픈 소스 유지관리자 중 한 명이며, 많은 다른 사람들과 함께 지난 30년 동안 이 라이브러리를 꾸준히 개선해 왔습니다. 최근 LLM들이 찾아낸 CVE의 쇄도를 감당해야 했음에도, 그와 협력자들은 curl을 잠재적 익스플로잇과 취약점으로부터 안전하게 유지하는 데 매우 훌륭한 일을 하고 있으며, curl이 매우 견고한 소프트웨어라는 점에 자부심을 갖고 있습니다.
그렇다면 정말 그런지 시험해 봅시다. libcurl의 문서를 열어보고, 인자를 받는 함수 중 가장 먼저 눈에 띈 curl_getenv를 골랐습니다. 이것은 서로 다른 운영체제에서 환경 변수 값을 가져오기 위한 이식성 있는 추상화를 제공하는 간단한 함수로 보입니다. curl은 안전하고 견고해야 하니, 분명 이 함수에는 UB나 메모리 비안전성이 없겠지요? 그렇다면 다음 C 프로그램은 어떨까요?
#include <curl/curl.h>
int main(void) {
curl_getenv(NULL);
}
이 5줄짜리 C 프로그램은 더없이 단순합니다. 그냥 curl_getenv 함수에 NULL 포인터 인자를 넘겨 호출할 뿐이고, 경고 없이 컴파일됩니다. 그런데도 실행해 보면 세그폴트가 발생할 수 있고, 따라서 메모리 안전성 버그가 되고, 결국 잠재적 취약점/익스플로잇이 됩니다.
$ gcc test.c -otest -lcurl -Wall -Wextra
$ ./test
Segmentation fault (core dumped)
물론 이 프로그램은 인위적으로 단순하지만, 바로 그 점이 핵심입니다. 실제로는 이런 상황이 더 큰 프로그램에서도 실수로 아주 쉽게, 그리고 항상 일어날 수 있습니다.
흠. 그렇다면 curl은 사실 그렇게 안전하지 않은 걸까요? 이걸 curl의 취약점으로 신고해야 할까요?!
아니요, 당연히 아닙니다. 그건 어리석은 일입니다. 저도 알고 있고, 여러분도 알고 있습니다. 하지만 우리가 실제로 어떻게 그것을 아는지가 흥미로운 부분입니다.
매우 비슷한 프로그램이 이 함수를 curl_getenv("FOO")처럼 호출한다고 생각해 봅시다. 만약 그 프로그램도 여전히 세그폴트를 일으켜 잠재적 취약점을 포함한다면 어떨까요? 그런 일이 발생한다면 curl 유지관리자들은 분명 그것을 알고 싶어 할 것이고, 제가 신고하면 꽤 큰 문제로 여길 것이라고 확신합니다. 동시에, 첫 번째 프로그램을 curl의 취약점으로 신고한다면 그들은 분명 저를 타당하게 나무랄 것입니다. 그런데 이 두 프로그램의 차이는 아주 미미합니다.
그럼 무엇이 다른 걸까요? 실제로는 제 원래 예제 같은 UB는 흔히 “잘못된 사용법”2 때문에 발생한 것으로 간주되며, 제가 사용하는 라이브러리나 API의 문제가 아니라 제 애플리케이션 코드의 문제로 취급됩니다. 이는 주로 다음 두 가지 이유 때문입니다.
실제로 curl_getenv의 문서에는 NULL로 호출하는 것이 금지되어 있고 세그폴트로 이어질 수 있다는 말이 없습니다! 따라서 작성자들은 여러분이 라이브러리를 “올바르게” 사용할 것이라고 가정합니다(그게 정확히 무엇을 뜻하든 간에). 그리고 그렇지 않다면 발생한 취약점은 여러분 책임이 됩니다.
따라서 C와 C++에서는 대체로 이런 상황을 사용 중인 라이브러리에 대한 CVE로 보지 않습니다. 다시 말해, 우리는 라이브러리를 오용한 구체적 사례 에 대해서 CVE를 만들지, 오용될 수 있는 라이브러리 API가 존재한다는 사실 자체 에 대해서 CVE를 만들지는 않습니다.
그렇다면 위 상황을 C나 C++에서 다루는 방식과 Rust에서 다루는 방식의 결정적인 차이는 무엇일까요? hyper는 아마 Rust에서 가장 인기 있는 네트워킹/HTTP 라이브러리일 것이고, C의 libcurl과 비슷한 위치에 있습니다. hyper에 인자를 받는 비슷한 단순 함수가 있다고 상상해 봅시다. 그러면 저는 다음과 같은 Rust 프로그램을 작성할 수 있을 것입니다.
fn main() {
hyper::foo(None);
}
그리고 cargo run을 실행했더니 프로그램이 세그폴트를 냈다고 해봅시다. 이것은 hyper의 CVE일까요? 네, 물론입니다4!
이 프로그램에는 어떤 unsafe 블록도 없으므로, 메모리 버그가 발생했다면 그것은 hyper 라이브러리에 soundness 버그가 있기 때문일 수밖에 없습니다.
차이는 이렇습니다. Rust에서는 사용자 코드에서 unsafe를 사용하지 않고도 라이브러리를 어떤 식으로든 상상 가능한 방식으로 사용했을 때 메모리 버그가 발생할 수 있다면, 그것은 언제나 사용자 코드가 아니라 라이브러리의 버그입니다. 그래서 우리는 그런 API를 unsound 하다고 부르거나, 안전한 Rust 안에서 메모리 안전성 측면으로 잘못 사용할 수 있는 방법이 존재하기 때문에 soundness hole 이 있다고 말합니다.
즉, 실제로 그런 식으로 작성된 프로그램을 아직 현장에서 발견하지 못했더라도, 안전한 라이브러리 API를 메모리 버그를 일으킬 수 있는 방식으로 사용하는 것이 가능하다면 우리는 CVE를 만듭니다. 이는 Rust에서 보고되는 일부 CVE가 C나 C++의 CVE보다 훨씬 더 “엄격하다”는 뜻이며, 어떤 사람들은 이를 “공정하지 않다”고 느끼기도 합니다.
같은 논리를 C에 적용한다면, curl_getenv는 메모리 버그를 일으키는 방식으로 사용할 수 있으므로 curl의 CVE로 표시되어야 할 것입니다. 하지만 물론 C에서는 이것이 별로 말이 되지 않습니다. 안전한 C와 unsafe C라는 개념이 없기 때문입니다(혹은 모든 C 코드가 암묵적으로 unsafe라고 보는 편이 맞습니다). 그래서 앞서 이 CVE를 신고하는 것은 어리석은 일이라고 말한 것입니다.
메모리 안전성 문제와 관련해 “내가 이 함수를 올바르게 사용하고 있는가?”라는 질문은 C나 C++에서는 종종 답하기 어렵지만, Rust에서는 매우 단순합니다.
unsafe로 표시되어 있지 않다면, 답은 그냥 YES입니다. 그것을 잘못 사용하는 것은 불가능합니다.unsafe라면, 저는 호출부를 unsafe 블록으로 표시해야 하고, 그러면 코드 리뷰 중이든 코드베이스 전체에서든 이 지점이 잠재적으로 위험하다는 사실이 즉시 분명해집니다. 이 경우(보통은 매우 드문 경우)에는 다시 C나 C++ 수준으로 돌아갑니다.이 답변의 첫 번째 부분이야말로 Rust의 메모리 안전성이 실제로 확장 가능하게 만드는 요소입니다. 여러분의 코드에서 unsafe를 사용하지 않고(운영체제나 락프리 자료구조 같은 것을 작성하는 경우가 아니라면 대부분의 상황에서 필요하지 않습니다), 컴파일러 버그도 만나지 않는다면, 메모리 비안전성의 잠재적 원인이 여러분 책임이 아니라는 것을 알 수 있습니다. 어떤 라이브러리가 unsafe 인터페이스를 노출하지 않는다면, 그 라이브러리가 내부적으로 unsafe를 사용하고 거기에 버그가 있는 경우를 제외하면, 여러분은 그 라이브러리를 메모리 버그를 일으키는 방식으로 사용할 수 없습니다. 그리고 그런 일이 벌어지더라도, 버그는 그 라이브러리 내부에서 수정되며, 그러면 그 라이브러리의 모든 사용자는 다시 자동으로 메모리 버그로부터 안전해집니다.
이것이 Rust와 C 또는 C++의 차이입니다. curl 개발자들이 완벽하게 안전하고 견고한 C 라이브러리를 만들기 위해 훌륭한 작업을 하고 있다고 해도, 그것을 사용하는 수백만 개의 다른 C 프로그램들은 여전히 아주 쉽게, 단지 “잘못 쥐는 것만으로도” 메모리 비안전성을 도입할 수 있으며, curl 개발자들은 그것을 막을 방법이 없습니다.
여기서는 curl을 예로 들었지만, 같은 이야기는 사실상 거의 모든 C나 C++ 라이브러리, 그 두 언어의 표준 라이브러리, 그리고 일반적으로 다른 메모리 비안전 언어들에도 적용됩니다. 원래는 더 많은 예제를 보여주고 싶었지만, 결국 모두 같은 이야기라는 것을 깨달았고, 그래서 하나의 curl 함수만 남겼습니다. 이 예제가 차이를 잘 보여준다고 생각하기 때문입니다.
이 블로그 글에서 설명한 내용이 어떤 면에서든 획기적인 것은 아니며, Rust가 어떻게 동작하는지 아는 대부분의 사람들에게는 어느 정도 보편적으로 이해되는 내용이라고 생각합니다. 하지만 이 주제를 다룬 블로그 글은 본 기억이 별로 없고, 저는 이것을 몇몇 사람들에게 반복해서 설명하고 있었기 때문에, 다음에 이 논쟁이 또 벌어졌을 때 그냥 링크를 보낼 수 있도록 제 생각을 정리해 두고 싶었습니다.
위에서 설명한 내용이 Rust와 C 또는 C++의 코드 줄당 CVE 개수를 단순 비교하는 것이 얼마나 여러 면에서 오해를 부를 수 있는지 보여주었기를 바랍니다. 그리고 Rust와 다른 시스템 프로그래밍 언어의 메모리 안전성을 비교할 때 우리는 이 점을 반드시 고려해야 합니다.
Rust나 C/C++에서 CVE가 어떻게 작동하는지에 대해 다른 생각이 있다면 Reddit에서 알려 주세요.
본질적으로 compiler bugs의 경우만 예외입니다.↩
어떤 사람은 심지어 “skill issue”라고 말할지도 모르겠습니다
↩
C++에서는 타입 시스템이 불변식을 설명하기 위한 훨씬 더 많은 편의 기능을 제공하지만, 동시에 실수로 UB를 몰래 끼워 넣을 수 있는 다른 방법도 많이 제공하므로 최종 결과는 비슷합니다.↩
다시 말해, 제 RAM 모듈의 하드웨어 문제나 컴파일러 버그가 아니라는 전제하에서입니다 :)↩