DEV Community

loading...

II. Implementing ICMP in Rust

Dave
K8s, Infra, Backend, and Distributed Systems!
・6 min read

Make sure to checkout previous part where we talk about IP and ICMP layouts if you haven't already: I. Implementing ICMP in Rust

3. (Cont'd) Implementation in Rust

First thing we need to do is to create a TUN/TAP device that allows us to receive/send raw frames directly. It's a kernel-feature which enables us to have software-defined interfaces.

fn main() {
    let mut nic = tun_tap::Iface::without_packet_info("tun0", tun_tap::Mode::Tun).unwrap();
    let mut buf = [0u8; 1500];
    loop {
        let nbytes = nic.recv(&mut buf[..]).unwrap();

        match etherparse::Ipv4HeaderSlice::from_slice(&buf[..nbytes]) {
            Ok(iph) => {
                let src = iph.source_addr();
                let dst = iph.destination_addr();
                let proto = iph.protocol();

                if proto != 1 {
                    continue;
                }

                let data_buf = &buf[iph.slice().len()..nbytes];

                if let Some(mut c) = Connection::start(
                    iph,
                    data_buf,
                ).unwrap() {
                    println!("connection c started!");
                    c.respond(&mut nic).unwrap();
                    println!("responded to {} packet from {} ", proto, src);
                }
            }
            Err(e) => {
                eprintln!("ignoring weird packet {:?}", e);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In our main, we create our device and a buf and keep reading from it in a loop. Whenever we find an IPv4 header, etherparse already takes care of the parsing for us and gives us the iph object.

For example, if you've read the previous installment you should know the proto == 1 for ICMP echo and echo reply packets, thus we skip everything else:

                if proto != 1 {
                    continue;
                }
Enter fullscreen mode Exit fullscreen mode

Finally we read the actual data, which is everything except header bytes and create a connection:

                let data_buf = &buf[iph.slice().len()..nbytes];

                if let Some(mut c) = Connection::start(
                    iph,
                    data_buf,
                ).unwrap() {
                    println!("connection c started!");
                    c.respond(&mut nic).unwrap();
                    println!("responded to {} packet from {} ", proto, src);
                }
Enter fullscreen mode Exit fullscreen mode

Since we're not dealing with a stateful protocol like TCP, we can get away with not using Connection but for sake of conforming to John's TCP implementation, I use one as well.

Still you need Connection to distinguish multiple streams and detect out of order sequence numbers.

pub struct Connection {
    ip: etherparse::Ipv4Header,
    icmp_id: u16,
    seq_no: u16,
}

impl Connection {
    pub fn start(iph: etherparse::Ipv4HeaderSlice, data: &[u8]) -> std::io::Result<Option<Self>> {
        let mut c = Connection {
            ip: etherparse::Ipv4Header::new(
                0,
                64,
                etherparse::IpTrafficClass::Icmp,
                [
                    iph.destination()[0],
                    iph.destination()[1],
                    iph.destination()[2],
                    iph.destination()[3],
                ],
                [
                    iph.source()[0],
                    iph.source()[1],
                    iph.source()[2],
                    iph.source()[3],
                ],
            ),
            icmp_id: u16::from_be_bytes(data[4..6].try_into().unwrap()),
            seq_no: u16::from_be_bytes(data[6..8].try_into().unwrap()),
        };

        Ok(Some(c))
    }

    pub fn respond(&mut self, nic: &mut tun_tap::Iface,) -> std::io::Result<usize> {
        let mut buf = [0u8; 1500];

        self.ip.set_payload_len(84-20 as usize);

        use std::io::Write;
        let mut unwritten = &mut buf[..];
        self.ip.write(&mut unwritten);

        let mut icmp_reply = [0u8; 64];
        // type
        icmp_reply[0] = ICMP_ECHO_REPLY;
        // code, always 0
        icmp_reply[1] = 0; 

        // checksum = 2 & 3, empty for now
        icmp_reply[2] = 0x00; 
        icmp_reply[3] = 0x00;

        // id = 4 & 5
        icmp_reply[4] = ((self.icmp_id >> 8) & 0xff) as u8; 
        icmp_reply[5] = (self.icmp_id & 0xff) as u8;

        // seq_no = 6 & 7
        icmp_reply[6] = ((self.seq_no >> 8) & 0xff) as u8; 
        icmp_reply[7] = (self.seq_no & 0xff) as u8;

        unwritten.write(&icmp_reply);

        let unwritten = unwritten.len();
        nic.send(&buf[..buf.len() - unwritten])?;

        Ok(0)
    }
}
Enter fullscreen mode Exit fullscreen mode

We create our Connection type and implement two methods: start and respond. All start does is create an IP header with the source and destination swapped.

Then in respond, we start writing the actual ICMP packet. We start by setting IP's size to 84, which if you remember from last part was the Total Length of our IP packet. 20 bytes is the IP header so we deduce that and prepare a 64 bytes buffer to hold our ICMP.

All we do here is change the ICMP type to 0 which is echo reply, leave checksum as 0, and copy in the identifier and sequence number.

Remember ICMP layout from previous part?

08          Type             0th byte       = Echo message
00          Code             1st byte                    
492b        Checksum         2-3rd byte
5514        Identifier       4-5th byte     = id 21780 (in raw capture)
0001        Sequence Number  6-7th byte     = seq 1
Enter fullscreen mode Exit fullscreen mode

4. Testing the implementation

We're ready to run our program.

Once it creates the device, we can ping it with any IP address associated with it and get:

# ping 192.168.0.3

PING 192.168.0.3 (192.168.0.3) 56(84) bytes of data.
64 bytes from 192.168.0.3: icmp_seq=1 ttl=64 time=1626615676072 ms
wrong data byte #16 should be 0x10 but was 0x0
#16 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
#48 0 0 0 0 0 0 0 0
64 bytes from 192.168.0.3: icmp_seq=2 ttl=64 time=1626615677081 ms
wrong data byte #16 should be 0x10 but was 0x0
#16 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
#48 0 0 0 0 0 0 0 0
64 bytes from 192.168.0.3: icmp_seq=3 ttl=64 time=1626615678105 ms
wrong data byte #16 should be 0x10 but was 0x0
#16 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
#48 0 0 0 0 0 0 0 0
Enter fullscreen mode Exit fullscreen mode

Turns out it's expecting our byte #16 to be 0x10, our #17 to be 0x11 and so on. So we add:

        for i in 16..64-8 {
            icmp_reply[i+8] = (0x10 + (i - 16)) as u8;
        }
Enter fullscreen mode Exit fullscreen mode

to our respond method and try ping again:

# ping 192.168.0.3

PING 192.168.0.3 (192.168.0.3) 56(84) bytes of data.
64 bytes from 192.168.0.3: icmp_seq=1 ttl=64 time=1626615769165 ms
64 bytes from 192.168.0.3: icmp_seq=2 ttl=64 time=1626615770169 ms
64 bytes from 192.168.0.3: icmp_seq=3 ttl=64 time=1626615771193 ms
64 bytes from 192.168.0.3: icmp_seq=4 ttl=64 time=1626615772217 ms
64 bytes from 192.168.0.3: icmp_seq=5 ttl=64 time=1626615773241 ms
Enter fullscreen mode Exit fullscreen mode

We get a working ping back and forth, only all the times are all wrong.

After a lot of head scratches, and double and triple checking the wiki page of IP, ICMP, etc, I finally found the solution on RFC-792 page:

The data received in the echo message must be returned in the echo reply message.

So instead of blindly adding 0x10, 0x11 to my packet, I copy the original values:

icmp_reply[8..64].clone_from_slice(&self.data[8..64]);
Enter fullscreen mode Exit fullscreen mode

and try once again:

# ping 192.168.0.3

PING 192.168.0.3 (192.168.0.3) 56(84) bytes of data.
64 bytes from 192.168.0.3: icmp_seq=1 ttl=64 time=0.162 ms
64 bytes from 192.168.0.3: icmp_seq=2 ttl=64 time=0.214 ms
64 bytes from 192.168.0.3: icmp_seq=3 ttl=64 time=0.174 ms
64 bytes from 192.168.0.3: icmp_seq=4 ttl=64 time=0.226 ms
Enter fullscreen mode Exit fullscreen mode

Everything seems to be working, except I don't have the correct checksum. Or any checksum for that mattter.

It's not showing up in ping but if you use Wireshark:

checksum-invalid

Following RFCs and Wikis only made me more confused when trying to write the checksum function. I had better luck writing one in Python and translating it to Rust after getting it right:

fn calculate_checksum(data: &mut [u8]) {
    let mut f = 0;
    let mut chk: u32 = 0;
    while f + 2 <= data.len() {
        chk += u16::from_le_bytes(data[f..f+2].try_into().unwrap()) as u32;

        f += 2;
    }

    while chk > 0xffff {
        chk = (chk & 0xffff) + (chk >> 2*8);
    }

    let mut chk = chk as u16;

    chk = !chk & 0xffff;

    // endianness
    //chk = chk >> 8 | ((chk & 0xff) << 8);

    data[3] = (chk >> 8) as u8;
    data[2] = (chk & 0xff) as u8;

}
Enter fullscreen mode Exit fullscreen mode

checksum-correct

And everything seems to be working, finally.

5. Github

The code is available on my Github xphoniex/icmp-rust.

Discussion (0)