[하루한줄] CVE-2026-42945 : NGINX rewrite module의 is_args 상태 미전파로 발생한 Heap Buffer Overflow RCE 취약점

URL

Target

  • NGINX Open Source 0.6.27 ~ 1.30.0
  • NGINX Plus R32 ~ R36
  • NGINX Instance Manager 2.16.0 ~ 2.21.1
  • NGINX Gateway Fabric 1.3.0 ~ 1.6.2, 2.0.0 ~ 2.5.1
  • NGINX Ingress Controller 3.5.0 ~ 3.7.2, 4.0.0 ~ 4.0.1, 5.0.0 ~ 5.4.1

Explain

CVE-2026-42945는 NGINX의 ngx_http_rewrite_module에서 발생하는 heap buffer overflow 취약점입니다. 진입점은 rewrite 디렉티브이며, replacement string에 물음표(?)가 포함된 unnamed PCRE capture($1, $2)를 사용하고 그 뒤에 rewrite / if / set 디렉티브가 이어지는 설정 패턴에서 트리거됩니다. API gateway 구성에서 흔히 쓰이는 패턴이라 노출 범위가 넓습니다.

공격자는 crafted URI를 담은 단일 HTTP 요청만으로 worker process의 heap을 오버플로우 시킬 수 있습니다. overflow되는 바이트가 공격자의 URI에서 파생되기 때문에 corruption이 worker process 내에서 RCE로 이어집니다.

Root cause

NGINX의 script engine은 rewrite / set 같은 디렉티브를 two-pass 방식으로 처리합니다. 1st pass에서 최종 문자열 길이를 계산해 memory pool에서 정확한 크기를 할당하고, 2nd pass에서 실제 데이터를 copy합니다. 이 설계는 두 pass에서 계산한 길이와 실제 쓰는 데이터 양이 정확히 일치해야만 안전합니다. 하지만 이때 두 pass 사이에 엔진 상태가 바뀌면 heap corruption이 트리거 되게 됩니다.

취약점은 src/http/ngx_http_script.c에 있습니다. rewrite replacement에 물음표가 들어가면 ngx_http_script_start_args_code가 메인 엔진의 e->is_args = 1을 세팅하는데, 이 플래그가 작업 완료후 초기화 되지 않아 발생합니다.

void
ngx_http_script_start_args_code(ngx_http_script_engine_t *e)
{
    e->is_args = 1;          // 이후로 플래그 리셋 X
    e->args = e->pos;
    e->ip += sizeof(uintptr_t);
}

이후 set 디렉티브가 capture group을 참조하면 ngx_http_script_complex_value_code가 호출됩니다. 여기서 길이 계산 pass는 0으로 초기화된 sub-engine의 le 를 사용합니다.

void
ngx_http_script_complex_value_code(ngx_http_script_engine_t *e)
{
    ngx_http_script_engine_t  le;
    ...
    ngx_memzero(&le, sizeof(ngx_http_script_engine_t)); // le.is_args = 0
    le.ip = code->lengths->elts;
    ...
}

le.is_args가 0이므로 길이 계산 함수 ngx_http_script_copy_capture_len_code는 escaping 분기를 타지 않고 raw(unescaped) capture 길이만 리턴합니다.

size_t
ngx_http_script_copy_capture_len_code(ngx_http_script_engine_t *e)
{
    ...
    if ((e->is_args || e->quote)
        && (e->request->quoted_uri || e->request->plus_in_uri))
    {
        // le.is_args == 0 이므로 여기로 안 들어옴
        return cap[n + 1] - cap[n]
                + 2 * ngx_escape_uri(NULL, &p[cap[n]], cap[n + 1] - cap[n],
                                     NGX_ESCAPE_ARGS);
    } else {
        return cap[n + 1] - cap[n];   // raw 길이만 반환 → 작게 할당됨
    }
}

반면 2nd pass의 copy 함수 ngx_http_script_copy_capture_code는 메인 엔진 위에서 동작하고, 메인 엔진의 e->is_args는 여전히 1입니다. 동일한 조건이 이번엔 true가 되어 ngx_escape_uriNGX_ESCAPE_ARGS 모드로 호출합니다. +, %, & 같은 바이트가 1바이트 → 3바이트로 expand됩니다.

void
ngx_http_script_copy_capture_code(ngx_http_script_engine_t *e)
{
    ...
    if ((e->is_args || e->quote)
        && (e->request->quoted_uri || e->request->plus_in_uri))
    {
        // OVERFLOW: 버퍼는 raw_size로 할당됐지만
        // escape 결과 raw_size + 2*N 바이트를 그대로 써버림
        e->pos = (u_char *) ngx_escape_uri(pos, &p[cap[n]],
                                           cap[n + 1] - cap[n],
                                           NGX_ESCAPE_ARGS);
    } else {
        e->pos = ngx_copy(pos, &p[cap[n]], cap[n + 1] - cap[n]);
    }
}

정리하면, 길이 계산은 is_args=0으로(raw), 실제 copy는 is_args=1로(escaped) 수행되는 상태 불일치 때문에, escapable 문자 개수 N에 대해 raw_size만큼 할당된 버퍼에 raw_size + 2*N 바이트가 쓰이면서 pool 경계를 넘어가는 heap overflow가 발생합니다.

트리거 설정 예시:

location ~ ^/api/(.*)$ {
    rewrite ^/api/(.*)$ /internal?migrated=true;   # 물음표 → is_args=1
    set $original_endpoint $1;                      # capture 참조 → 길이/copy 불일치
}

POC

NGINX는 master에서 fork된 worker 다중 프로세스 구조라, 모든 worker의 heap layout이 동일하게 복제됩니다. 따라서 exploit이 실패해 worker가 crash해도 master가 동일한 메모리 레이아웃의 worker를 다시 띄우므로, Leak을 통해 ASLR을 우회하였다면 성공할 때까지 여러 번 시도 가능합니다

overflow 바이트는 URI 파서와 escape 함수를 거치므로 임의 바이트는 못 넣고 URI-safe 문자로 제한됩니다. URI를 +로 패딩하면 escape 단계에서 각 바이트가 3bytes로 부풀어 overflow 크기를 조절할 수 있습니다.

POC에서의 타깃은 ngx_pool_tcleanup 포인터입니다. pool 파괴 시 호출되는 cleanup 핸들러의 함수 포인터(handler)와 인자(data)를 조작하는 게 목표입니다.

struct ngx_pool_s {
    ngx_pool_data_t       d;
    size_t                max;
    ngx_pool_t           *current;
    ngx_chain_t          *chain;
    ngx_pool_large_t     *large;
    ngx_pool_cleanup_t   *cleanup;   // ← 목표 (offset 64)
    ngx_log_t            *log;
};

typedef void (*ngx_pool_cleanup_pt)(void *data);
struct ngx_pool_cleanup_s {
    ngx_pool_cleanup_pt   handler;
    void                 *data;
    ngx_pool_cleanup_t   *next;
};

contiguous overflow라 cleanup에 도달하려면 앞쪽 메타데이터(d, max, current, chain, large)를 전부 덮어야 하는데, 그러면 allocator 상태가 깨져 다음 작업 시 crash가 납니다. 이를 피하기 위해 cross-request heap feng shui라고 하는 기술을 사용합니다.

  1. 첫 connection을 열고 partial header 전송 → request pool 할당
  2. 두 번째 victim connection을 열어 첫 pool에 인접한 victim pool 할당
  3. 첫 connection의 header를 완성시켜 rewrite overflow를 인접 victim pool header로 흘려보냄
  4. victim connection을 즉시 닫아 ngx_destroy_pool 유발

ngx_destroy_pool은 cleanup linked list만 순회하고 corrupted된 다른 필드는 건드리지 않기 때문에, worker를 죽이지 않고 victim pool header를 안정적으로 오염시킬 수 있습니다.

이후 임의 바이너리 포인터(null byte 포함)를 주입하기 위해 POST body로 heap spray를 합니다. URI나 header와 달리 POST body는 raw 스트림으로 취급되어 임의 바이트가 가능합니다. libc system을 가리키는 fake ngx_pool_cleanup_s 구조체와 명령 문자열을 spray해 둡니다.

for (c = pool->cleanup; c; c = c->next) {
    if (c->handler) {
        c->handler(c->data);   // 주입된 system("cmd") 실행
    }
}

앞서 언급한 것 처럼 heap layout이 nginx의 구조상 예측 가능하므로 spray된 fake 구조체는 고정 offset에 배치가 가능합니다. null byte는 overflow로 못 쓰니, cleanup 포인터의 하위 주소만 덮어 sprayed fake 객체를 가리키게 만들되, 전부 URI-safe 바이트로 구성된 주소를 brute-force로 골라 escape 함수를 무사히 통과시킵니다. 마지막으로 client 측에서 victim socket을 닫으면 worker의 ngx_destroy_pool이 cleanup list를 순회하며 주입한 핸들러를 실행, system으로 명령이 실행됩니다.

Reference



본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.