Skip to content

Latest commit

 

History

History
277 lines (227 loc) · 15.2 KB

File metadata and controls

277 lines (227 loc) · 15.2 KB

nginx HTTP/2 제어 프레임 무제한 큐잉으로 인한 메모리 고갈 (CVE-2018-16843)

메타데이터

항목 내용
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는 클라이언트가 보낸 SETTINGSPING 같은 제어 프레임에 대해 서버가 반드시 확인 응답(ACK) 프레임을 돌려주도록 규정한다. nginx는 이런 확인 응답용 프레임 구조체를 연결별 메모리 풀에서 할당하고, 다 전송하고 나면 재사용 목록(free list)에 되돌려 놓는다. 문제는 이 할당에 개수 상한이 없었다는 점이다. 공격자가 확인 응답이 필요한 프레임(SETTINGS, PING)을 빠르게 계속 보내면서, 동시에 서버가 보내는 응답을 자신은 읽지 않고 내버려두면(TCP 송신 버퍼가 가득 차 서버가 실제로 내보내지 못하는 상태), 전송을 마치지 못한 확인 응답 프레임들은 재사용 목록으로 돌아가지 못한 채 계속 새로 할당되기만 한다. 이 상태를 연결이 끊어질 때까지 유지하면 워커 프로세스의 메모리 사용량이 계속 늘어나며, 다수의 연결로 동시에 이 패턴을 반복하면 워커 프로세스를 메모리 부족으로 서비스 불능(DoS) 상태에 빠뜨릴 수 있다.

사전 지식

HTTP/2의 "프레임"과 제어 프레임에 요구되는 응답

HTTP/1.1이 텍스트 기반의 요청/응답을 순차적으로 주고받는 것과 달리, HTTP/2(RFC 7540)는 하나의 TCP 연결 위에서 여러 논리적 "스트림"을 다중화(multiplexing)하며, 모든 통신을 고정 헤더를 가진 작은 단위인 "프레임"으로 쪼갠다. 프레임에는 실제 요청/응답 본문을 나르는 HEADERS/DATA 같은 것도 있지만, 연결 자체의 상태를 관리하는 제어용 프레임도 있다.

  • SETTINGS: 연결 파라미터(동시 스트림 최대 개수, 초기 윈도 크기 등)를 알리는 프레임. 프로토콜 규칙상 SETTINGS를 받은 쪽은 반드시 ACK 플래그가 켜진 빈 SETTINGS 프레임으로 응답해야 한다.
  • PING: 연결이 살아있는지 확인하는 프레임. 마찬가지로 받은 쪽은 같은 8바이트 페이로드를 담아 ACK 플래그를 켠 PING 프레임으로 즉시 응답해야 한다.

즉 이 두 프레임 타입은 "받으면 곧바로 응답을 하나 만들어 내보내야 하는" 구조다. 서버 입장에서 이 응답 프레임을 만드는 비용은 (정상적인 상황이라면) 크지 않다 — 빠르게 만들어서 소켓으로 내보내고, 다 내보내면 그 메모리를 재사용하면 되기 때문이다. 문제는 "내보내는" 단계가 지연되거나 아예 막혀버리는 경우다.

nginx의 연결별 메모리 풀(pool)과 프레임 재사용 목록

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/PING 수신 시마다 응답 프레임을 무조건 큐잉

클라이언트가 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()에는 "재사용 목록에 여유분이 없으면 풀에서 새로 할당한다"는 분기만 있을 뿐, 이렇게 새로 할당한 프레임의 누적 개수를 제한하는 로직이 전혀 없었다.

공격이 성립하는 조건

  1. 공격자는 하나의 HTTP/2 연결을 맺는다.
  2. 그 연결로 SETTINGSPING 프레임을 빠르게, 반복적으로 계속 보낸다 — 이 방향(클라이언트→서버)의 수신 자체는 막을 이유가 없으므로 nginx는 계속 읽고 처리한다.
  3. 반면 서버가 그때마다 만들어 내보내는 ACK 응답은 공격자가 자신의 TCP 수신 윈도를 작게 유지하거나 소켓을 아예 읽지 않는 방식으로 흘려보내지 않는다 — 그러면 nginx 쪽 TCP 송신 버퍼가 가득 차서 write()가 더 이상 진행되지 못하고, 앞서 본 buf->pos != buf->last 상태로 프레임이 계속 "전송 중" 상태로 머무른다.
  4. 결과적으로 ngx_http_v2_get_frame()이 호출될 때마다 재사용 목록은 비어 있으므로, nginx는 계속 h2c->pool에서 새 ngx_http_v2_out_frame_t 구조체와 버퍼(NGX_HTTP_V2_FRAME_BUFFER_SIZE = 24바이트 + 부가 구조체)를 새로 ngx_pcalloc한다.
  5. 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로 제한하는 방식으로, 이번 리포트의 주제인 메모리 고갈 문제와는 트리거 조건이 다르지만 같은 근본 배경(제어 흐름에 상한이 없던 것)을 공유한다.

참고 자료

미해결/불확실 지점

  • nginx는 이 시기의 커밋을 더 이상 Mercurial(hg.nginx.org)로 공개하지 않고 GitHub 미러로만 서비스하고 있어(hg 서비스는 조사 시점에 "decommissioned" 상태), 원본 Mercurial changeset 메타데이터(리뷰어, 정확한 커밋 시각 등)는 대조하지 못하고 GitHub 미러의 커밋 정보로 대체했다.
  • 실제 익스플로잇에 필요한 "TCP 송신 버퍼를 가득 채우고 유지하는" 구체적인 소켓 옵션 조합(수신 윈도 0 유지 등)은 이번 정적 분석 범위에서는 실습하지 않았으며, RFC 7540과 nginx 소스 코드 상의 로직적 근거로만 재구성했다.