1949年,美國空軍一位名叫愛德華·墨菲的空軍上尉工程師,對他的某位運氣不太好的同事隨口開了句玩笑:「如果一件事有可能被做壞,讓他去做就一定會更壞。」由此衍生出墨菲定律(Murphy's Law),指「凡是可能出錯的事就一定會出錯」。
「墨菲定律」告訴我們:如果壞事情有可能發生,不管這種可能性有多小,它總會發生,並引起最大可能的損失。「墨菲定律」是軟體工程領域防禦性編程的最基礎理論,而防禦性編程是每個軟體工程師必須遵守的規範。
當你認為沒有錯誤的時候,錯就一定會來找你。
——《中國機長》劉傳健
武漢疾控中心副主任李剛2019年1月19日通報:「此次新型冠狀病毒的傳染力不強」。
219年(漢獻帝建安二十四年),劉備經已穩定益州、漢中,荊州守將關羽見時機成熟,遂北伐曹操。關羽攻樊城,孫權則從背後偷襲荊州。大本營南郡失,關羽唯有退守麥城,突圍時被擒殺。
以上的三個片段告訴我們,凡事疏忽大意,必然釀成極其嚴重的後果。反觀川航3U8633事故中,機長劉傳健在緊急情況下,對飛機的操作,出現任何一點差錯,都將直接導致機毀人亡。但是中國機長劉傳健史詩級的操控卻成就了成為世界航空史上的一個奇蹟。這與他時時謹記「墨菲定律」是分不開的。
任何時候都要做防禦性編程,將bug在早期控制住,因為bug修復成本的對數log(cost),與修復它的時間階段成比例:
所以,bug越晚暴露,修復的成本越指數級上升。如果李文亮醫生因為「造謠」被訓誡的時候,我們就開始修復這個bug,其成本毫無疑問會比現在的成本指數級下降。
早期的Android(2.2及以前的版本)有個著名的root提權漏洞,叫 「Rage Against The Cage(RAtC)」。
ADB在Android裡面最初以root權限運行,之後它通過setuid(AID_SHELL)降權到shell用戶,但是Android adb.c的代碼忽略了對函數返回值的檢查:
理論上,root用戶要降權到shell普通用戶,是沒人能阻擋的。類似馬雲在阿里巴巴強行要幹碼農,劉強東在京東強行要做快遞員。但是有一種情況下,馬雲做不了碼農,比如阿里巴巴公司有章程,規定最多只能1萬個碼農。公司HR聽說馬雲明天要宣布自己做碼農,前一天晚上就招滿了1萬個碼農,這樣第二天馬雲在宣布自己是碼農後,其實還是原來的身份。由於馬雲調用了setuid(碼農)也沒檢查返回值,它這個時候覺得自己已經是個碼農了。
在Linux系統中,一個用戶的進程數量受到RLIMIT_NPROC的限制,不可能無限制的創建。所以rage againest the cage攻擊的主要原理是不斷fork出shell用戶的進程,把pid的坑佔完,導致setuid(AID_SHELL)失敗:
相關代碼:rageagainstthecage.c
該提權漏洞被用於各類root刷機,漏洞發現人Sebastian Krahmer公布的利用工具RageAgainstTheCage(rageagainstthecage-arm5.bin)被用於z4root等提權工具、Trojan.Android.Rootcager等惡意代碼之中。
後續版本的Android對此bug進行了修正,非常簡單直接:
這個事情對我們的啟示是,過度"自信"的編碼,一定是錯誤代碼。
2019年,我也「親自」犯了一個這樣的錯誤,即使在我已經非常熟悉「墨菲定律」的情況下。
Linux的Graphics compositor與client端進行通信的一種常見協議wayland,通過UNIX DOMAIN socket在client和compositor進行通信,通信的每條消息通常很短:
主要是一些window對應的surfaces(以及對應的buffers)的創建、撤銷、自動等,消息通常非常短。經過我長達半年的觀察,我沒看到大於200個字節的消息。
後來我為了截獲wayland的message並進行最終的協議dump分析,弄了char buf[256]這樣的數據結構去緩存每一條消息,當時極度自信,因為半年的觀察告訴我,wayland上面的每條消息不可能超過200個字節,因此這裡256個字節應該是極度安全了。後來的事實是,軟體很快就崩了。因為網上下載的一個軟體,有個奇葩的標題(title),它的 title十分長。在wayland的shell協議裡面,window的標題是會透過socket發給compositor的:
如果有個奇葩軟體,其標題非常長,set_title後面的title字符串就可以撐破256個字節。
這件事情讓我再次領略到「墨菲定律」的神奇正確性,以及編碼不能太「自信」。否則,即使是白馬斬顏良,襄樊擒于禁、殺龐德,千裡走單騎的關羽大神,也可能兵敗荊州。
首先你一定要小心內存的越界,比如下面的代碼:
把環境變量拷貝給str,風險就是tmp完全可能大於str的長度。比較安全的做法可能是:
上述代碼,明確地告知了str的size,以及採用了strncpy保證了不越界。
我們從C語言各個版本memcpy函數的變遷也能看出防禦性編程對C語言本身發展的影響:
在C11之後,memcpy()演化出了memcpy_s(),多了一個destsz參數:
destsz | - | max number of bytes to modify in the destination (typically the size of the destination object) |
如果大家知道memcpy()這個函數引起的內存越界在歷史上作過多少孽的話,就會明白memcpy_s()的這個改進有多麼重大的意義。
防禦性編程要求我們一定要進行代碼的靜態掃描(在代碼運行前先進行體檢),這方面最出名的工具就是coverity,內核裡面有大量的補丁是修復coverity掃描出來的問題,比如這個:
https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=594619497f3d6d4b8d8440e6d380e8da9dcc9eeb
防禦性編程要求我們必須提供預設的行為,比如一定要考慮我們沒有考慮的分支,將代碼本身形成閉環。譬如,下面的代碼最後一定要有個else(無論你多麼的自信,這個else裡面的情況不可能出現):
防禦性編程要求我們一定要檢查函數的返回值,只要這個函數不是返回void,它就可能出錯,這個我們在rage againest the cage部分已經闡述。比如,哪怕是fork()、pthread_create()、read()、write()這些常規函數,都是極容易出錯的,你不能做最樂觀的估計。
防禦性編程要求我們一定要小心運算的越界,如Linux內核著名的Y2K38問題,時間變量會在2038年越界,但是如果2個時間變量平均呢?
avg_time = (time1 + time2)/2;
那麼就會提前一般的時間越界,我在2009年曾經花費了2個星期找到並修復這個bug,但是總共只改了1行代碼:
我國官方曾針對武漢八義士的事情發文:"但是,事實證明,儘管新型肺炎並不是SARS,但是信息發布者發布的內容,並非完全捏造。如果社會公眾當時聽信了這個'謠言',並且基於對SARS的恐慌而採取了佩戴口罩、嚴格消毒、避免再去野生動物市場等措施,這對我們今天更好地防控新型肺炎,可能是一件幸事。"這其實是最防禦性編程效果的最佳註解。
當然,防禦性編程不等於過度防禦。比如下面的代碼,不叫防禦性編程,叫有病:
拖著箱子從30層樓的家出門去機場,出到家門口,確認下門關好沒,反覆拉門把手;走到電梯口,拖著箱子走回家門口,反覆拉門把手確認門關好沒;坐電梯到1樓,又坐電梯回到30樓,反覆拉門把手確認門關好了沒~~。這不是防禦性編程,這是強迫症編程。
墨菲定律強調各種可能性最終都會實現,實際它也包含了正向的可能性。科幻電影巔峰之作之一的《星級穿越》,女主角的名字就叫「墨菲」。《星級穿越》實際重新詮釋了「墨菲定律」,讓「墨菲定律」並不總是指代壞事,而是說只要是有可能的事,就一定會發生,這自然包括拯救地球。主角的名字暗示了她和她的父親將最終聯手拯救地球:
同樣,李文亮醫生要拯救的地球一定會得救,我們一定會從疫情中走出來。勝利一定屬於英雄的中國人民。