Hey, folks!
I recently needed to collect DNS resolution events on macOS using the open-source tool osquery. At first, this seemed like a fairly straightforward task. That assumption turned out to be optimistic, and I had to dig a little deeper.
Fleetdm has a useful article on monitoring DNS traffic on macOS. It covers several approaches and their limitations, but one piece was missing for my use case: a working implementation that collects DNS events directly through osquery and exposes them in an osquery table.
I wanted to close that gap. I started by exploring what the existing tables could do and, spoiler, ended up writing my own extension.
Defining the scope
Encrypted DNS creates a major visibility gap, particularly when applications use their own resolvers and bypass the system resolver. Fleetdm covers this in detail in the encrypted DNS blind spot, so I will not repeat the entire explanation here.
Where supported, application-specific policies delivered through MDM can disable built-in resolvers and encrypted DNS features in browsers and other applications. This can help route more DNS activity through the system resolver and improve visibility. However, Fleetdm's statement that "There's no single MDM toggle to disable encrypted DNS" is accurate: there is no universal switch that turns off encrypted DNS everywhere.
A reasonable question at this point is why we need a dedicated table, or even osquery, in the first place. Why not configure workstations to use corporate DNS servers and collect the logs there?
That approach becomes less useful when corporate DNS servers are only reachable from the internal network and workstations regularly operate without a corporate VPN connection. Those devices create a visibility gap.
A managed secure DNS service is another option, with logs collected from the provider. However, my requirement was to collect DNS resolution events on the endpoint regardless of which upstream DNS server it used, without exposing corporate DNS resolvers to the internet or purchasing a third-party service for every workstation.
Collecting events locally does not eliminate every blind spot. To improve coverage, the main controls to consider are:
- Disable DNS over HTTPS (DoH), DNS over TLS (DoT), and DNS over QUIC (DoQ) in browsers and other applications where suitable policies are available.
- Optionally, block outbound TCP connections to port 853 to restrict DoT.
- Optionally, block connections to known encrypted DNS providers at the firewall.
These controls improve coverage, but they do not guarantee visibility into every DNS lookup.
Starting with the unified_log table
The first approach requires no extensions or changes to osquery: use the existing unified_log table to read events emitted by mDNSResponder.
On macOS, mDNSResponder provides the system DNS resolver and handles both unicast DNS and multicast DNS. It writes diagnostic events to the macOS Unified Logging system.
The following query retrieves relevant log entries:
SELECT * FROM unified_log
WHERE timestamp > (strftime('%s', 'now') - 150)
AND max_rows = 100000
AND process = 'mDNSResponder'
AND (
message LIKE '%DNSServiceQueryRecord %'
OR message LIKE '%DNSServiceGetAddrInfo %'
OR message LIKE '%getaddrinfo %'
);
Making domain names visible
The first issue is Apple's log privacy protection. Domain names and DNS answers may appear as masked hashes rather than plaintext values.
For example, a normalized event containing these masked values might look like this:
{
"time": "1791892800",
"client_pid": "3500",
"responder_pid": "228",
"interface_index": "0",
"expired": "0",
"correlated": "1",
"redacted": "1",
"eid": "0000000001",
"timestamp": "2026-10-13 12:00:00.000000+0000",
"kind": "answer",
"request_id": "42",
"question_id": "9",
"api": "DNSServiceQueryRecord",
"domain": "<mask.hash: 'a1b2c3d4'>",
"query_type": "A",
"answer": "<mask.hash: 'e5f60718'>",
"client_process": "Example App"
}
Both the domain name and the answer are masked. Apple's developer documentation describes this behavior under OSLogPrivacy.Mask.hash.
These hashed values can already be sufficient for threat hunting and IoC checks when comparable hashes are available. However, some use cases require DNS events in plaintext.
To expose plaintext values in newly collected events, I applied a configuration profile that enables private-data logging for the com.apple.mdns and com.apple.mDNSResponder subsystems. An example profile is available in the repository: dns.mobileconfig.
With the profile applied, domain names and DNS answers are logged in plaintext instead of masked hashes. For example, the domain field contains example.com., and the answer field contains IP-address.
That addressed the plaintext requirement. The next problem was the cost of repeatedly querying unified_log.
Why polling unified_log did not work for me
Three issues became apparent:
- DNS resolution generates a large number of events.
- Querying
unified_logcan consume substantial CPU and memory. -
unified_logis not an evented table. Each query reads the system log at execution time, so collection depends on which entries are still available under the system's logging and retention behavior.
CPU usage was the main bottleneck in my tests. The osquery_schedule table exposes CPU time counters, but it does not provide a ready-made CPU utilization percentage. To illustrate the impact, here are CPU usage snapshots taken on my own machine at different points during testing:
The following graph shows memory-related metrics from osquery_schedule, also collected on a single device. The point at which I enabled the query is clearly visible:
osquery includes a watchdog that enforces resource limits. Even with thresholds higher than the defaults, the query quickly ended up on the denylist and stopped running.
That behavior is unacceptable for continuous DNS collection. In practice, I could collect events during the first few executions and then lose visibility while the query remained denylisted, until the denylist timeout expired.
Disabling the watchdog was an option, but the resource consumption was excessive for the task.
Here is how quickly the query started being denylisted when I expanded the test to 10 devices:
The performance cost and the lack of event-based collection made this approach unsuitable for my production requirements. I needed a different way to collect the same events.
Building a dedicated osquery extension
I decided to write an osquery extension that would collect DNS events continuously and expose them through a dedicated dns_events table using osquery's eventing framework. The goal was to avoid repeated scans of the system log and keep CPU and memory overhead low.
That became osquery-dns-macos.
The extension adds a macOS-specific dns_events table and collects DNS resolution events from mDNSResponder. Example query below:
SELECT * FROM dns_events;
Without a profile that enables private-data logging, the events contain masked values:
{
"time": "1791892800",
"client_pid": "3500",
"responder_pid": "228",
"interface_index": "0",
"expired": "0",
"correlated": "1",
"redacted": "1",
"eid": "0000000001",
"timestamp": "2026-10-13 12:00:00.000000+0000",
"kind": "answer",
"request_id": "42",
"question_id": "9",
"api": "DNSServiceQueryRecord",
"domain": "<mask.hash: 'a1b2c3d4'>",
"query_type": "A",
"answer": "<mask.hash: 'e5f60718'>",
"client_process": "Example App"
}
After applying the configuration profile, newly collected events can include plaintext domain names and answers:
{
"time": "1791892800",
"client_pid": "3500",
"responder_pid": "228",
"interface_index": "0",
"expired": "0",
"correlated": "1",
"redacted": "0",
"eid": "0000000001",
"timestamp": "2026-10-13 12:00:00.000000+0000",
"kind": "answer",
"request_id": "42",
"question_id": "9",
"api": "DNSServiceQueryRecord",
"domain": "example.com.",
"query_type": "A",
"answer": "192.0.2.1",
"client_process": "Example App"
}
Coverage limitation: Some tools send DNS queries directly, bypassing Apple's resolver APIs and mDNSResponder. Examples include dig and nslookup. Those lookups do not appear in mDNSResponder logs and remain outside the extension's visibility.
Performance testing
During testing, I did not observe the CPU spikes that occurred when querying unified_log.
The following graph shows memory-related metrics reported by osquery_schedule for the extension's query. This test covered more than 15 devices running different macOS versions and hardware configurations:
The extension performs significantly better than querying unified_log in terms of CPU and memory usage. It also uses osquery's eventing framework to store collected events in osquery's RocksDB database, so their retention no longer depends on the system logs.
How this compares with NEDNSProxyProvider
An alternative is to use Apple's NEDNSProxyProvider, for example through a tool such as DNSMonitor.
That approach requires deploying an additional Network Extension to each device and deciding how to collect its output. If the tool writes events to a file and I want to ingest that file through osquery, one option is the Mac Admins extension and its file_lines table. Repeatedly reading log files this way can be inefficient at high event volumes.
A DNS proxy does offer broader coverage of conventional DNS traffic. It receives TCP and UDP flows to destination port 53, including queries sent directly by applications that bypass Apple's resolver APIs. As a result, it can observe some queries that never appear in mDNSResponder logs.
However, this should not be interpreted as a guarantee that every DNS lookup will be captured and logged. Applications that implement their own encrypted DNS remain a blind spot for both approaches, while encrypted DNS handled by the operating system can introduce additional limitations for a DNS proxy.
For my use case, collecting mDNSResponder events through an osquery extension provided a practical way to integrate DNS telemetry into the existing osquery deployment.
Try it out
The extension is available on GitHub: aleksandrclucle/osquery-dns-macos.
You can use it in your own environment. It follows the standard osquery extension deployment workflow and exposes the dns_events table once it has been loaded successfully.
I would love for the osquery maintainers to take a look and consider whether this functionality could eventually become part of osquery itself.
Thanks for reading! :)




Top comments (0)