C++의 안전성을 높이기 위한 현실적인 방안을 검토한다. STL의 경계 검사 기본화, -fbounds-safety, 초기화되지 않은 메모리 완화, 수명(lifetime) 표기 도입, 데이터 레이스 문제 등을 논의하며, 기존 C++ 개선과 안전한 언어로의 마이그레이션을 병행하는 포트폴리오 접근을 제안한다.
더 안전한 C++ · Alex Gaynor
2024년 8월 18일 (일)
나는 보안과 관련된 맥락에서 메모리 안전하지 않은 언어(C와 C++)에서 메모리 안전한 언어로의 전환을 꾸준히 옹호해 왔다. 많은 사람들이 이렇게 답한다. "거대한 코드베이스를 새로운 언어로 옮기는 일은 비싸니, 차라리 C++을 더 안전하게 만드는 편이 낫다." 세상에 C++ 코드가 엄청나게 많다는 점을 생각하면, 이는 분명 타당한 반응이다. 전 세계의 모든 C++ 코드를 Rust나 Swift, Go로 교체하는 극단적으로 공격적인 일정 하에서도, 우리는 오랫동안 상당한 C++ 공격 면을 안고 살 것이다. 그러니 메모리 안전 언어를 옹호하는 사람일지라도, 더 안전한 C++을 이루는 것은 충분히 가치 있는 목표다.
C++을 더 안전하게 만든다고 할 때, 이는 기존 C++ 코드에 대해 메모리 안전 언어와 동일한 보안 보장을 제공한다는 의미일 수도 있다. 하지만 이는 지나치게 협소한 정의다. 그래서 이 글에서는 소스 코드의 변경이 필요한 경우라도, 기존 C++ 코드베이스 전반에 걸쳐 점진적으로 도입할 수 있는 것들까지 함께 고려하겠다.
C++의 안전성을 개선하려는 제안을 평가하려면, 그 제안을 구현하는 데 드는 비용을 살펴봐야 한다. 다른 언어로 마이그레이션하는 비용보다 더 비싸다면 합리적이지 않다. 우리가 주로 신경 쓰는 비용은 개별 프로젝트가 그 제안을 채택하는 데 드는 비용이다(새 컴파일러 플래그처럼 사소한 것부터, 의미 있는 신규 언어 기능처럼 큰 것까지 범위가 있다). 또한 실제로 달성되는 안전성의 수준도 중요하다.
나는 이러한 변화가 얼마나 _실용적인지_는 고려하겠지만, 실현 가능성(채택 가능성)에 대해서는 논하지 않겠다. 그것은 결국 C++ 표준 위원회의 영역이다.
개선의 범위를 검토하며 몇 가지를 범위 _밖_으로 두겠다:
첫째, 사람들이 시스템 언어를 쓰는 이유를 훼손하는 것은 고려 대상이 아니다. 예컨대 눈에 띄는 성능/메모리 비용을 강요하는 기법은 범위 밖이다. 일부 도메인에서는 성능 요구가 없는데도 C++을 선택했을 수 있지만, 일반적으로 C++을 쓴다면 C++이 잘하는 특성을 중요하게 여긴다고 가정한다.
또한 스택 쿠키나 하드닝된 할당자처럼 익스플로잇 완화책은 고려하지 않겠다. 이 글은 C++ 언어 자체에 가해 안전성을 높이는 조치에 관한 것이다. 마찬가지로 샌드박싱이나 언어 차원이 아닌 보안 접근법도 다루지 않는다.
첫 출발점은 C++이 극적으로 개선될 여지가 가장 큰 곳, 즉 공간적 안전성(spatial safety)이다.
첫 단계로, C++ 표준 위원회는 STL 컨테이너의 모든 "기본" 인덱싱 연산(예: operator[])을 경계 검사 대상이 되도록 정의할 수 있다. 이는 표준 위원회와 표준 라이브러리 작성자에게는 어느 정도 일이지만, C++ 프로그래머에게는 사실상 일이 거의 없다(libc++의 하드닝 모드가 이러한 검사 상당수를 이미 구현한다). 이 조치는 STL 컨테이너에서의 버퍼 오버플로를 포괄적으로 해결해 줄 것이다. 실무적으로는 검사를 하지 않는 인덱싱을 허용하는 탈출구(escape hatch)도 필요하겠지만, [[assume()]]로 불변식을 단언하고 그걸 가정하게 하는 방식이 최선일 수 있다.
다음으로는, 원시 포인터에 대한 경계 검사를 가능하게 하는 애플의 제안 -fbounds-safety 같은 것을 채택하는 방안이 있다. 이를 도입하려면 더 많은 작업이 필요하다. 각 C/C++ 프로그램이 이를 활용하도록 코드를 수정해야 하기 때문이다. 그러나 이를 채택한 코드에 대해서는 버퍼 오버플로에 대해 마찬가지로 포괄적 보호를 제공한다.
이 두 가지를 합치면 많은 프로그램에서 C++의 공간적 안전성이 대폭 향상된다. 다만 여기에 기대치의 변화가 수반되어야 한다. 다른 데이터 구조(예: 스몰 벡터) 구현자들도 이에 발맞춰 기본적으로 안전한 API를 채택해야 한다. 또한 이터레이터 역시 안전하게 만들어야 한다(안전한, 경계 검사가 되는 API를 바탕으로 구현하는 식으로).
공간적 안전성은 더 안전한 C++을 위한 가능성 가운데 정점에 해당한다. STL 자료구조를 기본값으로 경계 검사하도록 만들고, -fbounds-safety를 도입한 뒤(점차적으로 이를 요구하는 방향으로!) 결합하면, C++에 대해 매우 포괄적인 보호를 제공하며, 메모리 안전 언어에서 기대할 수준에 거의 근접한다.
다음으로 비교적 포괄적으로 다룰 수 있는 취약점 범주는 초기화되지 않은 메모리다. 이는 두 가지로 나뉜다. 첫째는 스택으로, 일부 컴파일러는 이를 자동 초기화하는 플래그를 제공한다(일반적으로 0이나 특정 패턴으로; clang의 -ftrivial-auto-var-init 참조). 둘째는 힙 할당으로, malloc 구현이 초기화된 메모리만 반환하도록 해야 한다.
하지만 복잡한 점이 있다. 이러한 방식은 초기화되지 않은 메모리에 접근하는 정의되지 않은 동작을 막아줄 수는 있어도, 초기화에 쓰인 값이 의미론적으로 타당하고 안전하다는 보장을 할 수는 없다. 실무적으로는 값을 0으로 초기화하는 편이 정보 유출과 와일드 포인터를 방지해 대체로 더 안전하지만, 이것 또한 위험이 전혀 없는 것은 아니다. 성능 오버헤드도 소폭 유발한다. 다만 이 오버헤드는 컴파일러 최적화를 통해 종종 줄일 수 있다(완전히 제거되지는 않는다). 그래서 나는 이 취약점 범주가 다소 포괄적으로만 해결 가능하다고 말한다. 애플리케이션 개발자가 거의 손댈 필요가 없다는 장점이 있지만, 제공되는 안전성 수준은 정적으로 초기화를 강제하는 언어들보다는 낮다.
다음으로 고려할 취약점은 시간적 안전성(temporal safety), 특히 해제 후 사용(use-after-free) 취약점이다. 수년 동안 C++ 옹호자들은 스마트 포인터(예: std::unique_ptr, std::shared_ptr)가 소유권을 더 명확히 해줄 수 있다고 말해 왔다. 이것들이 소유권을 더 정확히 모델링하는 데 도움이 되고, 원칙적으로는 이 종류의 취약점을 막을 수 있다는 점은 사실이다. 하지만 실무에서는 이 도구들이 이미 수년간 이용 가능했음에도 문제가 해결되었다고 보기 어렵다(그랬다면 나는 이 글을 쓰지 않았을 것이다).
C++가 러스트와 유사한 "수명(lifetime)" 문법과 의미론을 얻도록 하는 다양한 제안도 있다. 내가 보기엔, 이들은 주로 비교적 단순한 소유권 모델을 표현한다. 러스트의 "수명 생략(lifetime elision)" 규칙으로 다루어지는 유형, 즉 대체로 함수의 모든 인자가 같은 수명을 가지며 반환값의 수명이 그들과 일치하는 경우에 가깝다. 이는 흔한 시나리오를 많이 다루지만, 모든 소유권 시나리오를 모델링하기에는 한참 부족하다. 이 문법으로 표현할 수 없는 수명 의미론에 대해서는 소유권 점검이 이루어지지 않는다. 이러한 수명 문법이 전형적인 C++ 코드의 어느 정도 비율을 커버할지는 불분명하다.
이러한 제안을 채택하는 것은 기존 프로그램에 결코 가볍지 않은 투자다. 기존 C++ 코드베이스에 수명 표기를 도입하는 데 드는 비용은, 제안의 표현력과 도입을 (부분적으로) 자동화할 수 있는 도구의 성숙도에 좌우된다. 현재로서는 어느 쪽도 속단하기 이르다. (수명 제안을 대규모 코드베이스에 적용했다는 사례를 나는 알지 못한다. 알고 있다면 알려주기 바란다!) 대규모 프로그램에서 높은 수준의 수명 커버리지를 달성하려면, 가능한 수명 관계를 더 충실히 모델링하도록 이 제안들을 확장할 필요가 매우 클 것이다.
마지막으로 고려할 보안 이슈 범주는 데이터 레이스다. 이를 해결하는 C++ 제안을 나는 알지 못한다. 특히, 수명을 강제하는 "차용 검사기(borrow checker)"만으로는 데이터 레이스 안전성을 얻기에 부족하다. 러스트의 차용 검사기가 안전성을 보장하는 핵심 요소 중 하나는 "가변(mut) XOR 공유(shared)" 규칙을 함께 강제한다는 점이다.
C++의 안전성이 상당히 개선될 수 있음은 분명하다. 특히 공간적 안전성은 완전히 해결하는 것이 손이 닿을 곳에 있어 보인다. 안타깝게도 C++을 Swift, Go, Rust만큼 안전하게 만드는 방법은 우리가 아직 알지 못하며, 간단한 해법이 나올 가능성도 낮아 보인다.
더 나아가, 제안을 채택하는 데 드는 노력과 그것이 제공하는 안전성 사이에는 트레이드오프가 있음을 인식해야 한다. 달리 말하면: 하위 호환성을 끊을 수 있다면 이 문제는 쉬웠을 것이다. 그러나 우리의 전제는 "기존 코드가 많다"는 것이며, 이를 그냥 깨버리면 본말이 전도된다.
엔지니어링 및 보안 팀은 안전화 접근을 포트폴리오 관점에서 바라보는 것이 유익하다. 이미 크고 성숙한 C++ 프로그램을 보유하고 있다면, 포트폴리오에는 C++을 더 안전하게 만드는 접근에 대한 투자가 포함되어야 함이 분명하다. 그러나 동시에, 포트폴리오의 큰 비중을 더 안전한 언어로의 마이그레이션에 투자해야 한다는 점 또한 분명하다고 믿는다.