본문 바로가기
CVE 취약점

CVE-2026-64638 (XSS2Shell) 분석: 워드프레스 로그인 화면 사전인증 XSS → RCE 체인 분석

by WhiteGuidance 2026. 9. 26.
반응형

개요

항목 내용
CVE ID CVE-2026-64638
별칭 XSS2Shell
취약점 유형 CWE-79 (반사형 XSS) → 권한 상승 체인
CVSS 3.1 7.5 (High) — AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H
영향 범위 워드프레스 4.7 이후 전 버전 (사실상 모든 유지보수 브랜치)
패치 버전 7.0.3 (2026-08-06), 6.9.6 / 6.8.7 등 각 브랜치 백포트

 

XSS2Shell이 흥미로운 이유는 취약점 자체의 기술적 난이도보다, 공격 지점이 로그인 페이지라는 데 있다. wp-login.php는 계정이 없어도 누구나 도달할 수 있고, 거의 모든 워드프레스 사이트가 이 페이지를 인터넷에 그대로 열어 둔다. 여기서 시작된 XSS 한 방이, 이미 core에 들어 있던 여러 기능을 갈아타면서 PHP 코드 실행까지 이어진다.

 

다만 이 취약점은 두 단계로 나눠서 봐야 한다. 1단계(반사형 XSS)는 인증도, 특별한 조건도 필요 없이 확정적으로 성립한다. 2단계(웹쉘까지의 에스컬레이션)는 로그인된 관리자가 악성 페이지를 열어야 한다는 조건이 붙는다. CVSS 점수의 AC:H(공격 복잡도 높음), UI:R(사용자 상호작용 필요)이 바로 이 조건부 특성을 반영한 것이다.


전체 공격 체인

 

7단계 중 앞의 4단계는 별도 조건 없이 누구나 재현할 수 있고, 뒤의 3단계는 관리자의 클릭 한 번을 전제로 한다. 지금부터 각 단계를 소스 코드 수준에서 뜯어본다.

 

 

 


1단계 · 파서 충돌 (Parser Confusion): 두 개의 살균기가 서로 다른 판단을 내린다

로그인에 실패했을 때, 워드프레스는 사용자가 입력한 아이디를 에러 메시지에 그대로 되돌려준다(예: "아이디 홍길동는 등록되어 있지 않습니다"). 이 값이 화면에 그려지기 전, 정확히 이런 경로를 거친다.

 

같은 문자열이 strip_tags()를 한 번, wp_kses_post()를 한 번, 총 두 번 살균을 거치는데, 문제는 이 둘이 "태그로 인정하는 조건"에 대해 서로 다른 기준을 갖고 있다는 점이다.

strip_tags()의 맹점: 꺾쇠와 태그 이름 사이의 공백

PHP의 strip_tags()는 < 바로 뒤에 공백 없이 태그 이름이 와야만 그것을 태그로 인식한다.

 

< area ...> 처럼 꺾쇠 바로 뒤에 공백 하나만 끼워 넣으면, strip_tags()는 이걸 "그냥 텍스트"라고 판단하고 손대지 않는다. 이 시점에서는 아직 위험하지 않다. 문자열이 아직 살아있다는 것 자체가 전부다.

wp_kses_post()의 맹점: 자체 토크나이저는 공백을 무시한다

문제는 다음 단계다. 워드프레스는 이 값을 화면에 출력하기 직전, 자신의 대표 HTML 살균 함수인 wp_kses_post()에 한 번 더 통과시킨다. 그런데 wp_kses_post()가 사용하는 자체 HTML 토크나이저는 strip_tags()와 달리 태그 이름 앞의 공백을 무시하고 파싱한다. 즉 < area ...>를 정상적인 <area> 태그로 인식해 버린다.

 

<area>는 wp_kses_post()의 허용 목록(allowlist)에 id, href, class, name 속성과 함께 등록되어 있는 "안전한" 태그다. 그래서 이 값은 이스케이프되지 않고 그대로 HTML로 렌더링된다.

2단계 · DOM 클로버링(DOM Clobbering): HTML만으로 자바스크립트 변수를 조작한다

로그인 페이지는 비밀번호 재설정 관련 기능을 위해 user-profile.js를 함께 로드한다. 이 스크립트는 페이지가 로드되면 비밀번호 자동 생성 버튼을 찾아 특정 동작을 수행하는데, 이 과정에서 워드프레스 전역 변수인 ajaxurl을 문자열처럼 참조한다.

 

DOM 클로버링은 HTML만으로 자바스크립트의 전역 변수를 오염시키는 잘 알려진 기법이다. 브라우저는 id나 name 속성이 붙은 엘리먼트를, 같은 이름의 전역 변수가 따로 선언되어 있지 않으면 자동으로 window 객체의 프로퍼티로 노출시킨다. 즉 공격자가 심은 <area id="ajaxurl" href="공격자URL"> 하나만으로, window.ajaxurl이 문자열 대신 이 DOM 엘리먼트 자체를 가리키게 만들 수 있다.

 

user-profile.js가 ajaxurl을 문자열처럼 사용하려는 순간(예: URL 뒤에 이어붙이는 문자열 연산), 자바스크립트 엔진은 자동으로 그 객체의 toString()을 호출한다. <area> 엘리먼트의 toString()은 정확히 그 엘리먼트의 href 속성값을 반환하도록 정의되어 있다. 결과적으로 스크립트가 요청을 보내려던 목적지가, 공격자가 href에 적어 넣은 임의의 URL로 바뀐다.

 

여기까지도 아직 "임의의 URL로 요청 한 번을 보낼 수 있다"는 수준일 뿐, 코드 실행은 아니다. 진짜 자바스크립트 실행은 다음 단계, REST API에서 이루어진다.

3단계 · REST API 반사 실행: _jsonp의 마침표 하나가 만드는 차이

DOM 클로버링으로 확보한 "임의 URL 요청" 능력을, 공격자는 워드프레스 REST API의 JSONP 지원 기능에 그대로 겨냥한다.

GET /?rest_route=/&_method=GET&_jsonp=<콜백이름>&_envelope=1

 

REST API는 _jsonp 파라미터 값을 정규식 ^[a-zA-Z0-9_.]+$ 로만 검증한다. 여기서 눈여겨봐야 할 문자가 바로 마침표(.)다. 알파벳과 숫자, 언더스코어만 허용하는 일반적인 콜백 이름 검증처럼 보이지만, 마침표까지 허용하면서 콜백이름에 window.opener.approve.click 같은 점으로 연결된 객체 경로를 그대로 써넣을 수 있게 된다.

서버는 이 요청에 대해 다음과 같은 응답을 Content-Type: application/javascript로 돌려준다.

/**/window.opener.approve.click({ ...REST API 응답 데이터... })

 

워드프레스 관리자 화면은 jQuery를 사용하는데, jQuery는 이런 응답을 받으면 내부적으로 globalEval()을 통해 그대로 실행해버린다. 결과적으로 공격자가 지정한 임의의 객체.메서드() 호출이, 사이트의 오리진(origin) 안에서 실제로 실행된다. 단순히 alert()를 띄우는 수준이 아니라, window.opener가 가리키는 창(즉 관리자가 원래 열어 두었던 관리자 화면)의 특정 버튼을 프로그램적으로 클릭시킬 수 있다는 뜻이다.

 

1~3단계까지가 인증 없이, 로그인 페이지 하나만으로 확정적으로 성립하는 반사형 XSS다. 여기까지는 공격 대상이 관리자든 일반 방문자든 상관없이 재현된다.

4단계 (조건부) · SOME(Same-Origin Method Execution)으로 애플리케이션 비밀번호 탈취

여기서부터는 이미 로그인되어 있는 관리자가 공격자가 만든 페이지를 열어야 한다는 조건이 붙는다.

 

워드프레스는 REST API 등에 프로그램적으로 접근할 수 있도록 "애플리케이션 비밀번호(Application Password)" 발급 기능을 제공한다. 이 발급 화면(authorize-application.php)은 사용자가 "승인" 버튼을 누르면 새 비밀번호를 생성하고, 미리 지정된 반환 URL로 사용자명과 비밀번호를 함께 전달하도록 설계되어 있다.

 

공격자는 관리자의 브라우저에서 이 승인 화면을 열게 만든 다음, 3단계에서 확보한 JSONP 실행 능력으로 "승인" 버튼의 클릭 이벤트를 프로그램적으로 발생시킨다. 관리자 본인은 아무것도 누르지 않았지만, 워드프레스 입장에서는 정상적인 클릭으로 판단해 애플리케이션 비밀번호를 생성하고, 그 값을 공격자가 지정해 둔 반환 URL로 그대로 넘겨준다.

 

이 기법을 SOME(Same-Origin Method Execution)이라 부른다. 별도의 취약점을 새로 만드는 게 아니라, 페이지에 이미 존재하는 정상 기능(버튼 클릭)을 공격자가 원하는 타이밍에 프로그램적으로 실행시키는 방식이다.

5단계 · 관리자 권한으로 REST API 접근

애플리케이션 비밀번호는 사용자의 실제 로그인 비밀번호나 세션 쿠키를 몰라도, HTTP Basic 인증 헤더에 사용자명:앱비밀번호를 실어 보내는 것만으로 REST API를 완전한 관리자 권한으로 호출할 수 있게 해준다. 공격자는 이제 로그인 세션이나 원래 비밀번호를 전혀 모른 채로, API를 통해 관리자가 할 수 있는 거의 모든 작업을 수행할 수 있는 위치에 선다.

6단계 · 플러그인 업로드를 통한 웹쉘 설치

마지막 단계는 워드프레스의 정상적인 플러그인 설치 기능을 악용하는 것이다.

  1. 플러그인 업로드 화면에서 CSRF 방지용 nonce 값을 REST API로 가져온다.
  2. PHP 웹쉘 코드가 담긴 ZIP 파일을 조작해 POST /wp-admin/update.php?action=upload-plugin 요청으로 전송한다.
  3. 워드프레스는 이 ZIP을 wp-content/plugins/ 아래에 그대로 압축 해제한다.

플러그인 디렉터리 안의 PHP 파일은 플러그인을 활성화하지 않아도 웹 서버가 직접 실행할 수 있는 경로에 위치한다. 즉 별도로 플러그인을 활성화하는 절차 없이도, 업로드된 PHP 파일에 직접 접근하는 것만으로 임의 코드 실행이 완성된다.


패치는 어떻게 이루어졌나

수정은 의외로 단순하다. 문제의 근원, 즉 strip_tags()와 wp_kses_post()가 서로 다른 판단을 내리는 지점 자체를 손보는 대신, 그 값이 화면에 출력되기 직전에 확실하게 이스케이프하는 방식을 택했다.

 

 

esc_html()을 거치면 <, > 같은 문자가 &lt;, &gt;로 인코딩되기 때문에, 그 뒤에 wp_kses_post()가 무엇을 허용 목록에 두고 있든 더 이상 실제 태그로 해석될 여지가 없다. strip_tags()와 wp_kses_post() 사이의 판단 차이 자체는 여전히 PHP/워드프레스 코드베이스 어딘가에 남아 있지만, 적어도 이 출력 지점에서는 더 이상 표면화되지 않는다.

 

패치는 7.0.3에서 이루어졌고, 4.7 브랜치까지 총 24개 유지보수 브랜치에 백포트되었다. 다만 백포트된 버전은 브랜치마다 다르므로(예: 6.9.x는 6.9.6, 6.8.x는 6.8.7), "7.0.3 이상"이 아니라면 자신이 속한 브랜치의 최신 유지보수 릴리스인지를 개별적으로 확인해야 한다. "7.0.3 미만이니 취약하다"는 단순 버전 비교는 구 브랜치에서 오탐을 만들 수 있다.

반응형