
CVE-2026-64638에 대한 개념 증명(PoC) 익스플로잇: WordPress 로그인에서의 반사형 XSS를 DOM clobbering과 연계하여 관리자 계정 탈취 및 원격 코드 실행을 달성합니다.
소프트웨어: WordPress Core ≤ 7.0.2 (7.0.3 이전의 모든 버전)
CVSS: 8.9 (높음)
CWE: CWE-79 — 웹 페이지 생성 중 입력의 부적절한 중화
인증 필요: 없음 (Pre-Auth)
사용자 상호작용: 활성 (관리자가 링크 1회 클릭 필요)
영향: XSS → 계정 탈취 → 원격 코드 실행
WordPress는 전 세계 웹사이트의 40% 이상을 차지하는 세계에서 가장 인기 있는 콘텐츠 관리 시스템입니다. 모든 WordPress 사이트에는 /wp-login.php에 로그인 페이지가 있으며, 이는 인증 없이 누구나 접근할 수 있는 공개 엔드포인트입니다.
사용자가 잘못된 사용자 이름을 입력하면 WordPress는 사용자가 방금 입력한 사용자 이름이 포함된 오류 메시지를 표시합니다: “사용자 이름 X은(는) 이 사이트에 등록되어 있지 않습니다.” 문제는 사용자 이름 값이 어떤 이스케이프 함수도 거치지 않고 HTML 응답에 직접 배치된다는 점입니다. 공격자는 실제 사용자 이름 대신 HTML/JavaScript를 입력하기만 하면 코드가 브라우저에서 실행됩니다.
이것은 반사형 XSS(reflected XSS) 결함입니다 — 페이로드가 요청에 포함되어 서버가 HTML에서 동일하게 반사합니다. 위험한 이유는 이 결함이 로그인 페이지에 있기 때문입니다. 로그인 페이지는 관리자가 자주 접근하는 곳이며, 여기서 관리자 세션 쿠키를 탈취할 수 있습니다.
연구팀은 또한 이 XSS가 WordPress의 emoji-loader의 DOM 클로버링 취약점과 연결될 수 있어 외부 서버에서 JavaScript를 로드할 수 있음을 발견했습니다. 이를 통해 공격자는 새 관리자 계정 생성 → 웹셸이 포함된 플러그인 설치 → 서버에서 PHP 코드 실행까지 진행할 수 있습니다. 이 공격 체인을 XSS2Shell이라고 합니다.
| 속성 | 값 |
|---|---|
| CVE ID | CVE-2026-64638 |
| CVSS 점수 | 8.9 (높음) |
| 소프트웨어 | WordPress Core ≤ 7.0.2 |
| 인증 | 필요 없음 (Pre-Auth) |
| 사용자 상호작용 | 클릭 1회 필요 (관리자가 링크 클릭) |
| 공격 복잡성 | 높음 |
| 패치됨 | WordPress 7.0.3 (2026-06-08) |
| 제보자 | pwn.ai 팀 (HackerOne 경유) |
| HackerOne 보고서 | #3877102 |
WordPress는 모든 페이지(로그인 페이지 포함)에서 emoji-loader.js 파일을 통해 이모지 지원을 로드합니다. 이 스크립트는 id="wp-emoji-settings" 요소에서 구성을 읽습니다:
// Before patch (vulnerable)
const settings = JSON.parse(
document.getElementById('wp-emoji-settings').textContent
);
document.getElementById()는 DOM에서 일치하는 id를 가진 첫 번째 요소를 반환합니다. 공격자가 원래 스크립트 태그 앞에 <div id="wp-emoji-settings">를 주입하면 getElementById는 실제 구성 대신 공격자의 콘텐츠를 읽습니다. 이 기법을 DOM 클로버링이라고 합니다 — HTML 요소를 주입하여 JavaScript 동작을 덮어쓰는 것입니다.
이모지 구성에는 JavaScript 파일(concatemoji)을 로드하기 위한 URL이 포함되어 있습니다. 공격자가 이 URL을 제어하면 → 외부 서버에서 JS 파일을 로드하여 → 브라우저 컨텍스트에서 임의 코드를 실행할 수 있습니다.
관리자 컨텍스트에서 JavaScript 실행이 달성되면 공격자는 전체 WordPress 관리자 권한을 갖게 됩니다:
/wp-admin/user-new.php 호출/wp-admin/plugin-install.php를 통해 플러그인 업로드위 3가지 방법 중 하나라도 서버에서 PHP 코드를 실행할 수 있게 합니다 — 즉 RCE입니다.
wordpress-develop의 수정 커밋 0d6d42e에서 wp-includes/user.php 파일의 사용자 이름/이메일이 오류 메시지에 직접 배치되는 3개 위치를 확인했습니다:
# View diff between vulnerable and patched versions
git diff 7.0.2..7.0.3 -- src/wp-includes/user.php
위치 1 — 189행 (사용자 이름이 존재하지 않음):
수정 전:
// BEFORE (vulnerable):
sprintf(
__( 'The username <strong>%s</strong> is not registered...' ),
$username // ← no escaping
)

수정 후:
// fixed:
sprintf(
__( 'The username <strong>%s</strong> is not registered...' ),
esc_html( $username ) // ← escaped
)
위치 2 — 216행 (잘못된 비밀번호):
수정 전:

// BEFORE:
'<strong>' . $username . '</strong>' // ← no escaping
수정 후:
// AFTER:
'<strong>' . esc_html( $username ) . '</strong>'
위치 3 — 299행 (이메일의 잘못된 비밀번호):
수정 전:

// BEFORE:
'<strong>' . $email . '</strong>' // ← no escaping
수정 후:
// AFTER:
'<strong>' . esc_html( $email ) . '</strong>'
POST 요청에서 오류 메시지까지의 데이터 흐름:
$_POST['log'] ← user input from login form
↓
wp_signon() [user.php:51]
$credentials['user_login'] = wp_unslash($_POST['log']) ← only removes backslashes
↓
wp_authenticate($username, $password) [pluggable.php:689]
$username = sanitize_user($username) ← strips HTML tags, but has a bypass
↓
wp_authenticate_username_password() [user.php:153]
get_user_by('login', $username) ← user not found
↓
sprintf('The username <strong>%s</strong>...', $username) ← XSS!
↓
WP_Error → login_header() → wp_admin_notice()
wp_kses_post(...) ← filters HTML but allows <div>, <a>, through
↓
HTML response → browser render → JavaScript execute
WordPress에는 사용자 이름이 HTML에 도달하기 전에 2개의 필터링 계층이 있습니다:
계층 1: sanitize_user() — strip_tags()를 호출하여 HTML 태그를 제거합니다. 그러나 PHP의 strip_tags()에는 알려진 한계가 있습니다: 비표준 태그 형식으로 필터를 우회할 수 있습니다.
계층 2: wp_kses_post() — <div>, <a>, `` 등 특정 속성을 가진 안전한 HTML 하위 집합을 통과시킵니다 (단, onerror, onload와 같은 이벤트 핸들러는 제거). 핵심: wp_kses_post는 DOM 클로버링에 정확히 필요한 요소인 <div id="wp-emoji-settings">를 허용합니다.
pwn.ai 팀은 두 계층을 모두 우회하여 실용적인 페이로드를 주입할 방법을 찾았습니다. 구체적인 기술적 세부 사항은 공개되지 않았습니다.
사용자 이름이 이스케이프 없이 HTML로 직접 들어가는지 시각적으로 확인하기 위해 Xdebug + VS Code를 사용하여 실행 체인의 주요 지점에 중단점을 설정했습니다.
1단계 — 로그인 폼에 XSS 페이로드 입력:
http://localhost:8282/wp-login.php에 접속하여 사용자 이름으로 ``를 입력한 후 Log In을 클릭합니다. 알림 팝업이 나타나면 — XSS가 동작하는 것입니다.


2단계 — user.php:184에 중단점 설정 — XSS가 발생하는 위치:
wp_authenticate_username_password() 함수 내부의 return new WP_Error(...)에 중단점을 설정합니다. 디버거가 멈추면 다음을 관찰합니다:
$username = "" — 그대로인 HTML 페이로드, 이스케이프되지 않음$_POST 패널: log = "" — 페이로드가 폼 입력에서 비롯되었음을 확인wp_authenticate_username_password() → WP_Hook->apply_filters → apply_filters → wp_authenticate → wp_signon → {main} wp-login.php
$username 값은 $_POST['log'] → wp_unslash() → (sanitize_user 우회) → 186-189행의 오류 메시지로 sprintf()되는 경로를 거치며, 그 사이에 esc_html()이 없습니다. 실습 환경에서는 pwn.ai가 발견한 우회를 시뮬레이션하기 위해 sanitize_user()를 주석 처리했습니다.
3단계 — functions.php:9200에 중단점 설정 — 최종 출력:
echo wp_get_admin_notice( $message, $args )에 중단점을 설정합니다 — 이는 HTML이 브라우저로 출력되기 직전의 마지막 줄입니다:
$message = "<p><strong>Error:</strong> The username <strong></strong> is not registered...</p>" — 페이로드가 HTML 오류 메시지 안에 온전히 존재wp_kses_post()로 감싸져 있지 않으며(우회를 시뮬레이션하기 위해 패치됨), 페이로드가 브라우저로 직접 전달됩니다
원본 WordPress에서 이 줄은 echo wp_kses_post( wp_get_admin_notice(...) )입니다 — wp_kses_post()는 onerror 속성을 제거하지만, <div>는 허용 목록에 있으므로 <div id="wp-emoji-settings">는 통과시킵니다. 이것이 정확히 DOM 클로버링 공격의 벡터입니다.
커밋 a12c8f5는 DOM 클로버링을 차단하도록 emoji-loader.js를 수정합니다:
// BEFORE (vulnerable): accepts any element with a matching id
const settings = JSON.parse(
document.getElementById('wp-emoji-settings').textContent
);
// AFTER (fixed): only accepts <script> element
const selector = 'script#wp-emoji-settings';
const script = document.querySelector(selector);
if (!(script instanceof HTMLScriptElement)) {
throw new Error(`Element missing:${selector}`);
}
const settings = JSON.parse(script.text);
수정은 3가지를 변경합니다: