

vArmor 功能实现研究
source link: https://paper.seebug.org/3035/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

vArmor 功能实现研究
2023年09月15日2023年09月15日经验心得 · 二进制安全
作者:Spoock
本文为作者投稿,Seebug Paper 期待你的分享,凡经采用即有礼品相送! 投稿邮箱:[email protected]
之前字节开源了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_",
对应.
前面定义了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/
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK