📚 네트워크 · 패킷 분석 › 01. TCP/IP 구조 이해 — 42편
이전 글: 41. TCP 3-Way Handshake — 연결이 성립했다는 증거 · 다음 글: 43. TCP Flag
참고(리눅스 시스템 기초): ss와 ip 명령어 — ss 기본 옵션 / 44. 프로세스 네트워크 연결 점검 — 연결과 프로세스 연결 짓기
08편과 09편에서 연결의 시작(SYN)과 끝(FIN·RST)을 패킷으로 봤습니다. 그런데 관제 현장에서 항상 패킷 캡처가 있는 것은 아닙니다. 서버에 접속해 지금 이 순간의 연결 상태만 확인할 수 있는 경우가 훨씬 많습니다.
이때 ss -tan의 State 열이 중요한 단서가 됩니다.
SYN-RECV가 수천 개 → 연결 요청은 오는데 마지막 ACK가 오지 않음SYN-SENT가 계속 쌓임 → 이 서버가 밖으로 연결을 시도하는데 응답이 없음CLOSE-WAIT가 계속 늘어남 → 상대는 끊었는데 이 서버의 프로그램이 소켓을 닫지 않음TIME-WAIT가 많음 → 대부분 정상이지만, 짧은 연결이 폭증했다는 신호일 수 있음상태 이름의 의미와 어떤 패킷을 계기로 그 상태가 되는지를 알면, 캡처 없이도 "지금 어느 쪽에서 무엇이 멈춰 있는지"를 추론할 수 있습니다. 이번 글은 RFC 9293에 정의된 TCP 상태와 전이 조건, 그리고 Linux에서 상태별로 소켓을 조회·해석하는 방법을 다룹니다.
| 상태 (RFC 이름) | ss 표시 | 누가 | 의미 |
|---|---|---|---|
| CLOSED | (일반적으로 표시 안 됨) | 양쪽 | 연결 없음 (개념상 상태) |
| LISTEN | LISTEN | 서버 | 연결 요청 대기 |
| SYN-SENT | SYN-SENT | 연결 시작한 쪽 | SYN 보내고 SYN/ACK 기다림 |
| SYN-RECEIVED | SYN-RECV | 서버 | SYN 받고 SYN/ACK 보냄, 마지막 ACK 기다림 |
| ESTABLISHED | ESTAB | 양쪽 | 연결 성립, 데이터 송수신 가능 |
| FIN-WAIT-1 | FIN-WAIT-1 | 먼저 닫은 쪽 | FIN 보내고 ACK 기다림 |
| FIN-WAIT-2 | FIN-WAIT-2 | 먼저 닫은 쪽 | 내 FIN은 확인됨, 상대 FIN 기다림 |
| CLOSE-WAIT | CLOSE-WAIT | 나중에 닫는 쪽 | 상대 FIN 받음, 내 애플리케이션이 close() 하기를 기다림 |
| CLOSING | CLOSING | 양쪽 | 양쪽이 거의 동시에 FIN을 보낸 드문 경우 |
| LAST-ACK | LAST-ACK | 나중에 닫는 쪽 | 내 FIN 보내고 마지막 ACK 기다림 |
| TIME-WAIT | TIME-WAIT | 먼저 닫은 쪽 | 마지막 ACK 보낸 뒤 일정 시간 대기 |
UDP 소켓은 연결 상태가 없어 ss -uan에서 대부분 UNCONN으로 보입니다(07편 참고).
| 현재 상태 | 계기 (받거나 보낸 것) | 다음 상태 |
|---|---|---|
| CLOSED | 앱이 listen() | LISTEN |
| CLOSED | 앱이 connect() → SYN 송신 | SYN-SENT |
| LISTEN | SYN 수신 → SYN/ACK 송신 | SYN-RECEIVED |
| SYN-SENT | SYN/ACK 수신 → ACK 송신 | ESTABLISHED |
| SYN-RECEIVED | ACK 수신 | ESTABLISHED |
| ESTABLISHED | 앱이 close() → FIN 송신 | FIN-WAIT-1 |
| ESTABLISHED | FIN 수신 → ACK 송신 | CLOSE-WAIT |
| FIN-WAIT-1 | 내 FIN에 대한 ACK 수신 | FIN-WAIT-2 |
| FIN-WAIT-1 | 상대 FIN 수신 (내 FIN의 ACK 전) | CLOSING |
| FIN-WAIT-2 | 상대 FIN 수신 → ACK 송신 | TIME-WAIT |
| CLOSE-WAIT | 앱이 close() → FIN 송신 | LAST-ACK |
| LAST-ACK | ACK 수신 | CLOSED |
| TIME-WAIT | 대기 시간 만료 | CLOSED |
| (대부분의 상태) | RST 수신 | CLOSED |
TIME-WAIT는 오류가 아니라 의도된 대기입니다.
Linux는 TIME-WAIT 시간을 60초로 고정해 두고 있습니다(커널 상수). 흔히 조정 대상으로 언급되는 net.ipv4.tcp_fin_timeout은 TIME-WAIT가 아니라 FIN-WAIT-2에 머무를 수 있는 시간을 정하는 값입니다.
연결 한 개의 생애를 클라이언트가 먼저 닫는 경우로 따라가면 다음과 같습니다.

그림 1. 연결을 시작·종료하는 쪽과 받는 쪽이 거치는 상태
클라이언트 서버
CLOSED CLOSED
│ │ listen()
│ ↓
│ connect() LISTEN
↓ ── SYN ──────────────→ │
SYN-SENT ↓
│ ←──────────── SYN/ACK ── SYN-RECEIVED
↓ ── ACK ──────────────→ │
ESTABLISHED ↓
│ ESTABLISHED
│ (데이터 송수신) │
│ close() │
↓ ── FIN ──────────────→ │
FIN-WAIT-1 ↓
│ ←──────────────── ACK ── CLOSE-WAIT ← 앱이 close() 할 때까지 머묾
↓ │ close()
FIN-WAIT-2 ↓
│ ←──────────────── FIN ── LAST-ACK
↓ ── ACK ──────────────→ │
TIME-WAIT ↓
│ (Linux 60초) CLOSED
↓
CLOSED
이 그림에서 관제 관점의 요점은 "상태가 오래 머무는 곳은 누군가가 무엇을 기다리는 곳" 이라는 점입니다.
| 오래 머무는 상태 | 기다리는 것 | 멈춘 쪽 |
|---|---|---|
| SYN-SENT | 상대의 SYN/ACK | 네트워크·상대 (drop, 호스트 없음) |
| SYN-RECEIVED | 상대의 마지막 ACK | 상대 (응답하지 않거나 위조된 출발지) |
| CLOSE-WAIT | 내 애플리케이션의 close() | 내 프로그램 |
| FIN-WAIT-2 | 상대의 FIN | 상대 애플리케이션 |
| LAST-ACK | 상대의 마지막 ACK | 상대·네트워크 |
특히 CLOSE-WAIT는 커널이 아니라 애플리케이션이 풀어야 하는 상태입니다. CLOSE-WAIT가 계속 늘어나면 네트워크가 아니라 프로그램의 소켓 처리(연결 누수)를 의심하는 것이 일반적입니다.
실습 환경: 본인 소유 Linux VM (Rocky Linux 또는 Ubuntu) 2대(192.168.10.10 클라이언트, 192.168.10.20 서버 예시, 서버에 sshd 동작 중). 인터페이스 이름은 ens33을 예시로 사용합니다(환경에 따라 다름). ss는 iproute2 패키지에 포함되어 두 배포판 모두 기본 설치되어 있습니다. nc가 없다면 sudo dnf install -y nmap-ncat(Rocky) 또는 sudo apt install -y netcat-openbsd(Ubuntu)로 설치합니다.
# 상태별 소켓 수 요약
ss -s
# TCP 소켓을 State 열 기준으로 집계
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn
# 특정 상태만 (-H: 헤더 생략)
ss -Htan state established
ss -Htan state syn-sent
ss -Htan state time-wait | wc -l
# 상태 + 포트 조건 (필터식은 작은따옴표로 감쌈)
ss -tan state established '( dport = :443 or sport = :443 )'
# 타이머 정보 포함 (-o): TIME-WAIT 남은 시간 등
ss -tano state time-wait
# 프로세스 정보 포함 (-p, 다른 사용자 소켓까지 보려면 sudo)
sudo ss -tanp state close-wait
# [클라이언트] 서버 22번에 연결만 했다가 끊기를 10회 반복 (클라이언트가 먼저 닫음)
for i in $(seq 1 10); do nc -zv 192.168.10.20 22 2>/dev/null; done
# [클라이언트] 먼저 닫은 쪽에 TIME-WAIT가 남는지 확인
ss -tano state time-wait '( dport = :22 )'
서버의 sshd 로그(Rocky: /var/log/secure, Ubuntu: /var/log/auth.log)에는 식별 문자열 없이 끊긴 연결 기록이 남을 수 있습니다. 연결만 맺고 끊는 동작도 서버에 흔적을 남긴다는 점을 함께 확인해 봅니다.
소켓을 받아 놓고 닫지 않는 간단한 서버를 60초 동안 실행합니다(실습 후 자동 종료).
# [서버] 실습 동안만 9000/tcp 허용 (firewalld 사용 시)
sudo firewall-cmd --add-port=9000/tcp
# [서버] 연결을 accept한 뒤 60초간 close()하지 않는 프로그램
python3 -c "import socket,time; s=socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1); s.bind(('0.0.0.0',9000)); s.listen(); c,a=s.accept(); time.sleep(60)"
# [클라이언트] 접속 후 즉시 종료 (FIN 송신)
nc -zv 192.168.10.20 9000
# [서버] 60초 안에 확인
ss -tan state close-wait '( sport = :9000 )'
# [클라이언트] 60초 안에 확인
ss -tan state fin-wait-2 '( dport = :9000 )'
# [서버] 실습 후 허용 해제
sudo firewall-cmd --remove-port=9000/tcp
sysctl net.ipv4.tcp_fin_timeout # FIN-WAIT-2 유지 시간(초)
sysctl net.ipv4.tcp_max_tw_buckets # 동시에 유지할 수 있는 TIME-WAIT 최대 수
tcp_tw_reuse 등 TIME-WAIT 관련 값을 바꾸라는 글이 많지만, 영향 범위를 이해하지 않고 운영 서버에서 변경하는 것은 권장되지 않습니다(과거의 tcp_tw_recycle은 NAT 환경 문제로 Linux 4.12에서 제거되었습니다).
📷 [실습 화면 삽입]
ss -tan | awk ... | uniq -c상태 집계 결과와ss -s요약
출력 형식 예시(값은 환경마다 다름):
$ ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn
12 TIME-WAIT
5 ESTAB
4 LISTEN
1 CLOSE-WAIT
$ ss -tano state time-wait '( dport = :22 )'
Recv-Q Send-Q Local Address:Port Peer Address:Port Process
0 0 192.168.10.10:51540 192.168.10.20:22 timer:(timewait,52sec,0)
$ ss -tan state close-wait '( sport = :9000 )'
Recv-Q Send-Q Local Address:Port Peer Address:Port Process
1 0 192.168.10.20:9000 192.168.10.10:51552
state 필터를 쓰면 출력에서 State 열이 빠집니다. 조회한 상태가 이미 정해져 있기 때문입니다. CLOSE-WAIT의 Recv-Q 1은 상대가 보낸 FIN이 아직 애플리케이션에 읽히지 않았다는 흔적으로 볼 수 있습니다.
ss -tan 결과를 상태별로 집계할 수 있다timer:(timewait,...)가 줄어드는 것을 확인했다tcp_fin_timeout이 TIME-WAIT가 아닌 FIN-WAIT-2 시간이라는 점을 설명할 수 있다📷 [실습 화면 삽입] 서버의
ss -tan state close-wait와 클라이언트의ss -tan state fin-wait-2결과를 나란히
| 관찰 | 정상일 수 있는 경우 | 의심해야 하는 경우 |
|---|---|---|
SYN-RECV 다수 | 순간 접속 폭주, 느린 클라이언트 | 수백 개 이상 지속 + 출발지 IP 다양 → SYN Flood (08편 카운터와 함께 확인) |
SYN-SENT 다수 (서버가 클라이언트) | 외부 API·업데이트 서버 장애, 방화벽 정책 누락 | 서비스와 무관한 외부 IP·비표준 포트로 반복 시도 → 악성 프로세스의 C2 접속 시도, 내부 대상이 여러 개면 서버발 스캔 가능성 |
ESTAB가 장시간 유지 | DB 커넥션 풀, SSH 세션, 메시지 큐 | 알 수 없는 프로세스가 외부 IP와 장시간 연결 (리버스 셸 등) |
CLOSE-WAIT 지속 증가 | — (정상 운영에서 계속 쌓이지 않음) | 애플리케이션 소켓 누수 → 자원 고갈·장애. 보안 사고라기보다 장애 신호지만 가용성 영향 |
TIME-WAIT 수천 개 | 웹서버·프록시처럼 짧은 연결을 많이 처리하는 서버 | 평소 기준선보다 급증 + 특정 출발지 집중 → 대량 요청 (크롤링·무차별 대입 가능성) |
기준선(평소 값)을 알아야 이상을 판단할 수 있습니다. 상태 분포를 주기적으로 남겨 두는 예시입니다.
# 시각과 상태 분포를 한 줄로 기록 (cron 등으로 주기 실행 가능)
echo "$(date '+%F %T') $(ss -Htan | awk '{print $1}' | sort | uniq -c | tr -s ' ' | tr '\n' ' ')" >> ~/tcp_state_baseline.log
# SYN-SENT 상태 소켓을 가진 프로세스 확인 (외부로 연결을 시도하는 주체)
sudo ss -tanp state syn-sent
# ESTAB 연결을 원격 IP별로 집계 (Peer Address 열)
ss -Htan state established | awk '{print $4}' | sed 's/:[0-9]*$//' | sort | uniq -c | sort -rn | head
state 필터를 쓴 ss -Htan 출력은 State 열이 빠지므로 Recv-Q Send-Q Local Peer 순서가 되어, Peer 주소는 네 번째 열입니다. 비정상 연결을 프로세스·실행 파일까지 추적하는 방법은 38. 비정상 네트워크 연결 탐지에서 다뤘습니다.
흔적이 남는 곳
ss 결과는 로그가 아니라 스냅숏입니다. 침해 대응 시에는 가장 먼저 수집해야 하는 휘발성 정보 중 하나입니다(시간이 지나면 TIME-WAIT·연결이 사라짐).한계와 오탐 주의
SYN-RECV·RST 패턴 분석은 05. 네트워크 스캔 · 공격 징후 분석 시리즈에서 다룹니다.ss -tan state <상태>와 상태별 집계로 캡처 없이도 연결 문제의 위치를 추론할 수 있습니다.