TCP 3-way handshake — SYN, SYN-ACK, ACK и sequence numbers
Каждый раз, когда твой браузер открывает любой сайт, твой Python-скрипт подключается к Postgres, или Git клонирует репозиторий — происходит TCP 3-way handshake. Тысячи раз в день на каждой машине. Это самая известная картинка в сетях, и понимание её необходимо для:
- Анализа сетевых проблем (почему сервер не отвечает?).
- Понимания, почему первый запрос всегда медленнее последующих.
- Оптимизации производительности (TCP Fast Open, connection pooling).
- Защиты от атак (SYN flood).
- Чтения вывода Wireshark/tcpdump.
В этом уроке разберём handshake в деталях: что значит каждый пакет, какие данные обмениваются, как это видно в захваченном трафике, и какие фокусы операционных систем тут происходят.
Зачем вообще нужен handshake?
UDP отправляет данные сразу, без всякого «здравствуй». TCP сначала договаривается. Зачем?
Цели handshake:
- Подтвердить, что обе стороны существуют и слушают. Если бы не было handshake, отправитель не знал бы, доступен ли сервер. Через 3 шага handshake обе стороны точно знают друг о друге.
- Согласовать initial sequence numbers (ISN). TCP нумерует байты для надёжности — каждая сторона должна сообщить свой стартовый номер.
- Обменяться параметрами соединения. Максимальный размер сегмента (MSS), окно (window scaling), поддержка SACK, timestamps — всё это согласуется в опциях SYN и SYN-ACK.
- Установить state на обеих сторонах. Каждая сторона создаёт TCB (Transmission Control Block) — структуру, в которой ядро хранит state соединения.
- Защититься от старых пакетов. Случайный ISN не даёт «осколкам» от старого соединения, на тех же портах, испортить новое.
Три пакета handshake
Handshake состоит из ровно трёх пакетов: SYN, SYN-ACK, ACK. Названия — это флаги в TCP-заголовке (один или два бита установлены).
Шаг за шагом:
Шаг 1: SYN (client -> server). Клиент шлёт TCP-сегмент с флагом SYN (Synchronize). В этом сегменте нет данных, только заголовок. Главные поля:
Source Port— клиентский ephemeral порт (например, 54321).Destination Port— порт сервера (например, 443 для HTTPS).Sequence Number = x— initial sequence number (ISN) клиента. Случайное число в диапазоне 0..2^32-1.Flags: SYN.- Опции: MSS, Window Scaling, SACK Permitted, Timestamps.
После отправки SYN клиент переходит в состояние SYN_SENT и ждёт ответа.
Шаг 2: SYN-ACK (server -> client). Сервер получил SYN, добавляет соединение в очередь принятых SYN (это место, где можно перегружать сервер SYN-flood’ом). В ответ отправляет сегмент с обоими флагами SYN и ACK:
Sequence Number = y— ISN сервера (тоже случайный).Acknowledgment Number = x + 1— «я ожидаю следующий байт x+1». Цифра «+1» — потому что SYN считается за 1 байт, даже если payload пустой.Flags: SYN, ACK.- Опции сервера в ответ на клиентские.
Сервер переходит в SYN_RCVD.
Шаг 3: ACK (client -> server). Клиент получил SYN-ACK. Он шлёт обратно подтверждение:
Sequence Number = x + 1— мой следующий байт.Acknowledgment Number = y + 1— жду следующий байт сервера y+1.Flags: ACK.
После отправки ACK клиент переходит в ESTABLISHED. Когда сервер получает этот ACK — он тоже в ESTABLISHED. Всё, соединение установлено. Можно слать данные.
Бонус: В современном TCP (TCP Fast Open, RFC 7413) клиент может приложить данные сразу к третьему пакету (или даже к первому, если установлены cookies). Но классически — handshake завершается до начала передачи данных.
Initial Sequence Numbers — почему они случайные
В очень старых TCP-стеках ISN был предсказуемым (например, нарастающим со скоростью 250 000 в секунду). Это позволяло атакующему предсказать ISN и подделать TCP-сегмент, который сервер бы принял как часть существующего соединения. Эта атака — TCP sequence prediction attack — была реальной угрозой в 90-е.
Современные ОС используют криптографически случайный ISN. Это две функции:
- Защита от sequence prediction. Угадать случайное 32-битное число невозможно за разумное время.
- Защита от старых пакетов того же соединения. Если ты только что закрыл соединение на (порт_клиента, порт_сервера) и сразу открыл новое — старые пакеты, висящие в сети, могут случайно попасть на новое соединение. Случайный ISN делает их невалидными (sequence numbers не совпадут с ожидаемыми).
# Захвати handshake в Wireshark или tcpdump:
sudo tcpdump -i any -n -nn -S 'tcp port 443' &
curl -s https://example.com > /dev/null
# Ты увидишь что-то вроде:
# Flags [S], seq 1234567890 <- SYN
# Flags [S.], seq 4321098765, ack 1234567891 <- SYN-ACK
# Flags [.], seq 1234567891, ack 4321098766 <- ACK
# Flags [P.], seq 1234567891:1234568000 <- первые данные (например, TLS ClientHello)
# Опция -S заставляет tcpdump показывать абсолютные seq numbers, не относительные
TCP-заголовок и его поля
Чтобы понимать handshake до конца, нужно знать структуру TCP-заголовка:
В handshake обмениваются опциями:
- MSS (Maximum Segment Size) — обычно 1460 байт на Ethernet (1500 MTU - 20 IP - 20 TCP). Клиент и сервер выбирают min обоих MSS.
- Window Scale — позволяет окну быть больше 65535 (множитель). Без этого на быстрых сетях невозможно держать «трубу» в полнометражности.
- SACK Permitted — Selective ACK, позволяет более эффективные retransmits.
- Timestamps — позволяют точно измерить RTT.
Анализ handshake в Wireshark
Когда захватываешь трафик в Wireshark и видишь начало соединения, оно выглядит так:
No. Time Source Destination Protocol Info
1 0.0000 192.168.1.10 93.184.216.34 TCP 54321 > 443 [SYN] Seq=0 Win=65535 MSS=1460 SACK_PERM WS=128
2 0.0234 93.184.216.34 192.168.1.10 TCP 443 > 54321 [SYN, ACK] Seq=0 Ack=1 Win=29200 MSS=1460 SACK_PERM WS=128
3 0.0235 192.168.1.10 93.184.216.34 TCP 54321 > 443 [ACK] Seq=1 Ack=1 Win=4194304
4 0.0240 192.168.1.10 93.184.216.34 TLSv1.3 Client Hello
Wireshark показывает sequence numbers как относительные (от ISN) по умолчанию для удобства — Seq=0 не значит, что в проводе реальный 0, это «первый байт относительно стартового». Чтобы видеть абсолютные значения, в Wireshark отключи Edit -> Preferences -> Protocols -> TCP -> «Relative sequence numbers».
Ключевые вещи в логе:
- Time — относительное время. Между пакетами 1 и 2 — это RTT до сервера. Здесь 23 мс.
- [SYN] — первый пакет handshake.
- [SYN, ACK] — второй.
- [ACK] — третий.
- Win — заявленный receive window.
- MSS, SACK_PERM, WS — TCP-опции, согласуемые при handshake.
После handshake идут реальные данные. В этом примере четвёртый пакет — TLS Client Hello (потому что мы открываем HTTPS). Без TLS это был бы сразу HTTP-запрос.
Жизненный цикл HTTP-соединения: от handshake до keep-aliveСостояния TCP во время handshake
ОС хранит state каждого соединения как state-машину. Во время handshake состояния:
Можно увидеть состояния всех соединений:
# Linux:
ss -tan | head
# State Recv-Q Send-Q Local Address:Port Peer Address:Port
# LISTEN 0 128 0.0.0.0:22 0.0.0.0:*
# ESTABLISHED 0 0 192.168.1.10:54321 93.184.216.34:443
# TIME_WAIT 0 0 192.168.1.10:54322 140.82.114.4:443
# macOS:
netstat -an -p tcp | head
SYN flood атака — почему важно понимать handshake
Когда сервер получает SYN, он создаёт запись в backlog-очереди (TCP_SYN_BACKLOG). Эта очередь имеет ограниченный размер. Если очередь полная — новые SYN отбрасываются, легитимные клиенты не могут подключиться.
SYN flood: атакующий шлёт тысячи SYN с поддельных IP-адресов. Сервер на каждый отвечает SYN-ACK и переходит в SYN_RCVD, ждёт ACK. Но третий пакет никогда не приходит (поддельный IP не отвечает). Очередь забивается, реальные клиенты не могут подключиться.
Защита: SYN cookies. Сервер не хранит state для половинно-открытых соединений. Вместо этого, в ISN сервера зашит криптографически подписанный куки от параметров соединения. Когда придёт реальный ACK, сервер по куки восстановит state. Тогда нет очереди, которую можно забить.
# Linux включает SYN cookies автоматически под нагрузкой:
sysctl net.ipv4.tcp_syncookies
# net.ipv4.tcp_syncookies = 1
# Размер backlog можно настроить:
sysctl net.ipv4.tcp_max_syn_backlog
# net.ipv4.tcp_max_syn_backlog = 1024
Сколько времени занимает handshake?
Handshake — это ровно 1 RTT (round-trip time). Конкретные цифры:
- Localhost: ~0.1 мс
- Внутри ЦОД: 0.5-2 мс
- Внутри страны (RTT клиент-сервер): 10-50 мс
- Между континентами: 100-300 мс
- Спутник: 500-1000 мс
Эти миллисекунды — это «налог» на установление TCP-соединения. Для одного запроса не страшно, но если нужно делать много коротких запросов (микросервисы, шкаф API-вызовов), эти RTT суммируются. Поэтому в production:
- Connection pooling. Переиспользуй TCP-соединения вместо открытия/закрытия каждый раз.
- HTTP keep-alive. Одно TCP-соединение для нескольких HTTP-запросов.
- HTTP/2 multiplexing. Несколько одновременных запросов в одном TCP-соединении.
- TCP Fast Open. Данные в первом пакете handshake (требует cookies от прошлой сессии).
- HTTP/3 (QUIC). 0-RTT resumption для повторных соединений.
# Замерь handshake-время на конкретный хост:
curl -w '@-' -o /dev/null -s https://www.example.com <<'EOF'
DNS lookup: %{time_namelookup}s
TCP handshake: %{time_connect}s
TLS handshake: %{time_appconnect}s
First byte: %{time_starttransfer}s
Total: %{time_total}s
EOF
# time_connect минус time_namelookup -- это и есть TCP handshake
Попробуй сам
# 1. Захвати handshake простого HTTP-запроса:
sudo tcpdump -i any -n 'tcp port 80' -c 6 &
curl http://example.com > /dev/null
# Ты увидишь SYN, SYN-ACK, ACK, потом GET-запрос и ответ
# 2. Замерь RTT через TCP handshake к разным серверам:
for host in example.com github.com www.baidu.com www.alibaba.com; do
printf "%s: " "$host"
curl -o /dev/null -s -w "TCP connect=%{time_connect}s\n" -m 5 "https://$host"
done
# Видно разницу: близкие -- миллисекунды, далёкие -- сотни мс
# 3. Посмотри TCP options, которыми обменивались стороны:
sudo tcpdump -i any -n -v 'tcp port 443' -c 4 &
curl -s https://example.com > /dev/null
# В подробном выводе видно MSS, SACK, WS, Timestamps
# 4. Посмотри активные TCP-соединения:
ss -tan | head -20
# или
netstat -an | grep ESTABLISHED | head -20
# 5. (Linux) Симулируй медленную сеть и посмотри, как handshake тормозит:
sudo tc qdisc add dev lo root netem delay 300ms
time curl -o /dev/null -s http://127.0.0.1:80
# С 300 мс задержки handshake займёт ~600 мс (1 RTT туда + RTT обратно с SYN, SYN-ACK, ACK)
sudo tc qdisc del dev lo root