데모 하나 띄워달라고 했을 뿐인데
"이번 주 발표용으로 데모 버전 따로 띄워줘. 주소는 demo.myapp.com으로."
Claude Code나 Codex한테 이렇게 시키면 일이 착착 진행된다. 새 프로젝트를 만들고, 빌드를 올리고, 마지막에 DNS 설정 안내가 나온다.
demo.myapp.com CNAME cname.vercel-dns.com
Cloudflare 대시보드에 이 한 줄을 넣으면 몇 분 뒤 데모가 뜬다. 발표가 끝난다. 다음 주에 대시보드를 정리하다가 데모 프로젝트를 지운다. 쓰지도 않는 걸 남겨둘 이유가 없으니까.
여기까지는 아무 문제가 없어 보인다. 프로젝트를 지웠으니 demo.myapp.com은 이제 아무것도 아니다. 실제로 들어가 보면 페이지도 안 뜬다.
그런데 지운 건 프로젝트고, DNS 레코드는 그대로 남아 있다. 저 한 줄은 여전히 살아서 이렇게 말하고 있다. "demo.myapp.com을 찾는 사람은 Vercel로 가세요."
이 상태에서 누군가 Vercel에 새 프로젝트를 만들고 도메인 칸에 demo.myapp.com을 적으면, 그 사람 사이트가 내 도메인으로 열린다.
이름은 남고 집은 비었다
왜 이게 가능한지 보려면 CNAME이 실제로 뭘 하는 줄인지부터 봐야 한다.
A 레코드는 "이 이름은 이 IP다"라고 못 박는다. CNAME은 다르다. "이 이름 말고 저 이름으로 다시 물어봐"라고 넘기는 줄이다. 위의 설정은 demo.myapp.com의 주소를 적어둔 게 아니라, 그 이름을 찾는 사람을 Vercel 쪽으로 안내하는 이정표다.
그러면 Vercel에 도착한 요청은 누구 사이트로 가는가. cname.vercel-dns.com이 가리키는 IP 뒤에는 고객 수십만 명의 사이트가 같이 올라가 있다. IP만 봐서는 구분이 안 된다. 그래서 플랫폼은 요청에 적힌 이름을 보고 주인을 찾는다. HTTP의 Host 헤더, HTTPS라면 TLS 핸드셰이크의 SNI에 적힌 도메인 이름이 그 기준이다. 지난주에 Host 헤더를 다뤘는데, 그때는 서버가 그 값을 믿고 메일 링크를 만들어서 문제였다. 이번에는 플랫폼이 그 값으로 주인을 찾는다는 점이 핵심이다.
플랫폼 입장에서 "이 이름의 주인"은 대시보드에 그 도메인을 등록해둔 계정이다. 내가 프로젝트를 지우는 순간 등록도 같이 사라졌다. 주인 없는 이름이 된 것이다.
우편물로 바꿔 생각하면 이해가 빠르다. 이정표(DNS 레코드)는 우편물을 계속 그 건물로 보내고 있는데, 관리사무소 명부에서 내 이름이 빠졌다. 이 상태에서 아무나 관리사무소에 가서 "그 호수 제 겁니다"라고 등록하면, 그날부터 그 우편물은 전부 그 사람이 받는다. 건물은 명부에 적힌 이름만 확인하지, 누가 진짜 주인인지는 모른다.
OWASP는 웹 보안 테스트 가이드에 이 항목을 따로 두고 있다. WSTG-CONF-10, "Test for Subdomain Takeover"다. 성립 조건을 두 가지로 정리해놨다. 존재하지 않는 외부 서비스를 가리키는 DNS 레코드가 있을 것, 그리고 그 서비스 제공자가 소유권 확인을 제대로 하지 않을 것. 두 개가 겹치면 그 서브도메인은 먼저 등록하는 사람이 임자다.
Microsoft는 이렇게 남은 레코드를 dangling DNS, 매달린 레코드라고 부른다. 문서에 적힌 표현은 이렇다. 해제된 리소스를 가리키는 DNS 레코드를 말하며, 특히 CNAME 레코드가 이 위협에 취약하다고.

그 주소는 이미 공개된 목록에 있다
여기서 자연스럽게 나오는 반문이 있다. 발표용으로 하루 쓰고 만 주소를 대체 누가 알고 찾아오느냐는 것이다.
찾을 필요가 없다. 목록이 공개돼 있다.
HTTPS를 붙이는 순간 그 도메인 이름은 인증서 투명성 로그에 올라간다. 공개 인증기관이 발급한 인증서는 추가만 가능하고 삭제나 수정이 안 되는 공개 장부에 기록되고, 누구나 조회할 수 있다. 원래는 인증서를 잘못 발급한 사고를 감시하려고 만든 장치인데, 부수 효과로 "이 도메인 아래 어떤 이름들이 쓰였는가"가 영구 기록으로 남는다.
데모를 띄우면서 자동으로 붙은 인증서 하나가 demo.myapp.com이라는 이름을 그 장부에 넣었다. 프로젝트를 지워도 기록은 남는다. 도메인 하나를 넣고 조회하면 그동안 쓴 서브도메인이 쭉 나오고, 그중 지금 살아 있지 않은 이름을 걸러내는 건 기계가 하는 일이다. 사람이 내 회사에 관심을 가질 필요조차 없다.
데모 페이지 하나 뺏긴 걸로 끝나지 않는 이유
그래도 남는 의문이 있다. 어차피 안 쓰는 주소에 남의 정적 페이지가 뜨는 것뿐인데 뭐가 그렇게 문제인가.
문제는 그 이름이 내 도메인 아래에 있다는 점이다. 브라우저와 서버는 여러 곳에서 "내 도메인 아래"를 특별 취급한다. 그리고 그 특별 취급을 설정한 코드는 대개 에이전트가 짜준 것이다.
가장 무거운 쪽이 쿠키다. 로그인 상태를 여러 서브도메인에서 공유하려면 쿠키를 상위 도메인에 걸어야 한다. "api랑 app이랑 로그인 상태 공유되게 해줘"라고 시키면 이 줄이 나온다.
res.cookie('session', token, {
domain: '.myapp.com', // app, api, admin 어디서나 붙게
httpOnly: true,
secure: true,
});
Domain 속성에 .myapp.com을 적으면 브라우저는 그 도메인과 모든 하위 도메인으로 이 쿠키를 붙여 보낸다. MDN에 적힌 그대로이고, 여기에는 demo.myapp.com도 당연히 포함된다. 그 주소를 가져간 쪽이 페이지를 하나 띄워두고 방문을 유도하면, 방문자 브라우저는 세션 쿠키를 그 페이지로 얌전히 실어 보낸다. httpOnly를 켜뒀으니 괜찮지 않냐는 질문이 여기서 나오는데, 그 옵션은 자바스크립트로 못 읽게 막는 장치지 전송을 막는 장치가 아니다. 받는 쪽 서버 로그에는 그대로 찍힌다.
그다음이 출처 허용목록이다. CORS 설정에서 서브도메인을 통째로 여는 패턴도 흔하게 나온다.
app.use(cors({
origin: /\.myapp\.com$/, // 내 서브도메인이면 다 허용
credentials: true,
}));
이렇게 두면 demo.myapp.com에서 날아온 요청은 내 API 입장에서 신뢰하는 출처다. credentials: true가 붙어 있으니 쿠키까지 함께 오간다. 공격자는 외부에서 내 API를 두드리는 게 아니라 내 사이트 안쪽에서 부르는 셈이 된다.
나머지 둘은 짧게만 봐도 된다. 콘텐츠 보안 정책에 script-src 'self' *.myapp.com을 적어두는 구성은 정적 자산을 서브도메인에 올릴 때 자연스럽게 나오는데, 가져간 서브도메인에 올린 스크립트가 본 사이트에서 실행 허가를 받게 된다. OAuth 콜백 주소를 서브도메인 단위로 넉넉하게 등록해둔 경우에는 그쪽으로 인가 코드가 떨어진다.
네 곳 모두 같은 기준으로 판단한다. 이름이 내 도메인 아래에 있는가. 그 이름 뒤에 지금 누가 앉아 있는지는 아무도 확인하지 않는다.
HTTPS가 걸러줄 거라는 기대도 접어야 한다. 인증서를 공격자가 직접 발급받으러 다닐 필요조차 없다. 요즘 배포 플랫폼은 사용자 지정 도메인을 등록받으면 인증서를 알아서 발급해 붙인다. 발급 기관이 확인하는 조건이 "이 이름으로 오는 요청을 이 서버가 받고 있는가"인데, 지금 그 조건을 만족하는 쪽이 공격자이기 때문이다. 방문자 화면에는 자물쇠가 멀쩡히 뜨고 주소창에는 내 도메인이 찍힌다.
OWASP가 이 항목의 영향으로 적어둔 목록이 정확히 이 그림이다. 악성 콘텐츠 제공, 피싱, 사용자 세션 쿠키와 자격증명 탈취.
플랫폼마다 문의 잠금이 다르다
같은 상황이라도 어느 플랫폼을 쓰느냐에 따라 결과가 갈린다. 이름을 등록할 때 소유권을 확인하느냐 마느냐의 차이다.
GitHub Pages는 2021년 11월에 도메인 검증을 도입했다. 조직이나 계정 설정에서 도메인을 검증해두면 다른 사용자가 그 도메인으로 Pages 사이트를 띄우지 못한다. 검증 범위가 바로 아래 서브도메인까지 자동으로 확장되는 것도 편하다. github.com을 검증하면 docs.github.com도 같이 보호된다. 단, 문서에 경고가 하나 붙어 있다. *.example.com 같은 와일드카드 DNS 레코드를 쓰면 검증을 해도 위험이 남는다는 것. example.com을 검증해서 a.example.com은 막아도, 와일드카드가 받아주는 b.a.example.com은 여전히 열려 있기 때문이다.
Azure App Service는 asuid.{서브도메인} 형식의 TXT 레코드에 도메인 확인 ID를 넣게 한다. 문서의 설명이 명확하다. 이 TXT 레코드가 있으면 다른 Azure 구독은 그 사용자 지정 도메인을 검증하거나 가져갈 수 없다. 자기 확인 ID를 DNS에 추가하지 않는 한.
Vercel은 조건부다. 도메인이 다른 계정에서 이미 쓰이고 있으면 TXT 레코드로 소유권을 확인하라고 요구한다. 그리고 팀 계정 수준에서 도메인 소유권을 한 번 확인해두면, 그 뒤로는 프로젝트에 붙일 때마다 다시 검증하지 않는다. 여기서 갈린다. 도메인을 계정에 제대로 확보해둔 상태라면 프로젝트를 지워도 그 이름은 여전히 내 계정에 묶여 있다. 반대로 프로젝트에만 도메인을 붙여 쓰다가 프로젝트째로 지웠다면, 플랫폼이 확인할 소유권 자체가 남지 않는다. 확인할 게 없는 이름은 먼저 적는 쪽이 가져간다.
플랫폼별로 지금 어느 쪽인지는 can-i-take-over-xyz라는 저장소가 목록으로 관리한다. 서비스마다 취약한지, 아닌지, 조건부인지를 표로 정리하고 응답에 나타나는 지문까지 적어둔다. 관리자들도 정확성을 보장하지는 않는다고 밝혀두었으니 판단의 출발점으로 쓰면 된다.
AWS는 결이 조금 다르다. AWS 보안 블로그가 2026년 6월에 이 주제를 정면으로 다뤘는데, 위험의 뿌리를 전역 고유 이름 공간으로 짚는다. S3 버킷 이름, Elastic Beanstalk 환경 이름, CloudFront 배포 도메인처럼 전 세계에서 하나뿐인 이름은 내가 반납하는 순간 다른 계정이 같은 이름을 다시 만들 수 있다.
이 부분은 2026년 3월에 나온 S3 계정 리전 이름 공간으로 일부 달라졌다. 버킷 이름에 계정 ID와 리전 코드가 붙어서 mybucket-123456789012-us-east-1-an 같은 형태가 되고, 이 이름 공간의 버킷은 다른 계정이 다시 만들 수 없다. 다만 직접 켜야 하는 기능이고 기본값은 여전히 전역 이름 공간이다. 이미 만들어둔 버킷에 소급 적용되지도 않는다.
삭제 순서가 방어다
AWS 블로그에서 가장 실용적인 문장은 예방책 쪽에 있다. 순서를 바꾸라는 것이다.
DNS를 항상 먼저 지우고, TTL이 만료되기를 기다린 다음, 리소스를 지운다. 이 순서가 방치된 레코드를 노릴 수 있는 틈을 없앤다.
왜 순서가 중요한지는 TTL을 알면 바로 이해된다. DNS 응답에는 유효기간이 붙어 있다. TTL이 300이면 이 답을 5분 동안 재사용해도 좋다는 뜻이고, 그 사이에는 세계 곳곳의 리졸버가 이미 받아둔 답을 계속 쓴다. 레코드를 지워도 5분 동안은 옛날 답이 살아 있는 셈이다.
리소스를 먼저 지우면 그 5분이 통째로 빈틈이 된다. 이름은 아직 플랫폼을 가리키고 있는데 주인은 없는 상태니까. 반대로 DNS를 먼저 지우고 TTL만큼 기다린 뒤 리소스를 지우면, 주인 없는 이름을 가리키는 시점이 아예 생기지 않는다.
에이전트한테 임시 환경을 정리시킬 때 이 순서를 말로 적어주면 대체로 맞게 한다. "DNS 레코드부터 지우고 TTL 지난 뒤에 프로젝트를 지워줘." 그냥 "데모 정리해줘"라고 하면 눈에 보이는 프로젝트부터 지운다. 대시보드에 있는 건 프로젝트고, DNS는 다른 화면에 있기 때문이다.

버킷 이름에서도 같은 일이 벌어진다
AWS가 말한 전역 고유 이름 공간의 위험이 실제로 어떤 모양인지 보여준 실험이 있다.
보안업체 watchTowr가 2025년 2월에 발표한 내용이다. 상용 소프트웨어, 오픈소스 프로젝트, 정부기관, 배포 파이프라인이 쓰다가 버린 S3 버킷 150개를 찾아 원래 이름 그대로 다시 만들었다. 비용은 다 합쳐 400달러쯤 들었다. 두 달 동안 그 버킷들에 도착한 요청이 800만 건을 넘겼다.
요청 내용이 문제였다. 소프트웨어 업데이트, 윈도우와 리눅스와 macOS용 실행 파일, 가상머신 이미지, CloudFormation 템플릿, SSL VPN 서버 설정 파일. 요청을 보낸 쪽에는 여러 나라의 정부기관과 군 네트워크, 포춘 500대 기업, 카드 결제망, 은행, 대학, 보안업체가 섞여 있었다. 버킷을 가져간 쪽이 악의를 가졌다면 그 자리에 조작된 파일을 올려두기만 하면 됐다.
구조는 서브도메인 이야기와 똑같다. 지운 쪽은 정리했다고 생각했고, 그 이름을 부르는 코드는 아무도 정리하지 않았다.
이게 바이브 코딩과 닿는 지점이 여기다. 에이전트가 짜주는 설치 스크립트나 배포 설정에는 외부 주소가 문자열로 박힌 채 들어간다. 버킷 URL, 설치 스크립트 원본 주소, 모델 체크포인트 링크 같은 것들이다. 그 주소를 관리하던 쪽이 어느 날 정리를 하면, 내 스크립트는 없어진 이름으로 계속 요청을 보내고 있고 그 이름은 이미 다른 사람 것이다. 내 코드에는 고칠 게 하나도 없는 상태로 그렇게 된다.
내 도메인에 매달린 레코드 찾기
확인은 DNS 조회 한 번과 요청 한 번이면 된다.
먼저 존에 있는 CNAME 레코드를 전부 꺼내야 한다. Cloudflare나 Route 53 같은 관리 화면에는 레코드 목록을 파일로 내보내는 기능이 있으니 그걸 받아서 CNAME만 보면 된다. dig axfr로 존을 통째로 받아오는 방법은 공개 서버에서 거의 막혀 있으니 기대하지 않는 게 좋다.
목록에서 외부 플랫폼을 가리키는 줄이 후보다. vercel-dns.com, github.io, herokudns.com, azurewebsites.net, s3-website, cloudfront.net 같은 이름이 보이면 거기서 멈춘다.
dig +short demo.myapp.com CNAME
# cname.vercel-dns.com.
curl -sI https://demo.myapp.com | head -3
# HTTP/2 404
# x-vercel-error: DEPLOYMENT_NOT_FOUND
판단 기준은 두 가지다. 지금 그 이름이 내가 관리하는 프로젝트에 등록돼 있는가, 그리고 접속했을 때 플랫폼이 만든 안내 화면이 뜨는가. 대시보드에 그 도메인이 없는데 플랫폼 쪽에서 배포를 찾을 수 없다거나 사이트가 없다는 응답이 온다면, 그 이름으로 트래픽은 도착하는데 받을 주인이 없다는 뜻이다. 공격자들이 대량으로 훑을 때 찾는 것도 정확히 이 응답이다. 이름 풀이 자체가 실패하는 경우도 같이 봐야 한다. 가리키던 대상이 통째로 사라진 상태인데, 그 이름을 다시 만들 수 있는 플랫폼이라면 위험은 같다.
정리는 둘 중 하나다. 그 이름을 계속 쓸 거면 프로젝트를 다시 만들어 도메인을 등록하고, 안 쓸 거면 DNS 레코드를 지운다. OWASP의 권고도 여기서 끝난다. 취약한 레코드를 DNS 존에서 없애고, 주기적으로 다시 확인할 것.
와일드카드 레코드가 있다면 그건 따로 봐야 한다. *.myapp.com을 어딘가로 보내두면 존재한 적도 없는 이름까지 전부 그쪽으로 흘러간다. 레코드를 하나씩 지우는 것으로는 정리가 안 되는 구조다.
쌓이는 속도가 달라졌다
문제 자체는 오래됐다. 새로워진 건 서브도메인이 늘어나는 속도다.
예전에는 스테이징 환경 하나 만드는 데 사람 손이 여러 번 갔다. 지금은 "데모 따로 띄워줘" 한 줄이면 끝난다. 만드는 비용이 0에 가까워지면 만드는 횟수가 늘고, 잊는 횟수도 같이 늘어난다. 발표용 데모, 클라이언트에게 보여줄 임시 버전, 브랜치별 미리보기, A안과 B안. 몇 달 지나면 존에 스무 줄쯤 쌓여 있고 그중 절반은 뭐였는지 기억이 안 난다.
그리고 에이전트는 만들 때는 DNS까지 챙기지만 지울 때는 챙기지 않는다. 만드는 작업은 안 되면 바로 티가 나니까 끝까지 가는데, 지우는 작업에는 어디까지 지워야 끝인지 알려주는 신호가 없다. 프로젝트를 지우면 대시보드에서 사라지니까 다 된 것처럼 보인다.
앞에서 본 세 가지가 결국 한 가지 문제의 다른 얼굴이다. 삭제 순서를 뒤집어야 틈이 안 생긴다는 것, 플랫폼마다 소유권을 확인하는 방식이 다르다는 것, 쿠키와 출처 허용목록이 서브도메인을 이름만 보고 통째로 신뢰한다는 것. 셋 다 "만든 것은 세는데 지운 것은 세지 않는다"는 습관 위에서 성립한다.
그래서 쿠키를 .myapp.com으로 열어둔 프로젝트라면 서브도메인 하나하나가 로그인 세션의 경계선이다. 그 경계선이 지금 몇 개이고 각각 뒤에 누가 있는지는 배포 플랫폼 대시보드에 안 나온다. 대시보드는 살아 있는 것만 보여주고, 죽은 뒤에도 남아 있는 것까지 보여주는 화면은 DNS 존 하나뿐이다.