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:

/// An ARP packet with ethernet headers still attached
#[repr(C)]
#[derive(Debug)]
struct RawArpFrame {
    // Ethernet frame headers
    destination_mac: [u8; 6],
    source_mac: [u8; 6],
    ether_type: u16, // should be 0x0806 BE for an ARP payload
    
    // ARP Payload
    hardware_type: u16, // expect 0x0001 for ethernet
    protocol_type: u16, // expect 0x0800 for IPv4
    hw_addr_len: u8, // expect 6 [octets] for MAC addresses
    proto_addr_len: u8, // expect 4 [octets] for IPv4 addresses
    operation: u16, // 1 for request, 2 for reply
    sender_hw_addr: [u8; 6],
    sender_proto_addr: [u8; 4],
    target_hw_addr: [u8; 6],
    target_proto_addr: [u8; 4]
}

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.

use libc::{socket, AF_PACKET, SOCK_RAW};
const ETH_P_ARP: u16 = 0x0806; // from if_ether.h for SOCK_RAW
 
/// Open a raw AF_PACKET socket for the ARP protocol.
fn open_fd() -> io::Result<i32> {
    unsafe {
        match socket(AF_PACKET, SOCK_RAW, ETH_P_ARP.to_be() as i32) {
            -1 => Err(io::Error::last_os_error()),
            fd => Ok(fd)
        }
    }
}

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.

let eth_p_arp: u16 = 0x0806;
println!("    {:#06X}", eth_p_arp);
// Prints:     0x0806
 
let eth_p_arp_be = eth_p_arp.to_be();
println!("    {:#06X}", eth_p_arp_be);
// Prints:     0x0608
 
let eth_p_arp_be_i32 = eth_p_arp_be as i32;
println!("{:#010X}", eth_p_arp_be_i32);
// Prints: 0x00000608

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.

type Mappings = HashMap<Ipv4Addr, MacAddr>;
type MacAddr = [u8; 6];
 
fn listen_for_arp_requests(mappings: &Mappings) -> io::Result<()> {
    let mut sender_addr: sockaddr_ll = unsafe { mem::zeroed() };
    let mut packet_buf: [u8; 1024] = [0; 1024];
 
    let fd = open_fd()?;
 
    loop {
        match recv_single_packet(fd, &mut sender_addr, &mut packet_buf) {
            Ok(len) => handle_packet(fd, mappings, sender_addr, &packet_buf[0..len]),
            Err(e) => return Err(e)
        }
    }
}

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.

fn recv_single_packet(fd: i32, addr: &mut sockaddr_ll, buf: &mut [u8]) -> io::Result<usize> {
    let len: isize;
    let mut addr_buf_sz: socklen_t = mem::size_of::<sockaddr_ll>() as socklen_t;
    unsafe {
        let addr_ptr = mem::transmute::<*mut sockaddr_ll, *mut sockaddr>(addr);
        len = match recvfrom(fd, // file descriptor
                buf.as_mut_ptr() as *mut c_void, // pointer to buffer for frame content
                buf.len(), // frame content buffer length
                0, // flags
                addr_ptr as *mut sockaddr, // pointer to buffer for sender address
                &mut addr_buf_sz) { // sender address buffer length
            -1 => {
                return Err(io::Error::last_os_error());
            },
            len => len
        };
    }
 
    // Return the number of valid bytes that were placed in the buffer
    Ok(len as usize)
}

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.

const OP_REQUEST: u16 = 1;
const OP_REPLY: u16 = 2;
 
fn handle_packet(fd: i32, mappings: &Mappings, sender: sockaddr_ll, packet: &[u8]) {
    if packet.len() < mem::size_of::<RawArpFrame>() {
        // Ignore frame that was too short
        return;
    }
    // We can't trust any of this data but assume all fields lined up correctly
    // Worst case we'll send some response that makes no sense
    let parsed: RawArpFrame = unsafe { ptr::read(packet.as_ptr() as *const _) };
 
    // Sanity check 1 - is the ether type correct?
    if parsed.ether_type.to_be() != 0x0806 {
        return;
    }
    // Is this a request?
    if parsed.operation.to_be() != OP_REQUEST {
        return;
    }
 
    // Now let's see if it's one of ours
    let tpa = Ipv4Addr::new(parsed.target_proto_addr[0],
        parsed.target_proto_addr[1],
        parsed.target_proto_addr[2],
        parsed.target_proto_addr[3]);
    println!("Saw ARP request for IP address {}", tpa);
    
    match mappings.get(&tpa) {
        Some(&mac_addr) => send_reply(fd, parsed, mac_addr, sender),
        _ => return
    }
}

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.

fn send_reply(fd: i32, mut frame: RawArpFrame, mac_addr: MacAddr, raw_addr: sockaddr_ll) {
    // First, modify the the request payload into a reply and send it
    frame.destination_mac = frame.source_mac;
    frame.source_mac = mac_addr; // pretend to be the machine queried at the Ethernet layer
    frame.operation = OP_REPLY.to_be();
 
    let target_addr = frame.target_proto_addr;
    frame.target_proto_addr = frame.sender_proto_addr;
    frame.target_hw_addr = frame.sender_hw_addr;
    frame.sender_hw_addr = mac_addr;
    frame.sender_proto_addr = target_addr;
 
    // For simplicity, re-use fields from the sockaddr_ll we got when we received the request.
    let mut sa = sockaddr_ll {
        sll_family: raw_addr.sll_family,
        sll_protocol: raw_addr.sll_protocol,
        sll_ifindex: raw_addr.sll_ifindex,
        sll_hatype: 0,
        sll_pkttype: 0,
        sll_halen: 0,
        sll_addr: [0; 8]
    };
 
    unsafe {
        let addr_ptr = mem::transmute::<*mut sockaddr_ll, *mut sockaddr>(&mut sa);
        match sendto(fd, &mut frame as *mut _ as *const c_void, mem::size_of_val(&frame),
                        0, addr_ptr, mem::size_of_val(&sa) as u32)
        {
            d if d < 0 => println!("Error sending reply"),
            _ => println!("Sent an ARP reply")
        }
    }
}

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.