
취약점 개요
| 항목 | 내용 |
| CVE | CVE-2026-87902 |
| 대상 | WordPress Core (wp-includes/template.php의 get_page_template()) |
| 유형 | Path Traversal → Local File Inclusion → 조건부 RCE |
| CWE | CWE-98 (include 파일명 통제 미흡), 일부 자료는 CWE-22로 분류 |
| CVSS 4.0 | 9.2 (Critical) |
| 인증 | 불필요 |
| 영향 버전 | 4.7.0 ~ 7.1.1 |
| 패치 버전 | 7.1.2, 7.0.6, 6.9.9, 6.8.10 (4.7.37까지 백포트) |
핵심을 한 줄로 요약하면 이렇습니다.
요청의 pagename 값으로 만든 템플릿 파일 경로에 경로 검증이 빠져 있었다.
워드프레스는 페이지 템플릿을 어떻게 고를까
워드프레스에서 글이나 페이지를 보여줄 때, 어떤 테마 파일로 화면을 그릴지 정하는 과정이 필요합니다. 이 일을 하는 함수가 get_page_template()입니다.
동작을 쉽게 비유하면 "이 파일 있어요?" 하고 순서대로 물어보는 과정입니다.
- 함수가 후보 파일명을 우선순위대로 늘어놓은 목록을 만든다. (예: page-about.php, page.php ...)
- 그 목록을 템플릿 로더에 넘긴다.
- 로더가 테마 디렉터리에서 앞에서부터 하나씩 찾아, 처음 발견한 파일을 include 한다.
여기서 중요한 점은 후보 파일명 중 일부가 사용자 요청에서 온다는 것입니다. pagename은 URL의 페이지 슬러그를 담는 쿼리 변수인데, 요청 값에서 그대로 나옵니다. 사용자 입력이 파일 경로가 되는 구조이니, 원래라면 반드시 검증을 거쳐야 합니다.
취약한 소스코드 분석
Patchstack이 공개한 7.1.1 이하의 문제 부분입니다.

첫 번째 블록: 검증이 있는 후보

$template은 관리자가 페이지에 지정한 템플릿 슬러그입니다. 이 값은 후보 목록에 넣기 전에 validate_file()을 통과해야 합니다. validate_file()은 워드프레스가 제공하는 경로 검증 함수로, 대략 ../ 같은 상위 디렉터리 이동이 들어 있으면 "안전하지 않다"고 판정합니다. 반환값이 0일 때만 안전하다고 보기 때문에 0 === validate_file(...) 형태로 씁니다.
즉 워드프레스는 이 후보가 위험할 수 있다는 걸 알고 방어해 둔 상태입니다.
두 번째 블록: 검증이 없는 후보

한 줄씩 보겠습니다.
| 코드 | 의미 |
| if ( $pagename ) | 요청에 pagename이 있으면 진행 |
| urldecode( $pagename ) | %2f 같은 URL 인코딩을 원래 문자(/)로 되돌림 |
| $pagename_decoded !== $pagename | 디코딩 결과가 원본과 다르면 (= 인코딩된 문자가 있었으면) |
| "page-{$pagename_decoded}.php" | 디코딩된 값으로 후보 파일명을 만들어 목록에 추가 |
문제는 바로 이 부분입니다. 위쪽 블록에는 있던 validate_file() 검사가 여기에는 없습니다. Patchstack의 표현대로 보호 코드가 바로 세 줄 위에 있었는데, 정작 필요한 곳에서는 빠져 있었습니다.
왜 ../가 살아남을까: 검사 시점과 사용 시점의 불일치
"그래도 워드프레스가 슬러그를 정제하지 않나?"라는 의문이 들 수 있습니다. 실제로 그렇습니다. 요청이 들어오면 워드프레스는 슬러그를 자체 정제 단계에 통과시키는데, 이 단계는 다음처럼 동작합니다.
- 리터럴 ../처럼 문자 그대로의 점과 슬래시는 다시 쓰거나 잘라냅니다.
- 반면 %2f, %2e 같은 이스케이프된 문자(escaped octet)는 그대로 보존합니다.
그러니 평범한 ../../를 보내면 정제 단계에서 걸러지지만, 인코딩해서 보내면 통과합니다. 그리고 정제가 끝난 뒤에 get_page_template()이 urldecode()를 호출하면서 인코딩되어 있던 위험 문자가 진짜 ../로 복원됩니다.
정리하면 다음 순서입니다.
- 정제 단계: 인코딩된 상태라서 "안전해 보임" → 통과
- urldecode(): 진짜 ../로 복원됨
- 복원된 값이 검증 없이 파일 경로에 사용됨
"검사할 때의 모습"과 "사용할 때의 모습"이 다른 전형적인 디코딩 후 사용 문제입니다. 실제 공격 트래픽에서 이중 인코딩(%252f 형태)이 관측되는 것도 이 때문입니다. 요청 단계에서 한 번 디코딩되고, urldecode()에서 또 한 번 디코딩되기 때문에 두 번 인코딩한 값이 마지막에 원래 문자로 돌아옵니다.
공격자가 넘어야 하는 제약
경로를 바꿀 수 있다고 해서 아무 파일이나 읽는 것은 아닙니다. 코드가 만드는 파일명은 항상 다음 꼴입니다.
page- + (공격자 값) + .php
여기서 두 가지 제약이 생깁니다.
- 접두사 page-: 값이 page-로 시작하는 실제 디렉터리를 이어서 써야 합니다. 그래서 활성 테마에 page-templates 같은 최상위 디렉터리가 있어야 합니다. 레거시 기본 테마와 여러 인기 서드파티 테마에 있는 구조입니다. 개념적으로는 page-templates/ 뒤에 상위 디렉터리 이동을 여러 번 붙여 테마 밖으로 빠져나가는 식입니다.
- 접미사 .php: 확장자가 자동으로 붙으므로 대상은 읽을 수 있는 .php 파일이어야 합니다.
또 하나 눈여겨볼 점은 page_id입니다. 실제 공격 요청에는 거의 항상 유효한 page_id가 pagename과 함께 붙어 있습니다. 실제 글로 해석되는 ID가 없으면 쿼리가 결과를 못 찾아 404가 나고, 템플릿 선택 단계까지 가지 못하기 때문입니다. 취약 코드에 도달하기 위한 조건인 셈입니다.
그림으로 보는 공격 흐름

그림 1은 요청 하나가 파일 실행까지 가는 4단계를 왼쪽에서 오른쪽으로 보여줍니다. 각 카드의 왼쪽 색 띠는 위험도를 뜻합니다.
- ① 공격자 요청 (빨강): 요청에 유효한 page_id와 인코딩된 pagename이 함께 들어갑니다. 로그인도 쿠키도 필요 없다는 점이 이 취약점을 위험하게 만듭니다.
- ② pagename 정제 (노랑): 워드프레스의 정제 단계입니다. 리터럴 ../는 막지만 %2f, %2e는 통과시킵니다. 노란색은 "방어가 있는 것처럼 보이지만 불완전한 곳"이라는 뜻입니다.
- ③ get_page_template() (파랑): 3장에서 본 그 코드입니다. urldecode()가 진짜 ../를 되살리고, 그 값으로 만든 page-...php 후보가 validate_file() 없이 목록에 들어갑니다.
- ④ 템플릿 로더 → include (초록): 로더가 후보 경로를 열어 봅니다. 테마 밖의 읽을 수 있는 .php를 가리키고 있으면 그대로 include, 즉 실행됩니다.
하단의 "핵심 포인트" 박스는 이 흐름의 요약입니다. 검증이 다른 후보에는 있었지만 pagename 후보에만 없었다는 점, 정제 시점과 사용 시점이 달랐다는 점, page_id가 함께 와야 한다는 점입니다.
LFI에서 RCE로: 언제 코드 실행까지 가는가
파일 포함(LFI)은 언제나 성립하지만, 원하는 코드를 실행하는 RCE는 서버 환경이 맞을 때만 성립합니다. LFI는 "서버에 있는 PHP 파일을 실행하게 만드는 것"이지 "내가 쓴 코드를 실행하게 만드는 것"이 아니기 때문입니다. 실행되는 것은 어디까지나 그 파일이 원래 하는 일입니다.
그래서 공격자에게는 include 되기만 해도 쓸모 있게 동작하는 파일이 필요합니다. 알려진 후보가 PEAR의 pearcmd.php입니다. 이 파일은 명령행 인자로 받은 명령을 처리하는데, PHP에서 register_argc_argv 설정이 켜져 있으면 웹 요청의 쿼리스트링이 $argv로 노출되어 명령행 인자처럼 해석됩니다. 여기에 파일을 생성하는 기능이 있어서, 결과적으로 공격자가 지정한 내용의 PHP 파일이 서버 디스크에 기록될 수 있습니다.
register_argc_argv는 공식 PHP Docker 이미지와 PHP 8.5 미만의 cPanel 환경에서 기본으로 켜져 있어서 "특수한 설정"이라고 하기 어렵습니다.

그림 3은 RCE에 필요한 네 가지 전제 조건과 두 갈래 결과를 보여줍니다.
- 전제 ① (파랑): 활성 테마에 page-로 시작하는 최상위 디렉터리가 있어야 합니다.
- 전제 ② (노랑): page- 접두사와 .php 접미사 조건 아래에서 임의의 읽을 수 있는 PHP 파일 include가 성립합니다. 여기까지가 취약점 자체입니다.
- 전제 ③ (노랑): 서버에 pearcmd.php처럼 include만으로 동작하는 파일이 있어야 합니다.
- 전제 ④ (빨강): register_argc_argv가 켜져 있어야 쿼리스트링이 명령 인자가 됩니다. 빨간색은 이 조건이 충족되는 순간 위험도가 크게 올라간다는 뜻입니다.
아래쪽 두 카드가 결과입니다. 결과 A는 ①~②만 충족된 경우로, 코어 파일이 엉뚱한 URL에서 실행되어 "이 사이트가 취약한지 확인하는 정도"에 머뭅니다. 결과 B는 ①~④가 모두 충족된 경우로, 파일 쓰기를 통한 원격 코드 실행까지 이어집니다.
패치 분석: 7.1.2는 무엇을 바꿨나
WordPress는 두 가지를 바꿨습니다.
6-1. 수정 ①: 빠졌던 검증 추가

바뀐 부분은 조건문 끝의 && 0 === validate_file( $pagename_decoded ) 하나입니다. 디코딩된 값에 대해 validate_file()을 실행해서, ../가 복원된 값이면 후보 목록에 아예 넣지 않습니다. 앞서 3-3에서 본 문제의 핵심, 곧 디코딩 이후 값을 검증하지 않던 부분을 정확히 막은 것입니다.
6-2. 수정 ②: 모든 템플릿 경로에 적용되는 격리 검사

이 함수는 최종적으로 해석된 템플릿 경로가 허용된 위치에 있는지를 검사합니다. 순서대로 풀어 보겠습니다.
- wp_normalize_path( $path ): 경로 구분자를 /로 통일합니다.
- preg_match( '#(?:^|/)\.\.[. ]*(?:/|$)#', ... ): 경로 조각 중에 .. 세그먼트가 있는지 찾습니다. 정규식은 다음과 같이 읽습니다.
- (?:^|/): 문자열 시작이거나 / 바로 뒤에서
- \.\.: 점 두 개가 오고
- [. ]*: 그 뒤에 점이나 공백이 더 붙어도 (... 같은 변형까지 잡기 위함)
- (?:/|$): / 또는 문자열 끝에서 끝나는 경우
- ..이 없으면 바로 true를 반환해 허용합니다. 정상적인 경로는 여기서 통과합니다.
- ..이 있으면 realpath()로 실제 경로를 계산한 뒤, 그 경로가 허용된 테마 디렉터리 안에 있는지 확인합니다. Patchstack에 따르면 허용 범위는 스타일시트 디렉터리, 템플릿 디렉터리, theme-compat입니다. (위 코드에서 // ...로 생략된 부분입니다.)
수정 ①만으로도 이 CVE는 막을 수 있습니다. 그런데도 수정 ②를 넣었다는 것은, 개별 버그가 아니라 템플릿 경로 해석 전체를 위험 영역으로 보고 어느 경로로 만들어졌든 한 번 더 거른다는 뜻입니다. 같은 종류의 우회가 다른 곳에 더 있을 수 있다고 판단한 방어적 설계로 읽을 수 있습니다.

그림 2는 같은 요청이 어디서 막히는지를 좌우로 비교합니다.
- 왼쪽(패치 전): pagename 수신 → urldecode → 검증 → include 순서인데, 세 번째 카드의 빨간 띠가 보여주듯 이 후보에는 검증 자체가 없습니다. 그래서 네 번째 카드에서 테마 밖 .php가 실행됩니다.
- 오른쪽(패치 후): 세 번째 카드가 1차 방어입니다. 디코딩한 값에 validate_file()을 적용해 ../가 있으면 후보에서 제외합니다. 네 번째 카드가 2차 방어로, 최종 경로가 허용된 테마 디렉터리 안에 있는지 다시 확인합니다. 초록색은 방어가 작동하는 지점입니다.
탐지와 대응
가장 먼저: 업데이트
각 브랜치별 패치 버전은 7.1.2, 7.0.6, 6.9.9, 6.8.10이고, 오래된 사이트를 위해 4.7.37까지 백포트되었습니다. 메이저 업그레이드 없이 같은 브랜치의 패치 버전으로 올릴 수 있습니다.
내 서버의 노출 정도 점검
- 활성 테마에 page-로 시작하는 최상위 디렉터리가 있는가
- PHP의 register_argc_argv가 켜져 있는가 (꺼져 있으면 pearcmd 경로의 RCE는 성립하지 않고, 파일 포함 수준에 머뭅니다)
- 서버에 pearcmd.php 등 include 시 쓸모 있는 PHP 파일이 읽을 수 있는 상태로 존재하는가
이 점검은 RCE 노출 정도를 가늠하는 것이지, 코어 취약점 자체의 유무를 판단하는 것은 아닙니다.
즉시 업데이트가 어려울 때
- pagename 파라미터에 상위 경로 이동이 포함된 요청을 차단합니다. 정상적인 페이지 슬러그에는 이런 값이 들어가지 않으므로 정상 트래픽에 영향이 거의 없습니다.
- register_argc_argv를 끄면 취약점이 고쳐지는 것은 아니지만 pearcmd 체인이 끊겨서 정보 노출과 코드 실행의 차이를 만듭니다.
8-4. 로그 헌팅 지표
- pagename에 %2e%2e 또는 %252e%252e가 포함된 요청 (쿼리스트링과 POST 본문 모두)
- pagename이 templates%2f 등 page- 디렉터리명으로 시작하는 요청
- 사이트 루트 또는 /index.php에서 pagename과 page_id가 함께 오는 요청
- 요청에 pearcmd, +config-show, +config-create가 포함된 경우
- User-Agent cve-2026-87902-poc/1.0, nuclei-cve-2026-87902/1.0 (대부분의 트래픽은 브라우저로 위장하므로 User-Agent만으로 거르면 안 됩니다)
- 일반 페이지 URL의 응답에 OPML/RSS 출력이 섞여 있다면 정찰 요청이 성공한 것입니다. 패치 전에 노출된 시기가 있다면 과거 로그도 같은 기준으로 확인해야 합니다.
- 호스트의 /tmp, /var/tmp에 예상치 못한 .php 파일이 있다면 파일 쓰기 시도가 성공한 것이므로 해당 호스트는 침해된 것으로 간주해야 합니다.