字节vArmor客户端代码解读

说明

之前分析了 vArmor-ebpf中的部分涉及思路,具体文章参考字节vArmor代码解读

本文主要是针对vArmor的客户端代码进行分析,对应的代码仓库是 vArmor

本文主要是分别从behaviorbpfenforcer以及规则实现进行简要分析。

bpfenforcer

bpfenforcer主要是加载内核中的bpfenforcer eBPF相关代码的.具体代码位于 enforcer.go

由于整个项目比较庞大,代码也比较多,所以这里只是简要分析一下其中加载eBPF代码的逻辑.加载eBPF的代码基本上都是在initBPF()中实现.

loadBpf

loadBpf函数用于解析eBPF代码并将其解析为CollectionSpec

1
2
3
4
5
6
7
8
9
10
// loadBpf returns the embedded CollectionSpec for bpf.
func loadBpf() (*ebpf.CollectionSpec, error) {
reader := bytes.NewReader(_BpfBytes)
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
if err != nil {
return nil, fmt.Errorf("can't load bpf: %w", err)
}

return spec, err
}

AttachLSM

1
2
3
4
5
6
7
8
enforcer.log.Info("attach VarmorSocketConnect to the LSM hook point")
sockConnLink, err := link.AttachLSM(link.LSMOptions{
Program: enforcer.objs.VarmorSocketConnect,
})
if err != nil {
return err
}
enforcer.sockConnLink = sockConnLink

这段代码就是将VarmorSocketConnect的程序附加到LSM钩子点,并将相关的链接保存在enforcer对象的sockConnLink字段中.其中enforcer.objs.VarmorSocketConnect就是定义的ebpf:"varmor_socket_connect"

当执行AttachLSM()方法,也就是将eBPF程序加载到了内核中.

1
2
3
4
5
6
7
8
9
10
11
type bpfPrograms struct {
VarmorBprmCheckSecurity *ebpf.Program `ebpf:"varmor_bprm_check_security"`
VarmorCapable *ebpf.Program `ebpf:"varmor_capable"`
VarmorFileOpen *ebpf.Program `ebpf:"varmor_file_open"`
VarmorPathLink *ebpf.Program `ebpf:"varmor_path_link"`
VarmorPathLinkTail *ebpf.Program `ebpf:"varmor_path_link_tail"`
VarmorPathRename *ebpf.Program `ebpf:"varmor_path_rename"`
VarmorPathRenameTail *ebpf.Program `ebpf:"varmor_path_rename_tail"`
VarmorPathSymlink *ebpf.Program `ebpf:"varmor_path_symlink"`
VarmorSocketConnect *ebpf.Program `ebpf:"varmor_socket_connect"`
}

上面的代码就是通过github.com/cilium/ebpf加载eBPF程序的一个基本流程. 更多使用ebpf的例子也可以参考 examples.

netInnerMap

1
2
3
4
5
6
7
8
9
// Create a mock inner map for the network rules
netInnerMap := ebpf.MapSpec{
Name: "v_net_inner_",
Type: ebpf.Hash,
KeySize: 4,
ValueSize: 4*2 + 16*2,
MaxEntries: uint32(varmortypes.MaxBpfNetworkRuleCount),
}
collectionSpec.Maps["v_net_outer"].InnerMap = &netInnerMap

这个就是定义和netInnerMap相关的代码,这个netInnerMap是用于保存规则的,具体规则的定义在后面会分析。

tracer

接下来介绍有关tracer客户端相关的代码,对应于内核态中的bpftracer

initBPF

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
spec, err := loadBpf()
if err != nil {
return err
}

return spec.LoadAndAssign(obj, opts)
}


func (tracer *Tracer) initBPF() error {
......
// Load pre-compiled programs and maps into the kernel.
tracer.log.Info("load bpf program and maps into the kernel")
if err := loadBpfObjects(&tracer.objs, nil); err != nil {
return fmt.Errorf("loadBpfObjects() failed: %v", err)
}
......
}

initBPF()函数中,关键的就是调用loadBpfObjects()函数,将eBPF程序加载到内核中。这个代码逻辑和bpfenforcer中的loadBpf()函数基本一致。

attachBpfToTracepoint

因为在加载eBPF时需要具体指定对应的时间类型和eBPF相关的代码段,所以这里需要先定义一个attachBpfToTracepoint函数,用于将eBPF代码段和对应的事件类型进行绑定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func (tracer *Tracer) attachBpfToTracepoint() error {
execLink, err := link.AttachRawTracepoint(link.RawTracepointOptions{
Name: "sched_process_exec",
Program: tracer.objs.TracepointSchedSchedProcessExec,
})
if err != nil {
return err
}
tracer.execLink = execLink

forkLink, err := link.AttachRawTracepoint(link.RawTracepointOptions{
Name: "sched_process_fork",
Program: tracer.objs.TracepointSchedSchedProcessFork,
})
if err != nil {
return err
}
tracer.forkLink = forkLink

return nil
}

在代码中的tracer.objs变量就是前面通过initBPF()函数加载到内核中的eBPF代码段。在attachBpfToTracepoint()中通过如下类似代码:

1
2
3
4
5
6
7
8
execLink, err := link.AttachRawTracepoint(link.RawTracepointOptions{
Name: "sched_process_exec",
Program: tracer.objs.TracepointSchedSchedProcessExec,
})
if err != nil {
return err
}
tracer.execLink = execLink

将内核代码和用户代码相互关联,这样就完成了eBPF代码的加载。

EventsReader

在加载了eBPF相关程序之后,接下来就是读取eBPF程序中的事件。这个过程是通过EventsReader函数实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

type bpfEvent struct {
Type uint32
ParentPid uint32
ParentTgid uint32
ChildPid uint32
ChildTgid uint32
ParentTask [16]uint8
ChildTask [16]uint8
Filename [64]uint8
Env [256]uint8
Num uint32
}

func (tracer *Tracer) createBpfEventsReader() error {
reader, err := perf.NewReader(tracer.objs.Events, 8192*128)
if err != nil {
return err
}
tracer.reader = reader
return nil
}

func (tracer *Tracer) handleTraceEvents() {
var event bpfEvent
for {
record, err := tracer.reader.Read()
........
// Parse the perf event entry into a bpfEvent structure.
if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.LittleEndian, &event); err != nil {
tracer.log.Error(err, "parsing perf event failed")
continue
}

for _, eventCh := range tracer.bpfEventChs {
eventCh <- event
}
}
}

根据以上两个函数的定义和实现,基本上也可以知道这两个函数的作用。

createBpfEventsReader 用于创建一个events reader对象,这个对象就是关联了perf eventshandleTraceEvents通过tracer.reader.Read()实时获取perf events中的数据,然后通过binary.Read将数据解析为bpfEvent结构体,最后将解析后的数据通过eventCh传递给其他的goroutine

通过以上的分析,对于整个eBPF的加载逻辑和事件读取逻辑应该就比较清晰了。

规则更新

内核代码

首先,分析在内核态如何获取以及使用规则。还是以varmor_socket_connect例子为例。具体代码例子位于 enforcer.c#L249

其中有关规则的代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct {
__uint(type, BPF_MAP_TYPE_HASH_OF_MAPS);
__uint(max_entries, OUTER_MAP_ENTRIES_MAX);
__type(key, u32);
__type(value, u32);
} v_net_outer SEC(".maps");

static u32 *get_net_inner_map(u32 mnt_ns) {
return bpf_map_lookup_elem(&v_net_outer, &mnt_ns);
}

SEC("lsm/socket_connect")
int BPF_PROG(varmor_socket_connect, struct socket *sock, struct sockaddr *address, int addrlen) {
.....
u32 mnt_ns = get_task_mnt_ns_id(current);
u32 *vnet_inner = get_net_inner_map(mnt_ns);
....
}

v_net_outer是一个BPF_MAP_TYPE_HASH_OF_MAPS类型的map,用于保存规则信息。
get_net_inner_map(mnt_ns)通过namespace信息得到对应得规则信息。
综合这两个部分的代码,可以知道v_net_outer就是将namespace作为key,对应的规则信息作为value保存在map中。

接下来,查看规则匹配的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct net_rule {
u32 flags;
unsigned char address[16];
unsigned char mask[16];
u32 port;
};

static struct net_rule *get_net_rule(u32 *vnet_inner, u32 rule_id) {
return bpf_map_lookup_elem(vnet_inner, &rule_id);
}

#define NET_INNER_MAP_ENTRIES_MAX 50
for(inner_id=0; inner_id<NET_INNER_MAP_ENTRIES_MAX; inner_id++) {
// The key of the inner map must start from 0
struct net_rule *rule = get_net_rule(vnet_inner, inner_id);
if (rule == NULL) {
DEBUG_PRINT("");
DEBUG_PRINT("access allowed");
return 0;
}
}

通过get_net_rule(vnet_inner, inner_id),得到对应的规则信息,然后进行匹配。规则信息的格式是:

1
2
3
4
5
6
struct net_rule {
u32 flags;
unsigned char address[16];
unsigned char mask[16];
u32 port;
};

因为后面的匹配逻辑比较简单,所以这里就不再分析了。

用户态代码

既然知道了在内核中是如何是用规则的,那么接下来就是看如何在用户端设置规则。

v_net_outer

既然知道规则是通过v_net_outer这种map类型传输的,同样看bpfenforcer中有关v_net_outer相关的代码.

代码文件:pkg/lsm/bpfenforcer/enforcer.go

1
2
3
4
5
6
7
8
netInnerMap := ebpf.MapSpec{
Name: "v_net_inner_",
Type: ebpf.Hash,
KeySize: 4,
ValueSize: 4*2 + 16*2,
MaxEntries: uint32(varmortypes.MaxBpfNetworkRuleCount),
}
collectionSpec.Maps["v_net_outer"].InnerMap = &netInnerMap

在这段代码中,定义了v_net_outer,这种类型就和内核代码中的如下定义相对应.

1
2
3
4
5
6
struct {
__uint(type, BPF_MAP_TYPE_HASH_OF_MAPS);
__uint(max_entries, OUTER_MAP_ENTRIES_MAX);
__type(key, u32);
__type(value, u32);
} v_net_outer SEC(".maps");

v_net_inner

有关规则的定义,则是在文件pkg/lsm/bpfenforcer/profile.go中定义.

1
2
3
4
5
6
7
8
9
10
11
12
13
mapName := fmt.Sprintf("v_net_inner_%d", nsID)
innerMapSpec := ebpf.MapSpec{
Name: mapName,
Type: ebpf.Hash,
KeySize: 4,
ValueSize: 4*2 + 16*2,
MaxEntries: uint32(varmortypes.MaxBpfNetworkRuleCount),
}
innerMap, err := ebpf.NewMap(&innerMapSpec)
if err != nil {
return err
}
defer innerMap.Close()

和前面代码中的Name: "v_net_inner_",对应.

rule

前面定义了mapName := fmt.Sprintf("v_net_inner_%d", nsID),接下来就是定义规则,并将规则放入到v_net_inner_%d

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
for i, network := range bpfContent.Networks {
var rule bpfNetworkRule

rule.Flags = network.Flags
rule.Port = network.Port
ip := net.ParseIP(network.Address)
if ip.To4() != nil {
copy(rule.Address[:], ip.To4())
} else {
copy(rule.Address[:], ip.To16())
}

if network.CIDR != "" {
_, ipNet, err := net.ParseCIDR(network.CIDR)
if err != nil {
return err
}
copy(rule.Mask[:], ipNet.Mask)
}

var index uint32 = uint32(i)
err = innerMap.Put(&index, &rule)
if err != nil {
return err
}
}

这段代码主要逻辑就是解释规则,然后将规则放入到v_net_inner_%d中.其中最关键的两行代码是:

1
2
var index uint32 = uint32(i)
err = innerMap.Put(&index, &rule)

和内核态中的struct net_rule *rule = get_net_rule(vnet_inner, inner_id);对应.

内核态中的net_rule定义是:

1
2
3
4
5
6
struct net_rule {
u32 flags;
unsigned char address[16];
unsigned char mask[16];
u32 port;
};

用户态中的bpfNetworkRule定义是:

1
2
3
4
5
6
type bpfNetworkRule struct {
Flags uint32
Address [16]byte
Mask [16]byte
Port uint32
}

两者的数据结构也是完全一致的.

V_netOuter

最后关键的代码是:

1
2
3
4
err = enforcer.objs.V_netOuter.Put(&nsID, innerMap)
if err != nil {
return err
}

v_net_inner_%d放入到v_net_outer中,这样就完成了规则的设置.其中nsID作为v_net_outer的key,v_net_inner_%d作为v_net_outer的value.

这个代码和内核中的u32 *vnet_inner = get_net_inner_map(mnt_ns)也是对应的.

总结

整体来说,VArmor整体代码逻辑十分清晰,对于想了解和学习eBPF开发相关的人来说,是一个很好的学习资料。同时由于VArmor的代码量比较大,本文也仅仅只是分析了其中的eBPF的加载机制部分。整个代码还有更多的设计和考虑,可以参考对应的PPT,从0到1打造云原生容器沙箱vArmor

后续有机会,也会对vArmor的其他部分进行分析。

参考

https://github.com/bytedance/vArmor
从0到1打造云原生容器沙箱vArmor