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_RAW
and 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.
The 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.
Overall Flow
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.
The 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 RawArpFrame
inside handle_packet()
.
Receiving an Individual Frame
The 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.
Our 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.
Tips
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 unsafe
.
Normally AF_PACKET
/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_ADMIN
and CAP_NET_RAW
. If you see permission denied errors, this is probably why.