![](/img/trans.png)
[英]When I call vfork(), can I call any exec*() function, or must I call 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 -O3
和clang -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.