Christiano Haesbaert

来自内部的信号:eBPF 如何与信号交互

本文探讨了从 eBPF 程序生成的 UNIX 信号的一些语义。

阅读时长 19 分钟安全研究
Signaling from within: how eBPF interacts with signals

背景

信号自 1971 年 UNIX 第一版问世以来就一直存在,虽然它的语义和系统调用多年来经历了变化,但其用途和应用在很大程度上保持不变。通常,当我们谈论信号语义时,我们谈论的是用户态可以观察和交互的内容。毕竟,我们主要生成和处理发送给/来自用户态进程的信号。

在本出版物中,我们将探讨从内核*内部*在 eBPF 程序中生成的信号的一些语义。更重要的是,我们将确定在处理此类信号后我们观察到的影响和保证。您可以在本文中找到有关 eBPF 的更多信息。

动机

Elastic Defend for Containers中,我们在 Linux 安全模块 (LSM) 钩子中使用 eBPF,该钩子限制对系统资源的访问。使用 LSM 是进行此类限制的首选方式,因为 eBPF 程序可以返回类似 EPERM (尝试操作,但没有适当的权限) 的错误,该错误会传播到系统调用的返回值。

以这种方式使用 eBPF+LSM 的问题在于,支持相对较新,并且大部分仅适用于 AMD64。因此,我们希望探索使用 eBPF 辅助函数 bpf_send_signal()(在必要时),例如较旧的内核或不同的架构。与其使用 EPERM 使系统调用失败,不如使用 bpf_send_signal() 发送 SIGKILL 给当前进程并终止它,这可以说是更具戏剧性,但在给定限制的情况下仍然是合理的。

通常,我们的目标是回答以下问题

  • 程序收到 SIGKILL 后会观察到哪些副作用(如果有)
  • 哪些副作用(如果有)是由信号子系统设计而不是实现造成的
  • 如果内核代码将来发生变化,这将如何影响这些副作用

场景:阻止 openat(2)

假设我们希望阻止某些进程打开文件,为了简单起见,我们希望阻止这些进程使用 openat(2) 系统调用。

如果 LSM 可用,我们会将 eBPF 程序挂钩到 LSM 钩子 security_file_open() 中,返回 EPERM,然后 openat(2) 将正常失败。由于 LSM 不可用,我们将改为生成 SIGKILL,但首先,我们需要找到一个在内核中挂钩 eBPF 程序的位置。

我们有以下选择:使用静态跟踪点(如 syscalls:sys_enter_openat2)或者可以使用 kprobes,并从我们选择的内核函数中运行 eBPF 程序。明显的候选项是 vfs_opendo_sys_openat2(发生时间稍早)或 __x64_sys_openat(甚至更早,但与机器有关)。我们可以使用 bpftrace 进行测试

bpftrace --unsafe -e 
 'kprobe:vfs_open /str(((struct path *)arg0)->dentry->d_name.name) == "__noopen"/ 
 { signal("SIGKILL") }'

# In another tty we can put it to the test
$ strace /bin/cat /tmp/__noopen
...
openat(AT_FDCWD, "/tmp/__noopen", O_RDONLY) = ?
+++ killed by SIGKILL +++
Killed

我们可以看到,当 cat(1) 尝试打开文件时,会立即被 SIGKILL 终止。乍一看,这似乎可以正常工作,但现在宣布胜利可能还为时过早。

重要的是要注意,信号不是由外部进程生成的,而是由执行系统调用自身的 cat(1) 进程的上下文中生成的。它执行了内核中的 kill(0, SIGKILL) 的等效操作,其中 0 表示“自身”。

我们唯一证明的是程序确实被终止了,但这引发了更多问题

  • 我们是否阻止了 openat(2)
  • 如果我们成功阻止了 openat(2),结果会改变吗?
  • 还有更多可观察的副作用吗?

如果我们在同一路径上进行相同的实验,但使用不存在的文件,并将 O_CREAT 标志传递给 openat(2),是否创建了该文件?应用程序是否仍然被终止?让我们看看会发生什么

$ rm /tmp/__noopen 
$ strace /bin/touch /tmp/__noopen
...
openat(AT_FDCWD, "/tmp/__noopen", O_WRONLY|O_CREAT|O_NOCTTY|O_NONBLOCK, 0666) = ?
+++ killed by SIGKILL +++
Killed

在这种情况下,我们仍然会被 SIGKILL 终止。但是,如果我们检查文件系统,现在会有一个由违规程序创建的空文件!我们可以得出结论,openat(2) 确实以某种方式执行了,因为观察到了文件创建。

内核处理 SIGKILL

信号不能在线处理;相反,它们必须在安全点进行后处理。在线是指:如果我正在进行系统调用,并且一个 SIGKILL 到达,我不能就这样停止存在。必须在安全点检查信号,在大多数 UNIX 系统中,这是在返回用户态之前完成的。

exit_to_user_mode_loop() 中运行完系统调用后,会进行信号待处理的检查。如果当前任务结构中设置了 TIG_SIGPENDING,则进程会跳转到信号处理代码。当 SIGKILL (一个致命信号)待处理时,进程会跳转到 do_group_exit(),该函数永不返回,导致进程结束。

为什么需要后处理信号

信号必须在安全点进行后处理和处理,否则内核将不得不考虑由于致命信号导致的进程非自愿退出。我们可以进行一个思想实验,想象一种在信号到达时立即尝试处理信号的实现。例如,可以通过中断正在运行的进程并强制其从中断上下文中退出来实现这一点

  • cpu0 上运行的进程 A 执行系统调用
  • cpu1 上运行的进程 B 向进程 A 发送 SIGKILL
  • 会从 cpu1cpu0 发送一个 IPI
  • cpu0 将陷入中断帧,意识到这是由于发送了信号,并执行当前进程的退出

幸运的是,事实并非如此,您无法从中断上下文中退出——此外,如果不引入对资源管理的重大更改,则无法实现这一点。当进程退出时,它必须释放任何资源——如锁、引用计数或任何其他可能受退出进程影响的可变数据。

我们可以与内核抢占进行类比,因为当配置了 CONFIG_PREEMPT_FULL 时,Linux 具有高度的抢占性。这允许调度器在正在运行的进程处于内核空间时将其搁置,并运行其他进程。从被抢占的进程的角度来看,这是一种非自愿的上下文切换,因为它没有自愿释放 CPU。这与抢占式的用户空间是正交的,在抢占式的用户空间中,调度器会抢占在用户模式下运行的正在运行的进程。历史上,UNIX 系统没有采用抢占式内核,保持低延迟的策略仅依赖于快速(短)系统调用和中断优先级

使用抢占进行编程更加困难,因为内核程序员必须始终考虑被抢占的影响,并判断何时禁用抢占。例如,在 自旋锁 上,未能及时禁用抢占可能会导致另一个进程无限期地在一个被抢占进程的锁上自旋。

如果我们允许进程从陷阱帧中非自愿退出,那将有点像抢占,但更加困难——如果不是不可能的话。内核程序员现在必须始终考虑“如果我的进程在这里非自愿退出会发生什么?”,这很可能涉及必须注册回调以在退出时释放资源。

希望现在很清楚为什么信号不能在线处理。像其他系统一样,Linux 中的信号在返回用户空间之前进行处理。

从 eBPF 发布 SIGKILL

让我们跟踪一个源自 eBPF 程序的 SIGKILL 的生命周期,直到进程终止。

当 eBPF 程序调用特殊辅助函数 bpf_send_signal(SIGKILL) 时,我们最终会进入 bpf_send_signal_common(SIGKILL, PIDTYPE_TGID)PIDTYPE_TGID 是“任务组 ID”,它指定当前进程的任何任务(即任何 pthread)都可以接受信号。但是 eBPF 还提供了 bpf_send_signal_task(),它仅通过指定 PIDTYPE_PID 将信号发送到当前任务。

必须谨慎使用 bpf_send_signal_common(),因为它必须能够从内核中可以附加 eBPF 程序的任何位置生成信号;这是一项棘手的工作,导致了一些过去的错误,如这个死锁。这是 eBPF 创建的一个有趣的强制;在此之前,内核生成的信号是在受控点完成的。

发布信号的大部分繁重工作是在 __send_signal_locked()complete_signal() 中完成的,我们通过以下堆栈到达那里

complete_signal()          ^
__send_signal_locked()     |
send_signal_locked()       |
do_send_sig_info()         |
group_send_sig_info()      |
bpf_send_signal()          |


static int __send_signal_locked(int sig, 
    struct kernel_siginfo *info, struct task_struct *t, 
    enum pid_type type, bool force)

在我们的例子中,在 __send_signal_locked 中:sigSIGKILLinfoSEND_SIG_PRIVt 是当前任务(正在运行的线程),typePIDTYPE_TGIDforce 为 true,当 infoSEND_SIG_PRIV 时始终设置该值,这意味着这是一个源自内核的信号,而不是来自某个用户空间程序的信号。

__send_signal_locked() 会将 SIGKILL 注册为 当前任务结构中的待处理信号(我们的 t),这是一个进程范围的结构,由该进程中的所有任务(pthread)共享(因为我们正在使用 PIDTYPE_TGID),然后控制权传递给 complete_signal()

SIGKILLcomplete_signal() 中有点特殊,因为它是一个致命信号,在进程的共享结构中设置的待处理信号位随后会复制到每个任务的待处理集中。这意味着 SIGKILL 被标记为当前进程的每个 pthread 的待处理信号。

complete_signal() 然后通过 signal_wake_up+signal_wake_up_state() 唤醒所有线程,以便它们可以被终止。每个线程必须自行终止并发送一个信号,礼貌地要求线程“请下次退出,而不是返回用户空间”。

signal_wake_up() 堆栈中,会设置一个标志 TIG_SIGPENDING ,警告任务检查其待处理的信号。可能存在这样一种情况:当我们尝试唤醒线程时,该线程正处于用户态,更糟糕的是,它可能无限循环。在这种情况下,除非调度程序决定抢占它或发生中断,否则它不会进入内核。为了避免这种情况,会通过 kick_process() 强制线程进入内核,该函数会向远程 CPU 发送一个 IPI,强制进程陷入内核,然后尝试返回用户态,检查 TIG_SIGPENDING,找到一个 SIGKILL,并终止进程。

自愿信号检查

虽然信号只在返回用户态时处理,但检查这些信号是否待处理可以在任何地方进行。tmpfs、ext4、xfs 和许多其他文件系统会在开始写入之前检查是否存在待处理的致命信号。如果存在待处理的致命信号,它们会向调用者返回一个错误,从而展开系统调用堆栈,直到返回用户态,然后像我们之前看到的那样终止程序。可以在这里看到对 tmpfs 和 ext4 写入的自愿检查。

现在我们可以推断,如果我们在内核入口早期安装一个生成 SIGKILL 的 eBPF 程序,tmpfs 中会发生什么:由于信号会被注意到,写入操作将不会被执行,并且操作会被中止。

然而,Btrfs 的行为与其他文件系统不同。它在 IO 堆栈中向下执行写入或读取操作之前,不会检查信号。当收到 SIGKILL 时,它会在终止之前完成 IO 操作。

当程序进入写入系统调用时,我们无法通过从 eBPF 程序生成 SIGKILL 来阻止 Btrfs 写入。假设这是我们想要做的,那么在 openat(2) 中更早地生成 SIGKILL 是合乎逻辑的:这样我们就可以更早地终止程序,甚至在它有机会执行写入操作之前。不幸的是,正如下一节所示,这也是不可靠的。

竞态打开 & 写入操作

如果我们在 openat(2) 中生成 SIGKILL,仍然有可能写入将返回的文件描述符,至少使用 Btrfs 是这样。以下 bpftrace 行将在 vfs_open() 上安装一个微小的 eBPF 程序,该程序将生成 SIGKILL 并终止任何尝试打开名为 __nowrite 的文件的进程。

bpftrace --unsafe -e 'kprobe:vfs_open /str(((struct path *)arg0)->dentry->d_name.name) == "__nowrite"/ 
 { signal("SIGKILL") }'

仍然有可能与内核竞争并写入即将成为的文件描述符,这意味着即使我们可以终止进程,也不能依赖此机制来阻止文件被修改。

现在应该很清楚,正如本文开头讨论的那样,打开操作确实发生了。可以使用 O_CREAT 标志创建文件,然后可以观察到打开操作和进程终止之间发生的影响。重要的可观察影响是进程文件表在终止之前被填充

进程文件表是一个进程内内核表,它将文件描述符编号映射到文件对象。例如,文件描述符 1 指的是表示标准输出的文件对象,因此如果用户态调用 write(1, "foo", strlen("foo")),内核将查找文件描述符 1 引用的对象,并对其调用 vfs_write()。文件结构具有知道如何写入标准输出的回调函数,我们称之为文件描述符的后备。

总的思路是猜测打开操作将返回的文件描述符编号,并在进程终止之前但在打开操作生效之后尝试写入该文件描述符。

第一个技巧是弄清楚文件描述符编号是什么,这可以通过以下方法完成

int guessed_fd;

guessed_fd = dup(0);
close(guessed_fd);

当通过 dup(2)open(2)accept(2)socket(2) 或任何其他系统调用创建文件描述符时,它保证使用可用的最低编号。如果我们 dup 任何文件描述符并将其关闭,则下一个创建文件描述符的系统调用很可能会使用我们之前从 dup(2) 获得的相同索引。对于多线程程序,情况并非总是如此,因为另一个线程可能会创建一个文件描述符并使我们的猜测无效。正是由于这些竞争,才有了 dup2(2),以便允许多线程程序进行无竞争的 dup。多线程是 UNIX 系统后期添加的功能,因此必须保留文件描述符编号的旧语义。

这种猜测不是必要的,因为我们有一个受控的环境。然而,它很有趣,因为它可以用作尝试利用这种竞争条件的攻击的基础块。

现在我们有了一个目标文件描述符,我们可以生成一堆工作线程来尝试写入它!

/*
 * Guess the next file descriptor open will get
 */
if ((fd = dup(0)) == -1)
	err(1, "dup");
close(fd);

/*
 * Hammer Time, spawn a bunch of threads to write at the guessed fd,
 * they hammer it even before we open.
 */
while (num_workers--)
	if (pthread_create(&t_writer, NULL, writer, &fd) == -1)
		err(1, "pthread_create");

/* Give the workers some lead time */
msleep(10);

/*
 * This should never return, since we are supposed to be SIGKILLed.
 * The race depends on the workers hitting the file descriptor after
 * open(2) succeeded (after fd_install()) but before
 * exit_to_user_mode()->do_group_exit().
 */
fd = open(path, O_RDWR|O_CREAT, 0660);
errx(1, "not killed, open returned fd %d", fd);

写入器-工作器代码非常简单,正如你所期望的那样

void *
writer(void *vpfd)
{
	ssize_t n;
	int fd = *(int *)vpfd;

	/*
	 * We'll just hammer-write the guessed file descriptor, if we succeed
	 * we just bail as the parent thread is about to do it anyway.
	 */
	while (1) {
		n = write(fd, SECRET, strlen(SECRET));
		/* We expect to get EBADFD mostly */
		if (n <= 0) {
			continue;
		}
		/* Hooray, the file has been written */
		break;
	}

	return (NULL);
}

完整的程序可以在这里找到。

大多数情况下,我们无法触发竞争条件,程序会以 SIGKILL 终止。但是,通过在循环中多次尝试运行程序,我们可以在大约一分钟内命中竞争。

truncate -s0 __nowrite
until test -s __nowrite; do ./race-openwrite __nowrite; done

值得指出的是,此行为在任何方面都不是内核错误,并且只能在 Btrfs 中重现。我们未能像 ext4、tmpfs 和 xfs 等其他文件系统中触发此竞争条件,因为这些实现会在继续写入之前明确检查是否存在待处理的致命信号。

其他影响

我们已经讨论了打开和写入,并且还检查了通过生成 SIGKILL 来阻止其他系统调用效果的行为。在下表中,BLOCKED 表示效果没有发生。例如,unlink 没有删除文件。正如您所猜测的那样,UNBLOCKED 表示效果确实发生了 – unlink 确实删除了文件。在这两种情况下,程序始终会被 SIGKILLed,这意味着我们的信号生成确实发生了。

6.5.5-200.fc38.x86_64BtrfstmpfsExt4
chmod(2)UNBLOCKEDUNBLOCKEDUNBLOCKED
link(2)UNBLOCKEDUNBLOCKEDUNBLOCKED
mknod(2)UNBLOCKEDUNBLOCKEDUNBLOCKED
write(2)UNBLOCKEDBLOCKEDBLOCKED
race-open-writeUNBLOCKEDBLOCKEDBLOCKED
rename(2)UNBLOCKEDUNBLOCKEDUNBLOCKED
truncate(2)UNBLOCKEDUNBLOCKEDUNBLOCKED
unlink(2)UNBLOCKEDUNBLOCKEDUNBLOCKED
6.1.55-75.123.amzn2023.aarch64XFS
chmod(2)UNBLOCKED
link(2)UNBLOCKED
mknod(2)UNBLOCKED
write(2)BLOCKED
race-open-writeBLOCKED
rename(2)UNBLOCKED
truncate(2)UNBLOCKED
unlink(2)UNBLOCKED
指令6.5.5-200.fc38.x86_646.1.55-75.123.amzn2023.aarch64
在 pipe(2) 上 write(2)UNBLOCKEDUNBLOCKED
fork(2)BLOCKEDBLOCKED

对于所有等效的“at”系统调用,也观察到相同的行为:openat(2)renameat(2)...

结论

我们已经演示了尝试使用来自 eBPF 的 SIGKILL 作为安全机制的一些缺陷,虽然在某些情况下可以可靠地使用它,但这些情况很微妙,需要深入了解它们运行的环境。本文的主要结论是

  • 来自 eBPF 的信号生成是同步的,因为它是在相同的进程上下文中生成和发送的
  • 信号在系统调用发生后在内核中处理
  • 如果存在待处理的致命信号,则特定的系统调用和组合将避免启动操作
  • 我们无法可靠地阻止 Btrfs 上的 write(2),即使我们在 open(2) 从内核返回之前终止程序

虽然我们的研究很彻底,但这些是微妙的语义,可能取决于外部因素。如果您认为我们遗漏了什么,请随时与我们联系。

如果您有兴趣了解更多信息,本研究中使用的程序和脚本是公开的,可在此存储库中找到。有兴趣了解更多关于内核的信息吗?查看关于调用堆栈的这篇深入探讨