Hello eBPF: Building a Lightning Fast Firewall with Java & eBPF (14)

Welcome back to my series on ebpf. In the last post, I told you how TC and XDP hooks allow us to monitor incoming and outgoing packets. This week, we’re extending this to build a firewall in Java, including a small Spring-Boot-based web frontend, with hello-ebpf:

Before I start, here is a disclaimer: The details of eBPF are hard, so I could only get the filtering of incoming packets to work reliably. Can I still call it a firewall? I would say yes, but please help me filter the outgoing packets if you disagree. Also, it’s my first Spring-Boot-based application, so please don’t judge it too harshly. Lastly, we only focus on IPv4 packets, so adding support for IPv6 rules is left to the reader.

Is it fast? Probably. I didn’t do any measurements myself, but research by Cloudflare suggests that XDP is far faster at dropping packets than the standard firewall.

Libraries

As we saw in last week’s blog post and in Hello eBPF: XDP-based Packet Filter (9), the packet parsing code is rather complicated. So we don’t want to copy it into every program. For this reason, I added a library mechanism to hello-ebpf with which we can implement a packet parsing library (based on the samples from xdp-project):

@BPFInterface
public interface BasePacketParser {

    int HTTP_PORT = 80;
    int HTTPS_PORT = 443;
    
    // ...

    @Type
    static class PacketInfo {
        public PacketDirection direction;
        public Protocol protocol;
        public IPAddress source;
        public IPAddress destination;
        public @Unsigned int destinationPort;
        public @Unsigned int sourcePort;
        public int length;
    }
    
    // ...

    @BPFFunction
    @AlwaysInline
    default boolean parsePacket2(@Unsigned int start, 
      @Unsigned int end, Ptr<PacketInfo> info) {
        return parsePacket(Ptr.voidPointer(start), Ptr.voidPointer(end), info);
    }

    /**
     * Parse a packet and extract the source and destination IP address and the protocol
     *
     * @param start start of the packet data
     * @param end   end of the packet data
     * @param info  output parameter for the extracted information
     * @return true if the packet is an IP packet and could be parsed, false otherwise
     */
    @BPFFunction
    @AlwaysInline
    default boolean parsePacket(Ptr<?> start, 
      Ptr<?> end, Ptr<PacketInfo> info) {

        // ...

        if (ethType == XDPHook.ETH_P_IP) {
            return parseIPPacket(
              start.add(offset).<runtime.iphdr>cast(), end, info);
        }
        if (ethType == XDPHook.ETH_P_IPV6) {
            return parseIPv6Packet(
              start.add(offset).<runtime.ipv6hdr>cast(), end, info);
        }
        return false;
    }
}

This library mechanism allows you to use the methods by simply implementing the interface in your BPF program, making it fairly easy to parse and filter packets (see BlockHTTP on GitHub):

@BPF(license = "GPL")
public abstract class BlockHTTP2 extends BPFProgram 
  implements XDPHook, BasePacketParser {

    @Override
    public xdp_action xdpHandlePacket(Ptr<xdp_md> packet) {
        PacketInfo info = new PacketInfo();
        if (parsePacket2(packet.val().data, 
                         packet.val().data_end, 
                         Ptr.of(info))) {
            if (info.sourcePort == HTTP_PORT) {
                BPFJ.bpf_trace_printk("Dropping https packet\n");
                return xdp_action.XDP_DROP;
            }
        }
        return xdp_action.XDP_PASS;
    }

    // ...
}

The main advantage of implementing libraries as Java interfaces is that their properties align:

  • Interfaces can’t have instance variables, so defining global variables or maps is impossible. This constraint simplifies the implementation of the library mechanism, as the Java compiler plugin has to deal with less complexity.
  • A program can implement and, therefore, use multiple interfaces.
  • Finding the used libraries is easy, as they are all declared initially.

We can extend this into a firewall with rules. The implementation is split into the base firewall (GitHub) and the Spring based frontend (GitHub).

Rule Data Structures

First, we define our data structures. Rules are based on the IP address and the source port:

/** A connection consists of an IP address and a port */
@Type
record IPAndPort(int ip, int port) {
}

@Type
record FirewallRule(
    @Unsigned int ip,
    /** 
      * Ignore the low n bytes for matching,
      * these bytes have to be zero in the ip field
      */
    int ignoreLowBytes,
    /* or -1 to match all ports */
    int port) {
}

@Type
enum FirewallAction implements Enum<FirewallAction> {
    ALLOW, DROP, 
    /** No rule applies */
    NONE
}

The firewall rules are stored in a map:

@BPFMapDefinition(maxEntries = 1000)
BPFHashMap<FirewallRule, FirewallAction> firewallRules;

Finding the matching Firewall Rule

Given a connection (IPAndPort), how can we find the associated action? We can observe that an IP address W.X.Y.Z is matched by the rules, where the most specific rule wins:

  • W.X.Y.Z ignoring no bytes
  • W.X.Y.0 ignoring the lowest byte
  • 0.0.0.0 ignoring all bytes

We implement this directly in code:

@BPFFunction
@AlwaysInline
FirewallAction computeAction(Ptr<IPAndPort> info) {
    // for all possible ip address matching bytes
    for (int i = 0; i < 5; i++) {
        var action = computeSpecificAction(info, i);
        if (action != FirewallAction.NONE) {
            return action;
        }
    }
    return FirewallAction.NONE;
}

The computeSpecificAction method checks for a rule with a matching IP address, which ignores the specified lowest bytes. Each rule has a specified port, so we have to check for the two values, -1 matching every port, and the port of the connection:

// This method can be both used in the eBPF and the Java part
@BPFFunction
@AlwaysInline
static int zeroLowBytes(int ip, int ignoreLowBytes) {
    return ip & (0xFFFFFFFF << (ignoreLowBytes * 8));
}

@BPFFunction
@AlwaysInline
FirewallAction computeSpecificAction(
  Ptr<IPAndPort> info, int ignoreLowBytes) {
    int ip = info.val().ip;
    // first null the bytes that should be ignored
    int matchingAddressBytes = zeroLowBytes(ip, ignoreLowBytes);
    if (matchingAddressBytes == 100) { 
        // don't ask, the code only works with this line
        // it's probably a compiler bug and this line adds
        // some memory dependencies, influencing the compiler
        // optimizations
        bpf_trace_printk("Checking rule for %d:%d\n", 
                         matchingAddressBytes, info.val().port);
    }
    var rule = new FirewallRule(
                  matchingAddressBytes, 
                  ignoreLowBytes, 
                  info.val().port);
    var action = firewallRules.bpf_get(rule);
    if (action != null) {
        return action.val();
    }
    rule = new FirewallRule(
                  matchingAddressBytes, 
                  ignoreLowBytes, -1);
    action = firewallRules.bpf_get(rule);
    if (action != null) {
        return action.val();
    }
    return FirewallAction.NONE;
}

We’re querying the firewallRules map between one and ten times for every connection. This amount of querying is quite a lot, so we cache this in the resolvedRules map:

@BPFMapDefinition(maxEntries = 1000)
BPFLRUHashMap<IPAndPort, FirewallAction> resolvedRules;

@BPFFunction
@AlwaysInline
FirewallAction getAction(Ptr<PacketInfo> packetInfo) {
    IPAndPort ipAndPort = new IPAndPort(
            packetInfo.val().source.ipv4(), 
            packetInfo.val().sourcePort);
    Ptr<FirewallAction> action = resolvedRules.bpf_get(ipAndPort);
    if (action != null) {
        return action.val();
    }
    var newAction = computeAction(Ptr.of(ipAndPort));
    resolvedRules.put(ipAndPort, newAction);
    return newAction;
}

Firewall

With the ability to find a matching action, extending the previous XDP example into a proper firewall is pretty simple. The only two additions are code to parse firewall rules like:

// Drop all HTTP (port 80) packets from the first
// IP address for google.com
google.com:HTTP drop

// Let all packets from the local network pass
192.168.0.0:-1/16 pass

And code that logs all dropped connections in a ring buffer:

@Type
record LogEntry(IPAndPort connection, long timeInMs) {
}

@BPFMapDefinition(maxEntries = 1000 * 128)
BPFRingBuffer<LogEntry> blockedConnections;

I spare you the details of both, but I show you the result:

> ./run.sh Firewall "google.com:HTTP drop"
Rule: FirewallRule[ip=1857682062, ignoreLowBytes=0, port=80] action: DROP
# run wget http://google.com in a separate shell
Blocked packet from 142.250.185.110 port 80
Blocked packet from 142.250.185.110 port 80
Blocked packet from 142.250.185.110 port 80
Blocked packet from 142.250.185.110 port 80

This code is a basic firewall written directly in Java. You can find all the code on GitHub.

Spring-Boot based Frontend

Command-line tools are nice, so why write a web front end? It shows how to integrate eBPF with Spring-Boot, the most used Java web framework, using hello-ebpf, pushing data directly from the web into the kernel.

The Spring-Boot application is split into two classes: The application class FirewallSpring which starts the Spring application and an instance of the Firewall, and the FirewallController class, which handles the web requests.

The controller has REST endpoints for adding rules, resetting all rules, obtaining the blocked connection log, and showing the website:

@RestController
@RequestMapping("/")
class FirewallController {
    // firewall set by FirewallSpring after
    // its creation
    static Firewall firewall;
    // log filled by the firewall log handler
    static List<LogEntry> log = new ArrayList<>();

    /** Add a raw drop rule */
    @PostMapping("/rawDrop")
    ResponseEntity<Void> rawDrop(@RequestBody FirewallRule rule) {
        addRule(rule, FirewallAction.DROP);
        return ResponseEntity.ok().build();
    }

    /** Parse and add rule string */
    @PostMapping("/add")
    ResponseEntity<Void> add(@RequestBody String rule) {
        var parsed = Firewall.parseRule(rule);
        addRule(parsed.rule(), parsed.action());
        return ResponseEntity.ok().build();
    }

    /** 
     * Add the passed rule and accompanying action to the firewall.
     * Clears the resolvedRules cache afterwards.
     */  
    private void addRule(FirewallRule rule, FirewallAction action) {
        System.out.println("Adding rule: " + rule + 
                           " action: " + action);
        validateRule(rule);
        // put the rule directly in the kernel
        firewall.firewallRules.put(rule, action);
        firewall.resolvedRules.clear();
    }

    private void validateRule(FirewallRule rule) { /* ... */}
    
    /** Drop all rules */
    @PostMapping("/reset")
    ResponseEntity<Void> reset() {
        firewall.firewallRules.clear();
        firewall.resolvedRules.clear();
        return ResponseEntity.ok().build();
    }

    /** Return the collected logs */
    @GetMapping("/logs")
    ResponseEntity<List<LogEntry>> getLogs() {
        return ResponseEntity.ok(log);
    }

    /** 
     * Trigger a request to the passed URL.
     * Quite useful for demos
     */
    @PostMapping("/triggerRequest")
    ResponseEntity<Void> triggerRequest(@RequestBody String url) {
        // call 'wget url' in a new thread
        // ... 
        return ResponseEntity.ok().build();
    }

    /**
     * Return the front-end written with some JavaScript.
     * I spare you of any details, as I essentially told
     * ChatGPT how the REST API looks like and the web-page
     * that I wanted. The resulting web-page looks good
     * enough for a small demo after some tweaking.
     */
    @GetMapping("/")
    @ResponseBody
    public String index() {
        return """
          ...
        """;
    }
}

The FirewallSpring class creates a firewall and starts the controller, filling the log:

@SpringBootApplication(scanBasePackages = "me.bechberger.ebpf.samples")
public class FirewallSpring {

    public static void main(String[] args) throws InterruptedException {
        System.setProperty("server.port", "8080"); // just to be sure
        System.setProperty("server.address", "0.0.0.0");
        try (Firewall program = BPFProgram.load(Firewall.class)) {
            program.xdpAttach();
            program.blockedConnections.setCallback(
              FirewallSpring::log);
            FirewallController.firewall = program;
            new Thread(() -> 
              SpringApplication.run(FirewallSpring.class, args)).start();
            while (true) {
                program.consumeAndThrow();
                Thread.sleep(100);
            }
        }
    }
    
    /** 
     * Log both to the command line and the log list
     * of Firewall controller, keep the last 1000
     * log entries.
     */
    static void log(LogEntry logEntry) {
        System.out.println(logEntry.timeInMs() + ": Blocked packet from " +
                NetworkUtil.intToIpAddress(logEntry.connection().ip()).getHostAddress() +
                           " port " + logEntry.connection().ip());
        FirewallController.log.add(logEntry);
        if (FirewallController.log.size() > 1000) {
            FirewallController.log.removeFirst();
        }
    }
}

You can find the complete code on GitHub. We can start this application via ./run.sh FirewallSpring and get frontend that you saw at the top of this post. We can use this frontend to add a firewall rule to block all incoming HTTP traffic from google.com:

After adding this rule, we can trigger a request to http://google.com:

And observe the dropped packages in the auto-updated log list:

Conclusion

In this blog post, I showed you how to build a firewall in Java and directly integrate eBPF with a commonly used Java framework. This firewall application is also the main driver of my talk, Building a Lightning Fast Firewall with Java & eBPF, in which I give an introduction to eBPF and show how to use hello-ebpf in a live demo.

I’ll present this talk at conferences all over Europe: Copenhagen Developer Festival, JavaZone, InfoQ DevSummit Munich, Devoxx Belgium, Open Community Experience, and Oredev. If you have the chance to join me in any of these, come by and say hello. I’m always to talk to people who follow my hello-ebpf journey. Register for the eBPF summit on September 11th if you’re more into online conferences.

This article is part of my work in the SapMachine team at SAP, making profiling and debugging easier for everyone.

Author

  • Johannes Bechberger

    Johannes Bechberger is a JVM developer working on profilers and their underlying technology in the SapMachine team at SAP. This includes improvements to async-profiler and its ecosystem, a website to view the different JFR event types, and improvements to the FirefoxProfiler, making it usable in the Java world. He started at SAP in 2022 after two years of research studies at the KIT in the field of Java security analyses. His work today is comprised of many open-source contributions and his blog, where he writes regularly on in-depth profiling and debugging topics, and of working on his JEP Candidate 435 to add a new profiling API to the OpenJDK.

    View all posts

New posts like these come out at least every two weeks, to get notified about new posts, follow me on Twitter, Mastodon, or LinkedIn, or join the newsletter: