Lobsters

Header Accordions: QPACK amplification attacks against HTTP/3 servers

Header Accordions: HTTP/3 서버를 겨냥한 QPACK 증폭 공격

QPACK 헤더 압축을 처리하는 HTTP/3 구현 10종 가운데 7종에서 작은 요청이 서버의 메모리 고갈이나 CPU 고정으로 이어지는 취약점이 발견됐습니다. 공통 원인은 압축된 크기만 제한하거나 디코딩 후에야 크기를 검사하는 데 있으며, 글은 필드별 누적 크기를 디코딩 과정에서 제한해야 한다고 설명합니다.

에디터 노트

압축된 입력 크기 제한만으로는 메모리 사용량을 제한하지 못한다는 점이 여러 구현의 공통 문제입니다. quic-go의 1MiB 요청이 약 15MiB로 남은 사례와 lsquic의 스트림별 제한 초기화는 디코딩 결과와 보관량을 함께 계산해야 한다는 주장을 뒷받침합니다. 다만 구현마다 실패 양상이 달라 같은 완화책으로 묶을 수는 없습니다. h3는 헤더 맵 용량에서 panic이 나고 aioquic은 제한값을 읽어도 검사에 쓰지 않습니다. 따라서 실제 보호 여부는 제한 설정의 존재가 아니라 디코딩 중 집행되는지와 애플리케이션이 객체를 얼마나 오래 보관하는지에 달려 있습니다. 글은 10개 구현을 조사했으며, 공개 서버 전체의 취약 비율이나 패치되지 않은 구현의 배포 규모는 제시하지 않습니다.

AI 요약

작은 HTTP/3 요청 하나가 서버 메모리를 요청 크기의 수십 배에서 수천 배까지 늘릴 수 있습니다. Sébastien Féry는 HTTP/3의 QPACK 헤더 압축을 구현한 10개 라이브러리를 조사해 7개에서 자원 고갈 취약점을 확인했습니다. 공격자는 인증 없이 연결할 수 있으며, 일부 구현에서는 연결 하나로 서버의 메모리를 고갈시키거나 CPU를 계속 점유하게 만듭니다.

압축 크기와 디코딩된 크기

QPACK은 자주 쓰는 헤더를 표나 인덱스로 표현해 전송량을 줄입니다. 예를 들어 원래 71바이트인 헤더를 정적 테이블 인덱스 1바이트로 보낼 수 있습니다. HTTP/3 표준 RFC 9114는 디코딩된 필드 목록의 크기를 헤더 이름과 값의 바이트 수에 필드마다 32바이트를 더해 계산하도록 정합니다. 취약 구현은 압축된 프레임 크기만 확인하거나, 모든 헤더를 먼저 풀고 나서 제한을 검사하거나, 32바이트 오버헤드를 빼먹었습니다. 제한값이 설정되지 않으면 무제한으로 처리하거나 설정값을 아예 적용하지 않는 구현도 있었습니다.

공격자는 서로 다른 이름의 헤더를 대량으로 넣어 수신 측에 많은 헤더 객체를 만들게 합니다. 100만 바이트 HEADERS 프레임이 약 16만 개의 고유 헤더로 풀릴 수 있습니다. 다른 방식은 동적 테이블에 약 4KiB 항목 하나를 넣은 뒤 1바이트 참조를 반복하는 것입니다. 참조마다 항목을 새로 복사하는 구현에서는 전송량과 메모리 사용량의 비율이 4064대 1에 이릅니다.

구현별 영향과 조치

quic-go는 압축 프레임을 최대 1MiB로 제한했지만 디코딩 결과에는 누적 제한을 적용하지 않았습니다. 약 100만 바이트 입력이 GC 후 약 15MiB의 요청 메모리로 남았습니다. 0.57.0은 필드별로 크기를 확인하는 점진적 디코딩을 도입하고, 초과 요청에 HTTP 431을 반환합니다. Cloudflare quiche는 필드별 32바이트 오버헤드를 빼먹은 데다, 선언된 프레임 길이만 보고 데이터를 받기 전에 버퍼를 미리 할당하는 문제도 있었습니다. 0.29.3에서 두 문제를 수정했습니다.

lsquic은 디코딩 누적 크기 제한이 없고 기본 제한값이 무제한이며, 64KiB 제한도 HEADERS 블록마다 초기화됐습니다. 그 결과 50개 연결과 10MiB 미만의 트래픽으로 메모리 5GiB 한도에 빠르게 도달했습니다. 4.9.0은 스트림마다 버퍼링하는 디코딩 헤더 집합 수를 제한합니다. Bun은 lsquic을 사용하면서 누적 제한을 적용하지 않아 2GiB 컨테이너를 메모리 부족으로 종료시킬 수 있었습니다. 약 516KiB 요청으로 버퍼 크기 계산의 정수 오버플로를 일으켜 서버 CPU를 100%로 고정하는 별도 문제도 있었으며, 글은 두 문제에 같은 크기 검사를 적용하는 수정 방안을 제시합니다.

aioquic은 MAX_FIELD_SECTION_SIZE 값을 읽고 저장하지만 디코딩 중 검사하지 않았습니다. 라이브러리에서 디코딩 결과를 바로 버리는 최소 서버는 버텼지만, 요청 객체를 스트림이 열려 있는 동안 보관하는 mitmproxy는 영향을 받았습니다. Rust의 h3는 디코딩한 헤더를 HeaderMap에 넣는 과정에서 32,768개 용량을 넘으면 panic이 발생합니다. Wireshark는 제한 없는 디코딩으로 HTTP/3 분석기가 멈추고 종료될 수 있었습니다. h3는 제한값을 작게 설정해 재현 경로를 막을 수 있다고 설명합니다.

제한을 실제로 집행하는 위치

저자는 네트워크 전송량을 세는 QUIC 계층은 헤더 객체가 메모리에서 차지하는 크기를 알지 못한다고 짚습니다. QPACK 디코더가 누적 한도를 적용하거나 HTTP/3 계층과 애플리케이션이 객체를 보관하기 전에 검사해야 합니다. Google quiche는 디코딩 중 60KiB 제한을 집행하고 위반 스트림을 초기화하며, IIS도 점진적으로 제한을 적용합니다. ngtcp2/nghttp3은 기본 동적 테이블 크기가 0이지만 max_field_section_size 설정은 알림 용도이며 애플리케이션이 직접 집행해야 합니다.

2026년 10월 20일 기준 quic-go, Cloudflare quiche, lsquic, Wireshark는 수정 버전을 배포했습니다. Bun, h3, aioquic/mitmproxy는 미수정으로 분류됐으며, 글은 Bun 서버에서 HTTP/3를 비활성화하고 mitmproxy는 http3=false를 설정하며 h3에서는 max_field_section_size를 합리적인 값으로 지정하도록 안내합니다. 저자는 JA4Scan-QUIC 지문과 재현 코드를 함께 공개했습니다.

원문: FoxIO 블로그 / 번역·요약: Trawling