From 11c82e21fee866a99ed6071cdc4958d5b8500c26 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Apr 2026 18:35:48 -0500 Subject: [PATCH 1/3] feat: add DNS-over-HTTPS support for post-analysis hostname resolution Add optional DoH support to encrypt all post-analysis DNS lookups (forward and reverse) via HTTPS instead of plaintext UDP/TCP DNS. When enabled, all calls to resolve() and reverse DNS in _enrich_hosts() route through a configurable DoH provider (default: Google). This prevents DNS query leakage from the analysis host. Configuration in cuckoo.conf [processing] section (same as reverse_dns): dns_over_https = yes doh_url = https://dns.google/resolve Compatible with any provider supporting application/dns-json: - Google: https://dns.google/resolve (default) - Cloudflare: https://cloudflare-dns.com/dns-query - Custom/self-hosted DoH resolvers Changes: - lib/cuckoo/common/dns.py: Add resolve_doh(), set_doh(), set_doh_url() with requests.Session for connection pooling, rdtype-aware response filtering, HTTPS URL validation, and specific exception handling - modules/processing/network.py: Read config, use DoH for PTR lookups, normalize trailing dots in both DoH and non-DoH PTR paths --- lib/cuckoo/common/dns.py | 88 +++++++++++++++++++++++++++++++++++ modules/processing/network.py | 21 +++++++-- 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/lib/cuckoo/common/dns.py b/lib/cuckoo/common/dns.py index a6b2d6a440b..89507a85bae 100644 --- a/lib/cuckoo/common/dns.py +++ b/lib/cuckoo/common/dns.py @@ -2,11 +2,21 @@ # This file is part of Cuckoo Sandbox - http://www.cuckoosandbox.org # See the file 'docs/LICENSE' for copying permission. +import logging import select import socket import threading from typing import Callable +try: + import requests + + HAVE_REQUESTS = True + DOH_SESSION = requests.Session() +except Exception: + HAVE_REQUESTS = False + DOH_SESSION = None + try: import pycares @@ -14,6 +24,8 @@ except Exception: HAVE_CARES = False +log = logging.getLogger(__name__) + # try: # import gevent, gevent.socket # HAVE_GEVENT = True @@ -137,8 +149,84 @@ def resolve_gevent_real(name): """ +# DNS-over-HTTPS +# Supports Google (/resolve JSON API) and other application/dns-json +# compatible endpoints (for example, Cloudflare-style JSON APIs). +# Default: Google DNS. Configurable via set_doh_url(). +DOH_URL = "https://dns.google/resolve" +USE_DOH = False + +# Expected DNS response type numbers for rdtype validation +_RDTYPE_MAP = {"A": 1, "AAAA": 28, "PTR": 12, "CNAME": 5, "MX": 15, "TXT": 16, "NS": 2, "SOA": 6} + + +def set_doh(enabled: bool): + global USE_DOH + USE_DOH = enabled + + +def set_doh_url(url: str): + global DOH_URL + if url: + if not url.startswith("https://"): + log.warning("DoH URL %s does not use HTTPS — DNS queries will not be encrypted", url) + DOH_URL = url.rstrip("/") + + +def resolve_doh(name: str, rdtype: str = "A") -> str: + """Resolve a DNS name using DNS-over-HTTPS (JSON API). + + Compatible with Google (/resolve), Cloudflare (/dns-query), and other + providers that support the application/dns-json content type. + + Uses a persistent requests.Session for connection pooling. + """ + if not HAVE_REQUESTS or DOH_SESSION is None: + if rdtype == "A": + log.warning("requests library not available for DoH, falling back to system DNS") + return resolve_thread(name) + log.warning( + "requests library not available for DoH, no system DNS fallback for %s queries", + rdtype, + ) + return DNS_TIMEOUT_VALUE + try: + expected_type = _RDTYPE_MAP.get(rdtype.upper()) + resp = DOH_SESSION.get( + DOH_URL, + params={"name": name, "type": rdtype}, + headers={"Accept": "application/dns-json"}, + timeout=DNS_TIMEOUT, + ) + if resp.status_code != 200: + log.debug("DoH request for %s returned HTTP %d", name, resp.status_code) + return DNS_TIMEOUT_VALUE + data = resp.json() + for answer in data.get("Answer", []): + answer_type = answer.get("type") + # If we know the expected type, only return matching records + if expected_type and answer_type == expected_type: + result = answer["data"] + if answer_type == 12: # PTR — strip trailing dot + result = result.rstrip(".") + return result + # Fallback for unknown rdtype: return first A/AAAA/PTR + if not expected_type and answer_type in (1, 12, 28): + result = answer["data"] + if answer_type == 12: + result = result.rstrip(".") + return result + except requests.RequestException as e: + log.debug("DoH resolution failed for %s: %s", name, e) + except (ValueError, KeyError) as e: + log.debug("DoH response parse error for %s: %s", name, e) + return DNS_TIMEOUT_VALUE + + # choose resolver automatically def resolve(name: str) -> str: + if USE_DOH: + return resolve_doh(name) if HAVE_CARES: return resolve_cares(name) # elif HAVE_GEVENT: diff --git a/modules/processing/network.py b/modules/processing/network.py index 891b98749ed..9ee878e65e7 100644 --- a/modules/processing/network.py +++ b/modules/processing/network.py @@ -33,7 +33,7 @@ from data.safelist.domains import domain_passlist_re from lib.cuckoo.common.abstracts import Processing from lib.cuckoo.common.config import Config -from lib.cuckoo.common.dns import resolve +from lib.cuckoo.common.dns import resolve, resolve_doh, set_doh, set_doh_url from lib.cuckoo.common.exceptions import CuckooProcessingError from lib.cuckoo.common.irc import ircMessage from lib.cuckoo.common.network_utils import _norm_domain @@ -99,6 +99,13 @@ enabled_passlist = proc_cfg.network.dnswhitelist passlist_file = proc_cfg.network.dnswhitelist_file +# Enable DNS-over-HTTPS if configured +if getattr(cfg.processing, "dns_over_https", False): + set_doh(True) + doh_url = getattr(cfg.processing, "doh_url", "") + if doh_url: + set_doh_url(doh_url) + enabled_ip_passlist = proc_cfg.network.ipwhitelist ip_passlist_file = proc_cfg.network.ipwhitelist_file @@ -321,7 +328,8 @@ def _add_hosts(self, connection): def _enrich_hosts(self, unique_hosts): enriched_hosts = [] - if cfg.processing.reverse_dns: + use_doh = getattr(cfg.processing, "dns_over_https", False) + if cfg.processing.reverse_dns and not use_doh: d = dns.resolver.Resolver() d.timeout = 5.0 d.lifetime = 5.0 @@ -331,8 +339,13 @@ def _enrich_hosts(self, unique_hosts): inaddrarpa = "" hostname = "" if cfg.processing.reverse_dns: - with suppress(Exception): - inaddrarpa = d.query(from_address(ip), "PTR").rrset[0].to_text() + if use_doh: + with suppress(Exception): + ptr_name = str(from_address(ip)) + inaddrarpa = resolve_doh(ptr_name, rdtype="PTR") + else: + with suppress(Exception): + inaddrarpa = d.query(from_address(ip), "PTR").rrset[0].to_text().rstrip(".") for request in self.dns_requests.values(): for answer in request["answers"]: if answer["data"] == ip: From 3cd775e799d60eb5102015aad43df3c4ea65a944 Mon Sep 17 00:00:00 2001 From: doomedraven Date: Mon, 20 Apr 2026 15:47:11 +0900 Subject: [PATCH 2/3] Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- lib/cuckoo/common/dns.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/cuckoo/common/dns.py b/lib/cuckoo/common/dns.py index 89507a85bae..ce46147d771 100644 --- a/lib/cuckoo/common/dns.py +++ b/lib/cuckoo/common/dns.py @@ -8,6 +8,7 @@ import threading from typing import Callable +try: try: import requests @@ -16,6 +17,7 @@ except Exception: HAVE_REQUESTS = False DOH_SESSION = None + DOH_SESSION = None try: import pycares From d284908b4a5da4e8dd8b868aa5bf1f136fa9755a Mon Sep 17 00:00:00 2001 From: doomedraven Date: Tue, 28 Apr 2026 15:55:54 +0900 Subject: [PATCH 3/3] Fix indentation error in dns.py --- lib/cuckoo/common/dns.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/cuckoo/common/dns.py b/lib/cuckoo/common/dns.py index ce46147d771..363ec7c55d2 100644 --- a/lib/cuckoo/common/dns.py +++ b/lib/cuckoo/common/dns.py @@ -8,7 +8,6 @@ import threading from typing import Callable -try: try: import requests