1
0
Fork
You've already forked c_arp_tutorial
0
ARP client implementation in C, for Linux
  • C 91.6%
  • Makefile 8.4%
2025年07月18日 13:27:20 +03:00
src moved README to article 2025年07月11日 11:55:10 +03:00
.gitignore first working version, no response handling 2025年07月11日 07:55:59 +03:00
Makefile first working version, no response handling 2025年07月11日 07:55:59 +03:00
README.md updated readme 2025年07月18日 13:27:20 +03:00

In case you are having a hard time following up, check out the book at the bottom of this page.

Step-by-Step Explanation

The ARP Protocol

Let's say that you're computer A and that, in the context of a UDP or TCP connection with an arbitrary host, the routing path enforces you to communicate with computer B located in the opposite side of your local switch (next hop). B's local IP address is assumed to be known (routing table). From the sender's point of view, TCP packets are enclosed inside IP datagrams which are enclosed inside Ethernet frames. We can't possibly fill in the headers of these Ethernet frames without first having knowledge of the corresponding MAC address of B! The ARP protocol solves exactly that.

Here's an overview when dealing with an IP target located on the other side of the planet: The OS will invoke the ARP protocol on your default gateway, that is, the router that connects your home to the outside world. The first Ethernet frame will travel the path hop by hop. After each individual hop, the active router will on its turn send an ARP request to identify the MAC addresses of its immediate neighbor until the actual destination is finally in sight. Note that proper caching (ARP tables) will eliminate most, if not all, of this traffic!

Collecting Interface Characteristics

Before we start bombarding our destination with packets, we first need to reflect and collect our own identity. Among the guts of your computers you will recognize a local network card connected to the outside world (your router and switch) with a (mostly) worldwide unique MAC address and (probably) a local IP address. Both of these identifiers are essential for constructing outgoing Ethernet frames and ARP packets. ioctl will serve as a communication channel between us (the user space) and the internal kernel structures related to such network devices.

First, extract the string identifier of your central network interface using ip link show (eth0 or something). Now, this might be somewhat confusing: ioctl requires a dummy socket argument as a means of properly interacting with the kernel's networking code, so we'll be recycling the socket described in the next section. We call ioctl thrice, once for each unknown characteristic! struct ifreq is nothing but a temporary structure responsible for storing the arbitrary return values of the response as well as configuring some of the properties of the request itself.

struct ifreq ethernet;
// Before dispatching the request we first need to define the target
strncpy(ethernet.ifr_name, iface (e.g. "eth0"), IF_NAMESIZE);
if (ioctl(client->fd, SIOCGIFHWADDR, &ethernet) == -1)
 exit_with_perror("ioctl addr");
// Store the result in persistent storage
memcpy(client->mac, ethernet.ifr_hwaddr.sa_data, sizeof(mac_t));

We store all such data inside a central arp_client_t struct, a pointer of which gets passed around as the first argument to most of our program's functions. That's how you typically emulate object-oriented programming in C without ever opening the Pandora's box of disgusting abstractions and modernisms enforced by C++!

Linux Raw Sockets

Raw sockets, in contrast with ordinary sockets, allow us to bypass the transport layer and interact with the network directly through link-layer protocols, such as Ethernet.

This might sound dumb, but I was surprised when I first read through TCP kernel code in C. My brain was expecting a hardware implementation, don't ask me why! Note that initiating raw socket connections will usually (and justifiably) require sudo privileges!

We'll be using AF_PACKET in tandem with SOCK_RAW since we're going to be dealing with link-layer frames and we'd like to construct the Ethernet header ourselves. Such sockets do not consume actual traffic. Packets will be duplicated and forwarded to the kernel anyways. The third argument of socket serves as a filter. We are currently implementing the sending side, so we can temporarily ignore it (man packet).

client->fd = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));

Sending an ARP Request

We've finally reached the fun part! ARP packets are encapsulated inside the payload of an Ethernet frame. For a detailed and rigorous presentation of all participating data fields, check out RFC 826! The Ethernet type field is required since we've got no way of differentiating between enclosed packets, which present a vast variety of types (including, for example, IPv4 datagrams).

This is the perfect time for you to flick through the book I present on the "Further Reading" section! It's way less complicated than it may seem like at first glance.

We're about to send data over the wire. Keep in mind that all C integers (MAC addresses can be overlooked since we've personally defined them using a simple array of bytes) need to be transformed according to the network's uniform big-endianness. As you might have already noticed, both hw_adr_len and proto_adr_len, being a single byte long, need not be transformed! Similarly, according to the man pages, IP addresses returned from inet_addr are already conveniently stored in network byte order.

The link-layer destination is unknown so an ARP request must be broadcast. Other than that, the remaining fields (which are described in the RFC anyway) are self-explanatory.

unsigned char buffer[
 sizeof(ethernet_header_t) + sizeof(arp_packet_t)
];
ethernet_header_t *header = (ethernet_header_t*) buffer;
header->type = htons(ETH_P_ARP);
// We're going to be broadcasting this, since the actual destination is unknown
memset(header->dest, 0xFF, sizeof(mac_t));
memcpy(header->src, client->mac, sizeof(mac_t));
// Defining the ethernet payload, i.e. our ARP packet
arp_packet_t *packet = (arp_packet_t*) (buffer + sizeof(ethernet_header_t));
*packet = (arp_packet_t) {
 // 0x01 = Ethernet Address Space (check out RFC "definitions" section)
 .hw_adr_space = htons(0x01),
 .proto_adr_space = htons(ETH_P_IP),
 // Single byte integers
 .hw_adr_len = sizeof(mac_t),
 .proto_adr_len = sizeof(ip4_t),
 .operation = htons(ARP_REQUEST),
};

Observe that our arp_packet.h structs have deliberately no padding. That's what allows this neat way of marshalling bytes to an arbitrary char buffer in a single line of code. If in need of a refreshment on memory alignment, read through this excellent article!

OK! The data has been successfully marshalled. Before releasing the packet in the wild, we'd first have to define some metadata for use by our beloved kernel. This might seem fairly repetitive, but it's apparently mandatory! This link-layer address structure, along with the data that we previously described, is all that's necessary for the definitive, long-awaited sendto system call!

// Forming the link-layer address of the destination. This is kernel
// metadata that is processed before the actual packet is analyzed and served
struct sockaddr_ll dest_address = {
 .sll_family = AF_PACKET,
 // Man Page: This is the sole, narcissistic value
 // that requires special endianess treatment.
 .sll_protocol = htons(ETH_P_ARP),
 .sll_ifindex = client->index,
 .sll_halen = sizeof(mac_t),
 .sll_pkttype = (PACKET_BROADCAST),
};

In case you're wondering, the CRC field of the Ethernet frame is automatically calculated and appended by either the Linux kernel or the computer's network card. Open up wireshark and execute the binary with the local IP address of a neighboring computer! You should be able to detect our ARP broadcast message, as well as the ARP reply from the destination, containing the MAC address that we so desperately needed! Exciting, I know.

Collecting the Response

Time to programmatically parse the response. We create a brand new listening socket that filters everything out, except for ARP packets. The process should remind you of an ordinary, high-level UDP server setup. We simply need to associate that socket with the address of our network card (bind).

static int create_arp_read_socket(int if_index)
{
 // We will filter everything out, except ARP packets (ETH_P_ARP)
 int fd = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ARP));
 if (fd == -1)
 exit_with_perror("socket");
 // Designated initializers are pretty :)
 struct sockaddr_ll adr = {
 .sll_family = AF_PACKET,
 .sll_ifindex = if_index
 };
 if (bind(fd, (struct sockaddr*) &adr, sizeof(adr)) == -1)
 exit_with_perror("bind");
 return fd;
}

Receiving the bytes is essentially trivial. We pass NULL because we can extract sender information from the packet itself! According to the RFC, the original target MAC address will reside in the received ARP mac_src field. We neatly cast the data into our struct definitions and that's about it!

Ethernet frames have a minimum length of 64 bytes, no more, no less. The OS will fill ARP packets with zero padding and the CRC will be stripped off before reaching our C code. This leaves us with exactly 60 bytes of data!

// Collect the response in an ordinary byte buffer
unsigned char buffer[60];
ssize_t length = recvfrom(fd, buffer, sizeof(buffer), 0, NULL, NULL);
// No padding, we can just cast!
ethernet_header_t *header = (ethernet_header_t*) buffer;
arp_packet_t *packet = (arp_packet_t*) (buffer + sizeof(ethernet_header_t));
if (ntohs(packet->operation) != ARP_REPLY)
 continue;
printf("I got an ARP reply from MAC address: ");
print_hex_bytes(packet->mac_src, sizeof(mac_t));

Further Reading

  • Computer Networking - A Top-Down Approach, 8th Edition: Sections 6.4.1 and 6.4.2. If you're a complete beginner, I'd advice you to read the introductory chapter first.