Skip to content

A naive implementation of sharing a file over the DNS

License

Notifications You must be signed in to change notification settings

w-i-l/dns-tunneling

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 

Repository files navigation

DNS tunneling

Transfering a file across multiple DNS queries



About it

Before starting I would recommend checking my previous project Python DNS Server as it is a prerequisite for this project. I have modified some functions to allow us transfering a file, so understanding the first project would sure help you understand this one.

So, what is DNS tunneling? DNS tunneling is a technique used to bypass network security measures by encoding data of other programs or protocols in DNS queries and responses. This way, we can transfer data across a network without being detected. This is a very useful technique for penetration testing and for bypassing network restrictions.

Note: This project is for educational purposes only. Many modern OS and ISPs have security measures to prevent DNS tunneling. This project is not intended to be used for malicious purposes.

This project has a client and a server that will allow us to transfer a file across multiple DNS queries. The server will read a file and encode it in DNS queries, and the client will decode the queries and write the file back. The server will send the file in chunks, and the client will receive the chunks and write them to a file.

Note: All the code written below was developed and tested in a Unix environment. If you are using Windows, you may need to make some changes to the code.



How to use it

Start the server by running the following command (we need root permisions as the DNS operates on port 53):

sudo python3 server.py

After that you receive a prompt to enter the server IP address. Enter the IP address of the server and press enter. For using the loopback address enter 127.0.0.1. The server will start listening for DNS queries.

Now, start the client by running the following command:

sudo python3 client.py

And again you will receive a prompt to enter the server IP address. Enter the IP address of the server and press enter. The client will start sending DNS queries to the server.

You can still use the server as a normal DNS server, the steps for this are provided in the previous project.

For checking if the files have been transfered correctly, you can check the checksum of the files. You can use the md5 command for this:

[ "$(md5 -q files/test.txt)" = "$(md5 -q files/received.txt)" ] && echo "The files are identical." || echo "The files are different."


How it works

For normal DNS queries, the server work as a normal DNS server. The server will receive a DNS query, parse it, and send a response. The client will send a DNS query, receive a response, and parse it.

For detecting the tunneling, we will only check if the question domain is terminated with DNS_TUNNELING_IDENTIFIER, which for our testing is set to live.tunnel. So in order to receive the test.txt file the question domain should look like this:

test.txt.example.com.live.tunnel

where the example.com is the domain of the server.

Once the detection has occurred, the server will create empty packages, in which will set the header and the question fields.

The tricky part is at segmenting the file into chunks that can be sent in a DNS query. Below is an exmplanation that should ease the understanding of the process:

    The DNS payload is limited to 512 bytes so we need to split the file data into chunks
    As the TXT record is split into chunks of 255 bytes we will split the file data into chunks of 255 bytes
    So if the total length of the file data is 512 bytes we will have maximum 2 chunks

    A TXT chunk will have the following format:
    - 1 byte for the length of the chunk
    - n bytes of data

    As the index can be at most 255 we will use 1 byte for the index
    it will be the last byte of the chunk

The index from above comes from the necessity of knowing the order of the chunks. As the UDP protocol does not guarantee the order of the packets, we need to know the order of the chunks so we can reconstruct the file. The index will be the last byte of the chunk, and it will be used to order the chunks. Also this index will help the client reconstruct the file and also avoid a big problem.

For ensuring the deliver of the chucks in right order we will use a stop and wait tehnique. The server will send a chunk and wait for the client to send an acknowledgment. The acknowledgment will be a simple OK message. If the server does not receive the acknowledgment, it will resend the chunk as it has set a timeout.

Once the chuck has been received from the client, it will be kept in a dictionary with the index as the key. This way we solve the issue of receiving the same chuck multiple times, by overwriting the chuck with the same index. Once all the chucks have been received, the server will write the file back, reconstructing them with their indexes.

Note: we have a limit for the file size

We can only store 255 chunks of data as the index is a byte
So we will store the data in a list and then write it to a file

The maximum size of a TXT record can be up to ~480 bytes
So we can store up to 255 * 480 bytes of data = 122400 bytes = 122.4 KB


Tech specs

I think that a brief explanation about the segmentation of the file and the implementation of the stop and wait technique is needed as they are the core of the project.

File segmentation

packet_data = build_packet()
packet_length = len(packet_data)

The build_packet() function will create the header and questions fields for the response packet and will return the packet data. The packet_length will be the length of the packet data. As we will need to calculate the remaining size for the file data.

answear_bytes = b''
answear_bytes += b'\xc0\x0c'
answear_bytes += DNSQuestionType.TXT.value.to_bytes(2, 'big')
answear_bytes += DNSQuestionClass.IN.value.to_bytes(2, 'big')
answear_bytes += (1200).to_bytes(4, 'big') # TTL

answear_length = len(answear_bytes) + 2 # 2 bytes for the length of the rdata

The answear_bytes will be the bytes for the answer field of the response packet. The answear_length will be the length of the answer field. Here we just add the boilerplate data for the answer field, and we calculate the length of the answer field.

remaining_bytes = filesize - f.tell() # remaining bytes which can be read
additional_bytes_length = 1 if remaining_bytes <= 255 else 2 # the length bytes for txt data chunks 
file_data_legth = 512 - (packet_length + answear_length + additional_bytes_length + 1) # index byte

We calculate the position of the file cursor to determine how many bytes are left to be read. if there are more than 255 bytes left, we will need 2 chucks for the TXT data, meaning that we will need 2 bytes for the length of the TXT data. The file_data_legth will be the length of the file data that can be read and sent in a DNS query.

file_data = f.read(file_data_legth)
file_data_legth = len(file_data) + 1 # 1 byte for the index
file_data = file_data.encode('utf-8')

Here we just read and encode the file data and calculate the length of the file data. We add 1 byte for the index of the chunk.

# encoding the length of the rdata
answear_bytes += (file_data_legth + additional_bytes_length).to_bytes(2, 'big')

# encoding file data
max_length = min(255, file_data_legth)

answear_bytes += max_length.to_bytes(1, 'big') # length of the first txt chunk
answear_bytes += file_data[:max_length]

We encode the first chuck here. The max_length will be the length of the first chunk. The min function has the role to prevent adding more than 255 bytes in case of a bigger file_data_length.

# second chunk length
max_length = max(0, file_data_legth - 255)

# if there is a second chunk
if max_length > 0:
    answear_bytes += max_length.to_bytes(1, 'big') # length of the second txt chunk
    file_data = file_data[255:] # remove the first chunk
    answear_bytes += file_data[:max_length]

For checking id there is another chuck to be sent, we substract the 255 bytes from the file_data_legth which are the bytes that have been sent in the first chuck. If there are more than 0 bytes left, we will send another chuck. We encode the length of the chuck and the data of the chuck.

Stop and wait

In a stop and wait tehniques we can encounter 4 situations:

  1. Data loss - the data is lost and the server needs to resend the packet

    This is solved by setting a timeout for the client to acknowledge the packet. If the client does not acknowledge the packet in time, the server will resend the packet.

  2. Acknowledgment loss - the acknowledgment is lost and the server needs to resend the packet

    This is solved by the server when timeouts. If the server does not receive an acknowledgment in time, it will resend the packet.

  3. Packet duplication - the client receives the packet multiple times

    This is solved by the client by overwriting the chunk with the same index. The index will be the last byte of the chunk, and it will be used to order the chunks.

  4. Working scenario - the client receives the packet and sends an acknowledgment

    This is the normal scenario where the client receives the packet, writes the chunk to a file, and sends an acknowledgment to the server.


    1. # wait for the client to acknowledge the packet
      # if the client does not acknowledge the packet in 50ms resend it
      connection.settimeout(1) # 1 second
      
      try:`
          curent_date = datetime.strftime(datetime.now(), "%d-%m-%Y %H:%M:%S")
          print(f"[{curent_date}] Waiting for ack")
          while True:
              data, _ = connection.recvfrom(1024)
      
              curent_date = datetime.strftime(datetime.now(), "%d-%m-%Y %H:%M:%S")
              if data == bytes(OK_FLAG, 'utf-8'):
                  print(f"[{curent_date}] Received ack for {filename}")
                  break
              elif data == bytes(RESEND_FLAG, 'utf-8'):
                  print(f"[{curent_date}] Resending {filename}")
                  connection.sendto(packet, address)
      
      except socket.timeout:
          print(f"[{curent_date}] Resending {filename} - timeout")
          connection.sendto(packet, address)
      

      Notice the connection.settimeout(1) line. This is the timeout for the server to wait for an acknowledgment. If the server does not receive an acknowledgment in 1 second, it will resend the packet. The server will wait for an acknowledgment in a loop, and if it receives an acknowledgment, it will break the loop. If the server receives a RESEND_FLAG message, it will resend the packet.

      For the client, the acknowledgment is simple:

      # fake packet loss
      if random.randint(0, 1) < 0.5:
          # acknowledge the received data
          s.sendto(bytes(RESEND_FLAG, 'utf-8'), (DNS_SERVER_IP, DNS_PORT))
      else:
          # acknowledge the received data
          s.sendto(bytes(OK_FLAG, 'utf-8'), (DNS_SERVER_IP, DNS_PORT))
      

      The random packet loss is just for testing purposes. The client will send an acknowledgment to the server. If the acknowledgment is OK_FLAG, the server will continue sending packets. If the acknowledgment is RESEND_FLAG, the server will resend the packet.

      The testing file content is from Metamorphosis by Franz Kafka, which can be found here.



      Further reading

About

A naive implementation of sharing a file over the DNS

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages