HTTP/2 CONTINUATION Flood: 로그에 남지 않는 DoS

2024년 4월, 한 연구자가 단 하나의 TCP 연결로 Apache·Node.js·Tomcat을 무너뜨릴 수 있음을 보였다. 그 HTTP/2 프레임은 액세스 로그에 남지 않는다. 버그는 코드가 아니라 명세의 이음새에 있었기에 동시에 모든 곳에서 터졌다.

HTTP/2 CONTINUATION Flood에서 머릿속을 떠나지 않는 대목은 이것이다: 이 공격은 액세스 로그에 남지 않는다. “노이즈 속에서 찾기 어렵다”가 아니다. 아예 없다. 웹 서버가 CPU 100%에 붙박이거나 OOM 강제 종료를 향해 메모리가 치솟고 있고, 단 하나의 TCP 연결로 공격을 흡수하고 있는데 — 무엇이 나를 때리는지 보려고 로그를 열면 아무것도 없다. 마지막 줄은 공격이 시작되기 전에 쓰였다.

바로 그 점이 한 해 전의 기록 경신 HTTP/2 공격, Rapid Reset보다 이걸 더 나쁘게 만든다. Rapid Reset은 적어도 자기 존재를 알렸다: 요청의 소방호스, 수천 대 봇넷, 그래프로 그릴 수 있는 무언가. CONTINUATION Flood는 Bartek Nowotarski가 2024년 4월 3일 CERT/CC의 VU#421644로 공개한 것으로, 단 한 대의 머신에서 — 때로는 단일 연결과 몇 개의 프레임만으로 — 서버를 무너뜨리면서 그 일이 벌어졌다는 요청 형태의 증거를 남기지 않는다. 왜 그런지 이해하려면 HTTP/2가 헤더를 보내는 방식의 작고 지루한 구석을 봐야 한다.

조각으로 도착하는 헤더

HTTP/1.1에서 요청의 헤더는 빈 줄이 나올 때까지 읽는 텍스트일 뿐이다. HTTP/2는 그렇게 동작하지 않는다. 모든 것이 프레임화되고, 헤더는 HPACK으로 압축되어 헤더 블록으로 전송된다. 블록은 HEADERS 프레임으로 시작한다. 그런데 프레임 하나에는 크기 상한이 있고, 실제 헤더 집합 — 쿠키, 인증 토큰, 늘 그렇듯 비대한 것들 — 은 그걸 넘칠 수 있다. 그래서 프로토콜은 블록을 여러 프레임에 걸쳐 이어가게 한다: HEADERS, 그다음 0개 이상의 CONTINUATION 프레임, 각각이 압축된 헤더 데이터의 다음 조각을 나른다.

수신 측은 헤더가 마침내 완성됐는지 어떻게 알까? 비트 하나다. 이 시퀀스의 각 프레임에는 END_HEADERS 플래그가 있다. 그게 꺼져 있는 동안 송신자는 “헤더 데이터가 더 온다, 계속 읽어라”라고 말하는 것이다. END_HEADERS가 켜진 프레임이 도착하면 블록이 끝나고 서버는 요청 조립을 마칠 수 있다. 이것이 RFC 9113 §6.10이고, 전적으로 합리적이다. 헤더는 가변 길이이니 “아직 안 끝났다”고 말할 방법이 필요하다. 그 플래그가 바로 그 방법이다.

이제 그 모양을 곱씹어 보자. END_HEADERS가 나타나기 전까지 요청은 요청이 아니다. 그 플래그 이전의 모든 것은 진짜가 아직 오고 있다는 약속이다. 그리고 절반쯤 읽은 헤더 블록을 쥔 서버는 기다리는 동안 그 조각들을 어딘가에 담아 둬야 한다.

”아직 더 있음”이라고 말하는 프레임

공격은 한 문장이다: 스트림을 열고, END_HEADERS 없는 HEADERS 프레임을 보낸 뒤, CONTINUATION 프레임을 영원히 보내며 그 플래그를 결코 켜지 않는다.

서버는 요청을 완성할 기회를 얻지 못하니, 요청을 디스패치하거나 응답하거나 — 결정적으로 — 로그를 남기는 단계에 이르지 못한다. 그렇다고 들어오는 프레임을 무시할 수도 없다. 유효한 HTTP/2이기 때문이다. 그래서 명세가 시사하는 대로 한다: 계속 받아서 처리한다. 그리고 그 처리는 구현에 따라 둘 중 하나의 나쁜 모양을 띤다.

첫째, 서버가 각 조각을 메모리에서 만들고 있는 헤더 블록에 덧붙이며 오지 않는 끝을 기다린다. 블록이 무한정 커진다. 메모리가 치솟는다. 결국 프로세스는 OOM 수확자에게 죽거나, 박스 전체를 끌고 내려간다. 연결 하나, 작은 프레임의 흐름, 그리고 서버가 자기 자신을 삼킨다.

둘째, 서버가 메모리에 대해 조금 더 영리하다 — 최대 헤더 크기를 강제하고 그 너머의 조각은 보관하지 않을지도 모른다 — 하지만 여전히 도착하는 CONTINUATION 프레임을 하나하나 디코드한다. HPACK 압축 해제를 돌리고, 검증하고, 버린다. 메모리 증가는 없지만, 결코 존재하지 않을 요청을 위해 끝없는 헤더 흐름에 CPU가 돈다. 같은 결과, 다른 자원: 가용성이 사라진다.

어느 쪽이든 로깅 계층은 이 모든 것 위에 앉아, 한 줄을 쓸 완성된 요청을 기다린다. 그것은 결코 오지 않는다. 공격은 전적으로 요청이 요청이 되기 이전의 공간에서 산다 — 정확히 당신의 관측 체계가 다루지 않는 그 공간에서.

왜 모든 곳에서 동시에 터졌나

이것이 베낀 버그가 아니라 설계 수준의 문제였다는 증거는 CVE 목록이다. 한 벤더의 실수가 아니었다. 거의 동시에: Apache httpd(CVE-2024-27316), Apache Tomcat(CVE-2024-24549), Apache Traffic Server(CVE-2024-31309), nghttp2를 통한 Node.js(CVE-2024-27983과 CVE-2024-28182), Envoy(CVE-2024-27919와 CVE-2024-30255), Go의 net/http(CVE-2023-45288), amphp HTTP 서버(CVE-2024-2653), Tempesta FW(CVE-2024-2758), 그 외 다수. 서로 다른 사람들이 서로 다른 언어로 쓴 독립된 코드베이스 한 무더기가, 같은 자리에 같은 구멍을 갖고 있었다.

그런 일은 결함이 누군가 타이핑한 것이 아니라 모두가 명세를 읽는 방식에 있을 때 벌어진다. RFC 9113은 CONTINUATION 프레임과 END_HEADERS 플래그를 정확히 기술한다. 명세가 구현자에게 기댄 것 — 그리고 대부분이 충분히 단단하게 하지 않은 것 — 은 그것을 한계 짓는 일이었다: END_HEADERS가 나타나야 하기 전에 피어가 보낼 수 있는 CONTINUATION 프레임 수, 혹은 총 헤더 바이트 수에 상한을 두는 것. 명세는 CONTINUATION 프레임이 악용될 수 있다고 경고하기까지 한다. 하지만 산문 속 경고는 코드 속 한계가 아니고, 열두 팀이 저마다 헤더 블록이야 당연히 언젠가 끝나겠지 하고 판단했다. 공격자는 바로 그것을 끝내지 않기로 하는 사람들이다.

조율된 공개가 이뤄진 뒤 수정들은 모두 같은 모양이었다: END_HEADERS 이전에 도착하는 프레임 또는 바이트를 세고, 피어가 합리적 상한을 넘기면 정중히 무한정 버퍼링하는 대신 연결을 끊어 버린다. 누구도 CONTINUATION 프레임을 없애지 않았다 — 정상적인 대형 헤더 집합에는 여전히 필요하다. 그저 문장을 끝내지 않은 피어에게 무한 신뢰를 베푸는 것을 멈췄을 뿐이다.

요청이라는 단위 아래를 때리기

내가 계속 되돌아오는 지점이자, Rapid Reset이 다른 조성으로 가르친 것과 같은 교훈이 여기 있다.

웹 서버를 지키려고 우리가 짓는 거의 모든 것은 요청을 원자 단위로 삼는다. 레이트 리밋은 초당 요청을 센다. WAF 규칙은 요청을 검사한다. 액세스 로그는 요청을 기록한다. 지표 대시보드는 요청을 그린다. 요청은 스택 전체의 회계 단위다. CONTINUATION Flood가 통하는 이유는 그것이 그 단위 아래를 때리기 때문이다 — 요청 이전(pre-request), 실제 CPU와 메모리를 먹으면서도 당신의 방어가 볼 줄 아는 그 객체로 결코 승격되지 않는 무언가. 당신의 아키텍처가 아직 존재한다고 믿지 않는 것을 레이트 리밋하거나 검사하거나 로깅할 수는 없다.

Rapid Reset은 반대편에서 같은 수를 썼다: 요청을 열고-취소하기를 너무 싸게 만들어, 그것을 한계 지어야 할 동시성 제한이 결코 걸리지 않게 했다 — 취소된 스트림은 동시 스트림으로 세지 않으니까. 두 공격 모두 HTTP/2가 무언가가 “세어지는” 시점을 미루는 순간을 찾았다 — 한쪽은 취소, 다른 쪽은 END_HEADERS — 그리고 그 미룸 속에 들어가 살았다.

앞으로 새겨 둘 패턴이 그것이다. 프로토콜이 어떤 자원이 커밋되는 순간이나 어떤 요청이 진짜가 되는 순간을 미루게 할 때마다, 당신은 창을 하나 만든 것이고, 그 창은 아직 회계가 적용되지 않는 곳이다. 효율과 악용 가능성은 자꾸 같은 지렛대로 드러난다. HTTP/2는 헤더가 조각으로 도착하게 하고 스트림을 자유롭게 취소하게 함으로써 빨라졌다 — 둘 다 진정 좋은 아이디어다 — 그리고 각각은 공격자에게, 스택 자신의 장부로는 시작조차 하지 않은 무언가에 서버가 무한한 일을 하게 만들 방법을 쥐여 줬다. 다음에 “이건 나중에 회계 처리하자”고 말하는 시스템을 설계하거든, 누군가 그 나중이 결코 오지 않게 만들 것이라고 가정하라.

토론 참여

← 블로그로 돌아가기