mirror of
https://github.com/coturn/coturn.git
synced 2026-10-24 00:08:57 +03:00
## 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>
235 lines
7.8 KiB
Python
Executable File
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())
|