Rust와 C/C++의 메모리 안전성 CVE는 왜 다르게 집계되는가
(kobzol.github.io)- Rust와 C/C++의 CVE 숫자를 그대로 비교하면, 메모리 안전성 취약점을 “라이브러리 문제”로 보는 기준 차이를 놓치기 쉬움
- C/C++에서는 잘못된 API 호출로 UB나 세그폴트가 나도 대개 사용자 코드의 오용으로 처리되며, 가능성 자체를 전부 CVE로 등록하지 않음
libcurl의curl_getenv(NULL)호출은 경고 없이 빌드되고 실행 시 세그폴트가 날 수 있지만, 보통curl취약점으로 보지는 않음- Rust에서는 사용자 코드에
unsafe가 없는데 안전한 API 호출만으로 메모리 버그가 발생하면, 라이브러리의 soundness bug로 간주함 - 그래서 Rust의 일부 CVE는 C/C++보다 더 엄격한 기준으로 기록되며, 원시 CVE 수 비교만으로 메모리 안전성을 판단하기 어려움
CVE 숫자 비교가 흔들리는 이유
- CVE는 소프트웨어 보안 취약점을 분류하고 보고하는 데이터베이스임
- 취약점은 단순한 프로그램 로직 버그에서 생길 수도 있고, 익스플로잇으로 이어지기 쉬운 메모리 안전성 문제에서 비롯될 수도 있음
- Rust와 C/C++의 CVE 수를 비교하며 Rust가 “실제로는 메모리 안전하지 않다”거나 “도입할 가치가 없다”는 주장도 나옴
- 하지만 메모리 안전성과 관련된 잠재 취약점을 두 생태계가 다루는 방식에는 큰 차이가 있음
Rust에서도 취약점은 가능함
- Rust 프로그램도 UB와 메모리 안전성 버그를 일으킬 수 있음
- 대부분의 경우 이런 문제에는
unsafe키워드가 필요함 - Rust 프로그램이 UB를 전혀 겪을 수 없다는 주장은 틀림
- 메모리 안전성과 무관한 일반 취약점도 Rust에서 가능함
- 관리자 대시보드 접근 권한 검사를 빠뜨리는 문제는 어떤 언어에서도 생길 수 있음
C 라이브러리 예시: curl_getenv(NULL)
curl은 널리 쓰이고 잘 관리되는 C 기반 네트워킹 라이브러리임libcurl의curl_getenv는 여러 운영체제에서 환경 변수 값을 가져오는 이식성 있는 추상화 함수임- 다음 C 프로그램은
curl_getenv에NULL포인터를 넘김
#include <curl/curl.h>
int main(void) {
curl_getenv(NULL);
}
- 이 프로그램은
gcc test.c -otest -lcurl -Wall -Wextra로 경고 없이 컴파일될 수 있음 - 실행하면 세그폴트가 발생할 수 있고, 이는 메모리 안전성 버그이자 잠재 취약점으로 볼 수 있음
- 하지만 이런 예시는 보통
curl의 취약점으로 보고할 사안이 아님
C/C++에서는 오용 가능성만으로 CVE를 만들지 않음
curl_getenv(NULL)처럼 문제가 생기는 경우는 일반적으로 API의 잘못된 사용으로 간주됨- 결함의 위치도 라이브러리나 API가 아니라 애플리케이션 코드 쪽으로 봄
- 이런 관행에는 두 가지 이유가 있음
- C의 제한적인 타입 시스템으로는 API의 계약, 불변식, 사전조건, 사후조건을 정밀하게 표현하기 어려움
- 가능한 모든 잘못된 사용을 문서화하는 것도 실용적이지 않음
- 실제로
curl_getenv문서는NULL호출이 금지되며 세그폴트로 이어질 수 있다고 말하지 않음 - C/C++에서는 UB를 우연히 유발하기가 매우 쉬워, 모든 잠재적 취약 가능성을 CVE로 보고하면 대부분의 라이브러리가 막대한 수의 CVE에 휩쓸릴 수 있음
- 따라서 C/C++에서는 보통 “오용 가능한 API의 존재”가 아니라 특정 오용 사례를 중심으로 CVE를 만듦
Rust에서는 안전한 API의 책임 경계가 다름
- Rust에서
hyper::foo(None)같은 안전한 호출만으로 프로그램이 세그폴트난다고 가정하면, 이는hyper의 CVE가 될 수 있음 - 사용자 프로그램에
unsafe블록이 없는데 메모리 버그가 발생했다면, 해당 라이브러리에 soundness bug가 있어야 하기 때문임 - Rust에서는 안전한 라이브러리 API를 어떤 방식으로든 사용했을 때 메모리 버그가 날 수 있다면, 이를 사용자 코드가 아니라 라이브러리 버그로 봄
- 이런 API는 unsound하거나 soundness hole이 있다고 말함
- 실제 프로그램에서 아직 문제가 발견되지 않았더라도, 안전한 API 사용만으로 메모리 버그를 일으킬 수 있으면 CVE가 만들어질 수 있음
safe와 unsafe가 책임을 드러냄
- Rust에서는 “이 함수를 메모리 안전성 관점에서 올바르게 쓰고 있는가”에 대한 답이 C/C++보다 명확함
- 호출하는 함수가
unsafe로 표시되지 않았다면 안전하게 사용할 수 있어야 함 - 호출하는 함수가
unsafe라면 호출 지점에unsafe블록이 필요하고, 코드 리뷰와 코드베이스에서 위험 지점이 분명해짐
- 호출하는 함수가
- 이 구분이 Rust의 메모리 안전성을 실무적으로 확장 가능하게 만드는 요소임
- 사용자 코드가
unsafe를 쓰지 않고 컴파일러 버그도 없다면, 잠재적 메모리 안전성 원인을 사용자 코드 책임으로 보기 어려움 - 라이브러리가
unsafe인터페이스를 노출하지 않는다면, 사용자는 그 라이브러리를 메모리 버그를 일으키는 방식으로 사용할 수 없어야 함 - 라이브러리가 내부적으로
unsafe를 사용하다 버그를 내더라도, 수정은 라이브러리 안에서 이루어지고 사용자는 다시 메모리 버그로부터 안전해짐
원시 CVE 수만으로는 메모리 안전성을 비교하기 어려움
- 같은 논리를 C에 적용하면
curl_getenv도curl의 CVE로 표시해야 하지만, C에는 Rust의safe와unsafe같은 구분이 없음 - 사실상 모든 C 코드는 암묵적으로
unsafe에 가깝기 때문에, Rust식 기준을 그대로 적용하기 어렵음 - C/C++ 라이브러리 개발자가 안전하고 견고한 라이브러리를 만들더라도, 이를 사용하는 수많은 C 프로그램은 API를 잘못 다루는 방식으로 쉽게 메모리 안전성 문제를 만들 수 있음
- 이 차이는
curl뿐 아니라 거의 모든 C/C++ 라이브러리와 두 언어의 표준 라이브러리에도 적용됨 - Rust와 C/C++의 코드 라인당 CVE 수 같은 원시 숫자 비교는 메모리 안전성을 평가할 때 오해를 부를 수 있음
댓글과 토론
Lobste.rs 의견들
-
순진한 질문일 수 있지만, C/C++의 많은 문제가 정의되지 않은 동작에서 온다면 왜 그냥 정의해버리지 않는지 궁금함
- 표준에서 어떤 동작이 정의되지 않은 데는 최소 세 가지 이유가 있다고 봄
첫째, 이제는 아무도 신경 쓰지 않는 역사적 잔재라서 “그냥 정의”할 수 있는 것들이 있고, @fanf가 말했듯 작업이 진행 중임. 예를 들면 종료되지 않은 문자열 리터럴이 들어 있는 소스 파일은 C에서 실제로 정의되지 않은 동작임
둘째, 정의할 수는 있지만 성능 비용이 드는 것들이 있음. 대표적으로 부호 있는 정수 오버플로가 있는데, 단순히 순환하도록 정의하면 더는 정의되지 않은 동작이 아니지만, 컴파일러는 “절대 발생하지 않는다”는 가정에 기반한 최적화를 할 수 없게 됨. 위원회에 컴파일러 쪽 사람들이 많고 이들이 벤치마크에 집착하는 경향이 있어서 쉽게 고쳐지진 않을 것 같음. 그래도 변화가 전혀 없는 건 아니고, 예를 들어 P2723은 C++에서 초기화되지 않았을 모든 지역 변수를 암묵적으로 0으로 초기화하자고 제안함
셋째, 합리적으로 동작을 정의하기 어려운 것들이 있음. 좋은 예가 해제 후 사용(use-after-free)임. Fil-C 같은 무거운 런타임 capability 시스템을 모두에게 강제하거나, Rust식 수명 주석을 언어 전반에 추가하지 않는 한 해제 후 사용에서 나타날 수 있는 동작 범위를 어떻게 제한할 수 있을지 애매함. “해제 후 사용 시 그때 그 자리에 있는 메모리를 건드리거나 세그폴트/중단된다”고 명시할 수는 있겠지만, 아무에게도 도움이 안 됨. 여전히 위험하고 CVE도 똑같이 생기며, 그 이후 프로그램이 무엇을 할 수 있고 없는지 유의미하게 말할 수 없으니 이름만 다른 정의되지 않은 동작임
안타깝게도 세 번째 범주가 영향이 압도적으로 크기 때문에, 일부를 “이제 그냥 정의”하는 건 좋지만 전체 상황을 크게 바꾸지는 못함 - 이번 개정 라운드에서 C 위원회는 언어의 정의되지 않은 동작을 줄이고 있음. https://open-std.org/jtc1/sc22/wg14/www/wg14_document_log.htm 의 “slaying earthly demons” 문서를 보면 됨
아는 한 아직 라이브러리는 대부분 다루기 시작하지 않았지만, 크기 인자를 받는 함수들은 널 포인터에 대해 합리적으로 동작하도록 바뀌었음. 널 포인터에 0을 더하는 것을 허용하는 언어 변경과 관련이 있었기 때문임. 비슷하게 고칠 수 있는 함수가 많이 있지만,getenv()변경은 POSIX와 조율하는 편이 좋을 듯함 - 가장 흔히 반복되는 설명은 일부 동작을 정의하지 않아야 원래는 허용되지 않을 최적화가 가능하다는 것임. 하지만 대체로 자기합리화에 가깝다고 봄
그런 성능 이득은 거의 전부 지엽적이고 기껏해야 미미함.rm -rf /를 호출하지만 실제로는 절대 호출되지 않는 함수가 있고, 정의되지 않은 동작이 있는 함수 포인터 호출을 만들면, 컴파일러는 기술적으로 디스크를 지우는 그 함수를 무조건 호출하는 코드를 생성해도 허용됨. 결국 나쁜 명세 설계와 유산일 뿐임 - 일부 정의되지 않은 동작은 시간이 지나며 정의되었지만, 많은 것들은 최적화 때문에 남아 있어야 함. 잘 알려진 예로
for (int ii = 0; ii < something; ii++)는 부호 있는 정수 오버플로가 정의되지 않았다는 점에 의존해something == INT_MAX가능성을 무시할 수 있고, 이를 통해 여러 루프 변환이 가능해짐
Rust에서는 동등한 기능이 안전 함수와unsafe함수로 나뉨. 안전 함수는 약간 느릴 수 있고,unsafe함수는 잘못 쓰면 정의되지 않은 동작을 허용함.i32::wrapping_add()와i32::unchecked_add()를 보면 됨
C에 어떤 함수를unsafe로 표시하고 특정 영역에서unsafe함수 사용을 허용하는 표기를 추가한다면 안전한 변형들을 정의하기 시작할 수 있음. 하지만 어느 순간 C를 바꾸는 노력, 더 중요하게는 C를 통제하는 사람들의 생각을 바꾸는 노력이 목표에 비해 맞지 않게 되고, 차라리 목표와 더 잘 맞는 언어를 찾는 편이 쉬워짐 - 이게 왜 어려운지 보여주는 예가 있음
C에서는 힙 객체를 가리키는 포인터를free에 넘긴 뒤 그 객체에 접근하면 정의되지 않은 동작임. CHERIoT에서는 이 경우 트랩이 발생하도록 정의하지만, 그건 우리가 이를 가능하게 하는 하드웨어를 만들었기 때문에 가능한 일임. 표준은 다양한 하드웨어를 지원해야 하므로, 무엇으로 정의해야 할지 문제가 됨
대략 두 가지 접근이 있음. 하나는 해제를 미루고, 객체를 가리키는 모든 포인터가 사라질 때까지 객체가 없어지지 않는다고 하는 것임. 이는 가비지 컬렉터와 비슷한 무언가를 요구하고, C의 많은 용도에는 감당하기 어려운 오버헤드가 됨. 다른 하나는 객체를 가리키는 모든 포인터의 위치를 알 수 있고 그것들을 무효화할 수 있는 타입 시스템을 정의하는 것임. Rust가 후자의 접근을 택했기 때문에, Rust에서 트리가 아닌 자료구조를 구현하려면unsafe나unsafe를 쓰는 표준 라이브러리 기능이 필요함. 이런 것은 언어 설계 단계에서 넣을 수는 있지만 나중에 덧붙이기는 거의 불가능함
경계 오류도 비슷함. CHERI 시스템에서는 객체나 하위 객체의 경계가 포인터의 본질적인 일부라서, 경계 밖 접근은 트랩이 됨. 다른 플랫폼에서는 포인터가 주소를 담은 단어일 뿐임. 산술 연산을 하고 나면 원래 객체로 다시 매핑할 방법이 없으니 경계를 어디서 얻을지가 문제임. 주소 살균기 같은 도구는 경계를 별도 구조에 저장하고 포인터 산술에 검사를 요구하지만, 메모리와 성능 오버헤드가 커서 운영 환경에서는 ASan을 켠 C보다 Java를 쓰는 편이 훨씬 낫고, 아마 코드도 더 빨리 작성할 수 있음
- 표준에서 어떤 동작이 정의되지 않은 데는 최소 세 가지 이유가 있다고 봄
-
널 포인터 역참조는 잘 정의된 동작이라고 생각했음
- https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3220.pdf 4쪽, PDF 기준 18쪽에 이렇게 나와 있음
- 용어, 정의, 기호
3.5.3 정의되지 않은 동작
예: 정의되지 않은 동작의 예로 널 포인터 역참조 시의 동작이 있다
- CPU 명령어 집합 관점에서는 맞을 수 있지만, 프로그래밍 대상은 그게 아니라 C 추상 기계이고, C 추상 기계는 이를 정의되지 않은 동작이라고 함
- https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3220.pdf 4쪽, PDF 기준 18쪽에 이렇게 나와 있음
-
이 글에서 한 가지 걸리는 부분이 있음
SEGFAULT는 패닉과 같은 서비스 거부 공격임
둘은 같은 오류 범주에 속하고, 보통 메모리 안전성과 연결해서 떠올리는 것은 스택 스매싱, 데이터 변조, 코드 변조 같은 것들임. 이런 것들은 Rust에서 훨씬, 훨씬 더 어렵고 어느 정도는 C에서도 어렵게 만들 수 있음
전체 글은 대체로 C의 타입 시스템이 형편없다는 얘기처럼 보였음. C++에서는 이런 실수를 막을 수 있고, C에서도 GCC의nonnull속성을 쓰면 함수에NULL을 넘기는 것을 컴파일러 오류로 끌어올릴 수 있음
개인적으로는 경계 밖 접근이 더 좋고 대표적인 예였을 듯함- “SEGFAULT는 패닉과 같은 서비스 거부 공격”이라는 말은 맞지 않음
패닉은 프로그램에 내장된 안전성 검사이고, 안정적으로 발생하며 동작이 명확히 정의되어 있음
세그폴트는 잘못된 메모리 연산을 운영체제가 잡아낸 것이고, 프로그램의 가상 메모리 맵에 있는 페이지 밖 주소에 대해서만 발생함. 그래서 많은 세그폴트 버그가 어떤 형태의 임의 코드 실행으로 조작될 수 있음
둘은 정상적인 경우 결과가 같아 보일 뿐, 근본적으로 다른 것들임
- “SEGFAULT는 패닉과 같은 서비스 거부 공격”이라는 말은 맞지 않음