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을 따라가며 정리한 것이다.
DEFLATE는 슬라이딩 윈도우 안에서 반복되는 바이트열을 뒤쪽에서 앞쪽 참조로 치환한다. 문제는 이 "반복"이 서로 다른 출처의 데이터를 한 문맥에서 섞는다는 데 있다. 공격자가 주입할 수 있는 값(예: URL 파라미터)과 비밀(세션 쿠키)이 같은 압축 문맥에 들어가면, 공격자가 넣은 값이 쿠키와 겹칠수록 압축 결과가 짧아진다.
압축 후 길이만 관찰해도 비밀을 한 바이트씩 알아낼 수 있다 — 이것이 2012년 Rizzo와 Duong이 보인 CRIME의 핵심이다.
즉 문제의 근원은 gzip 자체가 아니라 "서로 다른 신뢰 수준의 데이터를 하나의 슬라이딩 윈도우 문맥에서 섞는 것"이다. HPACK은 이 문맥 혼합을 버리고, 문맥 없이 필드 단위로 참조/치환하는 방식으로 처음부터 다시 설계됐다.
HPACK의 중심은 (이름, 값) 쌍을 올려두는 단일 인덱스 공간이다.
index: 1 ......... 61 | 62 .................. 61+k
[ 정적 테이블 ] [ 동적 테이블 (신→구) ]
읽기전용·불변 연결·방향별, FIFO 삽입/축출
:method, GET), index 1은 (:authority, "")다. 값이 비어 "이름만" 참조하는 용도도 많다. 모든 연결이 공유하고 절대 변하지 않는다.한 가지 더. 동적 테이블은 하나가 아니다. 인코더 측·디코더 측이 각각 유지하고 요청/응답 방향도 분리되므로, 한 연결에 실질적으로 여러 벌의 테이블이 존재한다. 공유되는 건 불변인 정적 테이블뿐이다.
엔트리의 "크기"는 실제 바이트가 아니라 규약값이다(§4.1). size = len(name) + len(value) + 32이고, +32는 참조 오버헤드를 정액으로 잡은 값이다. 동적 테이블 총합이 SETTINGS_HEADER_TABLE_SIZE(기본 4096바이트)를 넘으면 가장 오래된 엔트리부터 축출된다(FIFO).
헤더 필드는 첫 바이트의 상위 비트로 표현 방식이 갈린다(§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-value는 Literal + Incremental Indexing으로 보내면서 동적 테이블 62번에 등록되고, 다음 요청부터는 그 (이름, 값)이 1바이트 인덱스로 압축된다.
여기서 CRIME 방어의 핵심 장치가 Never Indexed(§6.2.3)다. Authorization처럼 비밀을 담은 헤더에 이 표현을 쓰면 값이 동적 테이블에 절대 캐싱되지 않는다. 캐싱이 안 되니 공격자 주입값과 같은 연결에 있어도 압축 문맥에 비밀이 섞일 여지가 없다. 다만 오해하기 쉬운 지점이 둘 있다.
인덱스와 길이는 모두 "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)" 프로토콜이다.