
PHP2Shell: 원문 소스로 이해하는 Root Cause
0. 먼저 결론
PHP2Shell을 “PHP가 공격 문자열을 쉘 명령으로 실행하는 버그”로 이해하면 출발점부터 틀린다. 핵심은 C로 작성된 PHP 런타임의 메모리 오류 처리와 요청 간 상태 관리다.
- OOM 계열: 메모리 부족이 발생한 C 함수 중간에서 비지역 점프가 일어나면, 뒤에 있는 정상 정리 문장이 실행되지 않을 수 있다. 다른 정리 코드가 아직 유효하다고 믿는 상태와 실제 메모리가 어긋난다.
- stale-global 계열: 요청 A의 힙을 가리키던 전역 필드가 요청 종료 후에도 남는다. 같은 워커의 요청 B에서 정상 초기화보다 먼저 실행되는 코드가 이를 다시 사용한다.
- 이 문서의 가장 구체적인 예는 세션 역직렬화의
BG(unserialize)와 PHP 8.5 fatal backtrace의EG(last_fatal_error_backtrace)다. 전자는 OOM과 조기 세션 처리의 연결을, 후자는 OOM이 없어도 fast shutdown의 누락만으로 생기는 수명 불일치를 보여 준다.
메모리 오류가 존재한다는 사실, 메모리를 읽거나 쓰는 원시 동작이 만들어진다는 사실, 실제 코드 실행에 성공한다는 사실은 서로 다른 단계다. 여기서는 앞의 두 단계가 발생하는 이유를 소스로 설명한다. 실행 가능한 공격 요청이나 힙 배치 절차, 제어 흐름 탈취 페이로드는 다루지 않는다.
분석 기준과 한계
8.2 소스 비교는 공식 수정 커밋 01a40e89fbfc499936e3b05888d8f00bfde886df와 그 직전 부모 512c04e39f674dbf4133b8fbbd381cd8c2df6836에 고정했다. 따라서 모든 8.x 버전의 함수명·줄 번호가 동일하다고 가정하지 않는다. 특히 이 버전의 OOM 함수명은 zend_mm_safe_error()다. 흔히 쓰는 zend_mm_error()라는 요약 명칭으로 실제 함수를 바꾸어 인용하지 않는다.
소스 정적 분석이며, 이 문서를 위해 취약 서버를 실행하거나 익스플로잇을 재현하지 않았다. 개별 설치의 공격 성공 여부는 SAPI, 빌드, 확장, 설정, 요청 수명에 따라 달라진다.
0.1 처음 읽는 사람을 위한 PHP 내부 용어 지도
아래 C 코드는 PHP 프로그램이 아니라 그 프로그램을 실행하는 런타임의 구현 코드다. “누가 실행하고, 무엇을 저장하며, 언제 정리하는가”를 먼저 구분하자.
A. PHP, Zend Engine, Zend VM은 무엇이 다른가
- PHP: 개발자가 사용하는 프로그래밍 언어이면서, 문맥에 따라 그 실행 프로그램 전체를 뜻한다. 실행 프로그램에는 엔진, 표준 함수·세션 같은 확장, 외부 환경과 연결하는 부분이 포함된다.
- Zend Engine: PHP의 컴파일·실행과 값·메모리 관리를 담당하는 핵심 엔진이다.
Zend/파일은 그 C 구현이다. 별도 웹 서버나 프레임워크가 아니다. - 컴파일과 opcode: PHP 소스를 엔진이 실행할 내부 명령들로 바꾸는 과정과 그 명령이다.
op_array는 컴파일된 명령 및 관련 정보를 담는다. 이 문맥의 컴파일은 PHP 파일을 곧바로 독립 실행 파일로 만든다는 뜻이 아니다. - Zend VM: opcode를 실행하는 엔진 내부의 가상 머신이다. 별도 운영체제를 띄우는 가상 컴퓨터가 아니라 명령을 처리하는 실행 장치다.
소스에서 zend_compile_file() 뒤에 zend_execute()가 이어진다. 컴파일·실행 연결 원문. OPcache는 컴파일 결과를 재사용할 수 있다. PHP 공식 OPcache 설명.
B. 웹 서버, SAPI, 워커는 어디에 있는가
SAPI(Server API)는 PHP와 실행 환경 사이의 접점이다. 요청 정보와 입출력 등을 연결하며, 웹 실행 방식뿐 아니라 CLI도 SAPI의 한 종류다.
- PHP-FPM(FastCGI Process Manager): PHP 요청을 처리하는 워커 프로세스들을 관리하는 FastCGI 구현이다. 웹 서버가 FastCGI로 넘긴 작업을 PHP 워커가 처리한다. 공식 FPM 설명.
- mod_php: PHP를 Apache 프로세스 안에 모듈로 넣는 방식이다. FPM과 실행 위치가 다르지만 PHP 엔진을 사용한다는 점은 같다. 공식 Apache 연동 설명.
- 프로세스와 스레드: 프로세스는 독립 주소 공간을 가진 실행 단위이고, 스레드는 그 안의 실행 흐름이다.
- 워커와 요청: 워커는 일을 처리하는 실행 주체이고 요청은 그 주체가 맡는 한 번의 작업이다. 같은 워커가 요청 A를 끝내고 B를 받을 수 있다. 요청 종료는 워커 종료와 다르며, 모든 요청이 반드시 같은 워커로 가는 것도 아니다.
개념도: FPM 방식의 대표적인 관계
브라우저 → 웹 서버 → FastCGI → PHP-FPM 워커 안의 SAPI·PHP 런타임
PHP 소스 → Zend Engine의 컴파일 → opcode → Zend VM의 실행
같은 워커의 시간 흐름: 요청 A 시작 → 처리 → 요청 정리 → 요청 B 시작
관계도이지 실제 함수 호출 순서가 아니다. 요청 시작 중에도 입력·오류 처리가 실행된다. 정확한 순서는 3.4절과 6.5절을 참고한다.
C. 힙, 포인터, PHP 값의 내부 표현
힙(heap)은 실행 도중 필요한 메모리를 할당받아 사용하는 영역이다. ZendMM(Zend Memory Manager)은 PHP의 메모리 할당·해제를 관리하며, 본문의 emalloc()은 요청용 메모리를 확보하고 efree()는 반환하는 함수로 읽으면 된다. 요청 끝에는 메모리 관리자가 요청 힙을 정리한다. 영속 할당이나 공유 캐시까지 모두 같은 수명인 것은 아니다.
포인터(pointer)는 데이터 자체가 아니라 메모리 위치를 가리키는 값이다. 데이터를 해제해도 다른 곳에 저장해 둔 주소가 자동으로 지워지지는 않는다. “주소가 남아 있음”과 “그 주소의 객체가 유효함”은 다른 사실이다. p->len은 가리킨 구조체의 len 필드 접근이고, NULL은 객체를 가리키지 않는 포인터 값이다.
PHP의 문자열·배열도 엔진에서는 구조체로 표현된다.
| 용어 | 초보자가 읽을 때의 의미 | 이 문서에서 중요한 점 |
|---|---|---|
zval |
PHP 값의 종류와 값 또는 데이터 포인터를 담는 기본 표현 | zval_ptr_dtor()는 값의 종류에 맞게 정리한다. 항상 같은 한 덩어리를 즉시 해제하는 함수는 아니다. |
zend_string |
문자 바이트와 길이, 관리 정보를 함께 가진 문자열 구조체 | ZSTR_LEN()은 저장된 길이를 읽고 ZSTR_VAL()은 문자 데이터에 접근한다. 포인터가 이미 무효라면 이 정보도 믿을 수 없다. |
HashTable / zend_array |
PHP 배열의 키·값 등을 관리하는 내부 자료구조 | 단순한 C 연속 배열과 다르다. backtrace를 담는 배열도 정리 과정에서 내부 값에 접근한다. |
| 참조 횟수(refcount) | 관리되는 데이터를 몇 개의 소유 참조가 유지하는지 나타내는 값 | 필요한 참조가 없어지면 해제할 수 있다. 단순히 C 포인터를 복사했다고 자동 증가하지 않으며, 모든 값에 동일하게 적용되는 것도 아니다. |
문자열 처리는 고정 버전 zend_string.h, 배열 관리는 같은 버전 zend_hash.c에서 확인한다. 정상적인 읽기·해제도 살아 있는 객체라는 전제가 필요하다.
D. BG, PS, EG와 요청 초기화·종료
BG(...), PS(...), EG(...)는 PHP 사용자 함수가 아니라 C 전처리기 매크로, 즉 해당 내부 상태에 접근하는 표기다.
BG: basic globals. 여기서는 표준 확장의 역직렬화 작업 상태가 중요하다.PS: PHP session globals. 여기서는 현재 세션 ID 등 세션 처리 상태가 중요하다.EG: executor globals. 실행기의 상태이며 bailout 지점이나 fatal backtrace 같은 필드를 포함한다.
globals는 모든 서버가 공유하는 저장소가 아니다. 비스레드 안전(NTS) 빌드에서는 프로세스 전역, 스레드 안전(ZTS) 빌드에서는 스레드별 상태를 구분한다. PHP의 $GLOBALS와도 다르다. 여기서는 다음 요청까지 남는 필드와 먼저 사라지는 요청 힙의 수명 차이가 중요하다.
RINIT(Request Initialization)은 확장의 요청 시작 초기화, RSHUTDOWN(Request Shutdown)은 요청 종료 처리다. 프로세스·모듈의 시작/종료와 별개다. 또한 RINIT은 요청 시작 경로의 “모든 처리보다 먼저 실행되는 마법의 초기화”가 아니다. 고정 소스에서는 sapi_activate()가 zend_activate_modules()보다 먼저 호출된다. 요청 시작·종료 원문. 이 순서 때문에 나중 초기화만으로 앞선 잘못된 접근을 막지 못할 수 있다.
E. OOM, bailout, backtrace, UAF를 연결해서 읽기
- OOM(Out Of Memory): 필요한 메모리 할당을 만족시키지 못한 상태다. 서버 RAM이 완전히 소진된 경우뿐 아니라 PHP의 메모리 제한에 걸린 경우도 포함한다.
- bailout / longjmp: 심각한 오류 때 현재 C 함수의 정상 반환 경로를 버리고, 미리 정해 둔 오류 처리 위치로 이동하는 장치다. 건너뛴 문장을 자동 실행하지 않으므로 뒤에 있던 정리 코드가 누락될 수 있다. 2절의
LONGJMP가 그 지점이다. - backtrace: 오류가 나기까지 어떤 함수 호출을 거쳤는지 보여 주는 호출 이력이다. 단순 출력 문자열만을 뜻하지 않으며, 6절에서는 엔진이 요청 메모리에 배열 형태로 보관한 이력을 다룬다.
- dangling pointer / stale global / UAF: 해제된 대상을 가리키는 남은 포인터가 dangling pointer이고, 전역 상태에 오래된 값이 남은 것이 stale global이다. 그 포인터로 해제 뒤 다시 접근하면 UAF(Use After Free)가 된다. 남은 포인터가 있다는 것과 실제 접근이 일어났다는 것은 구분한다.
읽는 순서: 용어 지도 → 1절 수명 → 2절 중단 → 3·4절 세션 → 6절 backtrace. 모든 OOM이 UAF가 되거나, 모든 UAF가 코드 실행으로 이어지는 것은 아니다.
1. 세 가지 수명을 먼저 구분한다
- C 함수 호출의 수명: 지역 변수와 정상 return까지의 처리.
- HTTP 요청의 수명:
emalloc()으로 확보한 Zend 요청 힙. 요청 종료 때 메모리 관리자가 정리할 수 있다. - 워커 또는 스레드의 수명: 같은 프로세스가 다음 HTTP 요청을 처리하면서 유지되는 전역 컨테이너.
BG(...), PS(...), EG(...)는 각각 basic/session/executor globals 접근 매크로다. 전역 컨테이너가 존재한다는 사실은 그 안의 포인터가 가리키는 메모리도 계속 살아 있다는 뜻이 아니다. BG(unserialize).data와 PS(id)는 요청 힙을 가리킬 수 있다. 요청 힙을 해제하더라도 모든 C 전역 포인터가 자동으로 NULL이 되는 것은 아니다.
2. OOM이 왜 정상적인 실패 반환과 다른가
용어 연결: 0.1절 E의 bailout은 정상 return과 다르다는 설명을 실제 C 제어 흐름으로 확인한다.
2.1 allocator가 현재 함수를 끝까지 실행시키지 않는다
수정 전 메모리 제한 경로는 제한을 넘는 새 청크 할당에서 다음 함수를 부른다.
원문 · before · Zend/zend_alloc.c:1007–1021 (고정 커밋 원문)
} else {
#if ZEND_MM_LIMIT
if (UNEXPECTED(ZEND_MM_CHUNK_SIZE > heap->limit - heap->real_size)) {
if (zend_mm_gc(heap)) {
goto get_chunk;
} else if (heap->overflow == 0) {
#if ZEND_DEBUG
zend_mm_safe_error(heap, "Allowed memory size of %zu bytes exhausted at %s:%d (tried to allocate %zu bytes)", heap->limit, __zend_filename, __zend_lineno, size);
#else
zend_mm_safe_error(heap, "Allowed memory size of %zu bytes exhausted (tried to allocate %zu bytes)", heap->limit, ZEND_MM_PAGE_SIZE * pages_count);
#endif
return NULL;
}
}
#endif
원문 · before · Zend/zend_alloc.c:371–397 (고정 커밋 원문)
static ZEND_COLD ZEND_NORETURN void zend_mm_safe_error(zend_mm_heap *heap,
const char *format,
size_t limit,
#if ZEND_DEBUG
const char *filename,
uint32_t lineno,
#endif
size_t size)
{
heap->overflow = 1;
zend_try {
zend_error_noreturn(E_ERROR,
format,
limit,
#if ZEND_DEBUG
filename,
lineno,
#endif
size);
} zend_catch {
} zend_end_try();
heap->overflow = 0;
zend_bailout();
exit(1);
}
여기서 중요한 것은 오류 메시지가 아니라 마지막 zend_bailout()이다.
heap->overflow = 1은 오류 처리 중 메모리 부족 재진입을 관리하는 상태다. 호출자가 수행하던 작업을 되돌리는 트랜잭션 로그가 아니다.- 안쪽
zend_try/zend_catch는 오류 보고 과정의 bailout을 받는다. - 그 뒤
heap->overflow를 되돌린 다음 다시zend_bailout()한다. 따라서 원래 할당을 요청한 C 함수가 실패 값을 받고 자신의 다음 문장을 실행하는 모델이 아니다.
실제 점프는 다음과 같다.
원문 · before · Zend/zend.c:1175–1189 (고정 커밋 원문)
ZEND_API ZEND_COLD ZEND_NORETURN void _zend_bailout(const char *filename, uint32_t lineno) /* {{{ */
{
if (!EG(bailout)) {
zend_output_debug_string(1, "%s(%d) : Bailed out without a bailout address!", filename, lineno);
exit(-1);
}
gc_protect(1);
CG(unclean_shutdown) = 1;
CG(active_class_entry) = NULL;
CG(in_compilation) = 0;
CG(memoize_mode) = 0;
EG(current_execute_data) = NULL;
LONGJMP(*EG(bailout), FAILURE);
}
LONGJMP(*EG(bailout), FAILURE)는 등록된 bailout 지점으로 C 실행 위치를 옮긴다. 이것은 PHP 사용자 코드의 평범한 return false도 아니고, 모든 C 자원을 자동 해제하는 예외 언와인딩도 아니다. 점프에 의해 건너뛴 함수에 “나중에 실행할 DESTROY”가 있더라도 그 문장은 자동 호출되지 않는다.
이 설명이 모든 emalloc() 호출 주변에 오류가 있다는 뜻은 아니다. 이미 할당을 먼저 하고 상태를 나중에 게시하는 안전한 코드도 있다. 예를 들어 이 버전의 zend_hash_packed_to_hash()는 이를 명시한다.
원문 · before · Zend/zend_hash.c:345–351 (고정 커밋 원문)
HT_ASSERT_RC1(ht);
// Alloc before assign to avoid inconsistencies on OOM
new_data = pemalloc(HT_SIZE_EX(nSize, HT_SIZE_TO_MASK(nSize)), GC_FLAGS(ht) & IS_ARRAY_PERSISTENT);
HT_FLAGS(ht) &= ~HASH_FLAG_PACKED;
ht->nTableMask = HT_SIZE_TO_MASK(ht->nTableSize);
HT_SET_DATA_ADDR(ht, new_data);
따라서 “배열 변환 도중 포인터를 먼저 바꿔서 반드시 손상된다” 같은 가상의 코드를 실제 PHP 취약점으로 설명해서는 안 된다. 아래에서는 공식 소스로 확인되는 구체적인 세션 상태의 미완료 정리를 추적한다. 이 사례를 모든 OOM 손상의 유일한 구조체라고 일반화하지 않는다.
3. 핵심 사례 A: BG(unserialize)가 다음 요청에서 살아 있는 decoder로 오인된다
3.1 정상 경로: decoder 생성 → 전역 게시 → 해제
역직렬화 상태 구조는 입력 문자열 자체가 아니라, 역직렬화 과정의 참조·소멸 대상 등을 관리하는 내부 bookkeeping이다. 다음 함수가 그 컨텍스트를 만들거나 기존 컨텍스트를 재사용한다.
원문 · before · ext/standard/var_unserializer.re:60–93 (고정 커밋 원문)
PHPAPI php_unserialize_data_t php_var_unserialize_init(void) {
php_unserialize_data_t d;
/* fprintf(stderr, "UNSERIALIZE_INIT == lock: %u, level: %u\n", BG(serialize_lock), BG(unserialize).level); */
if (BG(serialize_lock) || !BG(unserialize).level) {
d = emalloc(sizeof(struct php_unserialize_data));
d->last = &d->entries;
d->first_dtor = d->last_dtor = NULL;
d->allowed_classes = NULL;
d->ref_props = NULL;
d->cur_depth = 0;
d->max_depth = BG(unserialize_max_depth);
d->entries.used_slots = 0;
d->entries.next = NULL;
if (!BG(serialize_lock)) {
BG(unserialize).data = d;
BG(unserialize).level = 1;
}
} else {
d = BG(unserialize).data;
++BG(unserialize).level;
}
return d;
}
PHPAPI void php_var_unserialize_destroy(php_unserialize_data_t d) {
/* fprintf(stderr, "UNSERIALIZE_DESTROY == lock: %u, level: %u\n", BG(serialize_lock), BG(unserialize).level); */
if (BG(serialize_lock) || BG(unserialize).level == 1) {
var_destroy(&d);
efree(d);
}
if (!BG(serialize_lock) && !--BG(unserialize).level) {
BG(unserialize).data = NULL;
}
}
문장 순서대로 해석하면 다음과 같다.
BG(serialize_lock)이 걸려 있거나level이 0이면 새 컨텍스트를 만든다.emalloc(sizeof(struct php_unserialize_data))는 요청 힙에d를 만든다.last, destructor 목록, 깊이, 첫 entry block을 초기화한다.- 잠금이 없으면 전역에
data = d,level = 1을 게시한다. 이후의 중첩 역직렬화가 같은 컨텍스트를 사용하도록 하는 설계다. - 이미
level != 0이면 새로 할당하지 않는다.d = BG(unserialize).data를 그대로 채택하고level을 증가시킨다. - 정상 종료에서는
php_var_unserialize_destroy()가 컨텍스트를 해제하고, level이 0으로 내려가면 전역 포인터를 NULL로 만든다.
여기서 유지되어야 할 불변식은 “serialize_lock == 0이고 level > 0이면 data는 현재 요청에서 아직 살아 있는 decoder다”이다. level은 유효성 검사나 세대 번호가 아니다. 단지 진행 중인 중첩 호출 수를 나타낸다.
3.2 세션 처리의 실제 호출자
php_serialize 세션 serializer의 decode 함수는 이 세 단계를 연속으로 호출한다. 다른 serializer 설정에서는 다른 decode 구현을 거치므로 이 함수가 모든 설치의 기본이라고 단정하지 않는다.
원문 · before · ext/session/session.c:862–878 (고정 커밋 원문)
PS_SERIALIZER_DECODE_FUNC(php_serialize) /* {{{ */
{
const char *endptr = val + vallen;
zval session_vars;
php_unserialize_data_t var_hash;
bool result;
zend_string *var_name = zend_string_init("_SESSION", sizeof("_SESSION") - 1, 0);
ZVAL_NULL(&session_vars);
PHP_VAR_UNSERIALIZE_INIT(var_hash);
result = php_var_unserialize(
&session_vars, (const unsigned char **)&val, (const unsigned char *)endptr, &var_hash);
PHP_VAR_UNSERIALIZE_DESTROY(var_hash);
if (!result) {
zval_ptr_dtor(&session_vars);
ZVAL_NULL(&session_vars);
}
위 매크로가 앞서 읽은 함수로 연결된다는 점도 헤더에서 확인할 수 있다.
원문 · before · ext/standard/php_var.h:56–60 (고정 커밋 원문)
#define PHP_VAR_UNSERIALIZE_INIT(d) \
(d) = php_var_unserialize_init()
#define PHP_VAR_UNSERIALIZE_DESTROY(d) \
php_var_unserialize_destroy(d)
특히 INIT, php_var_unserialize(), DESTROY 사이에 자동 정리를 보장하는 언어 수준 장치가 없다.
- 정상 경로: INIT에서 게시한
d를 사용한 뒤 DESTROY가 level과 포인터를 정리한다. - 평범한 파싱 실패:
php_var_unserialize()가 false로 돌아오면 DESTROY는 여전히 실행된다. 그러므로 단순히 잘못된 직렬화 문자열을 넣었다고 곧바로 이 수명 오류가 성립하는 것은 아니다. - bailout 경로: 역직렬화 내부에서 OOM으로 비지역 점프하면, 해당 호출 다음 줄의 DESTROY에 도달하지 않을 수 있다. 전역 컨테이너에는
data = d,level > 0이 남는다.
이것이 “작업의 중간 상태”의 구체적인 의미다. 이 사례에서 d의 필드를 절반만 초기화했다고 주장하는 것은 부정확하다. 이미 정상 초기화되어 게시된 컨텍스트가, 종료 프로토콜을 완료하지 못한 채 요청 수명을 벗어나는 것이다.
3.3 요청 종료가 메모리를 없애도 전역의 숫자와 포인터는 남을 수 있다
요청 시작은 모듈 활성화 여부를 먼저 0으로 두며, 입력 처리 이후에야 모듈을 활성화한다.
원문 · before · main/main.c:1776–1784 (고정 커밋 원문)
/* initialize global variables */
PG(modules_activated) = 0;
PG(header_is_being_sent) = 0;
PG(connection_status) = PHP_CONNECTION_NORMAL;
PG(in_user_include) = 0;
zend_activate();
sapi_activate();
원문 · before · main/main.c:1820–1826 (고정 커밋 원문)
php_hash_environment();
zend_activate_modules();
PG(modules_activated)=1;
} zend_catch {
retval = FAILURE;
} zend_end_try();
php_hash_environment()가 모듈 RINIT보다 먼저다. startup 단계에서 bailout하면 PG(modules_activated)=1에 도달하지 않는다. 종료 코드의 확장 RSHUTDOWN 호출은 그 값에 의존한다.
원문 · before · main/main.c:1874–1878 (고정 커밋 원문)
/* 5. Call all extensions RSHUTDOWN functions */
if (PG(modules_activated)) {
zend_deactivate_modules();
}
원문 · before · main/main.c:1924–1929 (고정 커밋 원문)
/* 15. Free Willy (here be crashes) */
zend_arena_destroy(CG(arena));
zend_interned_strings_deactivate();
zend_try {
shutdown_memory_manager(CG(unclean_shutdown) || !report_memleaks, 0);
} zend_end_try();
두 단계는 독립적이다. 확장별 정리 함수가 호출되지 않는 실패 경로에서도 요청 힙 자체는 메모리 관리자에 의해 정리될 수 있다. 따라서 “메모리는 해제되었는데 전역의 data/level은 정상 종료 때처럼 지워지지 않은 상태”가 가능하다.
정확한 요청 종료·워커 재사용 여부는 SAPI에 달려 있다. startup 실패 뒤 프로세스가 종료되는 SAPI라면 동일 워커의 다음 요청으로 연결되는 이 경로는 끊긴다. 반대로 워커가 살아서 다음 요청을 처리하면 stale global이 의미를 갖는다.
3.4 왜 다음 요청의 초기화가 구해 주지 못했나
수정 전에는 basic globals의 역직렬화 상태를 일반 RINIT에서 지웠다.
원문 · before · ext/standard/basic_functions.c:407–413 (고정 커밋 원문)
PHP_RINIT_FUNCTION(basic) /* {{{ */
{
memset(BG(strtok_table), 0, 256);
BG(serialize_lock) = 0;
memset(&BG(serialize), 0, sizeof(BG(serialize)));
memset(&BG(unserialize), 0, sizeof(BG(unserialize)));
문제는 “초기화가 전혀 없다”가 아니라 사용보다 늦다는 것이다. multipart upload progress는 애플리케이션이 session_start()를 호출하기 전, 입력 처리 과정에서 세션을 다룰 수 있다. 실제 업로드 진행률 업데이트 함수에서 세션 초기화로 들어간다.
원문 · before · ext/session/session.c:2979–2992 (고정 커밋 원문)
}
php_session_initialize();
PS(session_status) = php_session_active;
IF_SESSION_VARS() {
zval *sess_var = Z_REFVAL(PS(http_session_vars));
SEPARATE_ARRAY(sess_var);
progress->cancel_upload |= php_check_cancel_upload(progress);
Z_TRY_ADDREF(progress->data);
zend_hash_update(Z_ARRVAL_P(sess_var), progress->key.s, &progress->data);
}
php_session_flush(1);
} /* }}} */
원문 · before · ext/session/session.c:475–484 (고정 커밋 원문)
}
if (val) {
if (PS(lazy_write)) {
PS(session_vars) = zend_string_copy(val);
}
php_session_decode(val);
zend_string_release_ex(val, 0);
}
return SUCCESS;
}
세션 초기화가 읽어 온 값을 php_session_decode()에 넘기면 세션 serializer가 위의 역직렬화 컨텍스트를 사용한다. 요청 B가 이 조기 경로에 진입했는데 요청 A의 level이 남아 있다면, php_var_unserialize_init()은 “현재 요청의 중첩 호출”로 착각하여 BG(unserialize).data를 반환한다.
중요한 상태 변화는 다음과 같다.
- A에서 게시:
data = d_A,level = 1. d_A는 A의 요청 힙에 있다. - A의 비정상 탈출: DESTROY를 건너뛰어 두 필드가 남는다.
- A의 힙 정리: d_A의 저장 공간은 더 이상 유효한 decoder가 아니다.
- B의 조기 소비: 일반 RINIT 이전이라 stale fields를 보고 기존 컨텍스트를 재사용한다.
- 결과: 현재 decoder라고 믿고 읽거나 쓰는 주소가 해제된 공간 또는 다른 용도로 재사용된 공간일 수 있다.
3.5 실제로 어디에 쓰는가
단순히 오래된 포인터를 반환하는 것으로 끝나지 않는다. 내부 참조 등록 함수는 그 구조의 필드를 그대로 신뢰한다.
원문 · before · ext/standard/var_unserializer.re:116–133 (고정 커밋 원문)
static inline void var_push(php_unserialize_data_t *var_hashx, zval *rval)
{
var_entries *var_hash = (*var_hashx)->last;
#if VAR_ENTRIES_DBG
fprintf(stderr, "var_push(" ZEND_LONG_FMT "): %d\n", var_hash?var_hash->used_slots:-1L, Z_TYPE_P(rval));
#endif
if (var_hash->used_slots == VAR_ENTRIES_MAX) {
var_hash = emalloc(sizeof(var_entries));
var_hash->used_slots = 0;
var_hash->next = 0;
(*var_hashx)->last->next = var_hash;
(*var_hashx)->last = var_hash;
}
var_hash->data[var_hash->used_slots++] = rval;
}
(*var_hashx)->last로 entry block을 찾고, used_slots를 읽으며, 마지막에는 data[used_slots++] = rval로 zval 포인터를 저장한다. 이 코드 자체는 유효한 컨텍스트라는 전제 아래 정상 동작한다. 그러나 입력으로 전달된 컨텍스트가 stale이면 last, used_slots, 배열 위치 계산이 모두 더 이상 보장되지 않는다.
여기까지가 소스로 확정되는 위험의 연결이다. 특정 공격자가 원하는 주소에 원하는 값을 쓰게 할 수 있는지는 메모리 재사용과 제어 범위에 대한 별도 검증이 필요하다. 이 한 줄만으로 임의 쓰기나 RCE가 모든 환경에서 즉시 보장된다고 결론 내리지 않는다.
3.6 수정: “다음 요청에서는 쓰기 전에 반드시 지운다”
패치는 모듈 RINIT을 기다리지 않고 더 이른 초기화를 추가했다.
원문 · after · main/main.c:1815–1817 (고정 커밋 원문)
zend_activate();
php_basic_early_rinit();
sapi_activate();
원문 · after · ext/standard/basic_functions.c:505–514 (고정 커밋 원문)
PHPAPI void php_basic_early_rinit(void)
{
/* The session upload progress handler (un)serializes the session and
* registers the session id with the URL rewriter from
* php_hash_environment(), before RINIT. */
BG(serialize_lock) = 0;
memset(&BG(serialize), 0, sizeof(BG(serialize)));
memset(&BG(unserialize), 0, sizeof(BG(unserialize)));
php_url_scanner_early_rinit();
}
원문 주석 자체가 이유를 명시한다. 업로드 진행률 처리기가 RINIT 전에 세션을 직렬화·역직렬화하므로 조기 초기화가 필요하다. 이제 B에서 level=0, data=NULL이 된 후 조기 소비자가 실행되므로, A의 컨텍스트를 중첩 호출로 오인하지 않는다.
이는 역직렬화 문자열 필터를 추가한 패치가 아니다. 상태의 생명주기 시작 지점을 실제 첫 사용자보다 앞으로 이동한 수정이다.
4. 연관 사례: PS(id)는 문자열의 내용뿐 아니라 길이도 신뢰한다
용어 연결: 0.1절 C의 zend_string과 D의 PS를 함께 떠올리면, “전역 필드가 문자열 구조체를 가리킨다”는 관계가 보인다.
세션 ID 역시 요청 힙 문자열의 포인터다. 업로드 진행률 경로는 다음과 같이 이를 게시한다.
원문 · before · ext/session/session.c:3093–3095 (고정 커밋 원문)
php_rinit_session(0);
PS(id) = zend_string_init(Z_STRVAL(progress->sid), Z_STRLEN(progress->sid), 0);
if (progress->apply_trans_sid) {
원문 · before · ext/session/session.c:108–122 (고정 커밋 원문)
static inline void php_rinit_session_globals(void) /* {{{ */
{
/* Do NOT init PS(mod_user_names) here! */
/* TODO: These could be moved to MINIT and removed. These should be initialized by php_rshutdown_session_globals() always when execution is finished. */
PS(id) = NULL;
PS(session_status) = php_session_none;
PS(in_save_handler) = 0;
PS(set_handler) = 0;
PS(mod_data) = NULL;
PS(mod_user_is_open) = 0;
PS(define_sid) = 1;
PS(session_vars) = NULL;
PS(module_number) = my_module_number;
ZVAL_UNDEF(&PS(http_session_vars));
}
원문 · before · ext/session/session.c:138–141 (고정 커밋 원문)
if (PS(id)) {
zend_string_release_ex(PS(id), 0);
PS(id) = NULL;
}
정상적인 초기화는 NULL을 대입하고, 정상 정리는 문자열을 release한 뒤 NULL을 대입한다. 그러나 이런 함수가 정의되어 있다는 사실만으로 모든 비정상 경로에서 수행된다고 보장할 수 없다. 앞서 본 모듈 활성화 이전의 실패와 조기 소비가 수명 경계를 어긋나게 만든다.
쿠키를 만드는 소비자는 다음 코드다.
원문 · before · ext/session/session.c:1333–1341 (고정 커밋 원문)
/* URL encode id because it might be user supplied */
e_id = php_url_encode(ZSTR_VAL(PS(id)), ZSTR_LEN(PS(id)));
smart_str_appendl(&ncookie, "Set-Cookie: ", sizeof("Set-Cookie: ")-1);
smart_str_appendl(&ncookie, PS(session_name), strlen(PS(session_name)));
smart_str_appendc(&ncookie, '=');
smart_str_appendl(&ncookie, ZSTR_VAL(e_id), ZSTR_LEN(e_id));
zend_string_release_ex(e_id, 0);
ZSTR_VAL(PS(id))는 문자열 바이트 위치, ZSTR_LEN(PS(id))는 문자열 헤더에 기록된 길이를 얻는다. URL encoding은 정상 세션 ID가 헤더 문법을 망가뜨리지 않게 하는 처리이지, 포인터가 살아 있는지 확인하는 처리 아니다.
만약 PS(id)가 해제 후 재사용된 공간을 가리킨다면, 이 코드는 “새 메모리의 일부를 zend_string 헤더와 본문”으로 해석할 수 있다. 그 결과 잘못된 길이만큼 메모리를 읽어 인코딩하고 응답 헤더로 내보내는 위험이 생긴다. 실제 쿠키 전송에 도달하는 조건, 읽을 수 있는 범위, 재현 가능성은 별도 문제이며 모든 요청에서 발생한다고 단정할 수 없다.
패치는 multipart의 시작 시점에 세션 전역을 먼저 초기화한다.
원문 · after · ext/session/session.c:3056–3066 (고정 커밋 원문)
switch(event) {
case MULTIPART_EVENT_START: {
multipart_event_start *data = (multipart_event_start *) event_data;
/* This runs before RINIT, so we need to clear these early */
php_rinit_session_globals();
progress = ecalloc(1, sizeof(php_session_rfc1867_progress));
progress->content_length = data->content_length;
progress->sname_len = strlen(PS(session_name));
PS(rfc1867_progress) = progress;
이 역시 “ID 문자열에서 위험한 문자를 제거”하는 방식이 아니다. 이전 요청의 포인터를 현재 요청의 세션 ID로 사용하지 않도록 하는 수명 수정이다.
5. OOM 처리 자체는 어떻게 바뀌었나
조기 초기화는 요청 간 stale state를 막는다. 별도로 OOM 수정은 임의의 C 함수 중간에서 즉시 longjmp하는 설계 자체를 줄인다.
5.1 soft limit: 오류를 기록하고 일관된 중단 지점까지 진행
원문 · after · Zend/zend_alloc.c:499–537 (고정 커밋 원문)
/* Returns FAILURE if allocation should fail (in case the heap is fallible).
* When the soft limit is to be exceeded, a delayed OOM error is queued.
* When the hard limit is to be exceeded, the function does not return.
*/
static zend_always_inline zend_result zend_mm_check_limit(zend_mm_heap *heap, size_t add_size, bool gc, bool *did_gc ZEND_FILE_LINE_DC)
{
#if ZEND_MM_LIMIT
/* Is the soft limit exceeded or going to be exceeded? */
if (UNEXPECTED(heap->limit < heap->real_size || add_size > heap->limit - heap->real_size)) {
if (gc && zend_mm_gc(heap)) {
if (did_gc) {
*did_gc = true;
}
}
ZEND_ASSERT(heap->hard_limit >= heap->limit && heap->hard_limit >= heap->real_size);
if (!heap->fallible && add_size > heap->hard_limit - heap->real_size) {
zend_mm_hard_limit_error(heap, add_size ZEND_FILE_LINE_RELAY_CC);
}
/* Check soft limit again as gc may have reduced memory usage */
if (!gc || (heap->limit < heap->real_size || add_size > heap->limit - heap->real_size)) {
if (!ZEND_HAS_OOM()) {
zend_mm_delayed_error(
# if ZEND_DEBUG
"Allowed memory size of %zu bytes exhausted at %s:%d (tried to allocate %zu bytes)",
# else
"Allowed memory size of %zu bytes exhausted (tried to allocate %zu bytes)",
# endif
heap->limit
ZEND_FILE_LINE_RELAY_CC,
add_size);
}
return heap->fallible ? FAILURE : SUCCESS;
}
}
#endif /* ZEND_MM_LIMIT */
return SUCCESS;
}
핵심은 zend_mm_delayed_error()와 return heap->fallible ? FAILURE : SUCCESS다.
- soft limit을 넘으면 지연 OOM을 기록한다.
- 실패 반환을 처리하도록 작성된 fallible 경로는 FAILURE를 받을 수 있다.
- 일반 경로에서는 hard limit 안에서 현재 내부 작업이 일관된 상태까지 진행할 여지를 준다.
- 모든 할당이 무제한 허용되는 것은 아니다. 앞부분에서 hard limit 초과를 별도 검사한다.
지연 오류를 실제 처리하는 함수는 현재 실행 상태를 보존하고 unwind 종료를 준비한다.
원문 · after · Zend/zend_alloc.c:3471–3496 (고정 커밋 원문)
ZEND_API void ZEND_FASTCALL zend_handle_delayed_oom(void)
{
zend_oom_info *info = &EG(delayed_oom);
zend_execute_data *execute_data = EG(current_execute_data);
if (info->state != ZEND_OOM_STATE_PENDING) {
return;
}
info->state = ZEND_OOM_STATE_HANDLING;
AG(mm_heap)->overflow = 1;
/* message was allocated persistently, use emalloc() now as error_get_last()
* may expose it to userland. */
zend_string *message = zend_string_init(ZSTR_VAL(info->message), ZSTR_LEN(info->message), 0);
zend_string_release(info->message);
info->message = message;
if (execute_data) {
/* An exception may already be in flight, and zend_throw_unwind_exit()
* does not allow that */
EG(exception) = NULL;
/* Object allocation should succeed unless it trips the hard limit */
zend_throw_unwind_exit();
}
이 인용은 함수 앞부분이다. 전체 함수는 링크된 파일에 있으며, 이어서 오류 보고와 OOM 상태 정리를 수행한다. “bailout이 PHP 전체에서 완전히 제거되었다”고 읽으면 안 된다. 수정의 중요한 차이는 할당 실패 순간의 임의 위치와 엔진이 오류를 처리하도록 정한 지점을 분리한 것이다.
5.2 hard limit: 위험한 요청 정리 대신 워커 종료
원문 · after · Zend/zend_alloc.c:490–497 (고정 커밋 원문)
/* See http://support.microsoft.com/kb/190351 */
# ifdef ZEND_WIN32
fflush(stderr);
# endif
_exit(137);
}
#endif /* ZEND_MM_LIMIT */
_exit(137)은 PHP 사용자 shutdown 함수나 일반 요청 정리를 계속 실행시키는 것이 아니라 프로세스를 즉시 종료한다. 이는 아직 안전한 정리 상태를 보장하지 못하는 더 심각한 부족 상황에서, 손상될 수 있는 메모리를 붙잡고 계속 정리하지 않겠다는 경계다.
“memory_limit을 넘겼으니 항상 같은 방식으로 fatal error 후 워커를 재사용한다”는 이전 가정은 더 이상 이 수정 구조를 설명하지 못한다.
6. 핵심 사례 B: PHP 8.5 fatal backtrace의 요청 간 수명 오류
용어 연결: 0.1절 C·E의 배열과 backtrace가 만나는 사례다. 호출 이력도 요청 메모리에 저장되므로, 기록이 남은 것처럼 보여도 실제 객체는 이미 해제됐을 수 있다.
이 절은 공식 php-src의 php-8.5.11과 php-8.5.12 태그 원문을 직접 대조했다. 여기서 실제 필드명은 EG(last_fatal_error_backtrace)다. 아래 사례는 “모든 정리 코드가 longjmp로 건너뛰어진다”는 설명보다 구체적이다. 8.5.11의 빠른 종료 경로에서는 이 필드의 초기화 자체가 빠져 있었다.
6.1. 생산자: 요청 메모리의 backtrace를 전역 필드에 보관
치명적 오류이고 backtrace 기능이 켜져 있으며 앞선 예외 분기로 들어가지 않는 경우, 오류 처리기가 backtrace 생성 함수에 전역 필드의 주소를 넘긴다.
} else if (EG(fatal_error_backtrace_on) && (type & E_FATAL_ERRORS)) {
zend_fetch_debug_backtrace(&EG(last_fatal_error_backtrace), 0, EG(exception_ignore_args) ? DEBUG_BACKTRACE_IGNORE_ARGS : 0, 0);
}
zend_fetch_debug_backtrace()는 Zend/zend_builtin_functions.c:1923에서 array_init(return_value)를 실행한다. 따라서 이것은 오류 문자열만 저장하는 것이 아니라, 요청 수명의 배열을 executor 전역 zval에서 가리키는 경로다. 배열 생성 원문
6.2. 종료 경로의 비대칭: 느린 종료에서는 지우지만 빠른 종료에서는 빠뜨림
8.5.11에서는 아래 두 줄이 if (!fast_shutdown) 블록 안에 있다. 그 조건은 같은 파일 282행에 있다.
zval_ptr_dtor(&EG(last_fatal_error_backtrace));
ZVAL_UNDEF(&EG(last_fatal_error_backtrace));
반면 아래 빠른 종료 분기는 상수 테이블을 버린 뒤 객체 저장소 정리로 진행하고 함수를 끝낸다. backtrace 필드를 UNDEF로 만드는 대응 코드가 없다.
} else {
zend_hash_discard(EG(zend_constants), EG(persistent_constants_count));
}
zend_objects_store_free_object_storage(&EG(objects_store), fast_shutdown);
}
일반 비디버그 빌드의 fast_shutdown 조건은 is_zend_mm() && !EG(full_tables_cleanup)이다(446행). 즉 이 차이는 단순히 비정상 종료에서만 존재하는 분기가 아니다. 빠른 종료는 개별 할당을 하나씩 해제하는 대신 메모리 관리자의 요청 단위 정리에 의존한다.
6.3. 메모리 수명은 끝나지만 전역 필드의 값은 남음
이후 php_request_shutdown()의 요청 메모리 정리 단계가 실행된다.
/* 15. Free Willy (here be crashes) */
zend_arena_destroy(CG(arena));
zend_interned_strings_deactivate();
zend_try {
shutdown_memory_manager(CG(unclean_shutdown) || !report_memleaks, 0);
} zend_end_try();
여기서 요청 메모리가 회수된 뒤에도 전역 zval이 배열을 가리키는 상태로 남을 수 있다. 전역 필드의 저장 공간이 유지되는 것과 그 안에서 가리키는 배열의 수명이 유지되는 것은 다르다. 또한 메모리 회수가 반드시 모든 페이지의 운영체제 반환을 뜻하는 것은 아니다. 이전 요청의 할당으로서 유효성이 끝났다는 것이 핵심이다.
6.4. 소비자: 다음 오류를 처리하면서 이전 backtrace를 파괴하려 함
8.5.11 오류 처리기는 새 backtrace를 만들기 전에 이전 값을 정리한다.
// Always clear the last backtrace.
zval_ptr_dtor(&EG(last_fatal_error_backtrace));
ZVAL_UNDEF(&EG(last_fatal_error_backtrace));
정상적인 살아 있는 배열이라면 필요한 정리지만, 이전 요청에서 이미 회수된 배열을 가리키고 있다면 첫 줄의 destructor가 stale state를 소비한다. 뒤의 UNDEF는 그 접근이 끝난 다음이므로 앞선 접근을 보호하지 못한다. 이 정리 지점에 도달하는 진단은 반드시 fatal일 필요가 없다. 다만 record_errors 분기에서 조기 반환하는 경우까지 “모든 진단이 무조건 도달한다”고 일반화해서는 안 된다.
6.5. 왜 다음 요청의 초기화가 먼저 막지 못하는가
init_executor()는 실제로 필드를 초기화한다. 문제는 초기화 이전에도 실행되는 경로다.
ZVAL_UNDEF(&EG(last_fatal_error_backtrace));
mod_php의 정상 요청 진입 경로에서 확인되는 순서는 다음과 같다.
- sapi_apache2.c:620의 apply_config(conf)가 먼저 실행된다.
- apache_config.c:200의 zend_alter_ini_entry_chars()를 통해 INI 갱신 콜백이 실행될 수 있다.
- 그 후 sapi_apache2.c:672에서 php_apache_request_ctor()를 호출한다. 이 함수는 554행에서 php_request_startup()으로 들어간다.
- php_request_startup()은 main.c:1905에서 zend_activate()를 호출하고, zend_activate()는 zend.c:1324에서 init_executor()를 호출한다.
따라서 INI 적용 중 진단이 발생하면 위 초기화보다 먼저 오류 처리기에 들어갈 수 있다. PHP 8.5의 구체적인 진단 예로 main.c:667–678의 OnUpdateReportMemleaks()는 false 설정에 deprecation을 발생시킨다. 이것은 소스의 호출 순서 설명이며 공격 요청이나 설정 재현 절차를 제공하는 것은 아니다.
Apache 설정 적용과 요청 생성 순서 · INI 갱신 호출 · 진단 콜백 · executor 활성화
6.6. 8.5.12 수정: 요청 종료에서 소비 가능한 전역 상태를 무효화
수정본은 느린/빠른 종료 분기 바깥, 객체 저장소 정리 뒤에 다음 코드를 추가한다.
/* may be used by zend_error_zstr_at before init_executor */
ZVAL_UNDEF(&EG(last_fatal_error_backtrace));
EG(record_errors) = false;
EG(num_errors) = 0;
EG(errors) = NULL;
EG(error_handling) = EH_NORMAL;
EG(exception) = NULL;
주석도 init_executor 이전의 zend_error_zstr_at 사용을 명시한다. 이 수정의 핵심은 다음 요청의 초기화까지 기다리지 않고 이전 요청 종료에서 외부에 남은 참조를 무효화하는 것이다. 빠른 종료의 일괄 메모리 정리는 유지하면서 그 메모리를 가리키던 전역 상태가 다음 요청에서 유효한 값처럼 소비되지 않도록 한다.
요약하면: fatal 오류가 요청 배열을 전역에 저장 → 빠른 종료에서 전역 reset 누락 → 요청 메모리 회수 → 같은 worker의 다음 요청에서 초기화 전 INI 진단 → 이전 배열에 대한 destructor 접근이다. 이는 CVE-2026-109619의 여러 상태 수명 문제 가운데 하나의 구체적인 소스 경로이지, 모든 PHP 버전·SAPI·환경이 동일한 경로를 사용한다는 주장은 아니다.
7. 전체 원인을 한 문장씩 다시 연결하면
- 요청별 메모리를 가리키는 포인터를 워커 수명의 전역에 저장하는 것은 그 자체로 버그가 아니다. 문제는 정상/비정상 종료와 조기 재사용 경로 모두에서 수명을 맞추지 못한 것이다.
- 정상 함수 반환에서는 후속 DESTROY를 실행할 수 있지만, OOM의 LONGJMP는 그 문장을 건너뛸 수 있다. 사용자 코드의 try/finally만으로 엔진 C 코드의 이런 경로를 일반적으로 고칠 수 없다.
- 요청 힙 정리는 전역 포인터의 자동 초기화와 다르다. 포인터 값이 남아 있다고 객체가 살아 있는 것이 아니다.
- 다음 요청 시작의 RINIT이 존재해도, multipart 또는 per-request INI 진단이 그보다 먼저 실행되면 보호하지 못한다.
- 소비자 함수의 문자열 길이 읽기, entry block 접근, zval 소멸은 유효한 객체라는 전제에서 정상이다. 수명 버그가 그 전제를 깨면 같은 코드가 부적절한 읽기·쓰기·해제를 한다.
- 패치는 단순 입력 필터가 아니라 오류 처리 방식, 전역 초기화 시점, shutdown 이후 상태를 함께 고친다.
이 문서가 증명한 것과 증명하지 않은 것
- 증명한 것: 지정한 소스 버전에서 데이터가 게시되는 문장, 정상 정리 문장, OOM의 비지역 점프, 요청 초기화 순서, stale data를 신뢰하는 구체적인 소비자 문장, 이를 바꾸는 공식 패치.
- 공식 공개와 구분한 것: PHP2Shell의 실제 공격 시연과 운영 환경별 영향은 공개 연구/보안 공지의 주장이다. 이 문서가 별도로 실행 검증한 결과가 아니다.
- 증명하지 않은 것: 임의의 PHP 서버에서 이 코드만으로 재현 가능한 RCE, 특정 주소/길이의 안정적 제어, 모든 OOM이 동일한 구조체를 손상시킨다는 주장.
8. 취약점들이 RCE 체인을 구성하는 방식
8.1. 체인의 구분 기준은 버그 이름보다 각 단계의 역할이다
앞서 확인한 원인들을 모두 한 줄로 연결하면 실제보다 과장된 체인이 된다. HashTable 손상, BG(unserialize), PS(id), backtrace, OPcache는 전부 차례대로 거쳐야 하는 단계가 아니다. 우선 잘못된 상태가 언제 생기며, 누가 그것을 소비하는지에 따라 경로를 나눈다.
| 부분 초기화 HashTable | 같은 요청의 삽입 처리에서 종료 처리로 | 미완성 원소를 소멸 대상으로 노출 |
| BG(unserialize) | 이전 요청에서 같은 워커의 다음 요청으로 | 해제된 메모리를 역직렬화 관리 정보로 사용 |
| PS(id) | 이전 요청에서 같은 워커의 다음 요청으로 | 잘못된 문자열 참조를 통한 정보 노출 가능성 |
| fatal backtrace | 이전 요청에서 다음 요청의 조기 진단으로 | 오래된 배열에 대한 소멸 연산 |
| OPcache | 워커 내부 상태와 공유 상태 사이 | 서로 다른 종류의 런타임 객체를 혼동 |
Wiz가 보고한 결합 사례에서는 역직렬화기 손상이 메모리 조작 능력을, 정보 누출이 주소에 관한 정보를 제공한다. backtrace와 OPcache는 별도의 RCE 시연 경로다. 따라서 아래의 화살표는 공통 공격 순서가 아니라 각 경로 내부에서 다음 동작이 가능해지는 이유를 나타낸다. Wiz 공개 분석
8.2. 같은 요청 안의 연결: 부분 초기화된 HashTable이 정리 대상으로 넘어간다
이 경로는 3절의 오래된 전역 포인터와 출발점이 다르다. 포인터를 다음 요청까지 남길 필요 없이, 아직 만들고 있는 원소를 현재 요청의 종료 코드가 완성된 원소로 취급하면 된다.
수정 전 _zend_hash_str_add_or_update_i()의 삽입 경로에는 다음 순서가 있다. 2절에서 인용한 안전한 zend_hash_packed_to_hash()와 다른 함수다.
원문 · before · Zend/zend_hash.c:886–890 (고정 커밋 원문)
add_to_hash:
idx = ht->nNumUsed++;
ht->nNumOfElements++;
p = ht->arData + idx;
p->key = key = zend_string_init(str, len, GC_FLAGS(ht) & IS_ARRAY_PERSISTENT);
이 다섯 줄에서 체인을 연결하는 값은 nNumUsed다. 문자열 할당에 성공해 대입을 마치기 전에 사용 범위가 늘어난다. 이어지는 해시값·값·충돌 연결의 설정까지 끝나야 정상 원소가 되지만, 그 사이의 할당에서 2절의 bailout이 발생하면 초기화를 완료할 기회가 사라진다.
이후 zend_array_destroy()의 비-packed 분기는 arData부터 nNumUsed가 정한 끝까지 순회한다. 즉 삽입이 완결됐는지를 해시 연결만으로 확인하지 않고, 이미 증가한 사용 범위를 정리 대상으로 받아들인다. 정리 순회 원문
연결 관계를 단계별로 읽으면 다음과 같다.
- 범위의 선행 갱신: 새 칸을 정리 범위에 포함한다. 이것만으로 손상이 발생하지는 않는다.
- 초기화의 중단: 키 할당이 비지역적으로 중단되면 사용 범위와 원소의 완성도가 어긋난다.
- 종료 코드로 전달: 해당 SAPI가 요청 정리를 계속하면 미완성 칸까지 소멸 과정에 전달될 수 있다.
- 내용을 관리 정보로 소비: 소멸 코드가 값의 종류와 포인터 등을 신뢰하면서 부적절한 메모리 접근이 발생할 수 있다.
정리 분기도 구분해야 한다. static-key 전용 경로는 값의 소멸을 수행하고, 다른 경로는 키 해제를 추가하며, 빈 칸이 있을 수 있는 경로는 IS_UNDEF 검사도 한다. 따라서 “미완성 칸의 모든 필드를 항상 해제한다”는 설명은 맞지 않는다. 핵심은 정리 범위에 들어갔다는 사실이 내부 필드의 유효성을 보장하지 못한다는 점이다.
Calif는 이 부분 초기화 경로가 mod_php의 시작 실패 후 요청 정리에서 소비된다고 보고했다. 같은 시작 실패에서 FPM은 워커 종료로 빠지므로 이 연결을 그대로 적용할 수 없다. 여기서 SAPI의 차이는 취약한 삽입 코드의 존재 여부보다 그 뒤에 어떤 소비자가 실행되는가를 결정한다. Calif 분석
8.3. 요청 사이의 연결: BG 상태는 손상된 메모리를 다시 해석하는 통로가 된다
3절에서 확인한 BG(unserialize)의 체인은 다음과 같이 압축할 수 있다.
정상 decoder 게시 → 정상 DESTROY를 건너뛰는 중단 → 요청 힙 회수 → 같은 워커의 조기 소비 → 옛 주소를 decoder로 재사용
여기서 OOM이 제공하는 것은 실행 권한이 아니라 정상 정리의 중단 지점이다. 요청 종료는 메모리의 유효성을 끝내고, 전역 슬롯은 그 주소를 이후까지 전달한다. 다음 요청의 조기 세션 처리는 일반 초기화가 지우기 전에 그 슬롯에 도달한다. 이 세 역할이 맞물려야 수명 오류가 실제 접근으로 이어진다.
체인에서 결정적인 전환은 옛 주소의 바이트를 다시 관리 구조체로 받아들이는 순간이다. 3.5절의 var_push()는 관리 구조체에서 읽은 목록과 사용량을 토대로 참조를 기록한다. 따라서 실패한 요청이 남긴 상태는 단순한 오류 표시가 아니라, 후속 정상 연산이 어디를 읽고 쓰는지 결정하는 입력이 된다.
다만 3.5절의 마지막 대입은 zval 포인터 등록이다. Calif가 별도로 설명한 destructor 목록의 zval 복사와 동일한 문장이 아니다. 전자는 이 문서가 소스로 확인한 stale 컨텍스트 소비의 예이고, 후자는 연구진이 보고한 손상 능력의 한 사례다. 둘을 섞어 “이 대입 한 줄이 곧 제한 없는 임의 쓰기”라고 해석해서는 안 된다. 형식이 정해진 내부 값의 복사는 목적지에 영향을 줄 수 있더라도 자유로운 바이트열 쓰기와 제약이 다르다. Calif 분석
또한 전역이 남는 경로와 그 전역에 다시 도달하는 경로를 각각 확인해야 한다. 조기 세션 처리가 존재한다는 사실만으로 이전 요청에서 필요한 상태가 남았다고 증명되지는 않는다. 공식 권고문도 producer와 consumer, 요청 메모리 회수, 같은 워커의 재사용을 별도 조건으로 둔다. PHP stale-global 권고문
FPM의 경우 시작 단계 실패와 시작 성공 이후의 fatal을 구별해야 한다. 후자에서는 워커가 살아남을 수 있으므로, 애플리케이션이 도달 가능한 역직렬화 작업을 제공하는지가 상태 생성 단계의 조건이 된다. 이것은 앞선 HashTable 경로가 FPM에도 그대로 적용된다는 뜻이 아니라, 다른 생산 경로가 다음 요청의 소비자와 연결될 수 있다는 뜻이다. Calif 분석
8.4. PS(id) 누출은 손상 능력과 다른 부족분을 채운다
4절의 PS(id) 소비자는 문자열의 위치와 길이를 읽어 응답용 문자열을 만든다. 이 지점은 메모리를 변경하는 경로와 달리, 프로세스 내부 정보가 외부에서 관찰 가능한 결과로 넘어가는 경계다.
이를 BG(unserialize)와 결합해서 읽을 때는 역할을 분리한다.
- BG 계열의 손상은 엔진이 잘못된 관리 정보를 바탕으로 연산하게 만들 수 있다.
- PS 계열의 누출은 메모리 안에 무엇이 있는지에 관한 정보를 제공할 수 있다.
- 공개된 결합 사례에서 누출은 주소의 불확실성을 줄여 손상 능력을 활용하는 데 기여했다. Wiz 공개 분석
누출이 손상 자체를 만들어 주는 것은 아니며, 주소를 알았다고 원하는 메모리를 변경할 권한이 생기는 것도 아니다. 반대로 손상 가능성이 있어도 대상과 결과를 충분히 제어하지 못하면 충돌로 끝날 수 있다. 두 기능은 서로 다른 제약을 완화한다.
따라서 PS(id) → BG(unserialize)라는 표기는 내부 함수가 서로를 직접 호출한다는 뜻으로 쓰면 안 된다. 한 경로가 얻은 정보를 다른 경로의 활용에 결합했다는 관계다. 각 경로의 재현 조건은 여전히 별개이며, 동일 요청에서 두 상태가 항상 함께 남거나 모든 환경에서 같은 조합이 필요하다고 추론할 수 없다. 누출만 성립해도 기밀성 피해는 독립적으로 남는다.
8.5. Backtrace와 OPcache는 다른 소비자를 통해 실행 영향으로 이어진다
6절의 backtrace 경로는 역직렬화기의 다음 단계가 아니다. 핵심 소비자가 새 오류를 처리하기 전에 실행하는 이전 backtrace의 destructor라는 점이 다르다. 여기서는 값을 새로 등록하거나 응답으로 내보내지 않아도, 기존 상태를 정리하려는 동작 자체가 오래된 배열에 접근한다.
또한 6절의 fast shutdown 누락은 OOM을 필수 조건으로 두지 않는다. 따라서 모든 체인의 맨 앞에 OOM을 붙이면 이 경로의 원인과 조건을 잘못 표현하게 된다. 실제 연결은 backtrace의 게시, 종료 시 무효화 누락, 다음 요청의 초기화 전 진단이라는 관계로 읽어야 한다.
OPcache는 또 다른 경계다. Wiz는 영속화 과정에서 워커 내부의 포인터 맵 진행 상태와 공유 상태의 갱신 시점이 어긋나고, 이후 슬롯 재사용으로 잘못된 종류의 객체가 참조되는 type confusion을 설명했다. 같은 워커에 남은 BG 포인터를 재사용하는 모델을 그대로 대입할 수 없다. Wiz 공개 분석
backtrace의 호출·정리 순서는 6절에서 공식 소스로 확인했지만, 이 문서는 OPcache의 전체 구현을 같은 깊이로 대조하지 않았다. OPcache의 연결 설명과 RCE 결과는 공개 연구진의 보고 범위로 한정한다. 두 경로 모두 앞선 세션 체인의 필수 구성 요소로 추가하지 않는다.
8.6. RCE로 이어지는 마지막 연결과 패치가 끊는 연결
지금까지의 소스가 보여 주는 것은 정상 객체라는 전제가 무너진 뒤에도 읽기·쓰기·소멸 연산이 계속되는 지점이다. 여기서 RCE를 주장하려면 손상이 실행에 영향을 주는 상태까지 도달하며, 그 영향이 공격자의 의도에 따라 제어된다는 추가 증거가 필요하다. 예를 들어 잘못된 포인터를 읽다가 종료된 결과와 실행 동작을 장악한 결과는 동일하지 않다.
연구진의 RCE 시연 보고는 그 연결이 특정 환경에서 성립했다는 근거다. 그러나 본문의 소스 분석만으로 마지막 실행 전환에 사용된 정확한 객체나 함수 포인터까지 확인한 것은 아니다. 그 빈칸을 일반적인 가젯 체인이나 임의의 함수 호출로 채우지 않는다.
반면 패치는 어느 연결을 끊는지 구체적으로 읽을 수 있다.
- 5절의 OOM 처리 변경: 내부 작업의 중간에서 곧바로 이탈해 불완전한 상태를 남기는 연결을 다룬다. hard limit에서는 위험한 요청 정리를 계속하기보다 프로세스를 종료한다.
- 3.6절·4절의 조기 초기화: 이전 요청의 상태가 남더라도 다음 소비자가 그것을 현재 상태로 받아들이는 연결을 끊는다.
- 6.6절의 종료 시 무효화: 요청 메모리가 사라진 뒤에도 소비 가능한 backtrace가 남는 연결을 끊는다.
특히 비교한 수정 후 zend_hash.c에도 사용 범위 증가 뒤 키를 할당하는 순서는 남아 있다. 따라서 8.2절의 해결을 “키 할당을 앞으로 옮긴 패치”라고 설명하면 안 된다. 이 경우에는 5절의 중단·오류 처리 방식 변경까지 함께 읽어야 실제 수정과 체인 설명이 맞는다. 수정 후 삽입 경로
9. 출처와 원문 재확인
- 공식 OOM 보안 공지 GHSA-w8hw-w9fx-j79w
- 공식 stale-global 보안 공지 GHSA-6wmf-4rv5-p3rp
- 8.2 전체 수정 커밋
- 공개 연구 PHP2Shell
- Calif: 부분 초기화 HashTable, SAPI 차이, 역직렬화 상태의 연결
- Wiz: 정보 누출 결합과 backtrace·OPcache의 별도 RCE 시연
- PHP: 요청 간 상태의 producer·consumer 성립 조건
두 취약점 분류는 원인 층위가 겹칠 수 있다. OOM은 상태를 남기는 원인이 될 수 있고, stale global은 그 상태가 다음 요청에서 소비되는 방식이다. 따라서 이 문서의 세션 경로를 “오직 한 CVE에만 속하는 독립 함수 버그”로 분리해서 읽지 않는다.
부록. 원문 코드의 저작권과 라이선스
인용 코드는 PHP Group 및 Zend Technologies와 기여자들의 공식 php-src 소스이다. 각 파일의 저작권 헤더 및 아래 라이선스가 적용된다. 해설은 원문 코드와 별도이며 PHP Group 또는 Zend의 보증·승인을 의미하지 않는다. 원문 파일과 파일별 헤더는 소스 묶음에 함께 보존했다.
PHP License 3.01
--------------------------------------------------------------------
The PHP License, version 3.01
Copyright (c) 1999 - 2024 The PHP Group. All rights reserved.
--------------------------------------------------------------------
Redistribution and use in source and binary forms, with or without
modification, is permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
3. The name "PHP" must not be used to endorse or promote products
derived from this software without prior written permission. For
written permission, please contact group@php.net.
4. Products derived from this software may not be called "PHP", nor
may "PHP" appear in their name, without prior written permission
from group@php.net. You may indicate that your software works in
conjunction with PHP by saying "Foo for PHP" instead of calling
it "PHP Foo" or "phpfoo"
5. The PHP Group may publish revised and/or new versions of the
license from time to time. Each version will be given a
distinguishing version number.
Once covered code has been published under a particular version
of the license, you may always continue to use it under the terms
of that version. You may also choose to use such covered code
under the terms of any subsequent version of the license
published by the PHP Group. No one other than the PHP Group has
the right to modify the terms applicable to covered code created
under this License.
6. Redistributions of any form whatsoever must retain the following
acknowledgment:
"This product includes PHP software, freely available from
<http://www.php.net/software/>".
THIS SOFTWARE IS PROVIDED BY THE PHP DEVELOPMENT TEAM ``AS IS'' AND
ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE PHP
DEVELOPMENT TEAM OR ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------
This software consists of voluntary contributions made by many
individuals on behalf of the PHP Group.
The PHP Group can be contacted via Email at group@php.net.
For more information on the PHP Group and the PHP project,
please see <http://www.php.net>.
PHP includes the Zend Engine, freely available at
<http://www.zend.com>.
Zend Engine License 2.00
--------------------------------------------------------------------
The Zend Engine License, Version 2.00
Copyright (c) 1999-2006 Zend Technologies Ltd. All rights reserved.
--------------------------------------------------------------------
Redistribution and use in source and binary forms, with or without
modification, is permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided
with the distribution.
3. The names "Zend" and "Zend Engine" must not be used to endorse
or promote products derived from this software without prior
permission from Zend Technologies Ltd. For written permission,
please contact license@zend.com.
4. Zend Technologies Ltd. may publish revised and/or new versions
of the license from time to time. Each version will be given a
distinguishing version number.
Once covered code has been published under a particular version
of the license, you may always continue to use it under the
terms of that version. You may also choose to use such covered
code under the terms of any subsequent version of the license
published by Zend Technologies Ltd. No one other than Zend
Technologies Ltd. has the right to modify the terms applicable
to covered code created under this License.
5. Redistributions of any form whatsoever must retain the following
acknowledgment:
"This product includes the Zend Engine, freely available at
http://www.zend.com"
6. All advertising materials mentioning features or use of this
software must display the following acknowledgment:
"The Zend Engine is freely available at http://www.zend.com"
THIS SOFTWARE IS PROVIDED BY ZEND TECHNOLOGIES LTD. ``AS IS'' AND
ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL ZEND
TECHNOLOGIES LTD. BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
SUCH DAMAGE.
--------------------------------------------------------------------
댓글