SagerNet / SagerNet/sing-box

Hijacked DNS sessions ignore the documented 10s protocol timeout

Open Beginner friendly
#4,428 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Go
Stars
38.1k
Forks
4.6k
Avg merge
19d 15h
Merged PRs (30d)
1

Description

Hijacked DNS sessions ignore the documented 10s protocol timeout, causing "unpack request: bad rdata" floods

Version

sing-box 1.13.18 (verified against the tag; also present in 1.13.11).
Reproduced on Windows 10, Android 11 and iOS 26 clients using tun inbound.

What happens

With tun + action: hijack-dns, the log fills with parse failures for packets
that are not DNS at all:

router: process DNS packet: unpack request: bad question name: dns: bad rdata
router: process DNS packet: unpack request: bad question name: dns: buffer size too small

They arrive in bursts — one connection ID producing 8–12 consecutive failures —
and the connection age at failure time clusters right below the inbound
udp_timeout:

ERROR [2631868615 57.25s] router: process DNS packet: unpack request: bad question name: dns: bad rdata
ERROR [2631868615 57.30s] ... (x4)
ERROR [2631868615 1m0s]   ...
ERROR [2631868615 1m1s]   ...

That log is from a client configured with "udp_timeout": "60s". With the
default 5m the same failures appear, just spread over a much wider window.

Root cause

1. The sniffer decides "this is DNS" once, for the whole session.

common/sniff/dns.go:

func DomainNameQuery(ctx context.Context, metadata *adapter.InboundContext, packet []byte) error {
	var msg mDNS.Msg
	err := msg.Unpack(packet)
	if err != nil || msg.Response || len(msg.Question) == 0 || len(msg.Answer) > 0 || len(msg.Ns) > 0 {
		return err
	}
	metadata.Protocol = C.ProtocolDNS
	return nil
}

Only the first packet is validated. Every later packet on that UDP NAT session
is handed to the DNS request parser unconditionally.

2. The NAT session outlives the client socket.

The application sends a query from ephemeral port X, gets its answer, and closes
the socket. The OS is then free to hand port X to another process — but the NAT
session is still alive and still flagged ProtocolDNS. The new owner's traffic
lands in ExchangeDNSPacket, msg.Unpack fails, and each packet logs an error.

This matches the observed timing exactly: failures cluster just under the
session timeout, i.e. at the very end of the session's life, which is when
port reuse becomes likely.

3. The timeout that would prevent this exists, but never reaches hijacked sessions.

constant/timeout.go defines exactly the right value:

var ProtocolTimeouts = map[string]time.Duration{
	ProtocolDNS:  10 * time.Second,
	ProtocolNTP:  10 * time.Second,
	ProtocolSTUN: 10 * time.Second,
	ProtocolQUIC: 30 * time.Second,
	ProtocolDTLS: 30 * time.Second,
}

and the documentation promises it
(Rule Action → udp_timeout):

Default value for protocol sniffed connections: 10sdns, ntp, stun

But that table is only consulted in route/conn.go, on the path for connections
routed out through an outbound:

var udpTimeout time.Duration
if metadata.UDPTimeout > 0 {
	udpTimeout = metadata.UDPTimeout
} else {
	protocol := metadata.Protocol
	...
	udpTimeout = C.ProtocolTimeouts[protocol]
}

Hijacked DNS does not go through ConnectionManager. It takes the separate path
in route/dns.go:

func (r *Router) hijackDNSPacket(ctx context.Context, conn N.PacketConn, ...) error {
	if natConn, isNatConn := conn.(udpnat.Conn); isNatConn {
		...
		natConn.SetHandler(&dnsHijacker{...})
		return nil
	}

No timeout is applied here, so the session keeps the inbound-wide udp_timeout
(5m by default) instead of the documented 10s for DNS.

4. The API to fix it already exists and is never used.

sing/common/udpnat2/conn.go provides:

func (c *natConn) SetTimeout(timeout time.Duration) bool

git grep SetTimeout over sing-box v1.13.18 returns no call sites.

Suggested fix

In hijackDNSPacket, shorten the session to the protocol timeout right where the
handler is installed:

if natConn, isNatConn := conn.(udpnat.Conn); isNatConn {
	metadata.Destination = M.Socksaddr{}
	natConn.SetTimeout(C.ProtocolTimeouts[C.ProtocolDNS])   // <-- 10s, as documented
	...
}

This makes the behaviour match the documentation, and shrinks the window during
which a reused ephemeral port can be mistaken for an ongoing DNS session by a
factor of 30 (5m → 10s).

Notes
  • The failures are harmless in themselves — the packet is dropped and the
    application retries — but they drown real errors in the log. On one client
    they accounted for the majority of ERROR lines over a night.
  • A partial workaround exists for one known source, mDNS/Bonjour, by routing
    UDP port 5353 directly instead of hijacking it. That removes one contributor
    but not the mechanism.
  • Possibly the same root cause as #3478 and #3531, which report the identical
    parse errors without an explanation.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in route/dns.go at hijackDNSPacket and inspect how the udpnat connection handler is installed. Read constant/timeout.go and sing/common/udpnat2/conn.go to confirm the existing protocol timeout and SetTimeout API. Done means hijacked DNS sessions use the documented 10-second timeout instead of the inbound-wide udp_timeout.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
networking
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
74/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.