在Linux中可以不需要有指令碼或者二進制程式的檔案在檔案系統上實際存在,只需要有對應的資料在記憶體中,就有辦法執行這些指令碼和程式。
原理其實很簡單,Linux裡有辦法把某塊記憶體對映成檔案描述符,對於每一個檔案描述符,Linux會在/proc/self/fd/<檔案描述符>這個路徑上建立一個對應描述符的實體,這個路徑可以當成普通的檔案來用,能正常從中讀出資料,因此只要有可執行許可權,就可以載入後執行。
其中第一步是建立記憶體到檔案描述符的對映,這一步可以靠memfd_create這個系統呼叫實現。這個系統呼叫會返回一個檔案描述符,關聯到一塊記憶體上,預設大小是0,大多數對普通檔案描述符可行的操作對這個描述符也都可用,比如read,write,ftruncate,close。write資料進去的時候系統會自動分配合適長度的記憶體。當所有引用這塊記憶體的fd被close之後,這塊記憶體會被自動釋放。
總之memfd_create提供了像操作檔案一樣操作記憶體的能力,是一切皆檔案理念的體現之一。
而且memfd_create建立的頁面預設有可執行許可權,在proc底下的對應的描述符檔案也有可執行許可權。
所以我們只要把指令碼或者二進制程式的資料寫進memfd_create返回的描述符就已經做完前兩步了。其中對於指令碼有一些要求,需要帶有Shebang(類似#!/usr/bin/env python3這種)。
有一點需要注意,雖然/proc/self/fd/<檔案描述符>有描述符檔案存在,但實際上這就是個軟連結,而我們的資料全在記憶體裡。
寫入成功後可以利用execve執行proc下的描述符檔案,也可以透過fexecve系統呼叫直接呼叫檔案描述符。golang沒提供fexecve,所以示例用exec.Cmd。
例子:
package main import ( "fmt" "os" "os/exec" "golang.org/x/sys/unix" ) func main() { // 名字其實無所謂,傳空字元傳也許,名字只是方便debug沒有其他影響 fd, err := unix.MemfdCreate("memexec", unix.MFD_CLOEXEC) if err != nil { panic(err) } file := os.NewFile(uintptr(fd), "memexec") defer func() { if err := file.Close(); err != nil { panic(err) } }() _, err = file.Write([]byte("#!/usr/bin/env python\nimport math\nprint('Hello, world!')\n")) if err != nil { panic(err) } _, err = file.Write([]byte("print(f'{math.sqrt(2)=}')\n")) if err != nil { panic(err) } // 因為設定了CLOEXEC,子程序裡execve之後看不到這個描述符,會導致呼叫失敗 // 所以只能用父程序的 cmd := exec.Command(fmt.Sprintf("/proc/%d/fd/%d", os.Getpid(), fd)) data, err := cmd.Output() fmt.Println("output:", string(data)) if err != nil { panic(err) } }
golang的話還以配合embed把二進制程式的資料提前嵌入程式內,這樣寫入的時候會比較方便。
安全性:memfd_create建立的東西預設有可執行許可權,同時預設也是可寫的,很可能會被惡意程式利用,所以目前核心也在推進解決這個問題已經新增了flag可以讓不新增可執行許可權,這裏建議是遵守許可權最小化的原則。
memfd原本的用途:用來在記憶體中建立檔案(比如不想在儲存器上建立檔案時可以用這個),並可以在父子程序間傳遞(最好配合file sealing api使用,防止資料被意外修改);或者乾脆當匿名共享記憶體用。執行記憶體中的程式是附帶效果。
參考資料
https://magisterquis.github.io/2018/03/31/in-memory-only-elf-execution.html