Abusing ping timestamps
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
) sostruct timeval
won’t fit,iputils
ping
would not display round-trip time at all.