원문 캡처 · calif.io
원문 캡처 · calif.io

PHP 5.4.0 이상에서 메모리 한도 초과나 치명적 오류가 발생하면 정리되지 않은 상태가 남아 원격 코드 실행(RCE)으로 이어질 수 있는 취약점이 발견됐다. 연구팀은 Apache mod_php와 PHP-FPM에서 서로 다른 악용 경로를 확인했으며, 기본 설정에서도 악용할 수 있다고 밝혔다.

연구팀은 인증 전 원격 코드 실행을 다음 PHP 애플리케이션에서 확인했으며, 일부 경우에는 임의의 PHP 스크립트도 영향을 받는다.

- WordPress 7.1.2
- Drupal 11.4.3
- Joomla 6.1.4
- MediaWiki 1.46.2
- Roundcube 1.7.4
- Magento 2.4.9
- phpMyAdmin 5.2.3
- Nextcloud 35.0.1
- Laravel 13.11
- Symfony 8.1
- WHMCS

요청 데이터로 메모리 한도에 도달하는 방법

연구팀은 먼저 memory_limit을 넘어 조절 가능한 치명적 오류를 일으키는 방법을 찾았다. 첫 번째 방법은 보통 요청이 메모리 한도에 이르기 전에 막는 post_max_size 제한을 우회하는 것이다. PHP는 자동 POST 분석을 시작하기 전에 선언된 Content-Length와 post_max_size를 비교하지만, 실제로 소비하는 바이트에 같은 제한을 적용하지 않는다. 따라서 Apache는 의미 있는 길이 정보가 없는 청크 방식 HTTP/1.1 요청이나 길이를 지정하지 않은 HTTP/2 멀티파트 요청을 전달할 수 있다. 입력 압축 해제 필터가 설정된 경우에도 압축된 길이와 압축 해제된 스트림 사이에 같은 불일치가 생긴다.

두 번째 방법은 post_max_size보다 작은 멀티파트 요청으로 memory_limit을 소진하는 것이다. filename=""인 RFC1867 파일 업로드 항목에는 파일 데이터가 없으며, PHP는 이를 max_file_uploads에 포함하지 않는다. 하지만 PHP는 각 항목에 대해 $_FILES 배열에 name, full_path, type, tmp_name, error와 size 등 여섯 개의 대응 항목을 만든다.

폼 필드 이름이 files[a][b][c]...처럼 중첩되면 PHP는 여섯 항목 각각에 대해 해당 계층 구조를 따로 만든다. 짧은 빈 업로드 기록 하나가 여러 네이티브 HashTables, Buckets와 문자열 키 할당으로 불어난다. 이런 기록 수백 개는 HTTP 본문 전체가 기본값인 8 MiB post_max_size보다 작아도 기본값 128 MiB인 PHP 힙보다 많은 메모리를 차지할 수 있다. 그러면 이후 원하는 할당에서 memory_limit 오류를 일으킬 수 있다.

mod_php에서 초기화되지 않은 버킷 악용

메모리 부족 오류를 원하는 시점에 일으킬 수 있더라도, 실행을 중단했을 때 악용 가능한 상태가 남는 지점이 있어야 한다. 연구팀은 ZendMM 할당, 치명적 오류 보고, 요청 종료 과정에서 예기치 않은 중단이 취약한 상태를 남기는 지점을 조사했다. 그 과정에서 _zend_hash_str_add_or_update_i의 다음 순서를 확인했다.

idx = ht->nNumUsed++;
ht->nNumOfElements++;

p = ht->arData + idx;
p->key = zend_string_init(str, len, persistent); /* may zend_bailout() */

p->h = ZSTR_H(p->key) = h;
ZVAL_COPY_VALUE(&p->val, pData);

PHP는 새 버킷을 완전히 초기화하기 전에 nNumUsed와 nNumOfElements를 늘린다. nNumUsed를 늘리면 이후 키 할당에서 치명적 오류가 나더라도 버킷이 정리 대상이 된다. 관련 코드인 zend_array_destroy()는 다음과 같다.

for (p = ht->arData; p != ht->arData + ht->nNumUsed; p++) {
 i_zval_ptr_dtor(&p->val);
 if (p->key) {
 zend_string_release(p->key);
 }
}

키 할당에서 오류가 나면 제어 흐름이 key, hash, zval이나 충돌 연결 목록을 지정하는 부분에 도달하지 못한다. 하지만 카운트는 이미 증가했으므로 정리 과정은 초기화되지 않은 메모리를 살아 있는 버킷으로 취급한다. 즉, 실제 버킷 초기화 전에 개수를 늘리고, 실패 가능한 작업인 키 할당을 수행한 뒤, 초기화나 복구가 건너뛰어지면 정리 과정의 소비자가 잘못된 상태를 신뢰하는 구조다.

연구팀은 이 취약한 함수가 자동 중첩 POST 분석을 통해 호출되며, mod_php의 종료 과정이 잘못된 테이블을 소비한다는 점도 추적했다. 일반 요청 변수 등록 경로는 다음과 같다.

URL-encoded POST root[x]=v
 php_std_post_handler()
 → php_register_variable_ex()
 → zend_symtable_str_update_ind()
 → _zend_hash_str_add_or_update_i()

RFC1867 upload root[x], filename="..."
 rfc1867_post_handler()
 → register_http_post_files_variable_ex()
 → php_register_variable_ex()
 → zend_symtable_str_update_ind()
 → _zend_hash_str_add_or_update_i()

두 경로 모두 PHP 대상 파일이 실행되기 전인 php_request_startup() 중에 실행된다. 키 할당에서 치명적 오류가 발생했을 때 부분적으로 만들어진 버킷이 소비되는지는 SAPI에 따라 다르다.

/* mod_php: registered request cleanup runs even after startup failure */
php_request_shutdown(NULL); // destroys malformed $_POST/$_FILES

/* PHP-FPM: startup failure takes the process-exit branch */
php_module_shutdown();
return FPM_EXIT_SOFTWARE; // no request-level table destruction

Apache는 HashTable 정리 과정에서 오래된 버킷을 해석하지만, PHP-FPM은 작업자를 종료해 버킷을 버린다. 이 경로는 Apache mod_php와 PHP 7.3 이상에서 임의의 PHP 스크립트를 대상으로 한 원격 코드 실행으로 이어진다.

연구팀은 처음 발견한 _zend_hash_str_add_or_update_i 문제에서 패턴을 일반화했다. 상태를 공개하거나 정리 대상으로 만든 뒤 실패할 수 있는 작업을 수행하고, 초기화·커밋·복구가 끝나기 전에 치명적 오류가 나면 살아남은 소비자가 잘못된 상태를 믿는 패턴이다. 이 패턴을 찾는 SKILL.md를 만들고 적용한 결과, Session upload-progress 기능에서도 새로운 문제를 발견했다. 이는 단일 결함이 아니라 더 넓은 유형의 문제라는 점을 뒷받침한다고 연구팀은 설명했다.

세션 업로드 진행 상태에 남는 포인터

Session upload-progress 기능은 PHP가 요청 본문을 분석하는 동안, 애플리케이션 코드가 실행되기 전에 세션을 열고 갱신할 수 있다. PHP 매뉴얼에도 이 이른 동작이 기록돼 있다. main/main.c의 요청 시작 순서는 다음과 같다.

PG(modules_activated) = 0;

/* ... */

php_hash_environment(); /* Parses request variables and multipart input. */
zend_activate_modules(); /* Calls normal module RINIT functions. */
PG(modules_activated) = 1;

따라서 멀티파트 분석은 PHP가 모듈 활성화를 완료했다고 표시하기 전에 세션 확장 기능으로 들어갈 수 있다. php_session_rfc1867_callback()은 조건을 만족하는 첫 파일 항목에서 다음 경로를 탄다.

case MULTIPART_EVENT_FILE_START: {
 /* ... */

 if (Z_ISUNDEF(progress->data)) {
 /* Initialize the progress arrays. */
 /* ... */

 php_rinit_session(0);
 PS(id) = zend_string_copy(Z_STR(progress->sid));

 /* ... */
 }

 /* Initialize this file's progress record. */
 /* ... */

 php_session_rfc1867_update(progress, 0);
}
break;

이 갱신은 PS(http_session_vars)도 초기화하는 php_session_initialize()를 호출한다. 이 시점에 세션 모듈 전역 변수는 현재 요청 힙을 가리키지만, 일반적인 모듈 활성화는 끝나지 않았다.

이 상태를 정리하는 과정은 중단될 수 있다. 정상이라면 멀티파트 END 콜백이 이른 세션 상태를 정리한다. 하지만 분석이 먼저 치명적 오류로 끝나면 해당 콜백이 실행되지 않는다. 요청 종료도 활성화 플래그가 켜졌을 때만 확장 기능 종료 함수를 호출한다.

/* php_request_shutdown() */

if (PG(modules_activated)) {
 zend_deactivate_modules();
}

/* ... */

shutdown_memory_manager(
 CG(unclean_shutdown) || !report_memleaks, 0);

영향을 받는 mod_php 경로에서는 PG(modules_activated)가 여전히 false이므로 세션의 RSHUTDOWN이 실행되지 않는다. 요청 힙은 그래도 해제된다. Apache 작업자는 살아남지만 PS(id)와 PS(http_session_vars)는 이미 해제된 힙을 가리킨다.

이후 요청에서 오래된 전역 상태에 접근할 수 있다. 두 번째 멀티파트 요청에 세션 ID와 PHP_SESSION_UPLOAD_PROGRESS 필드를 넣되 파일 항목은 넣지 않으면 된다. 이 요청은 FILE_START를 거치지 않고 MULTIPART_EVENT_END에 도달하므로, 이른 php_rinit_session(0) 초기화가 일어나지 않는다.

case MULTIPART_EVENT_END: {
 /* ... */

 if (Z_TYPE(progress->sid) && progress->key.s) {
 if (PS(rfc1867_cleanup)) {
 php_session_rfc1867_cleanup(progress);
 }

 /* ... */
 php_rshutdown_session_globals();
 }

 /* ... */
}
break;

업로드 진행 상태 정리가 활성화돼 있으면 php_session_rfc1867_cleanup()이 php_session_initialize()를 호출한다. 이 함수는 먼저 오래된 세션 ID를 읽고, 이어 php_session_track_init()에서 아직 정의된 값처럼 보이는 이전 세션 값을 해제한다.

if (!PS(id) || !ZSTR_VAL(PS(id))[0]) {
 /* ... */
}
if (!Z_ISUNDEF(PS(http_session_vars))) {
 zval_ptr_dtor(&PS(http_session_vars));
}

따라서 첫 번째 요청에서 파일 시작 단계에 세션을 초기화하고 요청 소유 포인터를 세션 전역 변수에 저장한 뒤, 멀티파트 분석 중 오류가 나면 END 콜백과 세션 RSHUTDOWN이 건너뛰어진다. 요청 힙은 해제되지만 작업자는 살아남는다. 두 번째 요청은 파일 없이 멀티파트 양식을 보내고, END 정리 과정에서 세션을 초기화하면서 해제된 세션 ID를 읽고 해제된 세션 값을 파괴한다.

mod_php에서 php_apache_request_ctor()는 php_request_startup()을 호출한다. 시작에 실패하면 바깥 핸들러로 빠져나오지만, 핸들러 뒤의 코드는 계속 실행된다. sapi_apache2.c:665의 코드는 다음과 같다.

zend_first_try {
 /* ... */

 if (php_apache_request_ctor(r, ctx) != SUCCESS) {
 zend_bailout();
 }

 /* Execute the PHP script. */
 /* ... */
} zend_end_try();

if (!parent_req) {
 php_apache_request_dtor(r);
 /* ... */
}

return OK;

요청 소멸자는 일반적인 PHP 요청 종료를 호출한다.

static void php_apache_request_dtor(request_rec *r)
{
 php_request_shutdown(NULL);
}

오류가 일반 모듈 활성화 전에 발생했기 때문에 요청 종료는 세션 RSHUTDOWN을 건너뛰지만 요청 힙은 해제한다. Apache 자식 프로세스는 다음 요청을 처리할 수 있고, 해제된 요청 메모리를 가리키는 세션 전역 상태는 남는다.

PHP-FPM은 시작 실패를 다르게 처리한다. fpm_main.c:1826의 요청 루프는 다음과 같다.

if (UNEXPECTED(php_request_startup() == FAILURE)) {
 fcgi_finish_request(request, 1);
 SG(server_context) = NULL;
 php_module_shutdown();
 return FPM_EXIT_SOFTWARE;
}

이 return은 PHP-FPM 작업자를 종료한다. 같은 프로세스에서 두 번째 요청이 처리되지 않으므로 매달린 세션 상태를 소비할 요청도 없다.

PHP-FPM에서 악용되는 역직렬화 상태

연구팀은 mod_php에서 악용 가능한 두 중단 지점을 찾았지만, 당시에는 PHP-FPM에 영향을 주는 악용 지점을 찾지 못했다. 핵심 차이는 치명적 오류가 발생하는 시점이다. php_request_startup()이 실패하면 PHP-FPM은 작업자를 종료한다. 그러나 시작이 성공한 뒤 스크립트 실행 중 일반적인 치명적 오류가 나면 정상적인 요청 종료 경로를 따르므로 작업자는 다음 요청을 받을 수 있다.

이에 따라 연구팀은 요청 시작 뒤 애플리케이션이 해제되지 않은 상태를 남기고, 다음 요청에서 업로드 진행 기능을 통해 초기화되기 전에 그 상태에 접근할 수 있는 경로를 찾았다. 단서는 PHP의 일반 역직렬화기였다. 역직렬화기는 직렬화된 참조와 파괴 대기 값에 대한 공용 기록을 유지한다. php_var_unserialize_init()은 이 상태를 요청 힙에 할당하고 Basic 확장 전역 변수에 공개한다. 중첩된 디코딩 작업은 이 상태를 재사용한다.

/* php_var_unserialize_init(), abbreviated */

if (BG(serialize_lock) || !BG(unserialize).level) {
 d = emalloc(sizeof(struct php_unserialize_data));

 /* Initialize the reference and destructor lists. */
 /* ... */

 if (!BG(serialize_lock)) {
 BG(unserialize).data = d;
 BG(unserialize).level = 1;
 }
} else {
 d = BG(unserialize).data;
 ++BG(unserialize).level;
}

정상적으로 끝나면 php_var_unserialize_destroy()가 상태를 해제하고 중첩 수준이 0이 될 때 전역 포인터를 지운다. 실행 중인 디코더 안에서 치명적 오류가 나면 이 작업을 건너뛸 수 있다. 세션 디코더는 다음과 같은 경로를 보여준다.

PHP_VAR_UNSERIALIZE_INIT(var_hash);

/* Decode the Session's values. */
current = var_tmp_var(&var_hash);
if (php_var_unserialize(current,
 (const unsigned char **)&q,
 (const unsigned char *)endptr,
 &var_hash)) {
 /* ... */
}

/* ... */

PHP_VAR_UNSERIALIZE_DESTROY(var_hash);

phpMyAdmin은 일반적인 session_start()로 이전에 HTTP 요청을 통해 구성한 세션 기록을 불러온다. WordPress도 보통의 maybe_unserialize() 호출을 통해 같은 일반 디코더에 도달한다. 이 작업 전후의 메모리 압력을 조절하면 디코더가 참조 목록과 소멸자 목록을 쌓은 뒤 후속 할당에서 memory_limit 오류가 나도록 할 수 있다.

세션 오류 처리기는 세션을 정리하지만 Basic의 버려진 디코더 상태는 지우지 않는다. 요청 종료 시 힙은 해제되지만 BG(unserialize).data는 그 힙을 계속 가리키고 중첩 수준도 0이 되지 않는다. 오류가 요청 시작 성공 뒤에 발생했으므로 PHP-FPM 작업자는 살아남을 수 있다.

다음 멀티파트 요청은 Basic의 일반적인 요청 초기화보다 먼저 업로드 진행 기능을 통해 세션에 접근한다. 오래된 중첩 수준 때문에 php_var_unserialize_init()은 해제된 포인터를 활성 중첩 디코더로 취급한다. 그 전에 요청의 일반적인 메모리 할당이 해제된 공간을 다시 차지할 수 있다.

Application request
 → request startup succeeds
 → application starts decoding
 → decoder state is published
 → allocation fatal skips decoder destruction
 → request heap is freed; PHP-FPM worker survives

Next multipart request
 → request data reclaims the freed storage
 → upload progress starts Session before Basic RINIT
 → decoder reuses the stale bookkeeping

직렬화 데이터의 R: 및 r: 참조는 재사용된 참조 목록에서 선택한 항목을 따라갈 수 있다. 소멸자 목록은 두 번째 원시 기능도 제공한다. 제어 가능한 네이티브 zval 복사다.

각 소멸자 블록에는 부호가 있는 used_slots 개수가 있다. 할당 도우미는 항목을 더했을 때 블록 용량을 넘는지는 확인하지만, 기존 개수가 음수가 될 수 없다고 가정한다.

var_hash = (*var_hashx)->last_dtor;

if (!var_hash ||
 var_hash->used_slots + num > VAR_DTOR_ENTRIES_MAX) {
 /* Allocate a fresh destructor block. */
 /* ... */
}

/* Initialize and return the selected slots. */

메모리를 다시 차지한 뒤 이 가정을 깨뜨릴 수 있다. 시험한 64비트 메모리 배치에서는 선택 슬롯이 다음과 같이 계산된다.

slot = destructor_block + 16 + 16 × used_slots

음수 개수는 의도된 배열보다 앞선 메모리를 선택한다. 직렬화 입력에 중복 키를 넣으면 var_push_dtor_value()가 이전 값을 보존하려고 실제 zval을 해당 슬롯에 복사한다.

zval *tmp_var = var_tmp_var(var_hashx);
/* ... */
ZVAL_COPY_VALUE(tmp_var, rval);

그 결과 PHP가 포인터와 이에 연결된 형식 정보를 어느 위치에 놓을지 제어할 수 있다. 연구팀은 이 경로가 애플리케이션에 따라 Apache mod_php와 PHP-FPM 모두에서 원격 코드 실행으로 이어진다고 밝혔다.

애플리케이션은 session_start(), session_decode(), unserialize()처럼 요청 시작 후 실행되는 디코딩 작업을 노출해야 한다. WordPress의 maybe_unserialize() 같은 래퍼도 포함된다. 디코딩 데이터 자체는 정상적인 데이터일 수 있다. 공격자는 주변 메모리 압력을 조절해 유용한 시점에 디코딩을 중단한 뒤, 이후 업로드 진행 요청으로 살아남은 작업자의 오래된 상태를 소비하게 할 수 있다.

발견 경위와 대응

연구팀은 Hack/HHVM도 비슷한 문제에 취약한지 조사했다. PHP가 2020년에 제출한 패치가 HHVM에서 취약점을 완화한 것으로 나타났다. 해당 패치는 PHP 이슈 #78876 ‘multipart/form-data의 긴 변수가 메모리 부족과 임시 파일을 유발’에 관한 것이다.

패치를 살펴보던 중 연구팀은 2020년 패치에 security-audit-2024라는 Git 태그가 붙은 점을 확인했다. 이는 2024년 OSTIFY 감사에서 비슷한 문제가 발견됐음을 가리켰다. 연구팀은 PHP 측에서 유사하거나 관련된 문제가 확인됐지만, 2024년 당시에는 악용이 입증되지 않았다는 점을 확인했다고 밝혔다.

연구팀이 정리한 사건 순서는 다음과 같다.

  • 2004년 스테판 에서(Stefan Esser)가 최초의 PHP 메모리 제한 원격 취약점을 발견했다.
  • 2020년 jr@coredu.mp가 #78876을 신고했다.
  • 2024년 OSTIFY와 Quarkslab이 #78876과 관련된 버그를 확인했지만 악용은 입증되지 않았다.
  • 2026년 8월 1일 Calif가 서로 다른 여러 원격 코드 실행 경로를 독립적으로 발견했다. 이 경로는 2024년 문제와 새로운 트리거 지점을 다시 드러냈다.
  • 2026년 8월 5일 Calif 연구원 라이언 오스틴(Ryan Austin)이 발견 사항 하나의 해시를 게시했다.
  • 2026년 8월 11일 Calif가 첫 번째 버그를 PHP에 신고하고 조정된 공개 절차를 시작했다.
  • 2026년 8월 14일 Calif가 PHP에 원격 코드 실행 개념 증명(PoC)을 전달했다.
  • 2026년 8월 15일 PHP는 PoC가 예상대로 작동한다고 확인했다.
  • 2026년 8월 21일 Calif가 두 번째 버그를 PHP에 신고했다.
  • 2026년 10월 21일 패치 버전이 출시됐다.

Calif의 라이언 오스틴은 초기 버그 발견에 특별한 테스트 하네스나 일반적으로 구할 수 없는 모델을 사용하지 않았다고 설명했다. 본인이 배경지식을 갖고 취약점 이력을 잘 아는 대상을 골랐고, 특정 버그가 코드베이스에 남아 있다는 가설을 세웠다는 것이다. 그는 과거 취약점 이력을 알고 있으면 LLM에 조사 방향을 구체적으로 제시할 수 있다고 덧붙였다. 공개된 과거 취약점 데이터를 모델이 학습했을 수는 있지만, 기본적인 분석은 최신의 유명한 버그에서 인접한 오래된 코드로 거슬러 올라가는 경향이 있으며, 20년 전의 잘 알려지지 않은 악용 기법부터 안정적으로 조사하지는 않는다는 설명이다. 간단한 프롬프트만으로 모든 버그를 찾아내는 모델은 아직 없다고도 밝혔다.

PHP를 사용하는 관리자는 가능한 한 빨리 수정된 PHP 릴리스로 업그레이드해야 한다. 즉시 패치할 수 없다면 다음 임시 완화책이 시간을 벌 수 있지만, 완전한 해결책은 아니다.

  • PHP-FPM에서는 session.upload_progress.enabled=0으로 세션 업로드 진행 기능을 끄고 memory_limit=-1로 설정한다.
  • mod_php에서는 memory_limit=-1로 설정한다.

memory_limit=-1은 PHP의 메모리 상한을 없애므로 메모리 고갈 위험을 높인다. 임시 설정은 패치를 대체하지 않는다. 공식 권고는 PHP 2026년 10월 보안 릴리스를 참고하면 된다.

연구와 글은 Calif의 라이언 오스틴이 맡았다. 만화는 Ibis Craft의 스테파니 코르테스(Stephanie Cortes)가 그렸으며, 포스터와 만화 이야기는 Calif의 미할 잘레프스키(Michal Zalewski)가 맡았다.