作者: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 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/3035/