這一次,徹底弄懂TCP三次握手,四次揮手

dhonor發表於2019-02-19
作為程式設計師,要有“刨根問底”的精神,知其然,更要知其所以然。這篇文章希望能抽絲剝繭,還原背後的原理。

什麼是“三次握手,四次揮手”

TCP 是一種面向連線的單播協議,在傳送資料前,通訊雙方必須在彼此間建立一條連線。

所謂的“連線”,其實是客戶端和伺服器的記憶體裡儲存的一份關於對方的資訊,如 IP 地址、埠號等。

TCP 可以看成是一種位元組流,它會處理 IP 層或以下的層的丟包、重複以及錯誤問題。

在連線的建立過程中,雙方需要交換一些連線的引數。這些引數可以放在 TCP 頭部。

TCP 提供了一種可靠、面向連線、位元組流、傳輸層的服務,採用三次握手建立一個連線。採用四次揮手來關閉一個連線。

TCP 服務模型

在瞭解了建立連線、關閉連線的“三次握手和四次揮手”後,我們再來看下 TCP 相關的東西。

一個 TCP 連線由一個 4 元組構成,分別是兩個 IP 地址和兩個埠號。一個 TCP 連線通常分為三個階段:啟動、資料傳輸、退出(關閉)。

當 TCP 接收到另一端的資料時,它會傳送一個確認,但這個確認不會立即傳送,一般會延遲一會兒。

ACK 是累積的,一個確認位元組號 N 的 ACK 表示所有直到 N 的位元組(不包括 N)已經成功被接收了。

這樣的好處是如果一個 ACK 丟失,很可能後續的 ACK 就足以確認前面的報文段了。

一個完整的 TCP 連線是雙向和對稱的,資料可以在兩個方向上平等地流動,給上層應用程式提供一種雙工服務。

一旦建立了一個連線,這個連線的一個方向上的每個 TCP 報文段都包含了相反方向上的報文段的一個 ACK。

序列號的作用是使得一個 TCP 接收端可丟棄重複的報文段,記錄以雜亂次序到達的報文段。

因為 TCP 使用 IP 來傳輸報文段,而 IP 不提供重複消除或者保證次序正確的功能。

另一方面,TCP 是一個位元組流協議,絕不會以雜亂的次序給上層程式傳送資料。

因此 TCP 接收端會被迫先保持大序列號的資料不交給應用程式,直到缺失的小序列號的報文段被填滿。

TCP 頭部

這一次,徹底弄懂TCP三次握手,四次揮手

源埠和目的埠在 TCP 層確定雙方程式,序列號表示的是報文段資料中的第一個位元組號,ACK 表示確認號。

該確認號的傳送方期待接收的下一個序列號,即最後被成功接收的資料位元組序列號加 1,這個欄位只有在 ACK 位被啟用的時候才有效。

當新建一個連線時,從客戶端傳送到服務端的第一個報文段的 SYN 位被啟用,這稱為 SYN 報文段。

這時序列號欄位包含了在本次連線的這個方向上要使用的第一個序列號,即初始序列號 ISN,之後傳送的資料是 ISN 加 1。

因此 SYN 位欄位會消耗一個序列號。這意味著使用重傳進行可靠傳輸,而不消耗序列號的 ACK 則不是。

頭部長度(圖中的資料偏移)以 32 位字為單位,也就是以 4bytes 為單位,它只有 4 位,最大為 15,因此頭部最大長度為 60 位元組,而其最小為 5,也就是頭部最小為 20 位元組(可變選項為空)。

  • ACK —— 確認,使得確認號有效。
  • RST —— 重置連線(經常看到的 reset by peer)就是此欄位搞的鬼。
  • SYN —— 用於初如化一個連線的序列號。
  • FIN —— 該報文段的傳送方已經結束向對方傳送資料。

當一個連線被建立或被終止時,交換的報文段只包含 TCP 頭部,而沒有資料。

狀態轉換

三次握手和四次揮手的狀態轉換如下圖:

這一次,徹底弄懂TCP三次握手,四次揮手

為什麼要“三次握手,四次揮手”

三次握手

換個易於理解的視角來看為什麼要三次握手。

客戶端和服務端通訊前要進行連線,“三次握手”的作用就是雙方都能明確自己和對方的收、發能力是正常的。

第一次握手:客戶端傳送網路包,服務端收到了。這樣服務端就能得出結論:客戶端的傳送能力、服務端的接收能力是正常的。

第二次握手:服務端發包,客戶端收到了。這樣客戶端就能得出結論:服務端的接收、傳送能力,客戶端的接收、傳送能力是正常的。

從客戶端的視角來看,我接到了服務端傳送過來的響應資料包,說明服務端接收到了我在第一次握手時傳送的網路包,並且成功傳送了響應資料包,這就說明,服務端的接收、傳送能力正常。

而另一方面,我收到了服務端的響應資料包,說明我第一次傳送的網路包成功到達服務端,這樣,我自己的傳送和接收能力也是正常的。

第三次握手:客戶端發包,服務端收到了。這樣服務端就能得出結論:客戶端的接收、傳送能力,服務端的傳送、接收能力是正常的。

第一、二次握手後,服務端並不知道客戶端的接收能力以及自己的傳送能力是否正常。

而在第三次握手時,服務端收到了客戶端對第二次握手作的迴應。從服務端的角度,我在第二次握手時的響應資料傳送出去了,客戶端接收到了。所以,我的傳送能力是正常的。而客戶端的接收能力也是正常的。

經歷了上面的三次握手過程,客戶端和服務端都確認了自己的接收、傳送能力是正常的。之後就可以正常通訊了。

每次都是接收到資料包的一方可以得到一些結論,傳送的一方其實沒有任何頭緒。

我雖然有發包的動作,但是我怎麼知道我有沒有發出去,而對方有沒有接收到呢?

而從上面的過程可以看到,最少是需要三次握手過程的。兩次達不到讓雙方都得出自己、對方的接收、傳送能力都正常的結論。

其實每次收到網路包的一方至少是可以得到:對方的傳送、我方的接收是正常的。

而每一步都是有關聯的,下一次的“響應”是由於第一次的“請求”觸發,因此每次握手其實是可以得到額外的結論的。

比如第三次握手時,服務端收到資料包,表明看服務端只能得到客戶端的傳送能力、服務端的接收能力是正常的。

但是結合第二次,說明服務端在第二次傳送的響應包,客戶端接收到了,並且作出了響應,從而得到額外的結論:客戶端的接收、服務端的傳送是正常的。

用表格總結一下:

這一次,徹底弄懂TCP三次握手,四次揮手

四次揮手

TCP 連線是雙向傳輸的對等的模式,就是說雙方都可以同時向對方傳送或接收資料。

當有一方要關閉連線時,會傳送指令告知對方,我要關閉連線了。這時對方會回一個 ACK,此時一個方向的連線關閉。

但是另一個方向仍然可以繼續傳輸資料,等到傳送完了所有的資料後,會傳送一個 FIN 段來關閉此方向上的連線。接收方傳送 ACK 確認關閉連線。

注意,接收到 FIN 報文的一方只能回覆一個 ACK, 它是無法馬上返回對方一個 FIN 報文段的,因為結束資料傳輸的“指令”是上層應用層給出的,我只是一個“搬運工”,我無法瞭解“上層的意志”。

“三次握手,四次揮手”怎麼完成?

其實三次握手的目的並不只是讓通訊雙方都瞭解到一個連線正在建立,還在於利用資料包的選項來傳輸特殊的資訊,交換初始序列號 ISN。

三次握手是指傳送了 3 個報文段,四次揮手是指傳送了 4 個報文段。注意,SYN 和 FIN 段都是會利用重傳進行可靠傳輸的。

這一次,徹底弄懂TCP三次握手,四次揮手

三次握手原理:

  • 客戶端傳送一個 SYN 段,並指明客戶端的初始序列號,即 ISN(c)。
  • 服務端傳送自己的 SYN 段作為應答,同樣指明自己的 ISN(s)。為了確認客戶端的 SYN,將 ISN(c)+1 作為 ACK 數值。這樣,每傳送一個 SYN,序列號就會加 1,如果有丟失的情況,則會重傳。
  • 為了確認伺服器端的 SYN,客戶端將 ISN(s)+1 作為返回的 ACK 數值。

這一次,徹底弄懂TCP三次握手,四次揮手

四次揮手原理:

  • 客戶端傳送一個 FIN 段,幷包含一個希望接收者看到的自己當前的序列號 K。同時還包含一個 ACK 表示確認對方最近一次發過來的資料。
  • 服務端將 K 值加 1 作為 ACK 序號值,表明收到了上一個包。這時上層的應用程式會被告知另一端發起了關閉操作,通常這將引起應用程式發起自己的關閉操作。
  • 服務端發起自己的 FIN 段,ACK=K+1, Seq=L。
  • 客戶端確認。ACK=L+1

為什麼建立連線是三次握手,而關閉連線卻是四次揮手呢?這是因為服務端在 LISTEN 狀態下,收到建立連線請求的 SYN 報文後,把 ACK 和 SYN 放在一個報文裡傳送給客戶端。

而關閉連線時,當收到對方的 FIN 報文時,僅僅表示對方不再傳送資料了但是還能接收資料,己方是否現在關閉傳送資料通道,需要上層應用來決定,因此,己方 ACK 和 FIN 一般都會分開傳送。

“三次握手,四次揮手”進階

ISN

三次握手的一個重要功能是客戶端和服務端交換 ISN(Initial Sequence Number), 以便讓對方知道接下來接收資料的時候如何按序列號組裝資料。

如果 ISN 是固定的,攻擊者很容易猜出後續的確認號:

ISN = M + F(localhost, localport, remotehost, remoteport) 複製程式碼

M 是一個計時器,每隔 4 毫秒加 1。F 是一個 Hash 演算法,根據源 IP、目的 IP、源埠、目的埠生成一個隨機數值。要保證 Hash 演算法不能被外部輕易推算得出。

序列號迴繞

因為 ISN 是隨機的,所以序列號容易就會超過 2^31-1。而 TCP 對於丟包和亂序等問題的判斷都是依賴於序列號大小比較的。

此時就出現了所謂的 TCP 序列號迴繞(sequence wraparound)問題怎麼解決?

/* 
* The next routines deal with comparing 32 bit unsigned ints 
* and worry about wraparound (automatic with unsigned arithmetic). 
*/ 
static inline int before(__u32 seq1, __u32 seq2){ 
    return (__s32)(seq1-seq2) < 0; 
} 

#define after(seq2, seq1) before(seq1, seq2) 複製程式碼

上述程式碼是核心中的解決迴繞問題程式碼。__s32 是有符號整型的意思,而 __u32 則是無符號整型。

序列號發生迴繞後,序列號變小,相減之後,把結果變成有符號數了,因此結果成了負數。

假設seq1=255, seq2=1(發生了迴繞)。 
seq1 = 1111 1111 seq2 = 0000 0001 
我們希望比較結果是 
seq1 - seq2= 
1111 1111 
-0000 0001 
----------- 
1111 1110 
 
由於我們將結果轉化成了有符號數,由於最高位是1,因此結果是一個負數,負數的絕對值為 
0000 0001 + 1 = 0000 0010 = 2 
 
因此seq1 - seq2 < 0 複製程式碼

SYN Flood 攻擊

最基本的 DoS 攻擊就是利用合理的服務請求來佔用過多的服務資源,從而使合法使用者無法得到服務的響應。SYN Flood 屬於 Dos 攻擊的一種。

如果惡意的向某個伺服器埠傳送大量的 SYN 包,則可以使伺服器開啟大量的半開連線,分配 TCB(Transmission Control Block), 從而消耗大量的伺服器資源,同時也使得正常的連線請求無法被響應。

當開放了一個 TCP 埠後,該埠就處於 Listening 狀態,不停地監視發到該埠的 SYN 報文,一 旦接收到 Client 發來的 SYN 報文,就需要為該請求分配一個 TCB。

通常一個 TCB 至少需要 280 個位元組,在某些作業系統中 TCB 甚至需要 1300 個位元組,並返回一個 SYN ACK 命令,立即轉為 SYN-RECEIVED 即半開連線狀態,系統會為此耗盡資源。常見的防攻擊方法有:

①無效連線的監視釋放

監視系統的半開連線和不活動連線,當達到一定閾值時拆除這些連線,從而釋放系統資源。

這種方法對於所有的連線一視同仁,而且由於 SYN Flood 造成的半開連線數量很大,正常連線請求也被淹沒在其中被這種方式誤釋放掉,因此這種方法屬於入門級的 SYN Flood 方法。

②延緩 TCB 分配方法

消耗伺服器資源主要是因為當 SYN 資料包文一到達,系統立即分配 TCB,從而佔用了資源。

而 SYN Flood 由於很難建立起正常連線,因此,當正常連線建立起來後再分配 TCB 則可以有效地減輕伺服器資源的消耗。常見的方法是使用 Syn Cache 和 Syn Cookie 技術。

③Syn Cache 技術

系統在收到一個 SYN 報文時,在一個專用 Hash 表中儲存這種半連線資訊,直到收到正確的迴應 ACK 報文再分配 TCB。這個開銷遠小於 TCB 的開銷。當然還需要儲存序列號。

④Syn Cookie 技術

Syn Cookie 技術則完全不使用任何儲存資源,這種方法比較巧妙,它使用一種特殊的演算法生成 Sequence Number。

這種演算法考慮到了對方的 IP、埠、己方 IP、埠的固定資訊,以及對方無法知道而己方比較固定的一些資訊。

如 MSS(Maximum Segment Size,最大報文段大小,指的是 TCP 報文的最大資料包長度,其中不包括 TCP 首部長度。)、時間等。

在收到對方的 ACK 報文後,重新計算一遍,看其是否與對方迴應報文中的(Sequence Number-1)相同,從而決定是否分配 TCB 資源。

⑤使用 SYN Proxy 防火牆

一種方式是防止牆 dqywb 連線的有效性後,防火牆才會向內部伺服器發起 SYN 請求。

防火牆代伺服器發出的 SYN ACK 包使用的序列號為 c, 而真正的伺服器迴應的序列號為 c',。

這樣,在每個資料包文經過防火牆的時候進行序列號的修改。另一種方式是防火牆確定了連線的安全後,會發出一個 safe reset 命令,Client 會進行重新連線,這時出現的 SYN 報文會直接放行。

這樣不需要修改序列號了。但是,Client 需要發起兩次握手過程,因此建立連線的時間將會延長。

連線佇列

在外部請求到達時,被服務程式最終感知到前,連線可能處於 SYN_RCVD 狀態或是 ESTABLISHED 狀態,但還未被應用程式接受。

這一次,徹底弄懂TCP三次握手,四次揮手

對應地,伺服器端也會維護兩種佇列,處於 SYN_RCVD 狀態的半連線佇列,而處於 ESTABLISHED 狀態但仍未被應用程式 Accept 的為全連線佇列。

如果這兩個佇列滿了之後,就會出現各種丟包的情形:

檢視是否有連線溢位 
netstat -s | grep LISTEN 複製程式碼

半連線佇列滿了

在三次握手協議中,伺服器維護一個半連線佇列,該佇列為每個客戶端的 SYN 包開設一個條目(服務端在接收到 SYN 包的時候,就已經建立了request_sock結構,儲存在半連線佇列中)。

該條目表明伺服器已收到 SYN 包,並向客戶發出確認,正在等待客戶的確認包。

這些條目所標識的連線在伺服器處於 Syn_RECV 狀態,當伺服器收到客戶的確認包時,刪除該條目,伺服器進入 ESTABLISHED 狀態。

目前,Linux 下預設會進行 5 次重發 SYN-ACK 包,重試的間隔時間從 1s 開始,下次的重試間隔時間是前一次的雙倍,5 次的重試時間間隔為 1s, 2s, 4s, 8s, 16s, 總共 31s,稱為指數退避。

第 5 次發出後還要等 32s 才知道第 5 次也超時了,所以,總共需要 1s + 2s + 4s + 8s + 16s + 32s = 63s,TCP 才會斷開這個連線。

由於,SYN 超時需要 63 秒,那麼就給攻擊者一個攻擊伺服器的機會,攻擊者在短時間內傳送大量的 SYN 包給 Server(俗稱 SYN Flood 攻擊),用於耗盡 Server 的 SYN 佇列。

對於應對 SYN 過多的問題,Linux 提供了幾個 TCP 引數來調整應對:

  • tcp_syncookies
  • tcp_synack_retries
  • tcp_max_syn_backlog
  • tcp_abort_on_overflow

這一次,徹底弄懂TCP三次握手,四次揮手

全連線佇列滿了

當第三次握手時,當 Server 接收到 ACK 包之後,會進入一個新的叫 Accept 的佇列。

當 Accept 佇列滿了之後,即使 Client 繼續向 Server 傳送 ACK 的包,也會不被響應。

此時 ListenOverflows+1,同時 Server 通過 tcp_abort_on_overflow 來決定如何返回,0 表示直接丟棄該 ACK,1 表示傳送 RST 通知 Client。

相應的,Client 則會分別返回 read timeout 或者 connection reset by peer。

另外,tcp_abort_on_overflow 是 0 的話,Server 過一段時間再次傳送 SYN+ACK 給 Client(也就是重新走握手的第二步),如果 Client 超時等待比較短,就很容易異常了。

而客戶端收到多個 SYN ACK 包,則會認為之前的 ACK 丟包了。於是促使客戶端再次傳送 ACK ,在 Accept 佇列有空閒的時候最終完成連線。

若 Accept 佇列始終滿員,則最終客戶端收到 RST 包(此時服務端傳送 SYN+ACK 的次數超出了 tcp_synack_retries)。

服務端僅僅只是建立一個定時器,以固定間隔重傳 SYN 和 ACK 到服務端:

這一次,徹底弄懂TCP三次握手,四次揮手

命令

[root@server ~]#  netstat -s | egrep "listen|LISTEN" 
667399 times the listen queue of a socket overflowed 
667399 SYNs to LISTEN sockets ignored 複製程式碼

上面看到的 667399 times ,表示全連線佇列溢位的次數,隔幾秒鐘執行下,如果這個數字一直在增加的話肯定全連線佇列偶爾滿了。

檢視 Accept queue 是否有溢位:

[root@server ~]#  netstat -s | grep TCPBacklogDrop 複製程式碼

ss 命令:

[root@server ~]#  ss -lnt 
State Recv-Q Send-Q Local Address:Port Peer Address:Port 
LISTEN     0      128 *:6379 *:* 
LISTEN     0      128 *:22 *:* 複製程式碼

如果 State 是 Listen 狀態,Send-Q 表示第三列的 Listen 埠上的全連線佇列最大為 50,第一列 Recv-Q 為全連線佇列當前使用了多少。

非 LISTEN 狀態中 Recv-Q 表示 receive queue 中的 bytes 數量;Send-Q 表示 send queue 中的 bytes 數值。

小結:當外部連線請求到來時,TCP 模組會首先檢視 max_syn_backlog,如果處於 SYN_RCVD 狀態的連線數目超過這一閾值,進入的連線會被拒絕。

根據 tcp_abort_on_overflow 欄位來決定是直接丟棄,還是直接 reset。

從服務端來說,三次握手中,第一步 Server 接受到 Client 的 SYN 後,把相關資訊放到半連線佇列中,同時回覆 SYN+ACK 給 Client。第三步當收到客戶端的 ACK,將連線加入到全連線佇列。

一般,全連線佇列比較小,會先滿,此時半連線佇列還沒滿。如果這時收到 SYN 報文,則會進入半連線佇列,沒有問題。

但是如果收到了三次握手中的第 3 步(ACK),則會根據 tcp_abort_on_overflow 欄位來決定是直接丟棄,還是直接 reset。

此時,客戶端傳送了 ACK, 那麼客戶端認為三次握手完成,它認為服務端已經準備好了接收資料的準備。

但此時服務端可能因為全連線佇列滿了而無法將連線放入,會重新傳送第 2 步的 SYN+ACK,如果這時有資料到來,伺服器 TCP 模組會將資料存入佇列中。

一段時間後,Client 端沒收到回覆,超時,連線異常,Client 會主動關閉連線。

“三次握手,四次揮手”Redis 例項分析

①我在 dev 機器上部署 Redis 服務,埠號為 6379,通過 tcpdump 工具獲取資料包,使用如下命令:

tcpdump -w /tmp/a.cap port 6379 -s0 
-w把資料寫入檔案,-s0設定每個資料包的大小預設為68位元組,如果用-S0則會抓到完整資料包 複製程式碼

②在 dev2 機器上用 redis-cli 訪問 dev:6379, 傳送一個 ping, 得到回覆 pong,停止抓包,用 tcpdump 讀取捕獲到的資料包:

tcpdump -r /tmp/a.cap -n -nn -A -x| vim - (-x 以16進位制形式展示,便於後面分析) 複製程式碼

共收到了 7 個包。

抓到的是 IP 資料包,IP 資料包分為 IP 頭部和 IP 資料部分,IP 資料部分是 TCP 頭部加 TCP 資料部分。

IP 的資料格式為:

這一次,徹底弄懂TCP三次握手,四次揮手

它由固定長度 20B+ 可變長度構成。

這一次,徹底弄懂TCP三次握手,四次揮手

對著 IP 頭部格式,來拆解資料包的具體含義。

這一次,徹底弄懂TCP三次握手,四次揮手

剩餘的資料部分即為 TCP 協議相關的。TCP 也是 20B 固定長度+可變長度部分。

這一次,徹底弄懂TCP三次握手,四次揮手

可變長度部分,協議如下:

這一次,徹底弄懂TCP三次握手,四次揮手

這樣第一個包分析完了。dev2 向 dev 傳送 SYN 請求。也就是三次握手中的第一次了。

SYN seq(c)=4133153791 複製程式碼

第二個包,dev 響應連線,ack=4133153792。表明 dev 下次準備接收這個序號的包,用於 tcp 位元組注的順序控制。dev(也就是 server 端)的初始序號為 seq=4264776963, syn=1。

SYN ack=seq(c)+1 seq(s)=4264776963 複製程式碼

第三個包,client 包確認,這裡使用了相對值應答。seq=4133153792,等於第二個包的 ack. ack=4264776964。

ack=seq(s)+1, seq=seq(c)+1 
複製程式碼

至此,三次握手完成。接下來就是傳送 ping 和 pong 的資料了。

接著第四個包:

這一次,徹底弄懂TCP三次握手,四次揮手

tcp 首部長度為 32B,可選長度為 12B。IP 報文的總長度為 66B,首部長度為 20B,因此 TCP 資料部分長度為 14B. seq=0xf65a ec00=4133153792。

ACK, PSH. 資料部分為 2a31 0d0a 2434 0d0a 7069 6e67 0d0a:

0x2a31         -> *1 
0x0d0a         -> \r\n 
0x2434         -> $4 
0x0d0a         -> \r\n 
0x7069 0x6e67  -> ping 
0x0d0a         -> \r\n 複製程式碼

dev2 向 dev 傳送了 ping 資料,第四個包完畢。第五個包,dev2 向 dev 傳送 ack 響應。

序列號為 0xfe33 5504=4264776964,ack 確認號為 0xf65a ec0e=4133153806=(4133153792+14)。

第六個包,dev 向 dev2 響應 pong 訊息。序列號 fe33 5504,確認號 f65a ec0e,TCP 頭部可選長度為 12B,IP 資料包總長度為 59B,首部長度為 20B,因此 TCP 資料長度為 7B。

資料部分 2b50 4f4e 470d 0a,翻譯過來就是+PONG\r\n。至此,Redis 客戶端和 Server 端的三次握手過程分析完畢。

總結

“三次握手,四次揮手”看似簡單,但是深究進去,還是可以延伸出很多知識點的,比如半連線佇列、全連線佇列等等。

以前關於 TCP 建立連線、關閉連線的過程很容易就會忘記,可能是因為只是死記硬背了幾個過程,沒有深入研究背後的原理。所以,“三次握手,四次揮手”你真的懂了嗎?

注:本文來自於公眾號:碼農桃花源。作者:饒全成


相關文章