Data Exfiltration through DNS with Rust

Modern cyber-criminal gangs, deploy multiple state-of-the-art techniques to retrieve information from a compromised or breached company.

Modern cyber-criminal gangs, deploy multiple state-of-the-art techniques to retrieve information from a compromised or breached company.

This is because most companies isolate, in varying degrees, their internal network from the public internet, blocking certain file transfer protocols, or prevent access to online file storage, such as Gdrive or Dropbox. Some internal networks may even be air-gaped without any direct internet access. But, what would happen if an attacker could execute domain name resolution, even if all other protocols were blocked? (HTTP, HTTPS, Direct TCP, SMTP, SMB) This leads us to a technique mapped by Mitre as ‘Exfiltration over Alternative Protocol‘, in which we will simulate the adversary behaviour of communications over domain name system (DNS) with the intent of exfiltrating compromised data. This technique has also the advantage of not raising too many alarms, our DNS traffic will seamlessly blend in with the remaining network traffic and will most likely remain undetected unless the firewalls or other network security devices are configured to inspect DNS packets and detect C2 traffic.

In order to make the most of my options, I like to understand, when there’s time for it, how everything works under the hood.

So let’s avoid pre-built DNS servers, and make our own from scratch, we don’t even need to fully implement the DNS protocol, since we are only interested in receiving data from an external agent.

Choosing a programming language

First let’s choose a programming language with which to build everything, for this, I was split between Rust and Nim, python would be easier, yes, but then we would have the runtime to worry about and the overall speed problems.

After trying both languages I eventually decided to go with Rust, the final code came out surprisingly clean, and byte-wise it was easier to work with than Nim. As this code is meant to be more of a POC, it’ll be bare-bones, multiple network-related optimisations could be made to handle heavy loads, but that’s not our scope nor is the server intended to be under heavy usage.

We’ve chosen our language, now how do we use the DNS protocol to do our biding?

DNS Protocol

Back to basics, knowing how DNS works will help us a lot.

Since names are easier to remember than random numeric sequences, the DNS protocol became one of the backbones of the internet, allowing end-users to type in a website name in the browser window, such as www.google.com, have their computer transparently translate it to an IP and them seamlessly load the requested webpage.

According to RFC 1035, all communications inside of the domain protocol are carried in a single format, which is designated as a message. The top level format is divided into five sections, some of which may be empty in some instances:

rfc-1035-diagram-1


So either we have a DNS request or response, both will share the same structure. Now where would we store our ill-gained data? As a rule of thumb I tend to stay away from the headers, but for the sake of discussion let’s have a look at it:

rfc-1035-diagram-2


In total we have 6 fields, of 16 bits each, in total we would have 96 bits in total, or 12 bytes, which is not an awful lot of space. What if we looked at the question section?

rfc-1035-diagram-3


So the QNAME section seems pretty spacious. It will hold a domain name represented as a sequence of labels, where each label will consist of a length octet followed by that number of octets, so roughly a query for subdomain.balwurk.com, would have as the first label:

|9|s|u|b|d|o|m|a|i|n|

But how much can we actually store in a single label? The answer is 63 octets or 63 bytes, meaning a subdomain with a maximum length of 63 characters. It’s not perfect, but we can work with it. There is also another limitation which is related to the character encoding of the labels, preferably our “host name” should assume a LDH syntaxt, in which the labels start with a letter, end with a letter or digit, and have as interior characters only letters, digits and hyphens.

So we will need printable ASCII characters in our domain names. Despite not having an actual technical limitation to using non-ASCII characters, we should try to comply with the protocol and not stand out. This said, there’s actually an easy solution, by applying base64 encoding, sure we bloat the data size a bit due to the compression ratio, but this way we obtain URL-safe representations of our data segments, and it becomes actually easier to transport data with white-spaces and newlines.

Implementing our Mock DNS Server

So how will we transport data? DNS can be implemented either using UDP or TCP, and since we are trying to blend our traffic with the rest of the network, it would only make sense to choose an UDP implementation.

By this point we have everything we need, let’s begin with the UDP server block:

Rustuse std::net::UdpSocket;fnmain(){let socket:UdpSocket=UdpSocket::bind("127.0.0.1:53").unwrap();letmut buffer:[u8;100]=[0u8;100];loop{let(_amt,_src)= socket.recv_from(&mut buffer).unwrap();println!("{:?}", buffer); buffer.fill(0u8);}}


Since we want to establish an exfiltration channel and not a full fledge C2 communication highway, let’s focus only on receiving and parsing DNS requests.

With the first code block, we opened a socket on the localhost to listen to requests on port 53, in which we are continuously waiting to receive data and write it into a 100 bytes length buffer.

Now we need to actually parse the DNS packets into information we can use and read, so let’s write a function and call it deconstruct_packet:

Rustfndeconstruct_packet(buffer:&[u8])->Vec<u8>{letmut query:Vec<u8>=Vec::<u8>::new();letmut base_offset:usize=0;letmut label_length:usize= buffer[0]asusize;letmut skip_rest:bool=false; buffer.iter().enumerate().for_each(|pos, value|{if!skip_rest {if pos ==(base_offset + label_length +1){if*value ==0x00{ skip_rest=true;} label_length =*value asusize; base_offset = pos; query.push(0x2eu8);//ascii hex of '.'}else{ query.push(*value);}}}) query}


Probably not the most idiomatic, but gets the job done. Using this function we can section out the QNAME section from the DNS query and parse all the labels into the original hostname request. This function isn’t really required, but will help us with debugging and make the DNS domain names from the queries more readable.

Since there’s no need to write a base64 encoding/decoding algorithm from scratch , let’s use a crate published by the community. Personally I chose https://crates.io/crates/base64.

We know the header is 12 bytes long, so we can ignore the first 12 bytes in the buffer and cut right into the length octet for the first label.

Rust[...]use base64::{Engine as _, engine::general_purpose};fnmain(){let b64engine =general_purpose::STANDARD;loop{[...]//inside the server looplet data:&[u8]=&buffer[12..];let query:Vec<u8>=deconstruct_packet(data);[...] //we start the slice at 1, since we don't need the length byte anymorelet label_section:&u8=&query[1..query[0]asusize+1];[...]match b64engine.decode(label_section){Ok(resulting_section)=>matchstr::from_utf8(&resulting_section){Ok(message)=>println!("{}", message),Err(_error)=>println!("Non ascii bytes in the encoded query"),},Err(_error)=>println!("Invalid base64 string")} buffer.fill(0u8);}}


We needed some error handling in the last part since the server will most likely receive non-base64 encoded domain names from other sources, and you might eventually receive non-ASCII data bytes from your deployed exfiltration agent, which is fine, but for this POC I wanted to print and read all the data we received. An alternative would be to output the bytestream into an external file and then figure out the file type and how to correctly read the information from the file header section.

Deploying our Server

Now let’s spin up our server and test it out. The fastest way for us to do this is to declare our local machine as a DNS server and resolve our own queries with our custom server. In a Linux distribution you can add yourself as a name server in order to redirect all DNS requests to localhost. Just access /etc/resolv.conf and add nameserver 127.0.0.1 before the address of your actual DNS resolver.

Let’s see if it works:
MicrosoftTeams-image-1


Success!! we managed to launch a DNS query with base64 encoded data which was then received by our server and successfully decoded.

Repository:
Source code

References:

RFC 1035 – Domain names – implementa`tion and specification (ietf.org)
RFC 3467: Role of the Domain Name System (DNS) (rfc-editor.org)
GitHub – EmilHernvall/dnsguide: A guide to writing a DNS Server from scratch in Rust

Últimos artigos