Versions
- Pi-hole Core: v6.4.3
- Web interface: v6.6
- FTL: v6.7
- Exact FTL source commit used for reproduction:
fa65a88f8cdef1013594d4de14108077954faea4
src/resolve.c blob at that commit: 079d5451e93035876a280104488c17461ad3969e
Platform
Original observation:
- OS: Debian 12
- Platform: native Raspberry Pi / arm64
- Internal resolver topology: FTL queries Pi-hole on
127.0.0.1, with local reverse forwarding and a local Unbound resolver
Deterministic reproduction:
- Full FTL daemon with embedded dnsmasq
- Pi-hole's official build/runtime image:
ghcr.io/pi-hole/ftl-build:v2.19
- Isolated Docker network with two client addresses and a controlled UDP DNS server
- Independent
AF_PACKET capture used to validate the packet sequence
Expected behavior
A UDP DNS response should only be accepted for the request it matches. At minimum, the internal resolver should correlate the response using the DNS transaction ID and the echoed question before parsing or storing its answer.
A delayed response to request A must not be accepted while the resolver is waiting for request B.
Actual behavior / bug
The internal PTR resolver reuses one UDP socket across sequential client lookups. Its UDP path sends a query and consumes a single datagram with recvfrom(), then parses that datagram as the active response without visibly checking the received response against the active request.
In the v6.7 source used here, the UDP receive path does not validate:
- source address and port against the intended endpoint;
QR and opcode;
- DNS transaction ID;
QDCOUNT;
- echoed QNAME;
- QTYPE;
- QCLASS.
The recvfrom() byte count is also discarded. Answer parsing starts at an offset based on the outbound query length, and parser bounds use the full receive buffer rather than the actual datagram length.
This allows the following sequence:
send PTR request A
A exceeds the resolver's receive timeout
send PTR request B on the same UDP socket
delayed response A arrives
response A is consumed and parsed as the response to B
A's PTR hostname is stored for client B
correct response B arrives later, after B's lookup has already completed
Deterministic full-daemon reproduction
The controlled server handled two reverse names:
A = 2.251.29.172.in-addr.arpa
B = 3.251.29.172.in-addr.arpa
The server intentionally delayed A beyond FTL's two-second receive timeout. After FTL sent B, the server sent the delayed, otherwise valid response for A, followed shortly afterwards by the correct response for B.
Response names:
A -> late-a.example
B -> answer-b.example
The test used the complete FTL daemon, embedded dnsmasq, real FTL client state, the normal resolver event flow, the HTTP API to inspect the stored client name, and an independent packet capture.
Results across five fresh daemon runs per variant:
BASELINE_STALE_NAME_STORED_COUNT=5
PATCH_STALE_RESPONSE_REJECTED_COUNT=5
PATCH_CORRECT_NAME_STORED_COUNT=5
PCAP_CAPTURE_PASS_COUNT=10
SIGNAL_FALLBACK_COUNT=0
Baseline result, 5/5 runs:
client B name = late-a.example
PoC-patched result, 5/5 runs:
delayed A response rejected
client B name = answer-b.example
SIGNAL_FALLBACK_COUNT=0 means no extra forced resolver event was required. The normal FTL resolver flow produced the result.
The independent PCAP validation confirmed the controlled A/B query and response sequence for all ten daemon runs.
Additional isolated regression harness
A smaller protocol-level harness reproducing the same shared-socket sequence was also run ten times:
BASELINE_FIRST_TIMEOUT=PASS
BASELINE_STALE_ACCEPT_COUNT=10
PATCH_STALE_REJECT_COUNT=10
PATCH_MATCH_ACCEPT_COUNT=10
REGRESSION_PASS_COUNT=10
PoC fix direction
I built a proof-of-concept change against the exact commit above. It:
- records the outbound transaction ID;
- captures the actual received datagram length;
- validates the response source endpoint;
- validates
QR, opcode, ID and QDCOUNT;
- parses and compares the echoed QNAME, QTYPE and QCLASS;
- discards mismatched datagrams and continues waiting;
- uses one absolute monotonic deadline, so rejected packets do not extend the original timeout;
- starts answer parsing after the received question section;
- bounds parsing by the actual received datagram length.
The PoC is intended as a demonstrated fix direction, not a demand that upstream use the exact implementation.
PoC artifacts:
patched src/resolve.c blob:
549838b2d3833b5d7a3c0f84b400e55a3ecb0f81
patch SHA-256:
f83e57c0f2bd2d366111952c7b3562d558584885e1e6d3e84c794f33e0875cac
baseline full binary SHA-256:
8d806657387ba05454b8cfdca3e0149ef67731efb1c8e7e61a3c393b157ef18a
patched full binary SHA-256:
285f9badda189f91b335011f2a671ce4ff819ef8ce3528c71f8a2853e97ff900
Both binaries passed FTL's binary-integrity verification. The patched source compiled as a complete FTL binary in the official v2.19 build image.
Public history check
Before reporting this, I mirrored the public repository and fetched public pull-request heads. The audit covered:
53 public branch heads
127 tags
2079 public PR heads
141 commits touching src/resolve.c
140 unique src/resolve.c versions
A function-scoped audit of ngethostbyname() found no publicly reachable version with either an explicit DNS response-ID comparison or complete response-question correlation:
MANUAL_REVIEW_SIGNAL_BLOB_COUNT=0
EXACT_RESPONSE_CORRELATION_CANDIDATE_BLOB_COUNT=0
Current public master and development both had the same src/resolve.c blob as the v6.7 baseline when checked.
This obviously cannot cover private branches, deleted refs, private discussions, or code never pushed publicly.
Original observation and configuration note
The original investigation began after intermittent invalid PTR hostname warnings for local clients. Normal direct results included one address with a valid local PTR and another returning NXDOMAIN.
The original installation also had a dead ::1#5335 Unbound upstream while Unbound listened only on IPv4 loopback. That configuration error was corrected. It can explain additional timeouts, but it cannot explain accepting request A's response as request B's response.
The full-daemon reproduction above is isolated from that installation and does not depend on Unbound, the router, conditional forwarding, DNSSEC, or the original network configuration.
The historical production event is therefore strongly consistent with this mechanism, but I cannot claim absolute proof for that specific past event because no packet capture exists from the exact occurrence.
Debug data and artifacts
I have retained:
- the exact PoC patch;
- the protocol harness source and logs;
- all full-daemon run logs;
- API state validation output;
- ten independent PCAP captures;
- manifests and SHA-256 receipts;
- the public-history audit output.
I have not attached the original installation's unsanitized debug bundle or network captures publicly because they contain local identifiers. I can provide sanitized reproduction artifacts and the PoC patch in a follow-up or prepare a PR after maintainers confirm the preferred direction.
Versions
fa65a88f8cdef1013594d4de14108077954faea4src/resolve.cblob at that commit:079d5451e93035876a280104488c17461ad3969ePlatform
Original observation:
127.0.0.1, with local reverse forwarding and a local Unbound resolverDeterministic reproduction:
ghcr.io/pi-hole/ftl-build:v2.19AF_PACKETcapture used to validate the packet sequenceExpected behavior
A UDP DNS response should only be accepted for the request it matches. At minimum, the internal resolver should correlate the response using the DNS transaction ID and the echoed question before parsing or storing its answer.
A delayed response to request A must not be accepted while the resolver is waiting for request B.
Actual behavior / bug
The internal PTR resolver reuses one UDP socket across sequential client lookups. Its UDP path sends a query and consumes a single datagram with
recvfrom(), then parses that datagram as the active response without visibly checking the received response against the active request.In the v6.7 source used here, the UDP receive path does not validate:
QRand opcode;QDCOUNT;The
recvfrom()byte count is also discarded. Answer parsing starts at an offset based on the outbound query length, and parser bounds use the full receive buffer rather than the actual datagram length.This allows the following sequence:
Deterministic full-daemon reproduction
The controlled server handled two reverse names:
The server intentionally delayed A beyond FTL's two-second receive timeout. After FTL sent B, the server sent the delayed, otherwise valid response for A, followed shortly afterwards by the correct response for B.
Response names:
The test used the complete FTL daemon, embedded dnsmasq, real FTL client state, the normal resolver event flow, the HTTP API to inspect the stored client name, and an independent packet capture.
Results across five fresh daemon runs per variant:
Baseline result, 5/5 runs:
PoC-patched result, 5/5 runs:
SIGNAL_FALLBACK_COUNT=0means no extra forced resolver event was required. The normal FTL resolver flow produced the result.The independent PCAP validation confirmed the controlled A/B query and response sequence for all ten daemon runs.
Additional isolated regression harness
A smaller protocol-level harness reproducing the same shared-socket sequence was also run ten times:
PoC fix direction
I built a proof-of-concept change against the exact commit above. It:
QR, opcode, ID andQDCOUNT;The PoC is intended as a demonstrated fix direction, not a demand that upstream use the exact implementation.
PoC artifacts:
Both binaries passed FTL's binary-integrity verification. The patched source compiled as a complete FTL binary in the official v2.19 build image.
Public history check
Before reporting this, I mirrored the public repository and fetched public pull-request heads. The audit covered:
A function-scoped audit of
ngethostbyname()found no publicly reachable version with either an explicit DNS response-ID comparison or complete response-question correlation:Current public
masteranddevelopmentboth had the samesrc/resolve.cblob as the v6.7 baseline when checked.This obviously cannot cover private branches, deleted refs, private discussions, or code never pushed publicly.
Original observation and configuration note
The original investigation began after intermittent invalid PTR hostname warnings for local clients. Normal direct results included one address with a valid local PTR and another returning NXDOMAIN.
The original installation also had a dead
::1#5335Unbound upstream while Unbound listened only on IPv4 loopback. That configuration error was corrected. It can explain additional timeouts, but it cannot explain accepting request A's response as request B's response.The full-daemon reproduction above is isolated from that installation and does not depend on Unbound, the router, conditional forwarding, DNSSEC, or the original network configuration.
The historical production event is therefore strongly consistent with this mechanism, but I cannot claim absolute proof for that specific past event because no packet capture exists from the exact occurrence.
Debug data and artifacts
I have retained:
I have not attached the original installation's unsanitized debug bundle or network captures publicly because they contain local identifiers. I can provide sanitized reproduction artifacts and the PoC patch in a follow-up or prepare a PR after maintainers confirm the preferred direction.