簡體   English   中英

使用系統調用時 vfork+execve 很奇怪

[英]vfork+execve strange when using syscall

如果執行下面的代碼,您會看到 execve 返回一個進程 ID,而父進程永遠不會執行。 我試着尋找文檔,但我要么找不到它,要么無法理解它。 clone 談論 vfork (CLONE_VFORK) 並在下面說,但父級似乎從未執行過。 如果您取消注釋非 sys call vfork 或使用 syscall fork,它將按預期工作

調用進程的執行被掛起,直到子進程通過調用 execve(2) 或 _exit(2)(與 vfork(2) 一樣)釋放其虛擬 memory 資源。

#include <unistd.h>
#include <syscall.h>
#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[])
{
    //int a = vfork();
    //int a = syscall(__NR_fork);
    int a = syscall(__NR_vfork);
    if (a) {
        write(2, "parent\n", 7);
    } else {
        char*args[] = {"/usr/bin/true", (char*)0};
        int res = execve(args[0], args, &argv[2]);
        char buf[256];
        sprintf(buf, "child got %d\n", res);
        write(2, buf, strlen(buf));
    }
    write(2, "Done\nChild\n", a?5:11);
}

我很好奇究竟發生了什么。 我使用strace -f./a.out看到 output 是這樣的,表明它是父級進行write(2, "Done\nChild\n", 11)系統調用。 (編號較小的 PID,而不是 vfork 之后附加的新 PID strace 報告)

...
prlimit64(0, RLIMIT_STACK, NULL, {rlim_cur=8192*1024, rlim_max=RLIM64_INFINITY}) = 0
munmap(0x7f7e48c59000, 193483)          = 0
vfork(strace: Process 515667 attached
 <unfinished ...>
[pid 515667] execve("/usr/bin/true", ["/usr/bin/true"], 0x7ffc4447ce18 /* 60 vars */ <unfinished ...>
[pid 515666] <... vfork resumed>)       = 515667
[pid 515666] write(2, "child got 515667\n", 17child got 515667
) = 17
[pid 515667] <... execve resumed>)      = 0
[pid 515666] write(2, "Done\nChild\n", 11Done
Child
) = 11
[pid 515667] brk(NULL <unfinished ...>
[pid 515666] exit_group(0 <unfinished ...>
[pid 515667] <... brk resumed>)         = 0x5603b644c000
[pid 515666] <... exit_group resumed>)  = ?
[pid 515667] arch_prctl(0x3001 /* ARCH_??? */, 0x7ffc878f2720) = -1 EINVAL (Invalid argument)
[pid 515666] +++ exited with 0 +++
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
... the parent has exited by now, leaving just the child running the dynamic linker for /usr/bin/true

這是終端 output 與 strace output 的混合; 我本可以使用strace -f -o vfork.trace./a.out單獨捕獲日志,或者./a.out &>/dev/null

孩子覆蓋父母的返回地址,到execve調用站點

具有未定義行為的 C 代碼的實際行為恰好與gcc (默認為 -O0)、 gcc -O3clang -O3 因此,對於使用 GDB 更容易單步執行的 asm,我在我的 Arch GNU/Linux 系統(GCC12.2,如果重要的話)上使用gcc -O3 -fno-plt構建了它。 -fno-plt表示動態鏈接不是“懶惰的”,因此我們可以進入庫函數。

使用符號名稱 ( https://godbolt.org/z/j6ME6rWaa ) 查看編譯器的 asm 源代碼也很方便。

vfork之后, GDB 分離孩子並讓它運行,所以你仍然單步執行父母。

父級從 glibc syscall()包裝器 function 返回的不是test eax,eax call syscall后的指令,而是另一個call后的指令似乎在子級從vfork返回后,它最終覆蓋了返回地址父級有機會運行之前的堆棧。 這就說得通了; 編譯器為main生成的 asm 在 function 條目之后不會調整 RSP,因此任何其他call都會將返回地址推送到同一位置,覆蓋其他進程中的返回地址。

vfork的 glibc 包裝器通過彈出syscall周圍的返回地址並在之后立即推送它來避免這種情況,以使其在 POSIX 和 Linux 手冊頁規定的條件下工作。 (這不包括您使用它的方式,但即使在安全使用中,在父級可以從包裝器ret返回之前call execve將是一個問題。)glibc 包裝器的正確性還依賴於 kernel 語義 not運行父母直到孩子退出或執行之后,請參閱下面的稍后部分; 如果只看用戶空間 asm,您會認為可能存在競爭條件,並且它通常只能工作。

它返回的實際位置是call之后的 RIP 相關 LEA,而不是test eax,eax 那是靈光一現的時刻,這是回信地址可能已被覆蓋的線索。 LEA 正在為sprintf設置參數; 前面的調用是call execve

這就說得通了; execve是孩子做的最后一件事,因為它只在出錯時返回; 成功后,它會用不再與父進程共享的新地址空間替換該進程。

子進程從syscall(__NR_vfork)返回后,它分支並調用execve ,推送返回地址,覆蓋父進程從call syscall返回的地址,因為它們共享一個地址空間,包括堆棧。

這只留下父級,從execve()的返回路徑執行,在無錯誤(或非 hacky)程序中,只有在出錯時才能訪問。

所以它執行sprintf。 它打印child got 515667 ,因為 PID 是 EAX 中的值,因為父級從vfork返回(到這個代碼塊,它從另一個調用站點的 EAX 返回值中獲取res 。)

至於它如何設法選擇11而不是5作為write系統調用的長度,調試與優化構建的細節可能有所不同。 在優化的構建中, if(a)的不同分支在調用write()使用的寄存器中留下不同的數字。

在調試版本中,只有子進程返回到vfork調用站點並將a值存儲到堆棧中。


像這樣的惡作劇就是為什么沒人再使用vfork的原因了; 幾個寫時復制頁面錯誤足夠便宜,不值得玩火。

這也是關於允許您如何使用vfork的規則非常嚴格的原因; 您最好在調用vfork之前execve構建好參數,這樣接下來就可以call execve了。


syscall(__NR_vfork)不安全; 它需要特殊處理

單步進入 glibc 包裝器(GDB 中的stepi aka si ,在layout asm TUI 模式下),我們可以看到它的 asm.

│    0x7ffff7e7d830 <vfork>          endbr64
│    0x7ffff7e7d834 <vfork+4>        pop    rdi
│    0x7ffff7e7d835 <vfork+5>        mov    eax,0x3a
│    0x7ffff7e7d83a <vfork+10>       syscall
│    0x7ffff7e7d83c <vfork+12>       push   rdi
│  > 0x7ffff7e7d83d <vfork+13>       cmp    eax,0xfffff001     # EAX >= -ERRNO_MAX
│    0x7ffff7e7d842 <vfork+18>       jae    0x7ffff7e7d858 <vfork+40>                                                                                                                                                                
               # else no-error return path.
│    0x7ffff7e7d844 <vfork+20>       xor    esi,esi
│    0x7ffff7e7d846 <vfork+22>       rdsspq rsi
│    0x7ffff7e7d84b <vfork+27>       test   rsi,rsi   # if shadow stack not in use
│    0x7ffff7e7d84e <vfork+30>       je     0x7ffff7e7d857 <vfork+39>
│    0x7ffff7e7d850 <vfork+32>       test   eax,eax   # in parent, normal return
│    0x7ffff7e7d852 <vfork+34>       jne    0x7ffff7e7d857 <vfork+39>
│    0x7ffff7e7d854 <vfork+36>       pop    rdi         # pop real return address
│    0x7ffff7e7d855 <vfork+37>       jmp    rdi         # and manually return to the correct address from the shadow stack?

     # no shadow-stack path of execution, return normally.
│    0x7ffff7e7d857 <vfork+39>       ret

  # error handling, set errno and return -1
│    0x7ffff7e7d858 <vfork+40>       mov    rcx,QWORD PTR [rip+0x105509]        # 0x7ffff7f82d68
│    0x7ffff7e7d85f <vfork+47>       neg    eax
│    0x7ffff7e7d861 <vfork+49>       mov    DWORD PTR fs:[rcx],eax
│    0x7ffff7e7d864 <vfork+52>       or     rax,0xffffffffffffffff   # code-size optimization for mov rax,-1   (really rarely executed for most system calls)
│    0x7ffff7e7d868 <vfork+56>       ret

rdsspq讀取“影子堆棧”指針,以防調用者使用 CET,控制流執行技術。 我不熟悉 CET,所以我對那部分的評論是基於這個 function 可能需要做什么以及它如何使用這些指令的猜測。

我應該看看手寫的 glibc 源代碼,它有注釋, glibc/sysdeps/unix/sysv/linux/x86_64/vfork.S 從那里更新了一些。

似乎仍然可以與孩子進行比賽,就像我們的push rdi在孩子返回並調用execve之前運行一樣。 但是,在正常的調度條件下,孩子確實先運行。

但是不,有特殊的邏輯來處理:

https://man7.org/linux/man-pages/man2/vfork.2.html

vfork()fork(2)的不同之處在於,調用線程被掛起,直到子進程終止(正常情況下,通過調用 _exit(2),或異常情況下,在發出致命信號后),或者調用execve(2) 在那之前,孩子與其父共享所有 memory,包括堆棧。 子進程不得從當前 function 返回或調用 exit(3)(這會調用父進程建立的退出處理程序並刷新父進程的 stdio(3) 緩沖區),但可以調用 _exit(2)。


正如您在評論中提到的,如果您想將其用於並發/線程,請使用pthread_create(3)啟動線程,而不是vfork() ,或者它使用的相同原始系統調用, clone(CLONE_THREAD) (請注意,用於clone的 glibc 包裝器使用新線程的堆棧 memory 來存儲要調用的代碼指針;kernel API/ABI 沒有代碼指針 arg;請參閱手冊頁的C 庫/kernel 差異部分,也許還有clone()的 glibc 源代碼。)

如今, vfork在 kernel 內部實現為clone( flags=CLONE_VM | CLONE_VFORK | SIGCHLD )

代碼中有多個未定義行為的實例。

您通過在execve()失敗后進行諸如sprintf()write()之類的調用來調用未定義的行為。 根據 POSIX

...如果vfork()創建的進程修改除用於存儲vfork()返回值的pid_t類型變量以外的任何數據,或者從調用vfork()的 function 返回,則行為未定義,或在成功調用_exit()或 exec 系列函數之一之前調用任何其他 function。

甚至在vfork()調用未定義行為后簡單地從main()返回。

@Barmar 總結得最好:“你根本不應該使用vfork()

此代碼還調用未定義的行為:

    char*args[] = {"/usr/bin/true", (char*)0};
    int res = execve(args[0], args, &argv[2]);

argv[2]不存在,因此將其地址傳遞給execve()會調用未定義的行為。 請注意,獲取argv[2]的地址本身不會調用未定義的行為 - 確實存在一個超出數組實際末尾的地址。 但它不能被安全地推導,而execve()可以。

execve()需要一個指向環境指針數組的指針作為它的第三個參數

使用 execve()

以下示例將 arguments 傳遞給 cmd 數組中的 ls 命令,並使用 env 參數指定新進程映像的環境。

 #include <unistd.h> int ret; char *cmd[] = { "ls", "-l", (char *)0 }; char *env[] = { "HOME=/usr/home", "LOGNAME=home", (char *)0 }; ... ret = execve ("/bin/ls", cmd, env);

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM