← Research

Rust in Peace

Replacing vulnerable C++ components with Rust, one binding at a time.

Ha Pham

October 29, 2026

Contents
Rust in Peace: orange Rust crabs in a moonlit Halloween landscape.

At Calif, we spend a lot of time performing offensive security research on software and hardware. If you’ve followed our recent posts, you’ve seen that work in action. What we’ve talked less about is how our exploitation work helps us deepen our understanding of how attacks succeed and which defenses are effective at stopping them. From there, we develop strategies to help our customers prevent and mitigate security bugs, limiting the impact of exploitation. Our software security defensive work covers, but is not limited to, these areas:

  1. Memory-safe bindings
  2. Mitigations
  3. Sandboxing
  4. Third-party supply chain hardening

This post is the first in a series to share what we’ve learned from this work. We'll begin with memory-safe bindings: how we've used interop tooling to help transition C++ code to Rust.

Background

Due to memory safety issues in C/C++ code, many companies have considered rewriting their entire codebase in Rust. However, this approach is often impractical especially for production systems which can’t afford large-scale code-swap overnight. A large codebase keeps changing as developers add features and fix bugs. A complete rewrite must keep pace with those changes while preserving existing behavior, making it expensive and risky. A more manageable approach is to selectively rewrite critical and vulnerable components in Rust while keeping the rest of the application in C/C++. This requires a way for the existing C++ code to call the new Rust code.

Handwritten bindings can work for small examples, but maintaining them becomes more complicated as the boundary expands. C++ and Rust represent values and manage ownership differently. We need tools to convert types back and forth and make clear who owns and frees memory. Interop tools such as cxx, bindgen, Diplomat, and Crubit can automate much of this work, reducing the interface code we need to write, review, and maintain. These tools support different language directions and type mappings. In this post, we’ll demonstrate this approach by using Crubit to generate a C++ binding for a small Rust API.

For a Rust library, Crubit generates C++ headers and the bridge code needed to invoke its public functions. Supported types are mapped across the language boundary. However, not every Rust type or language feature has a supported mapping; in those cases, a thin Rust wrapper can expose the functionality through types and function signatures Crubit understands.

How C++ calls Rust

Let's look at a simple example. Suppose a Rust crate named foo provides an add function. We can expose it through a C-compatible interface, declare that interface in a header, and link the compiled Rust library into our C++ application.

/// binding.rs
#[unsafe(no_mangle)]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    foo::add(a, b)
}

The Rust compiler places the compiled function in an object file. extern "C" specifies the C calling convention, while #[unsafe(no_mangle)] preserves the symbol name add so the linker can find it. We then declare the function in a header so the C++ compiler knows how to call it.

// binding.h
#include <stdint.h>

extern "C" int32_t add(int32_t a, int32_t b);

Then finally, the application codes will refer to it via the header path.

// main.cpp
#include "binding.h"
int main(void) {
    add(2, 3);
}

When we compile main.cpp, the C++ compiler reads binding.h to learn the add function’s signature. The compiler checks that add(2, 3) matches that declaration. It then compiles the C++ code, but the actual implementation resides in the Rust-generated object file or library. The linker then locates the corresponding symbol and combines the compiled C++ and Rust code into an executable file.

Rust and C++ compilation pipelines meet at the linker to produce an executable.

An example using ICU4C

To show how this works in practice, let’s build a small C++ application accepting a locale and sort names according to that locale’s collation rules, using the popular ICU4C library. We will build two versions. The first one uses an old version of ICU4C which contains a use-after-free security vulnerability (CVE-2021-30535). The second one uses ICU4X, a Rust implementation of ICU4C, with Crubit bindings. The UAF root cause is not relevant to our discussion here, but if you can read the explanation in Explanation of CVE-2021-30535. The source code for the application is here.

When we run ICU4C, AddressSanitizer catches the invalid read and reports the UAF as shown below.

% ./build/icu4c-demo --cve
=================================================================
==40228==ERROR: AddressSanitizer: heap-use-after-free on address 0x60f000000220 at pc 0x00010287671c bp 0x00016f9f5a90 sp 0x00016f9f5260
READ of size 2 at 0x60f000000220 thread T0
    #0 0x000102876718 in strlen+0x22c (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x16718)
    #1 0x0001004dd988 in uprv_strdup_66+0x10 (icu4c-demo:arm64+0x1000d5988)
    #2 0x0001004a9950 in icu_66::Locale::operator=(icu_66::Locale const&)+0x1d0 (icu4c-demo:arm64+0x1000a1950)
    #3 0x0001004a9be4 in icu_66::Locale::Locale(icu_66::Locale const&)+0x64 (icu4c-demo:arm64+0x1000a1be4)
    #4 0x000100420328 in icu_66::CollationLoader::getCacheEntry(UErrorCode&)+0x104 (icu4c-demo:arm64+0x100018328)
    #5 0x0001004200b8 in icu_66::CollationLoader::loadTailoring(icu_66::Locale const&, UErrorCode&)+0x194 (icu4c-demo:arm64+0x1000180b8)
    #6 0x000100415c90 in icu_66::Collator::makeInstance(icu_66::Locale const&, UErrorCode&)+0x24 (icu4c-demo:arm64+0x10000dc90)
    #7 0x00010041603c in icu_66::Collator::createInstance(icu_66::Locale const&, UErrorCode&)+0x28c (icu4c-demo:arm64+0x10000e03c)
    #8 0x0001004096b8 in main main.cc:20
    #9 0x0001849cc4e0 in start+0x1b4c (dyld:arm64e+0x204e0)

ICU4X avoids this particular ownership error by using a different representation. Its Locale type stores the language identifier and extensions as structured, owned fields. It does not maintain ICU4C’s pair of manually synchronized string pointers. Therefore, the second version runs without crashing,

% ./build/icu4x-demo --cve
Andersson
Meyer
Mueller
Müller
Özil
Zimmermann

Using Crubit to build the bridge

Let’s see how we rebuilt our C++ application with ICU4X using Crubit.

Since our application is in C++, in order for it to use ICU4X, we need an interface for C++/Rust interop. We can write that interface ourselves or generate it with Crubit. The manual approach starts with a C-compatible interface that exposes an opaque handle and three operations:

typedef struct ManualCollatorHandle ManualCollatorHandle;

uint8_t manual_collator_create(
    const uint8_t* locale, size_t len,
    ManualCollatorHandle** out);

int32_t manual_collator_compare(
    const ManualCollatorHandle* collator,
    const uint8_t* left, size_t left_len,
    const uint8_t* right, size_t right_len);

void manual_collator_destroy(ManualCollatorHandle* collator);

The Rust implementation converts the incoming pointer-and-length pairs into slices:

unsafe fn bytes<'a>(data: *const u8, len: usize) -> &'a [u8] {
    if len == 0 {
        &[]
    } else {
        unsafe { slice::from_raw_parts(data, len) }
    }
}

Then it calls ICU4X to create the collator and compare strings:

#[unsafe(no_mangle)]
pub unsafe extern "C" fn manual_collator_create(
    locale: *const u8,
    len: usize,
    out: *mut *mut Collator,
) -> u8 {
    // SAFETY: The caller supplies a valid output slot and readable locale.
    unsafe { *out = ptr::null_mut() };
    match Collator::new(unsafe { bytes(locale, len) }) {
        Ok(collator) => {
            unsafe { *out = Box::into_raw(Box::new(collator)) };
            0
        }
        Err(code) => code,
    }
}

#[unsafe(no_mangle)]
pub unsafe extern "C" fn manual_collator_compare(
    collator: *const Collator,
    left: *const u8,
    left_len: usize,
    right: *const u8,
    right_len: usize,
) -> i32 {
    unsafe { (&*collator).compare_utf8(bytes(left, left_len), bytes(right, right_len)) }
}

The returned handle from the manual_collator_create function must eventually be passed to the destroy function to release the Rust object:

#[unsafe(no_mangle)]
pub unsafe extern "C" fn manual_collator_destroy(collator: *mut Collator) {
    if !collator.is_null() {
        unsafe { drop(Box::from_raw(collator)) };
    }
}

The C++ side still needs an owner that releases the handle automatically, prevents accidental copies, and handles constructor errors. If you think this is a lot of manual work for just three functions, it is. And it only gets more complex and cumbersome as you want to support more types/functions. Our complete example shows the Rust bridge, C header, and C++ owner.

Let’s see how Crubit can automate the process. Our first attempt to generate bindings for ICU4X’s full collator API produced a C++ header that failed to compile because of an unsupported ZeroFrom trait alias. To work around this limitation, we define a small Rust Collator struct that keeps the ICU4X object private and exposes only the operations our application needs:

pub struct Collator {
    inner: CollatorBorrowed<'static>,
}

impl Collator {
    pub fn new(locale: &[u8]) -> Result<Self, u8> {
        let locale = Locale::try_from_utf8(locale).map_err(|_| 1u8)?;
        let inner = CollatorBorrowed::try_new(locale.into(), Default::default())
            .map_err(|_| 2u8)?;
        Ok(Self { inner })
    }

    pub fn compare_utf8(&self, left: &[u8], right: &[u8]) -> i32 {
        match self.inner.compare_utf8(left, right) {
            std::cmp::Ordering::Less => -1,
            std::cmp::Ordering::Equal => 0,
            std::cmp::Ordering::Greater => 1,
        }
    }
}

With the Rust API in place, we can connect it to our CMake build through Corrosion. Corrosion manages Cargo targets inside a CMake project. After preparing the pinned dependencies, we enable Crubit for the locale_collator crate and link the resulting library to our application:

include(FetchContent)
FetchContent_Declare(Crubit SOURCE_DIR "${CRUBIT_SOURCE}")
FetchContent_Declare(Corrosion SOURCE_DIR "${CORROSION_SOURCE}")
FetchContent_MakeAvailable(Corrosion)

# Generate C++ bindings for the Rust crate.
corrosion_experimental_crubit(locale_collator)
corrosion_import_crate(MANIFEST_PATH rust/Cargo.toml LOCKED)

get_target_property(
    support_targets crubit_support INTERFACE_LINK_LIBRARIES)
add_dependencies(crubit_support ${support_targets})

# Expose the Rust library and Crubit's support code to C++.
add_library(collation INTERFACE)
target_include_directories(
    collation INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}")
target_link_libraries(
    collation INTERFACE locale_collator crubit_support)
add_dependencies(collation crubit_support)

add_executable(icu4x-demo main.cc contacts.cc)
target_link_libraries(icu4x-demo PRIVATE collation)

The two key lines are corrosion_experimental_crubit(), which enables binding generation, and corrosion_import_crate(), which adds the Rust crate to the build. When we run cmake --build build, Corrosion invokes Crubit to generate the C++ header and Rust bridge code, then builds the Rust library. And voila\! The C++ application can now use the generated interface to create a collator and sort the input:

#include "collation.h"
#include "contacts.h"

#include <algorithm>
#include <exception>
#include <iostream>
#include <stdexcept>

int main(int argc, char** argv) {
    try {
        auto request = contacts::ParseArguments(
            argc, argv, "ICU4X 2.3.1 through Crubit");
        if (request.done) return 0;

        const auto locale = contacts::ToLanguageTag(request.locale);
        auto result = collation::Collator::new_(locale);
        if (!result.has_value())
            throw std::runtime_error("Invalid or unsupported locale");

        const auto& collator = result.value();
        std::stable_sort(request.names.begin(), request.names.end(),
            [&](const auto& left, const auto& right) {
                return collator.compare_utf8(left, right) < 0;
            });

        contacts::Print(request.names);
        return 0;
    } catch (const std::exception& error) {
        std::cerr << "error: " << error.what() << '\n';
        return 1;
    }
}

Understand the Safety Boundary

Crubit saves us from writing much of the boilerplate needed to connect C++ and Rust. If you look at the generated code, you will notice that it uses unsafe Rust. The unsafe keyword in Rust means the caller must follow certain rules, such as keeping borrowed memory alive and unchanged while Rust uses it. Crubit cannot make the entire C++ application memory-safe, and mistakes on either side of the boundary can still cause bugs.

In addition to the inherent risks of blending memory-safe and memory-unsafe code, the bridge itself can also introduce memory-safety bugs. For example, Crubit’s C++ interface for Rust vectors still contains bugs that can cause out-of-bounds writes and use-after-free. These issues show why generated bindings and their supporting code also need careful review and testing. Some migrations need even less integration work. In a future blog post, we’ll show how rust-brotli’s C FFI allows us to use it as a drop-in replacement while keeping callers unchanged. Reusing that interface reduces overhead and opportunities for new integration bugs, although the existing FFI still needs to be corrected.

Nevertheless, the incremental substitution of high-risk C++ code with Rust still offers a clear security benefit. In our demo, the main safety improvement comes from replacing ICU4C’s vulnerable code with ICU4X. Our small Rust API forbids unsafe code and does not keep references to the input strings after each call. Crubit then handles most of the work needed to connect that API to C++, reducing the code we have to write and review.

Performance cost

Another practical consideration for a large codebase maintainer is whether Crubit adds any performance overhead to the application. To measure the cost of our binding layer, we compare the same ICU4X adapter called directly from Rust and through Crubit from C++. Both paths used the same collator and identical input pairs. The Crubit path took about 1.0 - 2.4% longer, adding about 0.5-1.3 ns per comparison. These results suggest that calling our Rust component through Crubit has a small performance cost - a reasonable trade-off for the safety benefits of replacing the vulnerable implementation.

Conclusion

Making a C++ application safer does not require rewriting it entirely in Rust. By incrementally replacing components that are prone to memory-safety bugs, we can benefit from Rust’s type system and ownership model while preserving the rest of the application. Interoperability tools such as Crubit make this approach possible, helping us strengthen our defenses against exploitation with only a small performance cost. Memory safety is one part of a broader defense, so follow our upcoming posts as we explore techniques that make remaining vulnerabilities harder to exploit and limit the damage when attackers break through.

Appendix

Explanation of CVE-2021-30535

The vulnerable ICU4C implementation stores a locale’s complete name and keyword-free name in fullName and baseName. Without keywords, both point to the same buffer as visualized in Figure 1 below

The Locale fullName and baseName pointers share the same heap buffer.
Two string pointers share the same heap buffer

During collator construction, CollationLoader creates a keyword-free locale and restores the user’s collation setting. Our long base name makes adding collation=phonebook require a larger buffer.

In Locale::setKeywordValue(), line 1360 frees the old buffer, and line 1362 updates fullName, leaving baseName dangling. Because the pointers no longer match, ICU4C skips rebuilding the base name. A later copy reads freed memory, and destruction can free it again as visualized in Figure 2 below.

After reallocation, fullName points to a new buffer while baseName points to freed memory.
Dangling baseName pointer after reallocation

About Calif

We push offensive security research to its limits, understand what is becoming possible with AI, and use those insights to help our customers defend themselves.

Get in touch, and subscribe to our newsletter for more research like this: