DEV Community

Cover image for Simple XDP Firewall with Golang
Hasan Behbahani
Hasan Behbahani

Posted on • Updated on

Simple XDP Firewall with Golang

For almost 20+ years, the traditional security features of a Linux system were centered around iptables, which has been the de facto packet filtering mechanism in the Linux kernel.
However, the increase in network speed and the transformation of the type of applications running in a Linux server has led to the consciousness that the current implementation may not be able to cope with the modern requirements, particularly in terms of scalability.
That's where XDP & eBPF come in.

So what is XDP?

XDP is a part of the upstream Linux kernel, and enables users to inject packet processing programs into the kernel, that will be executed for each arriving packet, before the kernel does any other processing on the data.

Writing the Code

So, enough nerd-talk about the technology, how do we use it?

To demonstrate, we'll be writing a simple firewall that'll drop incoming packets based on their IP address using XDP.
The program is going to consist of 2 parts:

  • The Kernel Space Code
  • The User Space Code

The Kernel side is written in C or Rust (We'll be using C), and is compiled into the eBPF byte code format that is verified and JIT-compiled in the kernel.
I'll be writing the XDP application with a User-Space controller written in Go.
I should mention that writing complex eBPF programs requires much more context than what we'll be doing today. There is a lot to know about eBPF and XDP. We will barely scratch the surface.
All the code for this project can be found in here.

First, let's discuss the kernel side of things.

Kernel Space Code

As discussed earlier, we are a bit restricted in the language we use in here. Possible languages that can be used are Rust and C.
We'll be using C because there's a C program out there for almost every standard use-case scenario for us to grab and use, except if what we're trying to do is very specific to our usecase. (Cloudflare's L4 DDos Mitigation platform showcases this very well)
Our kernel-space program is gonna consist of one function called firewall and one map called blacklist.
We already know what a function is, but what is a map and why do we need it?

Maps are a “generic data structure for storage of different types of data” and can be used to share data between eBPF programs as well as between kernel and userspace. The key and value of a map can be of arbitrary size as defined when creating the map. The user also defines the maximum number of entries with max_entries.

The blacklist is gonna store all IP addresses that need to be dropped, and the firewall function will check each packet and see if the source IP of that packet matches any of the IP addresses stored in the blacklist map.
We'll be using the following XDP actions:

  • XDP_PASS if the IP address matches none of the IP addreses in the blacklist map.
  • XDP_DROP if it does match an IP address in the map.
  • XDP_ABORTED if the ipv4 header or ethernet header of the packet is malformed.

First, let's take a look at the defintion of the blacklist map:

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);
Enter fullscreen mode Exit fullscreen mode

The defintion is quite simple.

we'll first define the type of the map.
Here's the list of types of maps, found in the bpf_helpers.h header file:

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,
};
Enter fullscreen mode Exit fullscreen mode

Then we'll define the .key_size, .value_size and .max_entries.

Now, take a look at the firewall function.
Before we define the function, we need to define 2 structs:

  • ethhdr Ethernet header
  • iphdr IPv4 header
// 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));
Enter fullscreen mode Exit fullscreen mode

We're going to need to define these structs so we analyze the received packets. All of this logic happens in the firewall function.
Let's take a look at the defintion of the function:

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;
}
Enter fullscreen mode Exit fullscreen mode

Do not be intimidated!
C code can be intimidating, but taking a closer look at the code we can see what the function is trying to accomplish.

I should mention that this XDP program only supports ipv4 packets, so any other type of traffic (ipv6 included ) will be passed with XDP_PASS.

We're almost finished with the Kernel-Space side.
We just need to compile our code eBPF byte code.
But before we do that, we need to include the bpf_helpers.h header file at the start of C file:

#include "bpf_helpers.h"
Enter fullscreen mode Exit fullscreen mode

This is not the same bpf_helpers.h header file in the bcc github repo. You can find the header file in here.
Either download it and place it in your project, or clone the repo and compile.
We'll be using Clang to compile our code to eBPF byte code.
Install it, and run the following command:

$ clang -I ../headers -O -target bpf -c xdp.c -o xdp.elf
Enter fullscreen mode Exit fullscreen mode

the -I points to the directory where we downloaded the bpf_helpers.h file from earlier, so mind that. xdp.c is the file name of our C source code, and the output will be in the ELF format, that's what our User Space code will load into the kernel.
We can use the readelf command to verify the file:

$ 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
Enter fullscreen mode Exit fullscreen mode

If you're having issues compiling, check out the source code and check if there's anything missing, or comment down below.

Let's place these 2 files inside a directory called bpf and move on to the exciting part of the tutorial!

User Space Code

Now comes the exciting (and less frusterating) part of our program!! Writing Go code.

Before we dive in, let's first take a look at all the eBPF/Go libraries provided for us by the lovely open-source community around the world.

Here's a list of the most popular Go eBPF libraires:

  • ebpf-go by Cilium which is a pure-Go library to read, modify and load eBPF programs and attach them to various hooks in the Linux kernel.
  • gobpf by iovisor which provides go bindings for the bcc framework as well as low-level routines to load and use eBPF programs from .elf files.
  • libbpfgo by aquasecurity which is built around libbpf - the standard library for interacting with eBPF programs
  • goebpf by Dropbox - A nice and convenient way to work with eBPF programs / perf events from Go.

I've decided to use goebpf, the one from Dropbox in this tutorial, mainly because of it's simplicity and it's easy beginner-friendly approach to eBPF.

Let's install the goebpf library by Dropbox:

go get github.com/dropbox/goebpf
Enter fullscreen mode Exit fullscreen mode

Now that we have that out of the way, create a file called main.go and let's start coding!

Note: make sure the main.go file and the rest of C and ELF files are not in the same directory as that can cause errors and warnings, place the main.go file next to the bpf directory we made earlier.
So the directory hierarchy will look something similar to this:

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

Let's first define two variables, the interface we'll attach our XDP program to and a slice containing the IP addresses and their subnet.

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",
    }

...
Enter fullscreen mode Exit fullscreen mode

For my example, I'm going to attach my XDP program to my system's loopback device, and I'm gonna drop any packet coming from the source IP address 8.8.8.8.
Now let's load our XDP program:

// 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)
    }

Enter fullscreen mode Exit fullscreen mode

The code is pretty self explanatory.
We load the eBPF ELF file from the bpf directory. We then load and assign the blacklist map & the firewall function to the variables blacklist & firewall respectivly, and finally we attach the program to the interface, in this case the loopback interface (lo).

We just need to create a function that takes the IP addresses in ipList and adds them to the blacklist map. The rest is handled in the C code we wrote earlier.
Here's the function:

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
}
Enter fullscreen mode Exit fullscreen mode

Pretty simple isn't it.
Now we can use this function after attaching our program to the interface:

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

    BlockIPAddress(ipList, blacklist)

    defer xdp.Detach()
Enter fullscreen mode Exit fullscreen mode

We're pretty much done. The only thing missing is that if we build and run our program it'll exit immediately, and with it the XDP program will be detached( see defer xdp.Detach ).
We don't want that, we want the program running until we interrupt it. We'll use channels for that.
Here's the final Go code:

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
}
Enter fullscreen mode Exit fullscreen mode

Build and run the Go code with sudo:

$ 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.
Enter fullscreen mode Exit fullscreen mode

Now while the program is running, open another terminal and run the following command:

$ 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
Enter fullscreen mode Exit fullscreen mode

We can see the XDP program with an id of 173 is now attached.
If we go back to the last terminal and exit the program, and run the command again:

$ 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
Enter fullscreen mode Exit fullscreen mode

The XDP program has disappeared from the lo interface.

And this is pretty much it!
If you'd like to read more on this topic, I'll add a few links below for you to check out.

Oldest comments (0)