메모리 불안전성이 무엇인지, 왜 보안·안정성·생산성에 위협이 되는지, C/C++의 한계와 Rust·Swift·Go 등 대안, 그리고 조직에서 단계적으로 도입하는 방법을 설명한다.
2019년 8월 12일 월요일
메모리 불안전성은 일부 프로그래밍 언어의 성질로, 프로그래머가 특정 유형의 버그를 만들 수 있게 하고 그 버그들이 심각한 보안 문제를 야기하도록 방치하는 것을 뜻합니다. 이들 버그는 메모리를 공간적(spatial)·시간적(temporal)으로 어떻게 사용하는지와 관련된 오류에서 비롯됩니다.
이 버그들을 이해하기 위해, 다수 사용자의 할 일 목록(to-do list)을 관리하는 애플리케이션을 예로 들어보겠습니다. 먼저 공간적 오류부터 살펴보죠.
할 일 목록에 항목이 10개 있는데, 제가 11번째 항목을 달라고 하면 어떻게 해야 할까요? 분명 어떤 형태로든 오류를 반환해야 합니다. 음수 인덱스인 -1번째 항목을 달라고 해도 마찬가지겠죠. 메모리 불안전한 언어에서는, 프로그래머가 요청한 항목의 인덱스가 목록의 길이 안에 있는지 명시적으로 검사하지 않는 한, 그 시점의 메모리 그 위치에 우연히 있던 값이 가져와집니다. 반면 메모리 안전한 언어에서는 항상 오류가 발생하며, 종종 프로그램이 충돌합니다. 프로그램이 충돌하는 것은 과해 보일 수 있지만, 10개짜리 목록의 11번째 요소를 그냥 읽도록 허용하면 다른 사람 목록의 첫 번째 항목을 읽게 될 수도 있습니다! 마찬가지로 -1번째 항목은 다른 사용자 목록의 마지막 항목일 수도 있죠. 이는 심각한 보안 취약점입니다. 프로그램이 충돌하는 게 불행하긴 해도, 사용자가 서로의 데이터를 훔치게 두는 것보다는 낫습니다. 목록의 앞이나 끝을 넘어 읽도록 허용하는 것을 범위를 벗어난 읽기(out-of-bounds read)라고 합니다.
이와 밀접하게 관련된 공간적 취약점으로 범위를 벗어난 쓰기(out-of-bounds write)가 있습니다. 이 경우 11번째나 -1번째 항목을 바꾸려고 한다고 상상해보십시오. 이제는 다른 사람의 할 일 목록을 바꾸는 셈입니다.
다른 유형의 오류는 시간적 오류입니다. 제가 할 일 목록을 삭제했는데, 나중에 그 목록의 첫 번째 항목을 요청한다고 상상해보세요. 분명 오류를 받아야 합니다. 삭제된 목록에서 항목을 가져올 수는 없으니까요. 메모리 불안전한 언어는 프로그램이 이미 사용을 끝냈다고 선언한 메모리를 다시 가져오도록 허용합니다. 이 경우 그 메모리 위치에는 이제 다른 사람의 할 일 목록이 들어있을 수도 있습니다! 이를 해제 후 사용(use-after-free) 취약점이라고 부릅니다.
범위를 벗어난 읽기, 범위를 벗어난 쓰기, 해제 후 사용은 메모리 불안전성 취약점의 대다수를 차지하며, 많은 프로젝트에서 전체 취약점 중 다수를 차지하기도 합니다. 위의 예에서는 이러한 취약점의 영향이 메모리 훔치기로 나타났지만, 원격 코드 실행(remote code execution) 공격으로 이어지기도 합니다. 메모리 안전한 언어는 기본적으로 이러한 문제를 방지합니다 — 프로그래머가 일부러 애쓰지 않으면 이런 취약점을 도입할 수 없습니다. 반면 메모리 불안전한 언어에서는 프로그래머가 이런 취약점을 막기 위해 추가 노력을 해야 합니다! 가장 대표적인 메모리 불안전한 언어는 C, C++, 그리고 어셈블리입니다. 이 세 언어를 제외한 거의 모든 프로그래밍 언어는 메모리 안전하여, 프로그래머가 이런 버그를 도입할 수 없거나, 설령 버그가 있어도 보안 문제로 이어지지 않습니다. JavaScript, Rust, Python, Java, Ruby, Swift가 모두 메모리 안전한 언어의 예입니다.
메모리 불안전성으로 인한 취약점은 매우 영향력 있는 수많은 보안 문제의 근간입니다. 2003년의 Slammer 웜은 버퍼 오버플로우(범위를 벗어난 쓰기)였습니다. WannaCry도 마찬가지였습니다(범위를 벗어난 쓰기). iPhone을 겨냥한 Trident 익스플로잇은 서로 다른 세 가지 메모리 불안전성 취약점(해제 후 사용 2개, 범위를 벗어난 읽기 1개)을 이용했습니다. HeartBleed는 메모리 불안전성(범위를 벗어난 읽기)이었습니다. 안드로이드의 Stagefright도 그렇습니다(범위를 벗어난 쓰기). glibc의 Ghost 취약점요? 그럼요(범위를 벗어난 쓰기).
이런 취약점과 익스플로잇(그리고 그 외 다수)은 C와 C++이 메모리 안전하지 않기 때문에 가능했습니다.
이 글은 C와 C++처럼 메모리 불안전한 언어를 사용하는 소프트웨어 엔지니어링 조직의 리더, 특히 운영체제, 네트워크 서버, 데스크톱 소프트웨어처럼 보안 민감한 소프트웨어를 작성하는 분들을 위한 것입니다.
제 목표는 메모리 불안전성을 계속 사용할 때의 위험을 소개하고, 조직을 위한 대안을 제안하는 것입니다.
극도로 흔합니다. 최근 연구에 따르면 iOS와 macOS의 취약점 중 60~70%가 메모리 불안전성 때문에 발생합니다. Microsoft는 추정하기를 지난 10년간 자사 제품의 모든 취약점 중 70%가 메모리 불안전성 때문이었다고 합니다. Google은 안드로이드 취약점의 90%가 메모리 불안전성이라고 추정했습니다. 실제로 악용되는 0-day를 분석한 결과, 악용된 취약점의 80% 이상이 메모리 불안전성 때문이었다고 합니다1.
C와 C++로 대규모 코드를 작성하는 조직은 필연적으로 메모리 불안전성에 직접 기인하는 대량의 취약점을 만들어냅니다. 이러한 취약점은 병원, 인권 운동가, 보건 정책 전문가에게까지 피해를 줍니다. C와 C++을 쓰는 것은 사회에 해롭고, 평판에도 나쁘며, 고객에게도 해롭습니다.
물론 있습니다. 메모리 불안전성은 안정성, 개발자 생산성, 애플리케이션 성능에도 악영향을 미칩니다.
Firefox에서 보안 엔지니어로 일했던 제 경험에 따르면, 사용자들이 겪는 충돌(crash) 중 상당수가 메모리 불안전성에 뿌리를 두고 있었습니다. 이러한 충돌이 보안과 직접 관련이 없더라도, 사용자에게 매우 나쁜 경험을 줍니다.
더 나쁜 점은, 이런 버그는 개발자가 추적하기가 믿을 수 없을 정도로 어려울 수 있다는 겁니다. 메모리 손상(memory corruption)은 실제 버그가 있는 지점과 매우 동떨어진 곳에서 충돌을 일으키는 경우가 많습니다. 멀티스레딩이 개입되면, 스레드가 어느 타이밍에 실행되는지의 작은 차이로 추가 버그가 촉발되어 재현이 더 어려워지곤 합니다. 그 결과, 개발자들은 메모리 손상 버그의 원인을 파악하기 위해 충돌 리포트를 몇 시간씩 들여다보아야 합니다. 이러한 버그는 수개월간 고쳐지지 않을 수 있으며, 개발자들은 버그가 분명 존재한다고 확신하면서도 그 원인을 밝히고 수정하기 위해 어떻게 진전을 이뤄야 할지 전혀 감을 못 잡는 상황에 빠지곤 합니다.
마지막으로 성능 문제가 있습니다. 지난 수십 년 동안은 1~2년마다 CPU가 눈에 띄게 빨라졌지만, 이제는 그렇지 않습니다. 대신 CPU는 코어 수가 늘어납니다. 이를 활용하려면 개발자들은 멀티스레드 코드를 작성해야 합니다.
불행히도 멀티스레딩은 메모리 불안전성과 상극이며, 메모리 불안전한 언어에 내재된 안정성·보안 문제를 더욱 악화시키는 경향이 있습니다. 그 결과, 멀티코어 CPU를 활용하려는 노력은 C와 C++에서는 종종 난공불락이 됩니다. Mozilla는 Firefox의 C++ CSS 서브시스템에 멀티스레딩을 도입하려고 여러 차례 시도했지만 실패했고, 결국 멀티스레드 Rust로 시스템을 다시 작성하여 성공했습니다.
메모리 안전한 언어를 사용하면 됩니다! 훌륭한 선택지가 아주 많습니다. 운영체제 커널이나 웹 브라우저를 작성하나요? Rust를 고려해보세요! iOS와 macOS를 대상으로 하나요? Swift가 준비되어 있습니다. 네트워크 서버라면? Go도 훌륭한 선택입니다. 이는 몇 가지 예에 불과하며, 이 외에도 훌륭한 메모리 안전 언어와 다양한 멋진 용도-언어 조합이 많습니다!
조직에서 사용하는 프로그래밍 언어를 바꾸는 일은 가볍게 착수할 일이 아닙니다. 채용 시 필요한 역량이 달라지고, 기존 인력을 재교육해야 하며, 대규모 코드를 다시 작성해야 하기 때문입니다. 그럼에도 저는 장기적으로 이것이 필요하다고 믿습니다. 따라서 새로운 프로그래밍 언어 도입 대신 사용할 수 있는 대안들이 왜 성공적이지 못했는지 설명하고자 합니다.
메모리 불안전한 언어를 사용하면 일정 수의 취약점이 발생할 것이라고 가정한다면, 우리가 던져야 할 질문은 이렇습니다. 프로그래밍 언어를 통째로 바꾸지 않고도 이 위험을 줄일 수 있는 기법이 있는가? 답은 분명히 예입니다. 메모리 불안전한 언어로 작성되었다고 해서 모든 프로젝트가 똑같이 위험하고 신뢰할 수 없는 것은 아닙니다.
메모리 불안전한 언어 사용의 위험을 낮출 수 있는 실천들은 다음과 같습니다.
이러한 실천은 메모리 불안전한 언어를 사용할 때의 위험을 의미 있게 낮춥니다. 제가 언어 변경을 설득하는 데 실패했고, 여러분이 C와 C++을 계속 사용하겠다면, 위 실천들을 도입하는 것은 의무에 가깝습니다. 안타깝게도 이것만으로는 턱없이 부족합니다.
최신 C++ 관용구, 퍼저, 새니타이저, 익스플로잇 완화, 권한 분리 기법을 가장 앞서 개발해온 사람들은 브라우저와 운영체제 개발자들입니다 — 바로 글 서두에서 메모리 불안전성의 만연함을 통계로 보여준 그 집단입니다. 이 팀들이 이러한 기법들에 투자해 왔음에도, 메모리 불안전한 언어의 사용은 여전히 그들의 발목을 잡습니다. 대형 해킹 대회인 pwn2own 2019에서는, 해당 제품들에서 악용된 취약점의 절반 이상이 메모리 불안전성 때문이었고, 한 건을 제외하면 모든 성공적인 공격이 최소 하나의 메모리 불안전성 취약점을 악용했습니다.
이 시점에서 여러분은 아마 C와 C++ 같은 메모리 불안전한 언어가 우리 제품의 불안정성·취약성의 근본 원인이라는 점, 그리고 위험을 낮추기 위한 실천이 가능하더라도 이를 근본적으로 제거하는 데에는 턱없이 부족하다는 점에 동의하실 겁니다. 그럼에도 수백만 줄의 코드를 생산하는 조직에서 프로그래밍 언어를 바꾸는 일은 압도적으로 커 보일 수 있습니다. 하지만 이를 관리 가능한 조각들로 나누면, 우리는 진전을 시작할 수 있습니다 — 우리의 목표는 세상을 한 번에 갈아엎는 빅뱅식 재작성(rewrite-the-world)이 아니라, 위험을 줄이기 위한 점진적 진전입니다.
가장 먼저 시작할 곳은 완전히 새로운 프로젝트입니다. 이런 경우, 메모리 불안전한 언어를 선택하지 않으면 됩니다. 기존 코드를 다시 쓸 필요가 없기 때문에 가장 위험이 낮습니다. 다만, 새로운 프로그래밍 언어를 지원하기 위해 테스트나 배포 인프라를 개선해야 하는 경우가 종종 있습니다. ChromeOS의 새로운 OS 구성요소인 CrosVM은 이런 접근을 취했습니다.
새 프로젝트가 없다면, 기존 프로젝트의 새로운 구성요소를 메모리 안전한 언어로 작성할 기회를 찾아보세요. 몇몇 메모리 안전 언어는 C와 C++ 코드베이스와의 상호 운용을 일류 수준으로 지원합니다(예: Rust와 Swift). 이 경우에는 빌드 시스템 통합과, 두 언어 간 경계를 넘어 전달해야 하는 객체와 데이터를 위해 새 언어로 추상화를 구축해야 하므로 초기 투자가 약간 더 필요합니다. Firefox에 WebAuthn을 새로운 구성요소로 도입할 때 사용된 전략이 바로 이것이며, 제가 진행한 Linux 커널 모듈을 Rust로 작성 가능하게 한 프로젝트도 같은 전략을 사용했습니다.
이 첫 두 접근법의 공통점은 모두 새로운 코드에 관한 것이라는 점입니다. 이는 기존 코드와의 상호작용 지점이 명확하고, 시작을 위해 기존 코드를 다시 쓸 필요가 없다는 이점이 있습니다. 또한 출혈을 멈출 기회를 제공합니다. 즉, 메모리 불안전한 언어로는 새로운 구성요소를 더 이상 만들지 않고, 기존 코드는 점진적으로 다루겠다는 것입니다. 메모리 안전한 언어를 적용할 만한 자연스러운 새 구성요소가 없는 프로젝트에서는 도입이 더 어렵습니다.
이 경우에는 메모리 불안전한 언어로 된 기존 구성요소 중 일부를 메모리 안전한 언어로 ‘재작성’할 대상을 찾아야 합니다. 선택한 구성요소가 성능, 보안, 유지보수 난이도 등의 이유로 어차피 재작성을 고려하던 것이라면 가장 좋습니다. 첫 번째 메모리 안전성 재작성에서는 범위를 가능한 작게 잡아 프로젝트가 성공 확률을 높이고 최대한 빨리 출하할 수 있도록 해야 합니다. 이는 재작성에 내재된 위험을 줄이는 데 도움이 됩니다. Rust로 Firefox의 CSS 엔진을 다시 쓴 Stylo는 이 접근의 성공적 사례입니다.
어떤 접근이 조직에 가장 잘 맞든, 성공 가능성을 극대화하기 위해 염두에 둘 점들이 있습니다. 첫째, 내부 추진자(챔피언)와, 팀원 다수에게 새로운 언어가 될 그 언어로 코드 리뷰와 멘토링을 제공할 수 있는 시니어 엔지니어가 있어야 합니다. 다음으로, 새로운 언어로 일할 엔지니어들이 책, 교육, 내부 가이드 같은 자료를 이용할 수 있도록 하세요. 마지막으로, 빌드 시스템, 테스트, 배포, 충돌 보고(crash reporting) 및 기타 통합 등 기존 언어에 갖춘 공용 인프라를 새로운 언어에도 동일하게 갖추도록 하세요.
새로운 프로그래밍 언어를 채택하고 이에 맞춰 마이그레이션을 시작하는 일은 쉽지 않습니다. 계획, 리소스, 그리고 궁극적으로 조직 전체의 투자가 필요합니다. 이런 고민을 하지 않아도 된다면 삶은 훨씬 쉬울 것입니다. 하지만 데이터를 검토해보면, 보안 민감한 프로젝트에 메모리 불안전한 언어를 계속 사용하는 것은 더 이상 고려할 수 없다는 사실이 분명해집니다.
데이터는 거듭해서 보여줍니다. C와 C++ 같은 메모리 불안전한 언어를 사용할 때, 프로젝트는 결과적으로 쏟아지는 보안 취약점의 눈사태에 시달리게 됩니다. 엔지니어가 아무리 뛰어나고, 권한 분리와 익스플로잇 완화에 아무리 큰 투자를 하더라도, 메모리 불안전성은 너무 많은 버그를 낳습니다. 그리고 이 버그들은 보안뿐 아니라 안정성과 생산성도 파괴합니다.
다행히도 우리는 현상 유지에 만족할 필요가 없습니다. 지난 몇 년 사이 C와 C++의 훌륭한 대안들이 속속 등장했습니다. Rust, Swift, Go뿐 아니라 그 외에도 많습니다. 이는 우리가 메모리 손상 취약점을 앞으로 수년간 목에 알바트로스처럼 매달고 살 필요가 없다는 뜻이기도 합니다. 우리가 그렇게 선택하지 않기만 하면요. 저는 언젠가 메모리 불안전한 언어를 선택하는 것이 다중 요소 인증을 도입하지 않거나 전송 중 데이터를 암호화하지 않는 것만큼이나 태만한 일로 여겨지는 때가 오기를 기대합니다.