Objectives
Our main objectives in this section are to both learn more about how to write Zeek scripts by experimenting with the language and to create a script that will be useful in the real world.
Exercise
Description: In this exercise, we will create a script that reports outbound connections for which no previous DNS resolutions were observed.
First, for what reasons would a host ever attempt an outbound connection without first performing a DNS resolution? Here are some possibilities:
- Hard-coded DNS server addresses
- Other hard-coded configuration of addresses
- Malware phoning home to a set of known addresses
- Resolution occurred over another path
- Outbound network scanners
- Malware attempting to spread out of our border
- Use of DNS over HTTPS or DNS over TLS
There are surely other possibilities. However, the list above includes most of the more common explanations. Let’s discuss and explain these.
We do expect outbound connections to the upstream DNS servers with no corresponding name queries. Why? That’s just how it works! We statically configure our DNS server with a set of “root hints” or configure it to relay all requests upstream to a specific name server. We will never see queries related to finding these addresses.
We may have systems or services that have hard-coded IP addresses in them. These are not necessarily nefarious, but such configurations are very fragile! It is much better to configure systems to connect to names that can be resolved via DNS. This allows us to relocate systems or services without breaking anything.
The other reasons are not so good. This behavior could be an indication of a malware infection. Perhaps it is attempting to establish a command-and-control link to a static address or addresses. This is especially common when we are early on in a targeted compromise. Perhaps it’s generic malware that’s attempting to spread beyond the borders of our network. Since it doesn’t know exactly who it wants to infect, malware will frequently scan random addresses (without DNS lookups, of course!).
As far as DNS resolutions occurring over some other path, consider both why and how this might be done. Certainly, someone can argue that it would be done for privacy, but within an enterprise network, how much “private” activity should there be? It is more likely that, in this case, it is being done to bypass content filtering controls, though it could also be inadvertent.
A prime way that this could happen is through the use of DNS over TLS (port 853) or DNS over HTTPS (port 443). As you are likely aware, a number of browsers optionally support DNS over HTTPS today. While the script that we are creating will not allow us to see what the DNS resolutions were, we can certainly detect that a system seems to be circumventing our normal resolution infrastructure and take appropriate action to remediate the system or activity.
We can see that this is all interesting behavior. How can we find it?
- Using any editor of your choice that is installed on the VM, please create a script named anomalousOutbound.zeek that will print the address resolved every time a DNS A or DNS AAAA record is seen.
To begin, I should define a module name for my script. I am going to call my module a module NoDNS. This defines a namespace and moves everything contained in my script into that namespace.

Next, I need to determine which events I need to subscribe to using the Zeek Scripting protocol analyzers documentation for DNS. Looking at this documentation, there are two events that I am interested in:
event dns_A_reply(c: connection, msg: dns_msg, ans: dns_answer, a: addr)
and the IPv6 version:
event dns_AAAA_reply(c: connection, msg: dns_msg, ans: dns_answer, a: addr)
I need to create event handlers for these two events in my script and add a code block to each that will print the resolved address.

I am interested in printing the address resolved every time an A record or AAAA record is seen. The parameter ‘a: addr’ holds the resolved IP address from the DNS response and is passed to the event handler when the event is triggered during Zeek’s analysis of network traffic. This is exactly what I need to print. Let’s modify my script to account for this.

Let’s make sure that the output of this script is correct:

The next step is to add some sort of global variable that will keep track of the DNS addresses that have been resolved. I can add a global variable for this purpose and add all of the addresses to it. I am going to use the zeek_done()
event to print the content of this variable when the script completes to verify that it is working.

The output from this script doesn’t look much different, but it runs much differently. Rather than printing addresses as they are seen, the full list is printed at the very end. The entire list is contained within curly braces ({}), indicating that it is a set:

The final step is to remove the zeek_done()
call and to cause our script to trigger every time a connection is seen to a responding host that was not seen in our set. I need to identify an event that will be generated every time a new connection or session is seen. Looking at the zeek documentation, there is the event new_connection(c: connection)
that is perfect for my purposes. It is generated whenever a new stream is identified involving TCP, UDP, or ICMP.
Now I need to add some output to the event, but only if the responding host isn’t found in the set of resolved addresses. Both vectors and sets allow for the use of the ‘in’ keyword to check for membership.

Let’s run this new script:

This script still has a few problems. I am seeing external to internal and internal to internal. I need to add a condition to the new_connection()
event handler to ignore external to internal and internal to internal connections.

The very last thing that I will add to this script is an exception for certain approved external DNS servers (such as 8.8.8.8, which is Google’s DNS server). This is necessary because internal DNS resolvers often connect directly to external DNS servers without first performing a DNS lookup for their addresses. These addresses are typically configured in advance, so no DNS resolution happens beforehand. To avoid false positives, the script should exclude these approved servers from being flagged.

Let’s do a last test run :
