HTML 출력만 소독했더니, Markdown 출구로 위험 태그가 빠져나갔다
HTML 출력만 소독했더니, Markdown 출구로 위험 태그가 빠져나갔다
CVE · 웹·서버 · XSS(크로스사이트 스크립팅) · CVE-2026-8445 [표준]
소독 라이브러리의 안전 보증은 대개 한 출구(HTML 렌더링)를 기준으로 세워집니다. 문제는 같은 라이브러리가 종종 다른 출구도 가지고 있다는 점입니다. justhtml의 이번 사례에서, to_html()을 통과할 때는 안전했던 신뢰 불가 입력이 **Markdown 출력 경로(to_markdown())**로 나갈 때는 raw HTML 그대로 방출될 수 있었습니다. 엔티티로 디코딩된 텍스트(예: <script>)나 RCDATA로 파싱되는 요소(title·textarea·noscript·plaintext)의 내용이 그대로 새어 나가면서, 소독을 통과한 줄 알았던 콘텐츠가 결국 실행 가능한 마크업으로 되살아나는 소독 우회가 성립합니다.
개요
justhtml은 신뢰할 수 없는 HTML을 받아 위험한 요소를 제거하는 Python 소독 라이브러리입니다(CVE.org: vendor=EmilStenstrom, product=justhtml, pkg:pypi/justhtml — 확정). 이번 취약점(CVE-2026-8445)의 핵심은 소독 라이브러리의 여러 출력 경로 사이의 불일치입니다. 벤더 GHSA-3rcm-vjrc-p45j에 따르면, to_html()은 안전하지만 to_markdown()이 취약합니다: 텍스트 노드를 이스케이프할 때 일부 Markdown 메타문자는 이스케이프하면서도 <·> 같은 HTML에서 의미를 갖는 문자는 그대로 남깁니다(“text nodes are escaped for a small set of Markdown metacharacters, but HTML-significant characters such as < and > are preserved”). 그 대상에는 엔티티 디코딩을 거친 텍스트(예: <script>가 <script>로 풀리는 경우)와 RCDATA로 파싱되는 요소의 텍스트가 포함됩니다. 소독 결과를 신뢰해 Markdown 출구로 페이지를 구성하는 애플리케이션이라면, 이 경로 차이로 인해 공격자가 심은 스크립트가 피해자 브라우저에서 실행되는 XSS로 이어질 수 있습니다.
- 취약 라이브러리: justhtml 1.11.0 이하(<1.12.0), 패치 버전 1.12.0 — CVE.org 확정. 후속 재패치 신호 없음(단일 수정 버전).
- 취약점 유형: 소독 우회 → 크로스사이트 스크립팅 — CWE-79 (CVE.org·양쪽 GHSA 모두 확정).
- 핵심 메커니즘:
to_html()에서는 안전하나to_markdown()출력 경로에서 엔티티 디코딩된 텍스트·RCDATA 요소(title·textarea·noscript·plaintext) 내용이 raw HTML로 방출 (벤더 GHSA 확정). - CVSS: v4.0 9.3(CRITICAL) / v3.1 9.8(CRITICAL), 벡터
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/...— CVE.org 확정. 단, 저장소 자체 GHSA는 “High”로 자체 채점해 CVE 공식값(Critical)과 발행처 간 불일치가 있습니다 — 두 값 모두 병기합니다.
한 줄 요약: 한쪽 출구(HTML)만 지키는 소독은, 다른 출구(Markdown)가 열려 있으면 뚫립니다.
기술 배경 — 소독 안전성은 “어느 출구로 나가느냐”에 달려 있다
HTML 소독기의 일은 두 단계로 나뉩니다. 먼저 입력을 파싱해 트리로 만들고, 그다음 그 트리를 **다시 문자열로 직렬화(serialize)**합니다. 안전성 보증은 이 둘이 짝을 이룰 때만 성립합니다 — 파싱 단계가 위험 요소를 걸러내도, 직렬화 단계가 그 판정을 그대로 이어받아야 합니다.
여기서 두 가지 개념이 이번 사례의 축입니다.
- RCDATA / RAWTEXT 요소: HTML 파서는 모든 태그를 똑같이 읽지 않습니다.
<title>·<textarea>는 RCDATA,<style>·<script>·<xmp>등은 RAWTEXT로 취급되어, 그 안의 내용은 일반 마크업이 아니라 원시 텍스트로 파싱됩니다. 즉<title>안의<b>는 태그가 아니라 글자입니다. 소독기 내부에서 이 텍스트는 “그냥 텍스트”로 안전하게 분류되기 쉽습니다. - 엔티티 디코딩(entity decoding):
<script>같은 실체 참조는 소독기 내부에서 종종<script>로 디코딩됩니다. 트리 내부 표현에서는 이게 텍스트 노드라 안전하지만, 이 텍스트를 인코딩 없이 raw로 다시 뱉으면 브라우저는 그것을 태그로 읽습니다.
to_html() 출력 경로는 이 텍스트들을 다시 내보낼 때 문맥에 맞게 이스케이프해 안전을 지킵니다(추정). 반면 Markdown 출력 경로는 같은 텍스트를 다른 규칙으로 처리하며, 이 경로에서 이스케이프가 빠지면 트리 안에서 안전했던 텍스트가 출력 문자열에서는 raw HTML로 되살아납니다.
CVE 분석 요약 — 어느 출구가 새는가
공개 excerpt는 문제를 출력 경로의 불일치로 명확히 지목합니다. to_html() 기준으로 안전한 입력이 Markdown 출력으로 변환될 때 raw HTML로 방출된다는 것입니다. 대상은 두 갈래로 요약됩니다(excerpt 기준):
- 엔티티 디코딩된 텍스트 — 예: 참조가 풀려
<script>가 된 텍스트. - RCDATA로 파싱된 요소의 텍스트 —
title·textarea·noscript·plaintext요소 안의 내용. (script·style은 이미 필터되는 대상이라 —html_passthrough=True를 켜지 않는 한 — 이 유출 벡터에 포함되지 않습니다. 벤더 GHSA가 명시적으로 확인.)
우리가 코드를 직접 대조하기 전까지, 근본원인은 다음으로 추정합니다(사실이 아니라 우리 분석입니다):
- 직렬화 단계의 이스케이프 누락(추정): Markdown 렌더러가 텍스트 노드를 출력할 때, HTML 렌더러가 적용하는 문맥별 이스케이프를 동일하게 적용하지 않아, 위험 문자(
<,>등)가 그대로 통과함. 소독의 안전 보증이to_html()한 출구에만 걸려 있고 Markdown 출구는 같은 보증을 물려받지 못한 셈입니다. - 텍스트 노드의 “안전” 가정 재사용(추정): RCDATA/RAWTEXT나 엔티티 디코딩을 거쳐 “안전한 텍스트”로 분류된 노드를, Markdown 경로가 재이스케이프 없이 원문 그대로 삽입.
아래는 “같은 트리라도 어느 출구로 나가느냐에 따라 안전성이 달라진다”는 개념을 보여주는 무해한 예시입니다. 실제 우회 문자열이 아니며, 실행되지 않도록 무력화했습니다.
# 개념용 단순화, 실제 페이로드/원본 코드 아님 (무해화)
소독기 내부 트리: [텍스트 노드] "…위험해 보이는 글자열…" ← 트리에선 '텍스트'라 안전
├─ to_html() 출구 → 위험 문자를 이스케이프해서 출력 → 안전 (브라우저: 글자로 읽음)
└─ Markdown 출구 → 같은 텍스트를 raw로 출력 → 우회 (브라우저: 마크업으로 읽음)
└── 두 출구의 처리 규칙이 '어긋나는' 지점이 곧 우회 지점
즉 원인은 “필터가 무엇을 걸렀나”보다 **“소독 후의 모든 출력 경로가 같은 안전 규칙을 지키는가”**에 가깝습니다. 한 라이브러리가 여러 직렬화 출구를 제공할 때, 각 출구는 저마다 다시 검증돼야 합니다.
공격 시나리오 · 영향도 + 대응방안
공격 시나리오 (단계 압축, 완성형 페이로드 없음):
- 공격자가 소독을 거치는 입력란(댓글·프로필·문서 본문 등)에, 엔티티 인코딩이나 RCDATA/RAWTEXT 요소를 이용한 변형 마크업을 넣습니다.
- 애플리케이션이 justhtml로 입력을 소독하고, 그 결과를 Markdown 형태로 저장·렌더링합니다(“HTML 기준으로 안전하니 괜찮다”는 가정).
- Markdown 출력이 다시 HTML로 렌더링될 때, 소독을 통과해 raw로 남은 능동 콘텐츠가 피해자 브라우저에서 실행됩니다.
영향도: 저장형·반사형 XSS로 이어질 경우 세션 탈취·계정 조작·피해자 권한 내 임의 동작·피싱 발판 등 XSS의 일반적 영향이 성립할 수 있습니다. 실제 노출은 애플리케이션이 to_markdown() 출력 경로를 실제로 사용하는지에 달려 있습니다 — 벤더 GHSA가 확정하는 바로는 to_html()만 쓰는 배포는 이 취약점의 영향을 받지 않습니다.
대응방안:
- 근본 — 패치 버전으로 업데이트. justhtml 1.12.0 이상으로 올립니다(CVE.org 확정 패치 버전, 후속 재패치 신호 없음).
- 차선(즉시 업데이트 불가 시):
- Markdown 출력 경로에 대한 신뢰를 재검토합니다.
to_html()을 통과했다는 사실이 Markdown 출력의 안전을 보증하지 않는다고 가정하고, Markdown 결과를 다시 렌더링하기 전 출력 지점에서 문맥에 맞는 인코딩을 병행합니다. - 소독 단독 방어에 의존하지 않습니다. 콘텐츠 보안 정책(CSP, Content-Security-Policy)으로 인라인 스크립트 실행을 제한해, 우회가 실제 실행으로 번지는 걸 막습니다.
- 사용자 HTML을 신뢰 도메인과 분리해 렌더링(격리된 iframe·별도 오리진)하는 방안을 검토합니다.
- Markdown 출력 경로에 대한 신뢰를 재검토합니다.
- 탐지 관점: 소독→Markdown 변환을 거친 뒤에도 이벤트 핸들러·
javascript:스킴 같은 능동 콘텐츠 흔적이 최종 출력에 남는지 표본 검사·회귀 테스트로 상시 점검합니다. 특히 엔티티 인코딩·title/textarea/noscript/plaintext요소를 포함한 입력을 회귀 코퍼스에 넣어 두면 이 계열의 우회를 조기에 잡을 수 있습니다.
결론
소독기는 방패지만, 방패에 문이 둘 있으면 둘 다 잠가야 합니다. justhtml의 이번 사례는 HTML 문은 잠겼는데 Markdown 문이 열려 있던 자리였습니다.
이 부류의 버그가 반복되는 이유는 분명합니다. 안전 보증이 하나의 출력 경로에만 걸려 있고, 같은 데이터가 나가는 다른 경로가 그 보증을 물려받지 못하기 때문입니다. 이는 우리가 다른 글에서 다룬 파서 불일치(parser differential) — 소독기와 브라우저가 같은 바이트를 다르게 읽는 문제 — 와 뿌리가 같습니다. 다만 여기서는 “파싱하는 두 주체”가 아니라 “직렬화하는 두 출구”가 어긋난 형태입니다. 그래서 우리는 소독을 볼 때 “무엇을 걸렀나”만이 아니라 “소독 결과가 나가는 출구가 몇 개이고, 전부 같은 규칙을 지키는가”를 함께 봅니다 — 이 한 틈은 패치로 닫히겠지만, 이 사고방식은 여러 출력 포맷을 가진 다음 라이브러리에서 또 필요합니다. 그래서 도구가 아니라 방법을 남깁니다.
(이 CVE의 심층 분석판은 아직 없습니다. 소스 대조·패치 diff까지 파는 심층판으로 승급하면 여기에 링크됩니다.)
References / 참고자료
- CVE.org(MITRE) — CVE-2026-8445 공식 레코드. 라이브러리 정체(Python/PyPI)·취약/패치 버전(<1.12.0 / 1.12.0)·CVSS(v4.0 9.3, v3.1 9.8)·CWE-79 근거.
- GHSA-3rcm-vjrc-p45j — justhtml 저장소 원 보안 권고.
to_html()은 안전,to_markdown()이 취약하다는 핵심 메커니즘, RCDATA 유출 요소(title/textarea/noscript/plaintext, script·style은 이미 필터됨) 확증 근거. 저장소 자체 severity는 “High”로 채점(CVE 공식값 Critical과 불일치, 본문에 병기). - GHSA-wf6m-786j-gxrr — GitHub Advisory DB 미러. VulnCheck advisory와 함께 교차확인.
- WHATWG HTML Standard — RCDATA/RAWTEXT 파싱 상태와 엔티티 참조 처리에 관한 표준 배경(개념 인용용).
- OWASP, “Cross Site Scripting (XSS)” 및 “HTML Sanitization” 가이드 — 소독 우회·출력 인코딩·CSP의 일반 원리 배경.
이 글은 위 1차출처(CVE.org·GHSA-3rcm-vjrc-p45j·GitHub Advisory DB)를 우리 관점에서 다시 쓰고 논평을 더한 것입니다. 특정 문장·코드를 그대로 옮기지 않았으며, 자세한 재현 절차는 원문을 참고하세요. 이 초안은 RN-11 독립 팩트체크(2026-08-31)를 통과했습니다.