在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"
"time"
"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