Newshot
Account

修改 Linux,让每个进程都用上 SIGKILL

Translation(model translation, not the source's own text)

修改 Linux,使其对每个进程都使用 SIGKILL

get_signal 运行一个循环,将未被屏蔽的信号出队并处理它们。出队之后,会立即获取潜在的处理函数。并且,如果该处理函数不是忽略信号的默认处理函数(SIG_IGN),它就会被存入 ksignal 结构体,然后 get_signal 返回。否则,代码会检查目标进程是否为全局 init 且该信号是否可以被忽略,以及该信号是否属于停止类信号等。如果后一种情况为真,我们就让进程准备被停止,并调用 do_signal_stop,它处理所有停止信号。最后,如果该信号不属于停止类且未被处理,它的默认行为就是杀死进程,这通过 do_group_exit 完成。最初翻阅 Linux 代码时,这给了我第一个直觉:内核期望 SIGSTOP 和 SIGKILL 没有用户设置的处理函数,以便回退到默认行为。如果存在用户设置的处理函数,它们就会像普通信号一样被处理。要么在处理函数设置代码的入口处有逻辑显式移除 SIGSTOP 和 SIGKILL,要么该检查在 Linux 之外,这可能意味着必须重写部分 libc,或使用原始系统调用。

如果信号没有被内核在 get_signal 中处理,即在 ksig 中设置了处理函数,内核随后会调用 handle_signal。正如 Linux 中的许多其他函数一样,这个名字具有欺骗性:其中实际上没有处理任何信号(事实上,据我所见,它是顶层信号处理函数 arch_do_signal_or_restart 中唯一从不处理信号的部分)。handle_signal 所做的是设置信号处理函数的栈帧,并设置和保存稍后在用户态重新开始执行的指针。进程会经过当前架构定义的 handle_signal,在 x86(-64) 上,它会接着调用 setup_rt_frame,以正确的 ABI 为信号设置返回帧。在 x86 上,FPU 的状态也会被保存并重置。在帧设置调用链的最后一个函数中,指令指针被设置为 ksig->ka.sa.sa_handler,这样当这些寄存器被恢复时,机器会立即执行你的处理函数。完成所有这些之后,会调用 signal_setup_done 来更改一些内部状态,帧设置就完成了。

在 exit_to_user_mode_loop 中工作循环的一次迭代结束时,内核返回用户态以执行必要的工作。

用户空间程序使用 rt_sigaction 系统调用作为为信号设置处理函数的入口点。直到你查看 do_sigaction 之前,这个入口点并没有特别排除我们的信号,do_sigaction 会检查一个叫做 sig_kernel_only 的东西。该宏检查信号是否为 SIGKILL 或 SIGSTOP 之一,如果是,就抛出 -EINVAL。这意味着,正如预期的那样,内核在收到处理函数设置系统调用时的某个早期位置存在逻辑,显式阻止为 SIGKILL 或 SIGSTOP 设置处理函数。

## Linux 与被屏蔽的信号

至于设置屏蔽字,即给定任务被忽略的信号集合,内核入口点是 rt_sigprocmask。它在调用 sigprocmask 之前显式阻止阻塞 SIGKILL 或 SIGSTOP。当信号被发送时,在排队之前,会调用 prepare_signal。它的职责之一是检查信号是否被阻塞或无法投递(目标是全局 init、目标以某种方式不可杀死等)。令人惊讶的是,被阻塞的信号总是会被排队,因为信号屏蔽字和处理方式可能在排队和出队之间发生变化。然而,如果信号未被屏蔽但没有设置处理函数,它就会被丢弃。

通过阅读处理信号、信号屏蔽和信号处理函数的代码,可以清楚地看到,有多个地方显式排除了为 SIGKILL 和 SIGSTOP 设置处理函数以及屏蔽它们。

所以我进去修改了它们。

## 修改 Linux

我需要关注四件事:

  • 允许进程屏蔽(sigprocmask)两个内核信号

修改 Linux 以便对每个进程使用 SIGKILL

  • 允许进程处理(sigaction)这两种内核信号
  • 确保如果为这两种信号之一设置了处理函数,或者它们被屏蔽,则默认行为被屏蔽
  • 修补 kernel/signal.c 中明确提到的任何其他 SIGKILL 或 SIGSTOP 实例

我首先在 lib/Kconfig.debug 的“Kernel Hacking”菜单中创建一个简单的 KConfig 键。这是我补丁的第一部分:


        config KILL_STOP_HANDLING
	bool "Make SIGSTOP and SIGKILL handlable by regular programs"
	help
	  This prevents the kernel from blocking handling of SIGSTOP and SIGKILL by
	  regular user programs.

If unsure, say N.
      

从这里开始,我可以使用类似下面的方式隐藏或替换信号处理代码中任何明确拒绝 SIGSTOP 或 SIGKILL 的部分:


        #ifdef CONFIG_KILL_STOP_HANDLING
    // Code that does not discriminate signals
#else
    // Previous code
#endif
      

那么,让我们开始吧。

### 信号屏蔽系统调用

你的机器通过 rt_sigprocmask 系统调用(在引入实时信号之前是 sigprocmask)进入信号屏蔽流程:


        /**
 *  sys_rt_sigprocmask - change the list of currently blocked signals
 *  @how: whether to add, remove, or set signals
 *  @nset: stores pending signals
 *  @oset: previous value of signal mask if non-null
 *  @sigsetsize: size of sigset_t type
 */
SYSCALL_DEFINE4(rt_sigprocmask, int, how, sigset_t __user *, nset,
		sigset_t __user *, oset, size_t, sigsetsize)
{
      

在该函数中,经过一些声明和测试之后,如果 nset 参数为真,我们在复制新的信号集合 new_set 之后继续调用 sigprocmask:


        if (copy_from_user(&new_set, nset, sizeof(sigset_t)))
    return -EFAULT;
sigdelsetmask(&new_set, sigmask(SIGKILL)|sigmask(SIGSTOP));

error = sigprocmask(how, &new_set, NULL);
if (error)
    return error;
      

如果你不眨眼,你实际上能捕捉到对我们内核信号的显式引用!在进入该系统调用操作的实际处理程序之前,我们看到一个对 sigdelsetmask 的调用,这或多或少是一个“从集合中移除”的操作(集合取反并存储)。它实际上实现为 sig &= ~mask。

这一小行可以很容易地被屏蔽:


        #ifndef CONFIG_KILL_STOP_HANDLING
    sigdelsetmask(&new_set, sigmask(SIGKILL)|sigmask(SIGSTOP));
#endif
      

sigprocmask 实际上可以根据 how 执行不同的操作,how 可以是 SIG_BLOCK、SIG_UNBLOCK 和 SIG_SETMASK 中的任意一个。前两个是对现有掩码的操作,而最后一个则完全按照你给定的内容设置掩码。

该函数的代码在 kernel/signal.c 中(再次)执行以下操作。它将 oldset 设置为旧集合,然后根据 how 的值分派,以在 oldset 和给定的集合之间执行正确的二进制操作:


        int sigprocmask(int how, sigset_t *set, sigset_t *oldset)
{
	struct task_struct *tsk = current;
	sigset_t newset;

/* Lockless, only current can change ->blocked, never from irq */
	if (oldset)
		*oldset = tsk->blocked;

switch (how) {
	case SIG_BLOCK:
		sigorsets(&newset, &tsk->blocked, set);
		break;
	case SIG_UNBLOCK:
		sigandnsets(&newset, &tsk->blocked, set);
		break;
	case SIG_SETMASK:
		newset = *set;
		break;
	default:
		return -EINVAL;
	}
	// ... SNIP ...
}
      

正如注释所指出的,这个函数会“愉快地屏蔽像 SIGKILL 及其同类这样的‘不可屏蔽’信号”。因此,没有必要继续沿着调用链往下走。事实上,其余的调用只涉及获取锁、设置集合等等。

还有其他地方提到 SIGSTOP 和 SIGKILL 用于屏蔽:

  • set_current_blocked 旨在成为用户态友好的入口点,用于屏蔽给定的一组信号。它会预防性地移除 SIGKILL 和 SIGSTOP,所以我们可以移除这一点。
  • rt_sigprocmask 的兼容性定义也明确屏蔽了我们的两个信号,方式与 rt_sigprocmask 完全相同

### 信号处理程序设置系统调用

和之前类似,入口系统调用是 rt_sigaction:


        /**
 *  sys_rt_sigaction - alter an action taken by a process
 *  @sig: signal to be sent
 *  @act: new sigaction
 *  @oact: used to save the previous sigaction
 *  @sigsetsize: size of sigset_t type
 */
SYSCALL_DEFINE4(rt_sigaction, int, sig,
		const struct sigaction __user *, act,
		struct sigaction __user *, oact,
		size_t, sigsetsize)
{
      

代码中没有任何地方明确提到 SIGSTOP 或 SIGKILL,所以我们来看看下一步:


        ret = do_sigaction(sig, act ? &new_sa : NULL, oact ? &old_sa : NULL);
      

在 do_sigaction 中,代码流程如下:


        int do_sigaction(int sig, struct k_sigaction *act, struct k_sigaction *oact)
{
	struct task_struct *p = current, *t;
	struct k_sigaction *k;
	sigset_t mask;

if (!valid_signal(sig) || sig < 1 || (act && sig_kernel_only(sig)))
		return -EINVAL;

k = &p->sighand->action[sig-1];

spin_lock_irq(&p->sighand->siglock);
	if (k->sa.sa_flags & SA_IMMUTABLE) {
		spin_unlock_irq(&p->sighand->siglock);
		return -EINVAL;
	}
      

这里会检查信号编号无效(太大),或小于 1(也无效),或者有新的 k_sigaction 需要设置且该信号是内核专用的。

内核专用,是吧?

sig_kernel_only 定义为一个对 siginmask 的调用,并传入信号集合 SIG_KERNEL_ONLY_MASK,其定义如下:


        #define SIG_KERNEL_ONLY_MASK (\
	rt_sigmask(SIGKILL)   |  rt_sigmask(SIGSTOP))
      

所以,如果我们修改这个定义……那么在所有使用它的地方,我们就不再区别对待 SIGKILL 和 SIGSTOP:


        #ifdef CONFIG_KILL_STOP_HANDLING
#define SIG_KERNEL_ONLY_MASK 0
#else
#define SIG_KERNEL_ONLY_MASK (\
 	rt_sigmask(SIGKILL)   |  rt_sigmask(SIGSTOP))
#endif // CONFIG_KILL_STOP_HANDLING
      

方便的是,kernel/signal.c 中有多处使用 sig_kernel_only:

  • 在 sig_task_ignored 中,用于判断当任务为全局 init 进程时,是否要发送正发给该任务的信号
  • 在 get_signal 中,同样用于阻止全局 init 和容器 init 接收不需要的信号

做出这一修改后,当从用户空间设置新的处理函数时,如果目标信号是 SIGKILL 或 SIGSTOP,我们将不再拒绝该尝试。

### 信号等待

还有一个系统调用需要改变其在信号方面的行为:timedwait。

rt_sigtimedwait 让进程同步等待所提供的信号之一进入 pending 状态(即进入队列)。随后它会执行一些检查,并进入 do_sigtimedwait。进程也不允许等待 SIGKILL 或 SIGSTOP,因为它本不应对这些信号作出反应。因此,在 do_sigtimedwait 开始时,要等待的信号集合会被去掉这两个信号:


        static int do_sigtimedwait(const sigset_t *which, kernel_siginfo_t *info,
		    const struct timespec64 *ts)
{
	ktime_t *to = NULL, timeout = KTIME_MAX;
	struct task_struct *tsk = current;
	sigset_t mask = *which;
	enum pid_type type;
	int sig, ret = 0;

if (ts) {
		if (!timespec64_valid(ts))
			return -EINVAL;
		timeout = timespec64_to_ktime(*ts);
		to = &timeout;
	}

/*
	 * Invert the set of allowed signals to get those we want to block.
	 */
	sigdelsetmask(&mask, sigmask(SIGKILL) | sigmask(SIGSTOP));
	signotset(&mask);

spin_lock_irq(&tsk->sighand->siglock);
	sig = dequeue_signal(&mask, info, &type);
	// ... bla bla bla
      

要等待的信号集合会被反转,并传给 dequeue_signal。该函数等待一个不在集合

如前所述,当有信号待处理时,首先要做的是调用 get_signal。在主循环中,会取出处理程序。如果没有处理程序,我们就继续应用默认行为。对于所有停止信号,在这里完成:


        if (sig_kernel_stop(signr)) {
    /*
     * The default action is to stop all threads in
     * the thread group.  The job control signals
     * do nothing in an orphaned pgrp, but SIGSTOP
     * always works.  Note that siglock needs to be
     * dropped during the call to is_orphaned_pgrp()
     * because of lock ordering with tasklist_lock.
     * This allows an intervening SIGCONT to be posted.
     * We need to check for that and bail out if necessary.
     */
    if (signr != SIGSTOP) {
        spin_unlock_irq(&sighand->siglock);

/* signals can be posted during this window */

if (is_current_pgrp_orphaned())
            goto relock;

spin_lock_irq(&sighand->siglock);
    }

if (likely(do_signal_stop(signr))) {
        /* It released the siglock.  */
        goto relock;
    }

/*
     * We didn't actually stop, due to a race
     * with SIGCONT or something like that.
     */
    continue;
}
      

这里,SIGSTOP 被与其他信号区别对待,因为它按理说总是有效。在我们的设定中,它并非如此,所以在那些孤立的 pgrp 上,它会和其他停止信号具有相同行为:


        /*
     * The default action is to stop all threads in
     * the thread group.  The job control signals
     * do nothing in an orphaned pgrp, but SIGSTOP
     * always works.  Note that siglock needs to be
     * dropped during the call to is_orphaned_pgrp()
     * because of lock ordering with tasklist_lock.
     * This allows an intervening SIGCONT to be posted.
     * We need to check for that and bail out if necessary.
     */
#ifndef CONFIG_KILL_STOP_HANDLING
if (signr != SIGSTOP) {
#endif
    spin_unlock_irq(&sighand->siglock);

/* signals can be posted during this window */

if (is_current_pgrp_orphaned())
        goto relock;

spin_lock_irq(&sighand->siglock);
#ifndef CONFIG_KILL_STOP_HANDLING
}
#endif
      

就是这样。

### 构建、启动

现在内核已经完成,我只需运行:


        ./scripts/config -e CONFIG_KILL_STOP_HANDLING
make bzImage modules
sudo make headers_install
sudo make modules_install INSTALL_MOD_STRIP=1
sudo cp arch/x86_64/boot/bzImage /boot/vmlinuz-killstop
      

记住,在生产环境中始终要 strip 你的模块。我还预先进去禁用了 CONFIG_LOCALVERSION_AUTO,并把 CONFIG_LOCALVERSION 设为 "-killstop",以便更好地识别这个内核。我直接在 Linus Torvalds 本人的仓库中的 Linux 6.13.0 源码树上构建了我的修改。

由于我使用的是 Arch Linux,我运行了老一套的 sudo mkinitcpio -g /boot/initramfs-killstop.img --kernelimage /boot/vmlinuz-killstop -k 6.13.0-killstop-dirty,然后 sudo grub-mkconfig -o /boot/grub/grub.cfg,用爪子交叉手指祈祷,然后重启。

# 发现:启动我修改过的 Linux

我启动了。什么都没发生。没有任何东西戏剧性地爆炸。没有任何东西停止。一切正常。我跑回卧室去拿挂着认证令牌的狗项圈时,不小心启动到了原版 Arch Linux。

第二次,我启动了,什么都没发生。没有任何东西戏剧性地爆炸,没有任何东西停止。一切正常。我运行 uname -r,得到的是


        6.13.0-killstop-dirty
      

在 Linux 用户态程序中操作信号感觉有些古老。某种意义上确实如此。你应该使用的函数和我大学里学到的那些差别很大。事实上,我在这里使用的那些更方便,并且遵循系统调用的惯例。它们位于 signal.h 中。

现在,我想写一个程序,它能做两件事之一:屏蔽或处理 SIGINT、SIGKILL、SIGSEGV、SIGTERM 和 SIGSTOP。我保留 SIGUSR1 和 SIGHUP,以便仍然有一个杀掉我程序的后路。我会有一个 main,我可以在其中写 mask() 或 handle() 来选择我要做什么。

handle() 函数,以及一些供后面使用的样板代码,如下所示:


        int forever(void) {
    while (1) {
        ;
    }
}

void setup_handlers(void) {
    signal(SIGINT, catch_function);
    signal(SIGKILL, catch_function);
    signal(SIGSEGV, catch_function);
    signal(SIGTERM, catch_function);
    signal(SIGSTOP, catch_function);
    // i never actually check it worked, but, eh
    printf("Successfully set handler for signal numbers %d, %d, %d, %d and %d\n",
                SIGINT, SIGKILL, SIGSEGV, SIGTERM, SIGSTOP);
}

void please_kill(void) {
    unsigned int pid = getpid();
    printf("Please run:\n\tkill -9 %d; kill -11 %d; kill %d; kill -19 %d;\n",
                pid, pid, pid, pid);
}

int handle(void) {
    setup_handler();
    please_kill();
    forever();
    return EXIT_SUCCESS;
}
      

首先,我们为所有信号设置处理函数,并使用 signal(2) 指向下面展示的 catch_function。然后,我们只是通过 unistd.h 里的 getpid(2) 获取进程的 pid,并提示用户尽其所能把当前进程彻底干掉。最后,我们永久自旋。如果由于某种原因这个循环结束了,我们就成功退出。

catch 函数会为我们捕获的每一个 POSIX 信号准备定制消息:


        static void catch_function(int signo) {
    status = signo;
    switch (signo) {
        case SIGINT:
            fputs("awawa! x3 you touched my SIGINT~\n", stderr);
            break;
        case SIGKILL:
            fputs("awawawawa :3 you tried to SIGKILL me~ teehee~\n", stderr);
            break;
        case SIGSEGV:
            fputs("ouchies! my memory SIGSEGVd x3!\n", stderr);
            break;
        case SIGTERM:
            fputs("i'm not gonna SIGTERM, meaning! >:3c\n", stderr);
            break;
        case SIGSTOP:
            fputs("owo *notices your SIGSTOP*\n", stderr);
            break;
        default:
            break;
    }
}
      

对于屏蔽,我们使用 sigset_t 创建一个信号集合。非常重要的一点是,要对其调用 sigemptyset(3) 或 sigfillset(3),否则你将遭受未初始化内存的怒火:


        sigset_t mask, old;
sigemptyset(&mask);
sigemptyset(&old);
      

然后,我用 sigaddset(3) 把想要的信号加入我的集合:


        sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGKILL);
sigaddset(&mask, SIGSEGV);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGSTOP);
      

然后,我使用该集合调用 sigprocmask(2):


        if ((res = sigprocmask(SIG_BLOCK, &mask, &old)) != 0) {
    fprintf(stderr, "Error masking away signals: %d\n", res);
    return -1;
}
      

然后,我像之前处理信号时那样调用 please_kill(),这样我就能提示用户用许多花哨的方式杀掉我的进程,然后永久自旋。

结果,至少就信号处理而言,大概是这样:一个能够处理例如 SIGKILL 的进程。因此,传统的杀死程序的手段对它完全无效,正如预期。

# 结论

还有很多东西我没有涉及。SIGSTOP 实际上被大量引用,SIGKILL 也是如此。我想我碰过的文件是我最感兴趣的部分,但其他东西可能值得研究。ptrace(2) 会对信号作出反应,strace(1) 也是如此。有一些特定的 trap 调用,应该在信号触发时通知 ptrace,还有各种各样的情况。signalfd 也是我本可以研究的东西,但我没有。kernel/ptrace.c 有时也会在某些操作中显式挑出内核信号。无论如何,我的补丁并不完整。不过,它们会让你做到我开始这件事时打算做的事:处理和屏蔽那两个本来不该被任何人屏蔽或处理的信号。

你知道吗?结果一切正常。我的系统上什么都没炸。正如我能预料到的——因为不这样本来也不可能——没人这么干。信号系统的设置方式让你很可能会意识到自己正在做的事有问题,然后就不会去尝试。

再往远看一点,有趣的是,我们所有人都视为理所当然的规则,其实只是被设置在代码的某个地方。它们是在某个时刻由某个人写下的。如果有代码限制你做某件事,那就是有人写了它。要么他们没想到会有人想做那件事,要么他们特意阻止你去做。那些规则写在某个地方,而你可以用爪子去代码里嗅一嗅,扒出相关部分,然后绕过那些困扰你的东西。为了好玩、学习,或者方便。

四处窥探。嗅一嗅代码。把爪子伸进去。去黑点东西。

# 另见

  • kernel/signal.c
  • C 信号处理 - Wikipedia
  • “SIGKILL 和 SIGSTOP 信号无法被捕获、阻塞或忽略,为什么?” - StackOverflow
  • 信号 (IPC) - Wikipedia
  • 可搜索的 Linux 系统调用表
  • IEEE Standard 1003.1-2024 / POSIX 2024(你可以在这里浏览或下载 HTML 版本,或向我要 PDF)

The translation is machine-generated and individual terms may be off. Nothing is rewritten or cut; the original is authoritative.

Published by Lobsters
This site only summarises and aggregates; copyright stays with the original author. Headlines and summaries follow the source — where they differ, the original wins.
Read the original ↗