The ping command sends out Echo Request message to the specified host, receives Echo Reply message, calculates round-trip time taken for the reply to arrive, and displays it to the user.

$ ping -c 4 2001:4860:4860::8844
PING 2001:4860:4860::8844(2001:4860:4860::8844) 56 data bytes
64 bytes from 2001:4860:4860::8844: icmp_seq=1 ttl=60 time=42.7 ms
64 bytes from 2001:4860:4860::8844: icmp_seq=2 ttl=60 time=34.9 ms
64 bytes from 2001:4860:4860::8844: icmp_seq=3 ttl=60 time=31.9 ms
64 bytes from 2001:4860:4860::8844: icmp_seq=4 ttl=60 time=28.5 ms

--- 2001:4860:4860::8844 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3004ms
rtt min/avg/max/mdev = 28.479/34.493/42.666/5.242 ms

The most obvious way to calculate the RTT would be to remember the timestamp of an outgoing packet, and calculate the difference when the reply arrives. That would require using some local state.

However, some ping implementations don’t use local state. Instead, they send a timestamp inside the packet, cleverly using the fact that the remote host must return the data payload unchanged.

This implementation detail can be abused to make ping report complete gibberish instead of proper RTT, or even display something resembling sort of an ASCII art.

Check it out yourself on any nearby Linux machine: ping -i 0.2 bushwhackers.ru:

Let’s go over technical details how that can be accomplished.

ping round-trip time calculation

ICMP echo packet, beside various headers, also includes a payload. The payload must be returned back unchanged, but otherwise it has no special meaning.

Some ping implementations (that includes common in the Linux world iputils) exploit this for RTT calculation. Instead of remembering the timestamp of every outgoing packet, ping stores the timestamp inside the payload. When reply is received, the timestamp is loaded from the payload, using which RTT can be easily calculated. No local state required.

For example, iputils ping simply stores the struct timeval at the beginning of the payload (https://github.com/iputils/iputils/blob/309f28518adbe161b5dfd6717f91a3fbe3ab8eab/ping/ping.c#L1433-L1435).

// (somewhere from sys/time.h)
struct timeval {
    time_t      tv_sec;     /* seconds */
    suseconds_t tv_usec;    /* microseconds */
};

struct timeval tmp_tv;
gettimeofday(&tmp_tv, NULL);
memcpy(icp + 1, &tmp_tv, sizeof(tmp_tv));

This is how echo request packet looks in Wireshark:

Frame 8: 118 bytes on wire (944 bits), 118 bytes captured (944 bits) on interface wlp3s0, id 0
Ethernet II, Src: Chongqin_XX:XX:XX (b4:b5:b6:XX:XX:XX), Dst: Routerbo_XX:XX:XX (cc:2d:e0:XX:XX:XX)
Internet Protocol Version 6, Src: XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX, Dst: 2a01:4f8:1c0c:410b:0:2205:1998:849
Internet Control Message Protocol v6
    Type: Echo (ping) request (128)
    Code: 0
    Checksum: 0xe41f [correct]
    [Checksum Status: Good]
    Identifier: 0x0013
    Sequence: 1
    [Response In: 9]
    Data (56 bytes)
        Data: 9ed76d62000000001d9e060000000000101112131415161718191a1b1c1d1e1f20212223…

        [Length: 56]
Note
If you looked at ICMP (IPv4) packet instead, you’ll see that Wireshark helpfully suggests that ICMP has separate timestamp and data field. This is somewhat misleading, as it’s just an implementation detail of some common ping implementations.

In the example above, you can see the timestamp encoded as 9ed76d62000000001d9e060000000000:

>>> sec, usec = struct.unpack("QQ", binascii.unhexlify("9ed76d62000000001d9e060000000000"))
>>> time.ctime(sec + usec*1e-6)
'Sun May  1 03:43:10 2022'

Now, if we mess with the ping payload instead of echoing it back unchanged, ping can be fooled into miscalculating RTT.

Not all ping s are implemented this way, however. For example, Windows and FreeBSD ping don’t use this timestamp trick.

Messing with ICMP ping payload

What fun things can we actually do with it?

iputils stores the timestamp in native OS representation, so it can be either little- or big-endian, and numbers can be either 32- or 64-bit wide. There’s also mac OS ping that uses big-endian encoding even on little-endian processors, but otherwise it behaves the same.

Autodetecting the encoding is easy: tv_sec should be close to the current UNIX epoch time, and tv_usec should be between 0 and 999999. Checking only the latter turned out to be sufficient, though.

Since ping simply subtracts the timestamp received from the current timestamp, we can subtract arbitrary value from it to make the calculated RTT accordingly longer.

This is what happens when I subtract 123456789 seconds:

$ ping -c1 ::1
PING ::1(::1) 56 data bytes
64 bytes from ::1: icmp_seq=1 ttl=64 time=123456789000 ms

--- ::1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 123456789000.095/123456789000.095/123456789000.095/0.000 ms

If I added 123456789 seconds instead, so the packet would appear to come from the future, ping would become confused, and would ignore the timestamp altogether:

$ ping -c1 ::1
ping: Warning: time of day goes back (-123456788999921us), taking countermeasures
ping: Warning: time of day goes back (-123456788999854us), taking countermeasures
PING ::1(::1) 56 data bytes
64 bytes from ::1: icmp_seq=1 ttl=64 time=0.000 ms

--- ::1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.000/0.000/0.000/0.000 ms

Anyway, just subtracting a constant value is not fun yet.

First, it would be cool if every line in ping output looked differently. ICMP Echo Request packets include a serial sequence number, which starts with 1 for every ping invocation. We can look at this number, and identify the current "line" statelessly (ironic, isn’t it?).

Numbers encoded in 64 bits are enough to draw some letters (visible if you squint hard enough). 32 bits unfortunately are not enough, though. I chose to draw 1-bit images using digits 7 and 0 as "white" and "black" correspondingly (this pair seems to provide the better contrast in my font).

See the letter "B" below?

icmp_seq=1 ttl=52 time=700000000777041 ms
icmp_seq=1 ttl=52 time=700777770077059 ms (DUP!)
icmp_seq=1 ttl=52 time=700777770077042 ms (DUP!)
icmp_seq=1 ttl=52 time=700000000777039 ms (DUP!)
icmp_seq=1 ttl=52 time=700777770077061 ms (DUP!)
icmp_seq=1 ttl=52 time=700777770077161 ms (DUP!)
icmp_seq=1 ttl=52 time=700000000777042 ms (DUP!)
icmp_seq=1 ttl=52 time=53.8 ms (DUP!)

I also decided to overwrite the sequence number of replies to 1 because the output would otherwise misalign once sequence number becomes greater or equal to 10. That causes (DUP!) warning to be shown, but it’s harmless, and it’s shown at the end of the line.

Implementation

This idea is hopefully easy enough to grasp so far.

But how to actually edit ping packets on a Linux system? ICMP echo is implemented directly in the kernel, so its behaviour can’t be easily changed.

But there’s still a lot of ways to accomplish that in Linux.

One could, for example, drop all ICMP packets with iptables/nftables early, and reimplement replying to Echo Request packets completely in userspace with raw sockets.

Or maybe write a kernel module to skip the default handler.

But I decided to go with a simpler solution using NFQUEUE.

NFQUEUE is a special action in iptables/nftables that sends the packet to userspace for inspection, where it could be rejected, allowed to pass and possibly modified. I added a rule that would intercept all outgoing ICMP replies, and send them to userspace where their contents would be modified:

iptables -A OUTPUT --protocol icmp --icmp-type echo-reply -j NFQUEUE --queue-num 5256 --queue-bypass
ip6tables -A OUTPUT --protocol icmpv6 --icmpv6-type echo-reply -j NFQUEUE --queue-num 5256 --queue-bypass

I initially wrote the client program in C, but at some point I rewrote it in Rust, just for fun.

This is the gist how NFQUEUE API can be used from userspace:

let mut queue = nfq::Queue::open()?;
queue.bind(0x1337)?;
loop {
    let mut msg = queue.recv()?;

    // simply retrieving mut reference to the payload
    // will make the library write the payload back to kernel
    let payload = msg.get_payload_mut();

    modify_payload(payload);

    queue.verdict(msg)?;
}

Modifying the payload is relatively straightforward: parse IPv4 or IPv6 header first, parse ICMP header to verify it’s Echo Reply and extract its sequence number, modify the payload buffer, and recalculate the checksum.

The code for detecting the encoding is more complicated, but the idea is still simple: just try different encodings until you get microseconds in 0..=999999 range. The most common variants that we handle would be little-endian 64-bit (iputils, basically Linux) and big-endian 64-bit (mac OS).

I have published the full source code of this program running on bushwhackers.ru here: https://github.com/WGH-/ping-adjuster

Notes

  • If you reduce the size of payload with -s command line switch (ping -s15) so struct timeval won’t fit, iputils ping would not display round-trip time at all.