An ARP Server in Rust – Linux Raw Sockets
Linux has a fiddly but useful C interface for sending and receiving raw network packets. It’s documented primarily in the packet(7) manpage and the workhorse of this interface is a struct called
sockaddr_ll. It’s ostensibly a type of
sockaddr but it has a different length, different fields, and different parts that are relevant in different situations.
This is not especially ergonomic in Rust. The libc bindings for
recvfrom(), for example, require you to pass in a
*mut sockaddr. If you’re receiving from a raw socket then this actually needs to be a pointer to a
sockaddr_ll, which is included in libc but is a completely different type from Rust’s point of view. Clearly Rust’s type checker is not going to be thrilled about this.
I recently figured out how to make this work for sending and receiving raw AX.25 packets. I wanted to blog about how I got it working, but since AX.25 is a little obscure I’ve decided to build a basic ARP server using similar code. Hopefully it makes a good example. This is the complete code for the server I’m going to be describing: https://gist.github.com/thombles/16736c9c656e6dad9a08c81b30a974ac
Opening a Socket
To send and receive packets the first thing you need is a socket. If you’re using the
AF_PACKET interface you can choose between
SOCK_DGRAM. The first includes the ethernet headers in the packet body; the second does not. I’m using
SOCK_RAW so when I receive an ARP packet so this is the information I will end up receiving:
To open the socket I use the
socket() function from libc and some of the standard constants. You can choose to filter by a particular protocol by using one of the constants from the Linux header file if_ether.h. In this case I’m using
ETH_P_ARP because I only want to know about ARP packets.
ETH_P_ARP parameter must be handled carefully. Ethernet protocol identifiers are 2 bytes, so it’s a
u16. However Linux requires that we provide this parameter in network byte order, which is big-endian. The parameter to
socket() is then defined as a
c_int, which means an
i32. It’s important to do these steps in the right order. This is how it works on my Intel processor. If you happened to be using a big-endian platform it would be
0806 all the way through.
This is an unintelligent ARP server and it will work like this:
- Set up a HashMap of IPv4 addresses that we will respond to, with each entry storing the corresponding MAC address.
- Listen to all incoming ARP packets on all interfaces. If it is an ARP query for an address in our HashMap, send a reply, acting as that computer.
This won’t be useful for much more than ARP spoofing since anything that knows how to talk IP will generally take care of its own ARP. But it will do for a demo. In this code I’m just using a hard-coded
HashMap that’s configured when the app launches.
The following will open the socket then attempt to receive a packet. Each time it does, if it was not an error it will hand it off to another function to read.
For each received packet we get back two blobs of data. One is the actual packet. The other is a chunk of metadata, the
sockaddr_ll. In this case I am (slightly arbitrarily) using two different strategies for receiving these chunks of data.
sockaddr_ll is declared on the stack and I’m passing in a mutable pointer, so the C function can fiddle with the contents of the on-stack memory. Later on, after the unsafe code, hopefully the zeroed fields have been replaced with something valid.
The packet content is defined as a simple buffer of
u8. I could probably use my
RawArpFrame struct the same as
sockaddr_ll, but keeping it as an untyped buffer means I could potentially handle different kinds of protocols. I will instead turn the buffer into a
Receiving an Individual Frame
recvfrom() function will block until it it either successfully receives a frame or suffers an error. This function wraps
recvfrom() and returns the number of valid bytes that have been written into the buffer.
This is where it gets a little nasty. I can’t create a
*mut sockaddr from a
sockaddr_ll; they are simply different types. I use the unsafe
transmute function to convert the pointer from one type to another. That transmuted pointer can then be used as a parameter to
recvfrom(). On the other hand, the data buffer’s mut ptr can simply be cast to an opaque
*mut c_void as required for the libc parameter.
Parsing the Incoming Frame
Once an ARP frame has been received we have two pieces of information: the
sockaddr_ll “metadata” provided by Linux, and an opaque blob of binary data in a buffer. The
handle_packet() function converts that opaque blob into a struct with fields and decides if we want to reply to it or not.
sockaddr_ll was written into directly during the
recvfrom() so we can simply use it. The buffer takes more work. Here,
ptr::read takes a pointer and uses it to construct a real
RawArpFrame based on that chunk of memory. We have to hope that the layout in memory matches the definition of the struct, which is why it’s unsafe.
Once we have that frame it’s straightforward to decide whether we should reply to it or not.
Sending an ARP Response
Sending a raw packet in Linux requires filling in particular fields of the
sockaddr_ll as documented in the manpage. The most tricky of these is the interface index, which identifies which network interface on the machine should be used to send the data. You access this with the
SIOCGIFINDEX ioctl, as described in
netdevice(7). To find out which interfaces exist you can either read
/proc/net/devices (more complete if you don’t always have IP addresses) or use the
SIOCGIFCONF ioctl. (Example code for this ifindex discovery.)
It is possible to sidestep a lot of this work when you only wish to reply to incoming packets on the same interface that you received them. Happily, this is the case for ARP. All the information you need is contained in the
sockaddr_ll from the packet you received.
The top of the function which manipulates frame is simply shuffling fields around to turn it into a valid ARP response, including the missing MAC address.
It then creates a new
sockaddr_ll which is mostly initialised to zero but takes the key fields from the received
sockaddr_ll. It likely wouldn’t hurt to include more of the fields. The documentation is not crystal clear so I’m not taking my chances.
The call to
sendto() is very similar to what we saw before with
recvfrom(). We have to pass both a sockaddr and the data to be transmitted. The difference this time is that both of these sources of data are strongly typed structs.
The snippets above cover the main functionality. I want to mention a few other things I found along the way.
When you use the netdevice ioctls you are forced to work with an
ifreq union. This is an especially tricky data structure that can easily get messy. In my own code I used a technique by Herman J. Radtke III that makes this reasonably easy to handle without using
SOCK_RAW will only give you incoming packets. Word on Stack Overflow is that using
ETH_P_ALL instead of a specific protocol will include outgoing packets, but then it will be necessary to filter by protocol yourself.
Finally, capturing packets promiscuously and sending raw packets requires either root access or the capabilities
CAP_NET_RAW. If you see permission denied errors, this is probably why.