CVE-2026-43994: Pre-Auth Stack Buffer Overflow in coturn OAuth Token Decoder
Overview
| CVE | CVE-2026-43994 |
| Advisory | GHSA-74pg-rfh2-5qw5 |
| Component | src/client/ns_turn_msg.c (decode_oauth_token_gcm()) |
| Affected | coturn ≤ 4.9.0 when started with --oauth |
| Fixed | coturn 4.10.0 |
| Class | CWE-120 (Buffer Copy Without Checking Size of Input) |
| CVSS v3.1 | 8.1 (High) -- AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Found by | Lukas Johannes Moeller (@JohannesLks) |
TL;DR
coturn's OAuth token decoder reads a 16-bit nonce_len from the first two bytes of an attacker-supplied access token and passes it directly to memcpy() into a 256-byte stack buffer. The pre-copy length check validates the source token only, never the destination. Because the bug fires before AES-GCM authentication, no valid OAuth key material is needed -- the attacker only needs a configured kid and a NONCE obtained for free from a 401 challenge. Up to 735 bytes of fully attacker-controlled data are written past the buffer, overlapping get_user_key()'s saved frame pointer and return address.
Root Cause
decode_oauth_token_gcm() in src/client/ns_turn_msg.c reads the nonce length from the encoded token, validates the source buffer, and then copies straight into a fixed-size stack buffer without checking the destination capacity:
// ~line 2346
const uint16_t nonce_len = nswap16(*((const uint16_t *)csnl)); // attacker-controlled, 0-65535
dtoken->enc_block.nonce_length = nonce_len;
// ~line 2349
const size_t min_encoded_field_size =
2 + 4 + 8 + nonce_len + 2 + OAUTH_GCM_TAG_SIZE + 1;
if (etoken->size < min_encoded_field_size) { // checks source only
return false;
}
const unsigned char *nonce = ((const unsigned char *)etoken->token + 2);
// ~line 2358: nonce[] is 256 bytes, nonce_len reaches 991
memcpy(dtoken->enc_block.nonce, nonce, nonce_len); // OVERFLOW
The destination sits inside an oauth_encrypted_block embedded in an oauth_token that get_user_key() allocates on the stack at src/apps/relay/userdb.c:465:
offset 0: nonce_length (2 bytes)
offset 2: nonce[256] <-- destination buffer
offset 258: key_length (2 bytes) | overwritten
offset 260: mac_key[128] | overwritten
offset 388: timestamp (8 bytes) | overwritten
offset 396: lifetime (4 bytes) | overwritten
offset 400: [end of struct] |
[get_user_key locals] | overwritten
[saved rbp] | overwritten
[return address] | overwritten
The total token size is capped at 1024 bytes by MAX_ENCODED_OAUTH_TOKEN_SIZE (checked at userdb.c:471). After subtracting the fixed header (2 + 4 + 8 + 2 + 16 + 1 = 33 bytes), nonce_len reaches 991, giving a 735-byte overflow. AES-GCM authentication runs roughly 50 lines later at lines 2371-2415 -- the overflow is fully pre-auth.
Reachability
A STUN packet arrives via UDP or TCP at read_client_connection(), passes through the TURN message handler to check_stun_auth() in src/server/ns_turn_server.c:3714, and reaches get_user_key() at userdb.c:487. The OAuth path is taken when the USERNAME attribute matches a configured OAuth kid, looked up at userdb.c:434:
UDP/TCP STUN packet
-> read_client_connection()
-> handle_turn_message_new()
-> handle_request_turn_session()
-> check_stun_auth() ns_turn_server.c:3714
-> userkeycb() -> get_user_key() userdb.c:487
-> decode_oauth_token()
-> decode_oauth_token_gcm() *** overflow ***
Four conditions gate the path: the --oauth flag must be enabled (non-default but documented for WebRTC REST API credential provisioning); USERNAME must match a configured kid; a valid NONCE is acquired unauthenticated via a single 401 round-trip; and a 20-byte MESSAGE-INTEGRITY attribute must be present (its content is arbitrary -- HMAC verification only happens after the overflow). Of these, only --oauth and a known kid are real gates. WebRTC stacks that use coturn's REST API provisioning enable --oauth and configure kids for their credential servers.
Exploitability
- The 735 attacker-controlled bytes start at offset 2 of the OAuth token body and overwrite the rest of the
oauth_encrypted_block, all locals ofget_user_key(), the saved frame pointer, and the return address. 735 bytes is comfortable space for a ROP chain. - With
-fstack-protector(the default for distribution builds) the canary sits between locals and savedrbp, so a corrupted return address triggers__stack_chk_failbefore the function returns -- guaranteed crash, RCE requires a separate canary leak. - Without canaries the return address is directly overwritable. ASLR is the remaining constraint; coturn is a long-running daemon with a stable address space across requests.
- RCE was not attempted in this disclosure. The demonstrated, deterministic primitive is "remote process crash before any cryptographic verification".
Live proof (excerpt)
==PID==ERROR: AddressSanitizer: stack-buffer-overflow
WRITE of size 990 at ... thread T6
#0 in memcpy
#1 in decode_oauth_token_gcm ns_turn_msg.c:2358
#2 in decode_oauth_token ns_turn_msg.c:2480
#3 in get_user_key userdb.c:487
#4 in auth_server_receive_message netengine.c:509
Reproduced against a live coturn 4.9.0 instance (commit 86e5e72) with a single Python PoC: bare ALLOCATE for the NONCE, then a second ALLOCATE carrying a crafted OAUTH_ACCESS_TOKEN with nonce_len = 990 and a 20-byte arbitrary MESSAGE-INTEGRITY.
Fix
The fix adds a single bounds check before the memcpy, mirroring the constant the rest of the OAuth code already respects:
if (nonce_len > OAUTH_MAX_NONCE_SIZE) {
OAUTH_ERROR("%s: nonce too long: %u\n", __FUNCTION__, (unsigned)nonce_len);
return false;
}
The invariant is the usual one: every length field on the wire must be range-checked against the destination capacity before it is used as a copy length, even when downstream code intends to authenticate the input afterwards.