
원격의 인증되지 않은 클라이언트가 HTTP/3 서버에 작은 요청을 보내고도 전송한 데이터보다 수십 배에서 수천 배 많은 메모리를 쓰게 할 수 있다. 조사한 구현 10개 가운데 7개가 취약했으며, 2026년 10월 20일 기준 일부는 이미 수정됐고 일부는 패치되지 않았다.
QPACK은 HTTP/3 헤더를 압축해 전송량을 줄인다. 문제는 취약한 구현들이 압축 해제된 헤더의 크기를 제한하지 않거나, 제한을 불완전하게 적용하거나, 헤더를 모두 풀어 메모리에 보관한 뒤에야 검사한다는 점이다. 작은 HEADERS 프레임이 메모리에서 수십만 개의 헤더 객체로 커질 수 있다. 이를 막으려면 필드를 풀어 보관하는 동안 누적 크기를 제한해야 한다.
2026년 10월 20일 기준 수정된 구현은 quic-go 0.57.0(CVE-2025-64702), Cloudflare quiche 0.29.3(CVE-2026-12523), lsquic 4.9.0, Wireshark 4.6.2 또는 4.4.12(CVE-2025-13945)다. Bun 1.4.3, h3(hyperium), aioquic/mitmproxy는 패치되지 않은 상태로 표시됐다. 완화책으로는 Bun에서 HTTP/3 서버를 외부에 노출하지 않는 방법, mitmproxy에서 --set http3=false로 HTTP/3를 끄는 방법, h3에서 적절한 max_field_section_size를 설정하는 방법이 제시됐다. IIS, Envoy, ngtcp2/nghttp3는 영향받지 않는 구현으로 분류됐다.
취약점이 생기는 방식
JA4Scan이라는 능동 TLS 스캐너의 QUIC 스택을 조사하던 중 여러 HTTP/3 구현에서 같은 유형의 취약점이 발견됐다. 조사는 HTTP/2 요청 취소를 이용한 서비스 거부 공격을 다룬 갤 바르 나훔(Gal Bar Nahum)의 MadeYouReset 연재를 읽은 뒤, HTTP/3의 QPACK 헤더 압축에도 비슷한 증폭 문제가 있는지 살펴보며 시작됐다.
QPACK은 자주 쓰이는 user-agent, content-type 같은 필드를 전체 문자열 대신 작은 인덱스로 보낸다. 이를 위해 명세에 정해진 공통 필드의 정적 테이블과, 클라이언트와 서버가 새 헤더를 보며 실행 중에 만드는 동적 테이블을 사용한다. 예를 들어 71바이트인 strict-transport-security: max-age=31536000; includesubdomains; preload는 정적 테이블의 인덱스 58을 가리키는 1바이트 0xFA로 표현된다.
RFC 9114 4.2.2절은 압축 해제된 필드 목록의 크기를 필드 이름과 값의 바이트 길이에 필드마다 32바이트의 오버헤드를 더해 계산하도록 정한다. 조사 대상 구현 가운데 일부는 다음 가운데 하나 이상을 잘못 처리했다.
- 압축된 프레임 크기만 검사하고 압축 해제된 크기는 검사하지 않는다.
- 검사 전에 전체 데이터를 압축 해제한다.
- 크기 계산에서 필드당 32바이트 오버헤드를 빼먹는다.
- 설정이 없으면 제한을 무제한으로 둔다.
- 제한을 설정해도 실제로 적용하지 않는다.
공격 변형 하나는 단일 HEADERS 프레임 안에 들어가는 헤더 수를 최대화한다. 수신 측 헤더 맵에 서로 다른 항목이 생기도록 a부터 z, aa부터 zz까지 고유한 이름을 만들고, 인코딩 크기 한도에 이를 때까지 세 글자와 네 글자 이름을 이어 붙인다. 두 글자 이름은 676개지만, 특별한 값 조건이 있는 te는 제외해야 한다. 이 방식으로 1,000,000바이트 HEADERS 프레임에 약 160,000개의 고유 헤더를 담을 수 있다.
반대 방향의 증폭도 가능하다. 인코더 스트림에 약 4KiB짜리 동적 테이블 항목 하나를 넣은 뒤, 이를 1바이트 인덱스 참조로 수천 번 반복한다. 참조 하나마다 전체 항목을 새로 복사해 할당하므로 lsquic과 Bun에서 1바이트 참조가 전체 항목을 만드는 4064:1 비율의 증폭이 발생했다.
구현별 영향과 수정 상태
| 구현 | 취약 여부 | CVE·상태 | 전송량 대비 결과·근본 원인 |
|---|---|---|---|
| quic-go | 예 | CVE-2025-64702 | 보관 힙 메모리 약 15배. 압축 크기만 검사하고 압축 해제 한도는 없음 |
| Wireshark | 예 | CVE-2025-13945 | 충돌. QPACK 디섹터가 압축 해제 수를 제한하지 않음 |
| Cloudflare quiche | 예 | CVE-2026-12523 | 약 20배. 필드당 32바이트 오버헤드 누락, 프레임 사전 할당 문제 |
| lsquic | 예 | 수정됨, CVE 없음 | 약 600~1800배. 누적 압축 해제 크기 제한이 없고 HEADERS 블록을 무제한 허용 |
| aioquic / mitmproxy | 예 | - | 약 20~30배. MAX_FIELD_SECTION_SIZE를 읽지만 적용하지 않음 |
| Bun | 예 | - | 약 4000배. lsquic의 64KiB 제한을 없앰 |
| h3 (hyperium) | 예 | 보고됨, 미수정 | 스레드 패닉. HeaderMap::append가 항목 32,768개에서 패닉 |
| Google quiche | 아니요 | - | 60KiB 제한을 압축 해제 중 적용하고 위반 시 스트림을 재설정 |
| IIS (http.sys) | 아니요 | - | 점진적으로 제한을 적용하고 4KiB QPACK 테이블 사용, H3_EXCESSIVE_LOAD 반환 |
| ngtcp2 / nghttp3 | 아니요 | 보고됨, 문서 갱신 | 동적 테이블 기본값 0, 즉시 참조 감소 처리. max_field_section_size는 신호 전달만 하며 적용은 호출 측 책임 |
구현별 세부 내용
quic-go
quic-go는 Caddy, Traefik, cloudflared가 사용하는 Go 라이브러리다. HTTP/3 서버는 DefaultMaxHeaderBytes로 HEADERS 프레임을 1MiB로 제한하지만, 이 한도는 압축 해제된 결과가 아니라 압축된 QPACK 페이로드에 적용된다. qpack/decoder.go의 DecodeFull은 블록 전체를 한 번에 풀어 []HeaderField 슬라이스로 반환하고, 이후 이를 http.Header 맵으로 바꾼다.
func (d *Decoder) DecodeFull(p []byte) ([]HeaderField, error) {
var hf []HeaderField
d.emitFunc = func(f HeaderField) { hf = append(hf, f) }
if _, err := d.writeLocked(p); err != nil {
return nil, err
}
return hf, nil
}
http.Header는 map[string][]string이다. 키와 값 슬라이스, 내부 저장 공간 때문에 헤더를 인코딩한 크기보다 메모리를 더 쓴다. 1,000,000바이트 QPACK 블록은 160,075개 필드로 풀렸고, 가비지 컬렉션 뒤 요청에 약 15MiB가 유지됐다. 이는 인코딩 크기의 약 15배다. 즉 quic-go는 압축된 HEADERS 프레임만 제한하고 압축 해제된 헤더의 대응 한도는 적용하지 않았다.
수정된 quic-go는 SETTINGS_MAX_FIELD_SECTION_SIZE를 보내고, 항목을 하나씩 풀면서 크기를 검사하도록 바꿨다. 서버는 한도를 넘으면 HTTP 431로 요청을 중단한다.
Cloudflare quiche
quiche에는 하나의 CVE로 묶인 문제가 두 가지 있었다. 첫째, qpack/decoder.rs의 decode()는 크기를 계산할 때 name.len() + value.len()만 사용해 RFC가 요구하는 필드당 32바이트 오버헤드를 누락했다. 이 때문에 의도한 제한보다 최대 33배 많은 헤더가 통과할 수 있었다.
left = left
.checked_sub((name.len() + value.len()) as u64)
.ok_or(Error::HeaderListTooLarge)?;
max_field_section_size가 1MiB라면 RFC 기준으로는 필드마다 최소 33바이트를 차지하므로 약 32,000개 헤더가 허용된다. quiche의 계산은 길이가 1바이트인 헤더 약 1,000,000개까지 통과시킬 수 있었다.
둘째, quiche의 HTTP/3 프레임 파서는 페이로드를 받기 전에 선언된 프레임 길이를 기준으로 버퍼를 미리 할당했다. 공격자가 16MiB SETTINGS 또는 GOAWAY 프레임을 선언하고 데이터를 보내지 않으면 즉시 메모리 할당이 일어났다. quiche 0.29.3은 32바이트 오버헤드 계산을 추가하고 프레임 사전 할당 문제를 수정했다.
lsquic
조사에서 가장 복합적인 문제는 lsquic에서 발견됐다. header_out_dynamic_entry는 참조마다 메모리를 할당하고 복사하면서 누적 압축 해제 크기를 제한하지 않았다. es_max_header_list_size는 기본값 0, 즉 무제한이었고 QPACK 디코더에 전달되지 않았다. 또한 64KiB 헤더 제한은 스트림별이 아니라 HEADERS 블록마다 초기화됐으며, lsquic은 한 스트림에서 DATA보다 앞서 무제한 HEADERS 블록을 받아들였다.
공격자는 인코더 스트림에 약 4KiB 항목을 하나 넣은 뒤 각 요청 스트림에 1바이트 인덱스 참조 15개가 든 HEADERS 프레임을 연달아 보낼 수 있었다. 전송량 10MiB 미만, 연결 50개로 1초 안에 상주 메모리를 5GiB 상한까지 고정할 수 있었고, cgroup의 memory.events에는 한도 도달이 900회 넘게 기록됐다. PoC의 기본 설정인 연결 10개와 256MiB 컨테이너에서도 규모만 작을 뿐 같은 현상이 나타났다.
lsquic 4.9.0은 2026년 7월 15일 이 문제를 수정했다. 서버 기본값이 1인 es_max_header_sets를 추가해 스트림마다 버퍼에 보관하는 압축 해제 헤더 집합을 제한하고, 애플리케이션이 이를 소비할 때까지 HTTP/3 파싱을 멈춘다. 테스트 중에는 www.litespeedtech.com을 포함한 실제 서버가 QPACK_MAX_TABLE_CAPACITY=4096과 MAX_HEADER_LIST_SIZE=65536을 광고하는 것도 관찰됐다.
Bun
Bun의 HTTP/3 지원은 내부적으로 lsquic을 쓰지만, lsquic의 누적 64KiB 제한이 있는 내장 헤더 집합 콜백 대신 quic.c의 us_quic_hsi_prepare/process를 사용한다. 이 함수들은 헤더 하나의 크기는 64KiB로 제한하지만 누적 크기는 제한하지 않는다. es_max_header_list_size=16384도 SETTINGS에서 알리기만 하고 적용하지 않는다.
같은 동적 테이블 공격에서 연결 하나와 스트림 100개를 사용하고, 스트림마다 4KiB 항목을 가리키는 1바이트 참조를 최대 50만 번 보낼 수 있었다. 스트림당 참조 6만 개, 전체 전송량 5.8MiB만으로도 2GiB 컨테이너가 메모리 부족으로 종료됐다.
같은 함수에는 증폭을 영구 서비스 거부로 바꾸는 두 번째 버그도 있었다. us_quic_hsi_prepare는 버퍼 용량을 unsigned int로 추적하고 두 배씩 늘린다.
unsigned int need = h->len + (unsigned int) space;
if (need > h->cap) {
unsigned int ncap = h->cap ? h->cap : 512;
while (ncap < need) ncap *= 2;
char *nb = (char *) us_realloc(h->buf, ncap);
압축 해제된 헤더 집합이 2GiB를 넘으면 need가 2^31보다 커진다. ncap은 2^31을 지나 0이 되고, 0에서 두 배를 해도 계속 0이므로 0 < need 조건이 영원히 참으로 남는다. 그 결과 lsquic_engine_process_conns 안에서 무한 루프가 발생한다. 약 528,500개의 인덱스 참조, 전송량 약 516KiB를 담은 단일 스트림만으로 서버 CPU 사용률이 100%에 고정되고 복구되지 않아 새 요청을 처리하지 못했다. 누적 제한이 없어서 정수 오버플로가 가능한 지점까지 도달할 수 있었다. lsquic 기본 64KiB 제한은 이를 막는다.
두 문제의 수정 방법으로 다음 검사를 제시했다.
if (h->len + space > 16 * 1024) return NULL;
aioquic / mitmproxy
aioquic은 mitmproxy와 hypercorn이 쓰는 Python HTTP/3 라이브러리다. 설정 상수 MAX_FIELD_SECTION_SIZE를 정의하고 피어가 보낸 값을 읽어 저장하지만, 압축 해제 중에는 확인하지 않는다. _decode_headers()는 모든 압축 해제 헤더를 그대로 반환한다.
aioquic의 흐름 제어도 상한 없이 자동으로 두 배씩 늘어난다. 소비량이 50%를 넘으면 limit.value *= 2를 실행하므로 연결 하나가 무제한 데이터를 전달할 수 있다. 라이브러리 자체는 압축 해제 목록을 일시적으로만 보유한다. 헤더 처리 후 버리는 최소 aioquic 서버는 살아남지만, mitmproxy는 스트림이 열린 동안 스트림마다 http.Request를 만들고 유지하므로 영향을 받는다.
h3 (hyperium)
h3는 hyperium 프로젝트의 Rust HTTP/3 라이브러리다. h3-quinn 어댑터를 통해 quinn과 함께 쓸 수 있으며 tokio-quiche에서도 사용된다. h3는 QPACK 헤더를 모두 Vec<HeaderField>로 푼 뒤 h3/src/proto/headers.rs에서 TryFrom<Vec<HeaderField>>를 거쳐 http::HeaderMap으로 변환한다.
Field::Header((n, v)) => {
fields.append(n, v);
}
HeaderMap::append는 try_append(...).expect("size overflows MAX_SIZE")를 호출한다. 맵 용량을 넘으면 패닉이 발생한다. h3의 기본 설정에서는 max_field_section_size가 사실상 무제한이어서 지나치게 큰 압축 해제 헤더 집합이 이 경로에 도달할 수 있다. 충분히 작은 압축 해제 크기 제한을 설정하면 이 재현 사례가 맵 용량 한도에 도달하는 것을 막을 수 있다. 관찰된 로그는 다음과 같다.
thread 'tokio-runtime-worker' panicked at .../http-1.4.0/src/header/map.rs:526:43:
size overflows MAX_SIZE: MaxSizeReached
Wireshark
이 문제는 quic-go용 페이로드를 시험하던 중 우연히 발견됐다. 허용되는 압축 해제 헤더 수가 제한되지 않았고 느린 tvb_composite 처리가 겹쳐 HTTP/3 디섹터가 멈춘 뒤 충돌했다.
제한을 어느 계층에서 적용할 것인가
헤더를 압축 해제하고 보관하는 과정에는 여러 구성 요소가 관여한다. QUIC은 스트림으로 받은 바이트를 계산하지만 그 바이트가 메모리에서 몇 개의 헤더 객체가 되는지는 알지 못한다. QPACK 디코더는 필드를 복원하며, API에 따라 압축 해제 크기를 직접 제한할 수도 있고 호출 측에 책임을 넘길 수도 있다. HTTP/3 계층과 애플리케이션은 필드를 요청 객체로 바꾸고 보관하며, 이들 컨테이너에도 별도의 메모리 비용과 용량 한도가 있다.
nghttp3 사례는 책임 경계를 분명히 보여준다. 설정으로 한도를 광고하지만 실제 적용은 애플리케이션이 해야 한다. h3에서는 반대로 디코더를 통과한 필드가 나중에 HeaderMap에 삽입될 때 패닉을 일으킬 수 있다. 결국 어느 구성 요소가 한도를 넘는 헤더를 실제로 거부하는지가 중요하다. 애플리케이션이 책임을 맡는다면 헤더를 보관하기 전에 한도를 알고 적용해야 한다.
작은 HEADERS 프레임은 큰 처리량을 나타낼 수 있다. 조사한 구현에서는 그 처리가 대규모 메모리 할당, 보관된 헤더 집합 또는 하위 컨테이너 한도에 도달했을 때의 패닉으로 이어졌다. 프로토콜에는 압축 해제된 헤더 크기를 계산하는 방법이 있지만, 수신 코드가 이를 적용해야 한다. 압축된 프레임 크기 검사는 출발점일 뿐이다. 필드별 오버헤드를 포함한 압축 해제 한도를 헤더를 만드는 동안 적용하고, 애플리케이션이 보관하는 데이터에도 적용해야 한다. 여러 프로젝트가 이 경로를 수정했지만, 설정으로 광고한 한도가 실제 입력을 제한하지 않는 사례도 남아 있다.
영향받는 서버 찾기
공개 서버 가운데 영향받는 대상을 찾을 때 참고할 JA4Scan-QUIC 지문은 다음과 같다.
| 라이브러리 | JA4Scan-QUIC |
|---|---|
| quic-go v0.56.0 | 031g0hlss000_5973261134f6_6d6a17008abe_sr10sc7b63be |
| quiche 0.29.2 | 03100hlss200_8960528bd01f_6d6a17d04082_sr20ss7fb1bf |
| OLS 1.9.1 (lsquic 4.8.2) | 032g0h0ss200_24e1d2569964_d1082305b91e_0r100s0b3f78 |
| Bun 1.4.3 | 032g0h0ss200_756c1a5a0530_67f99a05b91e_s010s0fecef0 |
| aioquic 1.7.0 | 0310000ss000_2a673bc4abf8_db4d1fe4882a_sr1s0seb0750 |
| mitmproxy 12.2.3 | 0310000ss000_2a673bc4abf8_aee409e4882a_sr2s0seb0750 |
| h3 0.0.8 | 031g0hlsr200_8960528bd01f_6d6a17129383_se10s0fecef0 |
JA4Scan에 관한 전체 글은 FoxIO 블로그에서 확인할 수 있다. 재현 코드는 GitHub 저장소에 공개돼 있다.
공개·수정 일정
- 2025년 11월 9일: quic-go에 보고
- 2025년 11월 14일: Wireshark에 보고
- 2025년 11월 15일: quiche에 보고
- 2025년 11월 21일: quic-go 0.57.0 출시
- 2025년 12월 3일: Wireshark 4.6.2 출시
- 2025년 12월 4일: h3에 보고
- 2025년 12월 5일: h3 관리자가 이슈를 닫음. 알파 라이브러리이며 기본 설정을 바꿀 예정이라고 설명
- 2025년 12월 17일: nghttp3가
max_field_section_size는 신호 전달 전용이라고 문서화 - 2026년 5월 24일: Bun에 보고
- 2026년 7월 13일: aioquic 및 lsquic에 보고
- 2026년 7월 14일: quiche 0.29.3 출시
- 2026년 10월 20일: 블로그 게시
참고 자료
- RFC 9114 — HTTP/3, 4.2.2절(헤더 크기 제한)
- RFC 9204 — QPACK: HTTP/3용 필드 압축
- nghttp3 — 필드 크기 제한 설명
- quic-go CVE-2025-64702 — 보안 권고
- Wireshark CVE-2025-13945 — 보안 공지
- Cloudflare quiche CVE-2026-12523 — 보안 권고
- 갤 바르 나훔 — MadeYouReset HTTP/2
댓글 (0)
로그인하면 이 기사에 내 생각을 남길 수 있어요