링크를 씻는 손이 여러 곳에서 미끄러졌다

링크를 씻는 손이 여러 곳에서 미끄러졌다

CVE · 웹·서버 · XSS(크로스사이트 스크립팅) · CVE-2026-5388 [표준]

HTML 소독(sanitization) 라이브러리는 “신뢰할 수 없는 마크업에서 위험한 조각만 걸러 안전하게 만든다”는 한 가지 약속으로 존재합니다. justhtml의 1.15.0 이전 버전에서 그 약속이 한 군데가 아니라 여러 지점에서 새어 나갔습니다. 공개된 설명은 URL을 정리하는 도우미 함수(clean_url_value·clean_url_in_js_string), HTML을 문자열로 다시 직렬화하는 과정, Markdown 원문 통과(html_passthrough=True), 그리고 사용자 정의 소독 정책의 몇몇 경계 사례를 함께 지목합니다. 설정에 따라 공격자가 이 틈을 통해 위험한 콘텐츠를 통과시킬 수 있는 것으로 보입니다.

개요

justhtml은 신뢰할 수 없는 HTML을 받아 위험한 요소를 제거·무력화하는 Python(3.10+) HTML5 파서/소독 라이브러리입니다(GitHub EmilStenstrom/justhtml, “sanitize untrusted HTML by default” — 확정).

이번 취약점(CVE-2026-5388)의 특징은 단일 실수가 아니라는 점입니다. 공개 설명은 URL 소독 도우미, HTML 직렬화, Markdown 통과, 사용자 정의 정책의 경계 사례를 여러 개 나열합니다. 즉 “링크를 안전하게 만드는” 경로가 여러 지점에서 미끄러질 수 있고, 소독을 신뢰해 사용자 입력을 페이지에 넣는 애플리케이션이라면 이 틈으로 심어진 스크립트가 피해자 브라우저에서 실행될 수 있습니다.

  • 취약 버전: justhtml 1.15.0 미만(0 이상 <1.15.0), 패치 버전 1.15.0 — MITRE·VulnCheck·GHSA-c9vm-hv86-f23r 3중 확정.
  • 취약점 유형: HTML/URL 소독 우회 → 크로스사이트 스크립팅. 주 CWE는 CWE-20(부적절한 입력 검증) — CNA(VulnCheck) 권위 할당. GHSA는 부차적으로 CWE-79·CWE-178·CWE-755도 함께 나열합니다.
  • 영향 조건: 설정 의존적(configuration-dependent) — VulnCheck 설명: “Most custom-policy issues do not affect the default sanitize=True configuration; they primarily affect helper APIs, programmatic DOM construction, html_passthrough=True, and custom policies.” 즉 기본 설정(sanitize=True)만 쓰는 배포는 노출이 제한적입니다.
  • CVSS: v4.0 9.3(CRITICAL) CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N, v3.1 9.8(CRITICAL) CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H — MITRE·VulnCheck 확정.

한 줄 요약: “소독기를 통과했으니 링크도 안전하다”는 가정이, 링크를 씻는 손 여러 개가 동시에 미끄러지면서 무너진 사례입니다.

기술 배경 — URL과 Markdown, 소독기의 두 미끄러운 바닥

HTML 소독에서 태그·속성 필터링만큼 까다로운 것이 URL과 링크 처리입니다. href·src 같은 속성에 들어오는 값은 겉보기엔 평범한 주소지만, javascript: 스킴이나 data: URL, 제어문자·인코딩 트릭을 섞으면 클릭 한 번에 스크립트가 실행되는 통로가 됩니다. 그래서 소독기는 URL을 별도로 “정규화하고 씻는” 도우미를 둡니다 — justhtml의 clean_url_value나, 자바스크립트 문자열 안에 박힌 URL을 다루는 clean_url_in_js_string이 그런 역할을 합니다.

두 번째 미끄러운 바닥은 **Markdown 통과(passthrough)**입니다. 많은 소독기가 Markdown을 HTML로 변환하는데, html_passthrough=True처럼 “원문 HTML을 그대로 통과시키는” 옵션을 켜면 소독 단계를 의도적으로 건너뛴 마크업이 결과에 섞일 수 있습니다. 편의를 위한 옵션이 방어선에 구멍을 내는 전형적인 자리입니다.

세 번째는 **직렬화(serialization)**입니다. 소독기가 입력을 파싱해 안전하다고 판단한 뒤, 그 결과를 다시 문자열 HTML로 뱉는 과정에서 이스케이프가 어긋나면 “파싱할 땐 안전했는데 출력하고 보니 위험한” 재조합이 일어날 수 있습니다.

CVE 분석 요약 — 어디서 새는가

공개 설명은 우회 지점을 한 곳이 아니라 여러 곳으로 나열합니다. 벤더 GHSA-c9vm-hv86-f23r가 실제로 확인한 우회 유형은 다음과 같습니다(공개 자문이 확인한 사실 — 파일·라인 단위 소스 대조는 심층판 몫이라 표준판 범위 밖입니다):

  • 인코딩된 javascript: URL: 인코딩·공백 트릭이 섞인 javascript: 스킴이 URL 소독 도우미(clean_url_value·clean_url_in_js_string)를 통과.
  • 백슬래시 기반 상대 URL: 백슬래시를 쓴 상대 경로가 브라우저에서는 원격 호스트로 해석됨.
  • 프로그래매틱 DOM 구성의 요소/속성 이름·HTML 주석: 프로그램적으로 DOM을 만들 때 요소·속성 이름이나 HTML 주석을 이용해 마크업 구조를 깨뜨림.
  • Markdown 원문 통과(html_passthrough=True)를 통한 raw </textarea> 재도입: 편의 옵션이 소독되지 않은 마크업을 그대로 결과에 섞음.
  • 커스텀 정책의 <style>/<meta http-equiv=refresh>/<base href> 보존: 기본 정책이 아니라 사용자가 넓힌 커스텀 소독 정책의 특정 조합에서 방어가 뚫림.

아래는 “링크를 씻었다고 믿었는데 실행 경로가 남는다”는 개념을 보여주는 무해한 예시입니다. 실제 우회 문자열이 아니며, 실행되지 않도록 무력화했습니다.

# 개념용 단순화, 실제 페이로드/원본 코드 아님 (무해화)
입력(신뢰 불가):   <a href=" 위험스킴 : ...무해화... ">클릭</a>
소독기의 판단:     "정규화했고 스킴 목록에 없음 → 안전한 링크"   → 통과시킴
브라우저의 해석:   "앞쪽 공백/인코딩을 무시하고 스킴을 인식"       → 실행 경로 성립
                  └── '씻는 규칙'과 '읽는 규칙'이 어긋나는 지점이 곧 우회 지점

즉 원인은 “무엇을 차단 목록에 넣었나”보다 “URL을 씻는 규칙이 브라우저가 URL을 읽는 규칙과 일치하는가”, 그리고 **“편의 옵션(통과·커스텀 정책)이 방어선을 우회하도록 열어두지 않았나”**에 가깝습니다. 목록·정규화 기반 방어는 파서·정규화 불일치 앞에서 늘 취약합니다.

공격 시나리오 · 영향도 + 대응방안

공격 시나리오 (단계 압축, 완성형 페이로드 없음):

  1. 공격자가 소독을 거치는 입력란(댓글·프로필·Markdown 메모 등)에 우회를 유도하는 변형 링크/마크업을 넣습니다.
  2. 애플리케이션이 justhtml로 “소독·정리했으니 안전하다”고 판단하고 그 결과를 페이지에 그대로 렌더링합니다.
  3. 피해자가 그 페이지를 열거나 링크를 상호작용하면, 소독을 통과해 살아남은 실행 경로가 피해자 브라우저에서 동작합니다.

영향도: 저장형·반사형 XSS로 이어질 경우, 세션 탈취·계정 조작·피해자 권한 내 임의 동작·피싱 발판 등 XSS의 일반적 영향이 성립할 수 있습니다. 공개 설명이 확정하는 바로는, 기본 설정(sanitize=True)만 쓰는 대다수 배포는 이 이슈들의 영향을 받지 않으며, html_passthrough 같은 통과 옵션이나 넓힌 커스텀 정책을 쓰는 배포에서 노출이 집중됩니다(VulnCheck 확정).

대응방안:

  1. 근본 — 패치 버전으로 업데이트. justhtml 1.15.0 이상으로 올립니다(MITRE·VulnCheck·GHSA 3중 확정 패치 버전). 소독기 우회는 “일부만 막고 변종이 남는” 경우가 잦으므로, 이후 벤더 릴리스 노트에서 후속 재패치 공지가 없는지 계속 확인하는 것을 권합니다.
  2. 차선(즉시 업데이트 불가 시):
    • 원문 HTML 통과 옵션을 끕니다. html_passthrough=True처럼 소독을 건너뛰는 경로를 사용 중이면 비활성화하거나 통과 대상을 최소화합니다.
    • 소독 정책을 좁힙니다. 넓힌 커스텀 태그·속성·스킴 허용을 기본 프로파일에 가깝게 되돌리고, href/src에 대해 허용 스킴을 화이트리스트(http/https/mailto 등)로 제한합니다.
    • 소독 단독에 의존하지 않습니다. 출력 지점에서 문맥에 맞는 인코딩을 병행하고, 콘텐츠 보안 정책(CSP, Content-Security-Policy)으로 인라인 스크립트 실행을 제한해 우회가 실제 실행으로 번지는 걸 막습니다.
  3. 탐지 관점: 소독을 거친 뒤에도 출력의 href/src에 위험 스킴(javascript:·의심스러운 data:)이나 이벤트 핸들러가 남는지 표본 검사·회귀 테스트로 상시 점검합니다. CSP 위반 리포트를 수집하면 우회 시도를 사후에 포착하는 데 도움이 됩니다.

결론

소독기는 방패지만, 방패의 이음매는 “링크를 씻는 규칙과 브라우저가 링크를 읽는 규칙이 같은가”, 그리고 “편의를 위해 열어둔 통과 옵션이 방어선을 관통하지 않는가”에 달려 있습니다. justhtml 1.15.0 이전의 이 우회들은 그 이음매가 여러 곳에서 벌어진 자리였습니다.

이 부류의 버그가 반복되는 이유는 분명합니다. 소독은 “가장 위험한 값(URL·통과 옵션·커스텀 정책)을 예외로 두는 순간” 무너지기 때문입니다. 목록과 정규화를 아무리 다듬어도, 씻는 규칙과 읽는 규칙이 어긋나거나 편의 옵션이 방어를 건너뛰는 한 틈은 다른 자리에서 또 열립니다. 그래서 우리는 “무엇을 필터에 넣었나”가 아니라 “소독을 단일 방어선으로 두지 않았나, 통과 옵션이 구멍을 내지 않았나”를 먼저 봅니다 — 이 한 틈은 패치로 닫히겠지만, 이 사고방식은 다음 소독기에서 또 필요합니다. 그래서 도구가 아니라 방법을 남깁니다.

(이 CVE의 심층 분석판은 아직 없습니다. 소스 대조·패치 diff까지 파는 심층판으로 승급하면 여기에 링크됩니다. 같은 라이브러리의 다른 소독 우회 사례는 CVE-2026-7808 표준판과 역링크됩니다.)

References / 참고자료

  1. MITRE CVEAWG — CVE-2026-5388 공식 레코드. 영향 제품·버전·CVSS·CWE 근거. https://cveawg.mitre.org/api/cve/CVE-2026-5388
  2. VulnCheck advisory — CNA(할당 기관) 원문. 취약/패치 버전(<1.15.0 / 1.15.0)·CVSS 이중값(v4.0 9.3, v3.1 9.8)·CWE-20 주분류·설정 의존적 영향범위 확정 근거. https://www.vulncheck.com/advisories/justhtml-before-multiple-security-issues
  3. GHSA-c9vm-hv86-f23r (“Multiple security fixes in justhtml 1.14.0 found by LLM”) — 벤더(justhtml 저장소) 자문. 우회 유형(인코딩 URL·백슬래시 상대URL·DOM 구성 우회·Markdown passthrough·커스텀 정책 보존) 확증 근거.
  4. OWASP, “Cross Site Scripting (XSS)” 및 “DOM based XSS Prevention / HTML Sanitization” 가이드 — URL 스킴 검증·파서 불일치·출력 인코딩·CSP의 일반 원리 배경.

이 글은 위 1차출처(MITRE CVEAWG·VulnCheck advisory·GHSA-c9vm-hv86-f23r)를 우리 관점에서 다시 쓰고 논평을 더한 것입니다. 특정 문장·코드를 그대로 옮기지 않았으며, 자세한 재현 절차는 원문을 참고하세요. 이 초안은 RN-11 독립 팩트체크(2026-08-31)를 통과했습니다.