| src | moved README to article | |
| .gitignore | first working version, no response handling | |
| Makefile | first working version, no response handling | |
| README.md | updated readme | |
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, ðernet) == -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
sudoprivileges!
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.hstructs 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.