클라우드블리드: 버퍼 하나가 모두의 비밀을 흘렸다

2017년, Cloudflare 파서의 잘못된 비교 하나가 포인터를 버퍼 끝 너머로 넘겨 인접 메모리를 웹 페이지에 쏟아냈다 — 한 사이트의 요청에 다른 사이트의 비밀번호가 섞여 나왔고, 검색엔진이 그 유출을 캐시했다.

2017년 2월 17일, 구글 프로젝트 제로(Project Zero)의 타비스 오르만디(Tavis Ormandy)는 Cloudflare와 무관한 작업을 하다가, 자신이 받아 온 웹 페이지 안에 있어선 안 될 것들을 보기 시작했다. 쓰레기 값이 아니었다 — 비밀이었다. 남의 HTTPS 요청처럼 보이는 덩어리, 쿠키, 사적인 메시지, 자기 요청에 대한 응답에 절대 있을 이유가 없는 조각들. 처음엔 자기 도구를 잘못 읽은 거라 여겼다. 아니었다. 그는 Cloudflare에 메시지를 보냈는데, 회고를 보면 큰 구멍을 발견하고 애써 침착함을 유지하는 사람의 어조다: 지금 데이터가 새고 있는 것 같다고 했고, 그 판단은 옳았다.

그는 이걸 클라우드블리드(Cloudbleed)라 불렀다. 3년 전 하트블리드(Heartbleed)의 메아리다. 이름이 붙은 건 형태가 같았기 때문이다 — 내줄 이유가 없는 메모리를 내주는 프로그램. 하지만 하트블리드는 한 서버의 메모리를 요청한 사람에게 흘렸다. 클라우드블리드는 그보다 더 나쁜 짓을 했고, 왜 더 나빴는지가 이 글의 핵심이다.

문자 하나

버그부터 보자. 어이없을 만큼 작으니까.

Cloudflare의 엣지 서버는 HTML을 실시간으로 고쳐 쓴다. 그러려면 파싱을 해야 하고, 오랫동안 그 파서는 Ragel로 만들어졌다 — 상태 기계 문법을 받아 텍스트를 훑는 C 코드를 생성해 주는 도구다. Ragel이 만든 파서는 빠르고, 또한 미묘하게 틀리기 쉽기로 유명하다. 포인터 연산을 사람이 직접 쓰는 게 아니라 도구가 당신 문법으로부터 쓰는데, 도구는 당신이 시킨 그대로 충실히 생성하기 때문이다.

그 생성된 코드 어딘가에서, “이 버퍼의 끝에 도달했는가?”를 확인하는 검사가 등호(equality) 테스트를 썼다. 나중에 Cloudflare가 직접 밝힌 표현으로는: 버퍼 끝 도달을 등호 연산자로 확인했고, 포인터가 버퍼 끝을 지나쳐 걸어갈 수 있었다. 풀어 쓰면 ==>=의 차이다. 포인터가 정확히 끝에 내려앉으면 ==가 잡는다. 하지만 무언가가 포인터를 한 바이트 건너뛰게 만들면 — 직전에서 직후로 한 번에 점프하게 하면 — ==는 결코 발동하지 않고, 방어선은 그냥 지나가고, 코드는 버퍼에 속하지도 않은 메모리를 계속 읽는다.

이건 교과서적인 버퍼 오버런이다. 이걸 재앙으로 만든 건, 바쁜 엣지 서버에서 버퍼 끝 바로 너머에 뭐가 사는가다: 다른 요청들. 파서가 헤매고 들어간 메모리는, 방금까지 — 혹은 아직도 — 전혀 다른 연결의 데이터를 담고 있던 힙(heap)이었다. 남의 쿠키. 남의 POST 본문. 남의 Authorization 헤더. 파서는 그걸 퍼 담아, 자기가 고쳐 쓰던 HTML에 갖다 붙였고, 그 HTML은 요청을 보낸 누구에게든 돌아갔다.

버그는 오래됐고, 방아쇠는 새것이었다

포스트모템을 충분히 읽어 본 사람이라면 익숙하게 느낄 대목이 여기다. 그 ==는 갓 저지른 실수가 아니었다. 오랫동안 그 생성된 파서 코드에 얌전히 앉아 있었다 — 무해하게. 아무것도 포인터를 끝 바이트 너머로 건너뛰게 하지 않았으니까. 방아쇠를 당길 조건이 그냥 발생하지 않았던 것이다.

그러다 Cloudflare가 새 파서 cf-html을 도입하고 기능들을 그리로 옮겼다. cf-html은 더 나았고, 그리로 옮기면서 파싱 단계들 사이에서 버퍼가 채워지고 넘겨지는 방식이 바뀌었다. 그 변경은 — 합리적이고, 선의였고, 업그레이드였다 — 옛 Ragel 코드가 한 번도 마주친 적 없는 바로 그 조건을 만들었다: 포인터를 끝 한 걸음 너머에 남길 수 있는 입력. 새 파서는 버그를 담고 있지 않았다. 옛 파서에 먹이는 것의 형태를 바꿔, 그 안에 잠자던 버그를 깨운 것이다.

이것이 인프라 참사의 반복되는 구조이고, 나는 Cloudflare 자신의 장애가 이 구조를 어떻게 되풀이하는지 전에 쓴 적이 있다: 다들 몇 년째 안전하게 딛고 서 있던 잠재적 취약함, 더하기, 그걸 재우던 유일한 조건을 바꾸는 변경. 변경은 따로 보면 안전해 보인다. 취약함도 따로 보면 안전해 보인다. 위험한 건 오직 둘이 함께일 때인데, 아무도 그 ‘함께’를 테스트하지 않는다.

세 기능이 취약한 파서 체인으로 콘텐츠를 흘려보냈고, 각각이 누수를 유발할 수 있었다: Email Obfuscation, Server-side Excludes, Automatic HTTPS Rewrites. 마지막 것이 타임라인에 중요하다 — Automatic HTTPS Rewrites는 2016년 9월 22일에 출시됐고, 그날이 실제로 누수가 시작된 날이기 때문이다. 몇 달을 조용히 굴러갔다. 최악의 구간은 2017년 2월 13~18일, Email Obfuscation이 확률을 높이는 방식으로 옮겨진 뒤였다. 정점에서 Cloudflare는 이 버그가 대략 HTTP 요청 330만 건당 1건 — 약 0.00003% — 에서 발동했다고 추정했다.

이 숫자에서 잠깐 멈추자. 유혹적인 숫자니까. 330만 건당 1건은 아무것도 아닌 것처럼 들린다. 사람들을 안심시키려 슬라이드에 넣을 법한 숫자다. 그리고 이건 이 사건이 얼마나 나빴는지와 완전히 무관하다 — 확률과는 아무 상관 없는 이유로.

왜 0.00003%가 그래도 재앙이었나

Cloudflare는 수백만 웹사이트 앞에 앉아 있었다. 그게 가치 제안이다: 공유된 엣지 서버 하나의 함대를, 모두의 앞에 두어, 모두가 캐싱과 DDoS 방어와 TLS 종단을 싸게 얻게 한다. 공유. 이 단어가 엄청난 일을 하고 있다.

서버가 공유됐으니 메모리도 공유됐다. 메모리가 공유됐으니, 오버런한 파서가 퍼 담은 데이터는 요청한 사이트 자신의 데이터가 아니라 — 방금 그 엣지 머신을 지나간 무엇이든, 뒤에 있는 수백만 사이트 중 어느 것에서든 왔다. 작은 블로그에 대한 요청이 어느 대형 데이팅 사이트의 세션 조각을 물고 돌아올 수 있었다. 어느 포럼에 대한 요청이 비밀번호 관리자의 트래픽을 드러낼 수 있었다. 누수는 테넌트 경계를 존중하지 않았다 — 그 일이 벌어진 층위에서는, 공유 박스의 날것 힙 메모리에는, 테넌트 경계라는 게 없었으니까. 멀티테넌시가 조용히 맞바꾸는 게 바로 이거다. 대시보드에서 보이는 격리는 RAM에 존재하는 격리가 아니다.

그러니 “요청 330만 건당 1건”은 틀린 프레임이다. 맞는 프레임은 이렇다: 유출된 응답 하나하나가 어느 고객의 비밀이든 담을 수 있었고, 피해자 풀은 유출을 받은 그 사이트가 아니라 고객 전체였다. 아주 작은 요청당 확률에, 천문학적으로 크고 서로 오염된 요청량을, 다섯 달에 걸쳐 곱하면 — 그건 작은 사건이 아니다. 인터넷의 비밀을 인터넷의 페이지들 위에 느리고, 넓고, 무차별하게 흩뿌리는 일이다.

청소가 불가능하게 만든 부분

오르만디가 신고한 뒤 Cloudflare의 대응은 정말로 빨랐다. 이들에겐 기능용 글로벌 킬 스위치가 있고, 그걸 썼다: Email Obfuscation은 상세를 받은 지 47분 만에 전 세계에서 꺼졌고, Automatic HTTPS Rewrites는 그로부터 약 3시간 뒤, 전면 글로벌 완화는 7시간이 안 걸려 끝났다. 이 부류의 버그 — 공조된 수정의 업계 관행이 몇 주에서 몇 달로 측정되는 — 에서 7시간은 진짜 성취다. 이 이야기가 서버에 관한 것뿐이었다면 거기서 끝났을 것이다. 빠르게 진화된 불.

그런데 거기서 끝나지 않았다. 그 페이지들을 읽고 있던 또 다른 이들 때문이다.

검색엔진 크롤러는 하루 종일 페이지를 받아 사본을 저장한다. 다섯 달 동안, Cloudflare가 앞단에 있던 사이트에서 크롤러가 받아 온 일부 페이지는 유출된 메모리가 HTML에 박힌 채 돌아왔고 — 크롤러는 그 페이지를, 비밀까지 통째로, 캐시했다. 기능을 끄자 누수는 즉시 멈췄다. 하지만 구글, 빙, 야후의 캐시에 이미 앉아 있는 사본들 — 조용히 검색 가능하고, 올바른 검색어를 아는 누군가를 기다리는 — 에 대해선 아무것도 하지 못했다. Cloudflare는 검색엔진들과 함께 유출 데이터가 담긴 캐시 응답을 찾아 지워야 했다 — 깨끗한 종료 상태가 없는 청소였다. 모든 사본을 다 찾았다고는 결코 확신할 수 없으니까.

이게 오래 남는 교훈이고, “버퍼 오버런은 나쁘다”도 “파서 변경을 테스트하라”도 아니다 — 둘 다 맞지만. 교훈은 이거다: 유출된 데이터에는 당신이 통제하지 못하는 두 번째 생애가 있다. 비밀이 HTTP 응답에 내려앉는 순간, 그건 캐시되고, 로깅되고, 스크린샷 찍히고, 스크랩될 수 있다 — 평범한 일을 하는 기계들에 의해, 당신이 들어 본 적도 없는 시스템으로 복사된다. 당신의 사건 타임라인은 “7시간 만에 해결”이라 적는다. 데이터의 타임라인은 “무기한”이라 적는다. 다른 시계이고, 비밀번호가 새어 나간 사람에게 중요한 건 두 번째 것이다.

공유의 청구서

클라우드블리드를 “부주의한 C” 서랍에 넣고 넘어가긴 쉽다. 나는 그게 맞는 분류라고 생각하지 않는다. ==는 실수였다, 물론. 메모리 안전 언어였다면 그 오버런을 조용한 누수가 아니라 크래시로 바꿨을 것이다 — 진짜 논거이고, 그토록 많은 엣지 인프라가 이후 Rust와 샌드박싱 쪽으로 옮겨 간 이유의 일부다.

하지만 한 바이트짜리 포인터 미끄러짐을 대규모 비밀 노출 사건으로 바꾼 건 언어가 아니었다. 모두가 일부러 택한 아키텍처였다: 인터넷 전체를 공유 함대 뒤에 두는 것, 그게 경제성을 성립시키니까. CDN은 설계상 위험을 집중시킨다 — 가용성을 위해 단일 장애점을 받아들인다, 대안이 더 나쁘니까. 클라우드블리드는 같은 거래의 기밀성 버전이다. 메모리가 공유되면, 공유된 부분의 버그는 한 고객의 버그가 아니다. 공유지의 버그이고, 공유지를 흘린다.

오버런은 몇 년 전에 패치됐다. 거래는 조금도 바뀌지 않았다. 우리는 여전히, 우리 모두가, 같은 한 줌의 공유 머신에 우리 비밀을 흘려보내고 있고, 대개 무사한 이유는 경계가 튼튼해서가 아니다. 포인터가 대개 버퍼 끝에 정확히 내려앉기 때문이다.

토론 참여

← 블로그로 돌아가기