Blocky accepts and caches forged DNS answers while dnssec.validate: true is enabled. The issue has two related exploit paths:
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.
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.
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:
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.cfg.DNSSEC.Validate = true and do not override TrustAnchors, so they use the documented built-in root trust-anchor path.config.NetProtocolTcpUdp as the upstream transport, which is one of the documented upstream protocols.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.
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:
Attack steps:
cloudflare.com. A.cloudflare.com. 120 IN A 203.0.113.77.ValidationResultInsecure without issuing target DS or DNSKEY queries.This path is demonstrated by attachments/external-dnssec-basic-bypass/main.go.
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:
Attack steps:
victim.signed.example. A.no RRSIG branch.victim.signed.example. is in a signed or unsigned zone by querying DS records.ValidationResultInsecure for the bare domain name, and accepts the unsigned A RRset.This path is demonstrated by attachments/external-dnssec-cache-scope-pollution/main.go.
no RRSIG is treated as Insecure before chain status is checkedIn 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.
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.
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:
This allows one response path or proof purpose to seed a DNSSEC status for another path.
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.
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.
/home/hurrison/workspace/dnssec/repos/blockye0ea9b3ea56e3d074569abd3010251e7c6ebd593Run:
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.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:
Insecure for the domain and returns the poisoned answer.For a DNSSEC validating resolver:
Insecure without DS/DNSKEY chain checks.CachingResolver writes responses before outer DNSSEC validation runs.Insecure.Insecure result is cached by bare domain name.The impact is DNSSEC validation bypass and persistent DNS cache poisoning:
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.
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.Yuheng Zhang @ Tsinghua University Jianjun Chen@ Tsinghua University
{
"cwe_ids": [
"CWE-346",
"CWE-807"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-19T20:47:46Z",
"nvd_published_at": null,
"severity": "HIGH"
}