| 항목 | 내용 |
|---|---|
| CVE ID | CVE-2019-20372 |
| 영향 소프트웨어 | nginx 1.17.7 미만 (모든 안정/메인라인 분기 포함) |
| CWE | CWE-444 (Inconsistent Interpretation of HTTP Requests, "HTTP Request/Response Smuggling") |
| CVSS v3.1 | 5.3 (Medium, AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N) |
| 공개일 | 2020-01-09 (NVD 등록); 수정은 nginx 1.17.7 (2019-12-24 릴리스)에 포함 |
| 발견자 | Bert JW Regeer, Francisco Oca Gonzalez |
| 수정 커밋 | nginx c1be55f9 "Discard request body when redirecting to a URL via error_page." |
nginx는 error_page 지시어로 특정 상태 코드(예: 401, 404)가 발생했을 때 보여줄 페이지를
지정할 수 있는데, 그 대상이 /foo처럼 같은 서버 안의 경로가 아니라
http://example.org처럼 외부 URL이면 nginx는 그 URL로 302(또는 지정한 코드)
리다이렉트 응답을 만들어 보낸다. 문제는 이 리다이렉트 응답을 만드는 코드 경로가, 원래
요청에 아직 다 읽지 않은 **바디(body)**가 남아 있어도 그 바디를 신경 쓰지 않고 곧바로
응답을 내려보냈다는 데 있다. HTTP/1.1의 keep-alive(지속 연결) 환경에서 nginx는 하나의
TCP 연결 위에 요청이 여러 개 연달아(파이프라이닝) 올 수 있다고 가정하고, 현재 요청이
끝난 자리 바로 다음에 있는 바이트를 자동으로 "다음 요청의 시작"으로 해석한다. 만약 응답을
만드는 시점에 이전 요청의 바디가 아직 소켓 버퍼에 남아 있었다면(nginx가 그 바디를 읽지
않았으므로), 그 남은 바이트들이 다음 요청의 시작으로 잘못 해석돼 버린다. 공격자가 이
바디 안에 자신이 원하는 요청 한 개를 통째로 끼워 넣으면(HTTP 요청 밀수, request
smuggling), 로드밸런서/프런트엔드가 원래 한 개의 요청만 봤다고 믿고 있는 사이, nginx는
같은 백엔드 연결 위에서 프런트엔드의 접근 제어를 한 번도 거치지 않은 두 번째 요청을
처리하게 된다. NVD 설명이 언급하는 구체 시나리오는 "nginx가 로드밸런서 뒤에 있을 때,
허가되지 않은 웹 페이지를 읽을 수 있다"는 것이다.
HTTP/1.0까지는 요청 하나마다 TCP 연결을 새로 맺고 끊었지만, 이는 연결 설정/해제 비용이
커서 HTTP/1.1부터는 기본적으로 하나의 TCP 연결 위에서 요청-응답을 여러 번 주고받는
"지속 연결(persistent connection, keep-alive)"을 쓴다. 더 나아가 "파이프라이닝"은
클라이언트가 이전 응답을 기다리지 않고 다음 요청을 연달아 먼저 보내는 것까지 허용한다.
이 모델이 성립하려면 서버(그리고 그 서버 앞단의 모든 중개자)가 "지금 이 요청이 정확히
어디서 끝나는가"를 한 치의 오차도 없이 알아야 한다. 요청 본문(바디)이 있는
POST/PUT 요청의 경우, 그 끝은 Content-Length 헤더가 알려주는 바이트 수(또는
Transfer-Encoding: chunked의 청크 종료 마커)로 결정된다. 서버가 이 바이트 수만큼
정확히 읽고 나면, 그다음 바이트부터는 (있다면) 다음 요청의 시작이라고 간주한다. 요청
경계에 대한 합의가 어긋나면 — 예를 들어 서버가 바디를 다 읽지 않고 중간에 멈췄는데도
"이 요청은 끝났다"고 판단해버리면 — 실제로는 아직 이전 요청 바디의 일부였던 바이트를
다음 요청의 시작으로 착각하게 된다. HTTP 요청 밀수(request smuggling)는 이런 "요청
경계에 대한 서로 다른 해석"을 이용하는 공격 부류를 통칭하는 이름이다. 흔히 알려진
변종(CL.TE, TE.TE 등)은 프런트엔드와 백엔드 두 서버가 Content-Length와
Transfer-Encoding을 서로 다르게 해석해서 생기지만, 이 CVE는 그 변종들과 달리 nginx
혼자서 자신이 만든 응답 로직 때문에 자기 자신의 다음 파이프라인 파싱을 어긋나게 만드는
경우다.
로드밸런서나 리버스 프록시(예: nginx 앞단에 있는 또 다른 nginx, HAProxy, 클라우드
로드밸런서 등)는 성능을 위해 백엔드(nginx)와 맺은 TCP 연결을 매번 새로 만들지 않고 여러
클라이언트의 요청을 번갈아 같은 연결에 실어 재사용하는 경우가 흔하다. 이 구조에서
프런트엔드는 "요청 A를 보냈으니 응답 A 하나만 돌아올 것"이라 믿고 그 연결을 다음
클라이언트(B)의 요청에 다시 배정한다. 만약 A의 요청 안에 심어둔 바이트가 nginx에 의해
"요청 A 다음에 이어지는 별도의 요청"으로 처리되어 버리면, 그 부산물(밀수된 요청에 대한
응답)이 연결을 재사용하는 다음 클라이언트 B의 응답 자리에 끼어들거나, 혹은 공격자가
같은 연결에서 스스로 응답을 두 개 받아 프런트엔드가 애초에 라우팅/인증을 걸었어야 할
자원에 직접 접근하는 결과로 이어질 수 있다. error_page로 외부 URL 리다이렉트를 만드는
설정은 흔히 인증 실패(401) 페이지를 외부 로그인 페이지로 보내는 등 실무에서 드물지 않게
쓰인다.
nginx는 error_page <code> <uri>;에서 <uri>의 형태에 따라 내부적으로 완전히 다른 세
경로로 처리를 나눈다(ngx_http_send_error_page(), src/http/ngx_http_special_response.c).
<uri>가/로 시작 — 같은 서버 안의 경로로 내부 리다이렉트 (ngx_http_internal_redirect()). 요청이 다시 nginx의 요청 처리 파이프라인을 타고 흐른다.<uri>가@로 시작 — 이름 붙은 위치(named location)로 넘김 (ngx_http_named_location()). 역시 내부적으로 계속 처리된다.- 그 외(
http://...처럼 완전한 외부 URL) —Location헤더를 붙인 3xx 응답을 그 자리에서 직접 만들어 클라이언트에게 돌려준다 (ngx_http_send_special_response()). 이 CVE가 있는 지점이 바로 이 세 번째 경로다.
패치 전 ngx_http_send_error_page()(release-1.17.6 기준)는 위 세 갈래 중 앞의 두
경우를 처리한 뒤, 나머지(외부 URL)인 경우 곧바로 Location 헤더 값을 채우고
ngx_http_send_special_response()를 호출해 응답을 만든다.
/* src/http/ngx_http_special_response.c (release-1.17.6) */
static ngx_int_t
ngx_http_send_error_page(ngx_http_request_t *r, ngx_http_err_page_t *err_page)
{
...
if (uri.len && uri.data[0] == '/') {
...
return ngx_http_internal_redirect(r, &uri, &args);
}
if (uri.len && uri.data[0] == '@') {
return ngx_http_named_location(r, &uri);
}
/* 여기 도달하면 uri는 외부 URL(예: http://example.org) */
location = ngx_list_push(&r->headers_out.headers);
...
location->value = uri;
ngx_http_clear_location(r);
r->headers_out.location = location;
...
return ngx_http_send_special_response(r, clcf, r->err_status
- NGX_HTTP_MOVED_PERMANENTLY
+ NGX_HTTP_OFF_3XX);
}이 경로 어디에도 클라이언트가 이미 보내고 있는(또는 보낼) 요청 바디를 읽어서 버리는
호출이 없다. 즉 POST 요청이 error_page 조건(예: 401)에 걸려 이 경로로 빠지면, 그
POST 요청의 바디는 한 바이트도 읽히지 않은 채 응답이 나가버린다.
응답이 끝난 뒤 연결을 끊지 않고 keep-alive로 유지할지 결정하는 함수가
ngx_http_set_keepalive()(src/http/ngx_http_request.c)다. 이 함수는 요청 헤더를
읽어뒀던 버퍼(r->header_in, 변수명 b)에서 현재 요청을 파싱하고 남은 위치
(b->pos)가 버퍼의 끝(b->last)보다 앞서 있으면, 그 차이만큼의 바이트를 "파이프라인된
다음 요청"으로 간주하고 그대로 남겨 다음 요청 파싱에 넘긴다.
/* src/http/ngx_http_request.c, ngx_http_set_keepalive() (release-1.17.6) */
b = r->header_in;
if (b->pos < b->last) {
/* the pipelined request */
...
}이 로직 자체는 정상적인 파이프라이닝을 지원하기 위한 것이지만, 전제 조건은 "b->pos가
정확히 현재 요청의 끝(바디까지 포함해서)을 가리키고 있어야 한다"는 것이다. 그런데
error_page 외부 리다이렉트 경로는 바디를 전혀 소비하지 않았으므로, 클라이언트가 보낸
POST 바디의 첫 부분이 (헤더와 함께 읽힌 소켓 버퍼 안에 들어있었다면) 그대로
b->pos~b->last 사이에 남아 있게 된다. ngx_http_set_keepalive()는 이걸 구분할
방법이 없으므로 곧이곧대로 "다음 요청의 시작"으로 취급해버린다.
공격자가 같은 TCP 연결(keep-alive)에 보내는 바이트 스트림:
+----------------------------------------------------------------+
| POST /some/path HTTP/1.1\r\n | <- 원래 요청 (헤더)
| Host: victim\r\n |
| Content-Length: 54\r\n\r\n |
| GET /internal-only-page HTTP/1.1\r\nHost: victim\r\n\r\n | <- "바디"로 위장한
+----------------------------------------------------------------+ 밀수 요청 (54바이트)
│ nginx가 /some/path 처리 중 error_page 401 http://example.org; 발동
│ (인증 실패 등으로 401 트리거, error_page가 외부 URL로 리다이렉트)
▼
ngx_http_send_error_page() → 외부 URL 경로
→ Location: http://example.org 를 담은 302 응답을 즉시 생성
→ 이 사이 "GET /internal-only-page ..." 부분은 단 1바이트도 읽지 않음
│
▼
ngx_http_set_keepalive() 호출
→ b->pos (302 응답용으로 파싱을 마친 위치) < b->last (소켓에 이미 도착한 바이트 끝)
→ "the pipelined request" 로 간주하고 그대로 다음 요청 파싱에 사용
│
▼
+--------------------------------------------------+
| GET /internal-only-page HTTP/1.1 | <- 겹치는 지점: 원래는
| Host: victim | "바디"였던 이 바이트가
+--------------------------------------------------+ 이제 진짜 "다음 요청"으로
nginx에 의해 처리됨
=> 프런트엔드/로드밸런서는 "요청 하나(POST /some/path)를 보냈다"고만 알고 있는데,
같은 백엔드 연결 위에서 nginx는 그 접근 제어를 거치지 않은
GET /internal-only-page 요청까지 실제로 처리해버린다.
수정 커밋 c1be55f9는
외부 URL 리다이렉트 응답을 만들기 직전에 딱 두 줄을 추가해, 응답을 내려보내기 전에
반드시 요청 바디를 다 읽고 버리도록(discard) 강제한다.
--- a/src/http/ngx_http_special_response.c
+++ b/src/http/ngx_http_special_response.c
@@ -623,6 +623,12 @@ ngx_http_send_error_page(ngx_http_request_t *r, ngx_http_err_page_t *err_page)
return ngx_http_named_location(r, &uri);
}
+ r->expect_tested = 1;
+
+ if (ngx_http_discard_request_body(r) != NGX_OK) {
+ r->keepalive = 0;
+ }
+
location = ngx_list_push(&r->headers_out.headers);ngx_http_discard_request_body()(src/http/ngx_http_request_body.c)는 Content-Length
또는 청크 인코딩으로 명시된 바디 전체를, 애플리케이션에 실제로 전달하지 않고 그대로
읽어서 버리는(consume하지만 폐기하는) 함수다. 이미 소켓 버퍼에 도착해 있는 만큼은 즉시
건너뛰고, 아직 도착하지 않은 나머지가 있으면 별도의 읽기 이벤트 핸들러
(ngx_http_discarded_request_body_handler())를 등록해 응답을 keep-alive 상태로
넘기기 전에 바디 전체가 확실히 소비되도록 만든다. 이렇게 하면 ngx_http_set_keepalive()
가 다시 호출될 시점에는 b->pos가 이미 바디 끝까지 전진해 있으므로, 더 이상 바디 조각을
"다음 요청"으로 착각할 여지가 없다. 만약 어떤 이유로든 이 폐기 자체가 실패하면(예:
Expect: 100-continue 처리 실패 등 ngx_http_discard_request_body()가
NGX_OK가 아닌 값을 반환) r->keepalive = 0으로 연결을 아예 keep-alive 대상에서
제외해, 남은 바이트를 안전하지 않게 재해석할 가능성 자체를 차단한다. 이 수정은 nginx
1.17.7에 포함됐고, 이후 RHEL/Ansible Tower 등 다수 배포판·제품의 백포트를 통해 반영됐다.
동일한 공격 스트림:
POST /some/path HTTP/1.1\r\nHost: victim\r\nContent-Length: 54\r\n\r\n
GET /internal-only-page HTTP/1.1\r\nHost: victim\r\n\r\n
│ error_page 401 http://example.org; 발동
▼
ngx_http_send_error_page() → 외부 URL 경로
→ r->expect_tested = 1;
→ ngx_http_discard_request_body(r)
→ Content-Length(54바이트) 전체를 읽어서 폐기
→ "GET /internal-only-page ..." 54바이트가 여기서 전부 소비(폐기)됨
→ 실패 시에는 r->keepalive = 0 (연결을 닫아 재사용 자체를 막음)
│
▼
302 Location: http://example.org 응답 생성 및 전송
│
▼
ngx_http_set_keepalive() 호출
→ b->pos == b->last (더 이상 남은 바이트 없음, 정상 종료된 연결이거나
진짜 다음 요청이 도착하길 기다리는 상태)
=> "GET /internal-only-page ..." 는 더 이상 존재하지 않으므로
별도의(밀수된) 요청으로 처리될 여지가 사라짐
- NVD CVE-2019-20372 — 공식 CVE 설명, CVSS v3.1 5.3 / CWE-444. (검증됨)
- 수정 커밋
c1be55f9— "Discard request body when redirecting to a URL via error_page.", nginx/nginx — 실제 패치 diff, 발견자(Bert JW Regeer, Francisco Oca Gonzalez) 명시. (검증됨, 1차 자료) - nginx
src/http/ngx_http_special_response.c(release-1.17.6, 패치 직전) —ngx_http_send_error_page()의 세 갈래 처리(내부 리다이렉트/named location/외부 URL) 원본 확인. (검증됨, 1차 자료) - nginx
src/http/ngx_http_request.c(release-1.17.6, 패치 직전) —ngx_http_set_keepalive()의b->pos < b->last파이프라인 처리 로직 확인. (검증됨, 1차 자료) - nginx
src/http/ngx_http_request_body.c(release-1.17.6, 패치 직전) —ngx_http_discard_request_body()의 동작(바디 소비/폐기, 비동기 완료 처리) 확인. (검증됨, 1차 자료) - Red Hat Bugzilla CVE-2019-20372 — 취약 설정 패턴(
error_page 401 http://example.org;)과 안전한 대안(named location 사용) 요약, 배포판 대응(RHSA) 목록. (검증됨) - nginx.org CHANGES — 1.17.7 — 공식 변경 로그, 취약점 수정 포함 릴리스 확인. (검증됨, 벤더 공식)
- 발견자의 최초 상세 기술 문서("2019-12-10 - error_page request smuggling", Bert JW Regeer)는 원 호스팅 위치(
bertjwregeer.keybase.pub)와 대체 미러(blkcipher.pl) 모두 이 환경의 네트워크 접근 제한으로 직접 열람하지 못했다. 이 리포트의 코드 레벨 분석은 그 문서 대신 nginx 실제 소스(패치 전/후)와 수정 커밋만을 근거로 재구성했다.
- 발견자의 원본 PDF 문서를 확인하지 못해, 연구진이 실제로 사용한 정확한 PoC 요청 바이트열이나 "몇 바이트의 밀수 요청까지 성공했는지"와 같은 실험 세부사항은 검증하지 못했다. 이 리포트의 공격 스트림 예시는 코드 분석을 바탕으로 재구성한 것이며, 실제 검증된 PoC를 그대로 인용한 것이 아니다.
Transfer-Encoding: chunked바디에 대해서도 동일한 경로(외부 URLerror_page)가 청크 파싱 상태를 그대로 남겨두는지, 아니면 청크 파서 자체가 별도로 이 문제를 회피했는지는 이번 정적 분석 범위에서 별도로 재현·확인하지 않았다.