Technical writing

How Voidly detects DNS injection: forged responses, injection rates by country, and pipeline integration

· 9 min read· AI Analytics
CensorshipVoidlyMethodologyInfrastructure

DNS blocking and DNS injection are often treated as the same thing — both prevent a user from reaching a domain using DNS — but they are mechanically distinct and require different detection strategies. DNS blocking returns NXDOMAIN or SERVFAIL: the resolver tells the client the domain does not exist. DNS injection is more subtle: a middlebox intercepts the DNS query in transit and returns a forged A record before the legitimate authoritative nameserver can respond. The client receives what looks like a valid DNS answer, with a real IP address in the answer section. It is only when you compare that IP against what independent resolvers return — and inspect the source of the response and its TTL — that the forgery becomes detectable.

Injection is more prevalent than outright NXDOMAIN blocking in China, Iran, and Russia. China's Great Firewall has used forged A records as its primary DNS censorship mechanism for over a decade. Detecting it reliably requires running the DNS query through multiple independent vantage points simultaneously, capturing raw socket data to check the source of the response, and scoring four distinct anomaly signals against a confidence threshold. This post covers how Voidly implements that pipeline.

DNS injection vs. DNS blocking

When a censor blocks a domain via DNS, it has two broad choices. The first is to configure the resolver to return a negative response — NXDOMAIN (domain does not exist) or SERVFAIL (resolver error). These are easy to detect: the control resolvers return a valid A record, and the probe resolver returns nothing. The mismatch is unambiguous.

The second approach is injection. The censor operates a device on-path — between the probe and the authoritative nameserver — that monitors DNS UDP traffic and races to return a forged response before the real one arrives. The forged response is a valid DNS packet containing an A record, but pointing to a censor-controlled IP: a block page server, or a non-routable address like 0.0.0.0 or 127.0.0.1. Because the probe's resolver receives a well-formed DNS answer with no error code, the application layer sees a successful lookup — and then fails to connect, which may look like a server-side problem rather than interference.

Injection is harder to detect for exactly this reason: the response is syntactically valid. Detection requires comparing the returned IP against independent ground truth, examining the TTL for middlebox artifacts, capturing the source IP of the UDP response to verify it came from legitimate DNS infrastructure, and checking the response timing against what the round-trip to the authoritative server physically allows.

The DnsTestResult dataclass

Each DNS measurement in the Voidly pipeline produces a DnsTestResultthat carries both the raw measurement data and the derived injection scoring:

from dataclasses import dataclass, field
from typing import Optional

@dataclass
class DnsTestResult:
    domain: str
    query_type: str           # 'A', 'AAAA', 'CNAME'
    probe_response_ip: str    # IP returned by local resolver
    control_ips: list[str]    # IPs from 3 control resolvers
    probe_ttl: int
    control_ttl_median: int
    response_latency_ms: float

    injection_confidence: float = 0.0     # 0.0-1.0
    injection_signals: list[str] = field(default_factory=list)
    injection_type: Optional[str] = None  # 'FORGED_A', 'WRONG_IP', 'TTL_ANOMALY', 'NXDOMAIN'

The injection_confidence field is a composite score built from up to four signals, described below. The injection_type field captures the most specific classification once confidence crosses the flagging threshold. injection_signals is a list of the signal names that contributed, which is included verbatim in the incident record for analyst review.

Three control resolvers

Every DNS measurement is validated against three independent resolvers queried simultaneously with the probe's local resolver query:

  • Cloudflare 1.1.1.1, queried over DNS-over-TLS (DoT, port 853). TLS prevents an on-path middlebox from intercepting or forging the control query.
  • Google 8.8.8.8, queried over DNS-over-HTTPS (DoH). The HTTPS transport means control queries are indistinguishable from ordinary web traffic, making targeted suppression difficult.
  • Voidly's authoritative resolver, a Cloudflare-hosted resolver that returns the expected record set for every domain in the test list. Unlike Cloudflare 1.1.1.1 and Google 8.8.8.8, which resolve from their own caches and anycast infrastructure, Voidly's authoritative resolver is configured with the known-correct records for each domain. This makes it the ground truth source used for anycast false positive calibration (described below).

All three control queries are sent through the probe's WireGuard tunnel, bypassing the local network entirely. This is what gives them their validity: the local network may be operating a DNS middlebox that intercepts and manipulates all DNS traffic, but WireGuard-encapsulated UDP sent to the control resolvers exits the local network as encrypted tunnel traffic and resolves at the far end, outside the censor's reach. The local resolver query — the one being tested — goes through the probe's unmodified local network stack, where it can be intercepted.

Four injection detection signals

The four signals below are scored independently and summed to produce injection_confidence. A result with confidence ≥ 0.65 is flagged as DNS injection.

IP divergence (+0.4). The most direct signal: probe_response_ip is not present in control_ips. This is the classic injection fingerprint. China's Great Firewall, for example, returns IPs in the 202.108.0.0/16 block for domains including google.com — a range operated by Baidu that hosts a block page. The injected IP is routable and responds to HTTP connections, which distinguishes it from NXDOMAIN-equivalent injection. IP divergence alone scores 0.4, which is below the 0.65 threshold — this prevents anycast false positives from triggering injection flags without corroborating signals.

TTL anomaly (+0.3). Injected DNS responses frequently carry TTL values that are artifacts of the middlebox rather than the domain's SOA configuration. Two common patterns: TTL=0 (immediate expiry, preventing the forged record from being cached and compared) and TTL=300 (the default value of many middlebox implementations, regardless of the domain's real TTL). Voidly flags a TTL anomaly when probe_ttl deviates from control_ttl_medianby more than 3× or less than 0.3×. A domain like example.com with a SOA minimum TTL of 3600 seconds and an injected response with TTL=0 is strongly anomalous; a domain with a real TTL of 60 seconds returning TTL=300 is less so, but the ratio (5×) still exceeds the threshold.

Source IP divergence (+0.25). The IP that sent the DNS UDP response — captured via raw socket — is checked against the A records of the domain's authoritative nameservers. Legitimate DNS responses arrive from the authoritative nameserver's IP or the local resolver's IP. Injected responses arrive from a middlebox IP that is not part of the domain's DNS infrastructure. This signal requires SOCK_RAW with a BPF filter on the probe — see the privilege discussion below. When available, it adds 0.25 to injection confidence and is the signal most uniquely diagnostic of active injection (as opposed to misconfiguration or anycast variance).

Response timing (+0.05). Injected responses must arrive before the legitimate authoritative server responds. If the probe's measured round-trip time to the authoritative nameserver is 145ms but the DNS response arrived in 12ms, the timing is physically inconsistent with a legitimate response — the packet could not have reached the authoritative server and returned in that time. The timing signal carries only 0.05 weight because network asymmetry and local resolver caching can legitimately produce fast responses, making this a weak standalone signal. Combined with IP divergence, it provides useful confirmation.

Distinguishing injection types: FORGED_A vs. NXDOMAIN-equivalent

Not all injected responses look the same. When the injected IP is routable and reachable (a block page server), Voidly classifies the measurement as FORGED_A injection. When the injected IP is non-routable (0.0.0.0, 127.0.0.1, or an RFC 1918 private range) and the control resolvers return a valid public IP, Voidly classifies this as NXDOMAIN-equivalent injection — the domain exists but is being made unreachable via a non-routable redirect rather than a negative response code.

def classify_injection_type(result: DnsTestResult) -> Optional[str]:
    """
    Classify the injection type once injection_confidence >= 0.65.
    Returns None if the result is not classified as injection.
    """
    if result.injection_confidence < 0.65:
        return None

    probe_ip = result.probe_response_ip
    controls_have_valid = any(
        not _is_non_routable(ip) for ip in result.control_ips
    )

    # Non-routable probe response when controls return valid IPs
    if _is_non_routable(probe_ip) and controls_have_valid:
        return 'NXDOMAIN'   # NXDOMAIN-equivalent injection

    # Probe returns a routable IP, but it differs from all control IPs
    if probe_ip not in result.control_ips and not _is_non_routable(probe_ip):
        return 'FORGED_A'

    # IP matches controls but TTL or source anomaly triggered confidence
    if 'ttl_anomaly' in result.injection_signals:
        return 'TTL_ANOMALY'

    return 'WRONG_IP'


def _is_non_routable(ip: str) -> bool:
    NON_ROUTABLE = ('0.0.0.0', '127.0.0.1', '::')
    RFC1918 = (('10.', 1), ('192.168.', 1), ('172.', 1))
    if ip in NON_ROUTABLE:
        return True
    if any(ip.startswith(prefix) for prefix, _ in RFC1918):
        return True
    return False

The distinction matters for the incident record. A FORGED_A result includes the injected IP in injected_ips, which is used downstream to identify the specific block page server and correlate injections across probes. An NXDOMAIN-equivalent injection produces a non-routable IP that carries less infrastructure intelligence but is equally strong as a censorship signal.

Per-country injection rates (2025)

The following rates are measured across a sample of 50 domains per country per month, using all probes active in that country. Injection rate is defined as the fraction of measurements returning an IP for control-confirmed existing domains where the returned IP is injected — domains that are globally unreachable are excluded, since a probe failure on an offline domain is not injection.

CountryInjection ratePrimary methodTypical injected IP
China94%Forged A record202.108.0.0/16 block page range
Iran61%Forged A to block page10.10.34.34 (FATA block page)
Russia12%Mixed NXDOMAIN + injectionISP-specific
Turkey8%ISP-level injection195.175.254.2 (BTK)
Egypt4%Rare, ISP-levelVariable
Saudi Arabia3%NXDOMAIN preferred

China's 94% rate reflects a near-total reliance on injection as the DNS censorship mechanism — NXDOMAIN responses are relatively rare in the Great Firewall's DNS layer. Iran's FATA block page at 10.10.34.34 is an RFC 1918 private address, making it NXDOMAIN-equivalent injection: the injected response is non-routable outside of the Iranian national network infrastructure where the block page is hosted. Russia's mixed rate reflects decentralized enforcement: injection is an ISP-level decision, not a consistent national policy, so rates vary significantly between ASNs.

CAP_NET_RAW and Windows compatibility

Source IP divergence detection — signal c above — requires capturing raw UDP packets from the DNS response before the operating system hands them to the resolver. Voidly requests this capability through platform-specific mechanisms:

  • Linux: CAP_NET_RAW capability via capabilities(7). The probe binary is granted this capability at install time using setcap. The raw socket is opened with SOCK_RAW and a BPF filter that captures only DNS UDP responses on port 53 destined for the probe's IP.
  • macOS: A BPF device (/dev/bpf*). The installer grants the probe binary access to BPF devices via a privileged helper, which the operator approves at setup time.
  • Windows: WinPcap or Npcap, installed as a separate dependency. The probe binary uses the Npcap SDK to open a packet capture handle filtered to DNS traffic.

Operators who cannot or choose not to grant raw socket access still receive injection detection via signals a (IP divergence), b (TTL anomaly), and d (response timing). The tradeoff in detection sensitivity is measurable:

// Rust: privilege check before enabling raw socket capture
use std::io;

#[cfg(target_os = "linux")]
fn has_cap_net_raw() -> bool {
    use std::process::Command;
    // Check effective capabilities of the current process
    let output = Command::new("capsh")
        .args(["--print"])
        .output();
    match output {
        Ok(o) => String::from_utf8_lossy(&o.stdout).contains("cap_net_raw"),
        Err(_) => false,
    }
}

#[cfg(not(target_os = "linux"))]
fn has_cap_net_raw() -> bool {
    // On macOS / Windows: check whether the BPF/Npcap handle opens successfully
    #[cfg(target_os = "macos")]
    {
        std::fs::File::open("/dev/bpf0").is_ok()
    }
    #[cfg(target_os = "windows")]
    {
        npcap::open_live("").is_ok()
    }
}

pub struct DnsProbeConfig {
    pub raw_socket_enabled: bool,
    pub expected_detection_sensitivity: f64,
}

pub fn build_dns_probe_config() -> DnsProbeConfig {
    let raw_enabled = has_cap_net_raw();
    // 2025 calibration: source IP divergence signal adds ~13pp detection sensitivity
    // (94% with raw socket, 81% without, across 50-domain calibration sample)
    let sensitivity = if raw_enabled { 0.94 } else { 0.81 };
    DnsProbeConfig {
        raw_socket_enabled: raw_enabled,
        expected_detection_sensitivity: sensitivity,
    }
}

The 94% vs. 81% sensitivity figures come from Voidly's 2025 calibration dataset: measurements where injection was confirmed by all four signals vs. measurements where signal c was unavailable. The 13-percentage-point gap reflects cases where IP divergence, TTL anomaly, and timing alone produce a confidence score between 0.45 and 0.65 — below the flagging threshold — but source IP divergence would push the score above 0.65.

False positive calibration and the anycast problem

The most common source of false positives in DNS injection detection is legitimate geographic IP variation via anycast. Akamai, Cloudflare, and Google serve many domains from edge nodes that return different A records depending on the querying location — a probe in Singapore gets a Singapore-local edge IP, while the control resolver querying from a US data center gets a Virginia edge IP. To the naive IP divergence check, this looks like injection: the probe IP is not in the control IP set.

Three domain categories illustrate the calibration problem:

  • Akamai CDN domains (e.g. assets served from *.akamaihd.net). Akamai's authoritative DNS returns different IPs per query origin. IP divergence alone is uninformative; all three control resolvers return different Akamai edge IPs from each other.
  • Google's anycast infrastructure (e.g. www.google.com). Google returns IPs from its global anycast pool; the same domain resolves to different IPs from different vantage points, all of them in Google's ASN (AS15169).
  • Cloudflare-proxied domains. Cloudflare returns anycast IPs for proxied domains; the IP set is consistent within Cloudflare's ASN but different IPs are served to different regions.

Voidly's false positive rate from anycast before the authoritative resolver was added was 4.2% — one in 24 measurements on anycast domains was incorrectly flagged as injection. The Voidly-operated authoritative resolver is the solution: because it is configured with the known-correct record for each domain and returns that record regardless of querying location, it acts as a fixed ground truth. When the probe response IP differs from the other two control resolvers but matches the authoritative resolver's IP (or falls within the same ASN), the IP divergence signal is suppressed. After this calibration, the false positive rate dropped to 0.8%.

Pipeline integration

DNS injection signals are included in the measurement JSON uploaded to the ingest pipeline. The classifier reads injection_confidence and injection_signals and applies the following routing logic:

def route_dns_measurement(result: DnsTestResult) -> MeasurementRoute:
    if result.injection_confidence >= 0.80:
        # High-confidence injection: tag and route to real-time alert pipeline
        return MeasurementRoute(
            interference_type='dns_injection',
            alert_tier='realtime',
        )
    if result.injection_confidence >= 0.65:
        # Confirmed injection: tag measurement, feed into incident clustering
        return MeasurementRoute(
            interference_type='dns_injection',
            alert_tier='standard',
        )
    if result.injection_confidence >= 0.40:
        # Possible injection: flag for review, do not generate incident
        return MeasurementRoute(
            interference_type=None,
            review_flag='possible_dns_injection',
            alert_tier=None,
        )
    return MeasurementRoute(interference_type=None, alert_tier=None)

One important property of DNS injection measurements: they are not suppressed by the geoblocking filter. HTTP measurements on streaming or ecommerce domains may be suppressed when the p_geoblock score is above 0.70, because commercial geoblocking is a legitimate reason for HTTP inaccessibility. DNS injection is never legitimate geoblocking — a CDN or streaming service does not forge DNS responses to enforce licensing restrictions. Any measurement with injection_confidence≥ 0.65 passes through to the censorship classifier regardless of the domain's category or p_geoblock score.

The DNS injection incident record

When DNS injection measurements from multiple probes are clustered into an incident, the incident record includes an injection_evidence block that aggregates across all contributing measurements:

{
  "interference_type": "dns_injection",
  "injection_evidence": {
    "injected_ips": ["10.10.34.34"],
    "legitimate_ips": ["93.184.216.34"],
    "injection_rate": 0.97,
    "probe_count": 4,
    "asn_count": 3,
    "signals": ["ip_divergence", "ttl_anomaly"]
  }
}

The injected_ips list is deduplicated across all probes contributing to the incident. For China incidents, this list consistently contains IPs in the 202.108.0.0/16 block — their stability over time lets Voidly build a persistent fingerprint of the Great Firewall's injection infrastructure. The asn_count field indicates how many distinct ASNs the injected measurements were observed from: a high ASN count (3 or more) is strong evidence of ISP-level or national-level injection rather than a single misconfigured resolver.

The injection_rate for a given domain in a given incident window is the fraction of measurements in that window returning an injected IP, across all probes. A rate near 1.0 (as is typical for China) indicates consistent infrastructure-level injection. A rate near 0.5 is more common in Russia, where injection is ISP-dependent and some probes on unblocked ASNs receive the legitimate response while others on state-affiliated ASNs receive forged responses.


For how Voidly distinguishes geoblocking from genuine censorship (upstream false positive filter): Geoblocking vs. censorship: how Voidly distinguishes licensing restrictions from government interference →

For how probe health monitoring ensures the probe is functioning correctly before DNS anomalies are flagged as censorship: Voidly probe health monitoring: how we detect and replace failing probe nodes →

For how DNS injection evidence combines with HTTP and TCP signals in the anomaly classifier: Voidly's censorship anomaly classifier: combining DNS, TCP, TLS, and HTTP signals →

For the control server methodology that provides ground-truth DNS responses for comparison: The Voidly control server: how we tell censorship from a bad network →