2023년 8월 말, 지구상에서 가장 큰 네트워크 셋이 같은 공격에 맞았고, 셋 다 기록을 깼다. Google은 초당 3억 9800만 요청을 받아냈다. Cloudflare는 2억 100만. Amazon은 1억 5500만. 규모를 감 잡자면: 그 달 전까지 누구든 기록한 최대 공격은 초당 4600만 요청이었고, 그것도 Google이 한 해 전에 세운 것이다. 이번 건 기록을 살짝 밀어낸 게 아니다. 여덟 배 넘게 곱했다.
밤잠을 설치게 하는 건 봇넷이다. 대략 2만 대였다. 2만 대는 아무것도 아니다 — 어중간한 봇넷, 푼돈에 빌리는 그런 것. 2만 노드에서 초당 3억 9800만 요청이 나왔다는 건 각 노드가 초당 수만 요청을 지속적으로 뽑아냈다는 뜻이다. 이 계산은 평범한 HTTP 클라이언트로는 안 맞는다. HTTP/2가 허용하는 특정 동작 때문에만 맞는다. 그리고 그 동작은 버그가 아니다. 기능이다. 명세가 말하는 그대로 정확히 작동한다.
그래서 Rapid Reset — CVE-2023-44487 — 을 이해할 가치가 있다. 누가 코드에 남긴 결함이 아니다. 프로토콜 자신의 설계를 옆으로 비틀어 쓴 것이다.
HTTP/1.1은 어쩌다 공격자를 스로틀했나
무엇이 바뀌었는지 보려면, HTTP/1.1이 연결에 관해 얼마나 심심했는지 기억해야 한다.
HTTP/1.1에서 TCP 연결은 한 번에 요청 하나를 처리한다. 요청을 보내고, 응답을 기다리고, 그런 다음에야 그 연결에서 다음 요청을 보낼 수 있다. 더 많은 요청을 동시에 띄우고 싶으면 연결을 더 열어야 하는데 — 연결은 비싸다. 하나하나가 TCP 핸드셰이크, TLS 핸드셰이크, 양쪽 커널 상태다. 브라우저가 호스트당 대략 여섯 연결로 스스로를 제한하는 건 바로 연결이 공짜가 아니기 때문이다.
그래서 HTTP/1.1엔 아무도 일부러 설계하지 않은 우연한 성질이 있었다: 클라이언트를 스로틀했다. 연결을 여는 비용과 한 번에 하나 원칙 탓에, 공격하는 기계 한 대가 소켓이 바닥나기 전까지 밀어붙일 수 있는 양엔 한계가 있었다. 프로토콜의 굼뜸이 조용히 방어였던 셈이다.
HTTP/2는 바로 그 굼뜸을 고치려 했고, 완벽하게 성공했다.
멀티플렉싱, 그리고 그걸 가두려던 한도
HTTP/2의 간판 기능은 멀티플렉싱이다. TCP 연결 하나가 여러 독립 스트림을 동시에 나른다 — 각 스트림이 요청과 응답이고, 회선 위에서 인터리브되며, 더는 한 번에 하나씩 기다리지 않는다. 이건 진짜 훌륭하다. 애셋 100개짜리 페이지가 여섯 연결을 두고 다투는 대신 연결 하나로 뜨는 이유다.
하지만 “여러 스트림 동시에”엔 천장이 필요하다. 안 그러면 클라이언트가 백만 스트림을 열어 서버를 소진시킬 수 있으니까. 그래서 프로토콜엔 천장이 있다: SETTINGS_MAX_CONCURRENT_STREAMS. 서버가 사실상 “이 연결에선 한 번에 최대 100 스트림까지 열 수 있다”고 알린다. 백 개를 열면, 더 시작하기 전에 일부가 끝나길 기다려야 한다. 그 설정이 조속기다. 연결 하나가 요구할 수 있는 작업량을 묶기로 되어 있는 바로 그것이다.
여기 구멍이 있다. “열려 있는” 스트림이란 정확히 뭘 세는가?
공짜인 취소
HTTP/2는 어느 쪽이든 RST_STREAM 프레임을 보내 언제든 스트림을 취소할 수 있게 한다. 이건 좋고 필요한 기능이다 — 링크를 클릭해 페이지가 뜨기 시작했는데 다른 링크를 클릭하면, 브라우저는 버린 페이지의 진행 중 요청들을 취소한다. 취소는 정상이다. 끊임없이 일어난다.
그리고 스트림이 리셋되는 순간, 그건 더는 MAX_CONCURRENT_STREAMS에 계산되지 않는다. 당연하다 — 끝났으니까. 슬롯이 즉시 비어 새 요청이 차지할 수 있다.
이제 이 두 사실을 나란히 놓아 보자. 공격은 그게 전부다:
스트림을 연다 — HEADERS 프레임, 온전한 요청을 보낸다. 곧바로 RST_STREAM을 보내 취소한다. 슬롯이 다시 비었으니 또 하나 연다. 취소한다. 또 연다. 취소한다. 요청, 리셋, 요청, 리셋, 연결에 프레임을 써넣을 수 있는 최대 속도로. 어느 순간에도 스트림을 한두 개 넘게 “열어” 두지 않으니 동시성 천장을 절대 건드리지 않는다. 조속기는 한도 아래 편안히 앉은 얌전한 클라이언트를 본다. 그동안 당신은 프레임을 보내는 시간 안에 연결 하나로 수천 요청을 쏴버렸다 — 왕복 없음, 대기 없음, 새 연결 없음.
그게 비대칭이고, 잔혹하다. HEADERS 다음 RST_STREAM을 보내는 건 공격자에게 거의 공짜다: 작은 프레임 두어 개. 하지만 서버는 리셋을 처리하기 전 창에서 이미 비싼 부분을 해치운 경우가 많다 — 스트림 할당, 헤더 파싱, 요청 라우팅, 어쩌면 백엔드로 디스패치까지. 요청은 작업이 시작되기 전이 아니라 후에 취소된다. 당신은 우표값을 내고, 서버는 편지 전체 값을 낸 뒤 버린다. 연결당, 초당 수천 번.
그렇게 2만 대가 초당 3억 9800만 요청을 만든다. 각 기계는 대역폭을 쏟아붓는 게 아니다. 연결 몇 개를 열고 각각에서 요청-리셋 핑퐁을 치며, 이걸 묶기로 되어 있던 유일한 장치인 동시성 한도는 절대 걸리지 않는다. 취소된 스트림은 동시 스트림이 아니니까.
기능을 그냥 패치로 없앨 순 없다
Rapid Reset의 불편한 점은 지울 한 줄이 없다는 것이다. RST_STREAM은 존재해야 한다. 클라이언트는 정말로 요청을 취소해야 한다. 취소된 스트림은 동시 작업에 관한 동시성 한도에 정말로 계산되지 않아야 한다. 이 조각들 하나하나는 따로 떼면 올바르게 동작한다. 공격은 두 올바른 동작 사이의 이음새에 산다.
그래서 완화책은 기능을 금지하는 게 아니라 패턴을 알아채는 데 있다. 서버와 프록시는 연결의 리셋 비율을 추적하고, 완료하는 것보다 훨씬 많이 열고-취소하는 클라이언트를 악성으로 취급하도록 배웠다 — 스로틀하거나, 연결 전체를 닫아 TCP·TLS 비용을 다시 치르게 한다. 어떤 곳은 단일 연결이 평생 만들 수 있는 스트림 총수를 취소 여부와 무관하게 제한해, 요청-리셋 루프가 결국 재연결을 강제하게 한다. 예컨대 NGINX엔 오래전부터 연결당 요청을 제한하는 keepalive_requests가 있고, 이걸 낮추면 바로 이걸 무디게 한다. 어느 것도 취소를 불법화하지 않는다. 그저 초당 만 스트림을 취소하는 연결이 평범한 브라우저인 척하는 걸 멈출 뿐이다.
내가 자꾸 돌아오는 지점은 이렇다. HTTP의 역사 전체가 효율을 향한 행진이다 — 왕복 줄이기, 동시성 늘리기, 대기 줄이기. HTTP/1.1의 비효율은 아무도 지으려 하지 않은 우리였고, 우연히 공격자를 붙들어 뒀다. HTTP/2는 온당한 이유로 그 우리를 녹여버렸고, 페이지를 살아나게 하는 바로 그 효율이 공격자가 소켓 하나로 경기장 하나 분량의 요청을 쏘는 데 쓰는 효율이다. 우리는 취약점을 추가하지 않았다. 제약을 제거했고, 그 제약은 내내 무급으로 보안 일을 하고 있었다.
다음 프로토콜로 가져갈 교훈이 그거다. 무언가를 극적으로 싸게 만들면, 모두에게 싸게 만든다 — 당신 인프라가 청구서를 떠안는 채로 그걸 초당 만 번 하는 사람까지 포함해서. 효율적인 것과 악용 가능한 것은 자꾸 같은 것으로 밝혀지고, 대개 누가 기록을 깨고 나서야 어느 쪽이 어느 쪽인지 알게 된다.