| 항목 | 내용 |
|---|---|
| CVE ID | CVE-2018-16843 |
| 영향 버전 | nginx 1.9.5 ~ 1.15.5 (ngx_http_v2_module을 컴파일에 포함하고, listen 지시자에 http2 옵션을 쓴 경우) |
| CVSS v3.1 | 7.5 (High) — 가용성(A) 영향만 High, 기밀성/무결성 영향 없음 |
| CWE | CWE-400 (Uncontrolled Resource Consumption) |
| 공개/수정일 | 2018-11-06 (nginx 1.15.6 / 1.14.1 릴리스), NVD 등록일 2018-11-07 |
| 발견자 | nginx 보안 권고문에 별도 크레딧 없음 (같은 권고문에서 함께 공개된 자매 취약점 CVE-2018-16844의 CPU 소비 이슈만 F5 Networks의 Gal Goldshtein이 크레딧됨) |
| 관련 CVE | CVE-2018-16844 (같은 커밋 세트에서 함께 수정된 HTTP/2 CPU 소비 문제) |
HTTP/2는 클라이언트가 보낸 SETTINGS나 PING 같은 제어 프레임에 대해 서버가 반드시
확인 응답(ACK) 프레임을 돌려주도록 규정한다. nginx는 이런 확인 응답용 프레임 구조체를
연결별 메모리 풀에서 할당하고, 다 전송하고 나면 재사용 목록(free list)에 되돌려
놓는다. 문제는 이 할당에 개수 상한이 없었다는 점이다. 공격자가 확인 응답이
필요한 프레임(SETTINGS, PING)을 빠르게 계속 보내면서, 동시에 서버가 보내는
응답을 자신은 읽지 않고 내버려두면(TCP 송신 버퍼가 가득 차 서버가 실제로 내보내지
못하는 상태), 전송을 마치지 못한 확인 응답 프레임들은 재사용 목록으로 돌아가지 못한
채 계속 새로 할당되기만 한다. 이 상태를 연결이 끊어질 때까지 유지하면 워커
프로세스의 메모리 사용량이 계속 늘어나며, 다수의 연결로 동시에 이 패턴을 반복하면
워커 프로세스를 메모리 부족으로 서비스 불능(DoS) 상태에 빠뜨릴 수 있다.
HTTP/1.1이 텍스트 기반의 요청/응답을 순차적으로 주고받는 것과 달리, HTTP/2(RFC 7540)는
하나의 TCP 연결 위에서 여러 논리적 "스트림"을 다중화(multiplexing)하며, 모든 통신을
고정 헤더를 가진 작은 단위인 "프레임"으로 쪼갠다. 프레임에는 실제 요청/응답 본문을
나르는 HEADERS/DATA 같은 것도 있지만, 연결 자체의 상태를 관리하는 제어용
프레임도 있다.
SETTINGS: 연결 파라미터(동시 스트림 최대 개수, 초기 윈도 크기 등)를 알리는 프레임. 프로토콜 규칙상SETTINGS를 받은 쪽은 반드시 ACK 플래그가 켜진 빈SETTINGS프레임으로 응답해야 한다.PING: 연결이 살아있는지 확인하는 프레임. 마찬가지로 받은 쪽은 같은 8바이트 페이로드를 담아 ACK 플래그를 켠PING프레임으로 즉시 응답해야 한다.
즉 이 두 프레임 타입은 "받으면 곧바로 응답을 하나 만들어 내보내야 하는" 구조다. 서버 입장에서 이 응답 프레임을 만드는 비용은 (정상적인 상황이라면) 크지 않다 — 빠르게 만들어서 소켓으로 내보내고, 다 내보내면 그 메모리를 재사용하면 되기 때문이다. 문제는 "내보내는" 단계가 지연되거나 아예 막혀버리는 경우다.
nginx는 힙 할당을 요청 하나하나마다(malloc/free) 하지 않고, 연결 단위로 큰
메모리 풀(ngx_pool_t)을 만들어 그 안에서 필요한 만큼씩 떼어 쓰는(ngx_pcalloc)
방식을 쓴다. 이 풀 안에서 떼어준 메모리는 개별적으로 반환(free)하지 않고, 연결이
끝나거나 상태가 리셋될 때 풀 전체를 한 번에 파괴한다 (ngx_destroy_pool). 이
방식은 매 요청마다 자잘한 malloc/free를 반복하는 오버헤드를 줄여준다.
HTTP/2 모듈은 여기에 한 겹을 더 얹는다 — SETTINGS/PING/WINDOW_UPDATE/GOAWAY
같은 짧은 제어 프레임을 내보낼 때마다 매번 풀에서 새로 떼어 쓰는 대신, 다 쓴 프레임
구조체(ngx_http_v2_out_frame_t)를 h2c->free_frames라는 **연결 전용 재사용
목록(free list)**에 넣어뒀다가 다음에 프레임이 또 필요하면 새로 할당하지 않고 그
목록에서 하나 꺼내 재사용한다. 이 목록에 여유분이 없을 때만 풀에서 새로
ngx_pcalloc한다.
static ngx_http_v2_out_frame_t *
ngx_http_v2_get_frame(ngx_http_v2_connection_t *h2c, size_t length,
ngx_uint_t type, u_char flags, ngx_uint_t sid)
{
...
frame = h2c->free_frames;
if (frame) {
h2c->free_frames = frame->next; /* 재사용 목록에서 꺼내 쓰기 */
...
} else {
pool = h2c->pool ? h2c->pool : h2c->connection->pool;
frame = ngx_pcalloc(pool, sizeof(ngx_http_v2_out_frame_t)); /* 없으면 풀에서 새로 할당 */
...
}
...
return frame;
}그리고 프레임을 다 전송하고 나면(ngx_http_v2_frame_handler), 그 구조체를 다시
free_frames로 돌려놓는다.
static ngx_int_t
ngx_http_v2_frame_handler(ngx_http_v2_connection_t *h2c,
ngx_http_v2_out_frame_t *frame)
{
ngx_buf_t *buf = frame->first->buf;
if (buf->pos != buf->last) {
return NGX_AGAIN; /* 아직 다 못 내보냄 — 재사용 목록에 돌려주지 않음 */
}
frame->next = h2c->free_frames;
h2c->free_frames = frame; /* 다 내보낸 뒤에야 재사용 목록으로 복귀 */
return NGX_OK;
}핵심은 buf->pos != buf->last, 즉 버퍼 안의 데이터를 소켓에 완전히 다 써
보내지 못한 프레임은 재사용 목록으로 돌아가지 못한다는 점이다. 소켓에
write()가 얼마나 되는지는 TCP 송신 버퍼 여유 공간, 즉 상대방(클라이언트)이
데이터를 실제로 읽어가고 있는지에 달려 있다.
클라이언트가 SETTINGS 프레임(ACK 아닌 것)을 보내면 nginx는 곧바로 ACK용 프레임을
ngx_http_v2_get_frame()으로 만들어 출력 큐에 건다.
frame = ngx_http_v2_get_frame(h2c, NGX_HTTP_V2_SETTINGS_ACK_SIZE,
NGX_HTTP_V2_SETTINGS_FRAME, NGX_HTTP_V2_ACK_FLAG, 0);PING 프레임을 받아도 마찬가지로 ACK용 프레임을 만든다.
frame = ngx_http_v2_get_frame(h2c, NGX_HTTP_V2_PING_SIZE,
NGX_HTTP_V2_PING_FRAME, NGX_HTTP_V2_ACK_FLAG, 0);패치 전 ngx_http_v2_get_frame()에는 "재사용 목록에 여유분이 없으면 풀에서 새로
할당한다"는 분기만 있을 뿐, 이렇게 새로 할당한 프레임의 누적 개수를 제한하는
로직이 전혀 없었다.
- 공격자는 하나의 HTTP/2 연결을 맺는다.
- 그 연결로
SETTINGS나PING프레임을 빠르게, 반복적으로 계속 보낸다 — 이 방향(클라이언트→서버)의 수신 자체는 막을 이유가 없으므로 nginx는 계속 읽고 처리한다. - 반면 서버가 그때마다 만들어 내보내는 ACK 응답은 공격자가 자신의 TCP 수신
윈도를 작게 유지하거나 소켓을 아예 읽지 않는 방식으로 흘려보내지 않는다 —
그러면 nginx 쪽 TCP 송신 버퍼가 가득 차서
write()가 더 이상 진행되지 못하고, 앞서 본buf->pos != buf->last상태로 프레임이 계속 "전송 중" 상태로 머무른다. - 결과적으로
ngx_http_v2_get_frame()이 호출될 때마다 재사용 목록은 비어 있으므로, nginx는 계속h2c->pool에서 새ngx_http_v2_out_frame_t구조체와 버퍼(NGX_HTTP_V2_FRAME_BUFFER_SIZE= 24바이트 + 부가 구조체)를 새로ngx_pcalloc한다. ngx_pcalloc으로 할당된 메모리는 개별 반환이 안 되고, 이 연결의 풀이 통째로 파괴될 때까지(즉 연결이 완전히 유휴 상태가 되어ngx_destroy_pool(h2c->pool)이 호출될 때까지) 절대 회수되지 않는다. 공격자가 연결을 계속 "활성" 상태로 붙잡아 두는 한(스트림을 열어두거나, 계속 프레임을 보내는 한) 이 풀 파괴 시점 자체가 오지 않는다.
공격자 nginx 워커 프로세스 (h2c->pool)
SETTINGS 전송 ──────────────► ngx_http_v2_get_frame()
free_frames 비어있음 → pool에서 새 frame #1 할당
(ACK를 소켓에 쓰려 하지만 상대가 안 읽어 write 지연)
PING 전송 ──────────────► ngx_http_v2_get_frame()
free_frames 비어있음 → pool에서 새 frame #2 할당
(역시 전송 미완료 → free_frames 복귀 못함)
SETTINGS 전송 ──────────────► frame #3 할당 ...
PING 전송 ──────────────► frame #4 할당 ...
│
│ (제한 없이 계속 반복)
▼
h2c->pool
┌─────────────────────────────────┐
│ frame#1 │ frame#2 │ frame#3 │ ...│ ← 상한 없이 계속 증가
└─────────────────────────────────┘
풀 파괴 시점(연결 완전 유휴)이 오지 않는 한
단 하나도 회수되지 않음 → 워커 프로세스 메모리 고갈
수정 커밋(8ec4146e1a, "HTTP/2: flood detection.")은 연결 구조체
ngx_http_v2_connection_t에 지금까지 이 연결에서 새로 할당한 프레임 개수를 세는
카운터 h2c->frames를 추가하고, ngx_http_v2_get_frame()이 재사용 목록에서 꺼낼
수 없어 새로 할당해야 하는 상황에서 그 카운터가 이미 10000을 넘겼다면 할당을
거부하고 연결 자체를 에러로 종료시키도록 바꿨다.
--- src/http/v2/ngx_http_v2.c
@@ ngx_http_v2_handle_connection (연결이 유휴 상태로 전환될 때) @@
h2c->pool = NULL;
h2c->free_frames = NULL;
+ h2c->frames = 0;
h2c->free_fake_connections = NULL;
@@ ngx_http_v2_get_frame @@
frame->blocked = 0;
- } else {
+ } else if (h2c->frames < 10000) {
pool = h2c->pool ? h2c->pool : h2c->connection->pool;
frame = ngx_pcalloc(pool, sizeof(ngx_http_v2_out_frame_t));
...
frame->handler = ngx_http_v2_frame_handler;
+
+ h2c->frames++;
+
+ } else {
+ ngx_log_error(NGX_LOG_INFO, h2c->connection->log, 0,
+ "http2 flood detected");
+
+ h2c->connection->error = 1;
+ return NULL;
}--- src/http/v2/ngx_http_v2.h
ngx_uint_t processing;
+ ngx_uint_t frames;
ngx_uint_t pushing;h2c->frames는 연결이 완전히 유휴 상태가 되어 풀이 파괴될 때(h2c->pool = NULL
지점) 0으로 초기화되므로, 이 상한은 "연결 전체 누적"이 아니라 "한 번의 유휴→활성
주기 동안 재사용 없이 새로 할당한 프레임 개수"에 대한 상한이다. 정상적인 클라이언트는
같은 주기 안에서 이 정도로 많은 확인 응답 프레임이 동시에 미전송 상태로 쌓일 일이
없으므로, 이 상한은 정상 트래픽에는 영향을 주지 않으면서 공격 패턴만 걸러낸다.
h2c->pool, h2c->frames = 0 부터 시작
SETTINGS/PING 반복 전송 ───► frame 할당 시마다 h2c->frames++
┌───────────────────────────┐
│ frame#1 ... frame#9999 │ │ ← 9999개까지는 이전과 동일하게 허용
└───────────────────────────┘
│
▼ h2c->frames == 10000 도달
ngx_http_v2_get_frame() 이 else 분기로 진입
"http2 flood detected" 로그
h2c->connection->error = 1
│
▼
연결 강제 종료 → 풀 전체 파괴
(더 이상 프레임이 무한정 쌓이지 않음)
같은 커밋 세트에는 PING/SETTINGS 흐름과는 별도로, 연결이 계속 유휴↔활성
상태를 오가며 그때마다 풀을 새로 만들고 부수는 비용 자체를 노리는 CPU 소비형
공격(CVE-2018-16844)에 대한 수정("HTTP/2: limit the number of idle state
switches.")도 함께 들어갔다 — 유휴 상태 전환 횟수를 10 * http2_max_requests로
제한하는 방식으로, 이번 리포트의 주제인 메모리 고갈 문제와는 트리거 조건이
다르지만 같은 근본 배경(제어 흐름에 상한이 없던 것)을 공유한다.
- CVE-2018-16843 — NVD
- nginx security advisory (CVE-2018-16843, CVE-2018-16844) — nginx-announce 메일링 리스트, Maxim Dounin
- nginx 1.15.6 변경 이력 — nginx.org 공식 CHANGES
- 수정 커밋
8ec4146e1a"HTTP/2: flood detection." — nginx/nginx GitHub 미러 - 자매 커밋
60b93594cc"HTTP/2: limit the number of idle state switches." (CVE-2018-16844, CPU 소비) — 동일 미러 release-1.15.5태그 취약 버전 소스ngx_http_v2.c— 동일 미러- RFC 7540 — Hypertext Transfer Protocol Version 2 (HTTP/2), §6.5 SETTINGS / §6.7 PING
- nginx는 이 시기의 커밋을 더 이상 Mercurial(
hg.nginx.org)로 공개하지 않고 GitHub 미러로만 서비스하고 있어(hg 서비스는 조사 시점에 "decommissioned" 상태), 원본 Mercurial changeset 메타데이터(리뷰어, 정확한 커밋 시각 등)는 대조하지 못하고 GitHub 미러의 커밋 정보로 대체했다. - 실제 익스플로잇에 필요한 "TCP 송신 버퍼를 가득 채우고 유지하는" 구체적인 소켓 옵션 조합(수신 윈도 0 유지 등)은 이번 정적 분석 범위에서는 실습하지 않았으며, RFC 7540과 nginx 소스 코드 상의 로직적 근거로만 재구성했다.