Firewall Extension

This is a kernel module extension that I wrote to practice kernel development during my Master's degree. The purpose of it is to extend the netfilter functionality of the Linux kernel so that a userspace application can configure a list of firewall rules. A firewall rule consists of a port number and the full path to an application, and a list of these defines a whitelist for that port. If there is no rule for a given port, then any application is allowed to make outgoing connections. When an application makes an outgoing connection on a port that it is not registered for, the connection is immediately terminated.

Both the kernel module and the client are written in C.

Kernel Module

      #include <linux/module.h>
      #include <linux/kernel.h>
      #include <linux/netfilter.h>
      #include <linux/netfilter_ipv4.h>
      #include <linux/slab.h>
      #include <linux/fs.h>
      #include <linux/proc_fs.h>
      #include <linux/list.h>
      #include <asm/uaccess.h>
      #include <asm-generic/errno-base.h>
      #include <net/tcp.h>
      #include <linux/skbuff.h>
    
      #include "client_header.h"
      #include "firewall_rule.h"

      MODULE_LICENSE("GPL");

      #define PROC_ENTRY_FILENAME "firewall_extension"
      #define SUCCESS 0
      #define FAILURE -1

      // ...

      int init_module(void) {
        INIT_LIST_HEAD(&current_rule_set.list);
        INIT_LIST_HEAD(&new_rule_set.list);
        
        if (nf_register_net_hook(&init_net, &fw_nf_ops)) {
          printk(KERN_ALERT "Firewall netfilter hooking failed!\n");
          return FAILURE;
        } 

        fw_proc_file = proc_create_data(PROC_ENTRY_FILENAME, 0666, NULL, &fw_file_ops, NULL);
        if (!fw_proc_file) {
          printk(KERN_ALERT "Firewall procfile creation failed!\n");
          return FAILURE;
        }

        return SUCCESS;
      }

      void cleanup_module(void) {
        remove_proc_entry(PROC_ENTRY_FILENAME, NULL);
        nf_unregister_net_hook(&init_net, &fw_nf_ops);
        empty_firewall_rules_list();
      }

The entry to the kernel module is simple enough. It initialises two lists (a temporary working copy and the active list), sets the netfilter hook by passing in a function pointer, and sets up the entry in procfs. The details of each of these sections are below. When unloading the module, the reverse operations are executed.


      enum ClientAction { Unknown, AddRuleSet, DeleteRuleSet, PrintRuleSet };

      typedef struct {
        enum ClientAction action;
        unsigned long rule_count;
        char rules[];
      } ClientHeader;

To understand how communication works with the module via procfs, we must first look at the ClientHeader. This is a struct that the module expects to receive from the client. It defines the action that the module should perform, the number of firewall rules, and an optional list of rules.


      DEFINE_MUTEX(firewall_mutex);
      static int device_locked = 0;

      static int lock(void) {
        mutex_lock(&firewall_mutex);
        if (device_locked) {
          mutex_unlock(&firewall_mutex);
          return FAILURE; 
        }

        device_locked = 1;
        mutex_unlock(&firewall_mutex);
        return SUCCESS;
      }

      static int unlock(void) {
        mutex_lock(&firewall_mutex);
        device_locked = 0;
        mutex_unlock(&firewall_mutex);
        return SUCCESS;
      }

      int fw_proc_open(struct inode *inode, struct file *file) {
        if (lock() != SUCCESS)
          return -EBUSY;

        try_module_get(THIS_MODULE);
        return SUCCESS;
      }

      int fw_proc_close(struct inode *inode, struct file *file) {
        module_put(THIS_MODULE);
        unlock();
        return SUCCESS;
      }

      ssize_t fw_proc_write(struct file *file, const char __user *buffer, size_t count, loff_t *offset) {
        if (count < sizeof(ClientHeader)) {
          printk(KERN_ALERT "Error: the supplied buffer doesn't contain a valid header.\n");
          return count;
        }

        void* kernel_buffer = kmalloc(count, GFP_KERNEL);
        if (!kernel_buffer) {
          printk(KERN_ALERT "Error allocating buffer\n");
          return count;
        }

        // Copy whole buffer at once to reduce calls to access_ok().
        if (copy_from_user(kernel_buffer, buffer, count)) {
          printk(KERN_ALERT "Error copying data from userspace to kernelspace\n");
          goto out;
        }

        ClientHeader *header = kernel_buffer;
        switch(header->action) {
          case AddRuleSet:
            add_rules(kernel_buffer, count);
            break;
          case DeleteRuleSet:
            empty_firewall_rules_list();
            break;
          case PrintRuleSet:
            print_rules();
            break;
          default:
            break;
        }

      out:
        kfree(kernel_buffer);
        return count;
      }

      const struct proc_ops fw_file_ops = {
        .proc_open    = fw_proc_open,
        .proc_release = fw_proc_close,
        .proc_write   = fw_proc_write
      };

      static struct proc_dir_entry *fw_proc_file;

Access to the kernel module is controlled by a mutex so that only one client can work on the rules at a given time. It would be viable to make the access lock specific to the writing, but it would provide incorrect data if one client read the rules successfully and then they are immediately changed. Blocking reads ensures that the client receives accurate information. The userspace buffer in consumed in one operation into a kernelspace buffer to avoid multiple, unnecessary, copying. There is no read operation defined for the firewall rules as printing is designed to output to the kernel ringbuffer; usually read by dmesg.


      #include <linux/limits.h>

      typedef struct {
        unsigned short int port;
        char binary_path[PATH_MAX];
      } FirewallRule;

Time to examine what the methods called from procfs actually do! However, before we do that, we need to see how a FirewallRule is defined. The struct is very simple; containing the port number and the path to the binary, capped at the limit allowed by Linux (4096).


      typedef struct {
        struct list_head list;
        FirewallRule rule;
      } FirewallRuleList;

      static FirewallRuleList current_rule_set,
                              new_rule_set;

      void empty_firewall_rules_list(void) {
        FirewallRuleList *tmp, *safe_tmp;
        list_for_each_entry_safe(tmp, safe_tmp, &current_rule_set.list, list) {
          list_del(&tmp->list);
          kfree(tmp);
        }
      }

      void print_rules(void) {
        FirewallRuleList *tmp;
        printk(KERN_INFO "Current firewall rule list:\n");
        list_for_each_entry(tmp, &current_rule_set.list, list)
          printk(KERN_INFO "  port=%d binary=%s\n", tmp->rule.port, tmp->rule.binary_path);
      }

      int add_rules(void *buffer, size_t length) {
        // Header should always be at the start of the buffer.
        ClientHeader *header = buffer;
        FirewallRuleList *new_list_node;
        for (int i = 0; i < header->rule_count; i++) {
          FirewallRule *current_rule = (FirewallRule*) header->rules + i;
          // Allocate the memory for the new firewall rule.
          new_list_node = (FirewallRuleList*) kmalloc(sizeof(FirewallRuleList), GFP_KERNEL);
          if (!new_list_node) {
            printk(KERN_ALERT "Error allocating memory for new list node!\n");
            return FAILURE;
          }

          INIT_LIST_HEAD(&new_list_node->list);
          list_add_tail(&new_list_node->list, &new_rule_set.list);      
          memcpy(&new_list_node->rule, current_rule, sizeof(FirewallRule));
        }

        empty_firewall_rules_list();
        list_splice_init(&new_rule_set.list, &current_rule_set.list);

        return SUCCESS;
      }

Now we can understand what a FirewallRuleList is; simply a struct with a struct list_head, for the kernel's doubly-linked list, and a FirewallRule. Two of these lists are created and initialised during init_module(). One is the active list of rules, the other is used when a new list is added. The three methods are relatively self-explanatory, print_rules() iterates the list and prints the port and binary to the kernel ringbuffer. empty_firewall_rules_list() uses the safe deletion iterator to remove each list item and deallocate the resources. add_rules() uses the buffer passed in from fw_proc_write(), copying the data for each supplied firewall rule into the newly created list node. When all is finished, the current rule list is emptied and rebuild from the new rules.


      unsigned int nf_hook_fn(void *priv, struct sk_buff *skb, const struct nf_hook_state *state) {
        struct sock *sk = skb->sk;
        if (!sk) {
          printk(KERN_INFO "Netfilter called with an empty socket!\n");
          goto accept;
        }

        if (sk->sk_protocol != IPPROTO_TCP) {
          printk(KERN_INFO "Netfilter called with non-TCP packet!\n");
          goto accept;
        }

        struct tcphdr tcp_header,
                      *tcp_header_ptr;
        tcp_header_ptr = skb_header_pointer(skb, ip_hdrlen(skb), sizeof(struct tcphdr), &tcp_header);
        if (!tcp_header_ptr) {
          printk(KERN_ALERT "Error retrieving TCP header information!\n");
          goto accept;
        }

        struct mm_struct *mm = get_task_mm(current);
        if (!mm || in_irq() || in_softirq()) {
          // Can be verbose so error not reported.
          goto accept;
        }

        mmput(mm);

        void *buffer = kmalloc(PATH_MAX, GFP_KERNEL);
        if (!buffer) {
          printk(KERN_ALERT "Error allocating memory for binary path!\n");
          goto accept;
        }

        char *pathname = d_path(&mm->exe_file->f_path, buffer, PATH_MAX);
        int destination_port = ntohs(tcp_header_ptr->dest);
        
        /*
         * Transmission is enabled by default. However, if the port is encountered whilst looking through
         * the list of rules then further checks need to be performed against the program path.
         */
        int allowed_to_transmit = 1;
        FirewallRuleList *tmp;
        list_for_each_entry(tmp, &current_rule_set.list, list) {
          if (destination_port == tmp->rule.port)
            allowed_to_transmit = strcmp(pathname, tmp->rule.binary_path) == 0; 
        }

        if (!allowed_to_transmit) {
          printk(KERN_INFO "Rule found! %s cannot transmit on port %d.\n", pathname, destination_port);
          tcp_done(sk);
          return NF_DROP;
        }
        
        kfree(buffer);

        accept:
          return NF_ACCEPT;
      }

The final piece of the puzzle is the netfilter hook. This method is called every time that netfilter handles a new network packet. It has the ability to return netfilter actions, such as NF_ACCEPT/NF_DROP, which allow or deny those packets respectively. The logic that this function allows is based on remote ports. Transmission on a port is allowed by default, however, during the iteration of the rules, if the remote port is found, then only applications on the whitelist are allowed to send packets.

Client

      #include <stdio.h>
      #include <stdlib.h>
      #include <unistd.h>
      #include <string.h>
      #include <errno.h>

      #include "firewall_rule.h"
      #include "client_header.h"

      #define PROCFILE "/proc/firewall_extension"
      #define SUCCESS 0
      #define FAILURE -1
      #define CLIENT_HEADER_SIZE sizeof(ClientHeader)
      #define RULE_SIZE sizeof(FirewallRule)

      void error(char *msg) {
        fprintf(stderr, "Error! %s\n", msg);
        exit(FAILURE);
      }

      int write_to_proc_file(void *buffer, size_t size) {
        for (int i = 0; i < 10; i++) {
          FILE *proc_file = fopen(PROCFILE, "wb");
          if (proc_file) {
            fwrite(buffer, size, 1, proc_file);
            fclose(proc_file);
            return SUCCESS;
          } else {
            if (errno == -EBUSY) {
              sleep(1);
              continue;
            } else {
              fprintf(stderr, "Error opening %s\n", PROCFILE);
              break;
            }
          }
        }
        return FAILURE;
      }

      int write_rules(char *filename) {
        // Allocate memory for the header and the list of rules.
        void *buffer = malloc(CLIENT_HEADER_SIZE);
        if (!buffer)
          error("Could not allocate memory for array!");

        // Verify that the provided rule file is readable.
        FILE *rule_file = fopen(filename, "r");
        if (!rule_file)
          error("Could not open rule file!");

        // The header is always at the start of the buffer.
        ClientHeader *header = buffer;
        header->action = AddRuleSet;
        header->rule_count = 0;

        char *line = NULL,
             *space_index_pointer,
             *newline_index_pointer;
        size_t line_len;

        while (getline(&line, &line_len, rule_file) != -1) {
          char *space_index_pointer = strchr(line, ' ');
          char *newline_index_pointer = strchr(line, '\n');
          if (!(space_index_pointer && newline_index_pointer)) {
            fprintf(stderr, "ERROR: Ill-formed file\n");
            goto end;
          }

          header->rule_count++;
          buffer = realloc(buffer, CLIENT_HEADER_SIZE + (header->rule_count * RULE_SIZE));
          if (!buffer) {
            fprintf(stderr, "ERROR: Unable to extend buffer size.\n");
            goto end;
          }
          
          // Pointer will have changed from realloc.
          header = buffer;
          FirewallRule *rule = buffer + CLIENT_HEADER_SIZE + ((header->rule_count - 1) * RULE_SIZE);

          int space_index = space_index_pointer - line;
          int newline_index = newline_index_pointer - line; 
          int program_path_len = (newline_index - space_index) - 1;
          memcpy(rule->binary_path, space_index_pointer + 1, program_path_len);
          rule->port = atoi(line);
        }

        write_to_proc_file(buffer, CLIENT_HEADER_SIZE + (header->rule_count * RULE_SIZE));
         
        end:
          free(buffer);
          free(line);
          fclose(rule_file);
          return SUCCESS;
      }

      int clear_rules() {
        ClientHeader header = {};
        header.action = DeleteRuleSet;
        return write_to_proc_file(&header, CLIENT_HEADER_SIZE);
      }

      int print_rules(void) {
        ClientHeader header = {};
        header.action = PrintRuleSet;
        return write_to_proc_file(&header, CLIENT_HEADER_SIZE);
      }

      void print_usage(const char *exepath) {
        fprintf(stderr, "Usage: %s [L] | [W ] | [D]\n", exepath);
        exit(FAILURE);
      }

      int main(int argc, char **argv) {
        if (argc < 2)
          print_usage(argv[0]);

        char command;
        sscanf(argv[1], "%c", &command);

        switch(command) {
          case 'W':
            if (argc != 3)
              print_usage(argv[0]);
            else
              write_rules(argv[2]);
            break;
          case 'D':
            clear_rules();
            break;
          case 'L':
            print_rules();
            break;
          default:
            print_usage(argv[0]);
        }

        return SUCCESS;
      }

The client side of this application is very straightforward. The print and delete options simply forward the desired action wrapped in a ClientHeader. The add option expects a file with 1 rule per line with the format of '[port] [absolute_path]'. It reads this file line-by-line, formatting the input into a FirewallRule, and placing it in the buffer that is eventually written to the procfs file.