作者:Spoock
本文为作者投稿,Seebug Paper 期待你的分享,凡经采用即有礼品相送! 投稿邮箱:paper@seebug.org
说明
之前字节开源了vArmor
(本项目已加入404星链计划,项目地址:https://github.com/bytedance/vArmor ),刚好最近在研究eBPF
,所以就顺便看了一下vArmor
的实现,发现vArmor
的实现也是基于eBPF
的,所以就顺便记录一下。
vArmor 通过以下技术实现云原生容器沙箱
- 借助 Linux 的 AppArmor 或 BPF LSM,在内核中对容器进程进行强制访问控制(文件、程序、网络外联等)
- 为减少性能损失和增加易用性,vArmor 的安全模型为 Allow by Default,即只有显式声明的行为会被阻断
- 用户通过操作 CRD 实现对指定 Workload 中的容器进行沙箱加固
- 用户可以通过选择和配置沙箱策略(预置策略、自定义策略)来对容器进行强制访问控制。预置策略包含一些常见的提权阻断、渗透入侵防御策略。
vArmor 内核态的实现
本文主要是关注vArmor
如何借用eBPF
中的LSM
技术实现对容器加固的。vArmor
的内核代码是在一个单独仓库 vArmor-ebpf
在vArmor-ebpf
中存在两个主要目录,分别是behavior
和bpfenforcer
。
behavior
就是观察模式,不会对容器的行为进行任何阻断。
bpfenforcer
,按照官方的说法,就是强制访问控制器。通过对某些行为进行阻断达到加固的目的。
behavior
behavior
中的核心入口文件是tracer.c
。在这个文件中定义了两个raw_tracepoint
事件。
raw_tracepoint/sched_process_fork
raw_tracepoint/sched_process_exec
以其中的sched_process_exec
代码为例分析:
// https://elixir.bootlin.com/linux/v5.4.196/source/fs/exec.c#L1722
SEC("raw_tracepoint/sched_process_exec")
int tracepoint__sched__sched_process_exec(struct bpf_raw_tracepoint_args *ctx)
{
// TP_PROTO(struct task_struct *p, pid_t old_pid, struct linux_binprm *bprm)
struct task_struct *current = (struct task_struct *)ctx->args[0];
struct linux_binprm *bprm = (struct linux_binprm *)ctx->args[2];
struct task_struct *parent = BPF_CORE_READ(current, parent);
struct event event = {};
event.type = 2;
BPF_CORE_READ_INTO(&event.parent_pid, parent, pid);
BPF_CORE_READ_INTO(&event.parent_tgid, parent, tgid);
BPF_CORE_READ_STR_INTO(&event.parent_task, parent, comm);
BPF_CORE_READ_INTO(&event.child_pid, current, pid);
BPF_CORE_READ_INTO(&event.child_tgid, current, tgid);
BPF_CORE_READ_STR_INTO(&event.child_task, current, comm);
bpf_probe_read_kernel_str(&event.filename, sizeof(event.filename), BPF_CORE_READ(bprm, filename));
u64 env_start = 0;
u64 env_end = 0;
int i = 0;
int len = 0;
BPF_CORE_READ_INTO(&env_start, current, mm, env_start);
BPF_CORE_READ_INTO(&env_end, current, mm, env_end);
while(i < MAX_ENV_EXTRACT_LOOP_COUNT && env_start < env_end ) {
len = bpf_probe_read_user_str(&event.env, sizeof(event.env), (void *)env_start);
if ( len <= 0 ) {
break;
} else if ( event.env[0] == 'V' &&
event.env[1] == 'A' &&
event.env[2] == 'R' &&
event.env[3] == 'M' &&
event.env[4] == 'O' &&
event.env[5] == 'R' &&
event.env[6] == '=' ) {
break;
} else {
env_start = env_start + len;
event.env[0] = 0;
i++;
}
}
event.num = i;
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event));
return 0;
}
通过注释,可以看到主要是基于内核5.4.196
版本开发的。
有关rawtracepoint
的原理和机制,可以参考之前写的文章rawtracepoint机制介绍.
当一个进程执行新的可执行文件(例如通过 execve 系统调用)时,内核会发出 sched_process_exec
跟踪事件,以便跟踪和记录进程执行的相关信息。这个跟踪事件提供了以下信息:
- common_type:跟踪事件的类型标识符。
- common_flags:跟踪事件的标志位。
- common_preempt_count:跟踪事件发生时的抢占计数。
- common_pid:触发事件的进程 ID。
- filename:新可执行文件的文件名。
tracepoint__sched__sched_process_exec
整体的逻辑也比较简单,通过task_struct
获得子父进程的pid
、tgid
、comm
等信息,然后通过bpf_perf_event_output
将这些信息传递给用户态。
整体来说,就是一个观察模式,不会对容器的行为进行任何阻断,只是收集进程创建信息。
bpfenforcer
enforcer
入口文件是enforcer.c
,在这个文件中定义了多个lsm
事件。包括:
capable
file_open
path_symlink
path_link
path_rename
bprm_check_security
socket_connect
具体的函数逻辑是封装在capability.h
、file.h
、process.h
、network.h
中。
具体以lsm/socket_connect
为例,分析:
SEC("lsm/socket_connect")
int BPF_PROG(varmor_socket_connect, struct socket *sock, struct sockaddr *address, int addrlen) {
// Only care about ipv4 and ipv6 for now
if (address->sa_family != AF_INET && address->sa_family != AF_INET6)
return 0;
// Retrieve the current task
struct task_struct *current = (struct task_struct *)bpf_get_current_task();
// Whether the current task has network access control rules
u32 mnt_ns = get_task_mnt_ns_id(current);
u32 *vnet_inner = get_net_inner_map(mnt_ns);
if (vnet_inner == NULL)
return 0;
DEBUG_PRINT("================ lsm/socket_connect ================");
DEBUG_PRINT("socket status: 0x%x", sock->state);
DEBUG_PRINT("socket type: 0x%x", sock->type);
DEBUG_PRINT("socket flags: 0x%x", sock->flags);
// Iterate all rules in the inner map
return iterate_net_inner_map(vnet_inner, address);
}
通过address->sa_family != AF_INET && address->sa_family != AF_INET6
,只关注ipv4
和ipv6
的连接。
u32 mnt_ns = get_task_mnt_ns_id(current);
u32 *vnet_inner = get_net_inner_map(mnt_ns);
if (vnet_inner == NULL)
return 0;
获得当前进程的mnt_ns
,然后通过mnt_ns
获得vnet_inner
,vnet_inner
是一个bpf map
,存储了当前进程的网络访问控制规则。
整个代码的核心关键是iterate_net_inner_map(vnet_inner, address)
,iterate_net_inner_map
的实现是在network.h
中。
由于整个函数体较长,逐步分析。
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;
}
....
}
通过for
循环,配合get_net_rule(vnet_inner, inner_id)
获得vnet_inner
中的每一条规则。
针对每条规则,匹配address
是否符合规则,检查条件包括IP和端口信息:
// Check if the address matches the rule
if (rule->flags & CIDR_MATCH) {
for (i = 0; i < 4; i++) {
ip = (addr4->sin_addr.s_addr >> (8 * i)) & 0xff;
if ((ip & rule->mask[i]) != rule->address[i]) {
match = false;
break;
}
}
}
// Check if the port matches the rule
if (match && (rule->flags & PORT_MATCH) && (rule->port != bpf_ntohs(addr4->sin_port))) {
match = false;
}
执行动作,如果发现匹配的规则,执行规则中定义的动作:
if (match) {
DEBUG_PRINT("");
DEBUG_PRINT("access denied");
return -EPERM;
}
通过返回 -EPERM
,LSM 程序可以告知内核或调用者,当前的操作被拒绝,并且可能会触发相应的权限拒绝处理逻辑。至此整个处理流程结束。
其他类型的lsm
事件,处理逻辑也是类似的,只是针对的对象不同。
整体来说,vArmor-ebpf
代码逻辑是很清晰的,通过eBPF
的LSM
机制,实现了对容器的加固。通过behavior
和bpfenforcer
两种模式,可以实现观察模式和阻断模式。
vArmor用户态实现
将分别从behavior
,bpfenforcer
以及规则实现进行简要分析。
bpfenforcer
bpfenforcer
主要是加载内核中的bpfenforcer eBPF
相关代码的。具体代码位于 enforcer.go
由于整个项目比较庞大,代码也比较多,所以这里只是简要分析一下其中加载eBPF
代码的逻辑.加载eBPF
的代码基本上都是在initBPF()
中实现.
loadBpf
loadBpf
函数用于解析eBPF
代码并将其解析为CollectionSpec
// 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
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
程序加载到了内核中.
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
// 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
// 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
代码段和对应的事件类型进行绑定。
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()
中通过如下类似代码:
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
函数实现的。
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 events
。handleTraceEvents
通过tracer.reader.Read()
实时获取perf events
中的数据,然后通过binary.Read
将数据解析为bpfEvent
结构体,最后将解析后的数据通过eventCh
传递给其他的goroutine
。
通过以上的分析,对于整个eBPF
的加载逻辑和事件读取逻辑应该就比较清晰了。
规则更新
内核代码
首先,分析在内核态如何获取以及使用规则。还是以varmor_socket_connect
例子为例。具体代码例子位于 enforcer.c#L249
其中有关规则的代码是:
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
中。
接下来,查看规则匹配的逻辑:
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)
,得到对应的规则信息,然后进行匹配。规则信息的格式是:
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
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
,这种类型就和内核代码中的如下定义相对应.
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
中定义.
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
中
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
中.其中最关键的两行代码是:
var index uint32 = uint32(i)
err = innerMap.Put(&index, &rule)
和内核态中的struct net_rule *rule = get_net_rule(vnet_inner, inner_id);
对应.
内核态中的net_rule
定义是:
struct net_rule {
u32 flags;
unsigned char address[16];
unsigned char mask[16];
u32 port;
};
用户态中的bpfNetworkRule
定义是:
type bpfNetworkRule struct {
Flags uint32
Address [16]byte
Mask [16]byte
Port uint32
}
两者的数据结构也是完全一致的.
V_netOuter
最后关键的代码是:
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。
后续有机会,也会对vArmor
的其他部分进行分析。
参考
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/3035/
暂无评论