diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 4836f91915..0ccd3849bc 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -49,6 +49,7 @@ libdd-crashtracker*/ @DataDog/libdatadog-profiling
libdd-data-pipeline*/ @DataDog/libdatadog-apm
libdd-ddsketch*/ @DataDog/libdatadog-apm @DataDog/apm-common-components-core
libdd-dogstatsd-client @DataDog/apm-common-components-core
+libdd-heap-*/ @DataDog/libdatadog-profiling
libdd-http-client @DataDog/apm-common-components-core
libdd-agent-client @DataDog/apm-common-components-core
libdd-library-config*/ @DataDog/apm-sdk-capabilities-rust
diff --git a/.github/workflows/verify-heap-sampler-bindings.yml b/.github/workflows/verify-heap-sampler-bindings.yml
new file mode 100644
index 0000000000..b6e06a2655
--- /dev/null
+++ b/.github/workflows/verify-heap-sampler-bindings.yml
@@ -0,0 +1,49 @@
+name: 'Verify libdd-heap-sampler bindings'
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+ paths:
+ - 'libdd-heap-sampler/**'
+ - '.github/workflows/verify-heap-sampler-bindings.yml'
+env:
+ CARGO_TERM_COLOR: always
+ CARGO_INCREMENTAL: 0
+jobs:
+ verify-bindings:
+ name: "Verify libdd-heap-sampler generated bindings are in sync"
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout sources
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2
+ - name: Install libclang-dev
+ # Only this verification job needs libclang; the normal build
+ # path for libdd-heap-sampler consumes the checked-in bindings
+ # under `src/generated/` and does not depend on bindgen at all.
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libclang-dev
+ - name: Read Rust version from rust-toolchain.toml
+ id: rust-version
+ run: echo "version=$(grep -Po '^channel = "\K[^"]+' rust-toolchain.toml)" >> $GITHUB_OUTPUT
+ - name: Install ${{ steps.rust-version.outputs.version }} toolchain
+ run: rustup set profile minimal && rustup install ${{ steps.rust-version.outputs.version }} && rustup default ${{ steps.rust-version.outputs.version }}
+ - name: Cache [rust]
+ uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # 2.8.1
+ with:
+ cache-targets: true
+ cache-bin: true
+ - name: Regenerate bindings
+ # `LIBDD_HEAP_SAMPLER_REGEN=1` rewrites `src/generated/*`
+ # in-place via bindgen. If the committed files were stale the
+ # git diff below fails and instructs the author how to refresh
+ # them. We use an env var (rather than a cargo feature) so that
+ # unrelated `--all-features` CI jobs cannot accidentally invoke
+ # bindgen — see libdd-heap-sampler/Cargo.toml for the rationale.
+ run: LIBDD_HEAP_SAMPLER_REGEN=1 cargo build -p libdd-heap-sampler
+ - name: Verify committed bindings match regenerated output
+ run: |
+ if ! git diff --exit-code libdd-heap-sampler/src/generated/; then
+ echo "::error::libdd-heap-sampler/src/generated/ is out of date."
+ echo "Run \`LIBDD_HEAP_SAMPLER_REGEN=1 cargo build -p libdd-heap-sampler\` locally and commit the result."
+ exit 1
+ fi
diff --git a/Cargo.lock b/Cargo.lock
index 3df371d202..fe91fd8153 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -357,7 +357,7 @@ version = "0.13.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8bce4948d2520386c6d92a6ea2d472300257702242e5a1d01d6add52bd2e7c1"
dependencies = [
- "bindgen",
+ "bindgen 0.72.1",
"cc",
"cmake",
"dunce",
@@ -488,6 +488,26 @@ dependencies = [
"serde",
]
+[[package]]
+name = "bindgen"
+version = "0.71.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
+dependencies = [
+ "bitflags",
+ "cexpr",
+ "clang-sys",
+ "itertools",
+ "log",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash",
+ "shlex",
+ "syn 2.0.87",
+]
+
[[package]]
name = "bindgen"
version = "0.72.1"
@@ -3078,6 +3098,43 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "libdd-heap-allocator"
+version = "0.1.0"
+dependencies = [
+ "criterion",
+ "libdd-heap-sampler",
+]
+
+[[package]]
+name = "libdd-heap-gotter"
+version = "0.1.0"
+dependencies = [
+ "libc",
+ "libdd-heap-sampler",
+ "serial_test",
+]
+
+[[package]]
+name = "libdd-heap-gotter-ffi"
+version = "37.0.0"
+dependencies = [
+ "anyhow",
+ "build_common",
+ "function_name",
+ "libc",
+ "libdd-common-ffi",
+ "libdd-heap-gotter",
+]
+
+[[package]]
+name = "libdd-heap-sampler"
+version = "0.1.0"
+dependencies = [
+ "bindgen 0.71.1",
+ "cc",
+]
+
[[package]]
name = "libdd-http-client"
version = "37.0.0"
diff --git a/Cargo.toml b/Cargo.toml
index a4a09f13a8..f0474f32e2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,6 +5,10 @@
members = [
"builder",
"libdd-alloc",
+ "libdd-heap-sampler",
+ "libdd-heap-allocator",
+ "libdd-heap-gotter",
+ "libdd-heap-gotter-ffi",
"libdd-crashtracker",
"libdd-crashtracker-ffi",
"datadog-ffe",
diff --git a/NOTICE b/NOTICE
index ab7dccadf5..07440e227e 100644
--- a/NOTICE
+++ b/NOTICE
@@ -2,3 +2,11 @@ Datadog libdatadog
Copyright 2021-2022 Datadog, Inc.
This product includes software developed at Datadog ().
+
+--
+
+This product bundles a copy of the libbpf/usdt single-header USDT
+library in `libdd-heap-sampler/vendor/usdt.h`. That file is licensed
+under the BSD 2-Clause License, Copyright (c) 2024 Meta Platforms, Inc.
+and affiliates. The SPDX identifier and copyright notice are retained
+verbatim in the file header. Upstream: .
diff --git a/libdd-heap-allocator/Cargo.toml b/libdd-heap-allocator/Cargo.toml
new file mode 100644
index 0000000000..b7931753e2
--- /dev/null
+++ b/libdd-heap-allocator/Cargo.toml
@@ -0,0 +1,26 @@
+# Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/
+# SPDX-License-Identifier: Apache-2.0
+
+[package]
+name = "libdd-heap-allocator"
+version = "0.1.0"
+description = "Rust GlobalAlloc wrapper that drives libdd-heap-sampler."
+homepage = "https://github.com/DataDog/libdatadog/tree/main/libdd-heap-allocator"
+repository = "https://github.com/DataDog/libdatadog/tree/main/libdd-heap-allocator"
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+publish = false
+
+[lib]
+bench = false
+
+[dependencies]
+libdd-heap-sampler = { path = "../libdd-heap-sampler" }
+
+[dev-dependencies]
+criterion = "0.5.1"
+
+[[bench]]
+name = "sampler_overhead"
+harness = false
diff --git a/libdd-heap-allocator/README.md b/libdd-heap-allocator/README.md
new file mode 100644
index 0000000000..ab9af1f708
--- /dev/null
+++ b/libdd-heap-allocator/README.md
@@ -0,0 +1,49 @@
+# libdd-heap-allocator
+
+Rust `GlobalAlloc` wrapper with USDT-based heap profiling, effectively implementing [libdd-heap-sampler](../libdd-heap-sampler) for Rust apps at compile time. This lets Rust users quickly setup sampled heap profiling within their application regardless of the particular allocator they are using.
+
+For this to work _well_, you should make sure everything passes through the global allocator!
+
+Usage:
+
+```rust
+use libdd_heap_allocator::SampledAllocator;
+use std::alloc::System;
+
+// Wrap the default system allocator
+#[global_allocator]
+static ALLOC: SampledAllocator = SampledAllocator::::DEFAULT;
+```
+
+To wrap a custom allocator instead:
+
+```rust
+#[global_allocator]
+static ALLOC: SampledAllocator = SampledAllocator::new(MyAllocator::new());
+```
+
+For profiling, prefer wrapping the allocator that is actually installed as the
+process global allocator. Heap profiling is most useful when all allocations in
+the process pass through the sampled wrapper.
+
+See [`examples/usdt_demo.rs`](examples/usdt_demo.rs) for a runnable demo that fires USDT probes in a loop for `bpftrace` to observe.
+
+## Benchmarking sampler overhead
+
+The `sampler_overhead` Criterion benchmark measures the allocator/sampler hot path without installing `SampledAllocator` as the process global allocator. It compares direct `System` allocation, `SampledAllocator`, a no-op allocator, `SampledAllocator`, and direct sampler calls.
+
+```sh
+cargo bench -p libdd-heap-allocator --bench sampler_overhead
+```
+
+One quick validation run produced these fast-path results:
+
+| Size | Base: `System` alloc/free | Sampled fast path | Overhead | Overhead % |
+|---:|---:|---:|---:|---:|
+| 16 B | 5.9719 ns | 10.916 ns | +4.9441 ns | +82.8% |
+| 64 B | 5.9309 ns | 12.405 ns | +6.4741 ns | +109.2% |
+| 256 B | 5.9639 ns | 10.827 ns | +4.8631 ns | +81.5% |
+| 4096 B | 23.237 ns | 29.402 ns | +6.1650 ns | +26.5% |
+| 65536 B | 23.880 ns | 28.496 ns | +4.6160 ns | +19.3% |
+
+The no-op allocator comparison isolates the wrapper/sampler fast path at roughly **+5–6 ns per alloc/free pair**.
diff --git a/libdd-heap-allocator/benches/sampler_overhead.rs b/libdd-heap-allocator/benches/sampler_overhead.rs
new file mode 100644
index 0000000000..f4a022b6ea
--- /dev/null
+++ b/libdd-heap-allocator/benches/sampler_overhead.rs
@@ -0,0 +1,229 @@
+// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/
+// SPDX-License-Identifier: Apache-2.0
+
+// The sampler/allocator items exercised here are Linux-only; on other
+// targets the bench compiles to a no-op `main` so workspace-wide
+// `cargo check --all-targets` doesn't fail.
+
+#[cfg(not(target_os = "linux"))]
+fn main() {}
+
+#[cfg(target_os = "linux")]
+criterion::criterion_main!(linux_bench::benches);
+
+#[cfg(target_os = "linux")]
+mod linux_bench {
+ use criterion::{criterion_group, BenchmarkId, Criterion, Throughput};
+ use libdd_heap_allocator::SampledAllocator;
+ use libdd_heap_sampler::{
+ dd_allocation_created, dd_allocation_freed, dd_allocation_requested,
+ dd_tl_state_get_or_init,
+ };
+ use std::alloc::{GlobalAlloc, Layout, System};
+ use std::hint::black_box;
+ use std::ptr;
+
+ const SIZES: &[usize] = &[16, 64, 256, 4096, 65_536];
+ const ALIGN: usize = 8;
+
+ #[repr(align(4096))]
+ struct AlignedBuffer([u8; 128 * 1024]);
+
+ static mut NOOP_BUFFER: AlignedBuffer = AlignedBuffer([0; 128 * 1024]);
+
+ struct NoopAllocator;
+
+ unsafe impl GlobalAlloc for NoopAllocator {
+ unsafe fn alloc(&self, _layout: Layout) -> *mut u8 {
+ // Return a stable aligned pointer with mapped bytes before it. The sampler's free path
+ // may inspect header-sized bytes immediately before the user pointer when
+ // checking for sampled allocations.
+ unsafe { ptr::addr_of_mut!(NOOP_BUFFER.0).cast::().add(4096) }
+ }
+
+ unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {}
+ }
+
+ unsafe fn noop_user_ptr() -> *mut u8 {
+ unsafe { ptr::addr_of_mut!(NOOP_BUFFER.0).cast::().add(4096) }
+ }
+
+ unsafe fn sampler_tl_state() -> *mut libdd_heap_sampler::dd_tl_state_t {
+ unsafe { dd_tl_state_get_or_init() }
+ }
+
+ unsafe fn pin_sampler_to_fast_path() {
+ let tl = unsafe { sampler_tl_state() };
+ if !tl.is_null() {
+ unsafe {
+ (*tl).sampling_interval = u64::MAX / 4;
+ (*tl).remaining_bytes = i64::MIN / 4;
+ (*tl).remaining_bytes_initialized = true;
+ (*tl).reentry_guard = false;
+ }
+ }
+ }
+
+ unsafe fn force_next_allocation_to_sample() {
+ let tl = unsafe { sampler_tl_state() };
+ if !tl.is_null() {
+ unsafe {
+ (*tl).sampling_interval = 512 * 1024;
+ (*tl).remaining_bytes = 0;
+ (*tl).remaining_bytes_initialized = true;
+ (*tl).reentry_guard = false;
+ }
+ }
+ }
+
+ fn bench_system_alloc_free(c: &mut Criterion) {
+ let mut group = c.benchmark_group("alloc_free/system");
+ for &size in SIZES {
+ let layout = Layout::from_size_align(size, ALIGN).unwrap();
+ group.throughput(Throughput::Bytes(size as u64));
+ group.bench_with_input(BenchmarkId::from_parameter(size), &layout, |b, &layout| {
+ b.iter(|| unsafe {
+ let ptr = System.alloc(layout);
+ black_box(ptr);
+ System.dealloc(ptr, layout);
+ });
+ });
+ }
+ group.finish();
+ }
+
+ fn bench_sampled_system_alloc_free(c: &mut Criterion) {
+ let alloc = SampledAllocator::new(System);
+ let mut group = c.benchmark_group("alloc_free/sampled_system_fast_path");
+ for &size in SIZES {
+ let layout = Layout::from_size_align(size, ALIGN).unwrap();
+ group.throughput(Throughput::Bytes(size as u64));
+ group.bench_with_input(BenchmarkId::from_parameter(size), &layout, |b, &layout| {
+ unsafe { pin_sampler_to_fast_path() };
+ b.iter(|| unsafe {
+ let ptr = alloc.alloc(layout);
+ black_box(ptr);
+ alloc.dealloc(ptr, layout);
+ });
+ });
+ }
+ group.finish();
+ }
+
+ fn bench_noop_alloc_free(c: &mut Criterion) {
+ let alloc = NoopAllocator;
+ let mut group = c.benchmark_group("alloc_free/noop");
+ for &size in SIZES {
+ let layout = Layout::from_size_align(size, ALIGN).unwrap();
+ group.throughput(Throughput::Bytes(size as u64));
+ group.bench_with_input(BenchmarkId::from_parameter(size), &layout, |b, &layout| {
+ b.iter(|| unsafe {
+ let ptr = alloc.alloc(layout);
+ black_box(ptr);
+ alloc.dealloc(ptr, layout);
+ });
+ });
+ }
+ group.finish();
+ }
+
+ fn bench_sampled_noop_alloc_free(c: &mut Criterion) {
+ let alloc = SampledAllocator::new(NoopAllocator);
+ let mut group = c.benchmark_group("alloc_free/sampled_noop_fast_path");
+ for &size in SIZES {
+ let layout = Layout::from_size_align(size, ALIGN).unwrap();
+ group.throughput(Throughput::Bytes(size as u64));
+ group.bench_with_input(BenchmarkId::from_parameter(size), &layout, |b, &layout| {
+ unsafe { pin_sampler_to_fast_path() };
+ b.iter(|| unsafe {
+ let ptr = alloc.alloc(layout);
+ black_box(ptr);
+ alloc.dealloc(ptr, layout);
+ });
+ });
+ }
+ group.finish();
+ }
+
+ fn bench_sampler_only(c: &mut Criterion) {
+ let mut group = c.benchmark_group("sampler_only/fast_path");
+ for &size in SIZES {
+ group.throughput(Throughput::Bytes(size as u64));
+ group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, &size| {
+ unsafe { pin_sampler_to_fast_path() };
+ b.iter(|| unsafe {
+ let req = dd_allocation_requested(black_box(size), black_box(ALIGN));
+ let user = dd_allocation_created(black_box(noop_user_ptr()).cast(), req);
+ let freed = dd_allocation_freed(user, black_box(size), black_box(ALIGN));
+ black_box(freed);
+ });
+ });
+ }
+ group.finish();
+ }
+
+ fn bench_sampled_system_slow_path(c: &mut Criterion) {
+ let alloc = SampledAllocator::new(System);
+ let mut group = c.benchmark_group("alloc_free/sampled_system_slow_path");
+ for &size in SIZES {
+ let layout = Layout::from_size_align(size, ALIGN).unwrap();
+ group.throughput(Throughput::Bytes(size as u64));
+ group.bench_with_input(BenchmarkId::from_parameter(size), &layout, |b, &layout| {
+ b.iter(|| unsafe {
+ force_next_allocation_to_sample();
+ let ptr = alloc.alloc(layout);
+ black_box(ptr);
+ alloc.dealloc(ptr, layout);
+ });
+ });
+ }
+ group.finish();
+ }
+
+ fn bench_sampled_noop_slow_path(c: &mut Criterion) {
+ let alloc = SampledAllocator::new(NoopAllocator);
+ let mut group = c.benchmark_group("alloc_free/sampled_noop_slow_path");
+ for &size in SIZES {
+ let layout = Layout::from_size_align(size, ALIGN).unwrap();
+ group.throughput(Throughput::Bytes(size as u64));
+ group.bench_with_input(BenchmarkId::from_parameter(size), &layout, |b, &layout| {
+ b.iter(|| unsafe {
+ force_next_allocation_to_sample();
+ let ptr = alloc.alloc(layout);
+ black_box(ptr);
+ alloc.dealloc(ptr, layout);
+ });
+ });
+ }
+ group.finish();
+ }
+
+ fn bench_sampler_only_slow_path(c: &mut Criterion) {
+ let mut group = c.benchmark_group("sampler_only/slow_path");
+ for &size in SIZES {
+ group.throughput(Throughput::Bytes(size as u64));
+ group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, &size| {
+ b.iter(|| unsafe {
+ force_next_allocation_to_sample();
+ let req = dd_allocation_requested(black_box(size), black_box(ALIGN));
+ let user = dd_allocation_created(black_box(noop_user_ptr()).cast(), req);
+ let freed = dd_allocation_freed(user, black_box(size), black_box(ALIGN));
+ black_box(freed);
+ });
+ });
+ }
+ group.finish();
+ }
+
+ criterion_group!(
+ benches,
+ bench_system_alloc_free,
+ bench_sampled_system_alloc_free,
+ bench_noop_alloc_free,
+ bench_sampled_noop_alloc_free,
+ bench_sampler_only,
+ bench_sampled_system_slow_path,
+ bench_sampled_noop_slow_path,
+ bench_sampler_only_slow_path,
+ );
+} // mod linux_bench
diff --git a/libdd-heap-allocator/examples/usdt_demo.rs b/libdd-heap-allocator/examples/usdt_demo.rs
new file mode 100644
index 0000000000..e30dbada29
--- /dev/null
+++ b/libdd-heap-allocator/examples/usdt_demo.rs
@@ -0,0 +1,61 @@
+// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/
+// SPDX-License-Identifier: Apache-2.0
+
+//! Sample app exercising `libdd-heap-allocator` as the global allocator.
+//!
+//! Install `SampledAllocator` globally, then loop producing
+//! allocations (strings joined into a single buffer) so a tracer attached
+//! to the `ddheap:alloc` USDT probe sees samples fire periodically.
+//!
+//! Run (Linux, inside the crate's Lima VM):
+//! ```
+//! cargo run --example usdt_demo -p libdd-heap-allocator
+//! ```
+//! and in another shell, attach a tracer, e.g.
+//! ```
+//! sudo bpftrace -p -e 'usdt:*:ddheap:alloc { printf("alloc %p %d %d\n", arg0, arg1, arg2); }'
+//! ```
+//!
+//! `SampledAllocator` is Linux-only; on other targets the example
+//! compiles to an empty `main` so clippy/test on non-Linux don't fail
+//! with "configured out".
+
+#[cfg(not(target_os = "linux"))]
+fn main() {}
+
+#[cfg(target_os = "linux")]
+fn main() {
+ linux::main();
+}
+
+#[cfg(target_os = "linux")]
+mod linux {
+ use libdd_heap_allocator::SampledAllocator;
+ use std::alloc::System;
+ use std::thread::sleep;
+ use std::time::Duration;
+
+ #[global_allocator]
+ static ALLOC: SampledAllocator = SampledAllocator::::DEFAULT;
+
+ pub fn main() {
+ println!(
+ "pid={}; attach a tracer on 'usdt:*:ddheap:alloc'",
+ std::process::id()
+ );
+
+ let mut i: u64 = 0;
+ loop {
+ // ~1000 small allocations + one larger join: plenty of alloc
+ // pressure to cross the default 512 KiB sampling interval over
+ // a handful of iterations.
+ let parts: Vec = (0..1000)
+ .map(|j| format!("chunk-{i}-{j}-with-some-padding-to-make-it-meaningful"))
+ .collect();
+ let joined = parts.join(", ");
+ println!("[{i}] joined {} bytes", joined.len());
+ i = i.wrapping_add(1);
+ sleep(Duration::from_secs(1));
+ }
+ }
+}
diff --git a/libdd-heap-allocator/src/allocator.rs b/libdd-heap-allocator/src/allocator.rs
new file mode 100644
index 0000000000..9ed8b45598
--- /dev/null
+++ b/libdd-heap-allocator/src/allocator.rs
@@ -0,0 +1,162 @@
+// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/
+// SPDX-License-Identifier: Apache-2.0
+
+use core::alloc::{GlobalAlloc, Layout};
+use std::alloc::System;
+
+#[cfg(target_os = "linux")]
+use libdd_heap_sampler::{dd_allocation_created, dd_allocation_freed, dd_allocation_requested};
+
+/// `GlobalAlloc` wrapper that routes each alloc/dealloc through
+/// `libdd-heap-sampler` before forwarding to the inner allocator `A`.
+///
+/// The default `realloc` / `alloc_zeroed` impls from [`GlobalAlloc`] are
+/// inherited; they dispatch back to `alloc` / `dealloc`, so sampling
+/// still fires for those paths.
+///
+/// On non-Linux targets the sampler integration is a no-op and this is
+/// just a transparent forwarder to `inner`; callers can use the same
+/// `#[global_allocator]` setup unconditionally.
+pub struct SampledAllocator {
+ inner: A,
+}
+
+impl SampledAllocator {
+ /// Wrap an allocator. `const` so it's usable directly in a
+ /// `#[global_allocator]` static.
+ pub const fn new(inner: A) -> Self {
+ Self { inner }
+ }
+}
+
+impl SampledAllocator {
+ /// Default wrap of the system allocator, usable directly in a
+ /// `#[global_allocator]` static.
+ pub const DEFAULT: Self = Self { inner: System };
+}
+
+unsafe impl GlobalAlloc for SampledAllocator {
+ #[cfg(target_os = "linux")]
+ #[inline]
+ unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
+ let req = dd_allocation_requested(layout.size(), layout.align());
+ // Sampled paths may bump the size for inline flag storage;
+ // forward the returned size to the inner allocator verbatim.
+ let inner_layout = Layout::from_size_align_unchecked(req.size, layout.align());
+ let raw = self.inner.alloc(inner_layout);
+ dd_allocation_created(raw.cast(), req).cast()
+ }
+
+ #[cfg(not(target_os = "linux"))]
+ #[inline(always)]
+ unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
+ self.inner.alloc(layout)
+ }
+
+ #[cfg(target_os = "linux")]
+ #[inline]
+ unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
+ let freed = dd_allocation_freed(ptr.cast(), layout.size(), layout.align());
+ let inner_layout = Layout::from_size_align_unchecked(freed.size, layout.align());
+ self.inner.dealloc(freed.ptr.cast(), inner_layout);
+ }
+
+ #[cfg(not(target_os = "linux"))]
+ #[inline(always)]
+ unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
+ self.inner.dealloc(ptr, layout);
+ }
+}
+
+// Tests dispatch through the wrapped allocator and (on Linux) into the
+// sampler's C-side TLS primitives via FFI; miri can't execute those.
+#[cfg(all(test, not(miri)))]
+mod tests {
+ use super::*;
+ use core::sync::atomic::{AtomicUsize, Ordering};
+ #[cfg(target_os = "linux")]
+ use libdd_heap_sampler::dd_tl_state_get;
+
+ /// Minimal `GlobalAlloc` that forwards to `System` while recording
+ /// counters so tests can assert the sampled wrapper passed the right
+ /// size/align through.
+ struct CountingSystem {
+ alloc_count: AtomicUsize,
+ dealloc_count: AtomicUsize,
+ last_alloc_size: AtomicUsize,
+ }
+
+ impl CountingSystem {
+ const fn new() -> Self {
+ Self {
+ alloc_count: AtomicUsize::new(0),
+ dealloc_count: AtomicUsize::new(0),
+ last_alloc_size: AtomicUsize::new(0),
+ }
+ }
+ }
+
+ unsafe impl GlobalAlloc for CountingSystem {
+ unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
+ self.alloc_count.fetch_add(1, Ordering::Relaxed);
+ self.last_alloc_size.store(layout.size(), Ordering::Relaxed);
+ System.alloc(layout)
+ }
+ unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
+ self.dealloc_count.fetch_add(1, Ordering::Relaxed);
+ System.dealloc(ptr, layout);
+ }
+ }
+
+ #[test]
+ fn alloc_dealloc_forwards_to_inner() {
+ let sampled = SampledAllocator::new(CountingSystem::new());
+ let layout = Layout::from_size_align(128, 16).unwrap();
+
+ unsafe {
+ let p = sampled.alloc(layout);
+ assert!(!p.is_null());
+
+ assert_eq!(sampled.inner.alloc_count.load(Ordering::Relaxed), 1);
+ // Sampler may bump size for flag storage once that's wired up;
+ // today it returns the requested size verbatim.
+ assert!(sampled.inner.last_alloc_size.load(Ordering::Relaxed) >= 128);
+
+ sampled.dealloc(p, layout);
+ assert_eq!(sampled.inner.dealloc_count.load(Ordering::Relaxed), 1);
+ }
+ }
+
+ // Touches sampler TLS internals, which only exist on Linux.
+ #[cfg(target_os = "linux")]
+ #[test]
+ fn lazy_init_populates_tls_on_first_alloc() {
+ // Spin a fresh thread so we start with uninitialized sampler TLS.
+ std::thread::spawn(|| unsafe {
+ assert!(
+ dd_tl_state_get().is_null(),
+ "fresh thread should have NULL sampler TLS"
+ );
+
+ let sampled = SampledAllocator::::DEFAULT;
+ let layout = Layout::from_size_align(64, 8).unwrap();
+ let p = sampled.alloc(layout);
+ assert!(!p.is_null());
+
+ assert!(
+ !dd_tl_state_get().is_null(),
+ "TLS should be populated after the first alloc"
+ );
+
+ sampled.dealloc(p, layout);
+ })
+ .join()
+ .unwrap();
+ }
+
+ #[test]
+ fn default_matches_new_system() {
+ let _ = SampledAllocator::::DEFAULT;
+ let _ = SampledAllocator::new(System);
+ }
+}
diff --git a/libdd-heap-allocator/src/lib.rs b/libdd-heap-allocator/src/lib.rs
new file mode 100644
index 0000000000..7d53c61249
--- /dev/null
+++ b/libdd-heap-allocator/src/lib.rs
@@ -0,0 +1,27 @@
+// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/
+// SPDX-License-Identifier: Apache-2.0
+
+//! Rust `GlobalAlloc` wrapper that drives `libdd-heap-sampler` around each
+//! allocation. Wrap any underlying allocator with [`SampledAllocator`]; on
+//! each alloc/dealloc the sampler's decision/flag/USDT machinery runs
+//! around the inner call.
+//!
+//! `SampledAllocator` is portable across targets, so callers can use it in
+//! a single `#[global_allocator]` static without cfg-gating. The sampling
+//! integration (USDT probes via `libdd-heap-sampler`) is Linux-only; on
+//! every other target the wrapper compiles to a transparent pass-through
+//! to the inner allocator.
+//!
+//! # Example
+//!
+//! ```no_run
+//! use libdd_heap_allocator::SampledAllocator;
+//! use std::alloc::System;
+//!
+//! #[global_allocator]
+//! static ALLOC: SampledAllocator = SampledAllocator::::DEFAULT;
+//! ```
+
+mod allocator;
+
+pub use allocator::SampledAllocator;
diff --git a/libdd-heap-gotter-ffi/Cargo.toml b/libdd-heap-gotter-ffi/Cargo.toml
new file mode 100644
index 0000000000..d754ddea11
--- /dev/null
+++ b/libdd-heap-gotter-ffi/Cargo.toml
@@ -0,0 +1,33 @@
+# Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/
+# SPDX-License-Identifier: Apache-2.0
+
+[package]
+name = "libdd-heap-gotter-ffi"
+edition.workspace = true
+version.workspace = true
+rust-version.workspace = true
+license.workspace = true
+publish = false
+
+[lib]
+crate-type = ["lib", "staticlib", "cdylib"]
+bench = false
+
+[features]
+default = ["cbindgen"]
+cbindgen = ["build_common/cbindgen", "libdd-common-ffi/cbindgen"]
+
+[build-dependencies]
+build_common = { path = "../build-common" }
+
+[dependencies]
+anyhow = "1.0"
+function_name = "0.3.0"
+libdd-common-ffi = { path = "../libdd-common-ffi", default-features = false }
+# libdd-heap-gotter exposes the same public surface on every target
+# (no-ops on non-Linux), so this dependency is unconditional and the
+# FFI layer is portable too.
+libdd-heap-gotter = { path = "../libdd-heap-gotter" }
+
+[target.'cfg(target_os = "linux")'.dev-dependencies]
+libc = "0.2"
diff --git a/libdd-heap-gotter-ffi/README.md b/libdd-heap-gotter-ffi/README.md
new file mode 100644
index 0000000000..ce790cd565
--- /dev/null
+++ b/libdd-heap-gotter-ffi/README.md
@@ -0,0 +1,47 @@
+# libdd-heap-gotter-ffi
+
+C FFI bindings for `libdd-heap-gotter`.
+
+## Overview
+
+`libdd-heap-gotter-ffi` exposes a small C ABI for installing heap-profiling GOT table interposition from language runtimes such as Python and Ruby.
+
+The API installs hooks for supported allocator symbols, updates hooks after new libraries are loaded, and restores patched GOT entries when profiling is disabled.
+
+## API
+
+- `ddog_heap_gotter_install()` - install heap GOT overrides in the current process.
+- `ddog_heap_gotter_update()` - re-scan loaded libraries and patch newly introduced GOT entries.
+- `ddog_heap_gotter_restore()` - restore every GOT entry patched by install/update.
+- `ddog_heap_gotter_is_installed()` - return whether overrides are currently installed.
+
+## Important lifetime note
+
+The shared library containing these hooks must remain loaded while overrides are installed. Patched GOT entries point at functions in this library, so unloading it before calling `ddog_heap_gotter_restore()` can leave dangling function pointers and crash the process.
+
+TODO: consider hooking `dlclose` too, so we can at least partially protect against unloading this library while its hooks are still installed.
+
+## Building
+
+This crate follows the standard libdatadog FFI layout and can produce `staticlib` and `cdylib` artifacts.
+
+```bash
+cargo build -p libdd-heap-gotter-ffi
+```
+
+The C header is generated with cbindgen by the libdatadog release tooling, not by ordinary `cargo build`.
+
+## Dynamic-loading demo
+
+The `cdylib_demo` example loads the generated shared library with `dlopen`, resolves the C ABI symbols with `dlsym`, installs the GOT hooks, and produces allocation pressure.
+
+```bash
+cargo run -p libdd-heap-gotter-ffi --example cdylib_demo
+```
+
+If the cdylib is somewhere else, set:
+
+```bash
+DDOG_HEAP_GOTTER_FFI_CDYLIB=/path/to/liblibdd_heap_gotter_ffi.so \
+ cargo run -p libdd-heap-gotter-ffi --example cdylib_demo
+```
diff --git a/libdd-heap-gotter-ffi/build.rs b/libdd-heap-gotter-ffi/build.rs
new file mode 100644
index 0000000000..b61ab698a8
--- /dev/null
+++ b/libdd-heap-gotter-ffi/build.rs
@@ -0,0 +1,16 @@
+// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/
+// SPDX-License-Identifier: Apache-2.0
+
+//! Build script for `libdd-heap-gotter-ffi`. Generates the C header for
+//! the FFI surface via cbindgen.
+
+extern crate build_common;
+
+use build_common::generate_and_configure_header;
+
+fn main() {
+ println!("cargo:rerun-if-changed=src/*");
+ println!("cargo:rerun-if-changed=cbindgen.toml");
+ println!("cargo:rerun-if-changed=build.rs");
+ generate_and_configure_header("heap_gotter.h");
+}
diff --git a/libdd-heap-gotter-ffi/cbindgen.toml b/libdd-heap-gotter-ffi/cbindgen.toml
new file mode 100644
index 0000000000..ff29d2d549
--- /dev/null
+++ b/libdd-heap-gotter-ffi/cbindgen.toml
@@ -0,0 +1,37 @@
+# Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/
+# SPDX-License-Identifier: Apache-2.0
+
+language = "C"
+cpp_compat = true
+tab_width = 2
+header = """// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/
+// SPDX-License-Identifier: Apache-2.0
+"""
+include_guard = "DDOG_HEAP_GOTTER_H"
+style = "both"
+pragma_once = true
+no_includes = true
+sys_includes = ["stdbool.h", "stddef.h", "stdint.h"]
+includes = ["common.h"]
+
+[parse]
+parse_deps = true
+include = ["libdd-common-ffi", "libdd-heap-gotter"]
+
+[export]
+prefix = "ddog_"
+renaming_overrides_prefixing = true
+
+[export.mangle]
+rename_types = "PascalCase"
+
+[export.rename]
+"VoidResult" = "ddog_VoidResult"
+"Error" = "ddog_Error"
+
+[enum]
+prefix_with_name = true
+rename_variants = "ScreamingSnakeCase"
+
+[fn]
+must_use = "DDOG_CHECK_RETURN"
diff --git a/libdd-heap-gotter-ffi/examples/cdylib_demo.rs b/libdd-heap-gotter-ffi/examples/cdylib_demo.rs
new file mode 100644
index 0000000000..4558008829
--- /dev/null
+++ b/libdd-heap-gotter-ffi/examples/cdylib_demo.rs
@@ -0,0 +1,148 @@
+// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/
+// SPDX-License-Identifier: Apache-2.0
+
+//! Demonstrates using `libdd-heap-gotter-ffi` as an actual dynamically-loaded C ABI library.
+//!
+//! Build the cdylib first:
+//! ```sh
+//! cargo build -p libdd-heap-gotter-ffi
+//! ```
+//!
+//! Then run this demo:
+//! ```sh
+//! cargo run -p libdd-heap-gotter-ffi --example cdylib_demo
+//! ```
+//!
+//! The gotter-ffi crate is Linux-only; on other targets the example
+//! compiles to a no-op `main` so clippy/test on non-Linux don't fail
+//! with "configured out".
+
+#[cfg(not(target_os = "linux"))]
+fn main() -> Result<(), String> {
+ Ok(())
+}
+
+#[cfg(target_os = "linux")]
+fn main() -> Result<(), String> {
+ linux::main()
+}
+
+#[cfg(target_os = "linux")]
+mod linux {
+ use libdd_common_ffi::VoidResult;
+ use std::ffi::{CStr, CString};
+ use std::path::{Path, PathBuf};
+ use std::thread::sleep;
+ use std::time::Duration;
+
+ const LIB_NAME: &str = "liblibdd_heap_gotter_ffi.so";
+
+ type InstallFn = unsafe extern "C" fn() -> VoidResult;
+ type RestoreFn = unsafe extern "C" fn() -> VoidResult;
+ type IsInstalledFn = unsafe extern "C" fn() -> bool;
+
+ struct DlopenHandle(*mut libc::c_void);
+
+ impl DlopenHandle {
+ fn open(path: &Path) -> Result {
+ let path =
+ CString::new(path.to_string_lossy().as_bytes()).map_err(|e| e.to_string())?;
+ let handle = unsafe { libc::dlopen(path.as_ptr(), libc::RTLD_NOW | libc::RTLD_LOCAL) };
+ if handle.is_null() {
+ return Err(dlerror());
+ }
+ Ok(Self(handle))
+ }
+
+ unsafe fn symbol(&self, name: &CStr) -> Result
+ where
+ T: Copy,
+ {
+ let ptr = unsafe { libc::dlsym(self.0, name.as_ptr()) };
+ if ptr.is_null() {
+ return Err(dlerror());
+ }
+ Ok(unsafe { std::mem::transmute_copy(&ptr) })
+ }
+ }
+
+ impl Drop for DlopenHandle {
+ fn drop(&mut self) {
+ unsafe {
+ libc::dlclose(self.0);
+ }
+ }
+ }
+
+ fn dlerror() -> String {
+ let err = unsafe { libc::dlerror() };
+ if err.is_null() {
+ "unknown dlerror".to_string()
+ } else {
+ unsafe { CStr::from_ptr(err) }
+ .to_string_lossy()
+ .into_owned()
+ }
+ }
+
+ fn cdylib_path() -> Result {
+ if let Some(path) = std::env::var_os("DDOG_HEAP_GOTTER_FFI_CDYLIB") {
+ return Ok(PathBuf::from(path));
+ }
+
+ let exe = std::env::current_exe().map_err(|e| e.to_string())?;
+ let profile_dir = exe
+ .parent()
+ .and_then(|p| p.parent())
+ .ok_or_else(|| format!("could not infer target profile dir from {}", exe.display()))?;
+ Ok(profile_dir.join(LIB_NAME))
+ }
+
+ fn check(result: VoidResult, operation: &str) -> Result<(), String> {
+ match result {
+ VoidResult::Ok => Ok(()),
+ VoidResult::Err(err) => Err(format!("{operation} failed: {err}")),
+ }
+ }
+
+ pub fn main() -> Result<(), String> {
+ let lib_path = cdylib_path()?;
+ if !lib_path.exists() {
+ return Err(format!(
+ "{} does not exist; run `cargo build -p libdd-heap-gotter-ffi` first",
+ lib_path.display()
+ ));
+ }
+
+ println!("pid={}", std::process::id());
+ println!("loading {}", lib_path.display());
+ let lib = DlopenHandle::open(&lib_path)?;
+
+ let install: InstallFn = unsafe { lib.symbol(c"ddog_heap_gotter_install")? };
+ let restore: RestoreFn = unsafe { lib.symbol(c"ddog_heap_gotter_restore")? };
+ let is_installed: IsInstalledFn = unsafe { lib.symbol(c"ddog_heap_gotter_is_installed")? };
+
+ println!("pre-install is_installed={}", unsafe { is_installed() });
+ check(unsafe { install() }, "ddog_heap_gotter_install")?;
+ println!("post-install is_installed={}", unsafe { is_installed() });
+ println!("attach a tracer on `usdt:*:ddheap:*`; producing allocation pressure...");
+
+ for i in 0..30_u64 {
+ let parts: Vec = (0..1000)
+ .map(|j| format!("chunk-{i}-{j}-with-some-padding-to-make-it-meaningful"))
+ .collect();
+ let joined = parts.join(", ");
+ println!("[{i}] joined {} bytes", joined.len());
+ sleep(Duration::from_secs(1));
+ }
+
+ check(unsafe { restore() }, "ddog_heap_gotter_restore")?;
+ println!("post-restore is_installed={}", unsafe { is_installed() });
+
+ // Keep `lib` alive until after restore. The GOT entries patched by install point at
+ // functions in this cdylib; dropping/dlclosing it while installed can leave
+ // dangling function pointers.
+ drop(lib);
+ Ok(())
+ }
+} // mod linux
diff --git a/libdd-heap-gotter-ffi/src/lib.rs b/libdd-heap-gotter-ffi/src/lib.rs
new file mode 100644
index 0000000000..f4e171fe7b
--- /dev/null
+++ b/libdd-heap-gotter-ffi/src/lib.rs
@@ -0,0 +1,76 @@
+// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/
+// SPDX-License-Identifier: Apache-2.0
+
+//! C FFI bindings for [`libdd_heap_gotter`]. Exposes install / update /
+//! restore / is-installed entry points as `extern "C"` functions so
+//! language runtimes (Python, Ruby, …) can drive GOT-based heap
+//! profiling from their own native extension code.
+
+#![cfg_attr(not(test), deny(clippy::panic))]
+#![cfg_attr(not(test), deny(clippy::unwrap_used))]
+#![cfg_attr(not(test), deny(clippy::expect_used))]
+#![cfg_attr(not(test), deny(clippy::todo))]
+#![cfg_attr(not(test), deny(clippy::unimplemented))]
+#![cfg_attr(not(test), deny(clippy::unreachable))]
+
+// `wrap_with_void_ffi_result!` uses `function_name!()` below.
+use function_name::named;
+use libdd_common_ffi::{wrap_with_void_ffi_result, VoidResult};
+
+// `libdd_heap_gotter` exposes the same public surface on every target.
+// On non-Linux the underlying functions are no-ops, so callers that
+// invoke these FFI entry points outside Linux observe a clean error
+// from `ddog_heap_gotter_install` (nothing was overridden) without
+// having to `#ifdef` their integration code.
+
+/// Install GOT overrides for supported heap-allocation symbols in the current process.
+///
+/// The library containing these hooks must remain loaded until
+/// `ddog_heap_gotter_restore` has been called. GOT entries are patched to point at
+/// functions in this library, so unloading it while installed can leave dangling function pointers.
+///
+/// On non-Linux targets this returns an error indicating that nothing
+/// could be installed; the rest of the API can still be called safely.
+#[no_mangle]
+#[must_use]
+#[named]
+pub extern "C" fn ddog_heap_gotter_install() -> VoidResult {
+ wrap_with_void_ffi_result!({
+ let installed = libdd_heap_gotter::install_heap_overrides();
+ anyhow::ensure!(installed, "no heap GOT overrides could be installed");
+ })
+}
+
+/// Re-scan loaded libraries and patch newly-introduced GOT entries.
+///
+/// This is normally called automatically by the installed `dlopen` hook, but language runtimes may
+/// call it explicitly after unusual native-extension loading flows. No-op on non-Linux targets.
+#[no_mangle]
+#[must_use]
+#[named]
+pub extern "C" fn ddog_heap_gotter_update() -> VoidResult {
+ wrap_with_void_ffi_result!({
+ libdd_heap_gotter::update_heap_overrides();
+ })
+}
+
+/// Restore every GOT entry patched by `ddog_heap_gotter_install`.
+///
+/// Call this before unloading the shared library that provides the gotter hooks. No-op on
+/// non-Linux targets.
+#[no_mangle]
+#[must_use]
+#[named]
+pub extern "C" fn ddog_heap_gotter_restore() -> VoidResult {
+ wrap_with_void_ffi_result!({
+ libdd_heap_gotter::restore_heap_overrides();
+ })
+}
+
+/// Return whether heap GOT overrides are currently installed in this process. Always `false` on
+/// non-Linux targets.
+#[no_mangle]
+#[must_use]
+pub extern "C" fn ddog_heap_gotter_is_installed() -> bool {
+ libdd_heap_gotter::heap_overrides_are_installed()
+}
diff --git a/libdd-heap-gotter-ffi/tests/install.rs b/libdd-heap-gotter-ffi/tests/install.rs
new file mode 100644
index 0000000000..2361d9ba25
--- /dev/null
+++ b/libdd-heap-gotter-ffi/tests/install.rs
@@ -0,0 +1,49 @@
+// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/
+// SPDX-License-Identifier: Apache-2.0
+
+//! Smoke test for the FFI install/restore roundtrip.
+//!
+//! Installing GOT overrides mutates global process state, so this lives
+//! as an integration test in its own binary rather than a unit test.
+//! Miri can't execute the underlying dl_iterate_phdr/mprotect calls.
+
+#![cfg(all(target_os = "linux", target_pointer_width = "64", not(miri)))]
+
+use libdd_common_ffi::VoidResult;
+use libdd_heap_gotter_ffi::{
+ ddog_heap_gotter_install, ddog_heap_gotter_is_installed, ddog_heap_gotter_restore,
+};
+
+#[track_caller]
+fn assert_ok(result: VoidResult, what: &str) {
+ match result {
+ VoidResult::Ok => {}
+ VoidResult::Err(err) => panic!("{what} failed: {err}"),
+ }
+}
+
+#[test]
+fn install_restore_roundtrip() {
+ assert!(
+ !ddog_heap_gotter_is_installed(),
+ "expected clean process state before install"
+ );
+
+ assert_ok(ddog_heap_gotter_install(), "ddog_heap_gotter_install");
+ assert!(
+ ddog_heap_gotter_is_installed(),
+ "is_installed should be true after install"
+ );
+
+ // Touch the heap while installed so the patched GOT actually gets
+ // used. We just need the process to still be alive after this.
+ let v: Vec = vec![0; 128];
+ assert_eq!(v.len(), 128);
+ drop(v);
+
+ assert_ok(ddog_heap_gotter_restore(), "ddog_heap_gotter_restore");
+ assert!(
+ !ddog_heap_gotter_is_installed(),
+ "is_installed should be false after restore"
+ );
+}
diff --git a/libdd-heap-gotter/Cargo.toml b/libdd-heap-gotter/Cargo.toml
new file mode 100644
index 0000000000..9662e61620
--- /dev/null
+++ b/libdd-heap-gotter/Cargo.toml
@@ -0,0 +1,26 @@
+# Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/
+# SPDX-License-Identifier: Apache-2.0
+
+[package]
+name = "libdd-heap-gotter"
+version = "0.1.0"
+description = "GOT-table interposition that routes a running process's allocator through libdd-heap-sampler."
+homepage = "https://github.com/DataDog/libdatadog/tree/main/libdd-heap-gotter"
+repository = "https://github.com/DataDog/libdatadog/tree/main/libdd-heap-gotter"
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+publish = false
+
+[lib]
+crate-type = ["lib", "cdylib"]
+bench = false
+
+[target.'cfg(target_os = "linux")'.dependencies]
+libc = "0.2"
+libdd-heap-sampler = { path = "../libdd-heap-sampler" }
+
+[target.'cfg(target_os = "linux")'.dev-dependencies]
+libc = "0.2"
+libdd-heap-sampler = { path = "../libdd-heap-sampler" }
+serial_test = "3.2"
diff --git a/libdd-heap-gotter/README.md b/libdd-heap-gotter/README.md
new file mode 100644
index 0000000000..1a5865eb01
--- /dev/null
+++ b/libdd-heap-gotter/README.md
@@ -0,0 +1,14 @@
+# libdd-heap-gotter
+
+GOTter implements our GOT-patching mechanism to wrap (dynamically!) linked allocators in a running process.
+This follows the same approach as `ddprof`, and may prove useful to inject via our tracing libraries into running processes such as python.
+
+It contains:
+
+* A set of functions such as `gotter_malloc` that will be used to override the _originals_ of these functions
+* Overrides for _other bits_ we need for this to work robustly in a running process (`dlopen` to re-scan on new library load, `pthread_create` to materialise sampler TLS on new threads)
+* A function to install the overrides in a running process `install_heap_overrides()`
+
+Not yet covered: `operator new` / `operator delete`, `mmap`/`munmap`, jemalloc-specific `*allocx` variants, and `pthread_atfork` child-handler for clean `fork()` state reset.
+
+This will be used in places such as the python profiler to install the heap profiler at runtime to capture native allocations.
diff --git a/libdd-heap-gotter/examples/gotter_usdt_demo.rs b/libdd-heap-gotter/examples/gotter_usdt_demo.rs
new file mode 100644
index 0000000000..30c7aa2553
--- /dev/null
+++ b/libdd-heap-gotter/examples/gotter_usdt_demo.rs
@@ -0,0 +1,425 @@
+// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/
+// SPDX-License-Identifier: Apache-2.0
+
+//! Sample app exercising `libdd-heap-gotter`: install the GOT overrides,
+//! then drive libc's allocator functions directly (`malloc`, `calloc`,
+//! `realloc`, `free`, `posix_memalign`, `aligned_alloc`) from multiple
+//! threads with a mix of sizes and alignments — including alignments
+//! that exceed the sampler's cap and hit the passthrough path.
+//!
+//! Every allocation is filled with a deterministic per-allocation byte
+//! pattern; every free/realloc verifies that pattern first, so a bad
+//! header stamp, wrong memmove, or misplaced offset shows up as a loud
+//! panic rather than a silent corruption.
+//!
+//! Run (Linux):
+//! ```
+//! cargo run --example gotter_usdt_demo -p libdd-heap-gotter
+//! ```
+//! Options:
+//! * `--stress` — keep one CPU core hot between iterations (for CPU profiles).
+//! * `--secs N` — exit after N seconds instead of looping forever.
+//! * `--threads N` — number of worker threads (default: 4).
+//!
+//! Attach a tracer in another shell, e.g.:
+//! ```
+//! sudo bpftrace -p -e '
+//! usdt:*:ddheap:alloc { @allocs = count(); }
+//! usdt:*:ddheap:free { @frees = count(); }
+//! interval:s:1 { print(@allocs); print(@frees); }'
+//! ```
+//!
+//! The gotter crate is Linux-only; on other targets the example
+//! compiles to an empty `main` so clippy/test on non-Linux don't fail
+//! with "configured out".
+
+#[cfg(not(target_os = "linux"))]
+fn main() {}
+
+#[cfg(target_os = "linux")]
+fn main() {
+ linux::main();
+}
+
+#[cfg(target_os = "linux")]
+mod linux {
+ use std::hint::black_box;
+ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+ use std::sync::Arc;
+ use std::thread;
+ use std::time::{Duration, Instant};
+
+ /// Tracked live allocation: pointer, its user-visible size, and the
+ /// seed used to fill it. Content integrity is verified before any
+ /// realloc/free.
+ struct LiveAlloc {
+ ptr: *mut u8,
+ size: usize,
+ seed: u64,
+ }
+ // Raw pointers aren't Send by default; we're the sole owner in the
+ // worker thread that produced them, so this is fine.
+ unsafe impl Send for LiveAlloc {}
+ unsafe impl Sync for LiveAlloc {}
+ impl LiveAlloc {
+ fn as_slice(&self) -> &[u8] {
+ // SAFETY: `ptr` is the return of a libc allocator and
+ // `size` is the user-requested size. Lifetime is scoped by
+ // the caller and does not outlive the allocation.
+ unsafe { std::slice::from_raw_parts(self.ptr, self.size) }
+ }
+
+ fn as_slice_mut(&mut self) -> &mut [u8] {
+ // SAFETY: `ptr` is the return of a libc allocator and
+ // `size` is the user-requested size. `&mut self` prevents
+ // callers from creating two mutable slices to the same live
+ // allocation through this helper.
+ unsafe { std::slice::from_raw_parts_mut(self.ptr, self.size) }
+ }
+ }
+
+ /// Cheap deterministic PRNG (splitmix64). We use it for both
+ /// scheduling decisions and content fills so a corruption bug
+ /// reproduces deterministically for a given (thread, seed) pair.
+ #[derive(Clone, Copy)]
+ struct Rng(u64);
+ impl Rng {
+ fn new(seed: u64) -> Self {
+ Rng(seed)
+ }
+
+ fn next(&mut self) -> u64 {
+ let mut z = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
+ self.0 = z;
+ z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
+ z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
+ z ^ (z >> 31)
+ }
+
+ fn range(&mut self, lo: usize, hi: usize) -> usize {
+ lo + (self.next() as usize) % (hi - lo).max(1)
+ }
+
+ fn choice<'a, T>(&mut self, xs: &'a [T]) -> &'a T {
+ &xs[(self.next() as usize) % xs.len()]
+ }
+ }
+
+ /// Fill `buf` with a pattern derived from `seed`. Verified later by
+ /// `verify_content` so we notice content shifts or clobbers.
+ fn fill_content(buf: &mut [u8], seed: u64) {
+ let mut r = Rng::new(seed);
+ for chunk in buf.chunks_mut(8) {
+ let w = r.next().to_le_bytes();
+ let n = chunk.len();
+ chunk.copy_from_slice(&w[..n]);
+ }
+ }
+
+ fn verify_content(buf: &[u8], seed: u64) {
+ let mut r = Rng::new(seed);
+ for (i, chunk) in buf.chunks(8).enumerate() {
+ let w = r.next().to_le_bytes();
+ let n = chunk.len();
+ assert_eq!(
+ chunk,
+ &w[..n],
+ "content mismatch at byte offset {} (chunk {})",
+ i * 8,
+ i,
+ );
+ }
+ }
+
+ /// Weighted alignment menu. Small alignments dominate; a rare 4096
+ /// exercises the sampler's alignment cap; 8192 exceeds it and
+ /// forces the passthrough path.
+ const ALIGNMENTS: &[usize] = &[
+ 1, 8, 8, 8, 16, 16, 16, 16, 16, 32, 64, 128, 256, 512, 1024, 4096, 8192,
+ ];
+
+ fn pick_size(r: &mut Rng) -> usize {
+ // Log-uniform bucket, then a uniform offset inside the bucket.
+ // Skewed toward small allocs (matches typical workloads) but
+ // occasionally reaches into MB territory.
+ let bucket = r.range(0, 22);
+ let hi = 1usize << bucket;
+ let lo = hi / 2;
+ r.range(lo.max(1), hi.max(2))
+ }
+
+ fn pick_alignment(r: &mut Rng) -> usize {
+ *r.choice(ALIGNMENTS)
+ }
+
+ unsafe fn do_malloc(size: usize) -> Option {
+ let ptr = libc::malloc(size) as *mut u8;
+ if ptr.is_null() {
+ return None;
+ }
+ Some(LiveAlloc { ptr, size, seed: 0 })
+ }
+
+ unsafe fn do_calloc(nmemb: usize, size: usize) -> Option {
+ let ptr = libc::calloc(nmemb, size) as *mut u8;
+ if ptr.is_null() {
+ return None;
+ }
+ // calloc zeroes memory; verify that before we overwrite with a
+ // seed pattern. Catches allocator confusion between raw and
+ // user pointers.
+ let total = nmemb.saturating_mul(size);
+ let slice = std::slice::from_raw_parts(ptr, total);
+ assert!(
+ slice.iter().all(|&b| b == 0),
+ "calloc returned non-zeroed memory"
+ );
+ Some(LiveAlloc {
+ ptr,
+ size: total,
+ seed: 0,
+ })
+ }
+
+ unsafe fn do_aligned_alloc(alignment: usize, size: usize) -> Option {
+ // aligned_alloc requires size % alignment == 0. Round up.
+ let rounded = size.div_ceil(alignment) * alignment;
+ let ptr = libc::aligned_alloc(alignment, rounded) as *mut u8;
+ if ptr.is_null() {
+ return None;
+ }
+ assert_eq!(
+ (ptr as usize) % alignment,
+ 0,
+ "aligned_alloc returned misaligned pointer"
+ );
+ Some(LiveAlloc {
+ ptr,
+ size: rounded,
+ seed: 0,
+ })
+ }
+
+ unsafe fn do_posix_memalign(alignment: usize, size: usize) -> Option {
+ // posix_memalign requires alignment to be a power of two and a
+ // multiple of sizeof(void*).
+ if alignment < std::mem::size_of::<*mut u8>() || !alignment.is_power_of_two() {
+ return None;
+ }
+ let mut out: *mut libc::c_void = std::ptr::null_mut();
+ let rc = libc::posix_memalign(&mut out, alignment, size);
+ if rc != 0 || out.is_null() {
+ return None;
+ }
+ assert_eq!(
+ (out as usize) % alignment,
+ 0,
+ "posix_memalign returned misaligned pointer"
+ );
+ Some(LiveAlloc {
+ ptr: out as *mut u8,
+ size,
+ seed: 0,
+ })
+ }
+
+ unsafe fn do_realloc(old: LiveAlloc, new_size: usize) -> Option {
+ // Verify old contents before releasing the block.
+ verify_content(old.as_slice(), old.seed);
+ let new_ptr = libc::realloc(old.ptr as *mut libc::c_void, new_size) as *mut u8;
+ if new_ptr.is_null() {
+ // Old block is still live on realloc failure. Return it as-is.
+ return Some(old);
+ }
+ // Preserved bytes are `min(old.size, new_size)`; verify them
+ // against the OLD seed. Any misplaced offset in the sampler's
+ // realloc path shows up as a mismatch here.
+ let preserved = old.size.min(new_size);
+ let preserved_slice = std::slice::from_raw_parts(new_ptr, preserved);
+ // Re-run the deterministic fill to know what those bytes should be.
+ let mut expected = vec![0u8; preserved];
+ fill_content(&mut expected, old.seed);
+ assert_eq!(
+ preserved_slice,
+ &expected[..],
+ "realloc did not preserve user contents"
+ );
+ Some(LiveAlloc {
+ ptr: new_ptr,
+ size: new_size,
+ seed: 0,
+ })
+ }
+
+ unsafe fn do_free(a: LiveAlloc) {
+ verify_content(a.as_slice(), a.seed);
+ libc::free(a.ptr as *mut libc::c_void);
+ }
+
+ fn worker(
+ thread_id: u64,
+ stop: Arc,
+ allocs: Arc,
+ frees: Arc,
+ reallocs: Arc,
+ ) {
+ let mut rng = Rng::new(0xdead_beef_0000_0000 ^ thread_id);
+ // Cap the working set to keep total RSS bounded; when full,
+ // subsequent alloc ops replace a random slot (freeing it first).
+ const MAX_LIVE: usize = 256;
+ let mut live: Vec = Vec::with_capacity(MAX_LIVE);
+
+ while !stop.load(Ordering::Relaxed) {
+ let op = rng.range(0, 100);
+ let want_alloc = live.len() < MAX_LIVE / 2 || op < 40;
+
+ unsafe {
+ if want_alloc {
+ let size = pick_size(&mut rng);
+ let alignment = pick_alignment(&mut rng);
+ let flavor = rng.range(0, 4);
+ let a = match flavor {
+ 0 => do_malloc(size),
+ 1 => {
+ let nmemb = rng.range(1, 32);
+ let each = size.div_ceil(nmemb).max(1);
+ do_calloc(nmemb, each)
+ }
+ 2 => do_aligned_alloc(alignment, size.max(alignment)),
+ _ => do_posix_memalign(alignment, size),
+ };
+ let Some(mut a) = a else { continue };
+ a.seed = rng.next();
+ let seed = a.seed;
+ fill_content(a.as_slice_mut(), seed);
+ allocs.fetch_add(1, Ordering::Relaxed);
+ if live.len() == MAX_LIVE {
+ let idx = rng.range(0, live.len());
+ let victim = live.swap_remove(idx);
+ do_free(victim);
+ frees.fetch_add(1, Ordering::Relaxed);
+ }
+ live.push(a);
+ } else if !live.is_empty() && op < 80 {
+ // realloc a random live block.
+ let idx = rng.range(0, live.len());
+ let old = live.swap_remove(idx);
+ let new_size = pick_size(&mut rng);
+ let Some(mut resized) = do_realloc(old, new_size) else {
+ continue;
+ };
+ // Refill with a fresh seed — old bytes past
+ // min(old_size,new_size) are undefined per the
+ // realloc contract.
+ resized.seed = rng.next();
+ let seed = resized.seed;
+ fill_content(resized.as_slice_mut(), seed);
+ reallocs.fetch_add(1, Ordering::Relaxed);
+ live.push(resized);
+ } else if !live.is_empty() {
+ let idx = rng.range(0, live.len());
+ let victim = live.swap_remove(idx);
+ do_free(victim);
+ frees.fetch_add(1, Ordering::Relaxed);
+ }
+ }
+ }
+
+ // Drain remaining live allocations, verifying contents.
+ while let Some(a) = live.pop() {
+ unsafe { do_free(a) };
+ frees.fetch_add(1, Ordering::Relaxed);
+ }
+ }
+
+ fn burn_cpu_for(duration: Duration, mut state: u64) -> u64 {
+ let deadline = Instant::now() + duration;
+ while Instant::now() < deadline {
+ state =
+ state.wrapping_mul(0x9e37_79b9_7f4a_7c15).rotate_left(17) ^ 0xbf58_476d_1ce4_e5b9;
+ black_box(state);
+ }
+ state
+ }
+
+ pub fn main() {
+ let args: Vec = std::env::args().collect();
+ let stress = args.iter().any(|a| a == "--stress");
+ let secs: Option = args
+ .windows(2)
+ .find(|w| w[0] == "--secs")
+ .and_then(|w| w[1].parse().ok());
+ let threads: usize = args
+ .windows(2)
+ .find(|w| w[0] == "--threads")
+ .and_then(|w| w[1].parse().ok())
+ .unwrap_or(4);
+
+ println!(
+ "pid={}; pre-install. Attach a tracer on 'usdt:*:ddheap:*'. \
+ threads={threads} stress={stress} secs={:?}",
+ std::process::id(),
+ secs,
+ );
+
+ // Baseline noise before install — none of this should emit USDTs.
+ {
+ let warmup: Vec = (0..16).map(|i| format!("warmup-{i}")).collect();
+ println!("pre-install warmup: {} entries", warmup.len());
+ }
+
+ std::thread::sleep(Duration::from_secs(2));
+
+ let ok = libdd_heap_gotter::install_heap_overrides();
+ println!("install_heap_overrides() -> {ok}");
+
+ let stop = Arc::new(AtomicBool::new(false));
+ let allocs = Arc::new(AtomicU64::new(0));
+ let frees = Arc::new(AtomicU64::new(0));
+ let reallocs = Arc::new(AtomicU64::new(0));
+
+ let handles: Vec<_> = (0..threads)
+ .map(|t| {
+ let stop = Arc::clone(&stop);
+ let allocs = Arc::clone(&allocs);
+ let frees = Arc::clone(&frees);
+ let reallocs = Arc::clone(&reallocs);
+ thread::spawn(move || worker(t as u64, stop, allocs, frees, reallocs))
+ })
+ .collect();
+
+ let deadline = secs.map(|s| Instant::now() + Duration::from_secs(s));
+ let mut cpu_state = 0x1234_5678_9abc_def0u64;
+ let mut tick = 0u64;
+ loop {
+ if let Some(d) = deadline {
+ if Instant::now() >= d {
+ break;
+ }
+ }
+ if stress {
+ cpu_state = burn_cpu_for(Duration::from_secs(1), cpu_state ^ tick);
+ } else {
+ std::thread::sleep(Duration::from_secs(1));
+ }
+ tick += 1;
+ println!(
+ "[{tick}s] allocs={} frees={} reallocs={}",
+ allocs.load(Ordering::Relaxed),
+ frees.load(Ordering::Relaxed),
+ reallocs.load(Ordering::Relaxed),
+ );
+ }
+
+ stop.store(true, Ordering::Relaxed);
+ for h in handles {
+ h.join().unwrap();
+ }
+ println!(
+ "done. allocs={} frees={} reallocs={}",
+ allocs.load(Ordering::Relaxed),
+ frees.load(Ordering::Relaxed),
+ reallocs.load(Ordering::Relaxed),
+ );
+ }
+} // mod linux
diff --git a/libdd-heap-gotter/src/elf.rs b/libdd-heap-gotter/src/elf.rs
new file mode 100644
index 0000000000..6b255bb92a
--- /dev/null
+++ b/libdd-heap-gotter/src/elf.rs
@@ -0,0 +1,940 @@
+// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/
+// SPDX-License-Identifier: Apache-2.0
+
+//! GOT-table interposition primitives.
+//!
+//! Port of ddprof's `src/lib/elfutils.cc` + the parts of
+//! `symbol_overrides.cc` that drive it. Walks each loaded ELF object via
+//! `dl_iterate_phdr`, parses its `PT_DYNAMIC` for the symbol/string/hash
+//! tables and the relocation arrays, and rewrites GOT entries whose
+//! symbol name is in the override map. Records the previous values so
+//! the overrides can be reverted.
+//!
+//! Scope:
+//! * 64-bit ELF only (`Elf64_*`). Other targets are gated out at compile time via `#[cfg]` on the
+//! parent module.
+//! * GNU hash tables only - `DT_HASH` is skipped because it has caused problems on older glibc
+//! systems.
+//! * REL / RELA / JMPREL relocation arrays.
+
+use core::ffi::{c_char, c_int, c_void};
+use std::collections::HashMap;
+use std::ffi::CStr;
+
+use libc::{
+ dl_iterate_phdr, dl_phdr_info, mprotect, sysconf, Elf64_Rel, Elf64_Rela, Elf64_Sym, PROT_EXEC,
+ PROT_READ, PROT_WRITE, PT_DYNAMIC, PT_LOAD, _SC_PAGESIZE,
+};
+use std::io::{BufRead, BufReader};
+use std::sync::atomic::{AtomicUsize, Ordering};
+
+// ELF dynamic-section tags and friends. The `libc` crate doesn't export
+// these (they're processor-independent ELF spec constants), so we name
+// them locally. Values come from ``.
+#[allow(non_camel_case_types)]
+#[repr(C)]
+struct Elf64_Dyn {
+ d_tag: i64,
+ d_un: u64, // d_val / d_ptr union; we only ever read it as u64
+}
+const DT_NULL: i64 = 0;
+const DT_STRTAB: i64 = 5;
+const DT_SYMTAB: i64 = 6;
+const DT_RELA: i64 = 7;
+const DT_RELASZ: i64 = 8;
+const DT_STRSZ: i64 = 10;
+const DT_REL: i64 = 17;
+const DT_RELSZ: i64 = 18;
+const DT_PLTREL: i64 = 20;
+const DT_JMPREL: i64 = 23;
+const DT_PLTRELSZ: i64 = 2;
+const DT_GNU_HASH: i64 = 0x6fff_fef5;
+const STN_UNDEF: u32 = 0;
+
+/// The subset of an ELF object's `PT_DYNAMIC` entries needed to find and rewrite GOT entries.
+struct DynamicInfo {
+ strtab: *const c_char,
+ strtab_size: usize,
+ symtab: *const Elf64_Sym,
+ sym_count: u32,
+ rels: *const Elf64_Rel,
+ rels_count: usize,
+ relas: *const Elf64_Rela,
+ relas_count: usize,
+ jmprels: *const Elf64_Rela,
+ jmprels_count: usize,
+ gnu_hash: *const u32,
+ gnu_hash_words: usize,
+ base_address: usize,
+}
+
+impl DynamicInfo {
+ /// Read DT_* entries out of a PT_DYNAMIC array. Handles the
+ /// glibc-vs-musl quirk where glibc stores absolute addresses in DT
+ /// entries while musl stores load-relative offsets; we use the
+ /// `addr > base ? addr : base + addr` heuristic.
+ unsafe fn from_phdr(info: &dl_phdr_info) -> Option {
+ let phdrs = std::slice::from_raw_parts(info.dlpi_phdr, info.dlpi_phnum as usize);
+ let dyn_phdr = phdrs.iter().find(|p| p.p_type == PT_DYNAMIC)?;
+ let dyn_begin = (info.dlpi_addr as usize + dyn_phdr.p_vaddr as usize) as *const Elf64_Dyn;
+ let base = info.dlpi_addr as usize;
+ let containing_load_segment_end = |addr: usize| -> Option {
+ phdrs.iter().filter(|p| p.p_type == PT_LOAD).find_map(|p| {
+ let start = base.checked_add(p.p_vaddr as usize)?;
+ let end = start.checked_add(p.p_memsz as usize)?;
+ (addr >= start && addr < end).then_some(end)
+ })
+ };
+ let correct = |a: u64| -> usize {
+ let a = a as usize;
+ if a > base {
+ a
+ } else {
+ base + a
+ }
+ };
+
+ let mut strtab: *const c_char = std::ptr::null();
+ let mut strtab_size: usize = 0;
+ let mut symtab: *const Elf64_Sym = std::ptr::null();
+ let mut rels: *const Elf64_Rel = std::ptr::null();
+ let mut rels_size: usize = 0;
+ let mut relas: *const Elf64_Rela = std::ptr::null();
+ let mut relas_size: usize = 0;
+ let mut jmprels: *const Elf64_Rela = std::ptr::null();
+ let mut jmprels_size: usize = 0;
+ let mut gnu_hash: *const u32 = std::ptr::null();
+ let mut pltrel_type: i64 = 0;
+
+ let mut it = dyn_begin;
+ loop {
+ let d = &*it;
+ if d.d_tag == DT_NULL {
+ break;
+ }
+ let v = d.d_un;
+ match d.d_tag {
+ DT_STRTAB => strtab = correct(v) as *const c_char,
+ DT_STRSZ => strtab_size = v as usize,
+ DT_SYMTAB => symtab = correct(v) as *const Elf64_Sym,
+ DT_GNU_HASH => gnu_hash = correct(v) as *const u32,
+ DT_REL => rels = correct(v) as *const Elf64_Rel,
+ DT_RELA => relas = correct(v) as *const Elf64_Rela,
+ DT_JMPREL => jmprels = correct(v) as *const Elf64_Rela,
+ DT_RELSZ => rels_size = v as usize,
+ DT_RELASZ => relas_size = v as usize,
+ DT_PLTRELSZ => jmprels_size = v as usize,
+ DT_PLTREL => pltrel_type = v as i64,
+ _ => {}
+ }
+ it = it.add(1);
+ }
+
+ // JMPREL entries are RELA only if DT_PLTREL says so.
+ if pltrel_type != DT_RELA {
+ jmprels = std::ptr::null();
+ jmprels_size = 0;
+ }
+
+ if strtab.is_null() || symtab.is_null() || gnu_hash.is_null() {
+ return None;
+ }
+
+ let gnu_hash_addr = gnu_hash as usize;
+ let gnu_hash_words = match containing_load_segment_end(gnu_hash_addr) {
+ Some(end) => match end.checked_sub(gnu_hash_addr) {
+ Some(bytes) => bytes / core::mem::size_of::(),
+ None => return None,
+ },
+ None => return None,
+ };
+ let sym_count = gnu_hash_symbol_count(gnu_hash, gnu_hash_words).unwrap_or_else(|| {
+ // Fallback for degenerate .gnu.hash (e.g. executables with only
+ // undefined imports): estimate dynsym entry count from the common
+ // .dynsym-before-.dynstr layout. This is a heuristic, not an ELF
+ // guarantee. If it underestimates we may skip patching some
+ // relocations; valid relocation indexes should still keep an
+ // overestimate from faulting on normal loaded objects.
+ let symtab_addr = symtab as usize;
+ let strtab_addr = strtab as usize;
+ if strtab_addr > symtab_addr {
+ let bytes = strtab_addr - symtab_addr;
+ (bytes / core::mem::size_of::()) as u32
+ } else {
+ // Can't estimate; allow any index and rely on strtab
+ // bounds checking in sym_name to catch bad accesses.
+ u32::MAX
+ }
+ });
+
+ Some(Self {
+ strtab,
+ strtab_size,
+ symtab,
+ sym_count,
+ rels,
+ rels_count: rels_size / core::mem::size_of::(),
+ relas,
+ relas_count: relas_size / core::mem::size_of::(),
+ jmprels,
+ jmprels_count: jmprels_size / core::mem::size_of::(),
+ gnu_hash,
+ gnu_hash_words,
+ base_address: base,
+ })
+ }
+
+ unsafe fn sym_name(&self, idx: u32) -> Option<&CStr> {
+ if (idx as usize) >= self.sym_count as usize {
+ return None;
+ }
+ let sym = &*self.symtab.add(idx as usize);
+ let off = sym.st_name as usize;
+ if off >= self.strtab_size {
+ return None;
+ }
+ Some(CStr::from_ptr(self.strtab.add(off)))
+ }
+}
+
+/// Compute the GNU symbol hash used by `DT_GNU_HASH` tables.
+/// See .
+fn gnu_hash(name: &[u8]) -> u32 {
+ let mut h: u32 = 5381;
+ for c in name {
+ h = h.wrapping_shl(5).wrapping_add(h).wrapping_add(*c as u32);
+ }
+ h
+}
+
+/// Compute the total number of entries in `.dynsym` from the `.gnu.hash` table.
+///
+/// Returns `None` only when the table is structurally invalid. When the hash
+/// is degenerate (all buckets empty, typical for executables that only import
+/// symbols), returns `None` to signal that the caller should use a fallback
+/// (e.g. estimate from symtab/strtab distance) since the hash table doesn't
+/// tell us how many undefined-import entries precede the hashed region.
+unsafe fn gnu_hash_symbol_count(hashtab: *const u32, hashtab_words: usize) -> Option {
+ if hashtab_words < 4 {
+ return None;
+ }
+
+ let nbuckets = *hashtab;
+ let symbias = *hashtab.add(1);
+ let bloom_size = *hashtab.add(2);
+ let bloom_size_words = (bloom_size as usize).checked_mul(2)?;
+ let buckets_start = 4usize.checked_add(bloom_size_words)?;
+ let chains_start = buckets_start.checked_add(nbuckets as usize)?;
+
+ if bloom_size == 0 || buckets_start > hashtab_words || chains_start > hashtab_words {
+ return None;
+ }
+ if nbuckets == 0 {
+ // No buckets at all: can't determine symtab size from the hash.
+ return None;
+ }
+
+ let buckets = std::slice::from_raw_parts(hashtab.add(buckets_start), nbuckets as usize);
+ let mut idx = *buckets.iter().max()?;
+ if idx == STN_UNDEF {
+ // All buckets empty: hash covers zero defined symbols, but the
+ // symtab may still have undefined imports. Signal the caller to
+ // use a fallback.
+ return None;
+ }
+ if idx < symbias {
+ return None;
+ }
+
+ let chain_count = hashtab_words - chains_start;
+ loop {
+ let chain_idx = (idx - symbias) as usize;
+ if chain_idx >= chain_count {
+ return None;
+ }
+ if *hashtab.add(chains_start + chain_idx) & 1 != 0 {
+ return idx.checked_add(1);
+ }
+ idx = idx.checked_add(1)?;
+ }
+}
+
+unsafe fn gnu_hash_lookup(info: &DynamicInfo, name: &[u8]) -> Option {
+ let hashtab = info.gnu_hash;
+ if info.gnu_hash_words < 4 {
+ return None;
+ }
+
+ let nbuckets = *hashtab;
+ let symbias = *hashtab.add(1);
+ let bloom_size = *hashtab.add(2);
+ let bloom_shift = *hashtab.add(3);
+ let bloom_size_words = (bloom_size as usize).checked_mul(2)?;
+ let buckets_start = 4usize.checked_add(bloom_size_words)?;
+ let chains_start = buckets_start.checked_add(nbuckets as usize)?;
+
+ if nbuckets == 0
+ || bloom_size == 0
+ || buckets_start > info.gnu_hash_words
+ || chains_start > info.gnu_hash_words
+ {
+ return None;
+ }
+
+ let h = gnu_hash(name);
+ let bloom = hashtab.add(4) as *const u64;
+ let word = *bloom.add(((h / 64) & (bloom_size - 1)) as usize);
+ let bit1 = h & 63;
+ let bit2 = (h >> bloom_shift) & 63;
+ if ((word >> bit1) & (word >> bit2) & 1) == 0 {
+ return None;
+ }
+
+ let buckets = hashtab.add(buckets_start);
+ let mut symidx = *buckets.add((h % nbuckets) as usize);
+ if symidx == STN_UNDEF {
+ return None;
+ }
+ if symidx < symbias {
+ return None;
+ }
+
+ let chain_count = info.gnu_hash_words - chains_start;
+ loop {
+ let chain_idx = (symidx - symbias) as usize;
+ if chain_idx >= chain_count {
+ return None;
+ }
+ let chain_h = *hashtab.add(chains_start + chain_idx);
+ if ((chain_h ^ h) >> 1) == 0 {
+ if let Some(sname) = info.sym_name(symidx) {
+ let sym = info.symtab.add(symidx as usize);
+ if sname.to_bytes() == name && check_sym(&*sym) {
+ return Some(*sym);
+ }
+ }
+ }
+ if chain_h & 1 != 0 {
+ break;
+ }
+ symidx = symidx.checked_add(1)?;
+ }
+ None
+}
+
+/// Return whether this is a defining function/object/notype symbol.
+fn check_sym(sym: &Elf64_Sym) -> bool {
+ const SHN_ABS: u16 = 0xfff1;
+ let stt = sym.st_info & 0xf;
+ (sym.st_value != 0 || sym.st_shndx == SHN_ABS) &&
+ // STT_NOTYPE(0), STT_OBJECT(1), STT_FUNC(2), STT_GNU_IFUNC(10)
+ matches!(stt, 0 | 1 | 2 | 10)
+}
+
+/// Visit each loaded ELF object once. `is_exe` is true only on the
+/// first callback (the main executable). The callback returns `true` to
+/// stop iteration.
+fn iterate_libraries(mut callback: impl FnMut(&dl_phdr_info, bool) -> bool) {
+ struct Ctx<'a> {
+ callback: &'a mut dyn FnMut(&dl_phdr_info, bool) -> bool,
+ is_first: bool,
+ }
+ let mut ctx = Ctx {
+ callback: &mut callback,
+ is_first: true,
+ };
+
+ unsafe extern "C" fn trampoline(
+ info: *mut dl_phdr_info,
+ _size: libc::size_t,
+ data: *mut c_void,
+ ) -> c_int {
+ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ let ctx = &mut *(data as *mut Ctx);
+ let is_exe = ctx.is_first;
+ ctx.is_first = false;
+ (ctx.callback)(&*info, is_exe)
+ }));
+
+ // Never unwind a Rust panic through libc's dl_iterate_phdr callback.
+ // Treat patching as best-effort and stop iteration on panic.
+ result.map(i32::from).unwrap_or(1)
+ }
+
+ unsafe {
+ dl_iterate_phdr(Some(trampoline), &mut ctx as *mut _ as *mut c_void);
+ }
+}
+
+unsafe fn library_name(info: &dl_phdr_info) -> String {
+ if info.dlpi_name.is_null() {
+ String::new()
+ } else {
+ CStr::from_ptr(info.dlpi_name)
+ .to_string_lossy()
+ .into_owned()
+ }
+}
+
+fn loaded_library_name_at_base(base: usize) -> Option {
+ let mut found = None;
+ iterate_libraries(|info, _| unsafe {
+ if info.dlpi_addr as usize == base {
+ found = Some(library_name(info));
+ true
+ } else {
+ false
+ }
+ });
+ found
+}
+
+fn should_restore_library(expected_name: &str, current_name: Option<&str>) -> bool {
+ current_name == Some(expected_name)
+}
+
+/// A single /proc/self/maps entry: address range + current protection flags.
+#[derive(Clone, Copy)]
+struct MapEntry {
+ start: usize,
+ end: usize,
+ prot: i32,
+}
+
+/// Parse /proc/self/maps into a sorted list of (range, prot) entries.
+///
+/// Used to remember each GOT page's original protection so we can restore
+/// it after patching, rather than leaving Full-RELRO pages read-write for
+/// the lifetime of the process.
+fn read_proc_maps() -> Vec {
+ let Ok(f) = std::fs::File::open("/proc/self/maps") else {
+ return Vec::new();
+ };
+ let mut out = Vec::new();
+ for line in BufReader::new(f).lines().map_while(Result::ok) {
+ let mut parts = line.split_whitespace();
+ let Some(range) = parts.next() else { continue };
+ let Some(perms) = parts.next() else { continue };
+ let Some(dash) = range.find('-') else {
+ continue;
+ };
+ let Ok(start) = usize::from_str_radix(&range[..dash], 16) else {
+ continue;
+ };
+ let Ok(end) = usize::from_str_radix(&range[dash + 1..], 16) else {
+ continue;
+ };
+ let b = perms.as_bytes();
+ let mut prot = 0;
+ if b.first() == Some(&b'r') {
+ prot |= PROT_READ;
+ }
+ if b.get(1) == Some(&b'w') {
+ prot |= PROT_WRITE;
+ }
+ if b.get(2) == Some(&b'x') {
+ prot |= PROT_EXEC;
+ }
+ out.push(MapEntry { start, end, prot });
+ }
+ out
+}
+
+/// Batched GOT-entry patcher that remembers each touched page's
+/// original protection and restores it at the end of a patching pass.
+///
+/// On Full-RELRO binaries, GOT pages start read-only. The old
+/// `override_entry` helper flipped them to read-write and never
+/// restored them, weakening RELRO for the process lifetime. This guard
+/// mprotects each unique page once (RW), lets the caller write as
+/// many entries as it needs, then mprotects each page back to what
+/// `/proc/self/maps` reported at guard-construction time.
+struct PageProtGuard {
+ page_size: usize,
+ maps: Vec,
+ // Aligned page base -> original prot flags read from /proc/self/maps.
+ touched: HashMap,
+}
+
+impl PageProtGuard {
+ fn new() -> Self {
+ // sysconf can return -1 on error; fall back to a conservative
+ // 4 KiB default if the query fails.
+ let raw = unsafe { sysconf(_SC_PAGESIZE) };
+ let page_size = usize::try_from(raw).unwrap_or(4096);
+ Self {
+ page_size,
+ maps: read_proc_maps(),
+ touched: HashMap::new(),
+ }
+ }
+
+ fn original_prot(&self, addr: usize) -> Option {
+ self.maps
+ .iter()
+ .find(|m| addr >= m.start && addr < m.end)
+ .map(|m| m.prot)
+ }
+
+ /// Make the containing page writable if it isn't already touched,
+ /// then replace one GOT entry.
+ unsafe fn override_entry(&mut self, addr: usize, new_value: usize) -> bool {
+ let aligned = addr & !(self.page_size - 1);
+ if !self.touched.contains_key(&aligned) {
+ // If /proc/self/maps isn't available (or the page isn't in
+ // it, which shouldn't happen for a mapped GOT page) fall
+ // back to PROT_READ - the RELRO'd default. That's tighter
+ // than the previous behavior of leaving pages RW.
+ let orig = self.original_prot(aligned).unwrap_or(PROT_READ);
+ if mprotect(
+ aligned as *mut c_void,
+ self.page_size,
+ PROT_READ | PROT_WRITE,
+ ) != 0
+ {
+ return false;
+ }
+ self.touched.insert(aligned, orig);
+ }
+ std::ptr::write_unaligned(addr as *mut usize, new_value);
+ true
+ }
+
+ /// Restore every touched page to its original protection.
+ fn finish(mut self) {
+ for (aligned, orig) in self.touched.drain() {
+ // Best-effort: nothing sensible to do on failure other than
+ // leave the page RW, which is the pre-fix behavior.
+ unsafe { mprotect(aligned as *mut c_void, self.page_size, orig) };
+ }
+ }
+}
+
+/// Read one GOT entry without assuming pointer alignment.
+unsafe fn read_entry(addr: usize) -> usize {
+ std::ptr::read_unaligned(addr as *const usize)
+}
+
+#[derive(Clone, Copy)]
+pub struct LookupResult {
+ pub address: usize,
+}
+
+/// Look up a symbol across loaded objects, returning the first
+/// non-zero-sized definition whose address is not `not_this_symbol`.
+/// Null-sized symbols are ignored so hooks resolve to callable definitions.
+pub fn lookup_symbol(name: &str, not_this_symbol: usize) -> Option {
+ let needle = name.as_bytes();
+ let mut found: Option = None;
+ iterate_libraries(|info, _is_exe| unsafe {
+ let lib_name = if info.dlpi_name.is_null() {
+ ""
+ } else {
+ CStr::from_ptr(info.dlpi_name).to_str().unwrap_or("")
+ };
+ if lib_name.contains("linux-vdso") || lib_name.contains("/ld-linux") {
+ return false;
+ }
+ let Some(dyn_info) = DynamicInfo::from_phdr(info) else {
+ return false;
+ };
+ if let Some(sym) = gnu_hash_lookup(&dyn_info, needle) {
+ if sym.st_size > 0 {
+ let addr = sym.st_value as usize + dyn_info.base_address;
+ if addr != not_this_symbol {
+ found = Some(LookupResult { address: addr });
+ return true; // stop
+ }
+ }
+ }
+ false
+ });
+ found
+}
+
+/// Per-library revert info: GOT addr -> original value at that addr.
+#[derive(Default)]
+struct LibraryRevertInfo {
+ /// Identifies the library at this base address, so we can detect
+ /// base-address reuse after a `dlclose` + `dlopen` places a
+ /// different library at the same load address.
+ library_name: String,
+ old_value_per_address: HashMap,
+ processed: bool,
+}
+
+/// One registered override entry.
+struct OverrideInfo {
+ /// Output slot the install path fills with the resolved real symbol
+ /// address (so hooks can call through it). This is a shared static
+ /// atomic supplied by the caller; the install-time write goes through
+ /// `store(Release)` to pair with the hook-side `load(Acquire)`.
+ ref_slot: &'static AtomicUsize,
+ /// Address of our hook function, written into matching GOT entries.
+ new_symbol: usize,
+ /// If a GOT entry's address equals this, leave it alone. Used to
+ /// avoid clobbering our own ref slot's relocation in this library
+ /// (otherwise applying our override would replace the resolved real
+ /// symbol with our hook, causing infinite recursion when the hook
+ /// calls back through `ref_slot`).
+ do_not_override_this_symbol: usize,
+}
+
+/// Holds the override table and the per-library revert info needed to undo writes.
+#[derive(Default)]
+pub struct SymbolOverrides {
+ overrides: HashMap,
+ revert_info_per_library: HashMap,
+ last_seen_nb_libs: i32,
+}
+
+impl SymbolOverrides {
+ pub fn new() -> Self {
+ Self {
+ overrides: HashMap::new(),
+ revert_info_per_library: HashMap::new(),
+ last_seen_nb_libs: -1,
+ }
+ }
+
+ /// Register an override. `ref_slot` is filled in by `apply_overrides`
+ /// with the resolved address of the real symbol so the hook can call
+ /// through it. The install path publishes via `store(Release)`.
+ pub fn register(&mut self, name: &str, hook: usize, ref_slot: &'static AtomicUsize) {
+ self.overrides.insert(
+ name.to_string(),
+ OverrideInfo {
+ ref_slot,
+ new_symbol: hook,
+ // Filled in by apply_overrides: we set it to the address
+ // of our own `ref_slot` once we know it. For a static
+ // Rust we can pass 0 - see note in apply_overrides.
+ do_not_override_this_symbol: 0,
+ },
+ );
+ }
+
+ /// Resolve real-symbol addresses, then walk every loaded library and
+ /// patch GOT entries.
+ pub fn apply_overrides(&mut self) {
+ // 1. Resolve each override's underlying real symbol so hooks can forward through it.
+ // Excluding our own hook function address avoids picking up a self-reference (when the
+ // gotter library itself exports the same name - it won't in our case, but cheap
+ // insurance).
+ let resolved: Vec<(String, usize)> = self
+ .overrides
+ .iter()
+ .filter_map(|(name, ov)| {
+ lookup_symbol(name, ov.new_symbol).map(|r| (name.clone(), r.address))
+ })
+ .collect();
+ for (name, addr) in resolved {
+ if let Some(ov) = self.overrides.get_mut(&name) {
+ // Release pairs with the hook-side Acquire load.
+ ov.ref_slot.store(addr, Ordering::Release);
+ }
+ }
+ self.update_overrides();
+ }
+
+ /// Process any newly-loaded libraries (e.g. after `dlopen`).
+ /// No-op if the loaded-library count hasn't changed.
+ pub fn update_overrides(&mut self) {
+ // `dl_phdr_info::dlpi_adds` is incremented on every dlopen.
+ // Use it as a cheap "did anything change?" probe.
+ let mut nb_loaded: i32 = -1;
+ iterate_libraries(|info, _| {
+ nb_loaded = info.dlpi_adds as i32;
+ true
+ });
+ if nb_loaded == self.last_seen_nb_libs {
+ return;
+ }
+ self.last_seen_nb_libs = nb_loaded;
+
+ for v in self.revert_info_per_library.values_mut() {
+ v.processed = false;
+ }
+
+ // TODO: This is intentionally simple but expensive on workloads that
+ // dlopen many libraries: every change re-walks all loaded objects,
+ // re-parses their dynamic sections/GNU hash tables, and eagerly reads
+ // /proc/self/maps via PageProtGuard even if only one new object needs
+ // patching. Track already-processed libraries and lazily create the
+ // page-protection guard to avoid repeated heavy work.
+ let mut guard = PageProtGuard::new();
+
+ // SAFETY: closure runs synchronously inside dl_iterate_phdr.
+ let self_ptr = self as *mut Self as usize;
+ let guard_ptr = &mut guard as *mut PageProtGuard as usize;
+ iterate_libraries(move |info, _is_exe| unsafe {
+ let this = &mut *(self_ptr as *mut Self);
+ let g = &mut *(guard_ptr as *mut PageProtGuard);
+ let lib_name = if info.dlpi_name.is_null() {
+ String::new()
+ } else {
+ CStr::from_ptr(info.dlpi_name)
+ .to_string_lossy()
+ .into_owned()
+ };
+ if lib_name.contains("linux-vdso") || lib_name.contains("/ld-linux") {
+ return false;
+ }
+ if let Some(dyn_info) = DynamicInfo::from_phdr(info) {
+ this.apply_to_library(&dyn_info, lib_name, g);
+ }
+ false
+ });
+
+ guard.finish();
+
+ // Drop any tracked libraries that have been unloaded.
+ self.revert_info_per_library.retain(|_, v| v.processed);
+ }
+
+ /// Restore every GOT entry we touched.
+ pub fn restore_overrides(&mut self) {
+ self.restore_overrides_with_lookup(loaded_library_name_at_base);
+ }
+
+ fn restore_overrides_with_lookup(
+ &mut self,
+ mut loaded_name_at_base: impl FnMut(usize) -> Option,
+ ) -> usize {
+ let info_per_lib = std::mem::take(&mut self.revert_info_per_library);
+ let mut guard = None;
+ let mut restored_libraries = 0;
+ for (base, revert) in info_per_lib {
+ // A tracked library may have been dlclose'd since the last update,
+ // and its address range may even have been reused by a different
+ // mapping. Only write old GOT values back when the same library is
+ // still loaded at the original base address.
+ if !should_restore_library(
+ revert.library_name.as_str(),
+ loaded_name_at_base(base).as_deref(),
+ ) {
+ continue;
+ }
+
+ restored_libraries += 1;
+ let guard = guard.get_or_insert_with(PageProtGuard::new);
+ for (addr, old) in revert.old_value_per_address {
+ unsafe { guard.override_entry(addr, old) };
+ }
+ }
+ if let Some(guard) = guard {
+ guard.finish();
+ }
+ self.last_seen_nb_libs = -1;
+ restored_libraries
+ }
+
+ unsafe fn apply_to_library(
+ &mut self,
+ dyn_info: &DynamicInfo,
+ library_name: String,
+ guard: &mut PageProtGuard,
+ ) {
+ // Detect base-address reuse: a previous `dlclose` may have freed
+ // the load address, and a later `dlopen` can place a different
+ // library at the same address. If the name differs from what we
+ // recorded, the stored `old_value_per_address` refers to memory
+ // that is either unmapped or now belongs to the new library, so
+ // we must not try to restore it. Drop the stale entry and treat
+ // this as a fresh library.
+ let entry_is_new = match self.revert_info_per_library.entry(dyn_info.base_address) {
+ std::collections::hash_map::Entry::Vacant(e) => {
+ e.insert(LibraryRevertInfo {
+ library_name: library_name.clone(),
+ processed: true,
+ ..Default::default()
+ });
+ true
+ }
+ std::collections::hash_map::Entry::Occupied(mut e) => {
+ if e.get().library_name != library_name {
+ // Base-address reuse: replace the stale entry.
+ e.insert(LibraryRevertInfo {
+ library_name: library_name.clone(),
+ processed: true,
+ ..Default::default()
+ });
+ true
+ } else {
+ e.get_mut().processed = true;
+ false
+ }
+ }
+ };
+ if !entry_is_new {
+ return;
+ }
+
+ // Hand-managed split borrow so we can pass &overrides + &mut revert.
+ let revert = self
+ .revert_info_per_library
+ .get_mut(&dyn_info.base_address)
+ .unwrap();
+
+ if !dyn_info.rels.is_null() {
+ let relocs = std::slice::from_raw_parts(dyn_info.rels, dyn_info.rels_count);
+ for reloc in relocs {
+ Self::process_relocation(
+ &self.overrides,
+ dyn_info,
+ elf64_r_sym(reloc.r_info) as u32,
+ reloc.r_offset as usize,
+ revert,
+ guard,
+ );
+ }
+ }
+ for slice_ptr_and_len in [
+ (dyn_info.relas, dyn_info.relas_count),
+ (dyn_info.jmprels, dyn_info.jmprels_count),
+ ] {
+ if slice_ptr_and_len.0.is_null() {
+ continue;
+ }
+ let relocs = std::slice::from_raw_parts(slice_ptr_and_len.0, slice_ptr_and_len.1);
+ for reloc in relocs {
+ Self::process_relocation(
+ &self.overrides,
+ dyn_info,
+ elf64_r_sym(reloc.r_info) as u32,
+ reloc.r_offset as usize,
+ revert,
+ guard,
+ );
+ }
+ }
+ }
+
+ unsafe fn process_relocation(
+ overrides: &HashMap,
+ dyn_info: &DynamicInfo,
+ sym_index: u32,
+ r_offset: usize,
+ revert: &mut LibraryRevertInfo,
+ guard: &mut PageProtGuard,
+ ) {
+ // st_name -> string in strtab. Walk lazily: we look up the
+ // name in the override map; if it's not there, skip. Relocation
+ // symbol indices come from the object being inspected, so guard
+ // them before dereferencing dyn_info.symtab.
+ let Some(cstr) = dyn_info.sym_name(sym_index) else {
+ return;
+ };
+ if cstr.to_bytes().is_empty() {
+ return;
+ }
+ let Ok(name) = cstr.to_str() else { return };
+
+ let Some(ov) = overrides.get(name) else {
+ return;
+ };
+ // `ref_slot==0` means we never resolved the real symbol, so the
+ // hook would call a NULL pointer. Skip.
+ let real = ov.ref_slot.load(Ordering::Acquire);
+ if real == 0 {
+ return;
+ }
+
+ let addr = r_offset + dyn_info.base_address;
+ if addr == ov.do_not_override_this_symbol {
+ return;
+ }
+ if revert.old_value_per_address.contains_key(&addr) {
+ return;
+ }
+ revert.old_value_per_address.insert(addr, read_entry(addr));
+ guard.override_entry(addr, ov.new_symbol);
+ }
+}
+
+fn elf64_r_sym(info: u64) -> u64 {
+ info >> 32
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn restore_library_requires_same_live_name() {
+ assert!(should_restore_library(
+ "/tmp/libfoo.so",
+ Some("/tmp/libfoo.so")
+ ));
+ assert!(!should_restore_library("/tmp/libfoo.so", None));
+ assert!(!should_restore_library(
+ "/tmp/libfoo.so",
+ Some("/tmp/libbar.so")
+ ));
+ }
+
+ #[test]
+ fn page_prot_guard_finds_original_mapping_protection() {
+ let guard = PageProtGuard {
+ page_size: 4096,
+ maps: vec![
+ MapEntry {
+ start: 0x1000,
+ end: 0x2000,
+ prot: PROT_READ,
+ },
+ MapEntry {
+ start: 0x2000,
+ end: 0x3000,
+ prot: PROT_READ | PROT_EXEC,
+ },
+ ],
+ touched: HashMap::new(),
+ };
+
+ assert_eq!(guard.original_prot(0x1000), Some(PROT_READ));
+ assert_eq!(guard.original_prot(0x1fff), Some(PROT_READ));
+ assert_eq!(guard.original_prot(0x2000), Some(PROT_READ | PROT_EXEC));
+ assert_eq!(guard.original_prot(0x3000), None);
+ }
+
+ #[test]
+ fn restore_overrides_skips_unloaded_or_reused_libraries() {
+ let mut overrides = SymbolOverrides::new();
+ overrides.revert_info_per_library.insert(
+ 0x1000,
+ LibraryRevertInfo {
+ library_name: "/tmp/libfoo.so".to_string(),
+ old_value_per_address: HashMap::new(),
+ processed: true,
+ },
+ );
+ overrides.revert_info_per_library.insert(
+ 0x2000,
+ LibraryRevertInfo {
+ library_name: "/tmp/libbar.so".to_string(),
+ old_value_per_address: HashMap::new(),
+ processed: true,
+ },
+ );
+ overrides.revert_info_per_library.insert(
+ 0x3000,
+ LibraryRevertInfo {
+ library_name: "/tmp/libbaz.so".to_string(),
+ old_value_per_address: HashMap::new(),
+ processed: true,
+ },
+ );
+
+ let restored = overrides.restore_overrides_with_lookup(|base| match base {
+ 0x1000 => Some("/tmp/libfoo.so".to_string()),
+ 0x2000 => Some("/tmp/different.so".to_string()),
+ 0x3000 => None,
+ _ => unreachable!(),
+ });
+
+ assert_eq!(restored, 1);
+ assert!(overrides.revert_info_per_library.is_empty());
+ assert_eq!(overrides.last_seen_nb_libs, -1);
+ }
+}
diff --git a/libdd-heap-gotter/src/hooks.rs b/libdd-heap-gotter/src/hooks.rs
new file mode 100644
index 0000000000..5d114500c1
--- /dev/null
+++ b/libdd-heap-gotter/src/hooks.rs
@@ -0,0 +1,246 @@
+// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/
+// SPDX-License-Identifier: Apache-2.0
+
+//! GOT hook functions and their per-symbol "real" function pointer slots.
+//!
+//! Each `gotter_*` function:
+//! 1. Calls `dd_allocation_requested` (or skips, for free-side hooks).
+//! 2. Forwards to the real symbol via its `ORIG_*` slot, which the install path fills in by
+//! symbol-table lookup.
+//! 3. Calls `dd_allocation_created` / `dd_allocation_freed` to fire the USDT.
+//!
+//! Modeled on ddprof `src/lib/symbol_overrides.cc`, minus the C++ allocator
+//! family (operator new/delete) and the mmap/munmap pair which aren't
+//! supported by the sampler yet.
+
+use core::ffi::{c_char, c_int, c_void};
+use std::sync::atomic::{AtomicUsize, Ordering};
+
+use libdd_heap_sampler::{
+ dd_alloc_req_t, dd_allocation_created, dd_allocation_freed, dd_allocation_realloc_commit,
+ dd_allocation_realloc_prepare, dd_allocation_requested, dd_tl_state_init,
+};
+
+/// Resolved address of the real `malloc`; filled by `install_heap_overrides`.
+/// The bare `usize` payload is a function pointer.
+pub(crate) static ORIG_MALLOC: AtomicUsize = AtomicUsize::new(0);
+/// Resolved address of the real `free`.
+pub(crate) static ORIG_FREE: AtomicUsize = AtomicUsize::new(0);
+/// Resolved address of the real `calloc`.
+pub(crate) static ORIG_CALLOC: AtomicUsize = AtomicUsize::new(0);
+/// Resolved address of the real `realloc`.
+pub(crate) static ORIG_REALLOC: AtomicUsize = AtomicUsize::new(0);
+/// Resolved address of the real `posix_memalign`.
+pub(crate) static ORIG_POSIX_MEMALIGN: AtomicUsize = AtomicUsize::new(0);
+/// Resolved address of the real `aligned_alloc`.
+pub(crate) static ORIG_ALIGNED_ALLOC: AtomicUsize = AtomicUsize::new(0);
+/// Resolved address of the real `dlopen`.
+pub(crate) static ORIG_DLOPEN: AtomicUsize = AtomicUsize::new(0);
+/// Resolved address of the real `pthread_create`.
+pub(crate) static ORIG_PTHREAD_CREATE: AtomicUsize = AtomicUsize::new(0);
+
+/// Load a resolved function pointer from one of the `ORIG_*` slots.
+#[inline]
+unsafe fn load_fn(slot: &AtomicUsize) -> Option {
+ let v = slot.load(Ordering::Acquire);
+ if v == 0 {
+ None
+ } else {
+ // SAFETY: caller guarantees T is the right `extern "C" fn(...)`
+ // type and that `v` was written by `apply_overrides` with a
+ // function of that signature.
+ Some(core::mem::transmute_copy::(&v))
+ }
+}
+
+/// Signature of the real `malloc`.
+type MallocFn = unsafe extern "C" fn(usize) -> *mut c_void;
+/// Signature of the real `free`.
+type FreeFn = unsafe extern "C" fn(*mut c_void);
+/// Signature of the real `calloc`.
+type CallocFn = unsafe extern "C" fn(usize, usize) -> *mut c_void;
+/// Signature of the real `realloc`.
+type ReallocFn = unsafe extern "C" fn(*mut c_void, usize) -> *mut c_void;
+/// Signature of the real `posix_memalign`.
+type PosixMemalignFn = unsafe extern "C" fn(*mut *mut c_void, usize, usize) -> c_int;
+/// Signature of the real `aligned_alloc`.
+type AlignedAllocFn = unsafe extern "C" fn(usize, usize) -> *mut c_void;
+/// Signature of the real `dlopen`.
+type DlopenFn = unsafe extern "C" fn(*const c_char, c_int) -> *mut c_void;
+/// Linux RTLD_DEEPBIND. Some libcs we build against (notably musl on Alpine)
+/// don't expose this constant through the Rust libc crate, but the flag value
+/// is stable Linux ABI from .
+const RTLD_DEEPBIND: c_int = 0x00008;
+/// Signature of a `pthread_create` start routine.
+type StartRoutine = unsafe extern "C" fn(*mut c_void) -> *mut c_void;
+/// Signature of the real `pthread_create`.
+type PthreadCreateFn = unsafe extern "C" fn(
+ *mut libc::pthread_t,
+ *const libc::pthread_attr_t,
+ StartRoutine,
+ *mut c_void,
+) -> c_int;
+
+#[no_mangle]
+pub unsafe extern "C" fn gotter_malloc(size: usize) -> *mut c_void {
+ let Some(real): Option = load_fn(&ORIG_MALLOC) else {
+ return std::ptr::null_mut();
+ };
+ // Default alignment for malloc on glibc is 2*sizeof(void*) == 16.
+ let req = dd_allocation_requested(size, core::mem::align_of::() * 2);
+ let raw = real(req.size);
+ dd_allocation_created(raw, req)
+}
+
+#[no_mangle]
+pub unsafe extern "C" fn gotter_free(ptr: *mut c_void) {
+ let Some(real): Option = load_fn(&ORIG_FREE) else {
+ return;
+ };
+ if ptr.is_null() {
+ return;
+ }
+ let freed = dd_allocation_freed(ptr, 0, 0);
+ real(freed.ptr);
+}
+
+#[no_mangle]
+pub unsafe extern "C" fn gotter_calloc(nmemb: usize, size: usize) -> *mut c_void {
+ let Some(real): Option = load_fn(&ORIG_CALLOC) else {
+ return std::ptr::null_mut();
+ };
+ let Some(total) = nmemb.checked_mul(size) else {
+ return real(nmemb, size);
+ };
+ let req = dd_allocation_requested(total, core::mem::align_of::() * 2);
+ // calloc takes (nmemb, size); when the sampler bumps `req.size` we
+ // funnel the extra bytes into the size argument (nmemb stays 1's
+ // worth conceptually). The simplest robust path is to switch to a
+ // single (1, req.size) allocation when sampling kicks in, so the
+ // underlying allocator zeroes everything we hand back. Unsampled
+ // path keeps the user's (nmemb, size) verbatim.
+ let raw = if req.weight == 0 {
+ real(nmemb, size)
+ } else {
+ real(1, req.size)
+ };
+ dd_allocation_created(raw, req)
+}
+
+/// `realloc` hook.
+///
+/// The sampler owns the realloc cases and pointer math. Gotter only does
+/// the pre/post split: ask the sampler what raw call to make, call the
+/// real realloc symbol, then ask the sampler to turn the result back into
+/// the user-visible pointer.
+#[no_mangle]
+pub unsafe extern "C" fn gotter_realloc(ptr: *mut c_void, size: usize) -> *mut c_void {
+ let Some(real): Option = load_fn(&ORIG_REALLOC) else {
+ return std::ptr::null_mut();
+ };
+ let prep = dd_allocation_realloc_prepare(ptr, size);
+ let new_raw = real(prep.raw_ptr, prep.raw_size);
+ dd_allocation_realloc_commit(ptr, new_raw, prep)
+}
+
+#[no_mangle]
+pub unsafe extern "C" fn gotter_posix_memalign(
+ memptr: *mut *mut c_void,
+ alignment: usize,
+ size: usize,
+) -> c_int {
+ let Some(real): Option = load_fn(&ORIG_POSIX_MEMALIGN) else {
+ return libc::ENOMEM;
+ };
+ let req = dd_allocation_requested(size, alignment);
+ let ret = real(memptr, alignment, req.size);
+ // Always pair with dd_allocation_created, even on failure, so the
+ // sampler's reentry guard opened by dd_allocation_requested is
+ // closed. Passing raw == NULL is the documented "allocation
+ // failed" path: no flag stamped, no USDT fired, guard closed.
+ if ret == 0 && !memptr.is_null() {
+ let raw = *memptr;
+ *memptr = dd_allocation_created(raw, req);
+ } else {
+ let _ = dd_allocation_created(std::ptr::null_mut(), req);
+ }
+ ret
+}
+
+#[no_mangle]
+pub unsafe extern "C" fn gotter_aligned_alloc(alignment: usize, size: usize) -> *mut c_void {
+ let Some(real): Option = load_fn(&ORIG_ALIGNED_ALLOC) else {
+ return std::ptr::null_mut();
+ };
+ let req = dd_allocation_requested(size, alignment);
+ let raw = real(alignment, req.size);
+ dd_allocation_created(raw, req)
+}
+
+/// Forward `dlopen` and then patch any GOT entries introduced by the newly-loaded library.
+#[no_mangle]
+pub unsafe extern "C" fn gotter_dlopen(filename: *const c_char, flags: c_int) -> *mut c_void {
+ let Some(real): Option = load_fn(&ORIG_DLOPEN) else {
+ // Hooks not yet wired up; calling real() would NPE - punt to libc.
+ return libc::dlopen(filename, flags);
+ };
+ let handle = real(filename, flags);
+ if flags & RTLD_DEEPBIND != 0 {
+ // DEEPBIND changes symbol resolution order and causes issues with
+ // GOT patching, so skip newly-loaded deep-bound libraries for now.
+ return handle;
+ }
+ // New library may have introduced new GOT entries that need patching.
+ // This hook is an extern "C" boundary, so never let a Rust panic from
+ // best-effort ELF parsing/GOT patching unwind into the caller.
+ let _ = std::panic::catch_unwind(crate::update_heap_overrides);
+ handle
+}
+
+/// Args we package up so the wrapped start routine sees its original
+/// arg through our trampoline.
+struct PthreadCreateArgs {
+ start: StartRoutine,
+ arg: *mut c_void,
+}
+
+unsafe extern "C" fn pthread_start_trampoline(arg: *mut c_void) -> *mut c_void {
+ let boxed: Box = Box::from_raw(arg as *mut PthreadCreateArgs);
+ // Materialise per-thread sampler state up front so the first
+ // tracked alloc on this thread doesn't have to.
+ dd_tl_state_init();
+ (boxed.start)(boxed.arg)
+}
+
+#[no_mangle]
+pub unsafe extern "C" fn gotter_pthread_create(
+ thread: *mut libc::pthread_t,
+ attr: *const libc::pthread_attr_t,
+ start: StartRoutine,
+ arg: *mut c_void,
+) -> c_int {
+ let Some(real): Option = load_fn(&ORIG_PTHREAD_CREATE) else {
+ return libc::EAGAIN;
+ };
+ let boxed = Box::new(PthreadCreateArgs { start, arg });
+ let raw = Box::into_raw(boxed);
+ let rc = real(thread, attr, pthread_start_trampoline, raw as *mut c_void);
+ if rc != 0 {
+ // Reclaim the box; trampoline won't run.
+ drop(Box::from_raw(raw));
+ }
+ rc
+}
+
+// Touch the sampler-side reentry guard helpers indirectly; silences
+// "unused import" warnings about `dd_alloc_req_t` once cfg-gated paths
+// extend hooks later.
+#[allow(dead_code)]
+fn _types_anchor() -> dd_alloc_req_t {
+ dd_alloc_req_t {
+ size: 0,
+ user_size: 0,
+ alignment: 0,
+ weight: 0,
+ }
+}
diff --git a/libdd-heap-gotter/src/lib.rs b/libdd-heap-gotter/src/lib.rs
new file mode 100644
index 0000000000..233b05d86c
--- /dev/null
+++ b/libdd-heap-gotter/src/lib.rs
@@ -0,0 +1,250 @@
+// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/
+// SPDX-License-Identifier: Apache-2.0
+
+//! GOT-table interposition for heap profiling.
+//!
+//! This crate installs hook functions over a running process's dynamic
+//! symbol relocations (the GOT / PLT-resolved entries) so that calls
+//! such as `malloc` and `free` are routed through
+//! [`libdd-heap-sampler`] without recompiling or relinking the target
+//! application. The approach mirrors ddprof's `src/lib/symbol_overrides.cc`
+//! + `src/lib/elfutils.cc`.
+//!
+//! The public API is available on every platform so downstream code
+//! never has to `#[cfg]`-guard its callers. The GOT-patching machinery
+//! itself only exists on 64-bit Linux (where `dl_iterate_phdr` + ELF64
+//! relocs are well-defined); on every other target the entry points compile
+//! to no-ops and `heap_overrides_are_installed()` always returns
+//! `false`.
+//!
+//! # Quickstart
+//!
+//! ```no_run
+//! libdd_heap_gotter::install_heap_overrides();
+//! // ... application runs; malloc/free/calloc/realloc/etc. flow through
+//! // libdd-heap-sampler and emit ddheap:alloc / ddheap:free USDTs ...
+//! libdd_heap_gotter::restore_heap_overrides();
+//! ```
+//!
+//! # Status
+//!
+//! Initial port. Covers: `malloc`, `free`, `calloc`, `realloc`,
+//! `posix_memalign`, `aligned_alloc`, plus `dlopen` (to re-scan on new
+//! library load) and `pthread_create` (to materialise sampler TLS up
+//! front on new threads).
+//!
+//! Not yet covered:
+//! * `operator new` / `operator delete` family
+//! * `mmap` / `munmap` (sampler-side API doesn't exist yet)
+//! * jemalloc-specific `mallocx`/`dallocx`/etc.
+//! * `pthread_atfork` child handler to reset state cleanly across `fork()`.
+
+#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
+mod elf;
+#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
+mod hooks;
+
+#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
+use std::sync::{Mutex, MutexGuard, TryLockError};
+
+#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
+use elf::SymbolOverrides;
+
+/// Holds the SymbolOverrides registry across calls to `install` / `update`
+/// / `restore`. Guarded globally because GOT patching mutates process-wide
+/// state.
+#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
+static GLOBAL_OVERRIDES: Mutex