Skip to content

Accept DTLS replies from any port on the target host - #73

Merged
QuiteYellow merged 2 commits into
mainfrom
fix/host-filtered-udp-socket
Aug 31, 2026
Merged

Accept DTLS replies from any port on the target host#73
QuiteYellow merged 2 commits into
mainfrom
fix/host-filtered-udp-socket

Conversation

@QuiteYellow

Copy link
Copy Markdown
Owner

Fixes #66.

The failure

dtls_probe and DtlsCoapSession opened a connected UDP socket, which accepts datagrams only from the exact port it dialled. @elirnyk's tcpdump on an NQ8300T oven shows the reply arriving from a different port:

192.168.146.11.57331  > 192.168.146.252.5684:  UDP, length 160
192.168.146.252.59768 > 192.168.146.11.57331:  UDP, length 60
192.168.146.11 > 192.168.146.252: ICMP udp port 57331 unreachable

The kernel discarded that 60-byte reply and answered it with ICMP, so the probe reported a live appliance as dead.

Why an appliance does this

Stock RT-OCF, messaging/transport/rt_udp.c, rt_udp_open_server():

rt_udp_set_socket_bind_specific_port(udp_socket.mcast_v4, 1, MULTICAST_PORT_V4);  /* 5683 */
rt_udp_set_socket_bind_specific_port(udp_socket.ucast_v4, 1, 0);
rt_udp_set_socket_bind_specific_port(udp_socket.dtls_v4,  1, 0);

Multicast takes the only fixed port. The unicast and DTLS sockets bind port 0, so the kernel assigns them. rt_udp_get_secure_port_v4() reads that assigned port back off dtls_v4, and that is the value advertised as dport in /oic/res.

So a secure port is ephemeral by design, it is reassigned across a reboot, and a reply from a port nobody dialled is the appliance's real DTLS socket. This is also why the advertised port in #16 moved from 46060 to 39181 over five days.

The change

HostFilteredUdpSocket and open_host_filtered_udp_socket in endpoint.py. The socket binds without connecting, keeps sending to the resolved destination, and filters inbound datagrams on host alone, which is what ocf_discovery already does. Both dtls_probe call sites and the DtlsCoapSession call site use it. open_connected_udp_socket is unchanged and still covered by its own tests.

Sending stays on the port originally dialled. Following the reply port would be a behaviour change with nothing behind it, and #66's capture shows the oven still accepting on 5684.

Measured against a responder that answers from another source port

before after
probe, asymmetric reply dead, 3 ClientHellos discarded live 8ms HelloVerifyRequest, 1 ClientHello
probe, symmetric reply live live
session, asymmetric reply ClientHello cookie lengths 0, 0, 0 0, 32, 32, 32

The last row is the session half: before this, the client never saw the HelloVerifyRequest, so every ClientHello went out without a cookie. The responder used for those runs binds one socket for receiving and answers from a second, unbound one; the behaviour it exercises is covered by the unit tests below.

Two things to be aware of

Off-path spoofing resistance drops from address-and-port to address alone. The DTLS cookie exchange and handshake authentication are the real protection either way, and ocf_discovery already accepted this, but it now applies on the session path too.

_ADVISORY_ERRNOS in dtls_session.py covered ICMP errors that only a connected socket surfaces. The comment there already recorded that the reader survived unconnected sockets before d677c72, so that handling now costs nothing and catches nothing on this path. I updated the comment and kept the handling for callers supplying their own socket adapter.

676 tests pass, 6 of them new: acceptance from another port, send-target stability, rejection of another host, IPv6 scope as part of host identity, and a bound deadline under a flood of foreign datagrams.

RT-OCF binds its unicast and DTLS sockets to port 0 (rt_udp.c,
rt_udp_open_server), so an appliance's secure port is kernel-assigned and
it answers from a port the client never dialled. A connected UDP socket
drops those datagrams and the kernel returns ICMP port unreachable, which
made dtls_probe report a live appliance as dead and left the session
retransmitting a cookie-less ClientHello until its deadline.

Add HostFilteredUdpSocket and open_host_filtered_udp_socket, which bind
without connecting and filter inbound datagrams on host alone, matching
what ocf_discovery already does. Sending stays on the port originally
dialled, since issue #66's capture shows the appliance still accepting
there. open_connected_udp_socket is unchanged.

Off-path spoofing resistance drops from address-and-port to address
alone; the DTLS cookie exchange and handshake authentication remain the
real protection.
The share-safety check rejects non-documentation addresses, and the scope
comparison behaves identically on 2001:db8::/32.
@QuiteYellow
QuiteYellow merged commit 325dfe6 into main Aug 31, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

NQ8300T oven probe always failing

1 participant