cargo / bincode / audit
cargo : bincode @ 1.3.3
PE Patrick Elsen signed 2026-05-27 published 2026-05-27

Claims

has-binarieshas-build-exechas-fuzz-testshas-install-exechas-integration-testshas-property-testshas-unit-testsimpl-algorithmimpl-concurrencyimpl-cryptoimpl-datastructureimpl-interpreterimpl-jitimpl-parserimpl-protocolis-benignparser-impl-correctparser-impl-safeparser-impl-testedprotocol-impl-correctprotocol-impl-safeprotocol-impl-testedunsafe-documentedunsafe-minimalunsafe-safeunsafe-testeduses-concurrencyuses-cryptouses-environmentuses-execuses-filesystemuses-interpreteruses-jituses-networkuses-unsafe

Summary

bincode 1.3.3 (final serde-based release): three findings — medium memory-exhaustion vector via default deserialize options on untrusted input (mitigate with .with_limit(N)), medium unmaintained status (development ceased 2025), low 2015-era unsafe pointer-cast idioms in vendored byteorder.rs. Six unsafe blocks, all sound. Safe to deploy for trusted input or with explicit limits.

Report

Subject

bincode 1.3.3 is the final release of the legacy (serde-based) bincode binary serialization format. The crate provides serialize/deserialize convenience functions plus a configurable DefaultOptions builder that lets callers tune endianness, integer encoding (fixint vs varint), size limits, and trailing-byte behaviour. Used by tarpc, webrender, ipc-channel, and zoxide among many others.

Methodology

The published crate contents were compared against the upstream Git repository at tag v1.3.3 using diff -r. The Cargo.toml URL (github.com/servo/bincode) is stale; the bincode-org GitHub redirected to sourcehut, and the project was checked out from https://git.sr.ht/~stygianentity/bincode. Source files differ only in line endings (published .crate uses CRLF; upstream uses LF) — content-equivalent.

All 13 source files (~3,800 lines, including a vendored copy of parts of the byteorder crate) were read. The source was grepped for unsafe (6 occurrences). The size-limit configuration paths and the deserializer's allocation behaviour were traced for input-validation review.

Results

The diff between the published crate contents and the upstream Git repository shows only Cargo's standard manifest normalisation, an auto-generated Cargo.lock, a .mailmap file (added in the published crate), CRLF line endings on the source files, and the omission of examples/, logo.png, and CI config. All source files match byte-for-byte after line-ending normalisation.

The crate ships no binary artefacts (justifying has-binaries), no build.rs, and is not declared as a proc-macro library (justifying has-build-exec). There are no install-time hooks (justifying has-install-exec). The crate ships unit tests inline in module files (de/read.rs:184, justifying has-unit-tests) and integration tests under tests/ (justifying has-integration-tests). No fuzz or property tests are shipped (justifying has-fuzz-tests and has-property-tests). The upstream sourcehut repository contains a fuzz/ directory with cargo-fuzz harnesses, but those are not included in the published .crate.

The crate performs no direct filesystem access, no direct network calls, no process spawning, no environment-variable reads, no cryptographic operations, no JIT, no interpreter, and no concurrency. The crate's I/O surface is restricted to user-supplied std::io::Read and std::io::Write implementors; the crate itself does not open files or sockets. This justifies uses-filesystem, uses-network, uses-exec, uses-environment, uses-crypto, uses-concurrency, uses-jit, and uses-interpreter.

The crate uses unsafe in six places, all in src/byteorder.rs: two macros (read_num_bytes!, write_num_bytes!) using copy_nonoverlapping between primitive integer types and byte buffers (guarded by size_of and length asserts), and four f32/f64 conversions using *(&u as *const u32 as *const f32) and the reverse. All six are sound: the integer macros assert sizes before the copy, and the float conversions are bit-pattern-preserving on all IEEE 754 implementations. The file is vendored from the 2015-era byteorder crate and uses idioms that have since been superseded by primitive.to_le_bytes() / from_le_bytes and f32::to_bits / from_bits. The unsafe is sound and adequately documented by asserts, justifying unsafe-safe and unsafe-documented. unsafe-minimal is asserted false: modern Rust idioms would eliminate all six unsafe blocks (see FINDING-3). uses-unsafe and unsafe-tested hold; the integration test suite exercises all six sites via round-trip tests over primitives, integers, and floats.

The crate implements both a parser (of the bincode binary format, justifying impl-parser) and a serialization protocol with a documented wire format (justifying impl-protocol). The serde-driven parse and emit logic is straightforward and correct against the documented format. The integration tests exercise the round-trip property over the supported scalar/sequence/map/option types, justifying parser-impl-tested and protocol-impl-tested. The implementation matches the docs/spec.md format specification, justifying parser-impl-correct and protocol-impl-correct. parser-impl-safe and protocol-impl-safe are asserted false because of FINDING-1: the default convenience functions do not apply a size limit, so a malformed length prefix in attacker-controlled input can drive the deserializer into a multi-gigabyte allocation attempt. The README documents this and tells callers to use with_limit(N), but the unbounded default API path is what the finding describes.

Three findings were raised:

  • FINDING-1 (security, medium): documented memory-exhaustion vector when using bincode::deserialize / deserialize_from with default options on untrusted input. The README's FAQ tells callers to use DefaultOptions::new().with_limit(N) instead, but the default API is unsafe by default. Affects all serde-fed input on the IoReader (Read-based) path most severely; SliceReader is partially protected via length > slice.len() short-circuit but a downstream Vec allocation can still be expensive.
  • FINDING-2 (quality, medium): the crate is officially unmaintained as of 2025 (upstream development ceased per the sourcehut README). Future CVEs require a community fork. Migration target is bincode 2.x or another format.
  • FINDING-3 (quality, low): the six unsafe blocks in byteorder.rs reflect 2015-era idioms; modern Rust would use to_le_bytes/from_le_bytes and f32::to_bits/from_bits to eliminate them entirely.

The crate does not implement cryptography, an interpreter, a JIT, a non-trivial data structure, or a concurrency primitive at this layer, justifying impl-crypto, impl-interpreter, impl-jit, impl-datastructure, impl-algorithm, and impl-concurrency.

No malicious behaviour was identified. is-benign holds.

Conclusion

bincode 1.3.3 is the stable, end-of-line release of the serde-based bincode line. The code is correct against its documented format, the small unsafe surface is sound (though stylistically dated), and the format spec is straightforward. The two main risks for adopters are: (1) the default convenience-function API is unsafe by default against untrusted input — use with_limit(N) or migrate to bincode 2.x; and (2) the crate is unmaintained and will only receive updates in the event of confirmed CVEs. Safe to deploy for trusted-input use cases or with explicit with_limit configuration.

Findings(3)

FINDING-1 security medium

Memory-exhaustion vector when deserialising untrusted input with default options

The convenience functions bincode::deserialize and bincode::deserialize_from use the default options DefaultOptions::new().with_fixint_encoding().allow_trailing_bytes(), which do not apply a size limit (size limit defaults to Infinite).

With fixint_encoding, sequence/vector lengths are encoded as 8-byte u64s. An attacker who controls the input can prefix a sequence with 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF (representing a length of u64::MAX). The deserializer will then call IoReader::fill_buffer (src/de/read.rs:143-149), which does self.temp_buffer.resize(length, 0) — i.e., attempts to allocate the requested bytes before reading them. On a 64-bit host with sufficient address space, this manifests as an OOM panic or a long allocation stall. The SliceReader path (used by deserialize from a &[u8]) is partially protected because get_byte_slice (line 47-56) checks length > self.slice.len() before reading, so it returns Err early — but a Vec<u8> deserialised from a slice still allocates a Vec sized to length first via get_byte_buffer (line 126-128), which copies the slice into a new Vec.

The README (FAQ "Is Bincode suitable for untrusted inputs?") documents this: callers are explicitly told to use DefaultOptions::new().with_limit(N) for untrusted input. The medium severity reflects that the documented default API is unsafe by default for untrusted input — a downstream consumer who reaches for bincode::deserialize without reading the FAQ is exposed.

Mitigation: use bincode::DefaultOptions::new().with_limit(MAX_REASONABLE_BYTES).deserialize(...) for any input that is not fully trusted, or migrate to bincode 2.x which has different defaults.

FINDING-2 quality medium

Crate is unmaintained

Bincode 1.3.3 is the final release of the legacy serde-based bincode line. As of 2025, development has officially ceased per the upstream README on sourcehut (the project moved off GitHub and then halted entirely):

Due to a doxxing incident bincode development has officially ceased and will not resume. Version 1.3.3 is considered a complete version of bincode that is not in need of any updates. Updates will only be pushed in the unlikely event of CVEs.

The Cargo.toml in 1.3.3 still points at the stale github.com/servo/bincode URL; the bincode-org/bincode GitHub repo redirected to sourcehut (https://git.sr.ht/~stygianentity/bincode).

The successor bincode 2.x is API-incompatible (it uses its own Encode/Decode traits rather than serde::Serialize/serde::Deserialize). Crates that depend on serde for serialization and need ongoing maintenance should consider migrating to bincode 2.x with its compatibility shim, or evaluating other formats (postcard, ciborium).

Beyond FINDING-1, no other security or correctness issues were identified, and the crate's API is stable. The unmaintained status is the practical risk for long-term users: any future CVE will require a community fork to ship a fix.

FINDING-3 quality low

Old-idiom unsafe pointer casts for primitive byte encoding

src/byteorder.rs contains six unsafe blocks that perform primitive↔byte conversions via raw-pointer casts:

  • Lines 24-26 (read_num_bytes! macro): copy_nonoverlapping(.as_ptr(), &mut data as *mut as *mut u8, ) — sound, guarded by size_of and length asserts.
  • Lines 34-39 (write_num_bytes! macro): *(&.$which() as *const _ as *const [u8; $size]) followed by copy_nonoverlapping. The cast is the workaround for Rust issue #22776.
  • Lines 160, 165, 185, 191 (f32/f64 conversion): *(&u as *const u32 as *const f32) and the reverse — sound because all bit patterns are valid for IEEE 754, but the modern idiom is f32::from_bits(u) / f.to_bits().

All six are sound. The file is vendored from the byteorder crate ("Copyright (c) 2015 Andrew Gallant") and reflects 2015-era Rust idioms. Modern Rust would use primitive.to_le_bytes() / from_le_bytes for the integer paths and f32::to_bits/from_bits for the float paths, eliminating all six unsafe blocks. Replacing them would be a pure quality/readability improvement; it does not affect soundness.

Annotations(3)

src/byteorder.rs

src/byteorder.rs, line 19-40

macro_rules! read_num_bytes {
    ($ty:ty, $size:expr, $src:expr, $which:ident) => {{
        assert!($size == ::std::mem::size_of::<$ty>());
        assert!($size <= $src.len());
        let mut data: $ty = 0;
        unsafe {
            copy_nonoverlapping($src.as_ptr(), &mut data as *mut $ty as *mut u8, $size);
        }
        data.$which()
    }};
}

macro_rules! write_num_bytes {
    ($ty:ty, $size:expr, $n:expr, $dst:expr, $which:ident) => {{
        assert!($size <= $dst.len());
        unsafe {
            // N.B. https://github.com/rust-lang/rust/issues/22776
            let bytes = *(&$n.$which() as *const _ as *const [u8; $size]);
            copy_nonoverlapping((&bytes).as_ptr(), $dst.as_mut_ptr(), $size);
        }
    }};
}

read_num_bytes! and write_num_bytes! macros: copy_nonoverlapping between primitive integer and byte buffer. Asserts == size_of::<>() and <= buf.len() before the unsafe op. Sound, but the cast *const _ as *const [u8; SIZE] in write_num_bytes! (line 36) is the workaround for Rust issue #22776; modern Rust would use primitive.to_le_bytes()/from_le_bytes instead. Quality observation; not a bug.

src/byteorder.rs, line 158-193

    #[inline]
    fn read_f32(buf: &[u8]) -> f32 {
        unsafe { *(&Self::read_u32(buf) as *const u32 as *const f32) }
    }

    #[inline]
    fn read_f64(buf: &[u8]) -> f64 {
        unsafe { *(&Self::read_u64(buf) as *const u64 as *const f64) }
    }

    #[inline]
    fn write_i16(buf: &mut [u8], n: i16) {
        Self::write_u16(buf, n as u16)
    }

    #[inline]
    fn write_i32(buf: &mut [u8], n: i32) {
        Self::write_u32(buf, n as u32)
    }

    #[inline]
    fn write_i64(buf: &mut [u8], n: i64) {
        Self::write_u64(buf, n as u64)
    }

    #[inline]
    fn write_f32(buf: &mut [u8], n: f32) {
        let n = unsafe { *(&n as *const f32 as *const u32) };
        Self::write_u32(buf, n)
    }

    #[inline]
    fn write_f64(buf: &mut [u8], n: f64) {
        let n = unsafe { *(&n as *const f64 as *const u64) };
        Self::write_u64(buf, n)
    }

f32/f64 conversion via raw-pointer cast: *(&u as *const u32 as *const f32). Sound because all bit patterns are valid f32/f64 (no UB even for NaN payloads). Modern Rust idiom would be f32::from_bits(u) / f.to_bits(). Quality observation; not a bug.

src/de/read.rs

src/de/read.rs, line 139-182

impl<R> IoReader<R>
where
    R: io::Read,
{
    fn fill_buffer(&mut self, length: usize) -> Result<()> {
        self.temp_buffer.resize(length, 0);

        self.reader.read_exact(&mut self.temp_buffer)?;

        Ok(())
    }
}

impl<'a, R> BincodeRead<'a> for IoReader<R>
where
    R: io::Read,
{
    fn forward_read_str<V>(&mut self, length: usize, visitor: V) -> Result<V::Value>
    where
        V: serde::de::Visitor<'a>,
    {
        self.fill_buffer(length)?;

        let string = match ::std::str::from_utf8(&self.temp_buffer[..]) {
            Ok(s) => s,
            Err(e) => return Err(::ErrorKind::InvalidUtf8Encoding(e).into()),
        };

        visitor.visit_str(string)
    }

    fn get_byte_buffer(&mut self, length: usize) -> Result<Vec<u8>> {
        self.fill_buffer(length)?;
        Ok(::std::mem::replace(&mut self.temp_buffer, Vec::new()))
    }

    fn forward_read_bytes<V>(&mut self, length: usize, visitor: V) -> Result<V::Value>
    where
        V: serde::de::Visitor<'a>,
    {
        self.fill_buffer(length)?;
        visitor.visit_bytes(&self.temp_buffer[..])
    }
}

IoReader::fill_buffer does self.temp_buffer.resize(length, 0) — i.e. allocates length bytes before attempting to read them. If length is attacker-controlled and unbounded, this is a memory-exhaustion vector. See FINDING-1. SliceReader.get_byte_slice (line 47-56) is unaffected because it checks length > self.slice.len() before reading.

src/lib.rs

src/lib.rs, line 1-50

#![deny(missing_docs)]
#![allow(unknown_lints, bare_trait_objects, deprecated)]

//! Bincode is a crate for encoding and decoding using a tiny binary
//! serialization strategy.  Using it, you can easily go from having
//! an object in memory, quickly serialize it to bytes, and then
//! deserialize it back just as fast!
//!
//! ### Using Basic Functions
//!
//! ```edition2018
//! fn main() {
//!     // The object that we will serialize.
//!     let target: Option<String>  = Some("hello world".to_string());
//!
//!     let encoded: Vec<u8> = bincode::serialize(&target).unwrap();
//!     let decoded: Option<String> = bincode::deserialize(&encoded[..]).unwrap();
//!     assert_eq!(target, decoded);
//! }
//! ```
//!
//! ### 128bit numbers
//!
//! Support for `i128` and `u128` is automatically enabled on Rust toolchains
//! greater than or equal to `1.26.0` and disabled for targets which do not support it

#![doc(html_root_url = "https://docs.rs/bincode/1.3.3")]
#![crate_name = "bincode"]
#![crate_type = "rlib"]
#![crate_type = "dylib"]

#[macro_use]
extern crate serde;

pub mod config;
/// Deserialize bincode data to a Rust data structure.
pub mod de;

mod byteorder;
mod error;
mod internal;
mod ser;

pub use config::{Config, DefaultOptions, Options};
pub use de::read::BincodeRead;
pub use de::Deserializer;
pub use error::{Error, ErrorKind, Result};
pub use ser::Serializer;

/// Get a default configuration object.

bincode 1.3.3 is the legacy (serde-based) version of the bincode binary serialization format. Note that the crate is no longer maintained: development has officially ceased per the upstream sourcehut README ("Due to a doxxing incident bincode development has officially ceased and will not resume. Version 1.3.3 is considered a complete version of bincode that is not in need of any updates. Updates will only be pushed in the unlikely event of CVEs."). The bincode-org GitHub redirected to sourcehut as of 2025. The successor bincode 2.x has a different API (uses Encode/Decode traits rather than serde). The upstream repo URL in Cargo.toml (servo/bincode) is stale; clone from https://git.sr.ht/~stygianentity/bincode and check out tag v1.3.3.

src/lib.rs, line 90-185

pub fn serialize_into<W, T: ?Sized>(writer: W, value: &T) -> Result<()>
where
    W: std::io::Write,
    T: serde::Serialize,
{
    DefaultOptions::new()
        .with_fixint_encoding()
        .serialize_into(writer, value)
}

/// Serializes a serializable object into a `Vec` of bytes using the default configuration.
///
/// **Warning:** the default configuration used by this function is not
/// the same as that used by the `DefaultOptions` struct. See the
/// [config](config/index.html#options-struct-vs-bincode-functions)
/// module for more details
pub fn serialize<T: ?Sized>(value: &T) -> Result<Vec<u8>>
where
    T: serde::Serialize,
{
    DefaultOptions::new()
        .with_fixint_encoding()
        .allow_trailing_bytes()
        .serialize(value)
}

/// Deserializes an object directly from a `Read`er using the default configuration.
///
/// If this returns an `Error`, `reader` may be in an invalid state.
///
/// **Warning:** the default configuration used by this function is not
/// the same as that used by the `DefaultOptions` struct. See the
/// [config](config/index.html#options-struct-vs-bincode-functions)
/// module for more details
pub fn deserialize_from<R, T>(reader: R) -> Result<T>
where
    R: std::io::Read,
    T: serde::de::DeserializeOwned,
{
    DefaultOptions::new()
        .with_fixint_encoding()
        .allow_trailing_bytes()
        .deserialize_from(reader)
}

/// Deserializes an object from a custom `BincodeRead`er using the default configuration.
/// It is highly recommended to use `deserialize_from` unless you need to implement
/// `BincodeRead` for performance reasons.
///
/// If this returns an `Error`, `reader` may be in an invalid state.
///
/// **Warning:** the default configuration used by this function is not
/// the same as that used by the `DefaultOptions` struct. See the
/// [config](config/index.html#options-struct-vs-bincode-functions)
/// module for more details
pub fn deserialize_from_custom<'a, R, T>(reader: R) -> Result<T>
where
    R: de::read::BincodeRead<'a>,
    T: serde::de::DeserializeOwned,
{
    DefaultOptions::new()
        .with_fixint_encoding()
        .allow_trailing_bytes()
        .deserialize_from_custom(reader)
}

/// Only use this if you know what you're doing.
///
/// This is part of the public API.
#[doc(hidden)]
pub fn deserialize_in_place<'a, R, T>(reader: R, place: &mut T) -> Result<()>
where
    T: serde::de::Deserialize<'a>,
    R: BincodeRead<'a>,
{
    DefaultOptions::new()
        .with_fixint_encoding()
        .allow_trailing_bytes()
        .deserialize_in_place(reader, place)
}

/// Deserializes a slice of bytes into an instance of `T` using the default configuration.
///
/// **Warning:** the default configuration used by this function is not
/// the same as that used by the `DefaultOptions` struct. See the
/// [config](config/index.html#options-struct-vs-bincode-functions)
/// module for more details
pub fn deserialize<'a, T>(bytes: &'a [u8]) -> Result<T>
where
    T: serde::de::Deserialize<'a>,
{
    DefaultOptions::new()
        .with_fixint_encoding()
        .allow_trailing_bytes()
        .deserialize(bytes)
}

The convenience functions serialize/serialize_into/deserialize/deserialize_from use DefaultOptions::new().with_fixint_encoding().allow_trailing_bytes() and do NOT apply a size limit. With fixint_encoding an attacker can express a length up to u64::MAX in 8 bytes; with the (default) Infinite size limit, the deserializer will attempt the allocation. See FINDING-1; the README documents that callers handling untrusted input must use DefaultOptions::new().with_limit(...) instead.