1996년 9월, 뉴욕의 Panix라는 ISP가 메일을 배달하지 못하게 됐다. 서버가 죽거나 디스크가 찬 게 아니라, 누군가 커넥션이 되지 않는 커넥션 요청을 홍수처럼 보내고 있어서였다. 공격은 값쌌고, 추적이 어려웠고, 마땅한 방어가 없었다. 그런데 며칠 만에 Daniel Bernstein과 Eric Schenk가 방어법을 하나 만들어냈다. 그 방법은 지금도 당신이 만져 본 모든 리눅스 머신에 기본으로 켜진 채 돌고 있다. 이름은 SYN 쿠키. 흥미로운 건 이게 작동한다는 사실이 아니라, 작동하기 위해 포기한 이상한 것이다.
지금까지 몇 편에 걸쳐, 서버가 감당 못 할 것을 붙들게 만들어 이기는 공격들을 다뤘다. Slowloris는 끝나지 않는 요청에 worker를 묶어 둔다. Rapid Reset은 클라이언트가 이미 취소한 스트림을 위해 서버가 일하게 만든다. SYN flood는 이 계열의 맏이이고, SYN 쿠키는 여기에 나온 가장 깔끔한 답이다 — 왜냐하면 그 귀한 것을 더 효율적으로 붙드는 대신, 아예 붙들기를 거부하기 때문이다.
핸드셰이크 한가운데 뚫린 구멍
모든 TCP 커넥션은 패킷 세 개로 시작한다. 클라이언트가 SYN을 보내고, 서버가 SYN-ACK으로 답하고, 클라이언트가 ACK으로 마무리한다. 그 뒤 커넥션은 “established”가 되고 데이터가 흐른다. 다 아는 얘기다.
문제는 그다음이다. 서버가 SYN-ACK을 보낼 때, 마지막 ACK이 올 때까지 커넥션을 그냥 잊고 있을 수가 없다 — 세부 정보를 기억해야 한다: 누구의 커넥션인지, 자기가 고른 sequence number가 뭔지, 어떤 옵션을 협상했는지. 그래서 half-open 커넥션 레코드라는 작은 메모리 블록을 할당하고, SYN backlog라 불리는 고정 크기 큐에 넣어 둔다. 커넥션은 절반만 지어진 채, 클라이언트가 완성해 주길 기다리며 거기 앉아 있다.
두 사실이 고약하게 맞부딪힌다. 이 레코드는 SYN이 도착할 때 만들어진다 — 서버가 클라이언트로부터 아무 응답도 듣기 전에. 그리고 backlog는 작다. 그래서 공격자는 데이터를 보낼 필요도, 대역폭도, 심지어 진짜 IP 주소도 필요 없다. 출발지 주소를 위조한 SYN 패킷을 줄줄이 보내면, 서버는 착실하게 half-open 레코드를 할당하고 각 위조 주소로 SYN-ACK을 쏘고는, 영영 오지 않을 ACK을 기다린다. 그 주소들은 애초에 실재하지 않았으니까. 이 유령들로 backlog를 채우면 정상 SYN이 들어올 자리가 없다. 서버는 과부하가 아니다. 존재하지 않는 사람들과의 약속으로 꽉 차 있을 뿐이다.
매번 같은 사기다. 서버가 귀한 것을 — 여기선 backlog 슬롯을 — 지킬 생각 없는 약속에 걸어 버리게 만드는 것.
트릭: 기억하지 말고, 재구성하라
뻔한 해법들은 다 나쁘다. backlog를 키우면 공격자가 SYN을 더 보낸다 — 공짜니까. half-open 커넥션을 더 빨리 타임아웃시키면 느린 회선의 정상 클라이언트를 떨구기 시작한다. 하나같이 귀한 자원을 조금 더 영리하게 붙들려는 시도이고, 단위 비용이 위조 패킷 한 개인 공격자를 영리함으로는 못 이긴다.
Bernstein의 수는, 서버가 상태를 저장하는 이유가 딱 하나임을 알아챈 것이다: 클라이언트의 마지막 ACK을 알아보고 올바른 half-open 커넥션에 짝지어야 한다는 것. 그런데 그 ACK 안에는 서버가 이미 통제하는 필드가 있다 — 바로 서버 자신의 initial sequence number. TCP는 클라이언트가 그 숫자에 1을 더해 acknowledgment로 되돌려 보내도록 요구한다. 서버가 SYN-ACK에 어떤 숫자를 넣든, 그대로 다시 손에 돌아온다.
그렇다면 — sequence number가 곧 상태라면?
이게 전부다. 랜덤 initial sequence number를 골라 테이블에 기억해 두는 대신, 서버는 커넥션 자체로부터 sequence number를 계산해 보내고, 아무것도 저장하지 않는다. ACK이 돌아오면 그 숫자가 얼마여야 했는지를 다시 계산해 일치하는지 확인한다. 진짜 클라이언트는 방금 서버가 준 유효한 숫자를 돌려준다. 위조 flood는 그러지 못한다 — SYN-ACK을 애초에 받지 못했으니까(위조 주소로 갔다). backlog는 더 이상 공격할 가치가 있는 자원이 아니게 된다. 그 안에 아무것도 없으니까.
32비트에 들어가는 것
함정은 TCP sequence number가 겨우 32비트인데, 이제 그 32비트가 서버가 원래 메모리에 쥐고 있었을 모든 걸 담아야 한다는 것이다. Bernstein이 설계하고 리눅스가 지금도 쓰는 레이아웃은 이걸 셋으로 나눈다:
- 5비트는 거친 timestamp — 대략 1분에 한 번 째깍이는 카운터 — 라서 서버가 너무 오래된 쿠키를 거부하고 재전송된
ACK을 영원히 받아들이지 않게 한다. - 3비트는 maximum segment size. 실제 MSS(16비트가 필요하다)가 아니라, 흔한 값 여덟 개 테이블의 인덱스로 가장 가까운 값에 반올림한 것이다.
- 24비트는 커넥션의 four-tuple(출발·도착 IP와 포트)에 timestamp를 더하고, 서버만 아는 비밀 키로 섞은 keyed hash.
마지막 필드가 이 방식 전체의 보안이다. 공격자는 유효한 ACK을 허공에서 만들어낼 수 없다 — 갖고 있지도 않은 비밀로 키를 건 24비트 해시를 맞혀야 하니까. 예전에 캡처한 걸 오래 재생할 수도 없다 — timestamp가 나이를 먹여 버리니까. 마지막 ACK이 도착하면 서버는 sequence number를 도로 분해한다: 해시를 다시 계산해 일치하고 오래되지 않았는지 확인하고, MSS 인덱스를 도로 읽어내고, 진짜 커넥션을 짓는다 — 전부 클라이언트가 되돌려 준 숫자 하나에서, 테이블 조회 없이. 애초에 테이블이 없었으니까.
우아함의 정체는 버린 것에 있다
SYN 쿠키를 “우아하다”고 부를 때 아무도 말하지 않는 트레이드오프가 여기 있다. 이건 손실이 있다(lossy). 일부러.
정상 SYN은 헤더에 옵션을 싣는다 — window scaling, selective acknowledgment(SACK), timestamp — 그리고 서버는 커넥션이 사는 동안 이걸 기억해야 한다. 그런데 자리가 없다. 3비트로 반올림한 MSS를 샀고 그게 끝이다. 커넥션이 64KB보다 큰 receive window를 쓰게 해 주는, 현대 회선에서 빠른 전송과 느린 전송을 가르는 window scaling — 사라졌다. 전부 재전송하지 않고도 패킷 손실을 복구하게 해 주는 SACK — 사라졌다. 쿠키로 지은 커넥션은 성능 옵션이 조용히 벗겨진 커넥션이다.
두 번째 비용은 더 미묘하다. 서버가 상태를 하나도 안 남겼으니, 자기 SYN-ACK이 유실돼도 재전송할 수가 없다. 원래라면 서버가 빠진 ACK을 눈치채고 다시 보낸다. 쿠키 모드에선 다시 보낼 근거가 없어서, SYN-ACK이 떨어지면 클라이언트가 타임아웃 나고 처음부터 다시 시작해야 한다.
그래서 SYN 쿠키는 애초에 정상 경로로 쓰라고 만든 게 아니고, 리눅스는 이걸 정확히 옳게 다룬다. net.ipv4.tcp_syncookies는 오래전부터 기본으로 켜져 있지만, backlog가 실제로 넘치기 전까지는 아무 일도 하지 않는다. 평상시 부하에선 옵션이 온전히 살아 있는 보통 핸드셰이크를 얻는다. 큐가 꽉 찼을 때 — 공격받고 있을 법할 때 — 만 커널이 stateless하고 lossy한 쿠키 모드로 물러난다. 대문이 아니라 압력 배출 밸브다. 다른 선택지가 “아무 커넥션도 못 받는 것”일 때에 한해, 품질 떨어진 커넥션을 받아들이는 것이다.
이 손실이 사람들을 충분히 거슬리게 했는지, 2008년 커널 2.6.26이 원본만큼이나 영리한 보완을 내놨다. TCP timestamp 옵션 역시 클라이언트가 되돌려 echo한다. 그래서 클라이언트가 timestamp를 지원하면, 리눅스는 window-scale과 SACK 설정을 자기가 보내는 timestamp의 하위 비트에 쑤셔 넣고, ACK으로 되돌려 받아, sequence number에 못 담았던 옵션을 재구성한다. Bernstein의 원본은 이걸 못 했다. 현대 구현은 32비트 예산이 어쩔 수 없이 버리게 만든 것을 조용히 대부분 되찾는다. 쿠키 자체와 똑같은 수다 — 기억할 메모리가 없으면, 상태를 회선에 적어 클라이언트가 대신 지고 오게 하라.
이 방식이 공짜 보안은 아니라는 것도 솔직히 짚자. 24비트 해시와 1분 폭의 timestamp 창은 위조 flood를 확실히 막을 만큼 강하지만, 연구자들은 이게 blind connection spoofing의 문턱을 낮춘다는 걸 보였다 — 유효한 쿠키를 맞힐 수 있는 공격자가, 좁은 조건에서, SYN-ACK을 받지도 않은 커넥션을 위조할 수 있다는 것. 공격받는 중에도 살아 있기 위해 견고함을 아주 조금 내주는 거래다. 대안이 1996년 9월의 Panix가 되는 것임을 생각하면, 거의 모두가 받아들이는 거래다.
제약이 곧 설계였다
내가 자꾸 되돌아오는 지점은, SYN 쿠키가 한계 덕분에 존재한다는 것이다 — 한계에도 불구하고가 아니라. flood 중에 half-open 커넥션을 붙들 여분의 메모리가 없었고, 그래서 상태가 다른 데로 가야 했다 — 그리고 유일하게 남은 자리가 클라이언트가 어차피 되돌려 echo하도록 이미 요구되는 숫자였다. 32비트 예산이 반올림을, 손실을, 이 어색하고도 빛나는 형태 전부를 강제했다. 더 넉넉한 필드였다면 더 지루하고 더 나쁜 설계가 나왔을 것이다.
느린 공격들이 반대편에서 가르치는 교훈과 같다. 이 계열 전체 — SYN flood, Slowloris, HTTP/2 flood들 — 는 서버가 검증되지 않은 약속에 귀한 것을 걸게 만들어 이기고, 진짜 방어는 하나같이 같은 본능의 변주다: 클라이언트가 정말 거기 있음을 증명하기 전까지 그를 위해 아무것도 붙들지 마라. SYN 쿠키는 그 본능을 여태 쓰인 것 중 가장 순수하게 표현한다. 아무것도 붙들지 말고, 돌아오는 길에 증명시켜라. 1996년에 32비트에 들어갔고, 2026년에도 여전히 문을 붙들고 있다.