3

vArmor 功能实现研究

 7 months ago
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中存在两个主要目录,分别是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_",对应.

前面定义了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的其他部分进行分析。


Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/3035/


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK