1

Overview

I have been working on a side project which is essentially a network troubleshooting tool. My intent is to deepen my understanding of networking fundamentals and get comfortable using troubleshooting tools offerred by the OS.

It’s a CLI app, that would take the hostname and try to diagnose issues(if any). The plan was to implement ping and traceroute first and progressively implement other tools depending on my level of comfort.

However, my ping implmentation is not accurate in the sense that, the IPv4 packets are malformed. That’s what wireshark had to say.

1   0.000000    192.168.0.100   142.250.195.132 ICMP    300 Unknown ICMP (obsolete or malformed?)

enter image description here

Code

Here’s how I have implemented ping

package ping

import (
    "encoding/json"
    "net"

    "github.com/pkg/errors"
)

var (
    IcmpProtocolNumber uint8 = 1
    IPv4Version        uint8 = 4
    IPv4IHL            uint8 = 5
    ICMPHeaderType     uint8 = 8
    ICMPHeaderSubtype  uint8 = 0
)

type NativePinger struct {
    SourceIP string
    DestIP   string
}

type ICMPHeader struct {
    Type     uint8
    Code     uint8
    Checksum uint16
}

type ICMPPacket struct {
    Header  ICMPHeader
    Payload interface{}
}

type IPv4Header struct {
    SourceIP       string
    DestinationIP  string
    Length         uint16
    Identification uint16
    FlagsAndOffset uint16
    Checksum       uint16
    VersionIHL     uint8
    DSCPAndECN     uint8
    TTL            uint8
    Protocol       uint8
}

type IPv4Packet struct {
    Header  IPv4Header
    Payload *ICMPPacket
}

func (p *NativePinger) createIPv4Packet() (*IPv4Packet, error) {
    versionIHL := (IPv4Version << 4) | IPv4IHL

    icmpPacket := &ICMPPacket{
        Header: ICMPHeader{
            Type: ICMPHeaderType,
            Code: ICMPHeaderSubtype,
        },
    }
    ipv4Packet := &IPv4Packet{
        Header: IPv4Header{
            VersionIHL:     versionIHL,
            DSCPAndECN:     0,
            Identification: 0,
            FlagsAndOffset: 0,
            TTL:            64,
            Protocol:       IcmpProtocolNumber,
            SourceIP:       p.SourceIP,
            DestinationIP:  p.DestIP,
        },
        Payload: icmpPacket,
    }
    ipv4Packet.Header.Length = 40

    bytes, err := json.Marshal(icmpPacket)
    if err != nil {
        return nil, errors.Wrapf(err, "error converting ICMP packet to bytes")
    }

    icmpPacket.Header.Checksum = calculateChecksum(bytes)

    bytes, err = json.Marshal(ipv4Packet)
    if err != nil {
        return nil, errors.Wrapf(err, "error converting IPv4 packet to bytes")
    }

    ipv4Packet.Header.Checksum = calculateChecksum(bytes)

    return ipv4Packet, nil
}

func calculateChecksum(data []byte) uint16 {
    sum := uint32(0)

    // creating 16 bit words
    for i := 0; i < len(data)-1; i++ {
        word := uint32(data[i])<<8 | uint32(data[i+1])
        sum += word
    }
    if len(data)%2 == 1 {
        sum += uint32(data[len(data)-1])
    }

    // adding carry bits with lower 16 bits
    for (sum >> 16) > 0 {
        sum = (sum & 0xffff) + (sum >> 16)
    }

    // taking one's compliment
    checksum := ^sum
    return uint16(checksum)
}

func (p *NativePinger) ResolveAddress(dest string) error {
    ips, err := net.LookupIP(dest)
    if err != nil {
        return errors.Wrapf(err, "error resolving address of remote host")
    }

    for _, ip := range ips {
        if ipv4 := ip.To4(); ipv4 != nil {
            p.DestIP = ipv4.String()
        }
    }

    // The destination address does not need to exist as unlike tcp, udp does not require a handshake.
    // The goal here is to retrieve the outbound IP. Source: https://stackoverflow.com/a/37382208/3728336
    //
    conn, err := net.Dial("udp", "8.8.8.8:80")
    if err != nil {
        return errors.Wrapf(err, "error resolving outbound ip address of local machine")
    }
    defer conn.Close()

    p.SourceIP = conn.LocalAddr().(*net.UDPAddr).IP.String()

    return nil
}

func (p *NativePinger) Ping(host string) error {
    if err := p.ResolveAddress(host); err != nil {
        return errors.Wrapf(err, "error resolving source/destination addresses")
    }

    packet, err := p.createIPv4Packet()
    if err != nil {
        return errors.Wrapf(err, "error creating IPv4Packet")
    }

    conn, err := net.Dial("ip4:icmp", packet.Header.DestinationIP)
    if err != nil {
        return errors.Wrapf(err, "error eshtablishing connection with %s", host)
    }
    defer conn.Close()

    bytes, err := json.Marshal(packet)
    if err != nil {
        return errors.Wrapf(err, "error converting IPv4 packet into bytes")
    }

    _, err = conn.Write(bytes)
    if err != nil {
        return errors.Wrapf(err, "error sending ICMP echo request")
    }

    buff := make([]byte, 2048)
    _, err = conn.Read(buff) // The implementation doesn't proceed beyond this point
    if err != nil {
        return errors.Wrapf(err, "error receiving ICMP echo response")
    }

    return nil
}

Upon introspection

I'm uncertain whether the packet's malformation is due to a single reason or multiple reasons. I feel the problem lies in either(or both?) of these two places:

  1. Incorrect calculation of header length I have manually calculated the length to be 40 bytes (wordsize = 4 bytes). Wrote the struct fields in an order that would prevent a maligned struct. I referred this source to know the sizes of various types.
// 1 word (4 bytes)
type ICMPHeader struct {
    Type     uint8  // 8 bit
    Code     uint8  // 8 bit
    Checksum uint16 // 16 bit
}

// 3 words (3*4 = 12 bytes)
type ICMPPacket struct {
    Header  ICMPHeader  // 1 word
    Payload interface{} // 2 words
}

// 7 words (7*4 = 28 bytes)
type IPv4Header struct {
    // Below group takes 4 words (each string takes 2 words)
    SourceIP      string
    DestinationIP string

    // Following group takes 2 words (each 16 bits)
    Length         uint16
    Identification uint16
    FlagsAndOffset uint16
    Checksum       uint16

    // Below group takes 1 word (each takes 8 bits)
    VersionIHL uint8
    DSCPAndECN uint8
    TTL        uint8
    Protocol   uint8
}

// 10 words (40 bytes)
type IPv4Packet struct {
    Header  IPv4Header // 7 words as calculated above
    Payload ICMPPacket // 3 words as calculated above
}
  1. Incorrect checksum calculation I implemented the internet checksum algorithm. Please let me know if that's not what I was supposed to do here.

There are missing parts in the implementation such as configuring count, assigning sequence numbers to packets, etc, but before that the basic implementation needs to be fixed i.e. receiving a response for the ICMP ECHO packet. It would be great to know where I'm making an error.

Thanks!

Update 24th Aug '23

I've updated the code considering the suggestions I got in the comments i.e. fixing byte ordering and using raw bytes for source, dest addresses. However, this alone does not solve the problem, the packet is still malformed so there must be other things going wrong.

Swagnik Dutta
  • 129
  • 1
  • 15
  • 1
    I haven't examined everything in detail, but I see a few problems: the source and destination addresses in the IP header should be 4-byte values, not strings; you're sending a json object on the connection instead of the raw bytes; and you will need to make sure that the multi-byte values you send are in network byte order, which is most likely the reverse of your host's native byte order. Have you looked at https://pkg.go.dev/golang.org/x/net@v0.14.0/icmp and other packages in `x/net`? I haven't used them myself, but it looks like they will handle a lot of this for you. – Andy Schweig Aug 21 '23 at 15:11
  • I addressed your points in my updated code. Also, I'm curious to know why you wrote I was sending json object over the conn? I thought I was sending raw bytes (in my initial code) as the Marshal function `func Marshal(v any) ([]byte, error)` returns a byte slice – Swagnik Dutta Aug 24 '23 at 10:22
  • I did look at icmp and related packages but chose to try building on my own as it's part of a self-learning exercise. – Swagnik Dutta Aug 24 '23 at 10:27
  • 1
    `json.Marshal` produces JSON. Yes, It returns the JSON object in a byte slice, but that byte slice contains ASCII text. You can see it in the wireshark output. The first 14 bytes are the ethernet header, then you have the IP header, which is 20 bytes, then the bytes you sent, which are the ASCII characters making up the JSON object. JSON marshaling has nothing to do with producing the raw bytes of the IP or ICMP headers. https://pkg.go.dev/encoding/binary has functions that read and write values as bytes. Use `BigEndian` to produce network byte order. – Andy Schweig Aug 24 '23 at 18:40

1 Answers1

0

I got this to work at last. I should talk about a couple of issues with the code.

Serialization issues

As rightly pointed out by Andy, I was sending JSON object instead of sending raw bytes in network byte order. This was fixed using binary.Write(buf, binary.BigEndian, field)

However, since this method works only for fixed-size values, I had to do this for each struct field, making the code repetitive and somewhat ugly.

Struct optimization and serialization are separate concerns.

I knew about this practice of fitting the Version and IHL field together to optimize memory which is why I had this single field VersionIHL in my struct. But while serializing, the field values (4 and 5 in this case) were to be serialized individually which I didn't do. Instead, I was converting the entire VersionIHL field's value to bytes.

As a result, I found myself sending an unexpected octet 69 in my byte stream which came from combining 4 and 5 together 0100 0101.

Incomplete ICMP packet

The ICMP struct I had didn't include the identifier and sequence number fields. The information provided in the ICMP datagram header section on Wikipedia felt a bit generic. However, I found the details on the RFC pages(page 14) to be much more insightful.

This feels quite peculiar, given the significance of the sequence number for the ping utility. During the implementation process, I frequently found myself wondering about the appropriate placement of the sequence number in the code. It wasn't until I stumbled upon the RFC pages that I gained clarity on when and where to incorporate the sequence number.

For anyone who might be interested, here's the functional code I've put together.

Swagnik Dutta
  • 129
  • 1
  • 15