tracee源码初探(一)
计划看源代码之前,需要做一些准备工作,就是我为什么一定要看这份源代码,它是不是一份优秀的代码。和它类似的代码还有哪些,这些相似的工程里,我为什么选中了它。我们计划开发一套探测系统行为的软件,目前有两个开源软件做得不错,tracee和falco,看github上的星。Falco的星更多一些,但是falco是用C++开发的,看不懂因此只能看tracee了。
另外一点,需要注意的是,在看代码之前要先了解它的设计,也就是看它的逻辑结构图,了解不通过滤直接的连接关系,尤其要关注连线上的标签的含义。如果是个较为复杂的系统的话,还可能有物理结构图,也是需要我们的先了解的。在了解了大的框架之后才可以去着手看代码。这里遵循的是金字塔从上到下的原则。
Tracee和Falco都是做入侵检测的,Falco和Tracee不同,Falco是一个基于sysdig、k8saudit等开源事件源的规则引擎。它从事件源获取原始事件,并将其与一个yaml文件中由falco语言定义的规则进行匹配。相反,Tracee从eBPF跟踪事件,但不执行基于这些事件的规则。本文来尝试分析tracee的代码。
Tracee开发者的宗旨:
- Tracee从一开始就被设计为一个轻量级的基于eBPF的事件跟踪程序
- Tracee构建在bcc之上,并且不重写底层BPF接口。Tracee从v0.3开始就从bcc转到libbpf了
- Tracee被设计为易于扩展,例如,在Tracee中添加对新系统调用的支持就像添加两行代码一样简单,在这两行代码中描述系统调用名称和参数类型。
- 还支持其他事件,例如内部内核函数。我们现在已经支持了cap_capable,并且我们正在添加对security_bprm_check lsm钩子的支持。由于lsm安全钩子是安全的战略点,我们计划在不久的将来添加更多这样的钩子。如果想看这些非系统调用的函数被哪些系统调用所调用,可以使用
--output option:detect-syscall
参数来支持,完整的命令如下所示:
docker run --name tracee --rm -it --pid=host --cgroupns=host --privileged -v /etc/os-release:/etc/os-release-host:ro -v /sys/fs/cgroup/:/sys/fs/cgroup/ -e LIBBPFGO_OSRELEASE_FILE=/etc/os-release-host -e TRACEE_EBPF_ONLY=1 aquasec/tracee:0.7.0 --trace container=9ac163254c62 --output option:detect-syscall
首先贴一张官网上tracee的架构图
tracee主要由两个进程组成tracee-ebpf和tracee-rules, 你可选只运行tracee-ebpf. tracee-ebpf作为产生tracee-rules的输入事件源是必须运行的。
tracee-ebpf
tracee-ebpf的函数启动点是 cmd/tracee-ebpf/main.go, 这里关注两个函数 tracee.New(cfg) 和 t.Run(ctx). 先来说New()函数,它定义了观测的496个事件,在events_definitions.go中,可以用"Name: "关键字来查找。tarcee代码实现里用到了一个tracee结构体, 内容如下所示:
type Tracee struct {
config Config
events map[int32]eventConfig
bpfModule *bpf.Module
eventsPerfMap *bpf.PerfBuffer
fileWrPerfMap *bpf.PerfBuffer
netPerfMap *bpf.PerfBuffer
eventsChannel chan []byte
fileWrChannel chan []byte
netChannel chan []byte
lostEvChannel chan uint64
lostWrChannel chan uint64
lostNetChannel chan uint64
bootTime uint64
startTime uint64
stats metrics.Stats
capturedFiles map[string]int64
fileHashes *lru.Cache
profiledFiles map[string]profilerInfo
writtenFiles map[string]string
pidsInMntns bucketscache.BucketsCache //record the first n PIDs (host) in each mount namespace, for internal usage
StackAddressesMap *bpf.BPFMap
tcProbe []netProbe
netInfo netInfo
containers *containers.Containers
procInfo *procinfo.ProcInfo
eventsSorter *sorting.EventsChronologicalSorter
eventDerivations map[int32]map[int32]deriveFn
kernelSymbols *helpers.KernelSymbolTable
}
这里注意 eventsPerfMap、fileWrPerfMap 和 netPerfMap ,分别对应事件、文件读写和网络事件(event包含后面两个??)。这里tracee代码的框架是遵循libbpf的开发流程走的。它的流程主要有四部:
- 使用 bpftool 生成内核数据结构定义头文件vmlinux.h。BTF 开启后,你可以在系统中找到 /sys/kernel/btf/vmlinux 这个文件,bpftool 正是从它生成了内核数据结构头文件。
- 开发 eBPF 程序部分。为了方便后续通过统一的 Makefile 编译,eBPF 程序的源码文件一般命名为 <程序名>.bpf.c。
- 编译 eBPF 程序(<程序名>.bpf.c)为字节码,然后再调用 bpftool gen skeleton 为 eBPF 字节码生成脚手架头文件(Skeleton Header)。这个头文件包含了 eBPF 字节码以及相关的加载、挂载和卸载函数,可在用户态程序中直接调用。
- 最后就是用户态程序引入上一步生成的头文件,开发用户态程序(<程序名>.c),包括 eBPF 程序加载、挂载到内核函数和跟踪点,以及通过 BPF 映射获取和打印执行结果等。
所以,我们也安装这个思路来分析代码。建立好libbpf脚手架之后,每个PerfMap实例调用其Start函数中的 poll() 函数来抓取事件。
Run()函数中的eventsPerfMap、fileWrPerfMap 和 netPerfMap开始抓取事件后启动了四个协程,分别是 processLostEvents、handleEvents、processFileWrites和processNetEvents分别处理丢失事件(什么时候触发丢失事件呢?)、事件、写文件事件和网络事件。
先来看 handleEvents ,这个函数先解码事件 decodeEvents, 如果tracee配置了缓存(默认没有Cache)那么就把事件进行排队 queueEvents, 然后处理事件 processEvents --> 处理派生函数 deriveEvents (派生函数由initEventDerivationMap定义) --> sinkEvents .其中重要的是 processEvents, 它会调用processEvent() 按类别处理事件。sinkEvents下沉事件也就是经过上面的多个函数的处理最终把处理好的事件最终报告到 ChanEvents channel中,如果遇到未定义的事件就累计到ErrorCount中。
注意,sinkEvents中只会发送事件的 emit 属性为 true 的事件。而emit为true的情形在函数 prepareEventsToTrace 中写明,有如下几种:
1. 用户指定事件, 例如: --trace event=write
2. 如果用户未指定事件,那么就是把 EventsDefinitions map中Sets包含 default 字样的事件配置为默认事件,这些事件的 emit 值都会被置为 true.
我们来看一下 processEvents 函数,如果启动命令添加了 container 相关参数,那么这里会过滤没有containerID 的事件。这里 event.ContainerID 是在 decodeEvents 函数中通过事件的属性 cgroupID 找到对应的 containerID. 找的过程就是遍历/sys/fs/cgroup/下面的所有空文件的inode的值,如果这个inode值等于该cgroup值,那么这个文件的标题就包含了containerID.举个例子: 如果是k8s拉起的容器那么 cgroupID 一般是 /sys/fs/cgroup/cpuset/kubepods.slice/kubepods-besteffort.slice/kubepods-besteffort-pod34c79171_6818_46c9_8cc5_124c65b03f83.slice/docker-017b45b0440376470bc8d1d1c8179541e6ca8c942b6a993c4024895d652ea2b4.scope 的inode值, inode 值可以通过 stat
func (t *Tracee) processEvents(ctx gocontext.Context, in <-chan *trace.Event) (<-chan *trace.Event, <-chan error) {
out := make(chan *trace.Event, 10000)
errc := make(chan error, 1)
go func() {
defer close(out)
defer close(errc)
for event := range in {
err := t.processEvent(event)
if err != nil {
t.handleError(err)
continue
}
if (t.config.Filter.ContFilter.Value || t.config.Filter.NewContFilter.Enabled) && event.ContainerID == "" {
// Don't trace false container positives -
// a container filter is set by the user, but this event wasn't originated in a container.
// Although kernel filters shouldn't submit such events, we do this check to be on the safe side.
// For example, it might be that a new cgroup was created, and not by a container runtime,
// while we still didn't processed the cgroup_mkdir event and removed the cgroupid from the bpf container map.
// Note: this check should be placed after processEvent() so cgroup_mkdir event is processed
continue
}
select {
case out <- event:
case <-ctx.Done():
return
}
}
}()
return out, errc
}
再回过头来看main函数,有下面一段,这就是把处理好的事件从ChanEvents channel中取出来,进行输出到控制台或者其他的地方:
go func() {
printer.Preamble()
for {
select {
case event := <-cfg.ChanEvents:
printer.Print(event)
case err := <-cfg.ChanErrors:
printer.Error(err)
case <-ctx.Done():
return
}
}
}()
看代码里面多次涉及一个库libbpfgo,可以去了解一下这个库的用法,原理它是一个把libbpf用go语言封装的库,可以供用户空间的代码方便的用go语言的调用。关于该库的详细用法可以参考https://github.com/aquasecurity/libbpfgo中的perfbuffers这个例子,关键的语句如下所示:
// main.bpf.c 编译出的.o文件,该文件定义了BPF_MAP_TYPE_PERF_EVENT_ARRAY类型的 maps 名为 events,用于收集内核事件数据.
// main.bpf.c 还定义了挂载的系统函数为 kprobe/sys_mmap
bpfModule, err := bpf.NewModuleFromFile("main.bpf.o")
// 加载字节码
bpfModule.BPFLoadObject()
// 挂载到内核函数
prog, err := bpfModule.GetProgram("kprobe__sys_mmap")
// 挂载到内核跟踪点
_, err = prog.AttachKprobe("__x64_sys_mmap")
eventsChannel := make(chan []byte)
lostChannel := make(chan uint64)
// maps 和 内核页关联起来,maps也和通知channel关联起来
pb, err := bpfModule.InitPerfBuf("events", eventsChannel, lostChannel, 1)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(-1)
}
// 不能的从内核 maps 中取数据,发生一个订阅的系统调用就会放一个数据到 maps 中,并往 channel 中也放入事件数据
pb.Start()
// 下面就可以阻塞读取 channel 中的事件数据进行处理和展示了。
// 需要注意的是不同类型的 maps 在 .bpf.c 文件中使用的预处理的方法是不同的,例如 perfbuf 和 ringbuf 就不同。详情可以参考libbpfgo 的例子。
我看了代码感觉 eventsChannel、fileWrChannel和netChannel 都会受到所有事件,也就是说一个系统事件发生后,会通知这三个 channel,进行三次过滤处理。到底是不是这样呢?希望能有大牛来讨论。但是我又在 tracee.bpf.c 中看到
BPF_PERF_OUTPUT(events); // events submission
BPF_PERF_OUTPUT(file_writes); // file writes events submission
BPF_PERF_OUTPUT(net_events); // network events submission
定义了三个 perf_event maps, 又感觉应该是分开通知和处理的,但是我看不到事件定义的区别,他们都是统一用 initBPF() 这个函数定义了所有事件,然后再定义了这些时间的三个 maps ,所以我认为是一个系统事件发生后,会通知这三个 channel.
用户空间的事件ID定义在events.go中,可以通过关键字“NetPacketBase”搜索;内核态的事件ID定义在tracee/pkg/ebpf/c/types.h中,可以通过关键字“NET_PACKET_BASE”搜索。
查找指定事件对应的处理逻辑
tracee-ebpf --list 可以输出支持的所有事件,以net_packet_http事件为例,到 tracee/pkg/events/events.go 中可以查看该事件的定义,到 tracee/pkg/ebpf/c/tracee.bpf.c 中可以查看到该事件的挂载点. tracee/pkg/ebpf/tracee.go 中可以看到该事件的触发处理函数。
////////////////////////////////////!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!/////////////////////////////////////
tracee-rules
因为我们不知道Tracee的运行流程但我们知道它的运行结果,这里使用逆向思维,先看输出,根据输出一步一步找到输入。看代码就是不停循环从上到下,再从下到上,再抽取某个细节流程贯彻整个上下就可以了。
Output
我们根据输出的标志性短语“*** Detection ***”找到了output.go这个文件,看到Tracee的输出分为webhook输出和标准Console输出,我们先来看较为简单的Console输出,它默认的输出格式由代码中的常量 DefaultDetectionOutputTemplate 来表示,如果你希望它安装自定义的格式输出可以配置参数 --output-template=path/to/my.tmpl 来设定。代码是使用golang自带的 text/template 来解析并输出格式,把结构体数据安装固定的格式输出。
然后我们再一步一步根据配置output格式的最初流程找到了函数的入口main的setupOutput函数。
Input
上面我们在main函数中不仅看到了setupOutput函数,还看到配置input的函数,这个input是指tracee-rules的input源。首先分析源配置 parseTraceeInputOptions,然后配置源 setupTraceeInputSource. 通过代码得知 tracee-rules 程序通过input-tracee 参数获取配置,这时我们到主机上来看进程参数:
root@machine:/home/sj# ps -ef|grep tracee
root 3896 2210 0 15:08 pts/0 00:00:00 docker run --name tracee --rm -it --pid=host --cgroupns=host --privileged -v /etc/os-release:/etc/os-release-host:ro -e LIBBPFGO_OSRELEASE_FILE=/etc/os-release-host aquasec/tracee:0.7.0
root 3942 3921 0 15:08 pts/0 00:00:00 /bin/sh /tracee/entrypoint.sh
root 3996 3942 0 15:08 pts/0 00:00:00 /tracee/tracee-ebpf --metrics --output=format:gob --output=option:parse-arguments --cache cache-type=mem --cache mem-cache-size=512 --trace event=close,dup,dup2,dup3,execve,init_module,magic_write,mem_prot_alert,process_vm_writev,ptrace,sched_process_exec,sched_process_exit,security_bprm_check,security_file_open,security_kernel_read_file,security_sb_mount,security_socket_connect --output=out-file:/tmp/tracee/pipe
root 3997 3942 0 15:08 pts/0 00:00:00 /tracee/tracee-rules --metrics --input-tracee=file:/tmp/tracee/pipe --input-tracee=format:gob
通过以上输出可以看出 tracee-ebpf 的输出是文件 /tmp/tracee/pipe 而且是gob格式,同时 tracee-rulesde 输入也是文件 /tmp/tracee/pipe, 输入格式是 gob,这就刚好对上了。也就是说tracee-ebpf 一直在不停的探测事件,探测到一个事件就输输出到文件 /tmp/tracee/pipe 中,格式是二进制格式。而 tracee-rules 不停的到这个文件里去取数据再翻译成 rules 的格式进行过滤最终显示出来。
// Converts a trace.Event into a protocol.Event that the rules engine can consume
func (e Event) ToProtocol() protocol.Event {
return protocol.Event{
Headers: protocol.EventHeaders{
Selector: protocol.Selector{
Name: e.EventName,
Origin: string(e.Origin()),
Source: "tracee",
},
},
Payload: e,
}
}
读取rules文件
上面我们在main函数中不仅看到了setupOutput函数、setupTraceeInputSource函数,还可以看到配置初使rules的流程:getSignatures,并且根据代码知道rego-runtime-target 可以配置为wasm(wasm 为文件名后缀,是一种新的底层安全的二进制语法?)或者rego, 默认为 rego。getSignatures 函数的流程就是读取指定目录下(默认是/tracee/rules/下面)的rules文件,同一个目录下可以有.go 也可以有 .rego 文件。流程是先读取.go 文件再读取 .rego文件。其中,读取.go文件时会读取.so文件,读取.rego文件时会读取helpers.rego 文件,以此来扩充默认的helpers函数。这里只阐述rego文件的流程,如果以后有时间再更新go文件的流程。rego其实就是OPA’s policy language (Rego),基于 Open Policy Agent, 使用rego的一些功能函数可以方便的解析规则文件的字段、以及后面方便的判断是否符合规则文件的定义要求。如果想要了解更多关于rego可以参考链接https://www.openpolicyagent.org/docs/latest/
读取每个.rego 规则文件后,会生成一个结构体 RegoSignature , 这个结构体非常重要,如下所示:
type RegoSignature struct {
cb detect.SignatureHandler
compiledRego *ast.Compiler
matchPQ rego.PreparedEvalQuery
metadata detect.SignatureMetadata
selectedEvents []detect.SignatureEventSelector
}
其中,比较重要的字段是metadata , 类型是detect.Signature
// Signature is the basic unit of business logic for the rule-engine
type Signature interface {
//GetMetadata allows the signature to declare information about itself
GetMetadata() (SignatureMetadata, error)
//GetSelectedEvents allows the signature to declare which events it subscribes to
GetSelectedEvents() ([]SignatureEventSelector, error)
//Init allows the signature to initialize its internal state
Init(cb SignatureHandler) error
//Close cleans the signature after Init operation
Close()
//OnEvent allows the signature to process events passed by the Engine. this is the business logic of the signature
OnEvent(event protocol.Event) error
//OnSignal allows the signature to handle lifecycle events of the signature
OnSignal(signal Signal) error
}
然后,创建好一个signature后,把这个signature作为参数用函数engine.NewEngine()创建一个规则引擎。然后,启动该引擎接收事件并处理 e.Start(sigHandler()).
规则引擎处理事件流程
首先来看rule engine的数据结构:
// Engine is a rule-engine that can process events coming from a set of input sources against a set of loaded signatures, and report the signatures' findings
type Engine struct {
logger log.Logger
signatures map[detect.Signature]chan protocol.Event
signaturesIndex map[detect.SignatureEventSelector][]detect.Signature
signaturesMutex sync.RWMutex
inputs EventSources
output chan detect.Finding
waitGroup sync.WaitGroup
config Config
stats metrics.Stats
}
这里第二个栏位 signatures 是一个map, key 是detect.Signature类型 value是 protocol.Event类型的channel. 并且这个结构体里面只有这一个 channel, golang 中的 channel 类型的变量都有给予额外的关注,因为这种类型的变量往往是连接事件的桥梁。所以,这里我们也额外关注一个 sigatures 这个变量,猜测它就是从input文件中读取的事件。现在我们对整个处理流程有个大概的思路了,让我们再次从上到下,也就是从main函数开始来理一下,这一次我们主要关注创建 rule-engine, 运行engine 以及怎么往engine里放数据,怎么取数据,怎么过滤数据以及最后怎么显示结果。engine的框架代码在main.go中,先是 engine.NewEngine, 然后是 e.Start(sigHandler()). 其中 NewEngine的第一参数是 sigs, 它的创建函数是 getSignatures, 该函数中有一个函数 findRegoSigs, 因为 aioEnabled 未被使能,因此使用的是 traceerego.go 中的Signature的函数实现。
另外,signaturesIndex 是根据事件选择器为索引的signature索引,而signatures是根据signature的event索引。
event的读入
在engine.NewEngine之前有个函数setupTraceeInputSource,其调用链中有个函数如下所示:
func setupTraceeGobInputSource(opts *traceeInputOptions) (chan protocol.Event, error) {
dec := gob.NewDecoder(opts.inputFile)
gob.Register(trace.Event{})
gob.Register(trace.SlimCred{})
gob.Register(make(map[string]string))
gob.Register(trace.PktMeta{})
res := make(chan protocol.Event)
go func() {
for {
var event trace.Event
err := dec.Decode(&event)
if err != nil {
if err == io.EOF {
break
} else {
log.Printf("error while decoding event: %v", err)
}
} else {
res <- event.ToProtocol()
}
}
opts.inputFile.Close()
close(res)
}()
return res, nil
}
这里启动一个协程不停的从inputfile中读取事件,并且返回这个channel作为NewEngine的入参来初始化ruleEngine作为engine.inputs,
事件的处理
engine.Start()是event处理的起点,在它的调用链中有两个函数OnEvent和consumeSources。前面我们提到过OnEvent是 traceerego.go 中的Signature的函数实现,看代码主要是event的过滤,如果匹配则再实现的数据的填充和格式编排。signatureStart中有个sync.WaitGroup类型参数,联系上下文会看到consumeSources中有它会一直等待直到inputs中为空。
而 consumeSources 是真正处理事件源的函数。consumeSources 函数从engine.inputs.Tracee 中读取事件,如果能读取到事件就对事件进行纷发,这里把event放到engine.signatures[s]中去. 虽然代码里先启动了signatureStart的协程但是因为最开始engine.signatures里面没有数据所以signatureStart进程会阻塞在那里,等有事件了再进行处理。signatureStart会调用traceerego.go 中的OnEvent()事件进行处理,即打印事件出来。同时,consumeSources函数中会检查当没有输入事件后,会等所有的OnEvent()处理完毕返回(一般走不到这里)。
注意事项
tracee 事件采集要求的操作系统如下,更加上面的描述centos7应该不支持tracee-ebpf
https://github.com/aquasecurity/tracee/issues/971
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
· SQL Server 2025 AI相关能力初探