본문 바로가기
CVE 취약점

CVE-2026-93485 분석: WordPress 댓글 하나로 관리자 서버를 장악하는 Comment2XSS

by WhiteGuidance 2026. 10. 10.
반응형

개요

항목 내용
CVE ID CVE-2026-93485
별칭 Comment2XSS (최초 공개명: Comment2Shell)
영향 제품 WordPress Core
영향 버전 4.7 ~ 7.1 (각 브랜치 패치 버전 미만)
패치 버전 7.1.1 (각 브랜치별 백포트: 7.0.5, 6.9.8, 4.7.36 등)
CVSS 3.1 7.1 (High) — AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:L
취약점 유형 CWE-79 (저장형 XSS) → 관리자 세션 탈취 시 RCE로 확장 가능
인증 요구 여부 불필요 (익명 사용자가 댓글만 남기면 됨)

 

워드프레스는 댓글이 저장될 때 KSES로 한 번 필터링하고, 화면에 출력할 때 comment_text 필터 체인으로 다시 한 번 가공합니다. 이 두 단계는 각각 보면 문제가 없어 보이지만, 저장 시점의 필터링 결과를 출력 시점의 필터들이 서로 다르게 해석하면서 그 틈에 공격 코드가 숨어듭니다.

 

핵심은 <blockquote>의 cite 속성에 들어간 개행(\n) 하나입니다. 이 개행이 wpautop() 함수의 정규식 버그를 건드리면서, 원래는 <code> 태그 안의 평범한 텍스트였던 문자열이 <blockquote>의 진짜 HTML 속성으로 둔갑합니다. 그 결과 onfocus 같은 이벤트 핸들러가 살아나고, 페이지가 로드되는 순간 클릭 한 번 없이 스크립트가 실행됩니다.

 

더 심각한 부분은 그 다음입니다. 이 스크립트를 관리자가 로그인한 상태로 열람하면, 스크립트는 관리자의 플러그인 설치 권한을 그대로 빌려 쓸 수 있습니다. 즉 익명 방문자의 댓글 한 줄이 서버의 임의 코드 실행(RCE)으로까지 이어질 수 있는 체인입니다.


취약점이 숨어있던 자리 — 댓글 렌더링 파이프라인

워드프레스가 댓글을 다루는 흐름부터 짚고 가겠습니다.

  1. 사용자가 댓글을 작성하면 KSES(wp-includes/kses.php)가 허용되지 않은 태그/속성을 제거합니다.
  2. 저장된 댓글이 화면에 출력될 때, comment_text 필터에 등록된 함수들이 순서대로 HTML을 다시 가공합니다.

 

이 중 wpautop()과 wptexturize() 두 함수가 이번 취약점의 주범입니다. 둘 다 "HTML을 다시 쓰는" 함수라는 공통점이 있고, 이 재작성 과정에서 생긴 틈을 공격자가 파고듭니다.


단계별 소스코드 분석

1단계 — KSES는 cite 속성 안의 개행을 막지 않는다.

공격자는 아래와 같은 댓글을 익명으로 남깁니다.

<blockquote cite="a b"><code>x" onfocus=alert(document.domain) autofocus tabindex=0</code></blockquote>

 

KSES의 허용 목록에는 blockquote[cite]가 포함되어 있어 이 태그 자체는 통과합니다. 문제는 속성 값을 다시 인코딩하는 과정입니다.

 

wp_kses_hair()는 get_attribute()로 속성 값을 읽어 HTML 참조를 디코딩한 뒤, 위 표에 있는 문자만 다시 이스케이프합니다. 개행 문자(\n)는 이 표에 없습니다. 그래서 cite="a\nb"처럼 속성 값 안에 실제 줄바꿈이 들어있어도 그대로 저장됩니다. (cite="a&#10;b"처럼 인코딩된 형태로 보내도 워드프레스가 실제 개행으로 바꿔 저장하므로 결과는 같습니다.)

이 시점까지는 그냥 "속성 값에 줄바꿈이 있는 평범한 blockquote"일 뿐, 아직 위험하지 않습니다.

 

2단계 — wpautop()이 개행을 HTML 주석으로 바꿔치기한다

wpautop()은 문단을 자동으로 <p>로 감싸주는 함수인데, 그러려면 먼저 "태그 안에 있는 개행"과 "텍스트 사이의 개행"을 구분해야 합니다. 이를 위해 태그 내부의 개행을 임시로 치환합니다.

cite 속성 안의 \n은 이제 <!-- wpnl -->라는 HTML 주석으로 바뀝니다. 여기서 중요한 사실 하나: 이 주석 문자열 자체가 > 문자를 포함하고 있다는 점입니다. 이 >가 바로 다음 단계에서 정규식을 속이는 미끼가 됩니다.

 

3단계 — [^>]* 정규식이 엉뚱한 >에서 멈춘다 (핵심 결함)

wpautop()은 블록 레벨 태그 앞에 빈 줄을 추가해 문단을 분리하고, 그 내용을 <p>로 감쌉니다. blockquote도 이 블록 태그 목록에 포함되어 있습니다.

<blockquote cite="a <p> b"><code>x" onfocus=alert(document.domain) autofocus tabindex=0</code></p></blockquote>

 

이후 빈 줄 기준으로 문단을 나누고 각 문단을 <p>...</p>로 감싸는데, 이 과정에서 <p><blockquote ...>처럼 둘이 겹치는 경우를 다시 한 줄로 정리하는 정규식이 실행됩니다. 바로 이 줄이 문제입니다.

 

얼핏 보면 여전히 cite라는 "속성 값 문자열" 안에 <p>라는 "글자"가 들어가 있을 뿐이라 안전해 보입니다. 다음 단계가 이걸 진짜 태그 경계로 바꿔버립니다.

 

4단계 — wptexturize()가 <p>를 진짜 태그 경계로 오인한다

wptexturize()는 "straight quotes"를 “curly quotes”처럼 타이포그래피용 기호로 바꿔주는 함수입니다. 이 함수는 블록 테마(워드프레스 6.0 이후 기본값)가 템플릿을 렌더링할 때 자동으로 호출됩니다.

 

(일부 클래식 테마에서도 the_content()를 통해 호출됩니다. Twenty Twenty-One이 그 예입니다.)

 

wptexturize()는 HTML을 텍스트 노드 단위로 쪼개 처리하는데, 3단계에서 억지로 삽입된 <p>를 "진짜 태그의 끝"으로 인식해버립니다. 그러면 그 뒤에 오는 큰따옴표(")는 더 이상 속성 값 내부의 글자가 아니라 "문단 안의 텍스트"로 취급되어, 전각 기호 &#8221;로 치환됩니다.

 

단, wptexturize()는 특정 태그 내부는 건드리지 않습니다.

 

공격 댓글에서 onfocus=... 페이로드는 <code> 태그 안에 들어 있었습니다. KSES가 애초에 code 태그를 허용 목록에 포함시켜 두었기 때문에, <code> 안의 큰따옴표는 이 "변환 제외" 규칙 덕분에 원본 그대로 살아남습니다. <blockquote>를 닫는 가짜 "는 전각 기호로 바뀌어 무력화되는 반면, <code> 안의 진짜 "는 멀쩡히 남아 속성 구분자 역할을 하게 되는 것입니다.

 

5단계 — 최종 렌더링: 이벤트 핸들러가 살아난다

네 단계를 모두 거친 뒤 브라우저가 실제로 파싱하는 HTML은 다음과 같습니다.

<blockquote cite="a <p> b&#8221;> onfocus=alert(document.domain) autofocus tabindex=0>

 

브라우저 입장에서는 cite 값이 끝나는 지점(<code> 안에 있던 진짜 ")부터 onfocus, autofocus, tabindex가 <blockquote>의 정식 속성으로 읽힙니다. autofocus가 붙어 있으므로 페이지가 로드되는 즉시 해당 요소에 포커스가 가고, onfocus 핸들러가 클릭이나 다른 사용자 조작 없이 바로 실행됩니다. 이른바 제로클릭(Zero-Click) 실행입니다.


댓글 승인을 거치지 않고도 노출되는 경로

워드프레스는 기본적으로 "첫 댓글은 승인 대기"라는 설정(comment_previously_approved 기본값 1)을 갖고 있어, 얼핏 보면 공격자가 댓글을 남겨도 관리자가 승인하기 전까지는 노출되지 않을 것 같습니다. 하지만 연구자는 승인 없이도 페이로드가 바로 노출되는 경로 세 가지를 제시했습니다.

  • 설치 기본 댓글 작성자 재사용: 워드프레스를 처음 설치하면 A WordPress Commenter라는 이름/이메일로 된 승인 댓글이 하나 기본으로 들어 있습니다. check_comment()가 작성자 이름+이메일 조합만 비교하므로, 같은 이름/이메일로 댓글을 작성하면 즉시 승인됩니다.
  • 승인 요구 설정 자체가 꺼져 있는 경우: 관리자 설정에서 "댓글 작성자가 이전에 승인된 댓글이 있어야 함" 옵션이 꺼져 있으면, 새 신원으로 작성한 댓글도 바로 승인 상태로 저장됩니다.
  • 대기 중인 댓글도 특정 조건에서 노출: 미승인 댓글이라도 comment_author_<해시> 쿠키를 가진 사람(해당 사이트에 댓글을 남긴 적 있는 누구나)에게는 ?unapproved=<id>&moderation-hash=<hash> 링크를 통해 그대로 렌더링됩니다.

즉, "댓글 승인제를 켜두었으니 안전하다"는 전제가 깨지는 지점입니다.


XSS에서 RCE로 — 관리자 세션 탈취 이후

스크립트는 그것을 로드한 사람의 브라우저 컨텍스트에서 실행됩니다. 일반 방문자가 열람하면 그 사람의 권한만 쓸 수 있지만, 로그인된 관리자가 해당 게시물을 열람하면 이야기가 달라집니다.

 

플러그인 업로드 엔드포인트는 upload_plugins 권한을 확인하는데, map_meta_cap()을 통해 이는 관리자가 가진 install_plugins 역량으로 매핑됩니다. 공격 스크립트는 이 권한을 그대로 빌려 다음을 수행합니다.

 

업로드된 플러그인은 활성화하지 않아도 파일이 이미 디스크에 존재하고 직접 접근이 가능하므로, 다음과 같이 즉시 명령을 실행할 수 있습니다.

curl "https://example.com/wp-content/plugins/x/x.php?c=id"

 

c 파라미터로 전달된 값은 웹 서버 사용자 권한으로 그대로 실행됩니다. 댓글 한 줄에서 시작해 서버 셸 획득까지 이어지는 전체 체인이 완성되는 지점입니다.

 


패치 — 정규식에 "따옴표 인식" 추가

워드프레스 7.1.1은 문제가 된 정규식 한 줄을 교체하는 것으로 이 취약점을 수정했습니다.

 

기존 [^>]*는 ">가 아닌 문자"만 허용했지만, 새 패턴은 다음 세 가지 중 하나를 반복 허용하도록 바뀌었습니다.

  • [^>"\'] — 따옴표도 >도 아닌 일반 문자
  • "[^"]*" — 큰따옴표로 감싸인 속성 값 전체 (내부의 >를 무시)
  • '[^\']*' — 작은따옴표로 감싸인 속성 값 전체 (내부의 >를 무시)

즉 속성 값이 따옴표로 감싸여 있으면 그 안의 >는 더 이상 태그의 끝으로 오인되지 않습니다. 패치 적용 후 같은 입력을 넣으면 캡처 범위가 cite="a <!-- wpnl --> b" 전체로 올바르게 확장되고, <p>는 cite 속성이 완전히 끝난 뒤, 즉 진짜 > 바로 다음에 삽입됩니다. wpautop() 안에서 태그 속성 값 내부에 무언가를 끼워 넣는 곳은 이 한 줄뿐이었기 때문에, 수정도 이 한 줄로 충분했습니다.


대응 방안

  • WordPress 7.1 사용 중이면 7.1.1로, 그 외 구버전 브랜치는 각 브랜치의 패치 버전(7.0.5 / 6.9.8 / … / 4.7.36 등)으로 즉시 업데이트합니다.
  • 즉시 업데이트가 어려운 경우, 게시물별 댓글을 닫거나 사이트 전체에서 댓글 기능을 비활성화해 공격 경로 자체를 차단할 수 있습니다.
  • 웹 방화벽(WAF)이나 보안 플러그인으로 cite 속성에 개행이 포함된 요청을 차단하는 것도 임시 완화책이 될 수 있습니다.
  • 업데이트는 신규 공격을 막을 뿐, 이미 발생한 변조(알 수 없는 플러그인·파일 등)는 되돌리지 않으므로, 노출 가능성이 있었던 사이트는 설치된 플러그인과 파일 목록을 별도로 점검해야 합니다.
반응형