DNS 쿠키: 아무도 켜지 않은 스푸핑 방어책

10여 년 전 DNS는 위조된 응답과 반사 공격을 막을 값싼 방어책을 얻었다. 당신의 리졸버는 거의 확실히 이걸 지원한다. 그리고 거의 확실히 요구하지는 않는다 — '지원'과 '요구' 사이의 그 틈이 이야기의 전부다.

2008년 여름, 댄 카민스키는 많은 사람의 휴가를 취소시켰다. 그가 찾아낸 공격은 오프패스 공격자 — 당신의 트래픽을 볼 수는 없고 그저 추측만 하는 자 — 가 재귀 리졸버의 캐시를 오염시켜 도메인 하나를 통째로 딴 데로 돌릴 수 있게 했다. 뒤이은 소동이 낳은 해결책은 모두가 기억하는 그것이다: 소스 포트 무작위화. DNS 질의는 이미 공격자가 맞혀야 할 16비트 트랜잭션 ID를 담고 있었는데, 이제 무작위 소스 포트에서도 나가게 되어 위조자는 ID 포트를 함께 맞혀야 했고, 수천 번의 추측이 수십억 번으로 불어났다. RFC 5452가 2009년에 이를 명문화했고, 리졸버들이 배포했고, 당장의 위기는 지나갔다.

하지만 소스 포트를 무작위화하는 건 언제나 해결책으로 분장한 임시방편이었다. 아무것도 인증하지 않는다. 그저 복권을 맞히기 어렵게 만들 뿐이고, 충분히 빠른 공격자가 충분히 굵은 회선을 쥐고 있으면 어렵다는 건 불가능하다는 것과 같지 않다. 더 나은 아이디어가 바로 거기 있었다 — 값싸고, 상태 없고, 범용적인 — 그리고 2016년 마침내 표준이 됐다. 거의 아무도 켜지 않았다.

DNS 쿠키란 실제로 무엇인가

DNS 쿠키는 브라우저 쿠키가 아니고, 추적과도 아무 상관이 없다. 질의와 응답에 얹혀 다니는 EDNS 옵션 — 옵션 코드 10 — 이다. 2016년에 나온 RFC 7873은 이를 담백하게 “오프패스 공격자에 의한 여러 서비스 거부·증폭·위조·캐시 포이즈닝 공격에 대해 DNS 서버와 클라이언트에 제한적 보호를 제공하는 경량 DNS 트랜잭션 보안 메커니즘”이라고 설명한다.

그 문장에서 일하는 단어는 경량이다. 메커니즘 전체가 작은 불투명 바이트열 두 개다:

  • 클라이언트 쿠키 — 클라이언트가 골라 보내는 8바이트. 서버가 그대로 되돌려준다.
  • 서버 쿠키 — 서버가 생성해 클라이언트에게 건네는 8~32바이트. 클라이언트가 저장했다가 다음 질의에 실어 보낸다.

그게 전부다. 배포할 공유 키도, 인증서도, 맞춰야 할 시계도 없다. 그리고 이 미니멀함이 존재 이유의 전부다. 우리에겐 이미 DNS 트랜잭션을 암호학적으로 인증하는 방법 — TSIG — 이 있었지만, 바로 그것이 양쪽에 공유 비밀 키를 설정해야 하기 때문에 모르는 상대 사이에서는 거의 아무도 돌리지 않는다. 쿠키는 키 관리 프로젝트 없이 기본값으로 켜둘 수 있는 물건이 되도록 설계됐다. 금고가 아니라 과속방지턱. 하지만 정확히 옳은 자리에 놓인 과속방지턱이다.

값싼 바이트열 두 개가 서로 다른 두 공격을 막는 법

영리한 대목은 클라이언트 쿠키와 서버 쿠키가 서로 다른 것을 방어한다는 점이다.

클라이언트 쿠키는 질문하는 쪽을 보호한다. 리졸버가 클라이언트 쿠키를 담아 질의를 보내면, 정상 서버는 응답에 그걸 그대로 되돌려준다. 응답을 위조하는 오프패스 공격자는 그 쿠키를 볼 수 없다 — 그들이 추측하고 있는 회선 위에 있지 않다 — 그래서 위조본은 틀린 쿠키를 달고 오거나 아예 없이 오고, 리졸버는 그걸 버린다. 무작위 소스 포트와 같은 발상인데, 이제 오프패스 공격자는 매 질의마다 바뀌는 64비트를 추가로 맞혀야 한다. 그건 “더 어려워졌다”가 아니다. 끝난 것이다.

서버 쿠키는 서버를 보호하고, 이쪽이 인터넷의 더 큰 문제와 맞닿은 절반이다. UDP 위의 DNS는 인터넷이 가장 아끼는 증폭기다: 작은 질의에 위조된 소스 주소를 달아 보내면, 서버는 당신이 지목한 피해자에게 훨씬 큰 답을 쏜다. 서버 쿠키가 이 사기를 깬다. 서버는 낯선 클라이언트에게 UDP로 답하기 전에 유효한 서버 쿠키를 요구하도록 설정될 수 있다. 처음 온 클라이언트는 작은 응답 — BADCOOKIE, 확장 RCODE 23 — 을 받는데, 여기엔 새 쿠키와 그걸 가지고 다시 오라는 지시가 담겨 있다. 진짜 클라이언트는 그렇게 한다. 위조된 소스 주소는 그럴 수 없다. BADCOOKIE 응답이 공격자가 아니라 위조된 피해자에게 가기 때문이고, 핸드셰이크는 끝내 완성되지 않는다. 완성된 핸드셰이크가 없으면 증폭된 답도 없다. 열린 반사기를, 자신의 존재를 증명할 수 있는 클라이언트에게만 증폭해 주는 것으로 바꿔 놓은 셈이다.

‘지원’과 ‘요구’ 사이의 틈

바로 여기서 조용히 어긋난다.

BIND — 가장 널리 배포된 권한·재귀 서버 — 는 2016년 9.11 버전에서 DNS 쿠키를 기본값으로 켰다. 기본 상태에서 자기 질의에 쿠키를 실어 보내고, 받은 질의에 쿠키로 답한다. 현대적인 리졸버를 돌리고 있다면 당신은 지금 이 순간 거의 확실히 이 프로토콜을 말하고 있다.

하지만 쿠키를 보내고 답하는 것은 쿠키를 요구하는 것과 같지 않고, 보호는 요구하는 쪽에 있다. BIND의 require-server-cookie — “유효한 쿠키가 없으면 UDP 답도 없다”고 말하는, 실제로 반사를 차단하는 그 스위치 — 는 기본값이 no다. 기본 자세는 쿠키를 받아들이되 결코 고집하지 않는 것이다. 그리고 받아들이지만 결코 요구하지 않는 쿠키는 연극이다: 회선 위 바이트 비용은 치렀는데 방어는 거의 사지 못했다. 공격자는 그냥 쿠키를 빼고 보내고, 당신은 어차피 답하기 때문이다.

이 대목이 정말로 답답하다. 이 메커니즘은 초안에 멈춰 있지 않다. 브라우저 벤더나 플래그 데이를 기다리지도 않는다. 배포됐고, 켜져 있고, 수백만 대의 서버 사이 회선 위에 올라 있다 — 그런데 상호운용 골칫거리 비용은 치르면서 보안은 가장 적게 내주는 바로 그 모드로 공회전하고 있다.

왜 공회전했는가

솔직한 답은 순전한 태만이 아니다. 여러 해 동안 쿠키에는 운영자가 강제를 꺼둘 정당한 이유가 되어 준 진짜 상호운용 문제가 있었다.

서버 쿠키는 비밀에서 계산된다. 단일 서버에서는 괜찮다. 하지만 진지한 DNS는 대부분 애니캐스트나 로드밸런서 뒤 — 하나의 IP 주소를 위해 답하는 수십 대의 머신 — 에 산다. 각 머신이 자기만의 비밀로 서버 쿠키를 계산하면, 머신 A에서 쿠키를 받아 머신 B로 돌아온 클라이언트가 거부당하고, 강제는 당신이 막으려던 그 공격자로 당신의 인프라 자신을 바꿔 놓는다. 2016년 원 규격은 서버 쿠키의 구성 방식을 명시하지 않고 남겨 두었고, 그래서 구현들이 갈라졌고 클러스터는 합의할 수 없었다.

RFC 9018이 2021년에 이를 고쳤다. 상호운용 가능한 서버 쿠키를 못박은 것이다: 클라이언트 주소와 공유 비밀에 SipHash-2-4를 적용하는 정해진 구성으로, 클러스터의 모든 머신이 같은 쿠키를 계산하고 클라이언트가 그 사이를 자유로이 오갈 수 있게 했다. 그것으로 마지막 남은 좋은 핑계가 사라졌다. 그러나 그것은 원본보다 5년 늦게, DNS 쿠키를 이미 “지원됨, 무시”로 분류해 둔 인터넷에 도착했고, 기본값은 끈질기다. 기술적으로 이미 켜져 있는 기능을 켜자고 아무도 점검 창을 잡지 않는다.

배포됐고, 존재하고, 공회전 중

나는 DNS 보안 기능을 실제로 어떤 운명을 맞았는가로 머릿속에 분류해 둔다. DNSSEC은 배포됐지만 대체로 중요해지지 못했다 — 20여 년이 지나도록 대부분의 존은 여전히 서명되지 않았는데, 아무도 원하지 않는 키 관리 약속을 요구했기 때문이다. QNAME 최소화는 배포됐고 실제로 중요해졌다 — 남에게 아무 비용도 지우지 않는 리졸버 쪽 변경이라, 어디에나 있을 때까지 조용히 퍼졌다. DNS 쿠키는 세 번째, 더 이상한 범주다: 배포됐고, 존재하고, 수백만 대의 서버에서 돌아가는데, “존재”를 “보호”로 바꾸는 스위치가 기본값으로 꺼져 있어 제 일의 일부만 하고 있다.

해결책은 이미 쿠키를 지원하는 권한 서버에서 설정 한 줄이다 — require-server-cookie를 켜고, 이를 못 견디는 소수의 레거시 클라이언트를 지켜보면, 당신의 리졸버를 스푸핑하는 비용을 의미 있게 올리고 당신의 서버를 전 세계 증폭 풀에서 자원해서 빼낸 것이다. DNS에서 가용한 보호 대비 노력 비율이 가장 높은 축에 드는데, 지금 인터넷 인증서의 절반이 발급되기도 전부터 켜졌지만-장전되지 않은 채 선반에 놓여 있었다. 켜라.

토론 참여

← 블로그로 돌아가기