모던 C++의 string_view, 람다 캡처, optional, span 등 최신 관용구가 어떻게 보안 취약점을 낳는지 사례로 보여주고, 메모리 안전 언어로의 전환을 주장한다.
2019년 4월 21일 일요일
나는 주로 C와 C++ 같은 자주비판하는사람이며메모리 안전하지 않은 언어가 어떻게 유난히 많은 보안 취약점을 유발하는지 지적해왔다. C와 C++를 사용하는 수많은 대규모 소프트웨어 프로젝트의 증거를 검토한 제 결론은, 업계가 기본적으로 메모리 안전한 언어(예: Rust, Swift)로 옮겨가야 한다는 것이다. 자주 듣는 반론은 문제의 본질이 C와 C++ 자체가 아니라, 개발자들이 그 언어를 잘못 쓰기 때문이라는 주장이다. 특히 “C++는 C에서 물려받은 기능을 쓰지 않으면 안전하다”1라는 식의 C++ 옹호를 자주 듣는다. 혹은 현대적 C++ 타입과 관용구(modern idioms)를 사용하면, 다른 프로젝트를 괴롭히는 메모리 훼손 취약점으로부터 면역이 된다는 식이다.
C++의 스마트 포인터 타입이 큰 도움을 준다는 점은 인정하고 싶다. 불행히도, 현대 관용구를 사용하는 대규모 C++ 프로젝트에서 일한 내 경험으로는, 이것만으로는 쏟아지는 취약점의 범람을 멈추기에는 턱없이 부족하다. 이 글의 나머지 부분에서는, 전적으로 현대적인 C++ 관용구들인데도 취약점을 만들어내는 여러 사례를 살펴보려 한다.
내가 먼저 소개하고 싶은 사례는, 원래 Kostya Serebryany가 지적한 것처럼, C++의 std::string_view가 use-after-free 취약점을 얼마나 쉽게 숨길 수 있는지에 관한 것이다:
#include <iostream>
#include <string>
#include <string_view>
int main() {
std::string s = "Hellooooooooooooooo ";
std::string_view sv = s + "World\n";
std::cout << sv;
}
여기서 일어나는 일은 s + "World\n"이 새로운 std::string을 할당한 다음, 그것이 std::string_view로 변환된다는 것이다. 이 시점에서 임시 std::string은 해제되지만, sv는 여전히 그 임시 객체가 소유하던 메모리를 가리키고 있다. 이후 sv를 사용하는 모든 동작은 해제 후 사용(use-after-free) 취약점이 된다. 이런! C++에는 컴파일러가 sv가 참조 대상보다 더 오래 사는 참조를 캡처한다는 사실을 알 수 있도록 해주는 수단이 없다. 동일한 문제는 매우 현대적인 C++ 타입인 std::span에도 영향을 미친다.
또 다른 흥미로운 변형은, C++의 람다 지원을 이용해 참조를 숨기는 것이다:
#include <memory>
#include <iostream>
#include <functional>
std::function<int(void)> f(std::shared_ptr<int>& x) {
return [&]() { return *x; };
}
int main() {
std::function<int(void)> y(nullptr);
{
std::shared_ptr<int> x(std::make_shared<int>(4));
y = f(x);
}
std::cout << y() << std::endl;
}
여기서 f의 [&]는 람다가 값을 참조로 캡처하도록 한다. 그런 다음 main에서 x가 스코프 밖으로 나가면서 데이터에 대한 마지막 참조가 파괴되고, 그 결과 데이터가 해제된다. 이 시점에서 y에는 더 이상 유효하지 않은(dangling) 포인터가 들어 있다. 이는 우리가 전반에 걸쳐 꼼꼼히 스마트 포인터를 사용했음에도 발생한다. 그리고 그렇다, 사람들은 실제로 std::shared_ptr<T>&를 다루는 코드를 쓴다. 종종 참조 횟수의 증가/감소를 추가로 발생시키지 않으려는 시도로 말이다.
std::optional<T> 역참조std::optional은 값이 있을 수도 없을 수도 있음을 표현하며, 종종 매직 센티널 값(예: -1, nullptr)을 대체한다. 이 타입에는 value() 같은 메서드가 있어, 내부의 T를 꺼내고 optional이 비어 있으면 예외를 던진다. 하지만 operator*와 operator->도 정의되어 있다. 이 연산자들도 내부의 T에 접근을 제공하지만, optional에 실제로 값이 들어 있는지 여부는 확인하지 않는다.
예를 들어 다음 코드는 단순히 초기화되지 않은 값을 반환한다:
#include <optional>
int f() {
std::optional<int> x(std::nullopt);
return *x;
}
std::optional을 nullptr의 대체재로 사용한다면 훨씬 더 심각한 문제가 발생할 수 있다! nullptr를 역참조하면 세그폴트(segfault)가 발생한다(이는 오래된 커널을 제외하면 보안 문제는 아니다). 반면 nullopt를 역참조하면, 포인터로서 초기화되지 않은 값을 얻게 되며, 이는 심각한 보안 문제로 이어질 수 있다. 물론 초기화되지 않은 값을 가진 T*를 갖게 되는 것도 가능하지만, 올바르게 nullptr로 초기화된 포인터를 역참조하는 경우에 비해 이런 사례는 훨씬 드물다.
그리고 그렇다, 이것은 생포인터(raw pointer)를 사용할 때만 생기는 문제가 아니다. 스마트 포인터로도 초기화되지 않았거나 와일드 포인터(엉뚱한 포인터)를 만들 수 있다:
#include <optional>
#include <memory>
std::unique_ptr<int> f() {
std::optional<std::unique_ptr<int>> x(std::nullopt);
return std::move(*x);
}
std::span<T> 인덱싱std::span<T>은 연속된 메모리 조각에 대한 참조와 길이를 인자처럼 전달하기 편리한 방법을 제공한다. 이를 통해 여러 다른 타입 위에서 동작하는 코드를 쉽게 작성할 수 있다. 예컨대 std::span<uint8_t>은 std::vector<uint8_t>, std::array<uint8_t, N>, 심지어 생 포인터가 소유한 메모리를 가리킬 수 있다. 경계(범위)를 올바르게 검사하지 못하는 것은 자주 발생하는 보안 취약점의 원인이고, 많은 의미에서 span은 항상 올바른 길이를 함께 갖게 함으로써 이런 문제를 완화한다.
그러나 모든 STL 자료구조와 마찬가지로, span의 operator[]는 경계 검사를 수행하지 않는다. 이는 유감스러운 일인데, operator[]가 사람들이 자료구조를 사용할 때 가장 편하고 기본적인 방식이기 때문이다. std::vector와 std::array는 이론상 안전하게 사용할 수도 있다. 왜냐하면 이들은 경계 검사를 수행하는 at() 메서드를 제공하기 때문이다(실무에서 내가 이 방식을 본 적은 없지만, std::vector<T>::operator[] 호출을 금지하는 정적 분석 도구를 프로젝트가 채택하는 상상을 해볼 수는 있다). 하지만 span은 at() 메서드도, 경계 검사를 수행하는 다른 조회 메서드도 제공하지 않는다.
흥미롭게도, Firefox와 Chromium의 std::span 백포트는 operator[]에서 경계 검사를 한다. 따라서 이들은 표준 std::span으로 안전하게 마이그레이션할 수 없게 되었다.
현대 C++ 관용구는 보안을 개선할 잠재력을 지닌 많은 변화를 도입했다. 스마트 포인터는 예상되는 수명(lifetime)을 더 잘 표현하고, std::span은 항상 올바른 길이를 갖게 해주며, std::variant는 union에 대한 더 안전한 추상화를 제공한다. 그러나 현대 C++는 동시에 믿기 힘들 만큼 새로운 취약점의 원천도 도입했다. 람다 캡처에 의한 use-after-free, 초기화되지 않은 값을 가진 optional, 그리고 경계 검사를 하지 않는 span 등이 그것이다.
상대적으로 현대적인 C++ 코드를 직접 작성하고, Rust 코드(상당한 unsafe 사용을 포함한 Rust 코드까지)도 감수해 본 내 직업적 경험에 따르면, 현대 C++의 안전성은 Rust와 Swift 같은 기본적으로 메모리 안전한 언어와는 도저히 견줄 수 없다(혹은 Python과 Javascript도 마찬가지지만, 실제로는 Python이나 C++ 둘 중 하나로 쓰면 될 프로그램을 보는 경우는 드물다).
기존의 크고 방대한 C/C++ 코드베이스를 다른 언어로 옮기는 데 중대한 어려움이 있다는 점은 누구도 부인할 수 없다. 그럼에도 불구하고, 질문은 우리가 그것을 시도해야 하느냐가 아니라, 어떻게 해낼 수 있느냐가 되어야 한다. 가장 현대적인 C++ 관용구를 모두 동원하더라도, 대규모 환경에서는 C++을 제대로 다루는 것이 사실상 불가능하다는 증거가 분명하다.
malloc/free, 기타 유사한 기능을 가리킨다고 이해했다. 다만 C++가 명시적으로 C를 사양에 포함했다는 점을 고려하면, 실제로 대부분의 C++ 코드는 이러한 요소들 중 일부를 포함하고 있다는 사실을 인정할 필요가 있다고 본다.↩︎