Skip to content

Blocky DNSSEC validation bypass and validation-cache scope pollution

High severity GitHub Reviewed Published Jun 16, 2026 in 0xERR0R/blocky • Updated Jun 19, 2026

Package

gomod github.com/0xERR0R/blocky (Go)

Affected versions

>= 0.28.0, < 0.32.0

Patched versions

0.32.0

Description

Summary

Blocky accepts and caches forged DNS answers while dnssec.validate: true is enabled. The issue has two related exploit paths:

  1. Basic DNSSEC validation bypass. If an untrusted upstream returns an unsigned positive answer for a DNSSEC-signed public domain, Blocky classifies the response as Insecure solely because the response contains no RRSIG records. It does not first check the DS/DNSKEY chain to determine whether the queried name is below a signed delegation. The forged unsigned answer is returned and cached.

  2. Validation-cache scope pollution through forged insecure proofs. If a response contains some RRSIG material and enters RRset validation, an attacker-controlled response path can still cause Blocky to cache ValidationResultInsecure for the bare domain name by returning a DS response with no DS records and an unsigned NSEC/NSEC3 record in the authority section. Blocky treats the mere presence of NSEC/NSEC3 as authenticated DS absence and stores the resulting Insecure state without validating the parent-zone proof. That cached state is keyed only by domain name and can be reused for later responses and cache hits.

Both paths were reproduced through Blocky's real DNS listener using external UDP DNS client queries. In both reproductions, the malicious upstream was shut down before the second query; Blocky still returned the poisoned answer from its own cache.

DNSSEC validation Configuration

The PoCs use Blocky's documented DNSSEC configuration model. This is not a misconfiguration.

Blocky's own documentation states that the basic DNSSEC configuration is:

dnssec:
  validate: true

The documentation says this enables DNSSEC validation with default settings and built-in root trust anchors, and that Blocky will validate DNSSEC-signed domains. It also states that, when DNSSEC validation is enabled, Blocky will:

  • set the DNSSEC OK bit on upstream queries;
  • validate RRSIG records;
  • verify the chain of trust from the root zone to the queried domain using DNSKEY and DS records;
  • return SERVFAIL for bogus signatures;
  • protect against cache poisoning, man-in-the-middle attacks, DNS spoofing, and forged denial-of-existence.

The implementation-side defaults match this documented usage:

  • config/dnssec.go defines DNSSEC.Validate as the dnssec.validate option.
  • config/dnssec.go documents TrustAnchors []string as custom trust anchors; an empty value uses built-in IANA root trust anchors.
  • The PoCs set cfg.DNSSEC.Validate = true and do not override TrustAnchors, so they use the documented built-in root trust-anchor path.
  • The PoCs use config.NetProtocolTcpUdp as the upstream transport, which is one of the documented upstream protocols.
  • The cache configuration is normal Blocky behavior: caching.maxTime >= 0 enables caching, and the PoCs set a positive maxTime only to make cache replay observable.

Therefore, the expected behavior for a signed public domain such as cloudflare.com. is not to accept an unsigned forged answer. A validating resolver must determine whether the name is covered by a signed delegation before treating missing signatures as Insecure.

Threat models and attack paths

Attack model 1: untrusted recursive upstream or upstream-path attacker

This is the direct DNSSEC threat model. DNSSEC validation is supposed to protect clients even when the recursive upstream response path is malicious, compromised, or tampered with.

The attacker can be:

  • a malicious recursive upstream configured in Blocky;
  • an attacker who can tamper with plaintext UDP/TCP DNS traffic between Blocky and its upstream;
  • a compromised upstream resolver;
  • a misrouted or attacker-controlled conditional upstream.

Attack steps:

  1. The client queries Blocky for a DNSSEC-signed public name, for example cloudflare.com. A.
  2. The attacker-controlled upstream returns an unsigned forged positive answer, for example cloudflare.com. 120 IN A 203.0.113.77.
  3. Blocky observes that the response contains no RRSIG records.
  4. Blocky returns ValidationResultInsecure without issuing target DS or DNSKEY queries.
  5. The forged answer is returned to the client and cached.
  6. Later clients receive the cached forged answer, even if the malicious upstream is no longer reachable.

This path is demonstrated by attachments/external-dnssec-basic-bypass/main.go.

Attack model 2: forged insecure proof / validation-cache scope pollution

This path exercises the validator's insecure-proof and cache-scope logic. It is relevant when the response enters RRset validation and when different DNS views or response paths can seed DNSSEC state for the same domain name.

The attacker can be:

  • an attacker-controlled recursive upstream;
  • a network attacker who can tamper with DS/DNSKEY auxiliary queries;
  • a conditional-forwarding or split-horizon configuration that causes the final answer and DNSSEC auxiliary lookups to come from different views;
  • a malicious upstream group selected for DNSSEC auxiliary queries but not necessarily for the original user-facing answer.

Attack steps:

  1. The client queries Blocky for victim.signed.example. A.
  2. The attacker returns a poisoned A RR and an unrelated decoy RRSIG. The A RRset itself has no matching RRSIG, but the response contains some RRSIG material, so Blocky enters RRset validation instead of the simple no RRSIG branch.
  3. Blocky attempts to determine whether victim.signed.example. is in a signed or unsigned zone by querying DS records.
  4. The attacker returns a DS response with no DS records and an unsigned NSEC record in the authority section.
  5. Blocky treats the mere presence of NSEC as authenticated DS absence, caches ValidationResultInsecure for the bare domain name, and accepts the unsigned A RRset.
  6. The poisoned answer is returned and cached.
  7. On later queries, Blocky reuses both the poisoned DNS response cache entry and the polluted validation status. The PoC confirms replay after the malicious upstream is shut down.

This path is demonstrated by attachments/external-dnssec-cache-scope-pollution/main.go.

Details

1. no RRSIG is treated as Insecure before chain status is checked

In resolver/dnssec/validator.go, ValidateResponse dispatches as follows:

switch {
case !v.hasAnySignatures(response):
    v.logger.Debugf("No RRSIG records found for %s - zone is unsigned", question.Name)
    result = ValidationResultInsecure
case len(response.Answer) > 0:
    result = v.validateAnswer(ctx, response, question)
...
}

The bug is the assumption that a response with no RRSIG records means the zone is unsigned. That assumption is not valid for a validating resolver. The resolver must first prove that the queried name is below an insecure delegation. For a signed domain, an unsigned positive answer should be Bogus, not Insecure.

The basic bypass PoC uses cloudflare.com., a public DNSSEC-signed domain. Blocky returns NOERROR and the forged A record while issuing zero target DS and DNSKEY queries.

2. Cache writes happen before outer DNSSEC validation can reject or transform the response

server/server.go:526-543 constructs the resolver chain with dnssecResolver before cachingResolver and includes a comment saying DNSSEC validation happens before caching:

dnssecResolver, // DNSSEC validation BEFORE caching - validates all responses before they are cached
cachingResolver,
...
upstreamTree,

However, chained resolver execution is outer-to-inner. DNSSECResolver.Resolve first calls r.next.Resolve, and CachingResolver.Resolve writes cache entries on misses before control returns to the DNSSEC layer:

  • resolver/dnssec_resolver.go:88-96: the DNSSEC resolver calls r.next.Resolve(ctx, request) before ValidateResponse.
  • resolver/caching_resolver.go:225-230: on cache miss, the cache resolver calls the next resolver and then immediately calls putInCache.
  • resolver/caching_resolver.go:326-341: the cache write only checks rcode and basic cacheability; it does not bind the entry to a DNSSEC validation result.

The practical result is that the DNS response cache can store data that has not yet survived final DNSSEC validation.

3. Validation cache is keyed only by bare domain name

resolver/dnssec/chain.go:16-31 exposes:

getCachedValidation(domain string)
setCachedValidation(domain string, result ValidationResult)

resolver/dnssec/validator.go:638-642 reuses this cache for zone-security checks:

if cached, found := v.getCachedValidation(domain); found {
    return cached
}

The key does not include:

  • qclass;
  • qtype or proof purpose;
  • current client view;
  • ECS, client IP, client name, or request client ID;
  • conditional-forwarding branch;
  • effective upstream group;
  • proof source zone;
  • parent zone;
  • trust-anchor path;
  • validation policy or algorithm set.

This allows one response path or proof purpose to seed a DNSSEC status for another path.

4. Unsigned NSEC/NSEC3 presence is treated as authenticated DS absence

resolver/dnssec/validator.go:655-667 queries DS records when an RRset has no matching RRSIG. If no DS records are extracted, it calls handleNoDSRecords.

resolver/dnssec/validator.go:682-690 then does:

hasNSEC := len(extractNSECRecords(dsResponse.Ns)) > 0
hasNSEC3 := len(extractNSEC3Records(dsResponse.Ns)) > 0

if hasNSEC || hasNSEC3 {
    result := ValidationResultInsecure
    v.setCachedValidation(domain, result)
    return result
}

This code does not validate the NSEC/NSEC3 RRset signature and does not validate the parent zone chain before trusting the denial proof. The comment calls this an authenticated denial of DS existence, but the code only checks for record presence.

5. DNSSEC auxiliary queries do not preserve original request context

resolver/dnssec_resolver.go:47-52 creates the validator with upstream as the resolver used for DS/DNSKEY lookups. resolver/dnssec/query.go:57-69 builds synthetic requests containing only qname/qtype and sends them to v.upstream.Resolve.

Those synthetic requests do not preserve the original request's client IP, client names, ECS data, request client ID, or conditional-forwarding context. resolver/upstream_tree_resolver.go:123-162 chooses upstream groups based on client metadata; missing metadata can cause DNSSEC auxiliary queries to use a different upstream view from the answer being validated.

This is a scope problem even apart from the direct basic bypass.

Reproduction

Environment

  • Repository: /home/hurrison/workspace/dnssec/repos/blocky
  • Commit: e0ea9b3ea56e3d074569abd3010251e7c6ebd593
  • No root privileges required.
  • No public DNS dependency; PoCs use local loopback high ports.
  • Both PoCs query Blocky's real DNS listener through UDP.

PoC 1: basic unsigned-response DNSSEC bypass

Run:

cd /home/hurrison/workspace/dnssec/repos/blocky
go run ./exp/external-dnssec-basic-bypass

Artifact:

report/artifacts/basic-bypass-output.txt

Key output:

query 1:
  rcode: NOERROR
  answers: cloudflare.com. A 203.0.113.77 ttl=120
  target A upstream queries: 1
  target DS upstream queries: 0
  target DNSKEY upstream queries: 0

stopping malicious upstream before query 2
query 2:
  rcode: NOERROR
  answers: cloudflare.com. A 203.0.113.77 ttl=120
  target A upstream queries: 1
  target DS upstream queries: 0
  target DNSKEY upstream queries: 0

BASIC BYPASS CONFIRMED: Blocky accepted and cached an unsigned poisoned response without querying DS/DNSKEY for the target.

Interpretation:

  • cloudflare.com. is treated as if it were insecure only because the forged response contained no RRSIG records.
  • Blocky does not query DS or DNSKEY for the target before accepting the answer.
  • The second answer is served after the malicious upstream is shut down, proving cache replay.

PoC 2: forged insecure proof and validation-cache scope pollution

Run:

cd /home/hurrison/workspace/dnssec/repos/blocky
go run ./exp/external-dnssec-cache-scope-pollution

Artifact:

report/artifacts/poc-output.txt

Key output:

query 1:
  rcode: NOERROR
  answers: victim.signed.example. A 203.0.113.66 ttl=120 | decoy.victim.signed.example. RRSIG type-covered=TXT ttl=120
  victim A upstream queries: 1
  victim DS proof queries: 1

stopping malicious upstream before query 2
query 2:
  rcode: NOERROR
  answers: victim.signed.example. A 203.0.113.66 ttl=119 | decoy.victim.signed.example. RRSIG type-covered=TXT ttl=119
  victim A upstream queries: 1
  victim DS proof queries: 1

EXP SUCCESS: poisoned data was accepted over Blocky's DNS listener and replayed on a second external query after the malicious upstream was shut down.

Interpretation:

  • The response contains an unrelated RRSIG to force the RRset-validation path.
  • The A RRset has no matching RRSIG.
  • The forged DS response contains no DS and an unsigned NSEC in authority.
  • Blocky caches Insecure for the domain and returns the poisoned answer.
  • The second response is served after the malicious upstream is shut down.

Expected behavior

For a DNSSEC validating resolver:

  • Missing RRSIGs in a positive response must not automatically imply an insecure zone.
  • The resolver must prove that the queried name is under an insecure delegation before accepting an unsigned answer.
  • If the parent chain indicates the name should be signed, an unsigned positive answer must be treated as bogus.
  • DS absence must be proven by authenticated denial of existence, not by the mere presence of NSEC/NSEC3 records.
  • DNS response cache entries must not be written before the final DNSSEC decision, or they must be bound to validation metadata that is checked on cache hit.
  • Validation cache entries must be scoped to the proof purpose, class, view, upstream group, proof source, and trust path.

Actual behavior

  • Unsigned positive responses are classified as Insecure without DS/DNSKEY chain checks.
  • CachingResolver writes responses before outer DNSSEC validation runs.
  • An unsigned NSEC/NSEC3 in a DS response can mark a domain as Insecure.
  • The Insecure result is cached by bare domain name.
  • Poisoned answers are replayed from Blocky's DNS cache on later external queries.

Impact

The impact is DNSSEC validation bypass and persistent DNS cache poisoning:

  • forged A/AAAA/CNAME/MX/TXT records can be returned for signed domains;
  • poisoned records can be replayed to later clients for the cache TTL;
  • traffic can be redirected to attacker-controlled infrastructure;
  • update systems, package mirrors, service discovery, mail routing, and TLS bootstrapping flows may be affected depending on client behavior;
  • split-horizon and conditional-forwarding deployments may suffer cross-view validation-state pollution;
  • an attacker can also induce false Bogus or Indeterminate states in related logic, causing targeted SERVFAIL or AD-bit stripping.

The basic bypass is sufficient for a malicious or intercepted recursive upstream to defeat Blocky's documented DNSSEC protection. The cache-scope pollution path shows additional design risk in deployments with multiple views, upstream groups, or conditional forwarding.

Attachments

blocky-dnssec-validation-cache-scope-pollution-attachments.zip

report/
  blocky-dnssec-validation-cache-scope-pollution-report.md
  attachments/
    external-dnssec-basic-bypass/
      main.go
      README.md
    external-dnssec-cache-scope-pollution/
      main.go
      README.md
  artifacts/
    basic-bypass-output.txt
    poc-output.txt

Attachment descriptions:

  • attachments/external-dnssec-basic-bypass/main.go: external PoC for the direct unsigned-response DNSSEC bypass.
  • attachments/external-dnssec-basic-bypass/README.md: usage notes for the basic bypass PoC.
  • attachments/external-dnssec-cache-scope-pollution/main.go: external PoC for forged insecure proof and validation-cache scope pollution.
  • attachments/external-dnssec-cache-scope-pollution/README.md: usage notes for the cache-scope PoC.
  • artifacts/basic-bypass-output.txt: recorded output for PoC 1.
  • artifacts/poc-output.txt: recorded output for PoC 2.

Credit

Yuheng Zhang @ Tsinghua University
Jianjun Chen@ Tsinghua University

References

@0xERR0R 0xERR0R published to 0xERR0R/blocky Jun 16, 2026
Published to the GitHub Advisory Database Jun 19, 2026
Reviewed Jun 19, 2026
Last updated Jun 19, 2026

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
High
Availability
Low

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:L

EPSS score

Weaknesses

Origin Validation Error

The product does not properly verify that the source of data or communication is valid. Learn more on MITRE.

Reliance on Untrusted Inputs in a Security Decision

The product uses a protection mechanism that relies on the existence or values of an input, but the input can be modified by an untrusted actor in a way that bypasses the protection mechanism. Learn more on MITRE.

CVE ID

No known CVE

GHSA ID

GHSA-x845-2f78-7v36

Source code

Credits

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.