作者: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中存在兩個主要目錄,分別是behaviorbpfenforcer

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獲得子父進程的pidtgidcomm等信息,然后通過bpf_perf_event_output將這些信息傳遞給用戶態。

整體來說,就是一個觀察模式,不會對容器的行為進行任何阻斷,只是收集進程創建信息。

bpfenforcer

enforcer入口文件是enforcer.c,在這個文件中定義了多個lsm事件。包括:

  • capable
  • file_open
  • path_symlink
  • path_link
  • path_rename
  • bprm_check_security
  • socket_connect

具體的函數邏輯是封裝在capability.hfile.hprocess.hnetwork.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,只關注ipv4ipv6的連接。

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_innervnet_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代碼邏輯是很清晰的,通過eBPFLSM機制,實現了對容器的加固。通過behaviorbpfenforcer兩種模式,可以實現觀察模式和阻斷模式。

vArmor用戶態實現

將分別從behaviorbpfenforcer以及規則實現進行簡要分析。

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 eventshandleTraceEvents通過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的其他部分進行分析。

參考

  1. https://mp.weixin.qq.com/s/5rmkALNMhA1cVsk5A14wbA

  2. https://github.com/bytedance/vArmor


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/3035/