Back to Posts

Robust UDP Challenge - 網路程式設計概論

前情提要

這學期修習黃俊穎老師開設的網路程式設計概論(網程設),前半學期教了如何用 TCP 從簡單的 socket 應用,到實作較為複雜的 IRC 伺服器。

這次第 12 周的實驗內容讓大家用兩週的時間,寫出能抵抗惡劣網路環境的 UDP 程式,完整傳送 1,000 個平均 16 KB 的檔案。

實驗限制

這次評測端使用 tc-netem (8) 製造出不穩定的網路環境,限制如下:

  • 封包有 40% 機率會遺失、10% 機率損毀
  • 延遲 50 ms - 150 ms
  • 網速上限 10 Mbps
  • 封包大小上限 1,500 bytes

在這樣的環境下,如果不特別做處理,基本上是傳不了檔案的,也因此是考驗各組技術的地方。

使用 ping 測試的結果(封包來回需乘二)
使用 ping 測試的結果(封包來回需乘二)

最初的想法

在開始寫程式之前,當然是要先設計自己的通訊協定原型。原本規劃好了整套如何取得目錄、如何得知檔名及內容的協議,不過愈想愈覺得在這次的實驗中,分檔案慢慢傳反而沒效率。

因此後來決定把寫好的協議及寫到一半的程式碼全部刪掉,從頭開始設計當成單一檔案傳輸的方法。

封包結構

實驗環境 MTU 只允許每個封包傳送至多 1,500 bytes 的內容,因此會需要切成不同區塊,本次設計的封包結構前 2 bytes 是區塊編號。
每個小封包扣掉 IP 層標頭的 20 bytes 及 UDP 層標頭的 8 bytes,再扣掉 2 bytes 的區塊編號後,共有 1,470 bytes 可供存放資料。

在我的作法中,是先合併出一個大封包。大封包最前面的數字是總區塊數,後面接著 1,000 個數字是各檔案的尺寸,再來各檔案內容直接連接在後,以節省填充內容佔用的空間。

也因為老師保證檔案大小在 32,000 bytes 以下,最極端的例子封包數也不會超過 22,000 個,因此只需要 2 bytes 的空間(-32768 ~ 32767)即可儲存。後面再接上所有檔案,合併後一個大封包總共約 16 MB 大小。

本次定義的標頭檔
本次定義的標頭檔

傳送邏輯

在相當穩定的環境下,我們可以只傳送不確認;在反應迅速的環境下,我們可以等收到確認封包再進行下一步;但在這次既慢又不穩的環境下,兩種做法效率都不會太好。

因此我採用的方式是發送端自行估算在恰好頂到速度上限時,每隔多久可以發送一個封包。
而接收端確認的方式,則是定期告訴發送端目前順利收到哪些封包了。

發送端詳細邏輯

對發送端來說,如果封包傳得太急,會踩到 10 Mbps 速率上限,導致緩衝區愈來愈長,封包需要排隊才能送出。
如果因此造成多餘的延遲,讓確認封包也塞在緩衝區,最終導致無謂的時間浪費和送出更多重複的區塊,因此我們要找出適當的發送頻率以達到最佳平衡。

以我的實作來說,每個封包都是吃滿 1,500 bytes 上限,多數流量來自發送端,來自接收端的確認封包佔少數。
在撰寫本文之前,埋頭寫程式都沒去認真計算理論值,經過測試得出發送端大約需延遲 0.756 毫秒,恰好是碰到速率上限的甜蜜點。

對於總共 16 MB 的檔案,為了符合最大傳輸上限 1,500 bytes 限制,分割後大約有 11,500 個區塊需要傳輸。
最開始接收端什麼都沒有,因此先全部都發送一輪(約需 8.5 秒),之後再透過接收端回傳的確認封包,看哪些區塊未順利送達,再次依序發送。

理想狀況中,每輪需要發送的區塊數量減半,所需時間也跟著減半。不過加上考慮延遲後,大約第六七輪開始,確認封包會來不及回傳。

接收端詳細邏輯

發送確認封包的方式有許多不同做法,依筆者心中的效率簡單排序,由低到高大致為:

  1. 收到每個區塊時,都單獨回傳確認(可能較差)
  2. 收到數個區塊後,用一個封包,批量進行確認
  3. 每隔一段時間,用多個封包,回傳伺服器的完整狀態
  4. 每隔一段時間,用多個封包,回傳所有收過的區塊編號
  5. 每隔一段時間,用一個封包,回傳新收到了哪些區塊
  6. 每隔一段時間,用一個封包,以 bitset 回傳伺服器狀態(可能較佳)

因此在最終的版本中,我是以 bitset 形式回傳接收端的狀態,每次只需要一個封包即可完成。

使用 bitset 儲存、傳送狀態
使用 bitset 儲存、傳送狀態

有思考過在最後剩下不到 500 個未完成區塊的時候,或許直接回傳近期收到的區塊編號可以再更節省流量,不過感覺實作相對麻煩就作罷了。

延遲詳細計算

已知條件為:

  • 每個封包 1,500 bytes
  • 速率上限 10 Mbps
  • 約有 40% 機率遺失封包、10% 機率損毀

根據定義可以計算:
(封包大小 1,500 bytes) * (單位轉換 8 bit / byte) * (成功率 60%) = 平均封包大小 7,200 bits
(速度 10 Mbps) / (封包 7,200 bits) = 每秒可傳送 1,388.88 個封包
1 / (每秒 1,388.88 封包) = 每封包間隔 0.72 毫秒

雖然理論值是這樣,不過因為 tc-netem 實作的關係,似乎 0.75 毫秒才是最佳延遲,實驗及試算了好久都不知道怎麼得出正確數字。

檔案壓縮

原先在 Discord 嘴砲說想直接用外部函式庫完成這次實驗,想說既然都讓老師把檔案亂度增加了,那應該是不需要玩壓縮的技巧。

於 Discord 詢問使用壓縮函式庫
於 Discord 詢問使用壓縮函式庫

不過後來得知檔案內容只包含可視字元,轉換為 ASCII 是 0x210x7E 的區段,在每個 byte 中 256 種可能中只佔了 94 種。

在此假設檔案亂度不低,也就是外部壓縮函式庫幫助有限,因此採用類似 base64_decode() 的方式將檔案從可視字元區段映射到整個空間。

最簡單的做法當然是取消最前面固定為 0 的位元,只要透過簡單的位元運算,就讓每 8 個可視字元字映射到 7 bytes,節省 12.5% 空間。

每 8 個可視字元可映射至 7 bytes
每 8 個可視字元可映射至 14 bytes

經過計算後,發現可以透過運算,把每 17 個可視字元映射到 14 bytes,省下了 17.6% 的空間。而運算過程中,剛好勉強低於 __int128 的上限,實作起來不算複雜。

如此讓每個封包 1,470 bytes 的內容實際可塞下 1,785 個可視字元,封包總數量減少到 9,500 個。

把每 17 個可視字元映射到 14 bytes
把每 17 個可視字元映射到 14 bytes

在最極端狀況下,每個封包其實可以塞下 1,794 個可視字元:
(log(256) / log(94)) * 1470 = 1794
雖然可以省下 18.1% 的空間,不過實作起來會困難許多,因此沒有採用此方法。

減少多餘等待時間

在經過分析後,發現原本的程式最前面將檔案合併成大封包,再分割成 9,500 個區塊的過程中,大約會耗費 500 毫秒的時間,因此改為邊切割邊發送第一輪封包。

而接收端如果在結束前才一次寫入,會耗費大約 350 毫秒的時間,發現後改為判斷接收完一個檔案就寫入一次。

另外由於評測的機制是發送端結束後,會強制關閉接收端並開始比對檔案。因此發送端不能太快結束,但太晚結束又會影響表現。
後來測試出接收端在剩下大約 5 - 10 個區塊未完成時,提前傳送關閉指令是較為折衷的作法。

動態調整發送間隔

為了進一步最佳化性能,在接收端回傳狀態封包時,也順便帶上當下的時間戳。
發送端可以用來計算單向的延遲,判斷緩衝區是否已被塞滿。

原本有實作根據延遲時間自動調整間隔的功能,不過由於延遲是隨機在 50 - 150 毫秒之間飄動,事後證實效益不大。
後來改為使用理論值來計算發送間隔,可以達到較穩定的效果。

沒用到 UDP 的玩法

從看到實驗限制後,就有想到或許可以透過指令列參數,利用側通道傳送資料,不過覺得做起來稍嫌麻煩,因此一直沒有動工。

在正規作法進步到 13.08 秒,想不到可以怎麼繼續加速後,就決定來實作看看,完成後還真的得到比當時第一名還快 70 倍的破表分數。
雖然有股想要壓到 0.02 秒以下的衝動,不過畢竟初衷只是確認可行性,還是決定先就此打住。

當下計分板 0.18 秒成績
當下計分板 0.18 秒成績

評測方式

評測平台會建立 /sender_dir//receiver_dir/ 兩個資料夾,並將發送端及接收端的執行檔分別置於 /sender_dir/sender_bin/receiver_dir/receiver_bin
/sender_dir/files/ 資料夾放有 1,000 個隨機檔案,執行結束後會檢查是否成功傳輸至 /receiver_dir/files/ 資料夾。

以發送端來說,先使用 chroot("/sender_dir/") 防止執行環境存取外部檔案後,再執行 /sender_bin --read-from="/files" --send-to="localhost:4242" 指令啟動。
接收端則是以 chroot("/receiver_dir/") 限制環境後,再執行 /receiver_bin --listen-port="4242" --store-to="/files" 指令,將透過 UDP 協定收到的封包儲存至 /receiver_dir/files/

本題是限制網路層的傳輸速度,應該有不少做法作法可以繞過,我只挑自己較為熟悉的一種來實作。

cmdline 簡介

我是從 2021 年 4 月在調研 ldapsearch 資安漏洞時,翻閱原始碼而接觸到這件事的,不過後來發現因為有盡最大努力側面修補,攻擊雖然可行但時間點需要抓得很精確,因此後續不了了之。
知道 cmdline 這種玩法後一直覺得很酷,或許寫成一篇科普短文也還不錯,但這裡為了篇幅就只講重點。

熟悉 Linux 的大家應該都用過 ps aux 指令,但知道其中實作方式的人可能不多。
在 Linux 系統根目錄底下有個特殊的 /proc/ 資料夾,存放核心(kernel)的相關資訊,而底下以程序編號(Process ID)命名的資料夾則存放各程序的狀態。
例如 /proc/[PID]/fd/ 底下以數字命名的檔案,會連結(symbolic link)到程式開啟的各個檔案;而 /proc/[PID]/cmdline 檔案則寫著被執行時的參數,以 0x00 (NUL) 字元區隔。

這個 cmdline 檔案雖然無法直接修改,但程式可以透過 prctl()pthread_setname_np() 更動,或直接修改 main() 函式收到的 char *argv[] 內容也會反映在上面。

透過 cmdline 查看指令參數
透過 cmdline 查看指令參數

作法說明

在通常狀況下,main() 收到的 char *argv[] 長度也就幾十、至多幾百個字,如果要拿來傳資料顯然效率不足。
這時可以透過上面提過的函式更動,不過在此為了簡單實作,採用之前學過的 exec() 呼叫自己,並在 argv[1] 傳遞一個很長很長的參數,目的是擴充 cmdline 檔案大小,才塞得下整個檔案內容。

在完成後就能自由運用 argv[1] 參數了,開始每隔 0.1 毫秒更新一次 argv[1],依序將檔案內容放上。這會同步到 /proc/[PID]/cmdline,也就是 ps aux 指令會看到的結果。

而接收端則是先等候一小段時間,先確保傳送端已啟動後,持續檢查位於 /proc/[PID]/cmdline 檔案的參數內容,如果有異動就寫入 /receiver_bin/files/[ID] 目標檔案。

使用 argv / cmdline 繞過限制傳送
使用 argv / cmdline 繞過限制傳送

使用這個方法,理論上可以再 fork() 出不同程序(process)同步執行,用到本題上限的 15 個程序可以加速不少。

也可以把間隔時間依照檔案大小自動調整,或是讓接收端發出訊號,減少不必要的等待時間。

後記

寫這個實驗的過程中,心中一直被「執行效能」的想法所侷限,但仔細思考後認知到在這個場景下,最珍貴的資源是網路而不是 CPU 效能,有許多顯然花費不合理運算的事情實際上卻可以增進執行效率。

老師在隔週的課堂也說了,原先沒想到大家會把時間用在最佳化上面,因此乾脆將一堂實驗課讓出來,給同學們分享各自作法的機會。
雖然有做好 簡報, 不過由於剛好要 參與資安檢測, 當周課堂無法出席報告,經過老師同意只好以 預錄影片 代替。

感謝 Thectyc油成Jerry HsuCTFangSirius KoanTwinkleStar03 等朋友協助審閱文章提供建議,指出盲點讓文意更通順。
對於文章有任何想法,都歡迎來 TwitterFacebook 留言交流,或是在課堂實體捕捉我。

如果想嘗試的朋友,老師也在課後將 實驗題目規格 公開,歡迎參考練習。

本名韋詠祥,習慣用英文 Sean 暱稱。
自國中開始接觸程式,至今善於組合各種技巧,用來解決生活周遭的問題。
以資安競賽、網路治理、個人專案為興趣。

Read Next

開始滑冰才知道的幾件事

Read Previous

交大資工所 推甄紀錄 韋詠祥