평문 DNS가 남기는 흔적

전통적인 DNS 질의는 UDP/53 위를 평문으로 흐른다. 사용자가 접속하려는 도메인이 그대로 노출되기 때문에 같은 네트워크의 관찰자나 경로상의 ISP는 어떤 서비스에 접속했는지 손쉽게 수집할 수 있다. HTTPS로 본문을 암호화해도 SNI와 DNS가 평문이면 목적지 도메인은 사실상 공개된다. 사내망이나 VPN 게이트웨이에서 자체 리졸버를 운영할 때, 상위 리졸버로 나가는 구간이 평문이면 이 노출 지점을 그대로 넘겨받는 셈이다.

DNS over TLS(DoT, RFC 7858)는 이 구간을 TLS로 감싸 TCP/853에서 질의·응답을 암호화한다. 상위 리졸버와의 트래픽을 도청·변조로부터 보호하고, 인증서 검증으로 상대 리졸버가 정말 의도한 서버인지 확인한다.

DoT와 DoH 중 무엇을 쓸까

암호화 목적은 같지만 운영 관점의 차이가 크다.

항목DoTDoH
포트853 (전용)443 (HTTPS와 혼재)
차단 난이도포트 기반으로 쉬움일반 HTTPS와 구분 어려움
운영 가시성DNS 트래픽 식별 쉬움애플리케이션이 우회 가능
인프라 적합성중앙 리졸버 강제에 유리브라우저 개별 설정에 유리

사내 정책으로 리졸버를 강제하고 로그·감사를 남겨야 하는 인프라라면 포트가 분리된 DoT가 관리하기 쉽다. 반대로 개별 단말이 검열망을 우회해야 하는 상황이라면 DoH가 유리하다.

아키텍처 개요

구성은 단순하다. 사내 클라이언트는 내부 리졸버(예: 10.0.0.53)만 바라보고, 내부 리졸버는 자신을 재귀 캐시로 두면서 상위 공용 리졸버로 나가는 구간에만 DoT를 적용한다. 즉 클라이언트 → (평문, 내부망) → Unbound → (DoT, 인터넷) → 상위 리졸버 형태다. 내부망 구간은 신뢰 경계 안이므로 평문을 유지해 성능을 확보하고, 외부로 나가는 노출 구간만 암호화한다.

Unbound으로 DoT 포워딩 구성

Unbound은 재귀 캐시와 DoT 포워딩을 함께 처리할 수 있다. 아래 설정은 로컬 캐시로 동작하면서 상위 리졸버로는 TLS로만 질의한다.

# /etc/unbound/unbound.conf.d/dot.conf
server:
    interface: 10.0.0.53
    interface: 127.0.0.1
    access-control: 10.0.0.0/8 allow
    access-control: 127.0.0.0/8 allow
    # 캐시 최소 TTL로 반복 질의 절감
    cache-min-ttl: 60
    prefetch: yes
    # DoT 인증서 신뢰 앵커
    tls-cert-bundle: "/etc/ssl/certs/ca-certificates.crt"

forward-zone:
    name: "."
    forward-tls-upstream: yes
    # 반드시 인증서 검증용 이름(@853#hostname)을 명시
    forward-addr: 9.9.9.9@853#dns.quad9.net
    forward-addr: 1.1.1.1@853#cloudflare-dns.com

핵심은 forward-tls-upstream: yes와 주소 뒤의 #hostname이다. 호스트명을 빼면 TLS는 맺어지지만 인증서의 이름 검증을 건너뛰어 중간자 공격에 노출된다. 반드시 명시한다.

클라이언트를 내부 리졸버로 강제

단말이 임의의 공용 리졸버를 직접 쓰지 못하도록 리졸버를 고정한다. systemd-resolved를 쓰는 리눅스라면 다음과 같이 지정한다.

# /etc/systemd/resolved.conf
[Resolve]
DNS=10.0.0.53
Domains=~.
# 내부 리졸버가 이미 DoT를 처리하므로 여기선 opportunistic로 충분
DNSOverTLS=opportunistic
# 평문 폴백 차단
FallbackDNS=

여기에 더해 방화벽에서 내부 리졸버 IP를 제외한 목적지의 853/53 아웃바운드를 차단하면 우회를 막을 수 있다.

# 내부 리졸버 외 외부 DNS 차단 (nftables 예시)
nft add rule inet filter output ip daddr != 10.0.0.53 \
  udp dport 53 drop
nft add rule inet filter output ip daddr != 10.0.0.53 \
  tcp dport { 53, 853 } drop

동작 검증

구성 후에는 실제로 암호화 경로로 나가는지 확인한다. kdig로 DoT 질의를 직접 던져보고, 상위 구간을 패킷으로 확인한다.

# DoT로 직접 질의 (TLS 핸드셰이크·인증서까지 확인)
kdig -d @9.9.9.9 +tls-ca +tls-host=dns.quad9.net example.com

# 내부 리졸버가 상위로 나갈 때 853만 쓰는지 확인
sudo tcpdump -ni eth0 'tcp port 853 or udp port 53'

tcpdump 결과에서 외부로 향하는 udp/53이 보이지 않고 tcp/853만 나타나야 정상이다. 평문 53이 섞여 나온다면 폴백 경로가 열려 있는 것이다.

운영 시 주의점

첫째, DoT는 TCP 기반이라 커넥션 재사용이 중요하다. Unbound이 상위와 연결을 유지하도록 캐시·prefetch를 켜 핸드셰이크 비용을 줄인다. 둘째, 상위 리졸버가 853을 막거나 장애가 나면 전면 장애로 번진다. 서로 다른 사업자의 리졸버를 최소 둘 이상 등록해 이중화한다. 셋째, 인증서 신뢰 앵커(tls-cert-bundle) 경로와 시스템 시계를 점검한다. 시각이 틀어지면 인증서 검증이 실패해 해석이 통째로 멈춘다. 마지막으로 암호화는 도청을 막을 뿐 질의 내용 자체는 상위 리졸버가 본다. 로그 최소화나 자체 재귀 해석 여부는 별도 정책으로 다뤄야 한다.