開源WindivertDotnet

2022-10-18 06:00:36

0 前言

Hi,好久沒有寫部落格,因為近段時間沒有新的開源專案給大家。現在終於又寫了一篇,是關於網路方向的內容,希望對部分讀者有幫助。

1 WinDivert介紹

WinDivert是windows下為數不多的非常優秀網路庫,非常適合用於開發抓包或修改包的應用程式,其擁有以下能力:

  • 抓取網路封包
  • 過濾或丟棄網路封包
  • 嗅探網路封包
  • 注入網路封包
  • 修改網路封包

同時WinDivert還提供了完整的loopback(迴環)IP、IPv6的支援,簡約而強大的Api、高階別的過濾語言(可以想象為sql一樣的東西)。

如此優秀的專案自然有著各個語言的二次封裝專案,我在github上也找到了對應多個的dotnet封裝專案,但無一例外,他們封裝的比較簡陋或太過於簡陋,下面是封裝專案的一些不足之處:

  1. IPHeader、TcpHeader、UdpHeader等未提供網路和主機的Endian轉換
  2. 侷限於PInvoke,沒有意識使用dotnet的物件(比如IPv4直接宣告為uint型別)
  3. 沒有物件導向的封裝,甚至簡陋到只有宣告了static的PInvoke方法
  4. 過濾語言沒有任何處理,使用時要翻閱WinDivert的檔案(寫手sql一個感覺)
  5. 沒有非同步IO封裝,都是清一色的IO同步阻塞(非同步IO封裝難度大)

2 WindivertDotnet介紹

WindivertDotnet是物件導向的WinDivert的dotnet非同步封裝,其保持著完整的底層庫能力,又提供dotnet的完美語法來操作:

  • Filter物件支援Lambda構建filter language,脫離字串的苦海;
  • 記憶體安全的WinDivert物件,基於IOCP的ValueTask非同步傳送與接收方法;
  • 記憶體安全的WinDivertPacket物件,提供獲取包有效資料長度、解包、重構chucksums等;
  • WinDivertParseResult提供對解包的資料進行精細修改,修改後對WinDivertPacket直接生效;

2.1 網路和主機的Endian自動轉換

由於windows平臺是LittleEndian,而標準的IPHeader、TcpHeader、UdpHeader網路定義都是BigEndian,如果未做任何處理,當接收到一個SrcPort為80、DstPort為443的Tcp包時對映為結構體時,你調式會看到如下結果:

欄位 偵錯看到的值 要理解為的值
SrcPort 20480 80
DstPort 47873 443

由於沒有做Endian自動轉換,在偵錯時看到的資料甚至讓人抓狂,此時如果你把SrcPort改為我們理解為81埠,你是不能直接寫xxx.SrcPort = 81這樣的csharp程式碼的,應該是xxx.SrcPort = 20736

WindivertDotnet專案花了很大的時間精力,為所有涉及的結構體欄位存取時都做了必要的Endian讀取和寫入自動轉換,讓呼叫者不再為Endian問題費腦子。

2.2 結合使用dotnet型別

IPv4地址佔用4位元組,IPv6地址佔用16位元組,所以一些封裝專案直接在結構體宣告為uint SrcAddrfixed uint SrcAddr[4],當然這些宣告是沒有錯誤,但是你叫使用者怎麼使用呢,使用者往往是var ipAddress = IPAddress.Parse("1.2.3.4)"得到一個IPAddress型別,他們沒有精力去研究怎麼把IPAddress轉為你的uint或uint[4],或者從uint或uint[4]轉換為IPAddress型別,再加上使用了uint,又得注意Endian的轉換,造成這種封裝離實際應用太遙遠。

WindivertDotnet在宣告欄位型別時,當存在對應的dotnet高階型別時,優先使用這些高階型別,除了IPAddress之外,如果欄位可以使用列舉的,也都宣告為了列舉型別,甚至在修改這些屬性值時,有嚴格的輸入校驗。

2.3 物件導向的封裝

WindivertDotnet將零散的過程式c-api,包裝為多種物件,而不是讓你面對滿天飛的各種靜態方法PInvoke呼叫IntPrt控制程式碼和維護這些控制程式碼的生命週期,例如WinDivertPacket物件,其本質是一個非託管的緩衝區記憶體,在沒有封裝之前,它就是一個csharp的IntPrt型別,看到這個型別,你得加個八倍鏡觀察可以做為引數傳給哪些靜態Api方法,同時確保不要忘記不使用之後,要手動去釋放它,否則記憶體就一直佔用。

Api 原Api
int Capacity { get; }
int Length { get; set;}
Span Span { get; }
void Clear()
Span GetSpan(int, int)
bool CalcChecksums(WinDivertAddress, ChecksumsFlag) WinDivertHelperCalcChecksums
bool CalcNetworkIfIdx(WinDivertAddress )
bool CalcOutboundFlag(WinDivertAddress)
bool CalcLoopbackFlag(WinDivertAddress)
bool DecrementTTL() WinDivertHelperDecrementTTL
int GetHashCode() WinDivertHelperHashPacket
int GetHashCode(long) WinDivertHelperHashPacket
WinDivertParseResult GetParseResult() WinDivertHelperParsePacket
void Dispose()

2.4 Filter

filter language是WinDivert引以為豪的設計,對WinDivert來說就像是從0到1發明了sql一樣,它允許使用簡單的文字表示式來讓驅動層高效能地過濾得自己感興趣的封包,比如outbound and !loopback and (tcp.DstPort == 80 or udp.DstPort == 53),這種filter的作用,想必使用過wireshark軟體的都特別明白。

不足的是,人們在做dotnet封裝時,僅僅做了Invoke(string filter)這種傳話筒式的封裝,好傢伙,filter language一共100個欄位左右,我保證使用者不翻看filter language寶典的話,肯定不知道怎麼構造這個string內容,您好歹從語法層面超越一下,提供一下filter的Builder也好啊。

WindivertDotnet提供Filter型別使用Lambda來構造這個filter language,有了它您不再需要珍藏filter language葵花寶典了,就像使用了EF之後不會sql又何妨呢,因為如下的csharp程式碼,每個人都打得出:

var filter = Filter.True
    .And(f => f.Network.Outbound && !f.Network.Loopback)
    .And(f => f.Tcp.DstPort == 80 || f.Udp.DstPort == 53);

2.5 非同步IO封裝

沒有async和await的IO,那不是完美的IO,WinDivert提供了可選的LPOVERLAPPED,讓上層可以使用IOCP模型,遺憾的是目前沒有任何封裝專案應用了這個引數,並結合IOCP模型包裝為dotnet的Task或ValueTask非同步模型。他們都是直接PInvoke使用了SendRecv這兩個api,或者是SendEx之後又同步阻塞等待LPOVERLAPPED的完成,這種和dotnet裡的 Task.Wait() 其實是一個道理,呼叫工作執行緒在IO完成之前只能乾等,而沒法抽身回到執行緒池中。

WindivertDotnetLPOVERLAPPED與IOCP模型結合,並封裝為dotnet的TAP非同步模型,凝結出下面兩個核心方法:

ValueTask<int> RecvAsync(WinDivertPacket, WinDivertAddress, CancellationToken);

ValueTask<int> SendAsync(WinDivertPacket, WinDivertAddress, CancellationToken);

Api方法是簡單,但過程曲折,沒有資料,碰壁無數,哪怕是小小的CancellationToken引數,但它卻能讓pendding的IO操作復原下來。

3 後話

因FastGithub專案的需要,所以本專案才得以誕生,現在我是結合實際專案的中使用痛點來改進本專案,甚至新增了一些WinDivert目前沒有的功能,相信本專案越來越好用。