
생성형 AI에게 '결제 시스템을 만들게 했더니', 동일한 취약성이 3번이나 복제된 이야기
요약
생성형 AI를 이용한 앱 자동 생성 파이프라인 구축 중, 동일한 보안 취약성이 반복적으로 복제되는 현상을 분석합니다. 클라이언트 측 localStorage를 신뢰하는 설계 오류가 프롬프트 패턴을 통해 양산되는 과정을 다룹니다.
핵심 포인트
- AI가 생성한 코드에서 동일한 보안 취약성이 템플릿화되어 반복될 수 있음
- 클라이언트 측 localStorage 기반의 권한 판정은 보안상 매우 취약함
- 서버 측 검증(Webhook 및 API)을 통한 라이선스 확인 설계가 필수적임
- AI 리뷰 도구를 활용한 자동 보안 점검의 중요성
Gemini를 통해 미니 SaaS 앱을 자동 생성하는 파이프라인을 구축하고 있다. 아이디어를 한 줄 전달하면, Tailwind + Vanilla JS 기반의 단일 HTML 앱이 1~2분 내에 생성되며, Stripe 결제 링크와 함께 배포된다.
이 파이프라인의 '결제 판정 로직'에, 근본적으로 동일한 취약성이 생성된 앱 3개 모두에 복제되어 들어가 있었다. 프롬프트 작성 방식 하나로 취약성이 템플릿화되어 양산되는 모습을 기록해 둔다.
원래 설계: 고정 마스터 키
첫 번째 버전은 생성되는 모든 앱에 동일한 문자열 SaaS-FREE-TEST-2026을 하드코딩(Hardcode)하고, 이를 입력하면 localStorage에 isPremium = true를 기록하여 결제를 해제하는 설계였다.
외부 AI 리뷰에서 "이 설계는 $5 상품으로서 결함이 있다"라는 지적을 받았다. 이유는 단순하다. DevTools를 열고 localStorage.setItem('isPremium', 'true')라고 입력하기만 하면 누구나 무료로 Pro 기능을 사용할 수 있기 때문이다. 게다가 키 문자열이 모든 앱에 공통이므로, 앱 하나만 소스를 들여다보면 다른 모든 앱도 돌파할 수 있다.
서버 측 검증으로 교체
이를 Cloudflare Pages Functions + KV로 구축한 진짜 라이선스 검증 백엔드로 교체했다. 구성은 심플하게 만들었다.
- Stripe Webhook (
checkout.session.completed)을 받아 구매 시마다 랜덤한 라이선스 키를 발행하여 KV에 저장 charge.refunded를 받으면 해당 키를 무효화- 생성된 앱 측은
POST /api/verify-license로 키를 보내{"valid": true}가 반환되는지만 확인 - 개별 앱은 Cloudflare 설정(KV/Functions/Webhook)을 전혀 갖지 않고, 공통 포털 사이트 하나에 집약된 API를 fetch할 뿐
이로써 프롬프트에서는 '마스터 키'라는 개념 자체를 삭제했다.
- There is NO hardcoded bypass key of any kind. ...
- CRITICAL — DO NOT store a separate `isPremium` boolean flag in
`localStorage` at all. ... Instead:
...
구현은 Stripe의 테스트 모드에서 실제로 검증했다. Playwright로 테스트 카드 결제를 처음부터 끝까지 자동 조작하여, Webhook이 실제로 발화하고 키가 발행되며 앱 측에서 잠금이 해제되는 것까지 실기(Real device)로 확인했다. "아마 작동할 것이다"가 아니라, 요청 로그와 응답을 보고 확인하고 있다.
그럼에도 불구하고, 같은 구멍이 3개로 복제되었다
이 새로운 설계 하에 생성한 세 번째 앱(프리랜서 계약서의 위험 조항을 하이라이트하는 도구)을 claude -p를 이용한 자동 보안 리뷰에 돌렸더니, 다음과 같은 지적을 받았다.
isPremium is loaded unconditionally from localStorage on startup ... The only server-side check exits immediately if no key is stored ... Exploit: localStorage.setItem('shotwrap_is_premium', 'true') — deliberately omit the license key. Reload. Full Pro access, zero server contact.
해당 코드는 다음과 같았다.
// 기동 시, localStorage의 독립 플래그를 그대로 신뢰해 버림
let isPremium = false;
function loadState() {
...
isPremium의 초기값을 서버 검증 결과가 아닌 localStorage에서 직접 읽어오고 있다. license_key를 저장하지 않고 is_premium만 세우면, 재검증 함수는 키가 없으므로 그대로 종료된다. 프롬프트에서 "마스터 키를 금지"하더라도, 결제 상태를 나타내는 불리언(Boolean) 값을 localStorage에 쓰는 설계 그 자체가 동일한 구멍을 재생산하고 있었다.
확인을 위해 실제로 확인한 3개(회의 비용 타이머, 계약서 스캐너, 스크린샷 편집 도구)를 조사한 결과, 전부 동일한 패턴이 포함되어 있었다. 이는 개별적인 Gemini 생성 과정에서의 실수가 아니라, 프롬프트(Prompt) 사양 자체의 결함이라는 증거가 된다.
수정: "진실의 원천(Source of Truth)"을 하나로 줄이기
수정 방식은 독립 플래그(Flag) 자체를 삭제하는 것으로 결정했다.
// isPremium은 영속화(Persistence)하지 않는다. 페이지 로드 시마다 반드시 false에서 시작하며,
// 서버 검증 결과에 의해서만 메모리 상에서 true가 된다.
let isPremium = false;
...
localStorage에는 키(Key) 본체만 저장하고, isPremium은 메모리 상의 변수로서 매번 서버 응답으로부터만 도출하도록 했다. 공격자가 위조할 수 있는 대상을 "불리언(Boolean) 값 1개"에서 "122비트의 랜덤 문자열을 맞추는 것"으로 변경했다. 파이프라인 측의 프롬프트에도 동일한 제약 사항을 명문화하여, 이후 생성되는 모든 앱에 반영되도록 했다.
수정 후, 실제로 DevTools에서 동일한 수법(is_premium만 true로 설정하고 새로고침)을 재현하여, isPremium이 false인 상태로 유지되는 것을 확인한 뒤 배포했다.
또 다른 버그: 하이라이트 기능의 XSS
동일한 리뷰 과정에서 계약서 스캐너 측의 또 다른 종류의 취약성도 발견되었다. 붙여넣은 계약서 텍스트의 위험 조항을 하이라이트(Highlight) 표시하는 기능이, 매칭된 부분만 이스케이프(Escape) 처리하고 주변의 생텍스트(Raw text)는 이스케이프 처리되지 않은 채 innerHTML에 전달하고 있었다.
// Before: 하이라이트 부분만 이스케이프, 주변 생텍스트는 그대로 노출
let text = currentScanResult.text; // 사용자가 붙여넣은 생텍스트
matches.forEach(m => {
...
계약서 텍스트에 <img src=x onerror="...">와 같은 HTML이 섞여 있다면 그대로 실행된다. 수정 방법은 간단했다. 전체를 먼저 이스케이프한 다음 하이라이트를 덧씌우도록 변경했다.
// After: 전체를 먼저 이스케이프한 후, 이스케이프된 텍스트 내에서 치환
let text = escapeHtml(currentScanResult.text);
matches.forEach(m => {
...
수정 후, <script>나 <img onerror>가 포함된 텍스트를 실제로 붙여넣어 코드가 실행되지 않는지, 화면에는 무해한 문자열로 표시되는지를 브라우저에서 확인했다.
요약
이번에 깨달은 점은, "AI에게 리뷰를 시키는 것"만으로는 불충분하며, 동일한 설계 판단은 동일한 구멍을 복제한다는 사실이다. 마스터 키라는 명확한 결함을 제거하더라도, "결제 상태를 나타내는 불리언 값을 영속화한다"라는 한 단계 더 추상화된 설계 판단이 남아 있다면, 생성할 때마다 동일한 취약성이 재생산된다.
수정할 때는 개별 버그가 아니라, 그 밑바탕에 깔린 설계 판단(이번 사례의 경우 "진실의 원천은 서버 응답 하나로 한정한다")까지 거슬러 올라가 프롬프트에 다시 기록함으로써, 다음에 생성될 모든 앱에 효과가 나타나도록 했다. 앱 한 개를 고치는 것보다, 그 앱을 만들어낸 프롬프트를 고치는 것이 장기적으로는 더 저렴하다.
Discussion

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