헤더에 gzip을 씌웠더니 세션 쿠키가 새어나갔다 — HPACK은 CRIME을 어떻게 구조적으로 막는가

seonwoo_jung·2026년 8월 1일

1. "헤더용 gzip"이라는 오해에서 출발했다

HTTP/2를 처음 봤을 때 헤더 압축(HPACK)을 "요청 헤더에 gzip을 씌운 것" 정도로 넘겨짚었다. 그런데 스펙을 열어 보니 HPACK은 별도 RFC(RFC 7541) 하나를 통째로 차지하고 있었고, 그 설계 동기의 절반이 압축률이 아니라 보안이었다. HTTP/1.1은 요청마다 Cookie, User-Agent, Accept 같은 헤더를 거의 그대로 반복해서 보낸다. HTTP/2는 한 TCP 연결에 수십~수백 개의 스트림을 다중화하니 이 중복이 곱해져 무시할 수 없는 양이 된다.

그래서 HTTP/2의 전신인 SPDY는 헤더에 DEFLATE(gzip)를 그냥 씌웠다. 그리고 그게 CRIME 공격으로 뚫렸다. 이 글은 "왜 헤더에 gzip을 못 쓰는가", 그리고 HPACK이 그 자리를 어떤 자료구조로 대체했는가를 RFC 7541을 따라가며 정리한 것이다.

2. CRIME — 압축이 사이드채널이 되는 순간

DEFLATE는 슬라이딩 윈도우 안에서 반복되는 바이트열을 뒤쪽에서 앞쪽 참조로 치환한다. 문제는 이 "반복"이 서로 다른 출처의 데이터를 한 문맥에서 섞는다는 데 있다. 공격자가 주입할 수 있는 값(예: URL 파라미터)과 비밀(세션 쿠키)이 같은 압축 문맥에 들어가면, 공격자가 넣은 값이 쿠키와 겹칠수록 압축 결과가 짧아진다.

압축 후 길이만 관찰해도 비밀을 한 바이트씩 알아낼 수 있다 — 이것이 2012년 Rizzo와 Duong이 보인 CRIME의 핵심이다.

즉 문제의 근원은 gzip 자체가 아니라 "서로 다른 신뢰 수준의 데이터를 하나의 슬라이딩 윈도우 문맥에서 섞는 것"이다. HPACK은 이 문맥 혼합을 버리고, 문맥 없이 필드 단위로 참조/치환하는 방식으로 처음부터 다시 설계됐다.

3. 인덱스 주소공간 — 정적 테이블 + 동적 테이블

HPACK의 중심은 (이름, 값) 쌍을 올려두는 단일 인덱스 공간이다.

 index:  1 ......... 61 | 62 .................. 61+k
        [ 정적 테이블   ] [ 동적 테이블 (신→구)       ]
        읽기전용·불변      연결·방향별, FIFO 삽입/축출
  • 정적 테이블(§2.3.1, Appendix A): 61개 고정 엔트리. 예를 들어 index 2는 (:method, GET), index 1은 (:authority, "")다. 값이 비어 "이름만" 참조하는 용도도 많다. 모든 연결이 공유하고 절대 변하지 않는다.
  • 동적 테이블(§2.3.2): index 62부터 시작한다. 여기서 자주 헷갈렸는데, 새 엔트리는 항상 62번에 삽입되고 기존 동적 엔트리의 논리 인덱스가 하나씩 뒤로 밀린다(신→구 정렬). 인덱스는 고정 좌표가 아니라 "삽입 이후의 나이"에 따라 움직이는 값이다.

한 가지 더. 동적 테이블은 하나가 아니다. 인코더 측·디코더 측이 각각 유지하고 요청/응답 방향도 분리되므로, 한 연결에 실질적으로 여러 벌의 테이블이 존재한다. 공유되는 건 불변인 정적 테이블뿐이다.

엔트리의 "크기"는 실제 바이트가 아니라 규약값이다(§4.1). size = len(name) + len(value) + 32이고, +32는 참조 오버헤드를 정액으로 잡은 값이다. 동적 테이블 총합이 SETTINGS_HEADER_TABLE_SIZE(기본 4096바이트)를 넘으면 가장 오래된 엔트리부터 축출된다(FIFO).

4. 여섯 가지 필드 표현과 Never Indexed

헤더 필드는 첫 바이트의 상위 비트로 표현 방식이 갈린다(§6).

1xxxxxxx  Indexed              — 이름+값 전부 테이블 index로 (7비트 prefix)
01xxxxxx  Literal + Incr Index — 값은 리터럴, 동적 테이블에 "추가" (6비트 prefix)
0000xxxx  Literal, no Index    — 추가 안 함 (4비트 prefix)
0001xxxx  Literal, Never Index — 추가 안 함 + 중계기도 추가 금지 (보안)
001xxxxx  Dynamic Table Size Update

:method: GET은 정적 index 2를 가리키는 0x82 한 바이트로 끝난다. 처음 보는 custom-key: custom-valueLiteral + Incremental Indexing으로 보내면서 동적 테이블 62번에 등록되고, 다음 요청부터는 그 (이름, 값)이 1바이트 인덱스로 압축된다.

여기서 CRIME 방어의 핵심 장치가 Never Indexed(§6.2.3)다. Authorization처럼 비밀을 담은 헤더에 이 표현을 쓰면 값이 동적 테이블에 절대 캐싱되지 않는다. 캐싱이 안 되니 공격자 주입값과 같은 연결에 있어도 압축 문맥에 비밀이 섞일 여지가 없다. 다만 오해하기 쉬운 지점이 둘 있다.

  • Never Indexed라고 압축이 아예 안 되는 게 아니다. 값 자체는 뒤에서 볼 Huffman으로 여전히 짧아질 수 있고, 동적 테이블 캐싱만 금지된다.
  • 어떤 헤더에 Never Indexed를 쓸지 선택하는 건 인코더의 재량이다. 라이브러리가 어떤 헤더를 민감하다고 표시하느냐가 방어의 실효를 가른다 — 구조는 마련돼 있지만 자동은 아니라는 뜻이다.

5. 정수·문자열 인코딩과 상태 의존성

인덱스와 길이는 모두 "N비트 prefix 정수"로 인코딩한다(§5.1). 값이 2^N - 1보다 작으면 prefix 안에 그대로 담고, 그보다 크면 prefix를 전부 1로 채운 뒤 나머지를 7비트씩 continuation 옥텟으로 잇는다(각 옥텟 MSB가 1이면 "더 있음"). 작은 값은 1바이트로 끝나고 큰 값만 늘어난다.

문자열 리터럴은 [H][length][octets] 형식이고, 첫 비트 H가 1이면 정적 Huffman 코드(§5.2, Appendix B)를 적용한다. 이 코드표는 대량의 실제 HTTP 헤더 통계로 만든 canonical Huffman이라 흔한 문자가 짧다. 남는 비트는 반드시 EOS 심볼 prefix(전부 1)로 패딩해야 한다 — 이 규칙이 없으면 패딩에 데이터를 숨기는 우회가 생기기 때문이다. 그리고 Huffman이 원문보다 길어지는 값이면 인코더는 그냥 H=0으로 보낸다. Huffman이 항상 이득은 아니다.

마지막으로 RFC 7541 Appendix C.3.1의 예제로 정리해 보면 이렇다.

요청1:  :method: GET             -> 0x82 (정적 index 2, Indexed)
        custom-key: custom-value -> 0x40 (Literal + Incr Index)
                                    => 동적 62 = (custom-key, custom-value)
                                       size = 10 + 12 + 32 = 54
요청2:  custom-key: custom-value -> 0xBE (Indexed, index 62)  ← 1바이트

첫 요청에서 54바이트로 등록해 두면 같은 헤더가 이후엔 단일 바이트로 압축된다. 여기서 HPACK의 성격이 드러난다. 디코더가 index를 만나면 61 이하는 정적 테이블, 그 이상은 index - 61번째 동적 엔트리를 꺼내는데, 삽입 순서가 인코더와 하나라도 어긋나면 서로 다른 헤더로 복원돼 연결이 COMPRESSION_ERROR로 죽는다(§6.1, index 0은 불법). HPACK은 그래서 "상태 있는(stateful)" 프로토콜이다.

6. 정리

  • HPACK은 헤더용 gzip이 아니다. DEFLATE의 문맥 혼합이 CRIME의 원인이었고, HPACK은 그걸 버리고 인덱스 테이블 + 고정 Huffman + Never Indexed로 갈아탄, 보안 목적이 명시된 별도 스킴이다.
  • 동적 테이블 인덱스는 삽입/축출마다 이동하는 상태값이라, 인코더와 디코더가 연산을 완전히 같은 순서로 재현해야 한다. 이 순서 의존성이 곧 HPACK의 강점이자 약점이다.
  • 더 파고들 지점: 이 순서 의존성이 QUIC의 스트림 독립성과 충돌해 HTTP/3에서 QPACK으로 재설계된 과정, 그리고 고정 Huffman 코드가 임의 바이너리 값에는 오히려 팽창을 일으키는 경계.

참고 자료

  • RFC 7541 — HPACK: Header Compression for HTTP/2, §2~§6, Appendix A(정적 테이블)·B(Huffman)·C(예제)
  • RFC 9113 — HTTP/2, §4.3 Field Section Compression
  • CRIME (Rizzo & Duong, 2012) — DEFLATE 기반 헤더 압축의 사이드채널 공격

0개의 댓글