如果删除运行它的可执行文件,为什么 fork() 在 MacOs Big Sur 上会失败?
Why does fork() fail on MacOs Big Sur if the executable that runs it is deleted?
如果 运行 进程的可执行文件被删除,我注意到 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;
}
如果我在调用 fork
之前编译它并删除生成的可执行文件,我永远不会看到 fork
return 为 0 的 pid,这意味着子进程永远不会启动。我只有一个 Mac 运行 Big Sur,所以不确定这个 repro 是否适用于其他 OS。
有人知道为什么会这样吗?我的理解是一个可执行文件应该可以正常工作,即使它在 运行.
时被删除
即使二进制文件被删除,进程也应该继续的预期是正确的,但在 macOS
的情况下并不完全正确。该示例在 macOS 内核中的 System Integrity Protection
(SIP
) 机制的 side-effect 上跳闸,但是在解释到底发生了什么之前,我们需要做几个实验来帮助我们更好地理解整个场景。
修改示例以更好地展示问题
为了演示发生了什么,我将示例修改为计数到 9,而不是分叉,分叉后,child 将打印一条消息“我完成了”,等待 1 秒并通过打印 0
作为 PID 退出。 parent 将继续计数到 14 并打印 child PID。代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void) {
for(int i=0; i <10; i++)
{
sleep(1);
printf("%i ", i);
}
pid_t forkResult;
forkResult = fork();
if (forkResult != 0) {
for(int i=10; i < 15; i++) {
sleep(1);
printf("%i ", i);
}
} else {
sleep(1);
printf("I am done ");
}
printf("after fork %d \n", forkResult);
return 0;
}
编译后,我已经启动了正常的场景:
╰> ./a.out
0 1 2 3 4 5 6 7 8 9 I am done after fork 0
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 after fork 4385
因此,正常情况下按预期工作。我们两次看到从 0 到 9 的计数,这是由于在 fork 调用中完成了 stdout
的缓冲区副本。
追踪失败的例子
现在是执行负面场景的时候了,我们将在开始后等待 5 秒并删除二进制文件。
╰> ./a.out & (sleep 5 && rm a.out)
[4] 8555
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 after fork 8677
[4] 8555 done ./a.out
我们看到输出仅来自parent。由于 parent 计数为 14,并显示 child 的有效 PID,但是 child 丢失,它从未打印任何内容。因此,在执行 fork()
之后,child 创建失败,否则 fork()
会收到错误信息,而不是有效的 PID
。来自 ktrace
的痕迹显示 child 是在 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)
test5-ko.txt:2021-04-07 13:34:26.623783 +04 0.2 TMR_TimerCallEnter 9931ba49ead1bd17 0 330e7e4e9a59 41 888065 2 a.out(8677)
test5-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)
test5-ko.txt:2021-04-07 13:34:26.623783 +04 0.0 TMR_TimerCallEnter 9931ba49ead1bd17 0 330e7e4e9a59 0 888065 2 a.out(8677)
test5-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)
test5-ko.txt:2021-04-07 13:34:26.623854 +04 0.0 imp_update_thread 88775d 811200 140000100 1f 888065 2 a.out(8677)
test5-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)
test5-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)
test5-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)
test5-ko.txt:2021-04-07 13:34:26.623855 +04 0.0 imp_update_thread 88775d c15200 140000100 25 888065 2 a.out(8677)
test5-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)
test5-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)
test5-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)
test5-ko.txt:2021-04-07 13:34:26.623858 +04 1.0 MACH_MKRUNNABLE 88775d 25 0 5 888065 2 a.out(8677)
t
所以 child 的进程是用 MACH_DISPATCH
调度的,并使 运行 可用 MACH_MKRUNNABLE
。这就是 parent 在 fork()
.
之后有效 PID
的原因
此外,正常情况下的 ktrace
表明进程已发出 BSC_exit
并且发生了 imp_task_terminated
系统调用,这是进程退出的正常方式。但是,在我们删除文件的第二种情况下,跟踪不显示 BSC_exit
。这意味着 child 被内核终止,而不是正常终止。我们知道终止发生在 child 被正确创建之后,因为 parent 已经收到有效的 PID 并且 PID 被设置为 运行nable.
这让我们更加了解这里发生的事情。但是,在我们得出结论之前,让我们展示另一个更“扭曲”的例子。
更奇怪的例子
如果我们在启动进程后替换文件系统上的二进制文件怎么办?
这是回答这个问题的测试:我们将启动进程,删除二进制文件并在他的位置创建一个同名的空文件 touch
.
╰> ./a.out & (sleep 5 && rm a.out; touch a.out)
[1] 6264
0 1 2 3 4 5 6 7 8 9 I am done after fork 0
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 after fork 6851
[1] + 6722 done ./a.out
等一下,这个有用!?这是怎么回事!?!?
这个奇怪的例子给了我们重要的线索,可以帮助我们解释发生了什么。
问题root-cause
第三个示例有效而第二个示例失败的原因揭示了这里发生的很多事情。正如开头所提到的,我们被 SIP
的 side-effect 绊倒了,更准确地说是 runtime protection
机制。
为了保护系统完整性,SIP
将检查 system protection
和 special entitlement
的 运行ning 进程。来自 apple 文档:...当一个进程启动时,内核会检查主要可执行文件是否在磁盘上受到保护或是否使用特殊的系统授权进行签名。如果其中任何一个为真,则设置一个标志以表示它受到保护以防止修改。内核拒绝任何附加到受保护进程的尝试...
当我们从文件系统中删除二进制文件时,保护机制无法识别 child 的进程类型,也无法识别特殊系统授权,因为二进制文件从磁盘中丢失。这触发了保护机制将此进程视为系统中的入侵者并终止它,hanse 我们还没有看到 child 进程的 BSC_exit
。
在第三个示例中,当我们使用 touch
在文件系统上创建虚拟条目时,SIP
能够检测到这不是一个特殊进程,也没有特殊权利并允许继续的过程。这是一个非常可靠的迹象,表明我们在 SIP
实时保护机制上绊倒了。
为了证明是这样,我把recovery模式下需要重启的SIP
禁用了,执行了测试
╰> csrutil status
System Integrity Protection status: disabled.
╰> ./a.out & (sleep 5 && rm a.out)
[1] 1504
0 1 2 3 4 5 6 7 8 9 I am done after fork 0
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 after fork 1626
结论
因此,整个问题是由 System Integrity Protection
引起的。更多详情可以关注documentation
所有 SIP
需要的是在文件系统上有一个具有进程名称的文件,因此该机制可以 运行 验证并决定允许 child 继续执行。这表明我们正在观察 side-effect,而不是 desined 行为,因为空文件甚至不是有效的 dwarf
,但执行仍在继续。
如果 运行 进程的可执行文件被删除,我注意到 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;
}
如果我在调用 fork
之前编译它并删除生成的可执行文件,我永远不会看到 fork
return 为 0 的 pid,这意味着子进程永远不会启动。我只有一个 Mac 运行 Big Sur,所以不确定这个 repro 是否适用于其他 OS。
有人知道为什么会这样吗?我的理解是一个可执行文件应该可以正常工作,即使它在 运行.
时被删除即使二进制文件被删除,进程也应该继续的预期是正确的,但在 macOS
的情况下并不完全正确。该示例在 macOS 内核中的 System Integrity Protection
(SIP
) 机制的 side-effect 上跳闸,但是在解释到底发生了什么之前,我们需要做几个实验来帮助我们更好地理解整个场景。
修改示例以更好地展示问题
为了演示发生了什么,我将示例修改为计数到 9,而不是分叉,分叉后,child 将打印一条消息“我完成了”,等待 1 秒并通过打印 0
作为 PID 退出。 parent 将继续计数到 14 并打印 child PID。代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void) {
for(int i=0; i <10; i++)
{
sleep(1);
printf("%i ", i);
}
pid_t forkResult;
forkResult = fork();
if (forkResult != 0) {
for(int i=10; i < 15; i++) {
sleep(1);
printf("%i ", i);
}
} else {
sleep(1);
printf("I am done ");
}
printf("after fork %d \n", forkResult);
return 0;
}
编译后,我已经启动了正常的场景:
╰> ./a.out
0 1 2 3 4 5 6 7 8 9 I am done after fork 0
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 after fork 4385
因此,正常情况下按预期工作。我们两次看到从 0 到 9 的计数,这是由于在 fork 调用中完成了 stdout
的缓冲区副本。
追踪失败的例子
现在是执行负面场景的时候了,我们将在开始后等待 5 秒并删除二进制文件。
╰> ./a.out & (sleep 5 && rm a.out)
[4] 8555
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 after fork 8677
[4] 8555 done ./a.out
我们看到输出仅来自parent。由于 parent 计数为 14,并显示 child 的有效 PID,但是 child 丢失,它从未打印任何内容。因此,在执行 fork()
之后,child 创建失败,否则 fork()
会收到错误信息,而不是有效的 PID
。来自 ktrace
的痕迹显示 child 是在 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)
test5-ko.txt:2021-04-07 13:34:26.623783 +04 0.2 TMR_TimerCallEnter 9931ba49ead1bd17 0 330e7e4e9a59 41 888065 2 a.out(8677)
test5-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)
test5-ko.txt:2021-04-07 13:34:26.623783 +04 0.0 TMR_TimerCallEnter 9931ba49ead1bd17 0 330e7e4e9a59 0 888065 2 a.out(8677)
test5-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)
test5-ko.txt:2021-04-07 13:34:26.623854 +04 0.0 imp_update_thread 88775d 811200 140000100 1f 888065 2 a.out(8677)
test5-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)
test5-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)
test5-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)
test5-ko.txt:2021-04-07 13:34:26.623855 +04 0.0 imp_update_thread 88775d c15200 140000100 25 888065 2 a.out(8677)
test5-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)
test5-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)
test5-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)
test5-ko.txt:2021-04-07 13:34:26.623858 +04 1.0 MACH_MKRUNNABLE 88775d 25 0 5 888065 2 a.out(8677)
t
所以 child 的进程是用 MACH_DISPATCH
调度的,并使 运行 可用 MACH_MKRUNNABLE
。这就是 parent 在 fork()
.
PID
的原因
此外,正常情况下的 ktrace
表明进程已发出 BSC_exit
并且发生了 imp_task_terminated
系统调用,这是进程退出的正常方式。但是,在我们删除文件的第二种情况下,跟踪不显示 BSC_exit
。这意味着 child 被内核终止,而不是正常终止。我们知道终止发生在 child 被正确创建之后,因为 parent 已经收到有效的 PID 并且 PID 被设置为 运行nable.
这让我们更加了解这里发生的事情。但是,在我们得出结论之前,让我们展示另一个更“扭曲”的例子。
更奇怪的例子
如果我们在启动进程后替换文件系统上的二进制文件怎么办?
这是回答这个问题的测试:我们将启动进程,删除二进制文件并在他的位置创建一个同名的空文件 touch
.
╰> ./a.out & (sleep 5 && rm a.out; touch a.out)
[1] 6264
0 1 2 3 4 5 6 7 8 9 I am done after fork 0
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 after fork 6851
[1] + 6722 done ./a.out
等一下,这个有用!?这是怎么回事!?!?
这个奇怪的例子给了我们重要的线索,可以帮助我们解释发生了什么。
问题root-cause
第三个示例有效而第二个示例失败的原因揭示了这里发生的很多事情。正如开头所提到的,我们被 SIP
的 side-effect 绊倒了,更准确地说是 runtime protection
机制。
为了保护系统完整性,SIP
将检查 system protection
和 special entitlement
的 运行ning 进程。来自 apple 文档:...当一个进程启动时,内核会检查主要可执行文件是否在磁盘上受到保护或是否使用特殊的系统授权进行签名。如果其中任何一个为真,则设置一个标志以表示它受到保护以防止修改。内核拒绝任何附加到受保护进程的尝试...
当我们从文件系统中删除二进制文件时,保护机制无法识别 child 的进程类型,也无法识别特殊系统授权,因为二进制文件从磁盘中丢失。这触发了保护机制将此进程视为系统中的入侵者并终止它,hanse 我们还没有看到 child 进程的 BSC_exit
。
在第三个示例中,当我们使用 touch
在文件系统上创建虚拟条目时,SIP
能够检测到这不是一个特殊进程,也没有特殊权利并允许继续的过程。这是一个非常可靠的迹象,表明我们在 SIP
实时保护机制上绊倒了。
为了证明是这样,我把recovery模式下需要重启的SIP
禁用了,执行了测试
╰> csrutil status
System Integrity Protection status: disabled.
╰> ./a.out & (sleep 5 && rm a.out)
[1] 1504
0 1 2 3 4 5 6 7 8 9 I am done after fork 0
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 after fork 1626
结论
因此,整个问题是由 System Integrity Protection
引起的。更多详情可以关注documentation
所有 SIP
需要的是在文件系统上有一个具有进程名称的文件,因此该机制可以 运行 验证并决定允许 child 继续执行。这表明我们正在观察 side-effect,而不是 desined 行为,因为空文件甚至不是有效的 dwarf
,但执行仍在继续。