초안프로덕션 빌드에서 제외되며 검색엔진에도 노출되지 않습니다.

회사 테크

SYN만 통과하는 방화벽 — 포트는 열려 있는데 접속이 안 됩니다

iptables 화이트리스트를 ctstate NEW로만 심사하면 핸드셰이크가 끝나지 않습니다. 포트 스캔에는 열려 있다고 나오기 때문에 백업 실패의 원인을 한 달간 찾지 못했습니다.

삽질하는개발자

노트와 노트북이 놓인 상담 책상

백업 작업이 한 달째 실패하고 있었습니다. 로그에는 경로마다 이렇게 찍혔습니다.

백업 경로 /docker/app/config 이(가) 실패했습니다.(작업 시간이 초과되었습니다)
백업 경로 /docker/app/data   이(가) 실패했습니다.(작업 시간이 초과되었습니다)
백업 경로 /docker/app/ssl    이(가) 실패했습니다.(작업 시간이 초과되었습니다)

처음에는 용량이나 디스크 성능을 의심했습니다. 대상이 186GB였고, 백업 저장소 디스크가 SMR이라 쓰기가 느릴 수 있다고 봤습니다.

그 가설은 로그를 다시 보고 기각했습니다. config 경로는 118KB, ssl 경로는 43KB인데 이것들도 똑같이 10분씩 타임아웃이 났습니다. 118KB를 옮기는 데 10분이 걸릴 수는 없습니다. 전송이 느린 것이 아니라 연결 자체가 성립하지 않는다는 뜻이었습니다.

포트는 열려 있었습니다

백업은 rsync over SSH 방식이었습니다. 그래서 SSH 포트부터 확인했는데, 여기서 한 번 더 헤맸습니다.

TCP 연결 테스트는 성공했습니다. 포트 스캔에도 open으로 나왔습니다. 방화벽은 정상으로 보였습니다.

그런데 실제로 SSH를 붙여보면 이렇게 끝났습니다.

Connection timed out during banner exchange

SSH는 TCP 연결이 되면 서버가 먼저 버전 배너를 보냅니다. 연결은 됐는데 배너를 못 받았다는 것은, TCP 핸드셰이크는 시작됐지만 그 이후 패킷이 오가지 못하고 있다는 신호입니다.

tcpdump가 보여준 것

브리지 인터페이스에서 패킷을 떠보니 그림이 명확해졌습니다. 실패하는 외부 접속입니다.

외부IP.54640 > 서버.22: Flags [S]        SYN
서버.22 > 외부IP.54640: Flags [S.]       SYN-ACK
외부IP.54640 > 서버.22: Flags [.] ack 1  ACK (도착은 했다)
외부IP        ... SSH-2.0-OpenSSH_9.6p1  클라이언트 배너 전송
서버.22 > 외부IP: Flags [S.]  x4회 재전송

같은 시각 내부 대역에서 붙은 접속은 이랬습니다.

내부IP.40928 > 서버.22: [S] -> [S.] -> ack
서버.22 > 내부IP.40928: SSH-2.0-OpenSSH_8.9p1   즉시 배너

핵심은 마지막 줄입니다. 서버가 SYN-ACK를 네 번이나 재전송했습니다. 클라이언트는 분명 ACK를 보냈고 그 패킷이 인터페이스까지 도착했는데, 서버의 커널은 그것을 받지 못한 것으로 보고 계속 SYN-ACK를 다시 보내고 있었습니다.

패킷이 인터페이스에는 도착했는데 커널의 소켓까지 가지 못했다면, 그 사이에서 무언가가 버리고 있다는 뜻입니다.

원인: NEW만 심사하고 ESTABLISHED를 허용하지 않았다

방화벽 스크립트가 두 개 있었습니다. 작성 시기가 달랐고, 서로의 전제가 달랐습니다.

# 먼저 만든 것 — 사내 대역만 허용하는 정책
iptables -I INPUT -p tcp --dport 22 -j DROP                       # 상태를 가리지 않는다
iptables -I INPUT -p tcp --dport 22 -s 192.168.0.0/24 -j ACCEPT

# 나중에 추가한 것 — 외부 허용 IP 화이트리스트
iptables -I INPUT 1 -p tcp --dport 22 -m conntrack --ctstate NEW -j SSH-GUARD

두 스크립트가 함께 돌면 INPUT 체인이 이렇게 됩니다.

순서 외부 허용 IP에 미치는 영향
2 dport 22 ctstate NEW -> SSH-GUARD SYN만 심사하고 ACCEPT
4 192.168.0.0/24 dport 22 ACCEPT 내부는 상태와 무관하게 통과
5 dport 22 DROP 후속 ACK와 데이터가 전부 여기서 버려짐

화이트리스트는 ctstate NEW, 즉 연결의 첫 패킷인 SYN만 심사합니다. SYN은 통과합니다. 그런데 그다음에 오는 ACK와 데이터 패킷은 상태가 ESTABLISHED라 2번 룰에 걸리지 않고, 4번 룰의 소스 조건에도 맞지 않아, 결국 5번 DROP에 도달합니다.

연결의 첫 패킷만 통과하고 나머지가 전부 버려지는 상태였습니다. 그래서 TCP 연결 테스트는 성공하고 SSH 세션은 성립하지 않았습니다.

카운터가 결정적 증거였습니다

iptables -L -v -n의 패킷 카운터를 보면 한눈에 드러납니다.

SSH-GUARD  (허용 IP ACCEPT)         1 pkts       60 bytes
INPUT #5   (dport 22 무조건 DROP)   87,225 pkts  5,242 K
INPUT #4   (192.168.0.0/24 ACCEPT)  5,543 K pkts  342 M

허용 룰은 1패킷 60바이트입니다. SYN 하나의 크기입니다. 반면 DROP 룰은 87,225패킷을 버렸습니다. 그 아래 내부 대역 룰은 554만 패킷을 통과시키고 있었습니다.

허용 룰의 카운터가 비정상적으로 작으면 그 룰은 “일부만” 통과시키고 있는 것입니다. 정상이라면 세션 하나당 수백 패킷이 찍혀야 합니다.

수정은 한 줄이었습니다

정책은 그대로 두고, 이미 심사를 통과한 세션의 후속 패킷만 허용하는 룰을 DROP 앞에 넣었습니다.

iptables -I INPUT 5 -p tcp --dport 22 \
  -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

최종 체인입니다.

1 SSH-GUARD  dport 22 ctstate NEW
2 ACCEPT     lo dport 22
3 ACCEPT     192.168.0.0/24 dport 22
4 ACCEPT     dport 22 ctstate RELATED,ESTABLISHED     <- 추가
5 DROP       dport 22

이 룰은 화이트리스트를 느슨하게 만들지 않습니다. ESTABLISHED이미 NEW 심사를 통과해 연결이 만들어진 세션에만 붙는 상태이기 때문입니다. 심사를 통과하지 못한 IP는 애초에 세션이 만들어지지 않으므로 이 룰에 걸릴 것도 없습니다.

적용 후 카운터입니다.

INPUT (ESTABLISHED ACCEPT)  1,018 K pkts  59 M    그동안 버려지던 백업 트래픽
INPUT (DROP)                0 pkts               폐기 없음

한 달 만에 백업이 성공했습니다.

왜 한 달이나 걸렸을까

비대칭이 문제를 가렸습니다.

내부 대역 룰은 상태를 가리지 않고 통과시켰습니다. 그래서 사내에서 도는 백업 작업은 멀쩡히 성공하고 있었습니다. 백업이라는 기능 자체는 동작하는 것처럼 보였고, 실패하는 것은 외부에서 들어오는 한 건뿐이었습니다.

여기에 포트 스캔이 open을 반환한다는 점이 겹쳤습니다. “방화벽은 열려 있고, 백업 기능도 동작한다”는 두 가지 관찰이 모두 사실이었기 때문에, 원인을 방화벽에서 찾을 이유가 없어 보였습니다.

시간 순서를 정리하고 나서야 그림이 맞았습니다.

시점 사건 외부 백업
7월 중순 sshd 프로세스 사망 실패 (connection refused)
7월 하순 sshd 복구 + 사내 대역 전용 정책 적용 실패 (외부라 전면 차단)
8월 초 외부 허용 IP 화이트리스트 추가 실패 (ESTABLISHED 누락)
8월 중순 ESTABLISHED 허용 추가 성공

증상은 한 달 내내 같았지만 원인은 세 번 바뀌었습니다. 중간에 두 번 손을 댔고 두 번 다 “고쳤다”고 판단했습니다. 실제로는 매번 다음 단계의 문제가 드러났을 뿐입니다. 조치 후 검증을 실제 사용 경로로 하지 않으면 이런 일이 생깁니다.

판별 체크리스트

같은 증상을 만났을 때 확인할 순서입니다.

  1. Connection timed out during banner exchange — TCP는 됐는데 애플리케이션 계층이 시작되지 않았다는 신호입니다
  2. 서버 쪽 SYN-ACK 반복 재전송 — 클라이언트의 ACK가 커널에 도달하지 않고 있다는 뜻입니다
  3. 허용 룰의 패킷 카운터가 수십 바이트 수준 — SYN만 통과하고 있다는 뜻입니다
  4. 포트 스캔 결과를 근거로 삼지 않기 — 스캔은 SYN에 SYN-ACK가 오는지만 봅니다. 그 뒤가 막혀 있어도 open입니다

그리고 규칙 하나만 기억하면 됩니다. ctstate NEW로 심사하는 룰을 넣었다면, ESTABLISHED,RELATED를 허용하는 룰을 반드시 짝으로 둡니다. 상태 기반 심사는 첫 패킷만 보기 때문에, 나머지 패킷이 지나갈 길을 따로 열어주지 않으면 세션이 성립하지 않습니다.

덧붙여, 스크립트를 겹쳐 쓸 때

이번 사고의 배경에는 시기가 다른 방화벽 스크립트 두 개가 서로의 전제를 모른 채 같은 체인에 룰을 넣고 있었다는 사정이 있습니다.

먼저 만든 쪽은 “사내만 허용하면 되므로 나머지는 조건 없이 DROP”이라는 전제였고, 나중에 만든 쪽은 “심사는 NEW에서만 하면 된다”는 전제였습니다. 각각은 맞지만 합치면 틀립니다.

방화벽 룰을 여러 스크립트로 나눠 관리한다면 최소한 두 가지는 확인하는 편이 좋습니다. 하나는 최종 체인을 실제로 출력해서 순서를 눈으로 보는 것이고, 다른 하나는 스크립트를 연속으로 두 번 실행해도 결과가 같은지(멱등성) 확인하는 것입니다. 이번에도 수정 후 두 스크립트를 번갈아 재실행해 룰 중복과 순서 이상이 없는지 확인했습니다.