← Notes

CVE-2026-43994: Pre-Auth Stack Buffer Overflow in coturn OAuth Token Decoder

Overview

CVECVE-2026-43994
AdvisoryGHSA-74pg-rfh2-5qw5
Componentsrc/client/ns_turn_msg.c (decode_oauth_token_gcm())
Affectedcoturn ≤ 4.9.0 when started with --oauth
Fixedcoturn 4.10.0
ClassCWE-120 (Buffer Copy Without Checking Size of Input)
CVSS v3.18.1 (High) -- AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
Found byLukas 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 of get_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 saved rbp, so a corrupted return address triggers __stack_chk_fail before 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.