1
0
mirror of https://github.com/coturn/coturn.git synced 2026-10-24 00:08:57 +03:00
Files
coturn/examples/scripts/stateless_binding_probe.py
Pavel Punsky 0fb7fbade1 Binding stateless fastpath (#2024)
## What

Answer a plain STUN Binding request from an unknown UDP source in the
listener, instead of building a child socket and a session to reach
`handle_turn_binding()`.

That function derives its entire response from the request bytes and the
socket's own addresses — nothing in it needs session state. But reaching
it costs an `ioa_socket` plus an ~18KB `ts_ur_super_session`, and the
to-be-allocated timer holds both for 60 seconds. So one spoofed Binding
request per source buys ~22KB of server memory for a minute, in the
**default configuration**, with no authentication and no flag involved.

| | RSS growth, 3000 Binding requests from distinct source ports |
|---|---|
| master | **+66,720 KB** |
| this branch | **+304 KB** |

All requests are still answered, byte for byte.

## How

`udp_stateless_binding_fast_path()` in `dtls_listener.c`, called from
`handle_udp_packet()` for a source with no child socket yet. It rebuilds
the reply attribute for attribute:

- success response with XOR-MAPPED-ADDRESS (plus MAPPED-ADDRESS under
`--stun-backward-compatibility`, plus RESPONSE-ORIGIN/OTHER-ADDRESS
under `--rfc5780`), or `420` with the same UNKNOWN-ATTRIBUTES list;
- then SOFTWARE and FINGERPRINT on the same conditions as the session
path;
- and the same Prometheus binding counters (`stun_report_binding()`
already ignores its session argument).

The attribute walk mirrors `handle_turn_binding()`'s exactly, including
`SKIP_ATTRIBUTES`, so ICE-style requests carrying
USERNAME/PRIORITY/MESSAGE-INTEGRITY/FINGERPRINT are accepted rather than
flagged as unknown.

Requests the listener cannot answer identically still take the session
path:

- `--secure-stun` — BINDING is an authenticated method there, which is
`check_stun_auth()`'s business;
- CHANGE-REQUEST / RESPONSE-PORT / PADDING — the RFC 5780 probes leave
from an alternate socket that only the relay owns;
- `--log-binding` — its per-session log lines can only be written by the
relay, so the old path stays available for debugging.

Under `--no-stun` the request is dropped where the relay would have
ignored it anyway — same silence on the wire, minus the session it used
to leave behind.

`MAX_NUMBER_OF_UNKNOWN_ATTRS` moves from `ns_turn_server.c` to
`ns_turn_server.h` so both call sites share one definition.

## Wire compatibility

A byte-level dump of the reply (message type plus every attribute TLV)
was compared between a master build and this one for ten request shapes
— bare Binding, ICE-style, unknown comprehension-required attributes,
unknown comprehension-optional attribute, CHANGE-REQUEST with and
without bits set, RESPONSE-PORT, PADDING, and an attribute placed after
MESSAGE-INTEGRITY — in default configuration and with
`--software-attribute --fingerprint --stun-backward-compatibility`.
**Identical in every case.**

## Tests

New `examples/run_tests_stateless_binding.sh` with
`examples/scripts/stateless_binding_probe.py`:

- wire shapes, including that the reflexive port in XOR-MAPPED-ADDRESS
is the client's own and that the 420 echoes the offending attributes;
- `--no-stun` stays silent, `--secure-stun` still challenges with 401;
- a 2000-request flood asserting RSS growth stays under the threshold.

The suite discriminates: against a master binary the memory case fails
with `Binding flood grew RSS by 43632KB ... sessions are being created
again`, while every wire assertion passes on both builds.

Also run green: `ctest` (18/18 macOS, 17/17 Linux),
`turnutils_rfc5769check`, and `run_tests.sh`, `run_tests_conf.sh`,
`run_tests_mobile.sh`, `run_tests_multiplex_peer.sh`,
`run_tests_stateless_nonce.sh`, `run_tests_ratelimit_401.sh`,
`run_tests_rfc5780.sh` — the last one being the important one here,
since it exercises both the plain Binding response that now comes from
the listener and the CHANGE-REQUEST probes that must still reach the
relay.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:16:45 -07:00

235 lines
7.8 KiB
Python
Executable File

#!/usr/bin/env python3
# Wire probe for the listener-side STUN Binding fast path (see
# examples/run_tests_stateless_binding.sh).
#
# A Binding request from an unknown UDP source is answered by the listener
# itself, so every reply shape has to match what the relay's handle_turn_binding
# would have produced from a session: the success response and its
# XOR-MAPPED-ADDRESS, the 420 UNKNOWN-ATTRIBUTES list, the attributes ICE
# clients attach (USERNAME/PRIORITY/MESSAGE-INTEGRITY/FINGERPRINT are accepted
# without authentication), and the RFC 5780 probes, which must still reach the
# relay because only it owns the alternate sockets.
#
# Usage: stateless_binding_probe.py <host> <port> [mode]
# mode: wire (default) | silent | challenge | flood <count>
import binascii
import os
import socket
import struct
import sys
MAGIC = 0x2112A442
M_BINDING = 0x0001
BINDING_SUCCESS = 0x0101
BINDING_ERROR = 0x0111
A_MAPPED = 0x0001
A_CHANGE_REQUEST = 0x0003
A_USERNAME = 0x0006
A_MI = 0x0008
A_ERROR = 0x0009
A_UNKNOWN_ATTRS = 0x000A
A_XOR_MAPPED = 0x0020
A_PRIORITY = 0x0024
A_PADDING = 0x0026
A_RESPONSE_PORT = 0x0027
A_SOFTWARE = 0x8022
A_FINGERPRINT = 0x8028
def attr(t, v):
return struct.pack("!HH", t, len(v)) + v + b"\0" * ((-len(v)) & 3)
def make(attrs, tid=None, fingerprint=False):
body = b"".join(attr(t, v) for t, v in attrs)
tid = tid or os.urandom(12)
if fingerprint:
hdr = struct.pack("!HHI12s", M_BINDING, len(body) + 8, MAGIC, tid)
crc = (binascii.crc32(hdr + body) & 0xFFFFFFFF) ^ 0x5354554E
body += attr(A_FINGERPRINT, struct.pack("!I", crc))
return struct.pack("!HHI12s", M_BINDING, len(body), MAGIC, tid) + body
def parse(msg):
typ, ln, _magic, tid = struct.unpack("!HHI12s", msg[:20])
out = []
p = 20
while p + 4 <= min(len(msg), 20 + ln):
t, n = struct.unpack("!HH", msg[p : p + 4])
out.append((t, msg[p + 4 : p + 4 + n]))
p += 4 + n + ((-n) & 3)
return typ, tid, out
def get(attrs, t):
for k, v in attrs:
if k == t:
return v
return None
def errcode(attrs):
v = get(attrs, A_ERROR)
return (v[2] & 7) * 100 + v[3] if v and len(v) >= 4 else None
def xor_port(value):
"""Port from an XOR-MAPPED-ADDRESS value."""
return struct.unpack("!H", value[2:4])[0] ^ (MAGIC >> 16)
def xact(sock, msg, host, port):
sock.sendto(msg, (host, port))
return parse(sock.recvfrom(4096)[0])
def wire(host, port):
ok = True
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(3)
s.bind(("127.0.0.1", 0))
src_port = s.getsockname()[1]
# Plain Binding: success, and the reflexive port must be this socket's.
tid = os.urandom(12)
typ, rtid, aa = xact(s, make([], tid=tid), host, port)
xm = get(aa, A_XOR_MAPPED)
if typ != BINDING_SUCCESS or rtid != tid or not xm or xor_port(xm) != src_port:
print(f"FAIL: plain Binding: type={typ:#x} tid_match={rtid == tid} xor_port={xm and xor_port(xm)} want {src_port}")
ok = False
else:
print(f"OK: plain Binding: success, XOR-MAPPED-ADDRESS port {src_port}")
# ICE-style attributes are accepted without authentication, and a request
# that carries FINGERPRINT gets one back.
typ, _t, aa = xact(
s,
make([(A_USERNAME, b"remote:local"), (A_PRIORITY, b"\x7f\0\0\0"), (A_MI, b"\0" * 20)], fingerprint=True),
host,
port,
)
if typ != BINDING_SUCCESS or not get(aa, A_XOR_MAPPED) or get(aa, A_FINGERPRINT) is None:
print(f"FAIL: ICE-style Binding: type={typ:#x} fingerprint={get(aa, A_FINGERPRINT)}")
ok = False
else:
print("OK: ICE-style Binding (USERNAME/PRIORITY/MESSAGE-INTEGRITY/FINGERPRINT): success + FINGERPRINT")
# A comprehension-required attribute the server does not know: 420, and the
# attribute is echoed in UNKNOWN-ATTRIBUTES.
typ, _t, aa = xact(s, make([(0x0033, b"xy"), (0x0034, b"zw")]), host, port)
ua = get(aa, A_UNKNOWN_ATTRS)
if typ != BINDING_ERROR or errcode(aa) != 420 or ua != b"\x00\x33\x00\x34":
print(f"FAIL: unknown required attrs: type={typ:#x} err={errcode(aa)} unknown={ua!r}")
ok = False
else:
print("OK: unknown comprehension-required attributes: 420 + UNKNOWN-ATTRIBUTES")
# Comprehension-optional attributes are ignored.
typ, _t, aa = xact(s, make([(0x8033, b"xy")]), host, port)
if typ != BINDING_SUCCESS:
print(f"FAIL: unknown optional attribute: type={typ:#x} err={errcode(aa)}")
ok = False
else:
print("OK: unknown comprehension-optional attribute: ignored")
# RFC 5780 probes must still be answered (by the relay, which owns the
# alternate sockets). Without --rfc5780 a CHANGE-REQUEST asking for a
# different address is a 420.
typ, _t, aa = xact(s, make([(A_CHANGE_REQUEST, b"\0\0\0\x04")]), host, port)
if typ != BINDING_ERROR or errcode(aa) != 420:
print(f"FAIL: CHANGE-REQUEST: type={typ:#x} err={errcode(aa)}")
ok = False
else:
print("OK: CHANGE-REQUEST reaches the relay: 420 (no --rfc5780)")
for label, a in (("RESPONSE-PORT", (A_RESPONSE_PORT, b"\x30\x39\0\0")), ("PADDING", (A_PADDING, b""))):
typ, _t, aa = xact(s, make([a]), host, port)
if typ != BINDING_SUCCESS:
print(f"FAIL: {label}: type={typ:#x} err={errcode(aa)}")
ok = False
else:
print(f"OK: {label} reaches the relay: success")
return 0 if ok else 2
def silent(host, port):
"""--no-stun: the request is ignored, with no reply at all."""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(2)
s.sendto(make([]), (host, port))
try:
s.recvfrom(4096)
except socket.timeout:
print("OK: --no-stun: Binding ignored, no reply")
return 0
print("FAIL: --no-stun: server answered a Binding request")
return 2
def challenge(host, port):
"""--secure-stun: Binding needs credentials, so it must reach the relay."""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(3)
try:
typ, _t, aa = xact(s, make([]), host, port)
except socket.timeout:
print("FAIL: --secure-stun: no reply to an unauthenticated Binding")
return 2
if typ != BINDING_ERROR or errcode(aa) != 401:
print(f"FAIL: --secure-stun: type={typ:#x} err={errcode(aa)}, want 401")
return 2
print("OK: --secure-stun: Binding challenged with 401")
return 0
def flood(host, port, count):
"""One Binding request per source port, so each would have cost the relay a
child socket and a session."""
socks = []
replied = 0
msg = make([])
def drain(batch):
got = 0
for held in batch:
try:
held.recvfrom(4096)
got += 1
except socket.timeout:
pass
return got
for _ in range(count):
q = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
q.settimeout(0.2)
q.sendto(msg, (host, port))
socks.append(q)
# Drain as we go: the receive buffers are small and the sockets stay
# open so the server keeps seeing distinct sources.
if len(socks) % 200 == 0:
replied += drain(socks[-200:])
replied += drain(socks[len(socks) - (len(socks) % 200) :])
print(f"RESULT flood sent={count} replied={replied}")
return 0
def main():
host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
port = int(sys.argv[2]) if len(sys.argv) > 2 else 3478
mode = sys.argv[3] if len(sys.argv) > 3 else "wire"
if mode == "silent":
return silent(host, port)
if mode == "challenge":
return challenge(host, port)
if mode == "flood":
return flood(host, port, int(sys.argv[4]) if len(sys.argv) > 4 else 1000)
return wire(host, port)
if __name__ == "__main__":
sys.exit(main())