現(xiàn)實世界中的網(wǎng)絡(luò)是由無數(shù)的計算機和路由器組成的一張的大網(wǎng),應(yīng)用的數(shù)據(jù)包在發(fā)送到服務(wù)器之前都要經(jīng)過層層的路由轉(zhuǎn)發(fā)。而Traceroute是一種常規(guī)的網(wǎng)絡(luò)分析工具,用來定位到目標(biāo)主機之間的所有路由器
原理
在介紹Traceroute的原理之前,需要了解幾個技術(shù)名詞:
-
IP協(xié)議
IP協(xié)議是TCP/IP協(xié)議族中最核心的部分,它的作用是在兩臺主機之間傳輸數(shù)據(jù),所有上層協(xié)議的數(shù)據(jù)(HTTP、TCP、UDP等)都會被封裝在一個個的IP數(shù)據(jù)包中被發(fā)送到網(wǎng)絡(luò)上。
ICMP
ICMP全稱為互聯(lián)網(wǎng)控制報文協(xié)議,它常用于傳遞錯誤信息,ICMP協(xié)議是IP層的一部分,它的報文也是通過IP數(shù)據(jù)包來傳輸?shù)摹?/p>TTL
TTL(time-to-live)是IP數(shù)據(jù)包中的一個字段,它指定了數(shù)據(jù)包最多能經(jīng)過幾次路由器。從我們源主機發(fā)出去的數(shù)據(jù)包在到達目的主機的路上要經(jīng)過許多個路由器的轉(zhuǎn)發(fā),在發(fā)送數(shù)據(jù)包的時候源主機會設(shè)置一個TTL的值,每經(jīng)過一個路由器TTL就會被減去一,當(dāng)TTL為0的時候該數(shù)據(jù)包會被直接丟棄(不再繼續(xù)轉(zhuǎn)發(fā)),并發(fā)送一個超時ICMP報文給源主機。
具體到traceroute的實現(xiàn)細節(jié)上,有兩種不同的方案:
基于UDP實現(xiàn)
在基于UDP的實現(xiàn)中,客戶端發(fā)送的數(shù)據(jù)包是通過UDP協(xié)議來傳輸?shù)模褂昧艘粋€大于30000的端口號,服務(wù)器在收到這個數(shù)據(jù)包的時候會返回一個端口不可達的ICMP錯誤信息,客戶端通過判斷收到的錯誤信息是TTL超時還是端口不可達來判斷數(shù)據(jù)包是否到達目標(biāo)主機,具體的流程如圖:

- 客戶端發(fā)送一個TTL為1,端口號大于30000的UDP數(shù)據(jù)包,到達第一站路由器之后TTL被減去1,返回了一個超時的ICMP數(shù)據(jù)包,客戶端得到第一跳路由器的地址。
- 客戶端發(fā)送一個TTL為2的數(shù)據(jù)包,在第二跳的路由器節(jié)點處超時,得到第二跳路由器的地址。
- 客戶端發(fā)送一個TTL為3的數(shù)據(jù)包,數(shù)據(jù)包成功到達目標(biāo)主機,返回一個端口不可達錯誤,traceroute結(jié)束。
Linux和macOS系統(tǒng)自帶了一個traceroute指令,可以結(jié)合Wireshark抓包來看看它的實現(xiàn)原理。首先對百度的域名進行traceroute:traceroute www.baidu.com,每一跳默認發(fā)送三個數(shù)據(jù)包,我們會看到下面這樣的輸出:

對該域名的IP:115.239.210.27進行traceroute,此時Wireshark抓包的結(jié)果如下:

注意看紅框處的內(nèi)容,跟第一張圖對比,可以看到traceroute程序首先通過UDP協(xié)議向目標(biāo)地址115.239.210.27發(fā)送了一個TTL為1的數(shù)據(jù)包,然后在第一個路由器中TTL超時,返回一個錯誤類型為Time-to-live exceeded的ICMP數(shù)據(jù)包,此時我們通過該數(shù)據(jù)包的源地址可知第一站路由器的地址為10.242.0.1。之后只需要不停增加TTL的值就能得到每一跳的地址了。
然而一直跑下去會發(fā)現(xiàn),traceroute并不能到達目的地,當(dāng)TTL增加到一定大小之后就一直拿不到返回的數(shù)據(jù)包了:

其實這個時候數(shù)據(jù)包已經(jīng)到達目標(biāo)服務(wù)器了,但是因為安全問題大部分的應(yīng)用服務(wù)器都不提供UDP服務(wù)(或者被防火墻擋掉),所以我們拿不到服務(wù)器的任何返回,程序就理所當(dāng)然的認為還沒有結(jié)束,一直嘗試增加數(shù)據(jù)包的TTL。
目前在網(wǎng)上找到許多開源iOS traceroute實現(xiàn)大多都是基于UDP的方案,實際用起來并不能達到想要的效果,所以我們需要采用另一種方案來實現(xiàn)。
基于ICMP實現(xiàn)
上述方案失敗的原因是由于服務(wù)器對于UDP數(shù)據(jù)包的處理,所以在這一種實現(xiàn)中我們不使用UDP協(xié)議,而是直接發(fā)送一個ICMP回顯請求(echo request)數(shù)據(jù)包,服務(wù)器在收到回顯請求的時候會向客戶端發(fā)送一個ICMP回顯應(yīng)答(echo reply)數(shù)據(jù)包,在這之后的流程還是跟第一種方案一樣。這樣就避免了我們的traceroute數(shù)據(jù)包被服務(wù)器的防火墻策略墻掉。
采用這種方案的實現(xiàn)流程如下:

- 客戶端發(fā)送一個TTL為1的ICMP請求回顯數(shù)據(jù)包,在第一跳的時候超時并返回一個ICMP超時數(shù)據(jù)包,得到第一跳的地址。
- 客戶端發(fā)送一個TTL為2的ICMP請求回顯數(shù)據(jù)包,得到第二跳的地址。
- 客戶端發(fā)送一個TTL為3的ICMP請求回顯數(shù)據(jù)包,到達目標(biāo)主機,目標(biāo)主機返回一個ICMP回顯應(yīng)答,traceroute結(jié)束。
可以看出與第一種實現(xiàn)相比,區(qū)別主要在發(fā)送的數(shù)據(jù)包類型以及對于結(jié)束的判斷上,大體的流程還是一致的。
值得一提的是在Windows系統(tǒng)中也有traceroute程序,它的名字叫做tracert,tracert就是用采用這種方法來實現(xiàn)的,感興趣的話可以自行嘗試一下,這里就不再演示了。
實現(xiàn)
這里我們主要討論基于ICMP的實現(xiàn),相關(guān)的Demo已經(jīng)上傳至github:https://github.com/L-Zephyr/TracerouteDemo.git
采用這種方案時,ICMP數(shù)據(jù)包的創(chuàng)建、解析、校驗都需要我們自己進行,ICMP是封裝在IP數(shù)據(jù)包的數(shù)據(jù)段中傳輸?shù)模躁P(guān)鍵在于如何創(chuàng)建和發(fā)送ICMP數(shù)據(jù),以及接收到返回的數(shù)據(jù)時如何從IP數(shù)據(jù)包中將ICMP解析出來:
創(chuàng)建ICMP數(shù)據(jù)
ICMP數(shù)據(jù)包頭部的格式如下:

其中的類型字段用來表示消息的類型,在Wiki上可以看到所有類型代表的含義。報文中的標(biāo)識符和序列號由發(fā)送端指定,如果這個ICMP報文是一個請求回顯的報文(類型為8,代碼為0),這兩個字段會被原封不動的返回。
根據(jù)上圖中各個字段的大小可以定義如下類型:
typedef struct ICMPPacket {
uint8_t type; // 類型
uint8_t code; // 類型代碼
uint16_t checksum; // 校驗碼
uint16_t identifier; // ID
uint16_t sequenceNumber; // 序列號
// data...
} ICMPPacket;
其中的type字段指定了這個ICMP數(shù)據(jù)包的類型,是需要重點關(guān)注的對象,為此定義一個報文類型的枚舉:
// ICMPv4報文類型
typedef enum ICMPv4Type {
kICMPv4TypeEchoReply = 0, // 回顯應(yīng)答
kICMPv4TypeEchoRequest = 8, // 回顯請求
kICMPv4TypeTimeOut = 11, // 超時
}ICMPv4Type;
比較麻煩的是校驗的計算,這一部分直接使用了蘋果官方示例SimplePing中的代碼,所涉及到的幾個工具方法封裝在類型TracerouteCommon中。
在發(fā)送數(shù)據(jù)的時系統(tǒng)會自動加上IP頭部不需要自己處理,如此一來我們只需要創(chuàng)建一個ICMPPacket數(shù)據(jù)包并通過socket發(fā)送到目標(biāo)服務(wù)器就可以了。
解析ICMP數(shù)據(jù)
接下來就是要接收服務(wù)器向我們返回的ICMP數(shù)據(jù)了,我們接收到的是帶有IP頭部的原始數(shù)據(jù),所以必須先進行一些處理將ICMP從IP數(shù)據(jù)包中提取出來,IP數(shù)據(jù)包由兩部分組成:數(shù)據(jù)包頭部信息部分以及實際的數(shù)據(jù)部分。下圖是IPv4數(shù)據(jù)包的結(jié)構(gòu):

一眼看上去是不是感覺很混亂,其實這里面只有用紅框圈出來的這這三個字段需要我們關(guān)心:版本表示該數(shù)據(jù)包是IPv4還是IPv6;之前說過ICMP協(xié)議是通過IP協(xié)議來傳輸?shù)模绻摂?shù)據(jù)包傳輸?shù)氖荌CMP協(xié)議則協(xié)議字段會被設(shè)置為1;由于IPv4數(shù)據(jù)包帶有可選的選項字段,所以其頭部的長度是可變的,此時需要根據(jù)首部長度字段來獲取具體的數(shù)據(jù)。
根據(jù)上面的結(jié)構(gòu)可以定義類型:
typedef struct IPv4Header {
uint8_t versionAndHeaderLength; // 版本和首部長度
uint8_t serviceType;
uint16_t totalLength;
uint16_t identifier;
uint16_t flagsAndFragmentOffset;
uint8_t timeToLive;
uint8_t protocol; // 協(xié)議類型,1表示ICMP
uint16_t checksum;
uint8_t sourceAddress[4];
uint8_t destAddress[4];
// options...
// data...
} IPv4Header;
提取ICMP數(shù)據(jù)包的方法如下:
+ (ICMPPacket *)unpackICMPv4Packet:(char *)packet len:(int)len {
if (len < (sizeof(IPv4Header) + sizeof(ICMPPacket))) {
return NULL;
}
const struct IPv4Header *ipPtr = (const IPv4Header *)packet;
if ((ipPtr->versionAndHeaderLength & 0xF0) != 0x40 || // IPv4
ipPtr->protocol != 1) { // ICMP
return NULL;
}
// 獲取IP頭部長度
size_t ipHeaderLength = (ipPtr->versionAndHeaderLength & 0x0F) * sizeof(uint32_t);
if (len < ipHeaderLength + sizeof(ICMPPacket)) {
return NULL;
}
// 返回數(shù)據(jù)部分的ICMP
return (ICMPPacket *)((char *)packet + ipHeaderLength);
}
其中出現(xiàn)的如ipPtr->versionAndHeaderLength & 0xF0的判斷是因為版本號和首部長度各自只占4個bit,在結(jié)構(gòu)中直接定義了一個1字節(jié)的uint8_t類型來表示,所以只能通過位運算符&來獲取各自的值。
整體流程
有了上面的兩步,剩下的事情就很簡單了,下面是整體流程的偽代碼:
// 1. 創(chuàng)建一個套接字
int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP);
// 2. 最多嘗試30跳
int ttl = 1;
for (0...30) {
// 3. 設(shè)置TTL,發(fā)送3個ICMP數(shù)據(jù)包,每一跳都將遞增TTL
setsockopt(sock, IPPROTO_IP, IP_TTL, &ttl, sizeof(ttl));
++ttl;
for (0...3) {
// 4. 發(fā)送并等待返回的數(shù)據(jù)包
sendto(...);
recvfrom(...);
// 5. 解析數(shù)據(jù)包,記錄數(shù)據(jù),成功條件判斷
ICMPPacket *packet = unpack(...);
}
}
socket的類型采用了SOCK_DGRAM,有些小伙伴可能會感到疑惑:用SOCK_DGRAM創(chuàng)建套接字不還是發(fā)送UDP數(shù)據(jù)么?
確實在許多系統(tǒng)的實現(xiàn)中要直接發(fā)送ICMP的話需要使用原始套接字(類型為SOCK_RAW),這在iOS系統(tǒng)中是不被允許使用的,但是查閱資料中了解到macOS支持一種使用參數(shù)SOCK_DGRAM和IPPROTO_ICMP來直接創(chuàng)建ICMP套接字方式,嘗試之下果然iOS也支持這種用法。不過在使用中發(fā)現(xiàn)了一個問題:使用IPv4套接字的時候接收到的數(shù)據(jù)包是帶有原始IP頭部的,而使用IPv6套接字的時候收到的數(shù)據(jù)包卻沒有IP頭部,這個問題讓我比較疑惑,各位大佬如果有對這一塊了解的話還望賜教。
總結(jié)
Demo中的示例程序已經(jīng)在模擬器和真機環(huán)境經(jīng)過測試,可以看到,現(xiàn)在Traceroute已經(jīng)能夠正常的工作了:

有些路由器會隱藏的自己的位置,不讓ICMP Timeout的消息通過,結(jié)果就是在那一跳上始終會顯示星號,此外服務(wù)器也可以偽造traceroute路徑的,不過一般應(yīng)用服務(wù)器也沒有理由這么做,所以Traceroute的結(jié)果還是能夠為網(wǎng)絡(luò)分析提供一些參考的。