简单的XDP防火墙与Golang
#go #xdp #ebpf #firewall

近20年以上,Linux系统的传统安全功能一直围绕iPtables,这是Linux内核中的事实上的数据包过滤机制。
但是,网络速度的提高和Linux服务器中运行的应用程序类型的转换导致了意识,即当前实施可能无法应对现代需求,尤其是在可扩展性方面。

那是XDP和EBPF进来的地方。

那么什么是xdp?

XDP是上游Linux内核的一部分,并使用户能够将数据包处理程序注入内核,在内核对数据上进行其他任何处理之前,将对每个到达数据包执行。
XDP的早期采用者之一是Cloudflare。由于其内容交付网络收到和计算的大量请求,使用iptables为其用例(例如DDos Mitigation platformthe Layer-4 Load balancer)使用iptables并不像他们想要的那样高效。 XDP似乎可以解决它们的iptables问题,因为随着服务的数量的增长,测量结果显示出不可预测的延迟和降低的性能。

编写代码

因此,关于技术的足够书呆子讲话,我们如何使用它?

要演示,我们将编写一个简单的防火墙,该防火墙会使用XDP根据其IP地址删除传入数据包。
该程序将由2个部分组成:

  • 内核空间代码
  • 用户空间代码

内核端是用C或Rust编写的(我们将使用C),并将其编译到EBPF字节代码格式中,该格式经过验证并在内核中进行了jit编译。
我将用GO编写的用户空间控制器编写XDP应用程序。
我应该提到的是,编写复杂的EBPF程序比今天要做的要多得多。关于EBPF和XDP有很多了解。我们几乎不会刮擦表面。
该项目的所有代码都可以在here中找到。

首先,让我们讨论事物的内核方面。

内核空间代码

如前所述,我们在这里使用的语言受到了一些限制。可以使用的可能的语言是生锈和C。
我们会使用C,因为几乎每个标准用例场景都有一个C程序供我们捕获和使用,除非我们试图做的是非常特定于我们的用户酶。 (Cloudflare的L4 DDos Mitigation platform很好地展示了这一点)
我们的内核空间程序将由一个称为firewall的功能和一张名为blacklist的函数。
我们已经知道什么是功能,但是什么是地图,为什么我们需要它?

地图是用于存储不同类型数据的通用数据结构,可用于在EBPF程序以及内核和用户空间之间共享数据。地图的密钥和值可以是任意大小,如创建地图时所定义的。用户还定义了使用max_entries的最大条目数。

blacklist将存储所有需要删除的IP地址,firewall函数将检查每个数据包,并查看该数据包的源IP是否匹配blacklist Map中存储的任何IP地址。


我们将使用以下XDP操作:

  • XDP_PASS如果IP地址匹配blacklist地图中的IP addresers。
  • XDP_DROP如果它确实与地图中的IP地址匹配。
  • XDP_ABORTED如果数据包的ipv4 headerethernet header被畸形。

首先,让我们看一下blacklist地图的定义:

BPF_MAP_DEF(blacklist) = {
    .map_type = BPF_MAP_TYPE_LPM_TRIE,
    .key_size = sizeof(__u64),
    .value_size = sizeof(__u32),
    .max_entries = 16,
};
BPF_MAP_ADD(blacklist);

定义很简单。

我们将首先定义地图的类型。
这是bpf_helpers.h标头文件中的地图类型列表:

enum bpf_map_type {
  BPF_MAP_TYPE_UNSPEC = 0,
  BPF_MAP_TYPE_HASH,
  BPF_MAP_TYPE_ARRAY,
  BPF_MAP_TYPE_PROG_ARRAY,
  BPF_MAP_TYPE_PERF_EVENT_ARRAY,
  BPF_MAP_TYPE_PERCPU_HASH,
  BPF_MAP_TYPE_PERCPU_ARRAY,
  BPF_MAP_TYPE_STACK_TRACE,
  BPF_MAP_TYPE_CGROUP_ARRAY,
  BPF_MAP_TYPE_LRU_HASH,
  BPF_MAP_TYPE_LRU_PERCPU_HASH,
  BPF_MAP_TYPE_LPM_TRIE,
  BPF_MAP_TYPE_ARRAY_OF_MAPS,
  BPF_MAP_TYPE_HASH_OF_MAPS,
  BPF_MAP_TYPE_DEVMAP,
  BPF_MAP_TYPE_SOCKMAP,
  BPF_MAP_TYPE_CPUMAP,
  BPF_MAP_TYPE_XSKMAP,
  BPF_MAP_TYPE_SOCKHASH,
  BPF_MAP_TYPE_CGROUP_STORAGE,
  BPF_MAP_TYPE_REUSEPORT_SOCKARRAY,
  BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE,
  BPF_MAP_TYPE_QUEUE,
  BPF_MAP_TYPE_STACK,
  BPF_MAP_TYPE_SK_STORAGE,
};

然后,我们将定义.key_size.value_size.max_entries

现在,看看firewall函数。
在定义函数之前,我们需要定义2个结构:

  • ethhdr以太网标头
  • iphdr IPv4标头
// Ethernet header
struct ethhdr {
  __u8 h_dest[6];
  __u8 h_source[6];
  __u16 h_proto;
} __attribute__((packed));

// IPv4 header
struct iphdr {
  __u8 ihl : 4;
  __u8 version : 4;
  __u8 tos;
  __u16 tot_len;
  __u16 id;
  __u16 frag_off;
  __u8 ttl;
  __u8 protocol;
  __u16 check;
  __u32 saddr;
  __u32 daddr;
} __attribute__((packed));

我们将需要定义这些结构,因此我们分析了收到的数据包。所有这些逻辑发生在firewall函数中。
让我们看一下功能的定义:

SEC("xdp")
int firewall(struct xdp_md *ctx) {
  void *data_end = (void *)(long)ctx->data_end;
  void *data = (void *)(long)ctx->data;

  struct ethhdr *ether = data;

  // Check if the Ethernet header is malformed
  if (data + sizeof(*ether) > data_end) {
    return XDP_ABORTED;
  }

  if (ether->h_proto != 0x08U) {  // htons(ETH_P_IP) -> 0x08U
    // If not IPv4 Traffic, pass the packet
    return XDP_PASS;
  }

  data += sizeof(*ether);
  struct iphdr *ip = data;

  // Check if the IPv4 header is malformed
  if (data + sizeof(*ip) > data_end) {
    return XDP_ABORTED;
  }

  struct {
    __u32 prefixlen;
    __u32 saddr;
  } key;

  key.prefixlen = 32;
  key.saddr = ip->saddr;

  // Lookup the IP Address in the blacklsit map
  // we defined earlier.
  // If the source IP matches the IP in the blacklist map
  // We drop the packet!!
  __u64 *rule_idx = bpf_map_lookup_elem(&blacklist, &key);
  if (rule_idx) {
    __u32 index = *(__u32*)rule_idx;  // make verifier happy
    return XDP_DROP;
  }

  return XDP_PASS;
}

不要被恐吓!
C代码可能令人生畏,但是仔细查看代码,我们可以看到该功能要完成的工作。

我应该提到此XDP程序仅支持ipv4数据包,因此将使用XDP_PASS传递任何其他类型的流量(包括ipv6)。

我们几乎完成了内核空间的一面。
我们只需要编译我们的代码EBPF字节代码。
但是在这样做之前,我们需要在C文件开始时包含bpf_helpers.h标头文件:

#include "bpf_helpers.h"

这是不是 BCC GitHub存储库中相同的bpf_helpers.h标头文件。您可以在here中找到标题文件。
下载它并将其放入您的项目中,或克隆回购并编译。
我们将使用Clang将代码编译为EBPF字节代码。
安装并运行以下命令:

$ clang -I ../headers -O -target bpf -c xdp.c -o xdp.elf

-I指向我们从前下载bpf_helpers.h文件的目录,因此请注意。 xdp.c是我们C源代码的文件名,输出将以ELF格式,这就是我们的用户空间代码将加载到内核中。
我们可以使用readelf命令来验证文件:

$ readelf -a xdp.elf 
...

Symbol table '.symtab' contains 5 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS xdp.c
     2: 0000000000000130     0 NOTYPE  LOCAL  DEFAULT    3 LBB0_5
     3: 0000000000000000   312 FUNC    GLOBAL DEFAULT    3 firewall
     4: 0000000000000000    40 OBJECT  GLOBAL DEFAULT    5 blacklist

如果您有问题编译,请查看source code并检查是否缺少任何内容,或在下面发表评论。

让我们将这2个文件放入一个名为bpf的目录中,然后转到教程的令人兴奋的部分!

用户空间代码

现在是我们计划中令人兴奋的(且毫无意义的)一部分!编写GO代码。

在我们潜水之前,让我们首先看一下世界各地可爱的开源社区为我们提供的所有EBPF/GO图书馆。

这是最受欢迎的GO EBPF库的列表:

  • ebpf-go by Cilium是一个纯库,可以读取,修改和加载EBPF程序,并将其附加到Linux内核中的各种钩子上。
  • gobpf by iovisor为BCC框架提供GO绑定以及从.elf文件加载和使用EBPF程序的低级例程。
  • libbpfgo by aquasecurity围绕libbpf建造 - 与EBPF程序互动的标准库
  • Dropboxgoebpf-使用EBPF程序 / PERF活动的一种很好而便捷的方式。< / li>

我决定使用本教程中的Dropbox的GoeBPF,这主要是因为它很简单,并且很容易对EBPF进行初学者友好的方法。

让我们通过Dropbox安装GOEBPF库:

go get github.com/dropbox/goebpf

现在我们有了这个问题,创建一个名为main.go的文件,让我们开始编码!

注意:确保main.go文件和CELF文件的其余部分与可能导致错误和警告的目录不在同一目录中,请将main.go文件放在我们之前制作的bpf目录旁边的main.go文件。
因此,目录层次结构看起来与此相似:

.
├── bpf
│   ├── xdp.c
│   └── xdp.elf
├── go.mod
├── go.sum
├── headers
│   └── bpf_helpers.h
└── main.go

让我们首先定义两个变量,即我们将XDP程序附加到包含IP地址及其子网的切片。

package main

import (
    "fmt"
    "log"
    "os"
    "os/signal"

    "github.com/dropbox/goebpf"
)

func main() {

    // Specify Interface Name
    interfaceName := "lo"
    // IP BlockList
    // Add the IPs you want to be blocked
    ipList := []string{
        "8.8.8.8",
    }

...

在我的示例中,我将把我的XDP程序附加到系统的环回设备上,我将删除来自源IP地址8.8.8.8的任何数据包。
现在让我们加载XDP程序:

// Load XDP Into App
    bpf := goebpf.NewDefaultEbpfSystem()
        // According to our directory hierarchy,
        // The xdp.elf file is placed in the bpf directory 
        // next to the main.go file
    err := bpf.LoadElf("bpf/xdp.elf")
    if err != nil {
        log.Fatalf("LoadELF() failed: %s", err)
    }

        // Load blacklist map
    blacklist := bpf.GetMapByName("blacklist")
    if blacklist == nil {
        log.Fatalf("eBPF map 'blacklist' not found\n")
    }

        // Load firewall function
    xdp := bpf.GetProgramByName("firewall")
    if xdp == nil {
        log.Fatalln("Program 'firewall' not found in Program")
    }
    err = xdp.Load()
    if err != nil {
        fmt.Printf("xdp.Attach(): %v", err)
    }

        // attach the program to the interface
        // in this case, the lo interface
    err = xdp.Attach(interfaceName)
    if err != nil {
        log.Fatalf("Error attaching to Interface: %s", err)
    }

代码是非常自我解释的。
我们从bpf目录加载EBPF ELF文件。然后,我们将blacklist映射和firewall函数分配给变量blacklistfirewall,最后我们将程序附加到接口上,在这种情况下,在这种情况下为loopback接口(lo)。

我们只需要创建一个将IP地址在ipList中的函数,并将其添加到黑名单映射中。其余的是在我们之前编写的C代码中处理的。
这是功能:

func BlockIPAddress(ipAddreses []string, blacklist goebpf.Map) error {
    for index, ip := range ipAddreses {
        fmt.Printf("\t%s\n", ip)
        err := blacklist.Insert(goebpf.CreateLPMtrieKey(ip), index)
        if err != nil {
            return err
        }
    }
    return nil
}

非常简单。
现在,我们可以在将程序连接到接口之后使用此功能:

    ....
    err = xdp.Attach(interfaceName)
    if err != nil {
        log.Fatalf("Error attaching to Interface: %s", err)
    }

    BlockIPAddress(ipList, blacklist)

    defer xdp.Detach()

我们几乎完成了。唯一缺少的是,如果我们构建和运行程序,它将立即退出,并且XDP程序将被分离(请参阅defer xdp.Detach)。
我们不想要那个,我们希望程序运行,直到中断它为止。我们将使用频道为此。
这是最终的GO代码:

package main

import (
    "fmt"
    "log"
    "os"
    "os/signal"

    "github.com/dropbox/goebpf"
)

func main() {

    // Specify Interface Name
    interfaceName := "lo"
    // IP BlockList
    // Add the IPs you want to be blocked
    ipList := []string{
        "8.8.8.8",
    }

    // Load XDP Into App
    bpf := goebpf.NewDefaultEbpfSystem()
    err := bpf.LoadElf("bpf/xdp.elf")
    if err != nil {
        log.Fatalf("LoadELF() failed: %s", err)
    }
    blacklist := bpf.GetMapByName("blacklist")
    if blacklist == nil {
        log.Fatalf("eBPF map 'blacklist' not found\n")
    }
    xdp := bpf.GetProgramByName("firewall")
    if xdp == nil {
        log.Fatalln("Program 'firewall' not found in Program")
    }
    err = xdp.Load()
    if err != nil {
        fmt.Printf("xdp.Attach(): %v", err)
    }
    err = xdp.Attach(interfaceName)
    if err != nil {
        log.Fatalf("Error attaching to Interface: %s", err)
    }

    BlockIPAddress(ipList, blacklist)

    defer xdp.Detach()
    ctrlC := make(chan os.Signal, 1)
    signal.Notify(ctrlC, os.Interrupt)
    log.Println("XDP Program Loaded successfuly into the Kernel.")
    log.Println("Press CTRL+C to stop.")
    <-ctrlC

}

// The Function That adds the IPs to the blacklist map
func BlockIPAddress(ipAddreses []string, blacklist goebpf.Map) error {
    for index, ip := range ipAddreses {
        err := blacklist.Insert(goebpf.CreateLPMtrieKey(ip), index)
        if err != nil {
            return err
        }
    }
    return nil
}

使用sudo构建并运行GO代码:

$ go build
$ sudo ./xdp-firewall
2022/12/02 02:36:46 XDP Program Loaded successfuly into the Kernel.
2022/12/02 02:36:46 Press CTRL+C to stop.

现在在程序运行时,打开另一个终端并运行以下命令:

$ ip link list dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    prog/xdp id 173

我们现在可以看到具有173个ID的XDP程序。
如果我们回到最后一个终端并退出程序,然后再次运行命令:

$ ip link list dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

XDP程序已从lo接口中消失。

这几乎是它!
如果您想了解有关此主题的更多信息,我将在下面添加一些链接供您查看。