Noma Labs는 10월 14일, Claude Code Desktop의 미리보기(preview) 패널에서 발생하는 익스플로잇 체인(SneakPeek)을 발표했습니다. 공개 저장소에 있는 악성 HTML 파일이 Claude가 이를 미리보기 할 때 JavaScript를 실행하여 사용자의 홈 디렉터리에서 파일을 읽고 공격자 서버로 전송할 수 있었습니다. Noma가 개념 증명(proof of concept)으로 읽어낸 파일 목록에는 SSH 개인 키, AWS 키, npm 및 GitHub 토큰, Kubernetes 및 Docker 자격 증명이 포함되어 있습니다. Anthropic은 이를 CVSS 8.7 (High)로 평가했습니다.
이 문제는 해결되었습니다. Noma의 타임라인에 따르면, Anthropic은 자동 업데이트를 통해 Claude Desktop 1.30096.1 버전 이후인 8월 17일에 패치를 배포했습니다. 만약 사용자의 앱이 자체적으로 업데이트된다면, 이미 두 달 전에 이 수정 사항을 적용받았다는 의미입니다.
저는 에이전트가 작성한 코드를 읽기 위한 IDE인 Reado를 구축했기 때문에, '사용자에게 파일을 보여주는' 기능에 존재하는 버그는 저의 문제와도 직결됩니다. 이 글은 비록 문제가 해결되었지만 왜 이해할 가치가 있는지, 그리고 제가 이를 읽은 후 제 자신의 앱에서 무엇을 확인했는지에 대한 내용입니다.
발생한 일
Claude가 HTML 파일을 읽거나 편집할 때, Claude Code Desktop은 미리보기 패널에 해당 내용을 표시하여 사용자가 결과를 즉시 확인할 수 있게 합니다. Noma는 이 패널 주변에 세 가지 방어 장치가 있다고 설명합니다:
script-src 'none'을 포함하는 콘텐츠 보안 정책(Content Security Policy, CSP)으로 JavaScript 실행을 차단하고;
- 페이지를
data: URL로 로드하여 불투명한 출처(opaque origin)를 부여함으로써 로컬 파일 접근을 막고;
- 렌더러가 요청하는 것을 가로채는 개발 서버 프록시의 요청 필터(request filter)를 적용했습니다.
하지만 이 방어 장치들에는 허점이 있었습니다. 256KB 미만의 파일은 CSP와 함께 data: URL로 로드되었습니다. 256KB가 넘는 파일은 임시 파일로 작성된 후 file:// URL로 로드되었고, 이때 경로는 CSP의 적용을 받지 못했습니다. Chromium에서는 file:// 페이지가 다른 로컬 파일을 fetch()할 수 있습니다. 또한 요청 필터는 패널이 실행 중인 개발 서버를 프록시 할 때만 활성화되었으며, 디스크에서 정적 파일을 렌더링할 때는 작동하지 않았습니다.
공격은 다음과 같습니다: HTML 파일을 256KB를 초과하도록 패딩하고, 그 안에 스크립트를 넣은 다음, 누군가 그것을 보기를 기다리는 것입니다. Noma는 이것을 테스트 커버리지 보고서로 위장했는데, 아무도 크다고 놀라지 않을 파일입니다. 피해자는 리포지토리를 클론하고 Claude에게 커버리지 보고서를 살펴보라고 요청합니다. Claude는 파일을 읽고, 패널이 그것을 렌더링하며, 스크립트가 실행됩니다.
어떤 순간에도 권한 프롬프트가 없었고, 그 부분이 제가 계속 생각하는 지점입니다. Claude는 명령을 실행하거나 파일을 작성하지 않았습니다. 단지 파일을 읽었을 뿐인데, 이것은 에이전트가 할 수 있는 가장 무해한 행동입니다. 코드는 애플리케이션의 렌더러에서, 결과를 보여주는 부수적인 효과로 실행되었습니다.
첫 번째 수정과 우회 방법
Noma는 7월 4일에 이 버그를 보고했습니다. Anthropic이 적용한 첫 번째 패치는 페이지에서 file://로의 fetch() 및 XMLHttpRequest 호출을 차단했습니다. Noma는 동일한 fetch()를 필터가 커버하지 않는 컨텍스트인 blob: URL에서 생성된 Web Worker 내부로 옮겼고, 파일들이 다시 나오게 했습니다. 그들은 패치가 재테스트될 준비가 된 7월 24일에 이 우회 방법을 보고했습니다. 8월 3일 Anthropic은 첫 번째 수정이 불완전하다고 확인하고, Noma의 말대로
권한 시스템은 에이전트를 보호하는 것이지, 그 주변의 애플리케이션을 보호하는 것이 아닙니다. 코딩 에이전트의 안전성을 확보하기 위해 우리가 하는 대부분의 작업은 에이전트에 관한 것입니다. 이 명령 승인, 프로젝트 외부 쓰기 거부, 셸(shell)에 대한 샌드박싱 등이 그것입니다. 이 모든 것이 이곳에 적용되어 있었지만 아무것도 관련되지 않았습니다. Carlos는 dev.to의 버그 분석 글에서 다음과 같이 언급했습니다: "여기서 리포지토리 내 파일이 공격자였고, 뷰어(viewer)가 도구였습니다." 리포지토리에 있는 내용을 렌더링하는 모든 것(미리보기, 마크다운, 노트북, 이미지, 임베디드 브라우저 등)은 에이전트의 개입 여부와 관계없이 공격 표면(attack surface)의 일부입니다.
"이 파일을 검토해 주세요"는 파일에 대한 신뢰도가 가장 낮은 순간입니다. SneakPeek을 작동시킨 요청은 검토였습니다: 이 커버리지 리포트를 보세요. 그것은 파일이 당신이 신뢰하는 그 누구에게도 보여지지 않은 순간이므로, 해당 파일을 보여주는 표면은 앱에서 가장 능력이 없는 쪽이어야 하며, 가장 능력이 있는 쪽이어서는 안 됩니다.
Reado가 리포지토리를 표시하는 방법
Reado 역시 해결해야 할 동일한 문제가 있습니다. 그것은 당신이 작성하지 않은 파일들로 가득 찬 리포지토리의 파일을 보여주기 위해 존재합니다. Noma의 글을 읽고 난 후, 저는 SneakPeek의 세 가지 질문(파일 코드가 실행되는가, 페이지가 디스크를 읽을 수 있는가, 외부로 무언가를 보낼 수 있는가)을 가지고 그 기능이 작동하는 모든 곳을 살펴보았습니다.
• .html 파일은 소스 코드로 열립니다. Reado는 Markdown, Jupyter 노트북, 이미지 및 PDF를 렌더링하며, 그 외 모든 것(HTML 포함)은 에디터 내에서 텍스트로 처리됩니다. 커버리지 보고서는 실행되는 것이 아니라 읽어보는 것입니다.
• Markdown과 노트북 출력물 내부의 HTML은 검사기(sanitizer)를 거칩니다. README 파일에는 HTML이 가득합니다 (중앙 정렬된 로고, 배지, <details> 등). 따라서 Reado는 rehype-sanitize를 통해 이를 렌더링하며, 이 과정에서 스크립트, 이벤트 핸들러 및 javascript: URL은 제거됩니다.
• 검사기 뒤에는 앱의 콘텐츠 보안 정책(Content Security Policy)이 있습니다. Reado와 함께 제공되는 스크립트만 실행됩니다 (script-src 'self'), 그리고 이 규칙은 워커(workers)에도 적용되므로, blob: Worker 트릭은 시작할 기반 자체가 없습니다. 프레임도 없고, 페이지는 앱 자체를 제외하고는 어딘가로 요청을 보낼 수 없습니다 (fetch(), sendBeacon()).
• 파일 크기는 로딩 방식에 영향을 주지 않습니다. 500KB의 README 파일은 5KB 파일과 동일한 파이프라인을 거칩니다. 특정 크기 제한을 넘어서면 Reado는 작동을 멈춥니다: 큰 파일을 열기 전에 사용자에게 요청하며, 아예 너무 큰 파일은 열지 않습니다. 로더를 다른 것으로 전환하는 일은 없습니다.
• 로컬 이미지는 오직 열린 프로젝트에서만 가져옵니다. Reado가 디스크의 이미지에 사용하는 프로토콜은 빈 범위(empty scope)로 시작하여 프로젝트 폴더를 열 때 확장되며, 절대로 전체 디스크까지는 확장되지 않습니다. SVG는 <img> 태그를 통해 표시되는데, 여기서는 SVG 스크립트가 실행되지 않으며, PDF는 캔버스에 그려집니다.
같은 검토 과정에서 제가 '파일 표시'로 생각지 못했던 표면적 두 가지 버그를 발견했습니다. 바로 Reado의 임베디드 브라우저와 개발 서버 미리보기 창입니다. 이 기능은 저장소(repository)가 제어할 수 있는 두 곳에서 URL을 가져옵니다. .reado/에 있는 모든 Reado 댓글처럼, 디자인 코멘트(Design comments)는 자신이 고정된 페이지의 URL을 담고 있습니다. 그리고 에이전트(agents)는 같은 폴더 내 명령 파일(command file)을 통해 이 창을 구동합니다. 따라서 클론된 저장소는 file: 또는 data: 페이지를 가리키는 댓글을 포함하거나, 이미 미리보기 창을 열기 위해 대기하는 명령어를 담아 배포할 수 있었습니다. 두 가지 모두 수정되었습니다: 이제 해당 창은 오직 http 및 https 페이지만 열도록 제한되었으며, 모든 호출자가 거치는 Rust 계층(layer)의 한 곳에서 이 점검이 이루어집니다. 또한, 미리보기 창이 열릴 때 이미 존재했던 명령어는 기록만 되고 실행되지 않도록 수정되었습니다. 이 수정 사항들은 main 브랜치에 반영되어 다음 릴리스에 배포될 예정입니다.
공통점은 제가 다른 이유로 내린 선택이었습니다: Reado는 코드를 읽는 것을 중심으로 구축되었기 때문에, 파일의 기본 동작은 텍스트를 보여주는 것입니다. 이는 라이브 미리보기(live preview)보다 인상적이지 않지만, SneakPeek 이후에는 이 점이 오히려 다행이라고 생각합니다.
이러한 도구를 사용하거나 직접 만들 경우
만약 Claude Code Desktop을 사용한다면, Claude Desktop 1.30096.1 이상 버전을 사용하는지 확인하세요. Noma의 게시물에서는 GitHub 권고 및 CVE가 보류 중이라고도 언급했으니 주시해야 합니다.
저장소에서 파일을 표시하는 도구를 직접 만든다면, 이 버그로부터 몇 가지 규칙을 따르는 것이 좋습니다:
- 클론된 저장소의 모든 파일을 생면부지(stranger)의 입력으로 취급하고, 지루해 보이는 파일도 예외가 아닙니다.
- 파일 크기와 관계없이 하나의 렌더링 경로를 사용해야 합니다.
- 콘텐츠가 어디에서 오는지에 제한을 적용해야 하며, 사용하는 API에만 적용해서는 안 됩니다.
fetch()가 차단되지 않으면 공격자가 Worker를 이용할 수 있습니다.
- 기본적으로 소스 코드를 보여주어야 합니다. 필요한 것만 렌더링하세요.
Anthropic은 이 문제를 수정했으며, 우회 경로(bypass)까지 막을 때까지 계속 노력했습니다. 이러한 패턴은 그들만의 전유물이 아닙니다: 파일을 표시하는 모든 코딩 도구에는 어느 정도의 미리보기 창 버전이 존재합니다. 다른 사람이 보기 전에 여러분의 도구를 점검해 볼 가치가 있습니다.