博客 / 詳情

返回

Linux的binfmt_misc機制

在類UNIX系統上,可執行文件和shell腳本一般都是不帶後綴名的,操作系統內置的程序加載器會自動檢測文件的權限和內容是否是一個可執行的程序。這麼做的好處是可以在輸入命令的時候少打很多字。壞處自然是不對文件做徹底的檢查就無法確定其是否是可執行文件,這會帶來一些安全問題。

Linux則更進一步,提供了一套叫binfmt_misc的機制讓用户自定義哪些格式的文件是可執行文件,進一步提升了系統靈活性。

這篇文章就簡單講解一下Linux的binfmt_misc的工作原理和應用。閲讀這篇文章需要一些知識儲備:

  1. 會簡單的Linux操作
  2. 知道什麼是shell腳本
  3. 簡單瞭解過Python、c/c++、Go、Shell或者js等任何一門能進行Linux編程的語言

當然上面這些都只需要簡單瞭解即可,下面就進入正文吧。

什麼是binfmt_misc

binfmt_misc全稱是“Miscellaneous Binary Format”,它提供了一種用户接口,可以讓用户註冊自定義的可執行文件格式給內核。

內核在執行程序時會先檢查用户和程序文件的權限,然後讓程序加載器根據規則加載並執行程序,binfmt_misc所做的就是添加用户的自定義規則到加載器的規則集合中,使得除了傳統意義上的可執行文件(ELF文件或者有Shebang的腳本)之外的其他文件也可以直接被執行。

舉個例子,在Linux模擬Windows環境運行exe程序的模擬器wine,可以通過binfmt_misc機制把exe文件對應的執行規則註冊進加載器的規則集合,之後用户就可以像使用普通的Linux程序一樣直接執行exe文件,內核會檢查到wine註冊的規則,自動調用wine來模擬運行exe程序。

知道binfmt_misc是什麼之後,下面我們來看看binfmt_misc提供的用户註冊接口。

binfmt_misc的用户接口

説是用户接口,但因為涉及到操作內核數據以及影響整個系統的行為,所以binfmt_misc提供的接口都需要root權限,接口的操作結果會對所有用户立即生效。

binfmt_misc接口的操作結果只在系統運行中生效,關機重啓之後之前人工註冊的規則就消失了,所以有持久化需求的需要主動把註冊命令寫入啓動腳本之類的東西里。

binfmt_misc提供的接口不是系統調用,也不是特殊的命令,而是在/proc/sys/fs/binfmt_misc目錄下的一系列文件,通過讀取和寫入這些文件,可以實現註冊規則、刪除規則、暫停規則、查看規則狀態等操作。

接口文件主要有這幾個:

  1. /proc/sys/fs/binfmt_misc/register,一個不能讀取只能寫入的文件,寫入固定格式的數據可以註冊規則到內核
  2. /proc/sys/fs/binfmt_misc/status,可讀可寫的文件,讀取時獲取當前內核是否開啓binfmt_misc機制,返回值是enabled/disabled;寫入則可以關閉或重新開啓binfmt_misc,允許寫入的值只有0(關閉binfmt_misc)、1(重新打開)、-1(刪除所有註冊規則)。
  3. /proc/sys/fs/binfmt_misc/<rule-name>,所有註冊的規則都會生成一個和規則名相同的文件,讀取整個文件會獲得規則的詳細信息,寫入則可以控制整個規則,允許寫入的值有0(暫時讓規則失效)、1(重新生效)、-1(刪除這個規則,對應的文件也會在寫入完成之後被刪除)。

這些接口都比較簡單,你可以通過命令行或者任意一種可以讀寫系統文件的編程語言來操作它們。

接口中最核心的是/proc/sys/fs/binfmt_misc/register,向它寫入數據才能完成我們自定義規則的註冊。

註冊規則的數據格式是:name:type:offset:magic:mask:interpreter:flags,每個部分都以冒號開頭,字段可以省略但前導冒號需要保留,比如我們想省略mask和flags字段,就得寫成:name:type:offset:magic::interpreter:。下面解釋一下每個字段的意義:

  1. name,規則的名稱,目錄下生成的虛擬文件的名字也是它,所以name中不能包含/,也不能出現一些其他在文件名中不允許出現的字符。不同規則直接不能重名。
  2. type,設置以哪種方式識別文件,支持兩個選項ME,其中M表示通過文件頭來識別文件,E則表示通過擴展名來識別文件。
  3. offset,只在type是M時才有效,表示讀取文件頭信息時需要從文件開頭跳過多少個字節。
  4. magic,文件頭對應的二進制數據或者文件擴展名(擴展名不包含.),對於一些特殊數據比如\0\n需要轉換成\x00\x0a
  5. mask,只在type是M時才生效。mask會和magic進行&位運算,運算結果會作為識別文件所用的依據。這是因為一部分文件的文件頭特徵數據是不連續的,比如HEIC文件文件頭的前八個字節和第13到16字節的內容是固定的,但第9-12個字節可以是HEIF或者HEIC,我們可以用mask把第12個字節過濾掉,這樣就不要寫兩條大致內容重複的規則了。
  6. interpreter,負責執行這種文件的程序的絕對路徑,當前準備執行的文件的路徑或者描述符會作為第一個參數傳給這個程序。
  7. flags,控制程序執行行為的選項,注意這不是傳給interpreter的。選項可以傳遞多個。

flags的常用選項有:

  1. P,如果給了這個參數,加載器會在argv[0]之後添加一個可執行文件的完整路徑。
  2. O,默認情況下可執行文件的完整路徑會作為命令行參數被傳遞給interpreter,啓用這個選項後會打開可執行文件並把文件描述符通過auxv數組傳遞給interpreter。
  3. C,新的進程不會從interpreter繼承權限,比如setuid,權限會從待執行的可執行文件本身獲取。
  4. F,加載器會立即打開interpreter然後用fexecve/execveat執行程序,這通常被用在需要和容器交互的程序上。

只看文字描述可能有點抽象,我們看幾個具體的例子:

第一個例子我們註冊一條規則,使用python3執行擴展名為.py3的文件。

$ echo ':py3:E::py3::/usr/bin/python3:' > /proc/sys/fs/binfmt_misc/register
$ ls /proc/sys/fs/binfmt_misc

py3  register  status

$ cat /proc/sys/fs/binfmt_misc/py3

enabled
interpreter /usr/bin/python3
flags:
extension .py3

$ echo 'print("hello binfmt_misc!")' > /tmp/test.py3
$ chmod +x /tmp/test.py3
$ /tmp/test.py3

hello binfmt_misc!

如果不註冊規則就直接執行.py3文件,Linux會直接報錯。

第二個例子是用文件頭內容識別可執行文件,我們的可執行文件不會有文件擴展名,但會有-- binfmt_lua\n這樣的文件頭,文件內容是正常的lua腳本,但解釋器我們會使用luajit:

$ echo ':luajit.exec:M:3:binfmt_lua\x0a::/usr/bin/luajit:' > /proc/sys/fs/binfmt_misc/register
$ ls /proc/sys/fs/binfmt_misc

luajit.exec  py3  register  status

$ echo -e '-- binfmt_lua\nprint([[hello from luajit with binfmt_misc]])' > /tmp/testlua
$ chmod +x /tmp/testlua
$ /tmp/testlua

hello from luajit with binfmt_misc

在這個例子中對於非顯示的ascii字符換行符,我們把它轉換成了\x0a,並使用offset跳過了表示註釋的三個字符--

看完兩個例子我想大家應該掌握binfmt_misc的基本用法了。

binfmt_misc有幾個小限制還需要注意:

  1. proc的接口需要主動掛載才會出現,好在主流發行版都以及自動幫我們處理掛載了
  2. 規則字符串總長度不能超過1920字節
  3. 使用文件頭探測文件時,offset+len(magic)不能超過128字節
  4. interpreter長度不能超過127字節

當然,正常使用的情況下其實很難遇到這些限制。

binfmt_misc的工作原理

工作原理其實很簡單,整個調用鏈路是這樣的:

用户通過命令行或者GUI上點擊準備允許文件A -->
程序加載器先判斷文件是否是ELF或者是否有Shebang -->
都不符合則遍歷binfmt_misc規則,根據每條規則檢查文件內容 -->
找到第一條匹配的規則後,加載器修改命令行參數,把A傳遞給interpreter -->
加載器加載並運行interpreter

整個鏈路非常直觀,而且你可以發現這個規則是允許遞歸的,也就是説interpreter也可以是用binfmt_misc註冊的自定義可執行文件。不過實際生產中很少有人這麼做,因為調用鏈越長越容易出問題,排查錯誤也會變得更困難。

對於傳遞給interpreter和實際可執行文件的參數會這麼處理,我們可以寫個小程序來看下:

import (
    "fmt"
    "os"
)

func main() {
    for i, arg := range os.Args {
        fmt.Printf("idx: %d, arg: %s\n", i, arg)
    }
}

編譯這個程序並起名叫myinterp,然後註冊規則echo ':myinterp:E::myi::/home/apocelipes/myinterp:' > /proc/sys/fs/binfmt_misc/register

現在我們創建一個空的test.myi,然後運行:

$ ./test.myi

idx: 0, arg: /home/apocelipes/myinterp
idx: 1, arg: ./test.myi

$ ./test.myi --test1 --test2

idx: 0, arg: /home/apocelipes/myinterp
idx: 1, arg: ./test.myi
idx: 2, arg: --test1
idx: 3, arg: --test2

可以看到我們的程序被調用了,被執行的可執行文件的路徑會被作為argv[1]傳入,其餘的命令行選項會被依次傳進來。

flags的PO會對命令行選項產生影響,首先是P

$ test.myi --test1 --test2

idx: 0, arg: /home/apocelipes/myinterp
idx: 1, arg: /home/apocelipes/go/bin/test.myi
idx: 2, arg: test.myi
idx: 3, arg: --test1
idx: 4, arg: --test2

我們把test.myi移動到了$PATH中,這樣就不需要指定完整路徑了,現在在啓用P標誌時加載器會把被執行文件的完整路徑添加在argv[1]的位置上。這是為了方便我們的解釋器可以獲取被執行文件的路徑從而進行處理。

O的演示比較複雜,因為它並不會影響命令行參數,而是通過auxv傳遞打開的描述符,所以我們用c++重寫解釋器:

#include <iostream>
#include <cstdio>
#include <sys/auxv.h>
#include <unistd.h>

int main()
{
    std::cout << "pid: " << getpid() << "\n";
    unsigned long execfd = getauxval(AT_EXECFD);
    std::cout << "fd: " << execfd << "\n";

    auto file = fdopen(execfd, "r");
    if (file == nullptr) {
        std::perror("fdopen");
        return 1;
    }
    char buf[1024] = {0};
    std::fgets(buf, 1024, file);
    std::cout << "fd data: " << buf;
    if (std::fclose(file) != 0) {
        std::perror("fclose");
        return 1;
    }
}

我們用fdopenfclose來檢驗收到的fd是否有效,並讀取其內容:

$ echo -1 > /proc/sys/fs/binfmt_misc/myinterp
$ echo ':myinterp:E::myi::/home/apocelipes/myinterp:O' > /proc/sys/fs/binfm
t_misc/register
$ echo 'test data' > /home/apocelipes/go/bin/test.myi
$ test.myi --test1 --test2

pid: 4821
fd: 3
fd data: test data

程序沒有報錯説明fd是有效的,讀取到的內容也是我們之前寫入的,值為3通常意味着這是進程中除了標準輸入輸出之外第一個打開的文件。

到此我想大家應該都瞭解binfmt_misc的工作原理了。不過在介紹應用之前,我還要先介紹一個和它很相似的東西——Shebang。

Shebang

Shebang中文名又叫“釋伴”,是寫在腳本文件開頭第一行的特殊指令,可以讓操作系統調用特定的程序來執行這個腳本。它不光聽着和binfmt_misc很像,其實Shebang的實現代碼也在binfmt_misc裏。不過兩者終究只是有點像,具體行為上還是有區別的。

Shebang必須出現在腳本文件的開頭,以#!開始,以換行符結束,具體格式是:#![零個一個或多個空格]/path/to/interpreter 參數1 參數2 ...\n

程序加載器會找到路徑指定的解釋器,然後把腳本文件所在路徑添加在其他參數之後傳遞給解釋器。我們接着用前面golang寫的小程序作為解釋器,這回我們編寫一個帶有Shebang的腳本:

#! /home/apocelipes/myinterp --test1 --test2
echo hello

運行效果如下:

$ chmod +x ./myscript
$ ./myscript

idx: 0, arg: /home/apocelipes/myinterp
idx: 1, arg: --test1 --test2
idx: 2, arg: ./myscript

可以看到所有參數合併成了一個,並作為第一個參數傳遞給瞭解釋器,腳本路徑則是最後一個參數。解釋器的參數選項是可以省略的,這時候解釋器之後收到一個參數也就是腳本所在路徑。因此編寫解釋器的時候要根據參數數量自己處理命令行選項。

shebang總體上比binfmt_misc簡單很多,也是日常工作中使用最多的。

一個把Shebang利用到極致的例子是字節跳動編寫的ffmpeg rust綁定庫裏的腳本:

#!/bin/sh
#![allow(unused_attributes)] /*
OUT=/tmp/tmp && rustc "$0" -0 ${0UT} && exec ${OUT} $@ || exit $? #*/

use std::process::Command;
use std::io::Result;
use std::path::PathBuf;
use std::fs;

fn mkdir(dir_name: &str) →> Result<()> {
    fs::create_dir(dir_name)
}

fn main () {
    // 省略
}

這是合法的rust代碼,rust編譯器會忽略Shebang,其餘的代碼都是合法的rust代碼或者註釋。同時這也是合法的shell腳本,因為shell是解釋執行的,在執行到第三行後程序要麼exit退出執行要麼exec切換到編譯好的程序上了,儘管後面的rust內容都不是合法的shell代碼,但只要不執行到它們腳本就不會報錯。這是一個非常巧妙的利用Shebang把rust當腳本使用的例子。

當然,通過binfmt_misc這個例子可以進一步被簡化,但Shebang的可移植性更強。

binfmt_misc的應用

binfmt_misc的用處很多,比如前文提到的wine等模擬器會註冊類似:DOSWin:M::MZ::/usr/bin/wine:的規則,讓操作系統可以執行exe程序。Ubuntu也會註冊Python3.x之類的規則,讓python解釋器去運行.pyc文件。

除此之外binfmt_misc還有一些妙用。比如可以讓我們把.go代碼文件當作腳本來運行。

首先我們寫一個腳本編譯通過命令行參數傳入的代碼生成可執行文件,然後再執行這個編譯出來的程序:

#!/bin/bash
filename="/tmp/go-${RANDOM}.bin"
# $1 是傳入的腳本所在路徑,我們的註冊規則需要使用P flag
go build -o "$filename" "$1"
# 跳過前兩個參數,第一個參數的可執行文件路徑,第二個參數是可執行文件在命令行裏的名字,剩下的才是要傳遞給腳本的參數
"$filename" "${@:3}"
rm "$filename"

腳本起名叫mygointerp,然後我們給.go文件註冊一條規則::golang-script:E::go::/home/apocelipes/mygointerp:P

最後寫一個簡單的go腳本:

import (
    "fmt"
    "os"
)

func main() {
    fmt.Println("script start")
    for i, arg := range os.Args {
        fmt.Printf("idx: %d, arg: %s\n", i, arg)
    }
    fmt.Println("script end")
}

運行:

$ chmod +x goscript.go
$ ./goscript.go --test1 --test2

script start
idx: 0, arg: /tmp/go-21972.bin
idx: 1, arg: --test1
idx: 2, arg: --test2
script end

運行良好

我知道,大多數時候使用go run會更簡單,這個例子只是用來説明編譯型語言的代碼文件也可以通過binfmt_misc機制像腳本一樣方便地使用。

相比上一節提到的rust+Shebang的例子,這個利用binfmt_misc的例子可以讓開發者專注於go代碼本身,不需要在同一份源代碼文件中兼顧兩種不同的語言,缺點是需要額外的配置且可移植性不如Shebang。

總結

binfmt_misc機制提供了用户自定義可執行文件的能力,善加利用可有效提升生產力。

但如果濫用則會帶來安全問題,病毒和木馬會獲得更多感染系統的機會。

最後如果規則註冊太多,不僅排查問題會變得困難,還會拖慢程序加載執行的速度,所以凡事都有度,切不可濫用。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.