如果删除正在运行的进程的可执行文件,我注意到会发生fork失败,子进程永远不会执行。
例如,考虑下面的代码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void) {
sleep(5);
pid_t forkResult;
forkResult = fork();
printf("after fork %d \n", forkResult);
return 0;
}
Run Code Online (Sandbox Code Playgroud)
fork如果我编译它并在调用之前删除生成的可执行文件,我永远不会看到fork返回 pid 0,这意味着子进程永远不会启动。我只有一台运行 Big Sur 的 Mac,所以不确定是否可以在其他操作系统上重现。
有谁知道为什么会这样?我的理解是,即使可执行文件在运行时被删除,它也应该可以正常工作。
即使二进制文件被删除,该过程也应该继续的预期是正确的,但在macOS. System Integrity Protection这个例子是由于 macOS 内核内部( ) 机制的副作用而导致的SIP,但是在解释到底发生了什么之前,我们需要做几个实验,这将有助于我们更好地理解整个场景。
为了演示发生了什么,我将示例修改为数到 9,然后进行 fork,在 fork 后,子进程将打印一条消息“我完成了”,等待 1 秒并通过打印 PID 退出0。父进程将继续数到 14 并打印子进程 PID。代码如下:
#include <stdio.h>\n#include <stdlib.h>\n#include <unistd.h>\n\n#include <sys/types.h>\n#include <sys/wait.h>\n\nint main(void) {\n for(int i=0; i <10; i++)\n {\n sleep(1);\n printf("%i ", i);\n }\n pid_t forkResult;\n forkResult = fork();\n if (forkResult != 0) {\n for(int i=10; i < 15; i++) {\n sleep(1);\n printf("%i ", i);\n }\n } else {\n sleep(1);\n printf("I am done ");\n }\n\n printf("after fork %d \\n", forkResult);\n return 0;\n}\nRun Code Online (Sandbox Code Playgroud)\n编译完成后,我开始了正常场景:
\n\xe2\x95\xb0> ./a.out\n0 1 2 3 4 5 6 7 8 9 I am done after fork 0\n0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 after fork 4385\nRun Code Online (Sandbox Code Playgroud)\n因此,正常情况会按预期进行。stdout事实上,我们两次看到从 0 到 9 的计数,是由于在 fork 调用中完成了缓冲区的复制。
现在是时候进行负面场景了,我们将在启动后等待 5 秒并删除二进制文件。
\n\xe2\x95\xb0> ./a.out & (sleep 5 && rm a.out)\n[4] 8555\n0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 after fork 8677\n[4] 8555 done ./a.out\nRun Code Online (Sandbox Code Playgroud)\n我们看到输出仅来自父级。由于父进程已数到 14,并且显示了子进程的有效 PID,但是子进程丢失了,因此它从未打印任何内容。因此,子级创建在fork()执行后失败,否则fork()会收到错误而不是有效的PID。痕迹显示ktrace孩子是在pid下创建的并被唤醒:
test5-ko.txt:2021-04-07 13:34:26.623783 +04 0.3 MACH_DISPATCH 1bc 0 84 4 888065 2 a.out(8677)\ntest5-ko.txt:2021-04-07 13:34:26.623783 +04 0.2 TMR_TimerCallEnter 9931ba49ead1bd17 0 330e7e4e9a59 41 888065 2 a.out(8677)\ntest5-ko.txt:2021-04-07 13:34:26.623783 +04 0.0(0.0) TMR_TimerCallEnter 9931ba49ead1bd17 0 330e7e4e9a59 0 888065 2 a.out(8677)\ntest5-ko.txt:2021-04-07 13:34:26.623783 +04 0.0 TMR_TimerCallEnter 9931ba49ead1bd17 0 330e7e4e9a59 0 888065 2 a.out(8677)\ntest5-ko.txt:2021-04-07 13:34:26.623854 +04 0.0 imp_thread_qos_and_relprio 88775d 20000 20200 6 888065 2 a.out(8677)\ntest5-ko.txt:2021-04-07 13:34:26.623854 +04 0.0 imp_update_thread 88775d 811200 140000100 1f 888065 2 a.out(8677)\ntest5-ko.txt:2021-04-07 13:34:26.623855 +04 0.1(0.8) imp_update_thread 88775d c15200 140000100 25 888065 2 a.out(8677)\ntest5-ko.txt:2021-04-07 13:34:26.623855 +04 0.0(1.1) imp_thread_qos_and_relprio 88775d 30000 20200 40 888065 2 a.out(8677)\ntest5-ko.txt:2021-04-07 13:34:26.623855 +04 0.0 imp_thread_qos_workq_override 88775d 30000 20200 0 888065 2 a.out(8677)\ntest5-ko.txt:2021-04-07 13:34:26.623855 +04 0.0 imp_update_thread 88775d c15200 140000100 25 888065 2 a.out(8677)\ntest5-ko.txt:2021-04-07 13:34:26.623855 +04 0.1(0.1) imp_update_thread 88775d c15200 140000100 25 888065 2 a.out(8677)\ntest5-ko.txt:2021-04-07 13:34:26.623855 +04 0.0(0.2) imp_thread_qos_workq_override 88775d 30000 20200 40 888065 2 a.out(8677)\ntest5-ko.txt:2021-04-07 13:34:26.623857 +04 1.3 TURNSTILE_turnstile_added_to_thread_heap 88775d 9931ba6049ddcc77 0 0 888065 2 a.out(8677)\ntest5-ko.txt:2021-04-07 13:34:26.623858 +04 1.0 MACH_MKRUNNABLE 88775d 25 0 5 888065 2 a.out(8677)\nt\nRun Code Online (Sandbox Code Playgroud)\n因此子进程被调度MACH_DISPATCH并可通过 运行MACH_MKRUNNABLE。PID这就是父母在 后生效的原因fork()。
此外,ktrace对于正常情况,显示进程已发出BSC_exit并且imp_task_terminated发生了系统调用,这是进程退出的正常方式。但是,在我们删除文件的第二种情况下,跟踪不显示BSC_exit。这意味着子进程是由内核终止的,而不是正常终止的。我们知道,终止发生在子进程正确创建之后,因为父进程已收到有效的 PID,并且 PID 已变为可运行状态。
这使我们更加了解这里正在发生的事情。但是,在得出结论之前,让我们先展示另一个更加“扭曲”的例子。
\n如果我们在启动进程后替换文件系统上的二进制文件会怎么样?
\n这是回答这个问题的测试:我们将启动该过程,删除二进制文件并在其位置上创建一个具有相同名称的空文件,扩展名为touch.
\xe2\x95\xb0> ./a.out & (sleep 5 && rm a.out; touch a.out)\n[1] 6264\n0 1 2 3 4 5 6 7 8 9 I am done after fork 0\n0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 after fork 6851\n[1] + 6722 done ./a.out\nRun Code Online (Sandbox Code Playgroud)\n等一下,这有效!?这里发生了什么!?!?
\n这个奇怪的例子给了我们重要的线索,将帮助我们解释正在发生的事情。
\n第三个例子有效而第二个例子失败的原因揭示了这里发生的很多事情。正如一开始提到的,我们正在被 的副作用绊倒SIP,更准确地说是在runtime protection机制上。
为了保护系统完整性,将检查和 的SIP正在运行的进程。来自苹果文档:...当进程启动时,内核会检查主要可执行文件是否在磁盘上受到保护或是否使用特殊的系统权利进行了签名。如果任一为真,则设置一个标志来表示它受到保护以防止修改。任何附加到受保护进程的尝试都会被内核拒绝......system protectionspecial entitlement
当我们从文件系统中删除二进制文件时,保护机制无法识别子进程的类型,也无法识别特殊的系统权利,因为磁盘中丢失了二进制文件。这触发了保护机制,将该进程视为系统中的入侵者并终止它,但我们还没有看到BSC_exit子进程的情况。
在第三个示例中,当我们使用 在文件系统上创建虚拟条目时touch,SIP能够检测到这不是一个特殊进程,也不具有特殊权利,并允许该进程继续。这是一个非常明确的迹象,表明我们正在使用SIP实时保护机制。
为了证明情况确实如此,我禁用了SIP需要在恢复模式下重新启动的功能并执行了测试
\xe2\x95\xb0> csrutil status\nSystem Integrity Protection status: disabled.\n\xe2\x95\xb0> ./a.out & (sleep 5 && rm a.out)\n[1] 1504\n0 1 2 3 4 5 6 7 8 9 I am done after fork 0 \n0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 after fork 1626\nRun Code Online (Sandbox Code Playgroud)\n所以,整个问题都是由System Integrity Protection. 更多细节可以在文档中找到
所SIP需要的只是在文件系统上有一个带有进程名称的文件,以便该机制可以运行验证并决定允许子进程继续执行。这向我们表明,我们正在观察副作用,而不是设计的行为,因为空文件甚至不是有效的dwarf,但执行仍在继续。