naksheap: reconstruct heap object graphs from core dumps

Rust workspace that turns a core dump of a stripped, optimized C/C++
binary into a typed heap object graph: objects, sizes, allocator state,
references, and probable struct layouts, all without debug info.

- glibc ptmalloc carving (main + thread arenas, tcache/fastbin freed
  state, mmap allocations), ELF core + memory-list minidump parsing
- pointer scan, layout clustering, vtable/string/vector detection,
  confidence + evidence on every node
- ASCII/JSON/Graphviz/HTML output, synthetic fixtures with ground truth,
  and real-dump validation harness (aarch64 glibc 2.39) in scripts/
- web deployment reference stack in deploy/

MIT OR Apache-2.0
This commit is contained in:
lakshit verma 2026-08-15 04:21:59 +05:30
commit a48b163683
No known key found for this signature in database
59 changed files with 10518 additions and 0 deletions

View file

@ -0,0 +1,17 @@
[package]
name = "naksheap-inference"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Object graph construction, repeated-layout clustering, struct field inference, container detection, confidence scoring, JSON export"
[dependencies]
naksheap-core-parse = { path = "../naksheap-core-parse" }
naksheap-allocator-heuristics = { path = "../naksheap-allocator-heuristics" }
naksheap-pointer-scan = { path = "../naksheap-pointer-scan" }
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
naksheap-testkit = { path = "../naksheap-testkit" }

View file

@ -0,0 +1,507 @@
//! Layout-based clustering of carved heap objects.
//!
//! Objects are grouped by size, then by the shape of their words at aligned
//! (8-byte) offsets. A column is classified as *pointer* when at least half of
//! the members in a size group hold a pointer there (and the column is not
//! all-zero), and as *string* when at least half hold printable ASCII there.
//! A zero word at a pointer/string column is a nullable field slot and never
//! splits a member off. Objects whose per-member shape agrees with the group's
//! majority shape form the group's main cluster; deviant members are split into
//! their own clusters keyed by their identical shapes.
use std::collections::{BTreeMap, HashMap, HashSet};
use naksheap_allocator_heuristics::{Object, ObjectState};
use naksheap_core_parse::AddressSpace;
use naksheap_pointer_scan::Edge;
/// Fraction of members that must hold a pointer for a column to be a pointer
/// field. 50% (a majority) keeps nullable pointer fields from fragmenting.
const POINTER_THRESHOLD: f64 = 0.5;
/// Fraction of members that must hold a string for a column to be a string
/// field.
const STRING_THRESHOLD: f64 = 0.5;
/// Minimum run length of printable bytes for a word to read as a string.
const STRING_RUN: u32 = 4;
/// A string needs at least two distinct byte values, so constant fill patterns
/// ('AAAAAAAA') are padding, not text.
const STRING_MIN_DISTINCT: u32 = 2;
/// A group of objects sharing the same size and word shape.
#[derive(Debug, Clone, serde::Serialize)]
pub struct LayoutCluster {
pub size: u64,
/// Object addresses of the members.
pub members: Vec<u64>,
/// True at each aligned offset classified as a pointer column.
pub pointer_mask: Vec<bool>,
/// True at each aligned offset classified as a string column.
pub string_mask: Vec<bool>,
}
/// Address-space targets used for pointer classification: carved object
/// ranges plus known pointer-edge targets.
struct TargetIndex {
ranges: Vec<(u64, u64)>,
edge_targets: HashSet<u64>,
}
impl TargetIndex {
fn contains(&self, v: u64) -> bool {
if self.edge_targets.contains(&v) {
return true;
}
// `ranges` is sorted by start address; a stabbing lookup replaces the
// former linear scan (which made clustering quadratic on dumps where
// many object words do not resolve to mapped memory).
let idx = self.ranges.partition_point(|&(a, _)| a <= v);
if idx == 0 {
return false;
}
let (a, size) = self.ranges[idx - 1];
v < a.saturating_add(size)
}
}
/// Groups eligible (Allocated/Mmap) objects by layout similarity.
///
/// Freed objects are skipped for typing purposes; they are never clustered.
pub fn cluster_objects(
image: &(dyn AddressSpace + Sync),
objects: &[Object],
edges: &[Edge],
) -> Vec<LayoutCluster> {
let eligible: Vec<&Object> = objects
.iter()
.filter(|o| matches!(o.state, ObjectState::Allocated | ObjectState::Mmap))
.collect();
let mut ranges: Vec<(u64, u64)> = eligible.iter().map(|o| (o.addr, o.size)).collect();
ranges.sort_by_key(|&(a, _)| a);
let targets = TargetIndex {
ranges,
edge_targets: edges.iter().map(|e| e.to).collect(),
};
let mut by_size: BTreeMap<u64, Vec<&Object>> = BTreeMap::new();
for o in eligible {
by_size.entry(o.size).or_default().push(o);
}
let mut clusters: Vec<LayoutCluster> = Vec::new();
for (size, members) in by_size {
let agg = majority_masks(image, size, &members, &targets);
type MemberMask = (Vec<bool>, Vec<bool>);
let mut main: Vec<u64> = Vec::new();
let mut deviants: Vec<(&Object, MemberMask)> = Vec::new();
for m in members {
let indiv = individual_masks(image, m, &targets);
if agrees_with_aggregate(image, m, &indiv, &agg) {
main.push(m.addr);
} else {
deviants.push((m, indiv));
}
}
if !main.is_empty() {
clusters.push(LayoutCluster {
size,
members: main,
pointer_mask: agg.0,
string_mask: agg.1,
});
}
// Members that deviate from the majority shape are grouped among
// themselves by their identical shapes.
let mut dev_by_mask: HashMap<MemberMask, Vec<u64>> = HashMap::new();
for (m, mask) in deviants {
dev_by_mask.entry(mask).or_default().push(m.addr);
}
let addr_to_obj: HashMap<u64, &Object> = objects
.iter()
.map(|o| (o.addr, o))
.collect();
for (_, addrs) in dev_by_mask {
let sub_members: Vec<&Object> = addrs
.iter()
.filter_map(|a| addr_to_obj.get(a).copied())
.collect();
let (pm, sm) = majority_masks(image, size, &sub_members, &targets);
clusters.push(LayoutCluster {
size,
members: addrs,
pointer_mask: pm,
string_mask: sm,
});
}
}
clusters.sort_by_key(|c| std::cmp::Reverse(c.members.len()));
clusters
}
/// Majority-vote shape masks over a group of same-size objects.
fn majority_masks(
image: &(dyn AddressSpace + Sync),
size: u64,
members: &[&Object],
targets: &TargetIndex,
) -> (Vec<bool>, Vec<bool>) {
let n = members.len().max(1);
let n_words = size.div_ceil(8) as usize;
let mut ptr_cnt = vec![0usize; n_words];
let mut str_cnt = vec![0usize; n_words];
let mut zero_cnt = vec![0usize; n_words];
for m in members {
let (pm, sm) = individual_masks(image, m, targets);
for i in 0..n_words {
if pm[i] {
ptr_cnt[i] += 1;
}
if sm[i] {
str_cnt[i] += 1;
}
}
for (i, z) in zero_cnt.iter_mut().enumerate() {
let off = i as u64 * 8;
if off < m.size && image.read_word(m.addr.saturating_add(off)) == Some(0) {
*z += 1;
}
}
}
let ratio = |c: usize| c as f64 / n as f64;
// A column is padding only when *every* member is zero there; a mixed
// column (some NULL, some pointer) is a nullable pointer field.
let pointer_mask = (0..n_words)
.map(|i| ratio(ptr_cnt[i]) >= POINTER_THRESHOLD && zero_cnt[i] < n)
.collect();
let string_mask = (0..n_words)
.map(|i| ratio(str_cnt[i]) >= STRING_THRESHOLD)
.collect();
(pointer_mask, string_mask)
}
/// Per-member shape mask: each aligned word classified pointer/string.
fn individual_masks(
image: &(dyn AddressSpace + Sync),
obj: &Object,
targets: &TargetIndex,
) -> (Vec<bool>, Vec<bool>) {
let n_words = obj.size.div_ceil(8) as usize;
let mut ptr = vec![false; n_words];
let mut s = vec![false; n_words];
for i in 0..n_words {
let off = i as u64 * 8;
let addr = obj.addr.saturating_add(off);
if let Some(v) = image.read_word(addr) {
ptr[i] = is_pointer_value(image, v, targets);
s[i] = is_string_value(image, obj.addr, off);
}
}
(ptr, s)
}
/// A member belongs to the group when every column is compatible with the
/// aggregate shape. A zero word at an aggregate pointer/string column is a
/// nullable field slot, so it stays with the group instead of splitting.
fn agrees_with_aggregate(
image: &(dyn AddressSpace + Sync),
m: &Object,
indiv: &(Vec<bool>, Vec<bool>),
agg: &(Vec<bool>, Vec<bool>),
) -> bool {
let n_words = indiv
.0
.len()
.max(indiv.1.len())
.max(agg.0.len())
.max(agg.1.len());
for i in 0..n_words {
let off = i as u64 * 8;
if off >= m.size {
break;
}
let word = image.read_word(m.addr.saturating_add(off)).unwrap_or(0);
let is_ptr = indiv.0.get(i).copied().unwrap_or(false);
let is_str = indiv.1.get(i).copied().unwrap_or(false);
let agg_ptr = agg.0.get(i).copied().unwrap_or(false);
let agg_str = agg.1.get(i).copied().unwrap_or(false);
if agg_ptr {
// Nullable pointer column: zero is a compatible NULL slot; any
// other non-pointer value deviates.
if !is_ptr && word != 0 {
return false;
}
} else if is_ptr {
return false;
}
if agg_str {
// Nullable string column: a zero or a pointer is compatible.
if !is_str && word != 0 && !is_ptr {
return false;
}
} else if is_str {
return false;
}
}
true
}
/// A word is a pointer when it lands in a mapped/readable range, inside another
/// carved object, or in a file-backed (rodata/vtable) range. Zero is never a
/// pointer.
fn is_pointer_value(image: &(dyn AddressSpace + Sync), v: u64, targets: &TargetIndex) -> bool {
if v == 0 {
return false;
}
if image.read_bytes(v, 8).is_some() {
return true;
}
if targets.contains(v) {
return true;
}
image.is_file_backed(v)
}
/// A word is a string when the object's own 8 bytes at the offset are a
/// printable run with enough variance, or when the word's value points to
/// at least [`STRING_RUN`] consecutive printable bytes with at least
/// [`STRING_MIN_DISTINCT`] distinct values. Constant runs like `"AAAAAAAA"`
/// are fill/padding, not strings.
fn is_string_value(image: &(dyn AddressSpace + Sync), obj_addr: u64, off: u64) -> bool {
let addr = obj_addr.saturating_add(off);
// Inline case: a lone UTF-8 continuation byte (0x80..=0xbf) is not text on
// its own, so exclude it from the printable set here.
if let Some(bytes) = image.read_bytes(addr, 8) {
if printable_run_ok(bytes, true) {
return true;
}
}
if let Some(v) = image.read_word(addr) {
if v != 0 {
if let Some(bytes) = image.read_bytes(v, 16) {
if printable_run_ok(bytes, false) {
return true;
}
}
}
}
false
}
/// True when `bytes` contains a run of at least [`STRING_RUN`] printable bytes
/// with at least [`STRING_MIN_DISTINCT`] distinct printable values. When
/// `ascii_only` the UTF-8 continuation range is excluded (for inline text).
fn printable_run_ok(bytes: &[u8], ascii_only: bool) -> bool {
let mut run = 0u32;
let mut seen = [false; 256];
let mut distinct = 0u32;
for &b in bytes {
let printable = if ascii_only {
is_ascii_text(b)
} else {
is_printable(b)
};
if printable {
run += 1;
if !seen[b as usize] {
seen[b as usize] = true;
distinct += 1;
}
if run >= STRING_RUN && distinct >= STRING_MIN_DISTINCT {
return true;
}
} else {
run = 0;
}
}
false
}
/// Printable ASCII plus control whitespace; excludes UTF-8 continuation bytes.
fn is_ascii_text(b: u8) -> bool {
(0x20..=0x7e).contains(&b) || matches!(b, 0x09 | 0x0a | 0x0d)
}
/// Printable ASCII, control-whitespace, and UTF-8 continuation bytes.
pub(crate) fn is_printable(b: u8) -> bool {
(0x20..=0x7e).contains(&b)
|| matches!(b, 0x09 | 0x0a | 0x0d)
|| (0x80..=0xbf).contains(&b)
}
#[cfg(test)]
mod tests {
use super::*;
use naksheap_core_parse::{MappedImage, MemoryMap, MemoryRange, Perms, RangeKind};
const HEAP: u64 = 0x600000;
fn obj(addr: u64, size: u64) -> Object {
Object {
addr,
size,
state: ObjectState::Allocated,
arena: None,
chunk_header: addr.saturating_sub(0x10),
freed_reason: None,
}
}
struct Img {
bytes: Vec<u8>,
ranges: Vec<MemoryRange>,
}
impl Img {
fn new() -> Self {
Img {
bytes: vec![0x41u8; 0x10000],
ranges: vec![MemoryRange {
start: HEAP,
end: HEAP + 0x10000,
file_offset: 0,
file_size: 0x10000,
perms: Perms {
read: true,
write: true,
execute: false,
},
kind: RangeKind::Anon,
path: None,
name: Some("[heap]".into()),
}],
}
}
fn put(&mut self, addr: u64, v: u64) {
let off = (addr - HEAP) as usize;
self.bytes[off..off + 8].copy_from_slice(&v.to_le_bytes());
}
fn image(self) -> MappedImage {
MappedImage::from_bytes(self.bytes, MemoryMap::from_ranges(self.ranges), 8)
}
}
#[test]
fn cluster_same_size_identical_pointer_masks_group_together() {
let mut img = Img::new();
let a = obj(HEAP + 0x20, 0x20);
let b = obj(HEAP + 0x50, 0x20);
let c = obj(HEAP + 0x80, 0x20);
img.put(a.addr, b.addr);
img.put(b.addr, c.addr);
img.put(c.addr, a.addr);
let image = img.image();
let objects = vec![a, b, c];
let clusters = cluster_objects(&image, &objects, &[]);
assert_eq!(clusters.len(), 1);
let cl = &clusters[0];
assert_eq!(cl.size, 0x20);
assert_eq!(cl.members.len(), 3);
assert!(cl.pointer_mask[0]);
assert!(!cl.pointer_mask[1]);
}
#[test]
fn cluster_different_sizes_are_not_merged() {
let mut img = Img::new();
let a = obj(HEAP + 0x20, 0x20);
let b = obj(HEAP + 0x50, 0x20);
let c = obj(HEAP + 0x80, 0x20);
let d = obj(HEAP + 0xc0, 0x30);
img.put(a.addr, b.addr);
img.put(b.addr, c.addr);
img.put(c.addr, a.addr);
img.put(d.addr, HEAP + 0x1000);
let image = img.image();
let objects = vec![a, b, c, d];
let clusters = cluster_objects(&image, &objects, &[]);
let sizes: Vec<u64> = clusters.iter().map(|cl| cl.size).collect();
assert_eq!(clusters.len(), 2, "expected one cluster per size, got {sizes:?}");
assert!(sizes.contains(&0x20));
assert!(sizes.contains(&0x30));
}
#[test]
fn cluster_nullable_pointer_column_stays_together() {
// Four 0x20 objects; two hold a real pointer at word0 and two hold
// NULL (zero). The column is a pointer in 50% of members, so it is a
// pointer column, and the NULL slots are nullable field values that do
// not split the cluster.
let mut img = Img::new();
let a = obj(HEAP + 0x20, 0x20);
let b = obj(HEAP + 0x50, 0x20);
let c = obj(HEAP + 0x80, 0x20);
let d = obj(HEAP + 0xb0, 0x20);
img.put(a.addr, HEAP + 0x1000);
img.put(b.addr, HEAP + 0x1008);
img.put(c.addr, 0);
img.put(d.addr, 0);
let image = img.image();
let objects = vec![a, b, c, d];
let clusters = cluster_objects(&image, &objects, &[]);
assert_eq!(clusters.len(), 1, "nullable pointer column must not split");
let cl = &clusters[0];
assert_eq!(cl.members.len(), 4);
assert!(
cl.pointer_mask.first() == Some(&true),
"50% pointer column must remain a pointer field"
);
}
#[test]
fn cluster_all_zero_column_is_padding_not_pointer() {
// A column that is zero in every member is padding and never a pointer
// field, even though 0.5 <= zero ratio would have flagged it before.
let mut img = Img::new();
let a = obj(HEAP + 0x20, 0x20);
let b = obj(HEAP + 0x50, 0x20);
let c = obj(HEAP + 0x80, 0x20);
img.put(a.addr, HEAP + 0x1000);
img.put(b.addr, HEAP + 0x1008);
img.put(c.addr, HEAP + 0x1010);
// Word1 is zero in every member (fill is 0x41, so zero it out).
img.put(a.addr + 8, 0);
img.put(b.addr + 8, 0);
img.put(c.addr + 8, 0);
let image = img.image();
let objects = vec![a, b, c];
let clusters = cluster_objects(&image, &objects, &[]);
assert_eq!(clusters.len(), 1);
let cl = &clusters[0];
assert_eq!(cl.members.len(), 3);
assert!(cl.pointer_mask[0], "pointer column at word0");
assert!(
!cl.pointer_mask[1],
"all-zero column must be treated as padding"
);
}
#[test]
fn cluster_skips_freed_objects() {
let mut img = Img::new();
let a = obj(HEAP + 0x20, 0x20);
let mut freed = obj(HEAP + 0x50, 0x20);
freed.state = ObjectState::Freed;
img.put(a.addr, freed.addr);
let image = img.image();
let objects = vec![a.clone(), freed.clone()];
let clusters = cluster_objects(&image, &objects, &[]);
assert_eq!(clusters.len(), 1);
assert_eq!(clusters[0].members, vec![a.addr]);
assert!(!clusters[0].members.contains(&freed.addr));
}
}

View file

@ -0,0 +1,26 @@
//! JSON export of the object graph.
use serde_json::{json, Value};
use crate::graph::ObjectGraph;
/// Renders an [`ObjectGraph`] as a JSON value.
///
/// Top-level keys: `version`, `stats`, `arenas`, `nodes`, `edges`, `roots`.
pub fn graph_to_json(graph: &ObjectGraph) -> Value {
json!({
"version": format!("naksheap-inference/{}", env!("CARGO_PKG_VERSION")),
"stats": graph.stats,
"arenas": graph.arenas,
"nodes": graph.nodes,
"edges": graph.edges,
"roots": graph.roots,
})
}
/// Renders an [`ObjectGraph`] as pretty-printed JSON. Falls back to compact
/// JSON rather than panicking if serialization ever fails.
pub fn graph_to_json_pretty(graph: &ObjectGraph) -> String {
serde_json::to_string_pretty(&graph_to_json(graph))
.unwrap_or_else(|_| serde_json::to_string(&graph_to_json(graph)).unwrap_or_default())
}

View file

@ -0,0 +1,838 @@
//! Object reference graph construction: per-object type inference, edges,
//! root reachability, and statistics.
use std::collections::{HashMap, HashSet, VecDeque};
use naksheap_allocator_heuristics::{ArenaInfo, HeapInventory, Object, ObjectState};
use naksheap_core_parse::{AddressSpace, RangeKind};
use naksheap_pointer_scan::{index_objects, EdgeSource, ObjectIndex, ScanResult};
use crate::cluster::{is_printable, cluster_objects, LayoutCluster};
use crate::types::{
Field, FieldKind, GraphEdge, GraphStats, Node, ObjectLabel, ObjectType,
};
/// The complete object reference graph for a carved heap.
#[derive(Debug, Clone)]
pub struct ObjectGraph {
pub nodes: Vec<Node>,
pub edges: Vec<GraphEdge>,
pub roots: Vec<naksheap_pointer_scan::Root>,
pub stats: GraphStats,
/// Pass-through of the inventory arenas (included so JSON export can
/// reproduce them).
pub arenas: Vec<ArenaInfo>,
}
/// Builds the object graph from a carved inventory and a pointer-scan result.
///
/// Never panics on malformed reads; all reads go through `Option`.
pub fn build_graph(
image: &(dyn AddressSpace + Sync),
inventory: &HeapInventory,
scan: &ScanResult,
) -> ObjectGraph {
let clusters = cluster_objects(image, &inventory.objects, &scan.edges);
// addr -> (cluster index, member count)
let mut cluster_of: HashMap<u64, (usize, usize)> = HashMap::new();
for (idx, c) in clusters.iter().enumerate() {
for &addr in &c.members {
cluster_of.insert(addr, (idx, c.members.len()));
}
}
// O(1) base-address lookups: edge targets are always carved object bases,
// so a HashMap keyed on the base address replaces the per-edge linear scan.
let obj_idx: HashMap<u64, usize> =
inventory.objects.iter().enumerate().map(|(i, o)| (o.addr, i)).collect();
// Stabbing queries (a root word may point into an object's interior).
let obj_index = index_objects(&inventory.objects);
// Reference bookkeeping from scan edges.
let mut inbound: HashMap<u64, usize> = HashMap::new();
let mut outbound: HashMap<u64, usize> = HashMap::new();
let mut root_targets: HashSet<u64> = HashSet::new();
for e in &scan.edges {
match e.source {
EdgeSource::Object => {
*outbound.entry(e.from).or_default() += 1;
if let Some(target) = object_at(&inventory.objects, &obj_idx, e.to) {
*inbound.entry(target.addr).or_default() += 1;
}
}
EdgeSource::Stack | EdgeSource::Register => {
if let Some(target) = object_at(&inventory.objects, &obj_idx, e.to) {
root_targets.insert(target.addr);
}
}
}
}
// Roots may exist without a matching edge (robustness); treat them as
// root targets too. Root values are raw words that may point into an
// object's interior, so resolve them through the stabbing index.
for r in &scan.roots {
if let Some(target) = obj_index.target_at(r.value) {
root_targets.insert(target.addr);
}
}
// Build nodes.
let mut nodes: Vec<Node> = Vec::with_capacity(inventory.objects.len());
for obj in &inventory.objects {
let cluster = cluster_of
.get(&obj.addr)
.map(|&(idx, _)| &clusters[idx]);
let inb = inbound.get(&obj.addr).copied().unwrap_or(0);
let outb = outbound.get(&obj.addr).copied().unwrap_or(0);
let is_root = root_targets.contains(&obj.addr);
let ty = infer_type(image, obj, &obj_index, cluster, inb, outb, is_root);
nodes.push(Node {
addr: obj.addr,
size: obj.size,
state: obj.state,
ty,
inbound: inb,
outbound: outb,
reachable_from_root: false,
is_root: root_targets.contains(&obj.addr),
});
}
// Adjacency over Object-source edges (directed). Freed chunks carry
// allocator/fastbin garbage (forward/back pointers) rather than real
// references, so edges out of a freed node are never traversed, and live
// pointers INTO a freed chunk (a UAF hint) are not followed either. A freed
// node can still be marked reachable when a root directly targets it (see
// the BFS seed below), but its outgoing edges are never expanded.
let addr_to_idx: HashMap<u64, usize> =
nodes.iter().enumerate().map(|(i, n)| (n.addr, i)).collect();
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); nodes.len()];
for e in &scan.edges {
if e.source == EdgeSource::Object {
if let (Some(&from_idx), Some(&to_idx)) =
(addr_to_idx.get(&e.from), addr_to_idx.get(&e.to))
{
if nodes[from_idx].state == ObjectState::Freed
|| nodes[to_idx].state == ObjectState::Freed
{
continue;
}
adj[from_idx].push(to_idx);
}
}
}
// BFS from root-targeted nodes over object edges. A freed node targeted by
// a root (dangling stack pointer) is seeded as reachable, but since freed
// nodes contributed no adjacency entries above, their outgoing edges are
// never expanded.
let mut reachable = vec![false; nodes.len()];
let mut max_depth = 0usize;
let mut queue: VecDeque<(usize, usize)> = VecDeque::new();
for (i, n) in nodes.iter().enumerate() {
if n.is_root {
reachable[i] = true;
queue.push_back((i, 0));
}
}
while let Some((u, d)) = queue.pop_front() {
max_depth = max_depth.max(d);
for &v in &adj[u] {
if !reachable[v] {
reachable[v] = true;
queue.push_back((v, d + 1));
}
}
}
for (i, n) in nodes.iter_mut().enumerate() {
n.reachable_from_root = reachable[i];
}
let edges: Vec<GraphEdge> = scan
.edges
.iter()
.map(|e| GraphEdge {
from: e.from,
to: e.to,
offset: e.offset,
confirmed: e.confirmed,
source: e.source,
})
.collect();
let stats = compute_stats(&nodes, &edges, &clusters, max_depth);
ObjectGraph {
nodes,
edges,
roots: scan.roots.clone(),
stats,
arenas: inventory.arenas.clone(),
}
}
fn compute_stats(
nodes: &[Node],
edges: &[GraphEdge],
clusters: &[LayoutCluster],
max_depth: usize,
) -> GraphStats {
let total_objects = nodes.len();
let allocated = nodes.iter().filter(|n| n.state == ObjectState::Allocated).count();
let freed = nodes.iter().filter(|n| n.state == ObjectState::Freed).count();
let mmap = nodes.iter().filter(|n| n.state == ObjectState::Mmap).count();
let root_reachable = nodes.iter().filter(|n| n.reachable_from_root).count();
// clusters = distinct ProbableStruct clusters (>= 2 members).
let mut cluster_of: HashMap<u64, usize> = HashMap::new();
for (idx, c) in clusters.iter().enumerate() {
for &member in &c.members {
cluster_of.insert(member, idx);
}
}
let mut cluster_labels: HashMap<usize, ObjectLabel> = HashMap::new();
for n in nodes {
if let Some(&idx) = cluster_of.get(&n.addr) {
cluster_labels.insert(idx, n.ty.label);
}
}
let clusters_count = clusters
.iter()
.enumerate()
.filter(|(idx, c)| {
c.members.len() >= 2
&& cluster_labels.get(idx) == Some(&ObjectLabel::ProbableStruct)
})
.count();
GraphStats {
total_objects,
allocated,
freed,
mmap,
clusters: clusters_count,
root_reachable,
edges: edges.len(),
confirmed_edges: edges.iter().filter(|e| e.confirmed).count(),
max_depth,
}
}
/// Returns the carved object at exactly `value` (its base address), if any.
///
/// Pointer-scan edge targets always resolve to an object's base, so an exact
/// map lookup is equivalent to the old range scan while being O(1).
fn object_at<'a>(
objects: &'a [Object],
base_to_idx: &HashMap<u64, usize>,
value: u64,
) -> Option<&'a Object> {
base_to_idx.get(&value).map(|&i| &objects[i])
}
/// Infers the type of one carved object.
/// True when every readable byte of `[addr + word, addr + size)` is zero.
/// The first word is skipped: glibc leaves a stale size word at the start of
/// abandoned top-chunk remainders (the "phantom" chunk), so we accept that
/// pattern. Bounded to the first 256 KiB so huge chunks do not stall inference.
fn content_all_zero_after_first_word(
image: &(dyn AddressSpace + Sync),
addr: u64,
size: u64,
) -> bool {
const SAMPLE: u64 = 0x40000;
let word = image.pointer_width() as u64;
let n = size.min(SAMPLE);
let mut off = word;
while off < n {
let chunk = (n - off).min(0x1000);
match image.read_bytes(addr.saturating_add(off), chunk) {
Some(bytes) => {
if bytes.iter().any(|&b| b != 0) {
return false;
}
}
None => return false, // unreadable means not verifiably all-zero
}
off += chunk;
}
true
}
fn infer_type(
image: &(dyn AddressSpace + Sync),
obj: &Object,
obj_index: &ObjectIndex,
cluster: Option<&LayoutCluster>,
inbound: usize,
outbound: usize,
is_root: bool,
) -> ObjectType {
// (a) Allocator-verified freed chunk. The evidence names the actual
// mechanism: PREV_INUSE-clear (bin/unsorted free), tcache free-list
// cross-reference, or fastbin chain cross-reference.
if obj.state == ObjectState::Freed {
let reason = match obj.freed_reason {
Some(naksheap_allocator_heuristics::FreedReason::Tcache) => {
"user address found in the tcache free list".to_string()
}
Some(naksheap_allocator_heuristics::FreedReason::Fastbin) => {
"user address found in an arena fastbin chain".to_string()
}
_ => "successor chunk has PREV_INUSE clear".to_string(),
};
return ObjectType {
name: "freed chunk".to_string(),
label: ObjectLabel::FreedChunk,
confidence: 0.9,
evidence: vec![
reason,
format!(
"chunk at 0x{:x} returned to allocator (size 0x{:x})",
obj.addr, obj.size
),
],
size: obj.size,
members: 0,
fields: Vec::new(),
};
}
let word = image.pointer_width() as u64;
// For in-use glibc chunks the usable data may extend `word` bytes past the
// reported size into the next chunk's dead `prev_size` (e.g. a 24-byte
// std::vector stores its third word at offset 0x10 of a 0x20 chunk), so
// typed-heuristic reads cover that spill extent. Layout clustering does NOT
// extend (the spill is dead memory, not object data).
let read_extent = if obj.state == ObjectState::Allocated {
obj.size.saturating_add(word)
} else {
obj.size
};
let read = |off: u64| -> Option<u64> {
if off.checked_add(word)? > read_extent {
return None;
}
image.read_word(obj.addr.saturating_add(off))
};
// (a2) Large in-use chunks whose content is entirely zero are almost always
// unused allocator reservations (e.g. the stale top-chunk remainder glibc
// leaves after growing a heap), not live data. Flag them so they do not
// read as mysterious "opaque buffers" in the inventory.
// A large in-use chunk whose content is all zero after the first word is
// usually the abandoned top-chunk remainder glibc leaves after growing a
// heap, not live data. But a freshly-calloc'd buffer also reads all-zero,
// so only label it when nothing references it from a register/stack root
// (a live allocation) and keep the wording as a hypothesis.
if obj.state == ObjectState::Allocated
&& !is_root
&& obj.size >= 0x2000
&& content_all_zero_after_first_word(image, obj.addr, obj.size)
{
return ObjectType {
name: "zero-filled region (possibly unused allocator reservation)".to_string(),
label: ObjectLabel::ZeroRegion,
confidence: 0.5,
evidence: vec![format!(
"content is zero after the first word ({} bytes checked); no root reference",
obj.size.min(0x40000)
)],
size: obj.size,
members: 0,
fields: Vec::new(),
};
}
let cluster_members = cluster.map(|c| c.members.len()).unwrap_or(0);
// (b) std::string heuristics (24..=32 byte objects: {ptr,len,cap} or SSO).
if obj.size >= 24 && obj.size <= 32 {
if let (Some(w0), Some(w1), Some(w2)) = (read(0), read(8), read(16)) {
// Heap-buffer string: {data ptr, length, capacity}.
let heapish = w0 != 0
&& image.range_at(w0).is_some_and(|r| {
r.perms.read && matches!(r.kind, RangeKind::Anon | RangeKind::File)
})
&& w1 < 0x100000
&& w2 >= w1
&& w2 <= 0x1000000;
if heapish {
let density = string_density_at(image, w0, w1.min(16));
if density >= 0.5 {
let mut evidence = vec![
"heap-buffer std::string layout {ptr,len,cap}".to_string(),
format!("ptr 0x{:x} -> heap/anon range", w0),
format!("length 0x{:x}, capacity 0x{:x}", w1, w2),
format!("bytes at 0x{:x}: {:.0}% printable", w0, density * 100.0),
];
append_refs(&mut evidence, inbound, outbound);
return ObjectType {
name: "likely std::string".to_string(),
label: ObjectLabel::StdString,
confidence: finalize(0.85, inbound, false, Some(density)),
evidence,
size: obj.size,
members: cluster_members.max(1),
fields: vec![
Field {
offset: 0,
kind: FieldKind::Pointer,
hint: Some(format!("-> 0x{:x}", w0)),
},
Field {
offset: 8,
kind: FieldKind::Integer,
hint: Some(format!("length 0x{:x}", w1)),
},
Field {
offset: 16,
kind: FieldKind::Integer,
hint: Some(format!("capacity 0x{:x}", w2)),
},
],
};
}
}
// libstdc++ SSO: the capacity word has the SSO marker bit (bit 63)
// set and the inline length lives in word1. libstdc++ stores the
// SSO characters at offset 0, not a length word.
let libstdcxx_sso = w2 != 0
&& (w2 & (1u64 << 63)) != 0
&& w1 < 32
&& image.read_bytes(obj.addr, w1.min(16)).is_some();
if libstdcxx_sso {
let density = string_density_at(image, obj.addr, w1.min(16));
if density >= 0.5 {
let mut evidence = vec![
"libstdc++ SSO std::string: capacity word carries the SSO bit".to_string(),
format!("inline length 0x{:x}, capacity 0x{:x}", w1, w2),
format!(
"inline bytes at 0x{:x}: {:.0}% printable",
obj.addr,
density * 100.0
),
];
append_refs(&mut evidence, inbound, outbound);
return ObjectType {
name: "likely std::string".to_string(),
label: ObjectLabel::StdString,
confidence: finalize(0.75, inbound, false, Some(density)),
evidence,
size: obj.size,
members: cluster_members.max(1),
fields: vec![
Field {
offset: 0,
kind: FieldKind::Bytes,
hint: Some("inline string data".to_string()),
},
Field {
offset: 8,
kind: FieldKind::Integer,
hint: Some(format!("SSO length 0x{:x}", w1)),
},
Field {
offset: 16,
kind: FieldKind::Integer,
hint: Some(format!("capacity 0x{:x}", w2)),
},
],
};
}
}
// libc++-style SSO: word0 is a small inline length (secondary check).
if (1..0x20).contains(&w0) && w2 >= w0 && w2 <= 0x1000000 {
let density = string_density_at(image, obj.addr, w0.min(16));
if density >= 0.5 {
let mut evidence = vec![
"SSO std::string: first word is inline length".to_string(),
format!("inline length 0x{:x}, capacity 0x{:x}", w0, w2),
format!(
"inline bytes at 0x{:x}: {:.0}% printable",
obj.addr,
density * 100.0
),
];
append_refs(&mut evidence, inbound, outbound);
return ObjectType {
name: "likely std::string".to_string(),
label: ObjectLabel::StdString,
confidence: finalize(0.75, inbound, false, Some(density)),
evidence,
size: obj.size,
members: cluster_members.max(1),
fields: vec![
Field {
offset: 0,
kind: FieldKind::Integer,
hint: Some(format!("SSO length 0x{:x}", w0)),
},
Field {
offset: 8,
kind: FieldKind::Bytes,
hint: Some("inline string data".to_string()),
},
Field {
offset: 16,
kind: FieldKind::Integer,
hint: Some(format!("capacity 0x{:x}", w2)),
},
],
};
}
}
}
}
// (c) std::vector heuristics: {begin,end,cap} with 8-byte stride, and no
// further pointer words beyond the three leading ones. All three words must
// land in readable mapped ranges, begin must point at real heap data, both
// gaps must be 8-byte multiples, and the element count must be sane.
if obj.size >= 16 {
if let (Some(w0), Some(w1), Some(w2)) = (read(0), read(8), read(16)) {
let count = w2.saturating_sub(w0);
let vector_shape = w0 != 0
&& w0 <= w1
&& w1 <= w2
&& count <= 0x1000000
&& (w1 - w0) % 8 == 0
&& (w2 - w1) % 8 == 0
&& count % 8 == 0
&& count / 8 <= (1 << 20)
&& readable(image, w0)
&& readable(image, w1)
&& readable(image, w2)
&& (obj_index.target_at(w0).is_some()
|| image.range_at(w0).is_some_and(|r| {
r.kind == RangeKind::Anon && r.perms.write
}))
&& only_three_words(image, obj, word)
&& same_allocation(image, obj_index, w0, w1, w2);
if vector_shape {
let mut evidence = vec![
"likely std::vector layout {begin,end,cap}".to_string(),
format!("begin 0x{:x}, end 0x{:x}, cap 0x{:x}", w0, w1, w2),
format!("element stride 8 ({} elements)", (w1 - w0) / 8),
format!("begin 0x{:x} lands in a readable mapped range", w0),
];
append_refs(&mut evidence, inbound, outbound);
return ObjectType {
name: "likely std::vector".to_string(),
label: ObjectLabel::Vector,
confidence: finalize(0.8, inbound, false, None),
evidence,
size: obj.size,
members: cluster_members.max(1),
fields: vec![
Field {
offset: 0,
kind: FieldKind::Pointer,
hint: Some(format!("begin -> 0x{:x}", w0)),
},
Field {
offset: 8,
kind: FieldKind::Pointer,
hint: Some(format!("end -> 0x{:x}", w1)),
},
Field {
offset: 16,
kind: FieldKind::Pointer,
hint: Some(format!("capacity -> 0x{:x}", w2)),
},
],
};
}
}
}
// (d) Vtable object: the first word points into a file-backed,
// non-writable (rodata) range AND the first vtable slot itself points into
// an executable range (a code pointer). Without the code-pointer check a
// random rodata pointer (e.g. a string literal) would be mislabeled.
if let Some(w0) = read(0) {
if w0 != 0 {
if let Some(r) = image.range_at(w0) {
if r.kind == RangeKind::File
&& !r.perms.write
&& first_slot_is_code(image, w0)
{
let fname = file_name(image, w0).unwrap_or_else(|| "rodata".to_string());
let fields = cluster
.map(|c| fields_from_cluster(image, obj, c))
.unwrap_or_default();
let mut evidence = vec![
format!("vtable -> 0x{:x} in {}", w0, fname),
format!(
"object at 0x{:x} (size 0x{:x}) leads with a rodata pointer",
obj.addr, obj.size
),
format!(
"first vtable slot at 0x{:x} points into executable memory",
w0
),
];
append_refs(&mut evidence, inbound, outbound);
if cluster_members > 1 {
evidence.push(format!(
"{} instances share this layout",
cluster_members
));
}
return ObjectType {
name: "vtable object".to_string(),
label: ObjectLabel::VtableObject,
confidence: finalize(0.8, inbound, false, None),
evidence,
size: obj.size,
members: cluster_members,
fields,
};
}
}
}
}
// (e/f/g) Cluster-based classification.
if let Some(c) = cluster {
let fields = fields_from_cluster(image, obj, c);
let n = c.members.len();
let mut evidence = Vec::new();
let mut base_conf;
let name;
if n >= 3 {
base_conf = 0.6 + 0.06 * (n.min(5) as f64);
base_conf = base_conf.min(0.95);
evidence.push(format!(
"{} members share identical layout (size=0x{:x})",
n, obj.size
));
name = "probable struct".to_string();
} else if n == 2 {
base_conf = 0.5;
evidence.push(format!(
"2 instances share identical layout (size=0x{:x})",
obj.size
));
name = "probable struct".to_string();
} else {
base_conf = 0.3;
evidence.push(format!(
"unique layout (size=0x{:x}), no repeated instances",
obj.size
));
name = "opaque buffer".to_string();
}
append_refs(&mut evidence, inbound, outbound);
let vtable_hit = fields.iter().any(|f| f.kind == FieldKind::Vtable);
if vtable_hit {
if let Some(f) = fields.iter().find(|f| f.kind == FieldKind::Vtable) {
evidence.push(format!(
"vtable-like pointer at offset 0x{:x}: {}",
f.offset,
f.hint.as_deref().unwrap_or("file-backed memory")
));
}
}
let label = if n >= 2 {
ObjectLabel::ProbableStruct
} else {
ObjectLabel::OpaqueBuffer
};
return ObjectType {
name,
label,
confidence: finalize(base_conf, inbound, vtable_hit, None),
evidence,
size: obj.size,
members: n,
fields,
};
}
// No cluster (robustness; eligible objects always land in a cluster).
ObjectType {
name: "opaque buffer".to_string(),
label: ObjectLabel::OpaqueBuffer,
confidence: 0.3,
evidence: vec![format!("no layout cluster for object at 0x{:x}", obj.addr)],
size: obj.size,
members: 1,
fields: Vec::new(),
}
}
/// Fields from a cluster's pointer/string masks, with per-field hints.
fn fields_from_cluster(
image: &(dyn AddressSpace + Sync),
obj: &Object,
c: &LayoutCluster,
) -> Vec<Field> {
let mut fields = Vec::new();
let n = c.pointer_mask.len().max(c.string_mask.len());
for i in 0..n {
let off = i as u64 * 8;
if off >= obj.size {
break;
}
if i < c.pointer_mask.len() && c.pointer_mask[i] {
let (kind, hint) = pointer_field(image, obj.addr.saturating_add(off));
fields.push(Field {
offset: off,
kind,
hint: Some(hint),
});
} else if i < c.string_mask.len() && c.string_mask[i] {
fields.push(Field {
offset: off,
kind: FieldKind::String,
hint: Some("ascii string".to_string()),
});
}
}
fields
}
fn pointer_field(image: &(dyn AddressSpace + Sync), addr: u64) -> (FieldKind, String) {
match image.read_word(addr) {
Some(v) if v != 0 => {
// A vtable field requires a read-only file-backed target whose first
// slot dereferences to executable memory (a code pointer). A
// `const char*` into rodata is a plain pointer, not a vtable.
let rodata = image
.range_at(v)
.is_some_and(|r| r.kind == RangeKind::File && !r.perms.write);
if rodata && first_slot_is_code(image, v) {
let fname = file_name(image, v).unwrap_or_else(|| "rodata".to_string());
(FieldKind::Vtable, format!("vtable -> 0x{:x} in {}", v, fname))
} else {
(FieldKind::Pointer, format!("-> 0x{:x}", v))
}
}
_ => (FieldKind::Pointer, "invalid pointer".to_string()),
}
}
/// True when the word at `addr` is itself a pointer into an executable range
/// (the first slot of a real vtable points at a function).
fn first_slot_is_code(image: &(dyn AddressSpace + Sync), addr: u64) -> bool {
image
.read_word(addr)
.and_then(|v| image.range_at(v))
.is_some_and(|r| r.perms.execute)
}
fn readable(image: &(dyn AddressSpace + Sync), v: u64) -> bool {
image.range_at(v).is_some_and(|r| r.perms.read)
}
/// True when `w0`, `w1`, `w2` all resolve into the *same* allocation: either
/// the same carved object (`[addr, addr+size)`) or the same anonymous mapping.
///
/// A real `std::vector` owns one contiguous buffer, so its begin/end/cap all
/// fall inside a single allocation; a `{ptr, ptr, ptr}` struct typically spans
/// several objects. This gate kills the false-positive on ascending 3-pointer
/// structs whose three targets happen to be mapped and 8-byte aligned.
fn same_allocation(
image: &(dyn AddressSpace + Sync),
obj_index: &ObjectIndex,
w0: u64,
w1: u64,
w2: u64,
) -> bool {
if let Some(o) = obj_index.target_at(w0) {
// A vector's `finish`/`cap` are one-past-the-end pointers, so the
// upper bound is INCLUSIVE (`<= o.addr + o.size`).
let end = o.addr.saturating_add(o.size);
return w1 >= o.addr && w1 <= end && w2 >= o.addr && w2 <= end;
}
let r = image.range_at(w0).filter(|r| r.kind == RangeKind::Anon);
match r {
Some(r) => r.contains(w1) && r.contains(w2),
None => false,
}
}
fn file_name(image: &(dyn AddressSpace + Sync), v: u64) -> Option<String> {
let r = image.range_at(v)?;
if let Some(n) = &r.name {
return Some(n.clone());
}
if let Some(p) = &r.path {
return Some(p.rsplit('/').next().unwrap_or(p).to_string());
}
Some("file-backed range".to_string())
}
/// True when no aligned word at offset >= 24 points into mapped/file memory.
fn only_three_words(image: &(dyn AddressSpace + Sync), obj: &Object, word: u64) -> bool {
let mut off = 24u64;
while off.saturating_add(word) <= obj.size {
let addr = obj.addr.saturating_add(off);
if let Some(v) = image.read_word(addr) {
if v != 0 {
let mapped = image.read_bytes(v, 8).is_some();
if mapped || image.is_file_backed(v) {
return false;
}
}
}
off += word;
}
true
}
fn string_density_at(image: &(dyn AddressSpace + Sync), addr: u64, len: u64) -> f64 {
if len == 0 {
return 1.0;
}
match image.read_bytes(addr, len) {
Some(bytes) => {
let good = bytes.iter().filter(|&&b| is_printable(b)).count();
good as f64 / bytes.len() as f64
}
None => 0.0,
}
}
fn append_refs(evidence: &mut Vec<String>, inbound: usize, outbound: usize) {
if inbound > 0 {
evidence.push(format!(
"{} inbound reference{}",
inbound,
if inbound == 1 { "" } else { "s" }
));
}
if outbound > 0 {
evidence.push(format!(
"{} outbound reference{}",
outbound,
if outbound == 1 { "" } else { "s" }
));
}
}
/// Combines membership confidence with reference/vtable/density bonuses,
/// clamped to [0, 1] and rounded to two decimals.
fn finalize(mut conf: f64, inbound: usize, vtable_hit: bool, density: Option<f64>) -> f64 {
if inbound >= 2 {
conf += 0.15;
} else if inbound == 1 {
conf += 0.08;
}
if vtable_hit {
conf += 0.1;
}
if let Some(d) = density {
if d >= 0.8 {
conf += 0.1;
} else if d >= 0.5 {
conf += 0.05;
}
}
conf = conf.clamp(0.0, 1.0);
(conf * 100.0).round() / 100.0
}

View file

@ -0,0 +1,24 @@
//! naksheap-inference
//!
//! Consumes a carved heap inventory ([`naksheap_allocator_heuristics`]) plus a
//! pointer-scan result ([`naksheap_pointer_scan`]) and produces the naksheap
//! output model: per-object inferred types (probable struct, `std::string`,
//! vector, vtable object, opaque buffer) with confidence + evidence, an object
//! reference graph, root reachability, statistics, and JSON export.
//!
//! The pipeline:
//!
//! 1. [`cluster::cluster_objects`] groups live objects by layout similarity
//! (size + pointer/string column shapes).
//! 2. [`graph::build_graph`] infers per-object types, aggregates edges,
//! computes root reachability and statistics.
//! 3. [`export::graph_to_json`] renders the result as JSON.
pub mod cluster;
pub mod export;
pub mod graph;
pub mod types;
pub use export::{graph_to_json, graph_to_json_pretty};
pub use graph::{build_graph, ObjectGraph};
pub use types::{Field, FieldKind, Node, ObjectLabel, ObjectType};

View file

@ -0,0 +1,103 @@
//! The naksheap output model: per-object inferred types, confidence scores,
//! reference edges, and graph-level statistics.
use serde::Serialize;
/// Semantic kind of an inferred field inside a typed object.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldKind {
/// A word that points into a carved heap object (or another mapped range).
Pointer,
/// A word that points into file-backed (rodata-like) memory: a vtable.
Vtable,
/// An inline or pointed-to ASCII/UTF-8 string.
String,
/// A numeric value (length, capacity, counter, ...).
Integer,
/// A byte blob.
Bytes,
}
/// One inferred field inside a typed object.
#[derive(Debug, Clone, Serialize)]
pub struct Field {
/// Byte offset of the field inside the object's user region.
pub offset: u64,
pub kind: FieldKind,
/// Human-readable target description (e.g. `-> 0x7faa00002f00`).
pub hint: Option<String>,
}
/// The inferred type of a single object.
#[derive(Debug, Clone, Serialize)]
pub struct ObjectType {
/// Display name, e.g. "probable struct", "likely std::string".
pub name: String,
pub label: ObjectLabel,
/// 0.0..=1.0, rounded to two decimals.
pub confidence: f64,
/// Human-readable evidence lines (addresses, sizes, counts).
pub evidence: Vec<String>,
/// Object size in bytes.
pub size: u64,
/// Number of instances in the same layout cluster.
pub members: usize,
/// Inferred fields (empty for scalar / untyped objects).
pub fields: Vec<Field>,
}
/// Coarse classification label.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ObjectLabel {
ProbableStruct,
StdString,
Vector,
VtableObject,
OpaqueBuffer,
ZeroRegion,
FreedChunk,
}
/// A node in the object reference graph: one carved heap object plus its
/// inferred type and graph position.
#[derive(Debug, Clone, Serialize)]
pub struct Node {
pub addr: u64,
pub size: u64,
pub state: naksheap_allocator_heuristics::ObjectState,
pub ty: ObjectType,
/// Number of Object-source edges pointing here.
pub inbound: usize,
pub outbound: usize,
pub reachable_from_root: bool,
/// True when a register/stack root references this object.
pub is_root: bool,
}
/// Graph-wide aggregate statistics.
#[derive(Debug, Clone, Serialize)]
pub struct GraphStats {
pub total_objects: usize,
pub allocated: usize,
pub freed: usize,
pub mmap: usize,
/// Number of distinct ProbableStruct layout clusters (>= 2 members).
pub clusters: usize,
pub root_reachable: usize,
pub edges: usize,
pub confirmed_edges: usize,
/// BFS depth from roots over object edges.
pub max_depth: usize,
}
/// One reference edge in the object graph.
#[derive(Debug, Clone, Serialize)]
pub struct GraphEdge {
pub from: u64,
pub to: u64,
pub offset: u64,
pub confirmed: bool,
pub source: naksheap_pointer_scan::EdgeSource,
}

View file

@ -0,0 +1,551 @@
//! End-to-end integration tests against the synthetic testkit fixture, plus
//! JSON export round-trip checks.
use naksheap_allocator_heuristics::ObjectState as HeurState;
use naksheap_allocator_heuristics::{HeapInventory, Object};
use naksheap_core_parse::elf::parse_elf_bytes;
use naksheap_core_parse::{MappedImage, MemoryMap, MemoryRange, Perms, RangeKind};
use naksheap_inference::{
build_graph, graph_to_json, graph_to_json_pretty, FieldKind, ObjectGraph, ObjectLabel,
};
use naksheap_pointer_scan::{scan, Edge, EdgeSource, Root, RootSource, ScanOptions, ScanResult};
use naksheap_testkit::{CoreSpec, Manifest, ObjectState as KitState};
fn build_default() -> (ObjectGraph, Manifest) {
let fixture = CoreSpec::default().build().expect("build fixture");
let parsed = parse_elf_bytes(&fixture.bytes).expect("parse ELF core bytes");
let image = MappedImage::from_bytes(
fixture.bytes.clone(),
parsed.map.clone(),
parsed.pointer_width,
);
let inventory = naksheap_allocator_heuristics::carve(&image);
let scan_result = scan(&image, &inventory, &parsed.threads, &ScanOptions::default());
let graph = build_graph(&image, &inventory, &scan_result);
(graph, fixture.manifest)
}
fn manifest_addr(manifest: &Manifest, label: &str) -> u64 {
manifest
.objects
.iter()
.find(|o| o.label == label)
.unwrap_or_else(|| panic!("manifest has no object labelled {label}"))
.addr
}
fn manifest_state(obj_state: KitState) -> HeurState {
match obj_state {
KitState::Allocated => HeurState::Allocated,
KitState::Freed => HeurState::Freed,
KitState::Mmap => HeurState::Mmap,
KitState::Unknown => HeurState::Unknown,
}
}
#[test]
fn default_fixture_end_to_end() {
let (graph, manifest) = build_default();
// 1. Total object count matches the manifest.
assert_eq!(graph.stats.total_objects, manifest.objects.len());
// 2. Every object's addr/size/state matches the manifest.
for mobj in &manifest.objects {
let node = graph
.nodes
.iter()
.find(|n| n.addr == mobj.addr)
.unwrap_or_else(|| panic!("graph is missing node 0x{:x}", mobj.addr));
assert_eq!(node.size, mobj.size, "size mismatch at 0x{:x}", mobj.addr);
assert_eq!(
node.state,
manifest_state(mobj.state),
"state mismatch at 0x{:x}",
mobj.addr
);
}
// 3. The three-node ring (head/second/tail) clusters into a probable struct.
let head = manifest_addr(&manifest, "head");
let second = manifest_addr(&manifest, "second");
let tail = manifest_addr(&manifest, "tail");
for addr in [head, second, tail] {
let node = graph.nodes.iter().find(|n| n.addr == addr).unwrap();
assert_eq!(
node.ty.label,
ObjectLabel::ProbableStruct,
"ring node 0x{addr:x} label"
);
assert!(node.ty.members >= 3, "ring node 0x{addr:x} members");
// Fix 2: the 0x41 fill bytes must NOT render as fake string fields.
assert!(
!node.ty.fields.iter().any(|f| f.kind == FieldKind::String),
"ring node 0x{addr:x} must not report string fields for fill bytes; fields = {:?}",
node.ty.fields
);
}
// 4. payload carries a vtable field/evidence pointing at the fake rodata
// vtable in the read-only file-backed rodata segment.
let payload = graph
.nodes
.iter()
.find(|n| n.addr == manifest_addr(&manifest, "payload"))
.unwrap();
let has_vtable_field = payload.ty.fields.iter().any(|f| f.kind == FieldKind::Vtable);
let has_vtable_evidence = payload
.ty
.evidence
.iter()
.any(|e| e.contains("0x7faa00004080") || e.contains("vtable"));
assert!(
has_vtable_field || has_vtable_evidence,
"payload should carry vtable evidence; fields = {:?}, evidence = {:?}",
payload.ty.fields,
payload.ty.evidence
);
assert!(
payload.ty.evidence.iter().any(|e| e.contains("0x7faa00004080")),
"payload vtable evidence must name 0x7faa00004080; evidence = {:?}",
payload.ty.evidence
);
// 5. The freed object is labelled FreedChunk.
let freed = graph
.nodes
.iter()
.find(|n| n.addr == manifest_addr(&manifest, "freed_slot"))
.unwrap();
assert_eq!(freed.ty.label, ObjectLabel::FreedChunk);
// 6. head is reachable from the stack root.
let head_node = graph.nodes.iter().find(|n| n.addr == head).unwrap();
assert!(head_node.reachable_from_root, "head must be root-reachable");
assert!(head_node.is_root, "head is targeted by a stack/register root");
// 7. Ring edges are present.
for (a, b) in [("head", "second"), ("second", "tail"), ("tail", "head")] {
let from = manifest_addr(&manifest, a);
let to = manifest_addr(&manifest, b);
assert!(
graph.edges.iter().any(|e| e.from == from && e.to == to),
"missing ring edge {a}->{b}"
);
}
// 8. confirmed_edges semantics: only Object-source redundancy confirms.
// head has two object sources (payload + tail), so every edge targeting
// head is confirmed (including the root edges); head->second and
// second->tail each have a single source and are NOT confirmed.
let edges_to = |addr: u64| graph.edges.iter().filter(move |e| e.to == addr);
assert!(
edges_to(head).all(|e| e.confirmed),
"all edges targeting head must be confirmed (2 object sources)"
);
for (a, b) in [("head", "second"), ("second", "tail")] {
let from = manifest_addr(&manifest, a);
let to = manifest_addr(&manifest, b);
let edge = graph
.edges
.iter()
.find(|e| e.from == from && e.to == to)
.expect("ring edge");
assert!(
!edge.confirmed,
"single-source edge {a}->{b} must NOT be confirmed"
);
}
assert_eq!(graph.stats.confirmed_edges, 4, "head-confirming edges");
// Sanity on aggregate statistics.
assert_eq!(graph.stats.allocated, 4);
assert_eq!(graph.stats.freed, 1);
assert_eq!(graph.stats.mmap, 0);
assert_eq!(graph.stats.clusters, 1, "one probable-struct cluster of 3");
assert_eq!(graph.stats.root_reachable, 3, "head/second/tail reachable");
assert_eq!(graph.stats.max_depth, 2, "BFS depth head->second->tail");
}
#[test]
fn json_export_round_trips() {
let (graph, _manifest) = build_default();
let v = graph_to_json(&graph);
let obj = v.as_object().expect("top-level JSON must be an object");
for key in ["version", "stats", "arenas", "nodes", "edges", "roots"] {
assert!(obj.contains_key(key), "graph JSON missing key {key}");
}
assert_eq!(obj["nodes"].as_array().unwrap().len(), graph.nodes.len());
assert_eq!(obj["edges"].as_array().unwrap().len(), graph.edges.len());
assert_eq!(obj["roots"].as_array().unwrap().len(), graph.roots.len());
assert_eq!(obj["arenas"].as_array().unwrap().len(), graph.arenas.len());
// Stats are carried through faithfully.
assert_eq!(obj["stats"]["total_objects"], graph.stats.total_objects);
// Pretty export parses back to the identical value.
let pretty = graph_to_json_pretty(&graph);
let back: serde_json::Value = serde_json::from_str(&pretty).expect("pretty JSON parses");
assert_eq!(back, v);
}
/// A hand-built anonymous `rw-` heap mapping, mirroring the carve's backing
/// memory without involving the testkit.
fn anon_heap_image(fill: u8) -> (MemoryMap, Vec<u8>) {
const HEAP: u64 = 0x600000;
const SIZE: u64 = 0x10000;
let map = MemoryMap::from_ranges(vec![MemoryRange {
start: HEAP,
end: HEAP + SIZE,
file_offset: 0,
file_size: SIZE,
perms: Perms {
read: true,
write: true,
execute: false,
},
kind: RangeKind::Anon,
path: None,
name: Some("[heap]".into()),
}]);
(map, vec![fill; SIZE as usize])
}
fn put_word(bytes: &mut [u8], addr: u64, value: u64) {
let off = (addr - 0x600000) as usize;
bytes[off..off + 8].copy_from_slice(&value.to_le_bytes());
}
fn heap_obj(addr: u64, size: u64) -> Object {
Object {
addr,
size,
state: HeurState::Allocated,
arena: None,
chunk_header: addr - 0x10,
freed_reason: None,
}
}
#[test]
fn nullable_pointer_column_keeps_struct_cluster() {
// Fix 3: a column that is a pointer in 2 of 4 members (the other two are
// NULL) must stay a single probable-struct cluster with a pointer field,
// not fragment into pointer / non-pointer clusters.
let (map, mut bytes) = anon_heap_image(0x41);
let a = heap_obj(0x600020, 0x20);
let b = heap_obj(0x600050, 0x20);
let c = heap_obj(0x600080, 0x20);
let d = heap_obj(0x6000b0, 0x20);
put_word(&mut bytes, a.addr, 0x601000);
put_word(&mut bytes, b.addr, 0x601008);
put_word(&mut bytes, c.addr, 0);
put_word(&mut bytes, d.addr, 0);
let image = MappedImage::from_bytes(bytes, map, 8);
let inventory = HeapInventory {
arenas: Vec::new(),
objects: vec![a, b, c, d],
};
let graph = build_graph(&image, &inventory, &ScanResult::default());
assert_eq!(graph.stats.clusters, 1, "nullable pointer column must not split");
for n in &graph.nodes {
assert_eq!(n.ty.label, ObjectLabel::ProbableStruct);
assert!(n.ty.members == 4, "all four instances share one cluster");
assert!(
n.ty
.fields
.iter()
.any(|f| f.offset == 0 && f.kind == FieldKind::Pointer),
"nullable pointer field must be kept at offset 0; fields = {:?}",
n.ty.fields
);
}
}
#[test]
fn three_ascending_integers_not_vector() {
// Fix 4: a 24-byte struct holding three ascending integers must not be
// labeled a std::vector just because {w0,w1,w2} are sorted with 8-byte
// strides. The integers are not mapped, so the begin/end/cap gates reject
// the vector heuristic and the object falls through to struct clustering.
let (map, mut bytes) = anon_heap_image(0x00);
let a = heap_obj(0x600020, 0x18);
let b = heap_obj(0x600050, 0x18);
let c = heap_obj(0x600080, 0x18);
for o in [&a, &b, &c] {
put_word(&mut bytes, o.addr, 0x8000);
put_word(&mut bytes, o.addr + 8, 0x10000);
put_word(&mut bytes, o.addr + 16, 0x18000);
}
let image = MappedImage::from_bytes(bytes, map, 8);
let inventory = HeapInventory {
arenas: Vec::new(),
objects: vec![a, b, c],
};
let graph = build_graph(&image, &inventory, &ScanResult::default());
assert_eq!(graph.stats.clusters, 1, "three identical structs cluster");
for n in &graph.nodes {
assert_eq!(
n.ty.label,
ObjectLabel::ProbableStruct,
"ascending-integer struct must stay a probable struct"
);
assert!(
!n.ty.evidence.iter().any(|e| e.contains("std::vector")),
"no vector evidence expected; evidence = {:?}",
n.ty.evidence
);
}
}
#[test]
fn libstdcxx_sso_string_detected() {
// Fix 6: a libstdc++-style SSO string has the SSO marker bit (bit 63) set
// in the capacity word, the length in word1, and inline characters at
// offset 0. The old libc++-only heuristic missed these.
let (map, mut bytes) = anon_heap_image(0x00);
let o = heap_obj(0x600020, 0x20);
bytes[(0x600020 - 0x600000) as usize..(0x600020 - 0x600000) as usize + 5]
.copy_from_slice(b"hello");
put_word(&mut bytes, o.addr + 8, 5); // inline length
put_word(&mut bytes, o.addr + 16, (1u64 << 63) | 15); // SSO marker + capacity
let image = MappedImage::from_bytes(bytes, map, 8);
let inventory = HeapInventory {
arenas: Vec::new(),
objects: vec![o],
};
let graph = build_graph(&image, &inventory, &ScanResult::default());
assert_eq!(graph.nodes.len(), 1);
assert_eq!(
graph.nodes[0].ty.label,
ObjectLabel::StdString,
"libstdc++ SSO string must be detected; evidence = {:?}",
graph.nodes[0].ty.evidence
);
assert!(
graph.nodes[0]
.ty
.evidence
.iter()
.any(|e| e.contains("SSO")),
"evidence should describe SSO; evidence = {:?}",
graph.nodes[0].ty.evidence
);
}
#[test]
fn freed_node_edges_not_traversed() {
// Fix 1: BFS must not traverse edges out of a freed chunk (fastbin/fd
// garbage) nor follow a live pointer INTO a freed chunk. A freed node is
// reachable only when a root targets it directly (a dangling stack pointer
// is a UAF hint), and even then its outgoing edges are never expanded.
let (map, bytes) = anon_heap_image(0x00);
let a = heap_obj(0x600020, 0x20); // allocated, root-targeted
let mut f = heap_obj(0x600050, 0x20);
f.state = HeurState::Freed;
let g = heap_obj(0x600080, 0x20); // only reachable via f's garbage
let h = heap_obj(0x6000b0, 0x20); // only reachable via f's garbage
let image = MappedImage::from_bytes(bytes, map, 8);
let inventory = HeapInventory {
arenas: Vec::new(),
objects: vec![a.clone(), f.clone(), g.clone(), h.clone()],
};
let scan_result = ScanResult {
roots: vec![
Root {
value: a.addr,
source: RootSource::Stack,
addr: 0x7fff_0000_0000,
},
// A dangling stack pointer into the freed chunk.
Root {
value: f.addr,
source: RootSource::Stack,
addr: 0x7fff_0000_0008,
},
],
edges: vec![
Edge {
from: a.addr,
to: f.addr, // live -> freed: not traversed into
offset: 0,
confirmed: false,
source: EdgeSource::Object,
},
Edge {
from: f.addr,
to: g.addr, // freed fastbin garbage: not traversed out of
offset: 0,
confirmed: false,
source: EdgeSource::Object,
},
Edge {
from: f.addr,
to: h.addr, // freed fastbin garbage: not traversed out of
offset: 8,
confirmed: false,
source: EdgeSource::Object,
},
],
stray_pointers: Vec::new(),
};
let graph = build_graph(&image, &inventory, &scan_result);
let reach = |addr: u64| {
graph
.nodes
.iter()
.find(|n| n.addr == addr)
.expect("node")
.reachable_from_root
};
assert!(reach(a.addr), "root-targeted allocated node is reachable");
assert!(
reach(f.addr),
"freed node is reachable when a root points at it directly"
);
assert!(
!reach(g.addr),
"freed garbage edge must not make its target reachable"
);
assert!(
!reach(h.addr),
"freed garbage edge must not make its target reachable"
);
assert_eq!(graph.stats.root_reachable, 2);
assert_eq!(graph.stats.max_depth, 0, "no traversal out of freed nodes");
}
#[test]
fn three_pointers_into_distinct_objects_not_vector() {
// Fix 2: a 24-byte {ptr,ptr,ptr} struct whose three ascending pointers land
// in three DIFFERENT carved objects must not be labeled a std::vector.
// 16-byte allocator alignment makes the gaps 8-byte multiples, so the old
// gate (mapped + %8 + caps) misfired; the new gate requires begin/end/cap
// to share one allocation.
let (map, mut bytes) = anon_heap_image(0x00);
let s1 = heap_obj(0x600020, 0x18);
let s2 = heap_obj(0x600050, 0x18);
let s3 = heap_obj(0x600080, 0x18);
let t1 = heap_obj(0x600100, 0x18);
let t2 = heap_obj(0x600140, 0x18);
let t3 = heap_obj(0x600180, 0x18);
for o in [&s1, &s2, &s3] {
put_word(&mut bytes, o.addr, t1.addr);
put_word(&mut bytes, o.addr + 8, t2.addr);
put_word(&mut bytes, o.addr + 16, t3.addr);
}
let image = MappedImage::from_bytes(bytes, map, 8);
let inventory = HeapInventory {
arenas: Vec::new(),
objects: vec![s1, s2, s3, t1, t2, t3],
};
let graph = build_graph(&image, &inventory, &ScanResult::default());
for n in &graph.nodes {
if [0x600020u64, 0x600050, 0x600080].contains(&n.addr) {
assert_eq!(
n.ty.label,
ObjectLabel::ProbableStruct,
"struct spanning three allocations must not be a vector; node 0x{:x} fields = {:?}",
n.addr,
n.ty.fields
);
assert!(
!n.ty.evidence.iter().any(|e| e.contains("std::vector")),
"no vector evidence expected; evidence = {:?}",
n.ty.evidence
);
}
}
}
#[test]
fn rodata_string_pointer_is_plain_pointer_not_vtable() {
// Fix 3: a word pointing into read-only file-backed memory whose first
// bytes are a printable string (not a code pointer) is a plain pointer,
// not a vtable field. The word at offset 0 must route through the cluster
// field classifier (pointer_field): word1/word2 are large integers so the
// std::string heuristics do not hijack the object first.
const RODATA: u64 = 0x400000;
const HEAP: u64 = 0x600000;
let mut bytes = vec![0u8; 0x1000 + 0x10000];
bytes[0..11].copy_from_slice(b"hello world");
let map = MemoryMap::from_ranges(vec![
MemoryRange {
start: RODATA,
end: RODATA + 0x1000,
file_offset: 0,
file_size: 0x1000,
perms: Perms {
read: true,
write: false,
execute: false,
},
kind: RangeKind::File,
path: None,
name: Some("toy-server.rodata".into()),
},
MemoryRange {
start: HEAP,
end: HEAP + 0x10000,
file_offset: 0x1000,
file_size: 0x10000,
perms: Perms {
read: true,
write: true,
execute: false,
},
kind: RangeKind::Anon,
path: None,
name: Some("[heap]".into()),
},
]);
let a = heap_obj(HEAP + 0x20, 0x20);
let b = heap_obj(HEAP + 0x50, 0x20);
let c = heap_obj(HEAP + 0x80, 0x20);
for o in [&a, &b, &c] {
let heap_off = |addr: u64| 0x1000 + (addr - HEAP) as usize;
bytes[heap_off(o.addr)..heap_off(o.addr) + 8].copy_from_slice(&RODATA.to_le_bytes());
bytes[heap_off(o.addr + 8)..heap_off(o.addr + 8) + 8]
.copy_from_slice(&0x1000000u64.to_le_bytes());
bytes[heap_off(o.addr + 16)..heap_off(o.addr + 16) + 8]
.copy_from_slice(&0x2000000u64.to_le_bytes());
}
let image = MappedImage::from_bytes(bytes, map, 8);
let inventory = HeapInventory {
arenas: Vec::new(),
objects: vec![a, b, c],
};
let graph = build_graph(&image, &inventory, &ScanResult::default());
assert!(
graph.nodes.iter().all(|n| n.ty.label == ObjectLabel::ProbableStruct),
"rodata-string objects must stay probable structs, not std::string/vector"
);
for n in &graph.nodes {
let f = n
.ty
.fields
.iter()
.find(|f| f.offset == 0)
.expect("pointer field at offset 0");
assert_eq!(
f.kind,
FieldKind::Pointer,
"rodata string pointer must be a plain pointer, not a vtable; fields = {:?}",
n.ty.fields
);
assert!(
!n.ty.evidence.iter().any(|e| e.contains("vtable")),
"no vtable evidence expected; evidence = {:?}",
n.ty.evidence
);
}
}