30
II. Implementing ICMP in Rust
Make sure to checkout previous part where we talk about IP and ICMP layouts if you haven't already: I. Implementing ICMP 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);
}
}
}
}
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;
}
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);
}
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)
}
}
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
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
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;
}
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
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]);
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
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:
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;
}
And everything seems to be working, finally.
The code is available on my Github xphoniex/icmp-rust.
30