收到RST,就一定會斷開TCP連接嗎?
想必大家已經(jīng)知道我的niao性,搞個標題,就是不喜歡立馬回答。
就是要搞一大堆原理性的東西,再回答標題的問題。
說這個是因為我這次會把問題的答案就放到開頭嗎?
不!
我就不!
但是大家可以直接根據(jù)目錄看自己感興趣的部分。
之所以要先鋪墊一些原理,還是希望大家能先看些基礎的,再慢慢循序漸進,這樣有利于建立知識體系。多一點上下文,少一點gap。
好了,進入正題。
下面是這篇文章的目錄。

什么是RST
我們都知道TCP正常情況下斷開連接是用四次揮手,那是正常時候的優(yōu)雅做法。
但異常情況下,收發(fā)雙方都不一定正常,連揮手這件事本身都可能做不到,所以就需要一個機制去強行關閉連接。
RST 就是用于這種情況,一般用來異常地關閉一個連接。它是一個TCP包頭中的標志位。
正常情況下,不管是發(fā)出,還是收到置了這個標志位的數(shù)據(jù)包,相應的內(nèi)存、端口等連接資源都會被釋放。從效果上來看就是TCP連接被關閉了。
而接收到 RST的一方,一般會看到一個 connection reset 或 connection refused 的報錯。

怎么知道收到RST了?
我們知道內(nèi)核跟應用層是分開的兩層,網(wǎng)絡通信功能在內(nèi)核,我們的客戶端或服務端屬于應用層。應用層只能通過 send/recv 與內(nèi)核交互,才能感知到內(nèi)核是不是收到了RST。
當本端收到遠端發(fā)來的RST后,內(nèi)核已經(jīng)認為此鏈接已經(jīng)關閉。
此時如果本端應用層嘗試去執(zhí)行 讀數(shù)據(jù)操作,比如recv,應用層就會收到 Connection reset by peer 的報錯,意思是遠端已經(jīng)關閉連接。

如果本端應用層嘗試去執(zhí)行寫數(shù)據(jù)操作,比如send,那么應用層就會收到 Broken pipe 的報錯,意思是發(fā)送通道已經(jīng)壞了。

這兩個是開發(fā)過程中很經(jīng)常遇到的報錯,感覺大家可以把這篇文章放進收藏夾吃灰了,等遇到這個問題了,再打開來擦擦灰,說不定對你會有幫助。
出現(xiàn)RST的場景有哪些
RST一般出現(xiàn)于異常情況,歸類為 對端的端口不可用 和 socket提前關閉。
端口不可用
端口不可用分為兩種情況。要么是這個端口從來就沒有"可用"過,比如根本就沒監(jiān)聽(listen)過;要么就是曾經(jīng)"可用",但現(xiàn)在"不可用"了,比如服務突然崩了。
端口未監(jiān)聽

服務端listen 方法會創(chuàng)建一個sock放入到全局的哈希表中。
此時客戶端發(fā)起一個connect請求到服務端。服務端在收到數(shù)據(jù)包之后,第一時間會根據(jù)IP和端口從哈希表里去獲取sock。

如果服務端執(zhí)行過listen,就能從全局哈希表里拿到sock。
但如果服務端沒有執(zhí)行過listen,那哈希表里也就不會有對應的sock,結果當然是拿不到。此時,正常情況下服務端會發(fā)RST給客戶端。
端口未監(jiān)聽就一定會發(fā)RST嗎?
不一定。上面提到,發(fā)RST的前提是正常情況下,我們看下源碼。
// net/ipv4/tcp_ipv4.c
// 代碼經(jīng)過刪減
int tcp_v4_rcv(struct sk_buff *skb)
{
// 根據(jù)ip、端口等信息 獲取sock。
sk = __inet_lookup_skb(&tcp_hashinfo, skb, th->source, th->dest);
if (!sk)
goto no_tcp_socket;
no_tcp_socket:
// 檢查數(shù)據(jù)包有沒有出錯
if (skb->len < (th->doff << 2) || tcp_checksum_complete(skb)) {
// 錯誤記錄
} else {
// 發(fā)送RST
tcp_v4_send_reset(NULL, skb);
}
}
內(nèi)核在收到數(shù)據(jù)后會從物理層、數(shù)據(jù)鏈路層、網(wǎng)絡層、傳輸層、應用層,一層一層往上傳遞。到傳輸層的時候,根據(jù)當前數(shù)據(jù)包的協(xié)議是TCP還是UDP走不一樣的函數(shù)方法。可以簡單認為,TCP數(shù)據(jù)包都會走到 tcp_v4_rcv()。這個方法會從全局哈希表里獲取 sock,如果此時服務端沒有listen()過 , 那肯定獲取不了sock,會跳轉到no_tcp_socket的邏輯。
注意這里會先走一個 tcp_checksum_complete(),目的是看看數(shù)據(jù)包的校驗和(Checksum)是否合法。
校驗和可以驗證數(shù)據(jù)從端到端的傳輸中是否出現(xiàn)異常。由發(fā)送端計算,然后由接收端驗證。計算范圍覆蓋數(shù)據(jù)包里的TCP首部和TCP數(shù)據(jù)。
如果在發(fā)送端到接收端傳輸過程中,數(shù)據(jù)發(fā)生任何改動,比如被第三方篡改,那么接收方能檢測到校驗和有差錯,此時TCP段會被直接丟棄。如果校驗和沒問題,那才會發(fā)RST。
所以,只有在數(shù)據(jù)包沒問題的情況下,比如校驗和沒問題,才會發(fā)RST包給對端。
為什么數(shù)據(jù)包異常的情況下,不發(fā)RST?
一個數(shù)據(jù)包連校驗都不能通過,那這個包,多半有問題。

有可能是在發(fā)送的過程中被篡改了,又或者,可能只是一個胡亂偽造的數(shù)據(jù)包。
五層網(wǎng)絡,不管是哪一層,只要遇到了這種數(shù)據(jù),推薦的做法都是默默扔掉,而不是去回復一個消息告訴對方數(shù)據(jù)有問題。
如果對方用的是TCP,是可靠傳輸協(xié)議,發(fā)現(xiàn)很久沒有ACK響應,自己就會重傳。
如果對方用的是UDP,說明發(fā)送端已經(jīng)接受了“不可靠會丟包”的事實,那丟了就丟了。
因此,數(shù)據(jù)包異常的情況下,默默扔掉,不發(fā)RST,非常合理。

還是不能理解?那我再舉個例子。
正常人噴你,他說話條理清晰,主謂賓分明。此時你噴回去,那你是個充滿熱情,正直,富有判斷力的好人。
而此時一個憨憨也想噴你,但他思維混亂,連話都說不清楚,一直阿巴阿巴的,你雖然聽不懂,但大受震撼,此時你會?
A:跟他激情互噴
B:不跟他一般見識,就當沒聽過
一般來說最優(yōu)選擇是B,畢竟你理他,他反而來勁。
這下,應該就懂了。
程序啟動了但是崩了
端口不可用的場景里,除了端口未監(jiān)聽以外,還有可能是從前監(jiān)聽了,但服務端機器上做監(jiān)聽操作的應用程序突然崩了,此時客戶端還像往常一樣正常發(fā)送消息,服務器內(nèi)核協(xié)議棧收到消息后,則會回一個RST。在開發(fā)過程中,這種情況是最常見的。
比如你的服務端應用程序里,弄了個空指針,或者數(shù)組越界啥的,程序立馬就崩了。

這種情況跟端口未監(jiān)聽本質(zhì)上類似,在服務端的應用程序崩潰后,原來監(jiān)聽的端口資源就被釋放了,從效果上來看,類似于處于CLOSED狀態(tài)。
此時服務端又收到了客戶端發(fā)來的消息,內(nèi)核協(xié)議棧會根據(jù)IP端口,從全局哈希表里查找sock,結果當然是拿不到對應的sock數(shù)據(jù),于是走了跟上面"端口未監(jiān)聽"時一樣的邏輯,回了個RST??蛻舳嗽谑盏絉ST后也釋放了sock資源,從效果上來看,就是連接斷了。
RST和502的關系
上面這張圖,服務端程序崩潰后,如果客戶端再有數(shù)據(jù)發(fā)送,會出現(xiàn)RST。但如果在客戶端和服務端中間再加一個nginx,就像下圖一樣。

nginx會作為客戶端和服務端之間的"中間人角色",負責轉發(fā)請求和響應結果。但當服務端程序崩潰,比如出現(xiàn)野指針或者OOM的問題,那轉發(fā)到服務器的請求,必然得不到響應,后端服務端還會返回一個RST給nginx。nginx在收到這個RST后會斷開與服務端的連接,同時返回客戶端一個502錯誤碼。
所以,出現(xiàn)502問題,一般情況下都是因為后端程序崩了,基于這一點假設,去看看監(jiān)控是不是發(fā)生了OOM或者日志是否有空指針等報錯信息。
socket提前關閉
這種情況分為本端提前關閉,和遠端提前關閉。
本端提前關閉
如果本端socket接收緩沖區(qū)還有數(shù)據(jù)未讀,此時提前close() socket。那么本端會先把接收緩沖區(qū)的數(shù)據(jù)清空,然后給遠端發(fā)一個RST。

遠端提前關閉
遠端已經(jīng)close()了socket,此時本端還嘗試發(fā)數(shù)據(jù)給遠端。那么遠端就會回一個RST。

大家知道,TCP是全雙工通信,意思是發(fā)送數(shù)據(jù)的同時,還可以接收數(shù)據(jù)。
Close()的含義是,此時要同時關閉發(fā)送和接收消息的功能。
客戶端執(zhí)行close(), 正常情況下,會發(fā)出第一次揮手FIN,然后服務端回第二次揮手ACK。如果在第二次和第三次揮手之間,如果服務方還嘗試傳數(shù)據(jù)給客戶端,那么客戶端不僅不收這個消息,還會發(fā)一個RST消息到服務端。直接結束掉這次連接。
對方?jīng)]收到RST,會怎么樣?
我們知道TCP是可靠傳輸,意味著本端發(fā)一個數(shù)據(jù),遠端在收到這個數(shù)據(jù)后就會回一個ACK,意思是"我收到這個包了"。
而RST,不需要ACK確認包。
因為RST本來就是設計來處理異常情況的,既然都已經(jīng)在異常情況下了,還指望對方能正?;啬阋粋€ACK嗎?可以幻想,不要妄想。
但問題又來了,網(wǎng)絡環(huán)境這么復雜,丟包也是分分鐘的事情,既然RST包不需要ACK來確認,那萬一對方就是沒收到RST,會怎么樣?

RST丟了,問題不大。比方說上圖服務端,發(fā)了RST之后,服務端就認為連接不可用了。
如果客戶端之前發(fā)送了數(shù)據(jù),一直沒等到這個數(shù)據(jù)的確認ACK,就會重發(fā),重發(fā)的時候,自然就會觸發(fā)一個新的RST包。
而如果客戶端之前沒有發(fā)數(shù)據(jù),但服務端的RST丟了,TCP有個keepalive機制,會定期發(fā)送探活包,這種數(shù)據(jù)包到了服務端,也會重新觸發(fā)一個RST。

收到RST就一定會斷開連接嗎?
先說結論,不一定會斷開。我們看下源碼。
// net/ipv4/tcp_input.c
static bool tcp_validate_incoming()
{
// 獲取sock
struct tcp_sock *tp = tcp_sk(sk);
// step 1:先判斷seq是否合法(是否在合法接收窗口范圍內(nèi))
if (!tcp_sequence(tp, TCP_SKB_CB(skb)->seq, TCP_SKB_CB(skb)->end_seq)) {
goto discard;
}
// step 2:執(zhí)行收到 RST 后該干的事情
if (th->rst) {
if (TCP_SKB_CB(skb)->seq == tp->rcv_nxt)
tcp_reset(sk);
else
tcp_send_challenge_ack(sk);
goto discard;
}
}
收到RST包,第一步會通過tcp_sequence先看下這個seq是否合法,其實主要是看下這個seq是否在合法接收窗口范圍內(nèi)。如果不在范圍內(nèi),這個RST包就會被丟棄。
至于接收窗口是個啥,我們先看下面這個圖。

這里黃色的部分,就是指接收窗口,只要RST包的seq不在這個窗口范圍內(nèi),那就會被丟棄。
為什么要校驗是否在窗口范圍內(nèi)
正常情況下客戶端服務端雙方可以通過RST來斷開連接。假設不做seq校驗,如果這時候有不懷好意的第三方介入,構造了一個RST包,且在TCP和IP等報頭都填上客戶端的信息,發(fā)到服務端,那么服務端就會斷開這個連接。同理也可以偽造服務端的包發(fā)給客戶端。這就叫RST攻擊。

受到RST攻擊時,從現(xiàn)象上看,客戶端老感覺服務端崩了,這非常影響用戶體驗。
如果這是個游戲,我相信多崩幾次,第二天大家就不來玩了。
實際消息發(fā)送過程中,接收窗口是不斷移動的,seq也是在飛快的變動中,此時第三方是比較難構造出合法seq的RST包的,那么通過這個seq校驗,就可以攔下了很多不合法的消息。
加了窗口校驗就不能用RST攻擊了嗎
不是,只是增加了攻擊的成本。但如果想搞,還是可搞的。
以下是面向監(jiān)獄編程的環(huán)節(jié)。
希望大家只了解原理就好了,不建議使用。
相信大家都不喜歡穿著藍白條紋的衣服,拍純獄風的照片。
從上面可以知道,不是每一個RST包都會導致連接重置的,要求是這個RST包的seq要在窗口范圍內(nèi),所以,問題就變成了,我們怎么樣才能構造出合法的seq。
盲猜seq
窗口數(shù)值seq本質(zhì)上只是個uint32類型。
struct tcp_skb_cb {
__u32 seq; /* Starting sequence number */
}
如果在這個范圍內(nèi)瘋狂猜測seq數(shù)值,并構造對應的包,發(fā)到目的機器,雖然概率低,但是總是能被試出來,從而實現(xiàn)RST攻擊。這種亂棍打死老師傅的方式,就是所謂的合法窗口盲打(blind in-window attacks)。
覺得這種方式比較笨?那有沒有聰明點的方式,還真有,但是在這之前需要先看下面的這個問題。
已連接狀態(tài)下收到第一次握手包會怎么樣?
我們需要了解一個問題,比如服務端在已連接(ESTABLISHED)狀態(tài)下,如果收到客戶端發(fā)來的第一次握手包(SYN),會怎么樣?
以前我以為服務單會認為客戶端憨憨了,直接RST連接。
但實際,并不是。
static bool tcp_validate_incoming()
{
struct tcp_sock *tp = tcp_sk(sk);
/* 判斷seq是否在合法窗口內(nèi) */
if (!tcp_sequence(tp, TCP_SKB_CB(skb)->seq, TCP_SKB_CB(skb)->end_seq)) {
if (!th->rst) {
// 收到一個不在合法窗口內(nèi)的SYN包
if (th->syn)
goto syn_challenge;
}
}
/*
* RFC 5691 4.2 : 發(fā)送 challenge ack
*/
if (th->syn) {
syn_challenge:
tcp_send_challenge_ack(sk);
}
}
當客戶端發(fā)出一個不在合法窗口內(nèi)的SYN包的時候,服務端會發(fā)一個帶有正確的seq數(shù)據(jù)ACK包出來,這個ACK包叫 challenge ack。

上圖是抓包的結果,用scapy隨便偽造一個seq=5的包發(fā)到服務端(端口9090),服務端回復一個帶有正確seq值的challenge ack包給客戶端(端口8888)。
利用challenge ack獲取seq
上面提到的這個challenge ack ,仿佛為盲猜seq的老哥們打開了一個新世界。
在獲得這個challenge ack后,攻擊程序就可以以ack值為基礎,在一定范圍內(nèi)設置seq,這樣造成RST攻擊的幾率就大大增加了。

總結
RST其實是TCP包頭里的一個標志位,目的是為了在異常情況下關閉連接。
內(nèi)核收到RST后,應用層只能通過調(diào)用讀/寫操作來感知,此時會對應獲得 Connection reset by peer 和Broken pipe 報錯。
發(fā)出RST后不需要得到對方的ACK確認包,因此RST丟失后對方不能立刻感知,但是通過下一次重傳數(shù)據(jù)或keepalive心跳包可以導致RST重傳。
收到RST包,不一定會斷開連接,seq不在合法窗口范圍內(nèi)的數(shù)據(jù)包會被默默丟棄。通過構造合法窗口范圍內(nèi)seq,可以造成RST攻擊,這一點大家了解就好,千萬別學!
參考資料
TCP旁路攻擊分析與重現(xiàn) - https://www.cxyzjd.com/article/qq_27446553/52416369
