writing your own traceroute is surprisingly easy

Until you know how they work, programs related to networking always kind of feel like magic, at least to me. However, I have written enough code to know that there is no such thing as magic, just clever ideas that are properly implemented.

Some things I work on at $work touch a bit on the side of networking, and that drove me to be interested in the ICMP protocol, particularly traceroute.

ICMP, a short primer

ICMP is strictly a diagnostic and error-reporting protocol. It is not meant to carry application data; instead, it should instead carry status updates about the network itself.

In practice, an ICMP packet has a field that can embed data, which makes it a potential channel for malware. Some well-known projects allow for exfiltration over ICMP.

Here is an exemple of an ICMP tunnel, for instance: https://github.com/DhavalKapil/icmptunnel

The fields you need to know

  1. Type (8 bits) is the headline. It tells the receiver what this packet is. For instance:

    • 8: Echo Request (Ping)
    • 0: Echo Reply (Pong)
    • 11: Time Exceeded (The one Traceroute loves)
    • 3: Destination Unreachable
  2. Code (8 bits) provides context to the Type. For instance:

    • If Type is 3 (Unreachable), Code 3 means “Port Unreachable.”
    • If Type is 11 (Time Exceeded), Code 0 means “TTL expired in transit.”
  3. Rest of Header (32 bits - Variable) changes shape based on the Type. For Traceroute/Ping: This usually contains an Identifier and a Sequence Number. They allow you to match a returning reply to the specific probe you sent out (e.g., “This is the reply to packet #5”).

  4. Data / Payload (Variable) changes entirely based on the context:

    • When Sending (Echo Request): You can put whatever you want here. By default, many Pings put a timestamp or random padding here. If you put a custom string here (like “Hello World”), the router should echo it back to you.
    • When Receiving Errors (Time Exceeded): This is where the “magic” happens for traceroute. When a router kills your packet and sends a Type 11 error, it copies the header and first 8 bytes of your original packet into this data field.

The core mechanism behind traceroute

The core mechanism of traceroute exploits a specific ICMP error called Time Exceeded (Type 11). Every IP packet has a "Time to Live" (TTL) field, which is meant to prevent packets from circulating endlessly.

Whenever a packet passes through a router, the TTL field decreases by 1. So, if we send a packet with a TTL of 2, the packet will pass two routers, before sending back a “Time Exceeded” message with its IP address. Then, we can increase by 1, until we get the “Echo Reply”. Along the way, we collected every IP that we could get.

However, something I intentionally did not disclose until now is that this “Time Exceeded” thing is optional behaviour in the spec, and you’re not actually guaranteed to have a response.

If you want to read more about that, here is a better blog that explains in detail why traceroute is a hack, and should not be used as a “serious” debugging tool: https://gekk.info/articles/traceroute.htm

Enough of this, just show me the code !

In my case, I only wanted to experiment, and see if I could write my own version. I chose Golang as the language for this projects because its standard library is reasonnably well written, and it produces compiled binaries that don’t require any dependency.

You can get a simple implementation in less than a 100 LoC:

package main

import (
	"fmt"
	"net"
	"os"
	"time"

	"golang.org/x/net/icmp"
	"golang.org/x/net/ipv4"
)

const (
	maxHops    = 30
	timeout    = 3 * time.Second
	localAddr  = "0.0.0.0"
	protocolICMP = 1 // ICMP for IPv4
)

func main() {
	if len(os.Args) != 2 {
		fmt.Printf("Usage: %s <hostname>\n", os.Args[0])
		os.Exit(1)
	}

	destination := os.Args[1]

	dstAddr, err := net.ResolveIPAddr("ip4", destination)
	if err != nil {
		fmt.Printf("Error resolving %s: %v\n", destination, err)
		os.Exit(1)
	}

	fmt.Printf("Traceroute to %s (%s), %d hops max:\n", destination, dstAddr.String(), maxHops)

	c, err := icmp.ListenPacket("ip4:icmp", localAddr)
	if err != nil {
		fmt.Printf("Error listening: %v\n(Note: You likely need sudo/admin privileges)\n", err)
		os.Exit(1)
	}
	defer c.Close()

	pc := c.IPv4PacketConn()
	for ttl := 1; ttl <= maxHops; ttl++ {
		start := time.Now()

		if err := pc.SetTTL(ttl); err != nil {
			fmt.Printf("Error setting TTL: %v\n", err)
			return
		}

		wm := icmp.Message{
			Type: ipv4.ICMPTypeEcho, Code: 0,
			Body: &icmp.Echo{
				ID: os.Getpid() & 0xffff, Seq: ttl,
				Data: []byte("djnn.sh"),
			},
		}

		wb, err := wm.Marshal(nil)
		if err != nil {
			fmt.Println(err)
			return
		}

		if _, err := c.WriteTo(wb, dstAddr); err != nil {
			fmt.Printf("Error sending: %v\n", err)
			return
		}

		if err := c.SetReadDeadline(time.Now().Add(timeout)); err != nil {
			fmt.Println(err)
			return
		}

		rb := make([]byte, 1500)
		n, peer, err := c.ReadFrom(rb)
		elapsed := time.Since(start)

		if err != nil {
			fmt.Printf("%d\t*\n", ttl)
			continue
		}

		rm, err := icmp.ParseMessage(protocolICMP, rb[:n])
		if err != nil {
			fmt.Printf("%d\tError parsing: %v\n", ttl, err)
			continue
		}

		fmt.Printf("%d\t%v\t%v\n", ttl, peer, elapsed)
		if rm.Type == ipv4.ICMPTypeEchoReply {
			fmt.Println("\nTrace complete.")
			break
		}
	}
}

Let’s quickly go over this code, with the traceroute technique in mind.

Here, we make a packet listener, to be able to receive a response:

c, err := icmp.ListenPacket("ip4:icmp", localAddr)
if err != nil {
    fmt.Printf("Error listening: %v\n(Note: You likely need sudo/admin privileges)\n", err)
    os.Exit(1)
}
defer c.Close()

as the error message explains, you might need sudo privileges for this or you can set capabilities to your binary: https://unix.stackexchange.com/questions/447886/what-does-cap-net-raw-do

We can set the TTL field of our ICMP packet like so:

if err := pc.SetTTL(ttl); err != nil {
    fmt.Printf("Error setting TTL: %v\n", err)
    return
}

We can just wrap this in a loop, to gradually increase the TTL field. Afterwards, we simply create and serialize the packet:

wm := icmp.Message{
    Type: ipv4.ICMPTypeEcho, Code: 0,
    Body: &icmp.Echo{
        ID: os.Getpid() & 0xffff, Seq: ttl,
        Data: []byte("put anything here"),
    },
}

wb, err := wm.Marshal(nil)
if err != nil {
    fmt.Println(err)
    return
}

We send it, read from the reply and try to read the IP address. You may notice we allocate 1500 bytes for this. Why 1500 ? This is typically the recommended MTU size.

rb := make([]byte, 1500)
n, peer, err := c.ReadFrom(rb)
elapsed := time.Since(start)

if err != nil {
    fmt.Printf("%d\t*\n", ttl)
    continue
}

rm, err := icmp.ParseMessage(protocolICMP, rb[:n])
if err != nil {
    fmt.Printf("%d\tError parsing: %v\n", ttl, err)
    continue
}

If we receive an ICMP “Echo Reply” message, it means we are done traceroute-ing, so we can just exit the loop.

releasing ttr

If you want to simply test it out, I’ve cleaned up this code a bit (and added support for IPv6, for instance).

You can just run:

$ go install github.com/djnnvx/[email protected]
$ ttr --help
$ sudo ttr djnn.sh

The code itself is both available on github and on my personal mirror. If you have feature requests, or anything like that, please let me know!

See you next time :)~

djnn.sh

offensive security & software engineering


networking and icmp things

2025-12-14