Graham King

Solvitas perambulum

Raw sockets in Go: Link layer

Summary
I’m diving into the link layer of the Internet Protocol Suite in Go, where I'll show you how to read and craft IP headers, particularly for sending and receiving ICMP ping packets. I begin by creating a raw socket with `syscall.Socket`, using `AF_INET` for IPv4, `SOCK_RAW` to handle raw IP packets, and `IPPROTO_ICMP` to filter for ICMP only. I'll demonstrate how to read packets in a loop and then send ICMP ping requests by crafting my own IP and ICMP headers. The kernel handles some header fields, and I’ll explain how to calculate checksums and include timestamps for round-trip time measurements. Finally, I'll hint at working with Ethernet packets in the next post.

Continuing our dive into the Internet Protocol Suite from Go (See part 1 Raw sockets in Go: IP layer), we are going to the link layer, so we can see the IP headers. This will also allow us to craft our own IP headers, or handle address families outside IP. We’ll send ping packets (ICMP echo request) and watch the kernel’s response.

Receive

This isn’t wrapped in Go, so we need a syscall. Otherwise it’s very similar to the IP layer in part 1, and pretty similar to the C equivalent.

package main

import (
	"fmt"
	"os"
	"syscall"
)

func main() {
	fd, _ := syscall.Socket(syscall.AF_INET, syscall.SOCK_RAW, syscall.IPPROTO_ICMP)
	f := os.NewFile(uintptr(fd), fmt.Sprintf("fd %d", fd))

	for {
		buf := make([]byte, 1024)
		numRead, err := f.Read(buf)
		if err != nil {
			fmt.Println(err)
		}
		fmt.Printf("% X\n", buf[:numRead])
	}
}

On the first line of main we request the AF_INET family, meaning IPv4. We could ask for a different address family (AF_* constants) – here’s a list of address families. Most of the protocols in that list are rare (AF_IPX, AF_APPLETALK, etc). We’re in a IP world today.

Other useful address families:

  • AF_INET6 for IPv6.
  • AF_UNIX for unix domain sockets. It is used in net.DialUnix and net.ListenUnix. The POSIX name for AF_UNIX is AF_LOCAL, but Go largely sticks to AF_UNIX. They are equivalent.
  • An odd / interesting one is AF_NETLINK, which is for talking to the kernel. Read about it man 7 netlink or at Linux Journal. Docker has a netlink package.

The second parameter, SOCK_RAW is what makes this a raw socket, where we receive IP packets. SOCK_STREAM would give us TCP, SOCK_DGRAM would give UDP.

The third parameter filters packets so we only receive ICMP. You need a protocol here. As man 7 raw says “Receiving of all IP protocols via IPPROTO_RAW is not possible using raw sockets”. We’ll do that in the next post in this series, at the physical / device driver layer.

Build and run it as root (only root or CAP_NET_RAW can open raw sockets). In a different window ping localhost. You should see something like this:

45 00 00 3C EA FF 40 00 40 06 51 BA 7F 00 00 01 7F 00 00 01 ..

This is the IP Header. First byte 45 is 4 for the IP version (IPv4), and 5 for length of this header (5 32-bit words), and so on. This is just like the receive example in the previous post except that we also see the IP header.

Try replacing IPPROTO_ICMP in the Socket call with IPPROTO_TCP, and wget localhost. The first 20 bytes will be similar (the IP header), then you should see a TCP packet, and finally HTTP.

If you have UNIX Network Programming by Richard Stevens, you might be as confused as I was by section 28.4 which claims “received TCP packets are never passed to a raw socket.” That’s clearly not true. It’s a historic note related to BSD. man 7 raw says “Raw sockets may tap all IP protocols in Linux, even protocols like ICMP or TCP” but “This should not be relied upon in portable programs”.

That Stevens book is widely considered the best reference for unix network programming. It’s also very expensive. There’s a soft cover “International Student Edition” (meaning India) you can get at abebooks.com for much less.

Send

There’s an IP header type in the go.net sub-repository, with Marshall and ParseHeader methods. That file doesn’t depend on anything else in there, so grab it wget https://raw.githubusercontent.com/golang/net/master/ipv4/header.go, change the package statement to main, and we’ll play with it.

We’re going to send an ICMP ping packet, creating the IP header ourselves.

package main

import (
	"log"
	"net"
	"syscall"
)

func main() {
	var err error
	fd, _ := syscall.Socket(syscall.AF_INET, syscall.SOCK_RAW, syscall.IPPROTO_RAW)
	addr := syscall.SockaddrInet4{
		Port: 0,
		Addr: [4]byte{127, 0, 0, 1},
	}
	p := pkt()
	err = syscall.Sendto(fd, p, 0, &addr)
	if err != nil {
		log.Fatal("Sendto:", err)
	}
}

func pkt() []byte {
	h := Header{
		Version:  4,
		Len:      20,
		TotalLen: 20 + 10, // 20 bytes for IP, 10 for ICMP
		TTL:      64,
		Protocol: 1, // ICMP
		Dst:      net.IPv4(127, 0, 0, 1),
		// ID, Src and Checksum will be set for us by the kernel
	}

	icmp := []byte{
		8, // type: echo request
		0, // code: not used by echo request
		0, // checksum (16 bit), we fill in below
		0,
		0, // identifier (16 bit). zero allowed.
		0,
		0, // sequence number (16 bit). zero allowed.
		0,
		0xC0, // Optional data. ping puts time packet sent here
		0xDE,
	}
	cs := csum(icmp)
	icmp[2] = byte(cs)
	icmp[3] = byte(cs >> 8)

	out, err := h.Marshal()
	if err != nil {
		log.Fatal(err)
	}
	return append(out, icmp...)
}

func csum(b []byte) uint16 {
	var s uint32
	for i := 0; i < len(b); i += 2 {
		s += uint32(b[i+1])<<8 | uint32(b[i])
	}
	// add back the carry
	s = s>>16 + s&0xffff
	s = s + s>>16
	return uint16(^s)
}

Download that as send.go, and the header.go from net repo, and build it go build send.go header.go. Now run the receive in one window (remember to sudo), and send in another. Receive should output something like this:

45 00 00 1E 1C D4 00 00 40 01 60 09 7F 00 00 01 7F 00 00 01 08 00 37 21 00 00 00 00 C0 DE

45 00 00 1E 1C D5 00 00 40 01 60 08 7F 00 00 01 7F 00 00 01 00 00 3F 21 00 00 00 00 C0 DE

The first line is our ICMP Echo Request packet, with a few IP header fields filled in by the kernel. The second line is the kernel’s response! (yes I find that very exciting). Notice byte 21 is 0, instead of 8. That’s the only difference between an ICMP Echo message (ping) and an ICMP Echo Reply message (pong).

We use syscall.IPPROTO_RAW as the last argument to Socket because we will be including our own IP header. If you wanted to use syscall.IPPROTO_ICMP there, you also need to set the IP_HDRINCL socket option:

syscall.SetsockoptInt(fd, syscall.IPPROTO_IP, syscall.IP_HDRINCL, 1)

I think that last argument is only used so the kernel knows what protocol to put in the IP header’s Protocol field. If you’re building the header yourself, there’s not much point using anything except IPPROTO_RAW.

To build a basic ping client, combine send and receive into one program, put send timestamp in the data part (instead of 0xC0DE), and print the elapsed time when you get the Echo Reply. If you actually need to do ICMP (other than to play with raw sockets), the net repo has an icmp package.

In the final post I’ll write about the physical layer, and we’ll get to work with Ethernet packets.