libopus, in C, decodes audio from anyone in a Discord call #251

Open
opened 2026-10-02 19:03:39 -04:00 by Salastil · 1 comment
Owner

From a memory-safety audit of nobilis on 2 October 2026: its own code has two unsafe blocks (both sound libc calls in main.rs), so what follows is about the code it depends on. Advisories were checked against the OSV/RustSec database for every crate in Cargo.lock, and each one traced to whether it is actually compiled in.

The one place nobilis feeds data from strangers into C code: libopus 1.5, built by libopus_sys and wrapped by opus2 (31 unsafe lines in the wrapper). It decodes:

  • every voice packet from everybody in a Discord call or stage, and the audio of any Go Live stream being watched;
  • soundboard sounds in Ogg Opus, which anyone in a server can upload.

All of that arrives after DAVE and transport decryption, so it comes from another participant rather than from the network at large - but any participant is enough. libopus is fuzzed continuously (OSS-Fuzz) and is the decoder every browser ships, so this is a choice to make consciously, not an emergency:

  1. keep the bundled libopus on the newest 1.5.x and watch its advisories (record which version is bundled where it can be checked);
  2. bound what reaches it - packet sizes, frame counts, soundboard file size - before the call;
  3. consider a pure-Rust Opus decoder when one is mature enough for both voice and the Ogg path, or decoding in a separate, sandboxed process.
From a memory-safety audit of nobilis on 2 October 2026: its own code has two `unsafe` blocks (both sound libc calls in `main.rs`), so what follows is about the code it depends on. Advisories were checked against the OSV/RustSec database for every crate in `Cargo.lock`, and each one traced to whether it is actually compiled in. The one place nobilis feeds data from strangers into C code: **libopus 1.5**, built by `libopus_sys` and wrapped by `opus2` (31 `unsafe` lines in the wrapper). It decodes: - every voice packet from everybody in a Discord call or stage, and the audio of any Go Live stream being watched; - soundboard sounds in Ogg Opus, which anyone in a server can upload. All of that arrives after DAVE and transport decryption, so it comes from another participant rather than from the network at large - but any participant is enough. libopus is fuzzed continuously (OSS-Fuzz) and is the decoder every browser ships, so this is a choice to make consciously, not an emergency: 1. keep the bundled libopus on the newest 1.5.x and watch its advisories (record which version is bundled where it can be checked); 2. bound what reaches it - packet sizes, frame counts, soundboard file size - before the call; 3. consider a pure-Rust Opus decoder when one is mature enough for both voice and the Ogg path, or decoding in a separate, sandboxed process.
Salastil added this to the v1.0 milestone 2026-10-02 19:03:39 -04:00
Salastil added the bugCVE labels 2026-10-02 19:03:39 -04:00
Salastil removed this from the v1.0 milestone 2026-10-03 21:31:18 -04:00
Author
Owner

Layers 1 and 2 are done. Layer 3 is still open, so I've left the issue open too.

Layer 1: which libopus, and keeping it current

  • The bundled source (libopus_sys 0.3.3) is libopus 1.5.2, the newest 1.5.x. I confirmed it file by file against upstream's tags.
  • Release builds always use the bundled copy, but it reported "libopus unknown" because it has no version file. Now nobilis/.cargo/config.toml names it, and a test fails if libopus_sys changes without that being revisited.
  • Settings › About shows the decoder each build actually links ("Audio decoder"): 1.5.2 in a release, the system's own when one was installed at build time (1.6.1 on Arch).
  • The build scripts rebuild a stale bundled libopus that still says "unknown", so neither CI's cache nor a local build directory can keep it.

Layer 2: limiting what reaches the decoder

  • opus_packet::plausible checks RFC 6716's packet structure in Rust before libopus reads anything: frame sizes, frame counts and length fields, padding, and that the audio fits the decoder's buffer.
  • Voice: a packet that fails is treated as lost and concealed like any gap.
  • Soundboard: failing packets are skipped, and files are capped at 1 MB (Discord's own limit is 512 KB). The cap is checked while downloading, not just from the declared size.
  • Tested: everything libopus's own encoder makes passes (90 combinations of mode, bitrate and frame size); malformed packets of every kind are refused; 20,000 random inputs never panic; a real Ogg Opus file still decodes whole.

Open for later

Layers 1 and 2 are done. Layer 3 is still open, so I've left the issue open too. **Layer 1: which libopus, and keeping it current** - The bundled source (`libopus_sys` 0.3.3) is **libopus 1.5.2**, the newest 1.5.x. I confirmed it file by file against upstream's tags. - Release builds always use the bundled copy, but it reported "libopus unknown" because it has no version file. Now `nobilis/.cargo/config.toml` names it, and a test fails if `libopus_sys` changes without that being revisited. - Settings › About shows the decoder each build actually links ("Audio decoder"): 1.5.2 in a release, the system's own when one was installed at build time (1.6.1 on Arch). - The build scripts rebuild a stale bundled libopus that still says "unknown", so neither CI's cache nor a local build directory can keep it. **Layer 2: limiting what reaches the decoder** - `opus_packet::plausible` checks RFC 6716's packet structure in Rust before libopus reads anything: frame sizes, frame counts and length fields, padding, and that the audio fits the decoder's buffer. - Voice: a packet that fails is treated as lost and concealed like any gap. - Soundboard: failing packets are skipped, and files are capped at 1 MB (Discord's own limit is 512 KB). The cap is checked while downloading, not just from the declared size. - Tested: everything libopus's own encoder makes passes (90 combinations of mode, bitrate and frame size); malformed packets of every kind are refused; 20,000 random inputs never panic; a real Ogg Opus file still decodes whole. **Open for later** - **libopus 1.6.1** is out. Using it would mean vendoring a patched `libopus_sys`, because `opus2` 0.4 only accepts 0.3.x. It has more fuzzing-era fixes, but also new decoder code. - **Layer 3:** `opus2` 0.4 already has a second backend, `backend-mousiki`, built on a pure-Rust Opus implementation (`mousiki`). Switching to it would remove the C decoder entirely, once it proves good enough for voice and soundboard. - nobilis: https://git.salastil.com/Salastil/nobilis/commit/8eca16e - moho: https://git.salastil.com/Salastil/moho/commit/1782558
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Salastil/moho#251