DNSSEC을 끄라고 해서 알아봤다: DNS에 서명을 붙인다는 것
도메인을 다른 계정으로 옮기려는데 조건 목록에 이런 게 있었다.
DNSSEC must be turned off.
끄라니 껐는데, 껐다는 건 켜면 뭔가 좋은 게 있다는 뜻이다. 그게 뭔지, 그리고 왜 하필 옮길 때 꺼야 하는지 알아봤다.
알아보다가 예상 못 한 걸 하나 발견했는데, 그건 뒤에 적는다.
DNS에는 신원 증명이 없다
DNS의 동작은 단순하다. "example.com의 IP가 뭐야?"라고 묻고, 누군가 "1.2.3.4야"라고 답한다. 그러면 브라우저는 그 IP로 접속한다.
여기서 빠진 게 있다. 그 답이 진짜 권한 있는 서버에서 왔는지 확인할 방법이 없다.
전통적인 DNS는 대부분 UDP로 오간다. 연결을 맺지 않고 패킷 하나 던지고 하나 받는 구조다. 응답에 서명도, 인증서도 없다. 있는 거라곤 요청할 때 쓴 16비트 짜리 트랜잭션 ID와 포트 번호 정도다.
즉 먼저 도착한 그럴듯한 답이 이긴다. 진짜 서버의 응답보다 공격자의 위조 응답이 먼저 도착하면, 리졸버는 그걸 정답으로 받아들인다.
캐시 포이즈닝
문제는 이게 한 명에게만 영향을 주는 게 아니라는 점이다.
리졸버(통신사 DNS, 회사 DNS 서버 등)는 성능을 위해 응답을 캐시한다. 공격자가 위조 응답을 하나 심는 데 성공하면, 그 캐시를 쓰는 모든 사용자가 TTL이 만료될 때까지 가짜 IP로 간다.
2008년에 댄 카민스키가 이 공격을 실용적인 수준으로 끌어올릴 수 있다는 걸 보였다. 그전까지는 "이론상 가능하지만 확률이 낮다" 정도로 여겨졌는데, 질의를 대량으로 유도하면 현실적인 시간 안에 성공한다는 게 드러났다. 업계가 급하게 포트 랜덤화 같은 완화책을 넣었지만, 그건 확률을 낮추는 것이지 근본 해결이 아니다.
근본 해결은 하나뿐이다. 응답에 서명을 붙이는 것.
DNSSEC: 서명, 그리고 사슬
DNSSEC은 DNS 응답에 전자서명을 붙인다. 리졸버는 서명을 검증해서 "이 답은 진짜 그 도메인의 권한 서버가 만든 것이고, 오는 길에 변조되지 않았다"를 확인한다.
그런데 여기서 바로 다음 질문이 나온다.
서명을 검증할 공개키는 어디서 가져오나? 그 공개키도 DNS로 받아올 텐데, 그건 위조 안 되나?
이게 핵심이다. 그래서 DNSSEC은 부모가 자식을 보증하는 사슬을 만든다.
루트 (.)
│ 이 키가 .dev의 진짜 키다 (DS)
.dev
│ 이 키가 example.dev의 진짜 키다 (DS)
example.dev
│ 이 응답은 내 키로 서명했다 (RRSIG)
www.example.dev → 1.2.3.4각 단계에서 부모 존이 자식 존의 키 지문을 보증한다. 그 부모는 다시 자기 부모가 보증한다. 이걸 계속 올라가면 루트에 도달한다.
루트의 키는 누가 보증하나? 아무도 안 한다. 그건 리졸버 소프트웨어에 미리 박아둔다. 브라우저에 루트 CA 인증서가 내장된 것과 같은 구조다. 이 루트 키는 여러 참관인이 지켜보는 가운데 열리는 의식(Root KSK Ceremony)을 통해 관리된다. 검색해보면 영상도 있는데, 생각보다 볼 만하다.
레코드 세 가지
실제로 오가는 건 이 셋이다.
| 레코드 | 어디 있나 | 역할 |
|---|---|---|
| DNSKEY | 자기 존 | 서명 검증용 공개키 |
| RRSIG | 자기 존 | 각 레코드셋에 붙는 서명 |
| DS | 부모 존 | 자식 DNSKEY의 해시(지문). 부모의 보증서 |
여기서 제일 중요한 건 DS만 부모 쪽에 있다는 것이다. 이 비대칭이 나중에 사고의 원인이 된다.
직접 확인해보기
말로만 보면 감이 안 오니 실제로 조회해봤다.
서명된 도메인 (cloudflare.com)
$ dig +short DS cloudflare.com
2371 13 2 32996839A6D808AFE3EB4A795A0E6A7A39A76FC52FF228B22B76F6D63826F2B9.com 레지스트리가 "cloudflare.com의 키 지문은 이거다"라고 보증하고 있다. 숫자들은 순서대로 키 태그, 알고리즘 번호(13은 ECDSA P-256), 해시 타입(2는 SHA-256), 그리고 해시값이다.
서명 안 된 도메인 (내가 방금 산 duolabs.dev)
$ dig +short DS duolabs.dev
(아무것도 없음)DS가 없으면 그 도메인은 DNSSEC을 안 쓰는 것이다. 검증할 게 없으니 리졸버는 그냥 예전처럼 동작한다.
검증 성공 표시: ad 플래그
$ dig @1.1.1.1 +dnssec cloudflare.com A | grep flags
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 3, ...ad는 Authenticated Data의 약자다. 이게 붙어 있으면 리졸버가 서명을 실제로 검증했고 통과했다는 뜻이다.
서명이 깨진 도메인
dnssec-failed.org는 일부러 서명을 틀리게 해둔 테스트용 도메인이다.
$ dig @1.1.1.1 dnssec-failed.org A
;; ->>HEADER<<- opcode: QUERY, status: SERVFAILSERVFAIL. 답을 아예 안 준다. 여기가 중요한 지점인데, 뒤에서 다시 다룬다.
그런데 내 인터넷은 검증을 안 하고 있었다
위 명령들에 @1.1.1.1을 붙인 이유가 있다. 그거 없이 그냥 조회했더니 결과가 달랐다.
# 내 기본 리졸버로 조회
$ dig +short dnssec-failed.org A
96.99.227.255 ← 서명이 깨졌는데도 답을 준다
$ dig +dnssec cloudflare.com A | grep flags
;; flags: qr rd ra; ... ← ad 플래그가 없다내가 쓰던 리졸버는 DNSSEC 검증을 하지 않고 있었다. 서명이 명백히 깨진 도메인도 그냥 통과시킨다.
이게 DNSSEC의 현실적인 한계다. 도메인 소유자가 아무리 열심히 서명을 붙여도, 사용자 쪽 리졸버가 검증하지 않으면 아무 의미가 없다. 서명은 붙어 있지만 아무도 확인하지 않는 상태가 된다.
공개 리졸버 중에서는 1.1.1.1(Cloudflare)과 8.8.8.8(Google)이 검증한다. 통신사나 공유기 기본 DNS는 제각각이다. 한번 확인해볼 만하다.
dig +short dnssec-failed.org A
# 답이 나오면 → 검증 안 함
# 비어 있고 SERVFAIL이면 → 검증 함참고로 +cd 옵션(Checking Disabled)을 주면 검증하는 리졸버도 검증을 건너뛴다. 디버깅할 때 "이게 DNSSEC 때문인가?"를 가르는 데 쓴다.
$ dig @1.1.1.1 +cd +short dnssec-failed.org A
96.99.227.255 ← 검증을 끄니 답이 나온다오해: DNSSEC은 암호화가 아니다
이름 때문에 헷갈리기 쉬운데, DNSSEC은 DNS 트래픽을 암호화하지 않는다.
- DNSSEC이 보장하는 것: 무결성(변조되지 않았다)과 진정성(진짜 그 도메인이 만든 답이다)
- DNSSEC이 보장하지 않는 것: 기밀성(누가 무엇을 조회했는지)
서명이 붙어도 질의와 응답은 여전히 평문으로 오간다. 중간에 있는 사람이 "이 사람이 방금 어떤 사이트를 조회했다"를 다 볼 수 있다. 그걸 가리는 건 DoH(DNS over HTTPS)나 DoT(DNS over TLS)의 몫이고, 이 둘은 DNSSEC과 별개다. 목적이 다르니 같이 쓰면 된다.
실무에서 제일 위험한 순간: 옮길 때
이제 처음 질문으로 돌아온다. 왜 계정을 옮길 때 DNSSEC을 꺼야 하나.
앞에서 짚은 비대칭 때문이다.
DNSKEY, RRSIG → 내 존 (DNS 제공자가 관리)
DS → 부모 존 (레지스트리가 관리)DNS 제공자를 바꾸거나 네임서버가 바뀌면, 새 제공자는 새로운 키로 서명한다. 그런데 부모 레지스트리에 등록된 DS는 여전히 옛날 키의 지문을 가리킨다.
리졸버 입장에서는 이렇게 보인다.
부모가 보증한 키는 A인데, 실제 응답은 B라는 키로 서명되어 있다. 사슬이 끊겼다. 공격일 수도 있다. 차단.
그 결과가 SERVFAIL이다.
SERVFAIL이 NXDOMAIN보다 나쁜 이유
NXDOMAIN은 "그런 도메인 없음"이다. SERVFAIL은 "답을 줄 수 없음"이다.
검증하는 리졸버를 쓰는 사용자에게 그 도메인은 완전히 사라진다. 웹도, 메일도, API도 전부 죽는다. 게다가 증상이 지역과 통신사에 따라 갈린다. 검증하는 리졸버를 쓰는 사람만 안 되고, 안 하는 사람은 멀쩡하다. "저는 되는데요?"가 오가면서 원인 파악이 늦어진다.
그래서 DNS 제공자를 옮길 때의 안전한 순서는 이렇다.
- DS 레코드를 먼저 제거한다 (DNSSEC 끄기)
- 부모 존에서 DS가 완전히 사라질 때까지 기다린다 (TTL 만큼)
- 그다음에 네임서버를 옮긴다
- 이전이 끝나면 새 제공자에서 DNSSEC을 다시 켜고, 새 DS를 등록한다
Cloudflare가 계정 이동 조건에 "DNSSEC 끄기"를 넣어둔 건 이 사고를 막기 위해서다. 계정이 바뀌면 배정되는 네임서버 쌍도 바뀌기 때문에 같은 문제가 생긴다.
그래서 켜야 하나
켜는 게 맞다. 다만 순서를 지켜야 한다.
켜면 좋은 경우:
- 메일을 받는 도메인. MX 레코드가 위조되면 메일이 통째로 다른 서버로 간다. 웹은 HTTPS 인증서가 2차 방어선이 되어주지만, 메일에는 그런 게 없다
- 로그인이나 결제가 붙은 서비스
미루는 게 나은 경우:
- 가까운 시일 안에 DNS 제공자나 계정을 옮길 계획이 있을 때. 지금 내 상황이 정확히 이거다. 이관을 먼저 끝내고 켜는 게 맞다
켜는 방법 자체는 요즘 어렵지 않다. 등록기관과 DNS 제공자가 같으면(예: Cloudflare Registrar + Cloudflare DNS) 버튼 하나로 DS 등록까지 자동으로 처리된다. 둘이 다르면 DNS 제공자가 뱉어준 DS 값을 등록기관 화면에 직접 붙여넣어야 한다.
정리
- DNS는 원래 응답의 출처를 확인할 방법이 없다. 먼저 도착한 그럴듯한 답이 이긴다
- DNSSEC은 응답에 서명을 붙이고, 부모가 자식의 키를 보증하는 사슬로 루트까지 연결한다
- 레코드는 셋.
DNSKEY(공개키),RRSIG(서명),DS(부모에 있는 보증서) - 암호화가 아니다. 무결성과 진정성만 보장한다. 도청 방지는 DoH/DoT의 몫
- DS만 부모 쪽에 있다. 이 비대칭 때문에 이전할 때 사슬이 끊기고, 그 결과는
SERVFAIL, 즉 도메인 전면 마비다 - 옮길 계획이 있으면 끄고 → 옮기고 → 다시 켠다
마지막으로, 제일 인상적이었던 건 내 리졸버가 검증을 안 하고 있었다는 사실이다. 명령어 한 줄이면 확인된다.
dig +short dnssec-failed.org A여기서 IP가 나온다면, 당신의 DNS도 서명을 확인하지 않고 있다.
함께 읽기
- 도메인 하나 옮기면서 배운 것들: 이관, 메일, 그리고 터널 이중화의 착각새로 산 도메인을 세팅할 일이 생겼다. 이왕 하는 김에 계정부터 완전히 새로 만들어서 처음부터 끝까지 직접 해보기로 했다. 기존 설정을 복사해오는 대신 하나씩 이해하면서 가는 쪽으로.
- Cloudflare Tunnel로 Vercel을 연결하면 안 되는 이유Vercel 배포는 성공했습니다. 프로젝트명.vercel.app 으로 접속하면 잘 뜹니다.
- 배포하면 CSS가 404: stale-while-revalidate 함정"모바일에서 사이트가 이상해요."
- 캐시 퍼지 순서: 오리진을 먼저 비워야 하는 이유엣지 캐시를 켜고 나니 대가가 하나 생겼습니다. 글을 고쳐 발행해도 최대 5분 동안 옛 내용이 나갔습니다. 캐시가 없던 때는 즉시 반영되던 일이라 후퇴처럼 느껴졌습니다.
- Cloudflare가 캐시하지 않는 3가지 이유: Vary, RSC, 언어 감지사이트를 Cloudflare 뒤에 두고 캐시 헤더를 제대로 붙였는데도 CF-Cache-Status가 계속 DYNAMIC으로 나오는 일을 겪었습니다. 한 번이 아니라 세 번이었고, 매번 다른 문제처럼 보였습니다.