一個由程式記憶體佈局異常引起的問題

發表於2017-02-16

前段時間業務反映某類伺服器上更新了 bash 之後,ssh 連上去偶發登陸失敗,客戶端吐出錯誤資訊如下所示:

一個由程式記憶體佈局異常引起的問題

該版本 bash 為部門這邊所定製,但實現上並沒有改動原有邏輯,只是加入了些監控功能,那麼這些錯誤從哪裡來?

是 bash 的鍋嗎

從上面的錯誤資訊可以猜測,異常是 bash 在啟動過程中分配記憶體失敗所導致,看起來像是某些情況下該程式錯誤地進行了大量記憶體分配,最後導致記憶體不足,要確認這個事情比較簡單,動態記憶體分配到系統呼叫這一層上主要就兩種方式: brk() 和 mmap(), 所以只要統計一下這兩者的呼叫就可以大概估算出是否有大記憶體分配了。

bash 是由 sshd 啟動的,於是 strace 跟蹤了一下 sshd 程式,結果發現異常發生時,bash 分配的記憶體非常地少,少到有時甚至只有幾十位元組也會失敗,幾乎可以斷定 bash 在記憶體使用上沒有異常,但在這期間發現一個詭異的現象,Bash 一直只用 brk 在分配小記憶體,brk() 失敗後就直接退出了,一般程式使用的 libc 中的 malloc (或其它類似的 malloc) 會結合 brk 和 mmap 一起使用【0】,不至於 brk 一失敗就分配不到記憶體,順手檢視了下 bash 的原始碼,發現它確實基於 brk 做了自己的記憶體管理,並沒有使用 malloc 或 mmap。

但那並不是重點,重點是即使是隻使用 brk,也不至於只能分配幾十位元組的記憶體。

程式的記憶體佈局

程式的記憶體佈局在結構上是有規律的,具體來說對於 linux 系統上的程式,其記憶體空間一般可以粗略地分為以下幾大段【1】,從高記憶體到低記憶體排列:
1、核心態記憶體空間,其大小一般比較固定(可以編譯時調整),但 32 位系統和 64 位系統的值不一樣。
2、使用者態的堆疊,大小不固定,可以用 ulimit -s 進行調整,預設一般為 8M,從高地址向低地址增長。
3、mmap 區域,程式茫茫記憶體空間裡的主要部分,既可以從高地址到低地址延伸(所謂 flexible layout),也可以從低到高延伸(所謂 legacy layout),看程式具體情況【2】【3】。
4、brk 區域,緊鄰資料段(甚至貼著),從低位向高位伸展,但它的大小主要取決於 mmap 如何增長,一般來說,即使是 32 位的程式以傳統方式延伸,也有差不多 1 GB 的空間(準確地說是 TASK_SIZE/3 – 程式碼段資料段,參看 arch/x86/include/asm/processor.h 裡巨集 TASK_UNMAPPED_BASE 的定義)【4】
5、資料段,主要是程式裡初始化和未初始化的全域性資料總和,當然還有編譯器生成一些輔助資料結構等等),大小取決於具體程式,其位置緊貼著程式碼段。
6、程式碼段,主要是程式的指令,包括使用者程式碼和編譯器生成的輔助程式碼,其大小取決於具體程式,但起始位置根據 32 位還是 64 位一般固定(-fPIC, -fPIE等除外【5】)。

以上各段(除了程式碼段資料段)其起始位置根據系統是否起用 randomize_va_space 一般稍有變化,各段之間因此可能有隨機大小的間隔,千言萬語不如一幅圖:

一個由程式記憶體佈局異常引起的問題

所以現在的問題歸結為:為什麼目標程式的 brk 的區域突然那麼小了,先檢查一下 bash 的記憶體佈局:

一個由程式記憶體佈局異常引起的問題

這個程式的記憶體佈局和一般理解上有很大出入,從上往下是低記憶體到高記憶體:
#1 處為程式的程式碼段和資料段,這兩個區域一般處於程式記憶體空間的最低處,但現在在更低處明顯有動態庫被對映了進來。
#2 處為 brk 的區域,該區域還算緊臨著資料段,但是,brk 與程式碼段之間也被插入了動態庫,而且更要命的是,brk 區域向高處伸展的方向上,動態庫對映的區域貼的很近,導致 brk 的區域事實上只有很小一個空間(0x886000 – 0x7ac000)。

這並不是我們想要的記憶體佈局,我們想要的應該是長成下面這樣的:

一個由程式記憶體佈局異常引起的問題

看出來不同了沒有,兩個 bash 程式都是 64 位的,不同在於前者是 sshd 起的程式後者是我手動在終端上起起來的,手動 cat /proc/self/maps 看了下 64 位的 cat 的程式的記憶體佈局也是正常的:

一個由程式記憶體佈局異常引起的問題

那 sshd 程式呢?
一個由程式記憶體佈局異常引起的問題

sshd 程式也不正常,而且意外發現 sshd 是 32 位的,於是寫了個測試程式:

一個由程式記憶體佈局異常引起的問題

該程式編譯為 32 位在目標機器上可以重現問題,而如果編譯為 64 位則一切正常,另一個發現是隻要是 32 位的程式,它們的記憶體佈局都”不正常”。

作業系統的鍋嗎?

要搞清楚這個問題得先搞明白程式在核心裡啟動的流程,對使用者態的程式來說,任何程式都是從母程式 fork 出來後再執行 execve, execv 則主要呼叫對應的載入器(主要是 elf loader)來把程式碼段、資料段以及動態聯結器(ld.so,如果需要)載入進記憶體空間的各個相應位置,完成之後直接跳到動態聯結器的入口(這裡先忽略靜態連結的程式),其它的動態庫都由動態庫聯結器負責載入,需要注意的是,無論是核心載入 ld.so 還是 ld.so 載入其它動態庫,都需要 mmap 的協助,這是用來在記憶體空間裡找位置用的。

現在我們來看看核心出了什麼問題,目標系統版本如下,經過諮詢系統組的人確認,該系統基於 centos 6.5: http://vault.centos.org/6.5/centosplus/Source/SPackages/kernel-2.6.32-431.el6.centos.plus.src.rpm

一個由程式記憶體佈局異常引起的問題

首先看看 arch/x86/mm/mmap.c: arch_pick_mmap_layout() 這個函式,它的作用是根據程式和當前系統的設定初化 mmap 相關的入口:

一個由程式記憶體佈局異常引起的問題

Exec-shield 是一類安全功能的開關,由紅帽在很多年前主導搞的對 buffer overflow 攻擊的一系列增強,具體可以參看這幾個連線 1234,exec shield 在實現和使用上一直有問題,也破壞了有些舊程式的相容性【6】,因此一直沒進主幹,只在 redhat 家族 6.x 及其派生系統上使用。

這個功能有一個開關 /proc/sys/kernel/exec-shield,根據連結【6】上的說明,exec-shield 可以設定為 0、1、2、3,分別表示:強制關閉/預設關閉除非可執行程式指定開啟/預設開啟除非可執行程式指定關閉/強制開啟。

mm->get_unmapped_area 是程式需要進行 mmap 時呼叫的最終函式, arch_get_unmap_area() 用來以傳統方式從低位開始搜尋合適的位置,arch_get_unmapped_area_topdown() 則以 flexible layout 的方式從高位開始搜尋合適的位置,關鍵點在於 125 ~ 129 行,exec-shield 引進了另一種專門針對 32 位程式的記憶體分配方式,這種方式指定如果要分配的記憶體需要可執行許可權,那麼應該從 mm->shlib_base 這裡開始搜尋合適的位置,shlib_base 的值為 SHLIB_BASE 加上一個小的隨機偏移,而 SHLIB_BASE 的值為【7】:
一個由程式記憶體佈局異常引起的問題

注意到該地址位於 32 位程式的程式碼段之前(0x8048000),所以這就解釋了為什麼 32 位的程式,它的動態庫被載入到了低位甚至穿插進了 brk 和資料段之間的空隙,本來這個特殊的搜尋記憶體空間的方式是隻針對需要可執行許可權的記憶體,但由於 elf 載入器在載入動態庫時是分段(PT_LOAD)進行載入【8】,第一個段的位置由 mm->get_unmap_area() 搜尋合適的位置分配,後續的段則使用 MAP_FIXED 強制放在了第一個段的後面,所以導致資料段也對映到了低位.【9】

下圖 1641 行展示了 mmap 時怎樣從 mm 結構裡獲取 get_area 函式,可以看到,只要 mm->get_unmmapped_exec_area 不為空,且要分配的記憶體需要可執行許可權,就優先使用 mm->get_unmmapped_exec_area 進行搜尋。

一個由程式記憶體佈局異常引起的問題

上面這種針對 exec 記憶體的分配方式實際上很容易引起衝突,redhat 在這裡也是打了不少補丁,參看123

問題並沒有解決

上面的解釋說明了為什麼 32 位程式的記憶體佈局會異常,但是這裡的問題是,為什麼用 32 位程式起 64 位程式時,64 位的程式也同樣受到了影響。要搞清楚這裡的問題,就得看看 fs/binfmt_elf.c: load_elf_binary() 這個函式,它用來在當前程式中載入 elf 格式可執行檔案並跳過去執行,此函式被 32 位的 elf 與 64 位 elf 所共用(藉助了比較隱蔽的巨集),它做的事情總結起來包括如下:
1、讀取和解析 elf 檔案裡包含的各種資訊,關鍵資訊如程式碼段,資料段,動態連結器等。
2、flush_old_exec(): 停止當前程式內的所有執行緒,清空當前記憶體空間,重置各種狀態等。
3、設定新程式的狀態,如分配記憶體空間,初始化等。
4、載入動態聯結器並跳過去執行。

一個由程式記憶體佈局異常引起的問題

現在回到我們問題,當前程式是 32 位的,在 64 位的系統上執行 32 位的程式需要核心支援,當核心發現 elf 是 32 位的程式時,會在 task 內部置一個標誌,這個標誌在上圖 load_elf_binary() 函式裡 740 行呼叫 SET_PERSONALITY() 才會被清除,所以在 721 行時,當前程式仍認為自己是 32 位的,flush_old_exec() 做了什麼事情呢,參看:fs/exec.c: flush_old_exec()

一個由程式記憶體佈局異常引起的問題

注意其中 1039 行,bprm->mm 表示新的記憶體空間(舊的還在,但馬上就要釋放並切換新的),這裡需要對新的記憶體空間進行設定,參看: fs/exec.c: exec_mmap()

一個由程式記憶體佈局異常引起的問題

我們可以看到在當前程式還是 32 位的時候,核心對新的記憶體空間進行了初始化,導致 arch_pick_mmap_layout() 錯誤地將 arch_get_unmaped_exec_area 賦值給了 bprm->mm->get_unmapped_exec_area 這個成員變數,雖然圖-11中 load_elf_binary() 函式在 748 行,32 位的標誌被清空之後再次呼叫 set_up_new_exec() -> arch_get_unmapped_exec_area(),但 arch_get_unmaped_exec_area() 並沒有清空 mm->get_unmapped_exec_area 這個變數,導致 execv 後雖然程式是 64 位的,但仍然以 mm->shlib_base 這裡作為起始地址搜尋記憶體空間給動態庫使用, oops.

解決方案

最直接可靠的做法是在進入 arch_pick_mmap_layout() 時,先把 mm->get_unmapped_exec_area 置為 NULL,但這就要修改核心了,使用者態要規避的話有以下方式:
1、設定 ulimit -s unlimited,並設定 exec-shield 為 0 或 1,再起程式,這樣一來,因為使用者態的棧是無限長的,核心只能以傳統的方式來對 32 位程式分配記憶體,不會掉進 exec-shield 的坑裡。
2、把 randomize_va_space 禁掉,但這個做法只是把頭埋進了沙子裡。

總的來說,上面兩種使用者態的規避方案基本是哪裡疼往哪貼膏藥,並非解決問題之道(且有安全隱患),退一步來說,不要用 32 位的程式來起動 64 位程式還相對穩妥點.

參考

【0】https://en.wikipedia.org/wiki/C_dynamic_memory_allocation
【1】https://access.redhat.com/documentation/en-US/Red_Hat_Enterprise_Linux/5/html/Tuning_and_Optimizing_Red_Hat_Enterprise_Linux_for_Oracle_9i_and_10g_Databases/sect-Oracle_9i_and_10g_Tuning_Guide-Growing_the_Oracle_SGA_to_2.7_GB_in_x86_Red_Hat_Enterprise_Linux_2.1_Without_VLM-Linux_Memory_Layout.html
【2】understanding the linux kernel, page 819, flexible memory region layout: https://books.google.com.hk/books?id=h0lltXyJ8aIC&pg=PT925&lpg=PT925&dq=linux+flexible+memory&source=bl&ots=gO7rIYb8HR&sig=pirB5pswdHFHSljy57EksxS3ABw&hl=en&sa=X&ved=0ahUKEwjpkfa-2_rRAhVGFJQKHcETDSUQ6AEITDAH#v=onepage&q=linux%20flexible%20memory&f=false
【3】https://gist.github.com/CMCDragonkai/10ab53654b2aa6ce55c11cfc5b2432a4
【4】http://lxr.free-electrons.com/source/arch/x86/include/asm/processor.h#L770
【5】 https://access.redhat.com/blogs/766093/posts/1975793
【6】https://lwn.net/Articles/31032/
【7】https://lwn.net/Articles/454949/
【8】http://lxr.free-electrons.com/source/fs/binfmt_elf.c#L549
【9】http://lxr.free-electrons.com/source/fs/binfmt_elf.c#L563
【10】類似問題: https://bugzilla.redhat.com/show_bug.cgi?id=870914 https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=522849

相關文章