/dev/mem
裝置改寫自 Linux /dev/mem的新玩法 一文,採用 CC BY-SA 授權
/dev/mem
裡有什麼簡單來講,Linux 核心的 /dev/mem
是系統實體記憶體的映像檔案,且打從 Linux v0.01 就存在,這裡的「實體記憶體」需要進一步解釋。
實體記憶體是指插在主機板上的那些一條又一條的記憶體嗎?當然是,但實體記憶體不單單指條狀的記憶體儲存實體。
延伸閱讀: 像織毛衣一樣把記憶體織出來
嚴格來說,實體記憶體是指實體定址空間,而記憶體這樣的儲存裝置僅是映射到這個定址空間的一部分,其餘的還有各種 PCI 設備、IO port 等。我們可從 /proc/iomem
中看到這個映射:
由於 Linux 安全考量,用一般使用者的權限來執行 $ cat /proc/iomem
和超級使用者 (即 root
),會看到不同的內容,僅有後者能看到完整的資訊
其中,只有 RAM 才是指記憶體儲存裝置。關於實體定址空間的詳細情況,請參考 BIOS-e820 相關的素材,後者是 BIOS 讓開機引導程式 (boot loader) 或 Linux 一類的作業系統核心得以存取的記憶體映射資訊。
明白實體記憶體的組成之後,我們接著觀察 /dev/mem
裡面有什麼。
事實上,它就是個執行中的 Linux 系統即時映像 (live image),所有行程的 task_struct
結構、sock
結構、sk_buff
結構,和行程資料等都在裡面的某個位置:
如果能夠定位它們在 /dev/mem
裡的位置,我們就能得到系統中這些資料結構的實時值,所謂的除錯工具所做的事情也是如此。其實我們透過 core dump 檔案進行除錯所用的 vmcore
也是個實體記憶體映像,和 /dev/mem
不同的是,它是一具鹹魚。
當 Linux 核心發生崩潰 (crash) 時,可經由 kdump 等方式蒐集崩潰前記憶體的狀態,並建立一個 core dump 檔案,也就是 vmcore
來紀錄崩潰前的記憶體狀態,開發人員可以透過分析 vmcore
得知造成崩潰的原因。
也就是說,vmcore
雖然也是一個實體記憶體映像,但它紀錄的內容是崩潰前的記憶體狀態而不是當下的記憶體狀態,因此這邊戲稱為鹹魚。
粵語中「鹹魚」是「屍體」的意思:在處理屍體的過程中會「打包」,即是將屍體加上防腐劑後,再以白色麻布包裹著屍體,緊縛著以免屍體接觸空氣和水氣。這個形象和廣東人曬鹹魚的過程相仿:曬鹹魚前用鹽醃好魚類再曬乾,當魚類曬乾風乾之後,再用乾的白紙包裹鹹魚魚身。於是,廣東人將屍體稱作「鹹魚」。
但無論是活體,還是鹹魚,其功能與紀錄訊息的方式皆是相同的,分析它們的手段也相同。和靜態分析 vmcore
不同的是,/dev/mem
是一個動態的記憶體映像(會記錄當下的記憶體狀態),有時候可以借助它做一些正經的事情。
下面通過幾個範例,介紹和展示 /dev/mem
的一些玩法。
操作環境資訊
Linux 核心的記憶體管理子系統非常強大,同時也非常複雜。我們享受其恩惠的同時,偶爾也會被其折磨得痛苦不堪。動輒記憶體不足 (Out Of Memory killer, OOM killer) 殺掉關鍵行程,動輒 flush 導致 CPU 使用率飆高…
為了避免任意行程任意使用記憶體,我們引入資源隔離的機制,比如 cgroup,但這樣反而會變得更加複雜。能否單純保留一部分記憶體,使其不受到核心的記憶體管理系統控制呢?就好像很多資料庫可以不經檔案系統直接存取硬碟一樣,核心中有沒有什麼機制能讓我們不經過記憶體管理系統而直接使用記憶體呢?
當然有!加上 mem
啟動參數即可實現。
這裡介紹一種關於保留記憶體的最簡單配置,在cmdline
中設置 mem
啟動參數:
mem=11G
此指令為 kernel cmdline 的指令,cmdline 藉由 bootloader 傳送給 kernel,kernel 按照解析的結果進行資源配置。
使用 cat /proc/cmdline
查看目前 cmdline
的參數
系統總共有 12G 的記憶體(實體記憶體的總容量),那麼上述啟動參數將會保留 12G-11G 的記憶體空間不會被核心中的記憶體管理系統所管理,因此保留下來的記憶體就是 1G。
目前是利用 grub2 來傳入 cmdline,透過編輯 /etc/default/grub
檔案中的 GRUB_CMDLINE_LINUX_DEFAULT=""
來達成,從目前啟用前後的差異來看,少掉的記憶體不只 1G,問題應該是出在 mem=11G
這項,尚未找到解決辦法。
觀察加入 mem
前後 free
跟 /proc/iomem
的差異:
配置前
加入 mem=11g
後
可以觀察到,顯示的記憶體空間確實減少了,這是因為被保留下來的記憶體將不會記入核心的任何統計。
換句話說, 核心不再管理這 1G 的記憶體空間,程式可以任意修改,任意洩漏 (leak, 指程式未能釋放不再使用的記憶體),任意溢位 (超過能保存的最大空間),任意覆蓋,指的都不會對系統造成任何影響,所謂的系統保留的含意指的就是核心不會為該段記憶體空間建立–映射表(x86_64 為原系统可以映射 64T 的實體記憶體)。
我們經常使用的 crash 工具讀取記憶體使用的方式就是一一映射(mapping)。
在 x86_64 的平台上,每一個非保留的實體記憶體分頁可能會有多個映射,而被保留的實體記憶體分頁不會有下面第一種映射:
- 一一映射到
0xffff880000000000
開始虛擬記憶體地址。【保留的分頁無這種映射】- 映射到使用者空間,使用行程的記憶體空間。
- 臨時映射到核心模式空間臨時 touch。
- …
我們試著用 crash
工具來讀取一下保留的記憶體:
顯然,核心並沒有對保留分頁建立一一映射的分頁表,所以讀取是失敗的。
我們已經知道了 /dev/mem
檔案是整個實體記憶體的映像,所以使用者行程可以使用 mmap
系統呼叫來重建使用者空間的頁表。方法如下:
是不是很簡單呢?
此時,在我們操作 mmap
的行程中便可以存取保留的記憶體了:
這個範例中,我們展示了 /dev/mem
如何用來存取保留的記憶體。接下來我們將繼續用簡單的小例子展現 /dev/mem
的其它玩法。
注意: 這邊的操作因為會存取 /dev/mem
,需要以超級使用者的權限來執行
這邊的程式有稍作修改,先用 getchar(); 讓程式等待使用者輸入時操作 crash
做觀察;
但想展示修改後記憶體的值一直失敗,還在找顯示修改後數值方式。
有一種需求:
我們不希望 process_A 和 process_B 共用任何分頁,這意味著它們不能同時操作同一份資料。
但偶爾我們也希望 process_A 和 process_B 交換資訊,卻又不想用低效率的傳統行程間通信的機制。
是不是覺得兩難了呢? 其實我們可以讓這兩個行程進行分頁交換來達到目的。
為了讓分頁表交換盡可能簡單,我們依然使用保留記憶體,解除核心記憶體管理系統對操作的限制。
下面給出範例程式的程式碼,首先看 process_A (master.c):
接下來看要與 process_A 做分頁表交換的 process_B (slave.c):
分頁交換的原理非常簡單,互換兩個行程的兩個虛擬地址的頁表項即可。實作這件事意味著需要撰寫核心模組,但是由於我們僅進行展示,所以我們可以用 crash 工具輕鬆達到目標。
使用
crash
修改/dev/mem
時,需要使用ststemtap
hook 住 devmeme_is_allowed, 使其返回值永遠為 1,之後才可直接修改。操作步驟:
安裝systemtap
執行stap -g -e 'probe kernel.function("devmem_is_allowed").return { $return = 1 }'
接著再開啟crash
使用 crash
修改 master 的分頁
使用 crash
修改 slave 的分頁
master 執行結果
slave 執行結果
過程圖解如下:
crash
找到對應的實體記憶體地址與分頁資訊這個範例非常適合用在設計微核心的行程間通信,搭配快取一致性協議,可以達到非常高的效率。
所謂的安全竄改的記憶體指的是用一種可靠的方法改行程的記憶體,而不是通過手工 hack 分頁的方式,簡單起見,這次我們藉助 crash
工具來完成。
首先我們看一個程式:
執行
我們想要把「浙江溫州皮鞋濕」 這一塊記憶體的內容改成「下雨進水不會胖」的話,可以怎麼做呢?
方法很多,這裡介紹的是利用 crash
和 /dev/mem
的方法。
首先我們要找到 addr 對應的實體記憶體地址 (利用 crash
加上程式顯示的虛擬記憶體地址):
我們得到了 addr 對應的實體記憶體地址是 0x1f83ed000
現在讓我們再寫另一支程式,映射 /dev/mem
,然後修改偏移量 0x1f83ed000 處的記憶體即可竄改:
直接執行:
回到按一下 Enter 觀察結果:
這個例子比較簡單,也顯得比較無趣,下面這個例子稍微有點意思。
/dev/mem
來修改行程的名稱這一個例子我們將不使用 crash
工具,僅僅依靠 hack /dev/mem
來修改一個行程的名字。
這對於一個網際網路產品的運行是有意義的。
特別是在一些託管的機器上,為了防止資訊洩漏,一般是不允許使用類似crash & gdb
工具來 debug 的,當然了,systemtap
API 有限制,所以相對安全,而核心模組一般也會被禁止。
但是有systemtap
和/dev/mem
就足夠了!
我們來做這樣一個實驗:
看看如何完成。先來看一個很簡單的程式:
編譯並執行
現在我們想辦法把行程的名稱從 pixie
改成 skinshoe
。
沒有crash
也沒有 gdb
,只有一個可以讀寫的 /dev/mem
(假設我們已經 HOOK 了 devmem_si_allowed) 要怎麼做到呢?
我們知道,核心中所有的資料結構都可以在 /dev/mem
中找到,因此,我們要找到 pixie
行程的 task_struct
結構的位置,然後更改它的 comm
欄位。
問題是 /dev/mem
是實體記憶體空間,而作業系統操作的任何記憶體都是基於虛擬地址,如何建立兩者之間的關聯是關鍵的。
我們注意到三點事實:
這意味著,只要我們提供一個 Linux 核心空間資料結構的虛擬地址,我們就能求出它的實體地址,然後順藤摸瓜就能找到我們的 pixie 行程的task_struct 結構體。
在 Linux 系統中,很多地方都可以找到核心資料結構的地址:
/proc/kallsyms
/boot/System.map
lsof
的結果最簡單的方法,那就是藉由在 /proc/kallsyms
或者 System.map
裡找到 init_task
的地址,比如在我的環境下:
然後在 arch/x86/kernel/vmlinux.lds.S 裡找到 init_task
到實體記憶體的映射規則,從 init_task
開始走訪整個系統的 task
linked list,找到我們的目標 pixie
行程,改之。
但這種方法無法讓人體驗在 /dev/mem
裡順藤摸瓜的刺激感,所以我們最後再來說它,現在我們嘗試用一種稍微麻煩的方法來實現修改特定行程名字目標。
我的方法是建立一個 tcpdump
行程卻不抓任何的封包,它只是一個提供蛛絲馬跡的幌子,我們就它開始下手:
之所以建立 tcpdump
行程,是因為 tcpdump
會產生一個 packet socket
,而該 socket
的虛擬地址可以從 procfs
中找到:
OK,就是這個 0xffff93d0605d1000
作為我們的突破口,我們從它開始順藤摸瓜!
我們知道,0xffff93d0605d1000
是一個 struct sock
物件的記憶體地址,依據 sock
的結構可知道它的偏移量 224 byte 處是一個等待佇列 wati_queue_head_t
物件。
這一點需要你對 Linux 核心的資料結構非常熟悉,如果不熟悉,就去找對應的原始碼手算一下偏移量。【或者用一下
crash
的struct Xy -o
計算也可】
而 wait_queue_head_t
物件的內部又含有一個 wait_queue_t
物件指向下一個節點,節點本身會被 tcpdump
的 poll_wqueues
結構所管理,最終它的 polling_task
欄位會指向 tcpdump
本身,而我們需要的正是 tcpdump
的 task_struct
物件本身,因為整個系統的所有 task
都被被連接在一個 linked list 中。
整體的關聯圖示如下:
有了這個結構,我們就可以開始撰寫程式碼了。
由於 x86_64 可以直接一一映射 64T 的記憶體,而我只有區區 12G 的大小,可以保證的是,虛擬地址減去一一映射的基底 (在我的系統它就是 0x )後就可以得到實體地址了。
假設 packet socket
的地址是 0xffff88002c201000,我們就能確認其實體地址在 0x2c201000 處,撰寫程式映射 /dev/mem
,並從 開始找起:
這邊做不出來 …
照著他所提供的教學進行指標的位移會出錯,應該是不同系統版本所使用的結構有差異
/dev/mem
以走訪系統的所有行程/dev/mem
結束行程無條件殺掉一個行程的方式不外乎兩種:
SIGKILL
殺掉它。不外乎從外部著手,或者由內部自行腐爛,但一個行程的結束均需要理由。然而,一個好好的行程整體被掏空的話意味著什麼?可以做到嗎?這就好比一個善良且健康的人,突然間遭遇了嚴重的意外事故那般不幸。
通過改寫 /dev/mem
可以輕而易舉地掏空一個行程,當行程再次準備執行時,就會發現自己什麼都沒有了。
我們可以定位到行程在 /dev/mem
的位置,進而刪除行程的 VMA (Virtual Memory Areas), 清空 stack … 有點殘忍,我便不再舉例細說。
/dev/mem
修改函式內容作為最後一個範例,呼應〈解決 Linux 核心問題實用技巧之 Crash 工具結合 /dev/mem
任意修改記憶體〉的例子,修改 devmem_is_allowed
函式,使其返回值永遠為 1,現在,我們透過修改 /dev/mem
的方式把它還原回去,從而結束此範例。
我們可以從 /proc/kallsyms
中找到 devmem_is_allowed
的地址:
這是它原來的樣子:
前一部分的修改成 nop nop 遇到 segamentaion falut,未修改成功,找原因中。
到底 NULL 地址能不能存取呢?到底是誰禁止存取 NULL 地址呢?
先說結論:
NULL 地址完全可以存取,只要有分頁表映射它到一個實體記憶體分頁就行。
Linux 系統有一個參數控制能不能 mmap NULL地址:
我們做一個實驗,看個究竟,先看程式碼:
執行:
此時我們 crash
看看 NULL 的映射結果:
看起來並沒有不同。
之所以會無法存取 NULL ,是為了更好地區分什麼是合法地址,所以人為製造了一個特殊的地址稱為「NULL」並使其無法被存取,但是在 MMU (Memory Management Unit) 層面上,NULL 與其它記憶體並無不同。
好了,關於 crash
工具和 /dev/mem
的話題在此結束,結合前面一篇文章連結,建議親自做一次這些實驗,可以獲得更加深刻的印象。
更重要的是,每個人在親自動手做這些實驗的過程中,會碰到各式各樣新的問題,分析以及最終解決掉這些問題,正是快樂感覺的由來,分享這種快樂本身也是一件快樂的事情,這也是我寫這兩篇文章的動力。