2024년 2월 13일, 한 연구팀이 공개한 사실은 이랬다. DNS 패킷 하나 — 약 100바이트 — 로 DNSSEC 검증 리졸버를 최대 열여섯 시간 동안 멈춰 세울 수 있다. 크래시가 아니다. 멈춰 세운다: CPU 코어 하나를 붙들고, 공격자가 건넨 계산을 갈아대는 동안 다른 모두의 질의에 답하기를 그친다. 그들은 이걸 KeyTrap이라 불렀다.
불편한 지점은 심각도가 아니다 — 심각도는 진짜였다. 모든 주요 검증 리졸버가 영향을 받았고, Google Public DNS와 Cloudflare 같은 공용 거인들도 범위 안이었다. 불편한 지점은 결함이 살던 자리다. KeyTrap은 2019년에 누가 잘못 친 버퍼 오버플로가 아니었다. 그건 요구사항 — “MUST” — 이었고, DNSSEC 명세 안에, 훤히 보이는 자리에, 대략 2000년부터 앉아 있었다. ATHENE의 연구진(Elias Heftrig, Haya Schulmann, Niklas Vogel, Michael Waidner)이 찾은 건 코드의 실수가 아니었다. 코드가 표준이 시킨 그대로를 하고 있었다는 사실이었다.
명백히 옳아 보이던 규칙
DNSSEC은 DNS 레코드에 서명해, 리졸버가 전송 중 변조 여부를 검증하게 한다. 서명을 확인하려면 리졸버는 맞는 공개 키가 필요하다. 문제는 “맞는”이 늘 일대일이 아니라는 것이다.
각 서명(RRSIG 레코드)은 key tag를 지닌다 — 키에 대한 일종의 체크섬인 16비트 값으로, “이 키를 시도하라”는 빠른 힌트다. 리졸버는 key tag·알고리즘·서명자 이름이 서명과 맞아떨어지는 DNSKEY 레코드를 골라 시도한다. RFC 4035 §5.3.1이 못박은 함정이 여기 있다: 리졸버는 “서명이 검증되거나 시도할 매칭 공개 키가 소진될 때까지 각 매칭 DNSKEY RR을 반드시(MUST) 시도해야 한다.” 2013년 RFC 6840은 같은 관대함을 서명에도 확장했다 — 서명도 전부 시도하라고.
대낮에 읽으면 명백히 옳은 동작이다. key tag는 식별자가 아니라 힌트일 뿐이고, 서로 다른 두 키가 하나를 공유할 수 있다. 키 롤오버 중엔 존이 여러 키를 동시에 정당하게 게시한다. 리졸버가 맞아 보이지만 검증에 실패한 첫 키에서 포기해버린다면, tag가 충돌할 때마다 완벽히 유효한 서명된 도메인을 부당하게 거부하게 된다. 그래서 명세는 말한다: 관대하라. 포기하지 마라. 하나가 될 때까지 모든 키를 모든 서명에 시도하라. 게을렀다는 이유로 진짜 서명이 실패하는 일은 절대 없게 하라.
바로 그 본능 — 미심쩍으면 더 열심히 시도하라 — 이 무기가 됐다.
100바이트에서 나오는 이차 노동
key tag 충돌은 만들기 값싸다. 자기 존은 자기가 통제하니, 여러 키가 한 tag를 공유할 때까지 키를 생성하고, 전부 그 tag를 가리키는 서명을 게시하면 된다. 이제 수백 개의 충돌 키와 수백 개의 서명이 서로를 참조하는 존을 상상해보라.
명세를 따르는 리졸버는 모든 키를 모든 서명에 시도해야 한다. 이건 선형 노동이 아니라 곱이다. n개 키 곱하기 m개 서명은 이차이고, 모든 시도 하나하나가 값비싼 종류의 공개 키 암호 연산이다. 공격자가 그렇게 설계했으니 전부 실패하고, 리졸버는 성공으로 지름길을 탈 기회조차 없다. 마지막 조합까지, 충실하게, 그저 계속 시도할 뿐이다.
공격자는 무언가를 홍수처럼 퍼부을 필요조차 없다. 도메인을 등록하고, 이 악성 서명 존을 세우고, 피해 리졸버가 그 안의 이름 하나를 조회하게 만들면 된다. 리졸버는 답을 가져와, 검증하려다, 함정으로 걸어 들어간다. 질의 하나. 응답 하나. 연구진은 구현에 따라 몇 분에서 열여섯 시간에 이르는 멈춤을 측정했다 — 공유 리졸버가 그 뒤의 다른 모든 사용자에게 서비스를 멈추기에 충분한 시간이다. 알고리즘 복잡도 공격의 교과서적 형태다: 작은 입력, 파국적 노동, 그리고 CPU를 자발적으로 갖다 바치는 피해자.
그리고 인터넷 사용자의 3분의 1에 가까운 이들이 DNSSEC 검증 리졸버 뒤에 앉아 있으니, “리졸버 하나가 답을 멈춘다”는 결코 작은 폭발 반경이 아니다.
수정은 모두가 규칙을 어기기로 합의한 것
KeyTrap이 버그가 아니라 설계 문제였다는 증거가 여기 있다. 코드 한 줄을 고쳐서는 패치할 수 없다. 틀린 줄이 없었으니까. 그 동작은 RFC대로 옳았다. 그래서 벤더들은 실제로 뭘 했나?
노동에 상한을 걸었다. BIND, Unbound, Knot, PowerDNS — 각자 예산을 추가했다. 단일 응답이 소비할 수 있는 검증 노동의 한도를. 예산을 넘으면 리졸버는 멈추고, SERVFAIL을 반환하고, 넘어간다. 다시 말해, 모든 구현이 독립적으로 “서명이 검증되거나 … 시도할 공개 키가 소진될 때까지 각 매칭 DNSKEY RR을 반드시 시도해야 한다”를 따르지 않기로 결정했다. 표준은 최대한 관대하라고 했다. 수정은 어떤 문턱을 넘으면 의도적으로 비관대해지고, 병적이지만-기술적으로-유효한 어떤 존에서는 이제 엄밀히는 검증해야 할 것을 거부하게 되리라는 걸 받아들이는 것이다.
옳은 판단이다. 동시에 자백이기도 하다. 명세가 말하던 “옳음” — 서명된 레코드를 절대 부당하게 거부하지 않기 — 이 적 앞에서는 감당 불가로 드러났고, 업계는 조용히 옳음을 “스스로를 다치게 하기 전에 포기하기”로 재정의했다.
KeyTrap이 가르쳐야 할 것
교훈은 “DNSSEC은 유독 망가졌다”가 아니다 — 물론 이게 20년 걸려 소수 채택에 이르렀고 여전히 만료된 서명과 망친 롤오버로 사람을 넘어뜨리는 바로 그 프로토콜이라는 지적은 정당하지만. 교훈은 더 일반적이고, 신뢰할 수 없는 입력을 파싱하는 모든 것에 적용된다.
명세가 하나가 될 때까지 모든 조합을 시도하라고 말하고 그 조합이 공격자 통제 하에 있을 때마다, 당신은 지연 신관이 달린 복잡도 폭탄을 쥔 것이다. 이 실패 모드는 당신이 돌릴 모든 테스트에서 보이지 않는다. 테스트 데이터가 선량하니까 — 키 몇 개, 서명 두엇, 전부 마이크로초 안에 해석된다. 누군가 의도적으로 입력을 구성할 때에만 터진다. “받는 것에 관대하라”는 상호운용성엔 좋은 조언이고 가용성엔 장전된 총이며, 그 맞교환의 두 반쪽은 적이 방에 들어오기 전엔 같은 방에 나타나지 않는다.
KeyTrap은 20년 넘게 표준 안에 앉아, 지구상 모든 DNSSEC 리졸버가 충실히 구현했고, RFC를 쓰는 바로 그 종류의 꼼꼼한 사람들이 검토했다. 아무도 알아채지 못했다. 숨어 있지 않았으니까. 그건 책임감 있는 일처럼 읽혔다. 간직할 만한 지점이 바로 그거다: 가장 위험한 결함은 실수처럼 보이는 것이 아니다. 성실함처럼 보이는 것이다.