想了解如何利用eBPF技术精确跟踪Shell脚本及解释型语言的执行路径,同时解决因内核行为导致的监控盲点吗?从 execve 到子进程追踪,这些方法将帮助你全面掌握脚本运行的每一个细节。
本文译自 Tracking Shell Scripts (and Python, Perl, etc) with eBPF is Hard[1]
在Linux世界里,如果你想跟踪机器上的活动,eBPF几乎总是那个标准答案。这一点在进程跟踪上尤其明显:简单地挂载到 execve 系统调用,你就能获得大量信息。
不过,如果你需要更精确的数据,比如被启动的二进制文件的确切路径,就需要深入到类似 bprm_check_security 这样的BPF-LSM钩子,它能让你获取更详尽的进程上下文。
我们团队在项目中正是这样实践的,并且一度运行得完美无缺……直到它遇到了一个棘手的问题。

解释型脚本带来的困扰
当我们运行一个常规的可执行文件时,监控日志的输出清晰明了(例如,我们记录了进程访问某些受保护文件的事件):
exec => "/usr/bin/accessor"
read file => "/tmp/foo/bar"
然而,当我们运行一个Shell脚本时,输出却变成了这样:
exec => ""
read file => "/tmp/foo/bar"
那个关键的Shell脚本路径去哪了?
问题的根源在于内核执行带有 #!(Shebang)的文件的特殊方式,这包括了Shell脚本以及Python、Perl、Ruby等所有解释型语言脚本。
内核的“两次”execve把戏
当执行一个解释型脚本时,内核悄悄增加了一个额外步骤。首次调用 execve() 会触发我们的eBPF钩子。此时,我们捕获到的数据结构看起来是这样的:
process_id => 1
executable_file => "/path/to/script.sh"
interpreter_file => ""
但是,内核会读取文件的前两个字节。一旦它识别出 #!,Linux内核会紧接着发起第二次 execve() 调用!
内核看到了shebang行,这条指令告诉它:不要直接执行这个文件;应该将其作为参数传递给一个解释器程序,由解释器来实际执行代码。关键在于,脚本文件本身作为一个可执行实体,在第二次调用时“消失”了。
我们的eBPF钩子自然也会在第二次 execve() 时被触发,这正是问题的起点:
process_id => 1
executable_file => ""
interpreter_file => "/usr/bin/bash"
第二次调用针对的是同一个进程ID,但参数已经完全改变。由于没有“可执行文件”了,进程中唯一活动的实体变成了解释器(如 /bin/bash)。因此,当我们的eBPF代码将这些新数据写入存储(一个eBPF映射)时,它会覆盖掉第一次调用时存储的数据。
所以,逻辑过程如下:
- 第一次
execve() 之后,映射中的数据是:
process1 : exec { path: "/path/to/script.sh", interpreter: "" }
- 第二次
execve() 之后,数据被覆盖为:
process1 : exec { path: "", interpreter: "/usr/bin/bash" }
解决方案其实很直接:我们需要在写入数据前增加一个检查。如果发现该进程ID对应的“路径”字段已经被记录过(即非空),就不再对其进行覆写。这样,我们就能成功保留住脚本文件的原始路径信息。对于 Bash 或其他解释器的深入监控,可以在 运维/DevOps/SRE 实践中找到更多思路。
追踪子进程:另一座待翻越的山
虽然上述方法让我们成功捕获了脚本的路径,但这并没有解决所有问题。对于Shell脚本,我们还需要注意另一个特性。
你想过吗?大多数在Shell脚本中执行的操作,其实是通过调用其他独立的可执行文件来完成的。比如,当你想在脚本里读取一个文件内容时,通常会使用 cat 这个命令(即 /bin/cat 这个可执行文件)。
但 cat 会作为一个全新的子进程被启动。因此,如果我们监控一个打开了 file.txt 的Shell脚本,我们期望的输出是:
exec => "/path/to/script.sh"
read file => "file.txt"
可实际我们得到的却是:
exec => "/usr/bin/cat"
read file => "file.txt"
这是因为Shell脚本本身从未直接执行文件操作;它将任务“委派”给了自己创建的子进程。这就导致了监控视角的割裂:父进程(脚本)和子进程(如 cat)在监控日志中表现为两个独立且不直接关联的实体。
因此,如果我们想要精确地归因行为——例如,知道是哪个脚本最终导致了某个文件被读取——仅仅跟踪单一的 execve 调用是不够的。我们还需要跟踪进程的派生关系,将整个进程树(父脚本及其所有子进程)的行为进行关联和分析。这涉及到更复杂的 eBPF 程序逻辑,需要挂钩 fork、clone 等系统调用来构建和维护进程间的父子关系图谱。
总结
使用 eBPF 跟踪解释型脚本,初看简单,实则暗藏两层挑战:
- Shebang导致的数据覆盖:内核的双重
execve 调用会覆盖我们第一次记录的脚本路径。解决方法是在写入 eBPF 映射时,检查并保留非空的路径字段。
- 子进程的行为归因:脚本的真实行为往往隐藏在它调用的子进程中。要获得完整的视图,必须追踪进程树,将子进程的行为正确地关联回其父脚本。
克服这些挑战,你的 eBPF 监控工具才能真正做到明察秋毫,无论是编译型二进制文件还是灵活的脚本,都逃不过你的法眼。想了解更多底层系统调用和内核机制的细节,可以关注相关的技术讨论。
引用链接
[1] Tracking Shell Scripts (and Python, Perl, etc) with eBPF is Hard: https://substack.bomfather.dev/p/tracking-shell-scripts-and-python