記得剛畢業找工作面試的時候,經常會被問到:你知道“3次握手,4次揮手”嗎?這時候我會“胸有成竹”地“背誦”前期準備好的“答案”,第一次怎麼怎麼,第二次……答完就沒有下文了,面試官貌似也沒有深入下去的意思,深入下去我也不懂,皆大歡喜!
作為程式設計師,要有“刨根問底”的精神。知其然,更要知其所以然。這篇文章希望能抽絲剝繭,還原背後的原理。
什麼是“3次握手,4次揮手”?
TCP是一種面向連線的單播協議,在傳送資料前,通訊雙方必須在彼此間建立一條連線。所謂的“連線”,其實是客戶端和伺服器的記憶體裡儲存的一份關於對方的資訊,如ip地址、埠號等。
TCP可以看成是一種位元組流,它會處理IP層或以下的層的丟包、重複以及錯誤問題。在連線的建立過程中,雙方需要交換一些連線的引數。這些引數可以放在TCP頭部。
TCP提供了一種可靠、面向連線、位元組流、傳輸層的服務,採用三次握手建立一個連線。採用4次揮手來關閉一個連線。
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層確定雙方程式,序列號表示的是報文段資料中的第一個位元組號,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頭部,而沒有資料。
狀態轉換
三次握手和四次揮手的狀態轉換如下圖。
為什麼要“三次握手,四次揮手”?
三次握手
換個易於理解的視角來看為什麼要3次握手。
客戶端和服務端通訊前要進行連線,“3次握手”的作用就是雙方都能明確自己和對方的收、發能力是正常的。
第一次握手:客戶端傳送網路包,服務端收到了。這樣服務端就能得出結論:客戶端的傳送能力、服務端的接收能力是正常的。
第二次握手:服務端發包,客戶端收到了。這樣客戶端就能得出結論:服務端的接收、傳送能力,客戶端的接收、傳送能力是正常的。 從客戶端的視角來看,我接到了服務端傳送過來的響應資料包,說明服務端接收到了我在第一次握手時傳送的網路包,並且成功傳送了響應資料包,這就說明,服務端的接收、傳送能力正常。而另一方面,我收到了服務端的響應資料包,說明我第一次傳送的網路包成功到達服務端,這樣,我自己的傳送和接收能力也是正常的。
第三次握手:客戶端發包,服務端收到了。這樣服務端就能得出結論:客戶端的接收、傳送能力,服務端的傳送、接收能力是正常的。 第一、二次握手後,服務端並不知道客戶端的接收能力以及自己的傳送能力是否正常。而在第三次握手時,服務端收到了客戶端對第二次握手作的迴應。從服務端的角度,我在第二次握手時的響應資料傳送出去了,客戶端接收到了。所以,我的傳送能力是正常的。而客戶端的接收能力也是正常的。
經歷了上面的三次握手過程,客戶端和服務端都確認了自己的接收、傳送能力是正常的。之後就可以正常通訊了。
每次都是接收到資料包的一方可以得到一些結論,傳送的一方其實沒有任何頭緒。我雖然有發包的動作,但是我怎麼知道我有沒有發出去,而對方有沒有接收到呢?
而從上面的過程可以看到,最少是需要三次握手過程的。兩次達不到讓雙方都得出自己、對方的接收、傳送能力都正常的結論。其實每次收到網路包的一方至少是可以得到:對方的傳送、我方的接收是正常的。而每一步都是有關聯的,下一次的“響應”是由於第一次的“請求”觸發,因此每次握手其實是可以得到額外的結論的。比如第三次握手時,服務端收到資料包,表明看服務端只能得到客戶端的傳送能力、服務端的接收能力是正常的,但是結合第二次,說明服務端在第二次傳送的響應包,客戶端接收到了,並且作出了響應,從而得到額外的結論:客戶端的接收、服務端的傳送是正常的。
用表格總結一下:
視角 | 客收 | 客發 | 服收 | 服發 |
---|---|---|---|---|
客視角 | 二 | 一 + 二 | 一 + 二 | 二 |
服視角 | 二 + 三 | 一 | 一 | 二 + 三 |
四次揮手
TCP連線是雙向傳輸的對等的模式,就是說雙方都可以同時向對方傳送或接收資料。當有一方要關閉連線時,會傳送指令告知對方,我要關閉連線了。這時對方會回一個ACK,此時一個方向的連線關閉。但是另一個方向仍然可以繼續傳輸資料,等到傳送完了所有的資料後,會傳送一個FIN段來關閉此方向上的連線。接收方傳送ACK確認關閉連線。
注意,接收到FIN報文的一方只能回覆一個ACK, 它是無法馬上返回對方一個FIN報文段的,因為結束資料傳輸的“指令”是上層應用層給出的,我只是一個“搬運工”,我無法瞭解“上層的意志”。
“三次握手,四次揮手”怎麼完成?
其實3次握手的目的並不只是讓通訊雙方都瞭解到一個連線正在建立,還在於利用資料包的選項來傳輸特殊的資訊,交換初始序列號ISN。
3次握手是指傳送了3個報文段,4次揮手是指傳送了4個報文段。注意,SYN和FIN段都是會利用重傳進行可靠傳輸的。
三次握手
客戶端傳送一個SYN段,並指明客戶端的初始序列號,即ISN(c).
服務端傳送自己的SYN段作為應答,同樣指明自己的ISN(s)。為了確認客戶端的SYN,將ISN(c)+1作為ACK數值。這樣,每傳送一個SYN,序列號就會加1. 如果有丟失的情況,則會重傳。
為了確認伺服器端的SYN,客戶端將ISN(s)+1作為返回的ACK數值。
四次揮手
客戶端傳送一個FIN段,幷包含一個希望接收者看到的自己當前的序列號K. 同時還包含一個ACK表示確認對方最近一次發過來的資料。
服務端將K值加1作為ACK序號值,表明收到了上一個包。這時上層的應用程式會被告知另一端發起了關閉操作,通常這將引起應用程式發起自己的關閉操作。
服務端發起自己的FIN段,ACK=K+1, Seq=L 4. 客戶端確認。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狀態,但還未被應用程式接受。
對應地,伺服器端也會維護兩種佇列,處於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 來調整應對。
全連線佇列滿
當第三次握手時,當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到服務端:
命令
netstat -s命令
[root@server ~]# netstat -s | egrep "listen|LISTEN"
667399 times the listen queue of a socket overflowed
667399 SYNs to LISTEN sockets ignored
複製程式碼
上面看到的 667399 times ,表示全連線佇列溢位的次數,隔幾秒鐘執行下,如果這個數字一直在增加的話肯定全連線佇列偶爾滿了。
[root@server ~]# netstat -s | grep TCPBacklogDrop
複製程式碼
檢視 Accept queue 是否有溢位。
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位元組,如果用-S 0則會抓到完整資料包複製程式碼
在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的資料格式為:
它由固定長度20B+可變長度構成。
10:55:45.662077 IP dev2.39070 > dev.6379: Flags [S], seq 4133153791, win 29200, options [mss 1460,sackOK,TS val 2959270704 ecr 0,nop,wscale 7], length 0
0x0000: 4500 003c 08cf 4000 3606 14a5 0ab3 b561
0x0010: 0a60 5cd4 989e 18eb f65a ebff 0000 0000
0x0020: a002 7210 872f 0000 0204 05b4 0402 080a
0x0030: b062 e330 0000 0000 0103 0307
複製程式碼
對著IP頭部格式,來拆解資料包的具體含義。
剩餘的資料部分即為TCP協議相關的。TCP也是20B固定長度+可變長度部分。
可變長度部分,協議如下:
這樣第一個包分析完了。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的資料了。
接著第四個包。
10:55:48.090073 IP dev2.39070 > dev.6379: Flags [P.], seq 1:15, ack 1, win 229, options [nop,nop,TS val 2959273132 ecr 3132256230], length 14
0x0000: 4500 0042 08d1 4000 3606 149d 0ab3 b561
0x0010: 0a60 5cd4 989e 18eb f65a ec00 fe33 5504
0x0020: 8018 00e5 4b5f 0000 0101 080a b062 ecac
0x0030: bab2 6fe6 2a31 0d0a 2434 0d0a 7069 6e67
0x0040: 0d0a
複製程式碼
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建立連線、關閉連線的過程很容易就會忘記,可能是因為只是死記硬背了幾個過程,沒有深入研究背後的原理。
所以,“三次握手,四次揮手”你真的懂了嗎?
作者:饒全成,中科院計算所碩士,滴滴出行後端研發工程師。
宣告:本文為作者投稿,版權歸其個人所有。