作者: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_forkraw_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事件。包括:
capablefile_openpath_symlinkpath_linkpath_renamebprm_check_securitysocket_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 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/3035/
暫無評論