mirror of
https://github.com/vee1e/naksheap.git
synced 2026-09-01 10:18:37 +00:00
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:
commit
a48b163683
59 changed files with 10518 additions and 0 deletions
17
crates/naksheap-inference/Cargo.toml
Normal file
17
crates/naksheap-inference/Cargo.toml
Normal 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" }
|
||||
507
crates/naksheap-inference/src/cluster.rs
Normal file
507
crates/naksheap-inference/src/cluster.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
26
crates/naksheap-inference/src/export.rs
Normal file
26
crates/naksheap-inference/src/export.rs
Normal 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())
|
||||
}
|
||||
838
crates/naksheap-inference/src/graph.rs
Normal file
838
crates/naksheap-inference/src/graph.rs
Normal 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
|
||||
}
|
||||
24
crates/naksheap-inference/src/lib.rs
Normal file
24
crates/naksheap-inference/src/lib.rs
Normal 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};
|
||||
103
crates/naksheap-inference/src/types.rs
Normal file
103
crates/naksheap-inference/src/types.rs
Normal 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,
|
||||
}
|
||||
551
crates/naksheap-inference/tests/end_to_end.rs
Normal file
551
crates/naksheap-inference/tests/end_to_end.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue