From a48b163683803984eda6710c2804c5ab9801e936 Mon Sep 17 00:00:00 2001 From: lakshit verma Date: Sat, 15 Aug 2026 04:21:59 +0530 Subject: [PATCH] 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 --- .gitignore | 8 + Cargo.lock | 563 +++++++ Cargo.toml | 35 + README.md | 114 ++ .../naksheap-allocator-heuristics/Cargo.toml | 11 + .../src/glibc.rs | 1476 +++++++++++++++++ .../naksheap-allocator-heuristics/src/lib.rs | 89 + crates/naksheap-cli/Cargo.toml | 25 + crates/naksheap-cli/src/main.rs | 362 ++++ crates/naksheap-cli/tests/cli.rs | 136 ++ crates/naksheap-core-parse/Cargo.toml | 13 + crates/naksheap-core-parse/src/elf.rs | 294 ++++ crates/naksheap-core-parse/src/error.rs | 30 + crates/naksheap-core-parse/src/image.rs | 210 +++ crates/naksheap-core-parse/src/lib.rs | 64 + crates/naksheap-core-parse/src/maps.rs | 127 ++ crates/naksheap-core-parse/src/minidump.rs | 246 +++ crates/naksheap-core-parse/src/notes.rs | 332 ++++ crates/naksheap-inference/Cargo.toml | 17 + crates/naksheap-inference/src/cluster.rs | 507 ++++++ crates/naksheap-inference/src/export.rs | 26 + crates/naksheap-inference/src/graph.rs | 838 ++++++++++ crates/naksheap-inference/src/lib.rs | 24 + crates/naksheap-inference/src/types.rs | 103 ++ crates/naksheap-inference/tests/end_to_end.rs | 551 ++++++ crates/naksheap-pointer-scan/Cargo.toml | 13 + crates/naksheap-pointer-scan/src/index.rs | 150 ++ crates/naksheap-pointer-scan/src/lib.rs | 606 +++++++ crates/naksheap-pointer-scan/src/roots.rs | 122 ++ crates/naksheap-pointer-scan/src/scan.rs | 264 +++ crates/naksheap-testkit/Cargo.toml | 17 + crates/naksheap-testkit/examples/dump.rs | 27 + .../naksheap-testkit/examples/gen_fixture.rs | 20 + crates/naksheap-testkit/src/build.rs | 664 ++++++++ crates/naksheap-testkit/src/error.rs | 42 + crates/naksheap-testkit/src/lib.rs | 85 + crates/naksheap-testkit/src/manifest.rs | 145 ++ crates/naksheap-testkit/src/spec.rs | 186 +++ .../naksheap-testkit/tests/carve_fidelity.rs | 80 + crates/naksheap-testkit/tests/determinism.rs | 74 + crates/naksheap-testkit/tests/invalid_spec.rs | 130 ++ .../tests/manifest_consistency.rs | 180 ++ crates/naksheap-testkit/tests/roundtrip.rs | 124 ++ crates/naksheap-viz/Cargo.toml | 18 + crates/naksheap-viz/src/ascii.rs | 224 +++ crates/naksheap-viz/src/dot.rs | 59 + crates/naksheap-viz/src/html.rs | 161 ++ crates/naksheap-viz/src/lib.rs | 132 ++ deploy/.dockerignore | 6 + deploy/Dockerfile | 22 + deploy/docker-compose.yml | 37 + deploy/server.py | 220 +++ deployment.md | 93 ++ fixtures/README.md | 16 + fixtures/toy-server.core | Bin 0 -> 58444 bytes fixtures/toy-server.core.manifest.json | 115 ++ scripts/real-dump-test.sh | 117 ++ scripts/real-src/manyfree.cpp | 45 + scripts/real-src/test.cpp | 123 ++ 59 files changed, 10518 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 crates/naksheap-allocator-heuristics/Cargo.toml create mode 100644 crates/naksheap-allocator-heuristics/src/glibc.rs create mode 100644 crates/naksheap-allocator-heuristics/src/lib.rs create mode 100644 crates/naksheap-cli/Cargo.toml create mode 100644 crates/naksheap-cli/src/main.rs create mode 100644 crates/naksheap-cli/tests/cli.rs create mode 100644 crates/naksheap-core-parse/Cargo.toml create mode 100644 crates/naksheap-core-parse/src/elf.rs create mode 100644 crates/naksheap-core-parse/src/error.rs create mode 100644 crates/naksheap-core-parse/src/image.rs create mode 100644 crates/naksheap-core-parse/src/lib.rs create mode 100644 crates/naksheap-core-parse/src/maps.rs create mode 100644 crates/naksheap-core-parse/src/minidump.rs create mode 100644 crates/naksheap-core-parse/src/notes.rs create mode 100644 crates/naksheap-inference/Cargo.toml create mode 100644 crates/naksheap-inference/src/cluster.rs create mode 100644 crates/naksheap-inference/src/export.rs create mode 100644 crates/naksheap-inference/src/graph.rs create mode 100644 crates/naksheap-inference/src/lib.rs create mode 100644 crates/naksheap-inference/src/types.rs create mode 100644 crates/naksheap-inference/tests/end_to_end.rs create mode 100644 crates/naksheap-pointer-scan/Cargo.toml create mode 100644 crates/naksheap-pointer-scan/src/index.rs create mode 100644 crates/naksheap-pointer-scan/src/lib.rs create mode 100644 crates/naksheap-pointer-scan/src/roots.rs create mode 100644 crates/naksheap-pointer-scan/src/scan.rs create mode 100644 crates/naksheap-testkit/Cargo.toml create mode 100644 crates/naksheap-testkit/examples/dump.rs create mode 100644 crates/naksheap-testkit/examples/gen_fixture.rs create mode 100644 crates/naksheap-testkit/src/build.rs create mode 100644 crates/naksheap-testkit/src/error.rs create mode 100644 crates/naksheap-testkit/src/lib.rs create mode 100644 crates/naksheap-testkit/src/manifest.rs create mode 100644 crates/naksheap-testkit/src/spec.rs create mode 100644 crates/naksheap-testkit/tests/carve_fidelity.rs create mode 100644 crates/naksheap-testkit/tests/determinism.rs create mode 100644 crates/naksheap-testkit/tests/invalid_spec.rs create mode 100644 crates/naksheap-testkit/tests/manifest_consistency.rs create mode 100644 crates/naksheap-testkit/tests/roundtrip.rs create mode 100644 crates/naksheap-viz/Cargo.toml create mode 100644 crates/naksheap-viz/src/ascii.rs create mode 100644 crates/naksheap-viz/src/dot.rs create mode 100644 crates/naksheap-viz/src/html.rs create mode 100644 crates/naksheap-viz/src/lib.rs create mode 100644 deploy/.dockerignore create mode 100644 deploy/Dockerfile create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/server.py create mode 100644 deployment.md create mode 100644 fixtures/README.md create mode 100644 fixtures/toy-server.core create mode 100644 fixtures/toy-server.core.manifest.json create mode 100755 scripts/real-dump-test.sh create mode 100644 scripts/real-src/manyfree.cpp create mode 100644 scripts/real-src/test.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9860a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +target/ +.DS_Store +*.core +!fixtures/*.core +!fixtures/*.manifest.json +spec.md +scripts/real-src/test +scripts/real-src/manyfree diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..8c7210a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,563 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "naksheap-allocator-heuristics" +version = "0.1.0" +dependencies = [ + "naksheap-core-parse", + "serde", +] + +[[package]] +name = "naksheap-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "naksheap-allocator-heuristics", + "naksheap-core-parse", + "naksheap-inference", + "naksheap-pointer-scan", + "naksheap-testkit", + "naksheap-viz", + "serde_json", + "tempfile", +] + +[[package]] +name = "naksheap-core-parse" +version = "0.1.0" +dependencies = [ + "memmap2", + "object", + "serde", + "thiserror", +] + +[[package]] +name = "naksheap-inference" +version = "0.1.0" +dependencies = [ + "naksheap-allocator-heuristics", + "naksheap-core-parse", + "naksheap-pointer-scan", + "naksheap-testkit", + "serde", + "serde_json", +] + +[[package]] +name = "naksheap-pointer-scan" +version = "0.1.0" +dependencies = [ + "naksheap-allocator-heuristics", + "naksheap-core-parse", + "rayon", + "serde", +] + +[[package]] +name = "naksheap-testkit" +version = "0.1.0" +dependencies = [ + "naksheap-allocator-heuristics", + "naksheap-core-parse", + "serde", + "serde_json", + "tempfile", + "thiserror", +] + +[[package]] +name = "naksheap-viz" +version = "0.1.0" +dependencies = [ + "naksheap-allocator-heuristics", + "naksheap-core-parse", + "naksheap-inference", + "naksheap-pointer-scan", + "naksheap-testkit", + "serde", + "serde_json", +] + +[[package]] +name = "object" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" +dependencies = [ + "flate2", + "memchr", + "ruzstd", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "ruzstd" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "twox-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..134c35e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,35 @@ +[workspace] +resolver = "2" +members = [ + "crates/naksheap-core-parse", + "crates/naksheap-allocator-heuristics", + "crates/naksheap-pointer-scan", + "crates/naksheap-inference", + "crates/naksheap-viz", + "crates/naksheap-testkit", + "crates/naksheap-cli", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.95" +license = "MIT OR Apache-2.0" +authors = ["naksheap contributors"] + +[workspace.dependencies] +object = "0.40" +memmap2 = "0.9" +rayon = "1.12" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +clap = { version = "4", features = ["derive"] } +anyhow = "1" +thiserror = "2" +tempfile = "3" + +[profile.release] +lto = "thin" + +[profile.dev] +opt-level = 1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..dec896f --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +
+ +# naksheap + +**Reconstruct the heap from a core dump.** Point it at a crash dump of a stripped, optimized C++ binary and it recovers the live heap objects, their sizes and states, the pointers between them, and probable struct layouts, all without debug info and without a debugger. + +
+ +## Why + +A stripped `-O2` binary crashes. The stack is garbage, gdb has no symbols, and nobody can tell you what the heap held at the moment of death. That is usually where the evidence lives. + +The tools you already have stop at the wrong layer. Volatility works on the operating system, pwndbg needs a live process, Valgrind needs to re-run the program, and ASan needs the source rebuilt. None of them read a dead core dump and answer the real question: which objects were alive, and who pointed at what. + +naksheap reads the allocator metadata that is already in the dump. It walks glibc's chunk headers, decodes the tcache and fastbin free lists, finds large mmap-served allocations, discovers main and thread arenas, and builds a reference graph of everything it recovers. Every object gets a label with a confidence score and the evidence behind it, not a bare guess. + +## What it does + +The pipeline is one command, end to end. + +```mermaid +flowchart LR + A[core dump] --> B[find arenas and heap regions] + B --> C[carve objects: address, size, allocated or freed] + C --> D[scan for pointers between objects, registers, stack] + D --> E[group identical layouts, detect vtables, strings, vectors] + E --> F[object graph: ASCII, JSON, Graphviz, HTML report] +``` + +## Quick start + +```bash +cargo build --release +./target/release/naksheap self-test # smoke test on a synthetic fixture +./target/release/naksheap graph dump.core # analyze a real core dump +./target/release/naksheap graph dump.core --html --out report +``` + +Open `report/report.html` in a browser to browse the object graph. Or use `--json` for the machine-readable version. + +Generate a demo fixture if you do not have a core handy: + +```bash +cargo run -p naksheap-testkit --example gen_fixture -- /tmp/fixtures +./target/release/naksheap graph /tmp/fixtures/toy-server.core +``` + +## CLI + +| Command | What it prints | +|---|---| +| `info` | file format, process, pointer width, memory map | +| `maps` | the memory map, one range per line | +| `heap` | carved objects: address, size, state, arena | +| `graph` | the object graph. Default is an ASCII tree; add `--json`, `--dot`, or `--html` to export | +| `self-test` | run the whole pipeline on a synthetic fixture | + +`graph` accepts `--max-depth`, and `--out` writes files into a directory instead of stdout. Piping to `head` is fine; a truncated core prints a warning instead of failing silently. + +## Output + +The graph lists objects and their relationships. This is real output from a real crash core, with the source object noted: + +```text +0xf742c8000b70 +└── likely std::vector [conf 0.80, n=2] [root] + ├── +0x00 pointer begin -> 0xf742c8000c90 + ├── +0x08 pointer end -> 0xf742c8000ca8 + ├── +0x10 pointer capacity -> 0xf742c8000cb0 + └── 0xf742c8000c90 + └── opaque buffer [conf 0.45, n=1] + ├── +0x00 pointer -> 0xf742c8000b90 + └── 0xf742c8000b90 + └── vtable object [conf 0.95, n=4] + ├── +0x00 vtable -> 0xbab435e1fbb0 in test +``` + +Each node carries its label, a confidence between 0 and 1, and evidence lines in the JSON export. Reachability from the registers and stack is computed, so objects with no live references are listed separately as unreachable. + +## Supported inputs + +| Input | Status | +|---|---| +| ELF core dumps, x86-64 | primary; validated on synthetic fixtures, real-core runs pending | +| ELF core dumps, aarch64 | works, validated against real cores | +| Windows minidumps | memory list only, 64-bit, no threads or registers | +| 32-bit, macOS cores, /proc/kcore, QEMU snapshots | not supported | + +The allocator parser targets glibc ptmalloc on 64-bit. jemalloc and tcmalloc heaps are not parsed. + +## Validation + +The pipeline is tested on two layers. + +Synthetic fixtures with a ground-truth manifest cover the carve, scan, inference, and export stages, and regenerate byte-for-byte. + +Real core dumps come from `scripts/real-dump-test.sh`. It runs real C++ programs in a Linux container, crashes them, and snapshots them with gcore. It then checks that every address the program printed appears in the recovered graph at the same address. The checked-in results are aarch64 Ubuntu 24.04 with glibc 2.39. That testing is what exposed the glibc quirks the tool handles: tcache and fastbin frees do not clear the chunk PREV_INUSE bit, so freed chunks are found by cross-referencing the free lists, and large mmap allocations are recovered from their own chunk headers. + +## Limitations + +| Limitation | Detail | +|---|---| +| Inference is heuristic | Type labels are hypotheses with confidence and evidence, never certainty. Addresses, sizes, and allocator state are the reliable part. | +| Zombie memory | Freed chunks stay physically present until reused. They are flagged as freed, not silently dropped. | +| Large bin lists | Unsorted, small, and large bins are not walked yet. Fastbin and tcache free lists are. | +| No live debugging | This reads a static snapshot. It cannot groom a live heap or predict the next allocation. | + +## Privacy + +Analysis is fully offline. The only network request in the whole stack is the optional cytoscape.js download in the HTML report, made by the browser, not by the tool. Dumps can contain credentials and keys, so the reference web deployment is self-hosted and documented in `deployment.md`. + +## License + +MIT OR Apache-2.0 diff --git a/crates/naksheap-allocator-heuristics/Cargo.toml b/crates/naksheap-allocator-heuristics/Cargo.toml new file mode 100644 index 0000000..136dfba --- /dev/null +++ b/crates/naksheap-allocator-heuristics/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "naksheap-allocator-heuristics" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Allocator-aware heap carving: glibc ptmalloc chunk walking, arena discovery, alloc/free state" + +[dependencies] +naksheap-core-parse = { path = "../naksheap-core-parse" } +serde.workspace = true diff --git a/crates/naksheap-allocator-heuristics/src/glibc.rs b/crates/naksheap-allocator-heuristics/src/glibc.rs new file mode 100644 index 0000000..5cee392 --- /dev/null +++ b/crates/naksheap-allocator-heuristics/src/glibc.rs @@ -0,0 +1,1476 @@ +//! glibc ptmalloc carving. +//! +//! Implements the glibc (64-bit) allocator metadata parser: chunk-header +//! walking, `main_arena` discovery, and arena-to-object carving. +//! +//! # Chunk layout (64-bit glibc) +//! +//! A chunk's header sits `0x10` bytes before its user pointer: +//! +//! ```text +//! header+0x0 : prev_size (8 bytes) +//! header+0x8 : size (8 bytes), low 3 bits carry flags +//! header+0x10: user data +//! ``` +//! +//! `PREV_INUSE` (bit 0) is stored in the *next* chunk's size field; a cleared +//! bit means the current chunk was freed. `IS_MMAPPED` (bit 1) marks chunks +//! served by `mmap`. The usable size is `(size & !0xF) - 0x10` and the next +//! chunk's header is at `header + (size & !0xF)`. + +use std::collections::{HashMap, HashSet}; + +use naksheap_core_parse::{AddressSpace, MemoryRange, RangeKind}; + +use crate::{ArenaInfo, HeapInventory, Object, ObjectState}; + +/// ptmalloc alignment for 64-bit glibc. +pub const MALLOC_ALIGNMENT: u64 = 16; +/// Size of a machine word on 64-bit glibc. +pub const SIZE_SZ: u64 = 8; +/// Size-field flag: previous chunk is in use. +pub const PREV_INUSE: u64 = 1; +/// Size-field flag: chunk was served by `mmap`. +pub const IS_MMAPPED: u64 = 2; +/// Size-field flag: chunk belongs to a non-main arena. +pub const NON_MAIN_ARENA: u64 = 4; +/// Byte offset of `top` inside `malloc_state` (64-bit glibc). +pub const ARENA_TOP_OFFSET: u64 = 0x60; +/// Byte offset of `next` inside `malloc_state` (64-bit glibc). +pub const ARENA_NEXT_OFFSET: u64 = 0x870; +/// Byte offset of `system_mem` inside `malloc_state` (64-bit glibc); on a +/// real arena this is the total system memory of the heap it manages. +pub const ARENA_SYSTEM_MEM_OFFSET: u64 = 0x888; + +const PAGE_SIZE: u64 = 0x1000; +/// Minimum accepted chunk size (`size & !0xF`); smaller values are garbage. +const MIN_CHUNK_SIZE: u64 = 0x20; +/// Upper bound on carved objects (amplification guard). +pub const MAX_OBJECTS: usize = 5_000_000; +/// Max words scanned per range in `find_arenas` (amplification guard). +const MAX_SCAN_WORDS: u64 = 1 << 28; + +/// Confidence score at or above which an arena candidate is kept. +const ARENA_CONFIDENCE: u64 = 60; + +/// A parsed chunk header. +#[derive(Debug, Clone)] +pub struct Chunk { + /// Address of the chunk header (`user - 0x10`). + pub header: u64, + /// Address of the user data (`header + 0x10`). + pub user: u64, + /// Raw `size` field as read from memory. + pub size_field: u64, + /// Usable size of the user region (`(size & !0xF) - 0x10`). + pub user_size: u64, + /// `true` if the previous chunk is in use (from the next chunk's flags). + pub prev_in_use: bool, + /// `true` if this chunk was served by `mmap`. + pub is_mmapped: bool, + /// `true` if this chunk belongs to a non-main arena. + pub non_main_arena: bool, +} + +/// Walks a heap region `[start, end)` and returns every valid chunk header. +/// +/// The FIRST chunk header sits at the region start: `prev_size` at `start`, +/// `size` at `start + 8`, user data at `start + 0x10`. The walk stops (without +/// panic) at the first chunk whose size field is invalid, or when the +/// successor's header cannot be read (the current chunk is the top chunk). +pub fn walk_heap(image: &dyn AddressSpace, start: u64, end: u64) -> Vec { + let mut chunks = Vec::new(); + if end <= start || end - start < MIN_CHUNK_SIZE { + return chunks; + } + let region_size = end - start; + let max_iter = region_size / MIN_CHUNK_SIZE + 16; + let mut cur = start; + + for _ in 0..max_iter { + if chunks.len() >= MAX_OBJECTS { + break; + } + let Some(size_field) = cur.checked_add(8).and_then(|p| image.read_u64(p)) else { + break; + }; + let mask = size_field & !0xF; + if mask < MIN_CHUNK_SIZE { + break; + } + let Some(remaining) = end.checked_sub(cur) else { + break; + }; + if mask >= remaining { + break; + } + let Some(next_header) = cur.checked_add(mask) else { + break; + }; + // The next chunk's PREV_INUSE bit describes the current chunk's state; + // if we cannot read it, the current chunk is the top chunk. + let Some(next_size) = next_header.checked_add(8).and_then(|p| image.read_u64(p)) else { + break; + }; + let prev_in_use = next_size & PREV_INUSE != 0; + chunks.push(Chunk { + header: cur, + user: cur + 0x10, + size_field, + user_size: mask - 0x10, + prev_in_use, + is_mmapped: size_field & IS_MMAPPED != 0, + non_main_arena: size_field & NON_MAIN_ARENA != 0, + }); + cur = next_header; + } + chunks +} + +/// A scored arena candidate discovered during the file-backed scan. +struct ArenaCandidate { + addr: u64, + top: u64, + is_main: bool, +} + +/// Returns the arena's `top` pointer when it points into an anonymous rw- +/// heap region. +fn arena_top(image: &dyn AddressSpace, cand: u64) -> Option { + let top = cand + .checked_add(ARENA_TOP_OFFSET) + .and_then(|p| image.read_word(p))?; + let r = image.range_at(top)?; + if matches!(r.kind, RangeKind::Anon | RangeKind::Unknown) && r.perms.write { + Some(top) + } else { + None + } +} + +/// `true` if the chunk header at `top` (an mchunkptr, not the user pointer) +/// carries a plausible size that stays inside its containing range. The top +/// chunk's size is the remaining region bytes with `PREV_INUSE` set. +fn valid_top_header(image: &dyn AddressSpace, top: u64) -> bool { + let Some(range) = image.range_at(top) else { + return false; + }; + let Some(size_field) = top.checked_add(8).and_then(|p| image.read_u64(p)) else { + return false; + }; + let mask = size_field & !0xF; + mask >= MIN_CHUNK_SIZE && top.checked_add(mask).is_some_and(|t| t <= range.end) +} + +/// Scores a candidate `malloc_state` at `cand`; returns it when confident. +/// +/// The hard gates are: `top` points into an anonymous rw- region, the chunk +/// header at `top` is plausible, and the arena's `system_mem` field (offset +/// 0x888) is within a factor of the heap region size. Real heaps grown by +/// glibc keep `system_mem` roughly equal to the heap mapping size; libc data +/// words that merely happen to point into the heap fail that check. +fn evaluate_arena(image: &dyn AddressSpace, cand: u64) -> Option { + let top = arena_top(image, cand)?; + if !valid_top_header(image, top) { + return None; + } + // `system_mem` must be plausible for the heap region `top` points into. + let region_len = image.range_at(top).map_or(0, |r| r.len()); + let system_mem = cand + .checked_add(ARENA_SYSTEM_MEM_OFFSET) + .and_then(|p| image.read_word(p)) + .unwrap_or(0); + // Scale the absolute cap with the heap region so multi-GB heaps keep + // their arena, and use saturating arithmetic as defense in depth. + let cap = 0x4000_0000u64.max(region_len.saturating_mul(16)); + if !(0x1000..=cap).contains(&system_mem) + || region_len == 0 + || system_mem < region_len / 4 + || system_mem > region_len.saturating_mul(4) + { + return None; + } + + let mut score: u64 = 40; // passed the hard gates (top+header+system_mem) + if top.is_multiple_of(0x10) { + score += 10; + } + // `main_arena` is a static in libc's data segment (file-backed); a thread + // arena's malloc_state lives in its own anonymous heap. That is the + // robust main-arena discriminator (the `next` self-loop disappears as + // soon as a second arena exists). + let is_main = image + .range_at(cand) + .is_some_and(|r| r.kind == RangeKind::File && r.perms.write); + if let Some(next) = cand + .checked_add(ARENA_NEXT_OFFSET) + .and_then(|p| image.read_word(p)) + { + if next == cand { + score += 100; + } else if next == 0 { + score += 15; + } else if image + .range_at(next) + .is_some_and(|r| r.kind == RangeKind::Anon && r.perms.write) + { + score += 25; + } else if image + .range_at(next) + .is_some_and(|r| r.kind == RangeKind::File && r.perms.write) + { + score += 15; + } + } + if cand.is_multiple_of(0x10) { + score += 5; + } + if score < ARENA_CONFIDENCE { + return None; + } + Some(ArenaCandidate { + addr: cand, + top, + is_main, + }) +} + +/// Size of the heap region the arena's `top` points into. +fn arena_region_size(image: &dyn AddressSpace, top: u64) -> u64 { + image.range_at(top).map_or(0, |r| r.len()) +} + +/// Scans file-backed writable ranges for a `malloc_state` whose `top` field +/// points into an anonymous rw- heap, validating `main_arena` via its +/// self-looping `next` pointer. Never panics on garbage. +pub fn find_arenas(image: &dyn AddressSpace) -> Vec { + let mut arenas: Vec = Vec::new(); + if image.pointer_width() != 8 { + return arenas; + } + + let mut candidates: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + for range in image.map().writable_ranges() { + if range.kind != RangeKind::File && range.kind != RangeKind::Unknown { + continue; + } + // Align the scan start up to a word boundary without overflowing for + // starts near u64::MAX (fall back to the range end, which then skips). + let scan_start = range + .start + .checked_add(7) + .map(|x| x & !7) + .unwrap_or(range.end); + // Only scan bytes physically present in the dump; a malicious range + // claiming end == u64::MAX must not spin the loop forever. + let scan_end = range + .start + .saturating_add(range.file_size) + .min(range.end); + // Cap the words scanned per range (amplification guard). + let scan_end = scan_end.min(scan_start.saturating_add(MAX_SCAN_WORDS * 8)); + if scan_end <= scan_start { + continue; + } + let mut p = scan_start; + loop { + if p >= scan_end { + break; + } + let Some(next) = p.checked_add(8) else { + break; + }; + if next > range.end { + break; + } + if let Some(top) = image.read_word(p) { + if image + .range_at(top) + .is_some_and(|r| r.kind == RangeKind::Anon && r.perms.write) + { + // The word at `p` is the arena's `top` field, so the + // `malloc_state` struct begins `ARENA_TOP_OFFSET` earlier. + if let Some(cand) = p.checked_sub(ARENA_TOP_OFFSET) { + if cand >= range.start && cand < range.end && seen.insert(cand) { + if let Some(ac) = evaluate_arena(image, cand) { + candidates.push(ac); + } + } + } + } + } + p = next; + } + } + + for ac in &candidates { + arenas.push(ArenaInfo { + addr: ac.addr, + size: arena_region_size(image, ac.top), + top: ac.top, + is_main: ac.is_main, + }); + } + + // Follow `next` chains to pick up non-main arenas chained off confirmed + // ones. Bounded so garbage chains cannot run away. + let mut idx = 0; + let mut guard = 0; + while idx < candidates.len() && guard < 4096 { + guard += 1; + let cand = candidates[idx].addr; + if let Some(next) = cand + .checked_add(ARENA_NEXT_OFFSET) + .and_then(|p| image.read_word(p)) + { + if next != cand { + let plausible = image + .range_at(next) + .is_some_and(|r| r.perms.write && (r.kind == RangeKind::Anon || r.kind == RangeKind::File)); + if plausible && seen.insert(next) { + if let Some(ac) = evaluate_arena(image, next) { + arenas.push(ArenaInfo { + addr: ac.addr, + size: arena_region_size(image, ac.top), + top: ac.top, + is_main: false, + }); + candidates.push(ac); + } + } + } + } + idx += 1; + } + + // Non-main arenas: a thread's arena heap is a standalone anonymous mapping + // that begins with a `heap_info` header whose `ar_ptr` points at the + // arena's `malloc_state` (also in the region). `find_arenas` scans + // file-backed ranges above, so an arena whose state lives in an anon heap + // (common once a thread has exited and glibc dropped it from the `next` + // chain) is only discoverable by validating this anon signature. The + // `ar_ptr -> top-into-same-region -> plausible system_mem` gates make + // false positives very unlikely (stacks and mmap buffers do not start + // with an in-region `malloc_state` pointer). + for region in image.map().writable_ranges() { + if region.len() < PAGE_SIZE || !matches!(region.kind, RangeKind::Anon | RangeKind::Unknown) { + continue; + } + let Some(ar_ptr) = image.read_word(region.start) else { + continue; + }; + // glibc places the arena's malloc_state right after the 0x20-byte + // `heap_info`, so `ar_ptr - region.start` is a small offset (0x20..0x100). + // Requiring that shape rejects stack frames and arbitrary anon buffers + // whose first word happens to be an in-region pointer. + let off = match ar_ptr.checked_sub(region.start) { + Some(o) if (0x20..0x100).contains(&o) => o, + _ => continue, + }; + let _ = off; + if let Some(ac) = evaluate_arena(image, ar_ptr) { + if seen.insert(ar_ptr) { + arenas.push(ArenaInfo { + addr: ac.addr, + size: arena_region_size(image, ac.top), + top: ac.top, + is_main: false, + }); + } + } + } + + arenas.sort_by_key(|a| a.addr); + arenas.dedup_by(|a, b| a.addr == b.addr); + arenas +} + +/// Probes candidate first-chunk offsets within a region and walks from the +/// first one that yields any valid chunk. +/// +/// The main heap (`main_arena`) starts its first chunk header at the region +/// start (offset 0). Non-main arenas' heaps begin with a `heap_info` header +/// (32 bytes on 64-bit), so the first chunk header sits at a small positive +/// offset. Walking from offset 0 would read `heap_info` as a chunk header, +/// get garbage, and yield zero objects — silently. This probes a small set of +/// aligned offsets within the first page and uses the first that recovers at +/// least one chunk. +/// Walks a heap region by probing a small set of plausible first-chunk +/// Counts chunks recoverable from `start`, stopping after `cap` chunks. Used +/// by [`probe_walk_region`] to rank candidate first-chunk offsets cheaply +/// without walking an entire (potentially huge) region once per probe. +fn count_chunks_capped(image: &dyn AddressSpace, start: u64, end: u64, cap: usize) -> usize { + let mut cur = start; + let mut count = 0usize; + for _ in 0..cap { + let Some(size_field) = cur.checked_add(8).and_then(|p| image.read_u64(p)) else { + break; + }; + let mask = size_field & !0xF; + if mask < MIN_CHUNK_SIZE || mask >= end.saturating_sub(cur) { + break; + } + let Some(next) = cur.checked_add(mask) else { + break; + }; + let Some(_next_size) = next.checked_add(8).and_then(|p| image.read_u64(p)) else { + break; + }; + count += 1; + cur = next; + } + count +} + +/// Walks a heap region by probing a small set of plausible first-chunk +/// offsets and returning the objects carved from the best offset. +/// +/// Real glibc main-arena heaps start their first chunk at the region base, +/// but non-main arenas begin with a `heap_info` header, so the first chunk +/// may sit 0x20..0x100 bytes in. Probing is two-phase: each offset gets a +/// CHEAP capped count (bounded, so a crafted huge region cannot be walked +/// once per probe), then a single full walk runs from the winning offset. +/// `min_chunks` is 1 for a self-looping `main_arena` and 3 otherwise (the +/// same evidence bar as [`chunk_chain_plausible`]). +fn probe_walk_region( + image: &dyn AddressSpace, + region: &MemoryRange, + arena: Option, + min_chunks: usize, + prefer_offset0: bool, +) -> Vec { + const COUNT_CAP: usize = 256; // enough to rank offsets without full walks + + // Candidate first-chunk offsets: fixed small ones, offsets relative to the + // known arena address (non-main arenas put the malloc_state ~0x898 bytes + // in), and a dense grid over the first 4 KiB. + let mut probe_offsets: Vec = vec![0x0, 0x10, 0x20, 0x30, 0x40, 0x80]; + let arena_anchor: Option = if let Some(ar) = arena { + if ar >= region.start { + let base = ar - region.start; + for delta in [0x880u64, 0x890, 0x898, 0x8a0, 0x8b0, 0x8c0, 0x8d0, 0x900] { + probe_offsets.push(base.saturating_add(delta)); + } + Some(base.saturating_add(0x8d0)) + } else { + None + } + } else { + None + }; + let mut off = 0x100u64; + while off < 0x1000 { + probe_offsets.push(off); + off += 0x20; + } + + // Phase 1: cheap capped counts for every offset. + let mut ranked: Vec<(u64, usize)> = Vec::new(); + for &off in &probe_offsets { + let Some(start) = region.start.checked_add(off) else { + continue; + }; + if start >= region.end { + continue; + } + let count = count_chunks_capped(image, start, region.end, COUNT_CAP); + if count >= min_chunks { + ranked.push((off, count)); + } + } + if ranked.is_empty() { + return Vec::new(); + } + + // Phase 2: pick the winner. Prefer the main-arena anchor (offset 0) or + // the arena-relative anchor (thread arenas) when it clears the bar, and + // only let a challenger displace an anchor on a DECISIVE margin (>= 2x) + // so a dense array of chunk-lookalike words inside the first page cannot + // steal the walk. + let anchor = if prefer_offset0 { Some(0x0u64) } else { arena_anchor }; + let best_off = match anchor { + Some(a) => match ranked.iter().find(|(o, _)| *o == a) { + Some(&(_, anchor_count)) => { + // Keep the anchor unless a challenger decisively beats it. + let challenger = ranked + .iter() + .filter(|(o, _)| *o != a) + .filter(|(_, c)| *c >= anchor_count.saturating_mul(2)) + .max_by_key(|(_, c)| *c); + challenger.map(|(o, _)| *o).unwrap_or(a) + } + None => ranked.iter().max_by_key(|(_, c)| *c).expect("ranked non-empty").0, + }, + None => ranked.iter().max_by_key(|(_, c)| *c).expect("ranked non-empty").0, + }; + + let start = region.start.checked_add(best_off).unwrap_or(region.start); + walk_heap(image, start, region.end) + .into_iter() + .map(|c| { + let (state, freed_reason) = if c.is_mmapped { + (ObjectState::Mmap, None) + } else if c.prev_in_use { + (ObjectState::Allocated, None) + } else { + (ObjectState::Freed, Some(crate::FreedReason::PrevInuseClear)) + }; + Object { + addr: c.user, + size: c.user_size, + state, + arena, + chunk_header: c.header, + freed_reason, + } + }) + .collect() +} + +/// Converts a region's chunks into carved objects. +fn collect_objects( + image: &dyn AddressSpace, + region: &MemoryRange, + arena: Option, + min_chunks: usize, + prefer_offset0: bool, +) -> Vec { + probe_walk_region(image, region, arena, min_chunks, prefer_offset0) +} + +/// glibc safe-linking unmangle: a stored pointer `v` at slot address `pos` +/// was mangled as `(pos >> 12) ^ ptr`; reveal the original pointer. Used to +/// decode a freed chunk's `fd`/`bk` free-list linkage. Kept for future +/// free-list decoding. +fn reveal_safe_link(pos: u64, v: u64) -> u64 { + (pos >> 12) ^ v +} + +/// Marks chunks freed into glibc's per-thread tcache as [`Freed`]. +/// +/// tcache'd chunks do NOT clear the successor's `PREV_INUSE` bit (that only +/// happens for bin/unsorted consolidation), so the chunk-header heuristic +/// reports them as allocated. The `tcache_perthread_struct` keeps the +/// per-size-class free lists; its `entries[]` hold the PLAIN user pointers of +/// the freed chunks (only the intra-list `fd` links inside each freed chunk +/// are safe-link mangled), so any carved object whose address appears there is +/// a freed chunk. +fn mark_tcache_freed(image: &dyn AddressSpace, objects: &mut [Object]) { + const MAX_CHAIN: usize = 16; // TCACHE_FILL_COUNT is 7 + // Global step budget so a crafted dump full of tcache-lookalike objects + // cannot stall carving (each candidate would otherwise cost up to + // 64 bins * 16 chain steps). + const MAX_STEPS: usize = 4096; + let by_addr: HashMap = objects + .iter() + .enumerate() + .map(|(i, o)| (o.addr, i)) + .collect(); + let mut freed: Vec = Vec::new(); + let mut steps = 0usize; + for obj in objects.iter() { + if steps >= MAX_STEPS { + break; + } + if obj.state != ObjectState::Allocated || obj.size < 0x280 { + continue; + } + // tcache_perthread_struct: u16 counts[64] at +0, entries[64] at +0x80. + let mut counts = [0u16; 64]; + let counts_ok = (0..64).all(|i| { + let c = image.read_u16(obj.addr + i as u64 * 2); + if let Some(c) = c { + counts[i] = c; + } + c.is_some_and(|v| v < 0x40) + }); + if !counts_ok { + continue; + } + for (i, &count) in counts.iter().enumerate() { + if steps >= MAX_STEPS { + break; + } + // glibc invariant: entries[i] != NULL <=> counts[i] > 0. + if count == 0 { + continue; + } + let slot = obj.addr + 0x80 + i as u64 * 8; + let Some(mut cur) = image.read_u64(slot) else { + continue; + }; + if cur == 0 { + continue; + } + // `entries[i]` is the PLAIN user pointer of the bin head; the rest + // of the chain is safe-link mangled in each freed chunk's `fd` + // (`(chunk_user >> 12) ^ next`), so walk it with reveal. Abort the + // bin as soon as a link does not resolve to a carved object: a + // corrupt chain (or a false tcache-lookalike) must not mark + // unrelated objects freed. + let chain_max = (count as usize).min(MAX_CHAIN); + for _ in 0..chain_max { + steps += 1; + if cur == 0 { + break; + } + let Some(&idx) = by_addr.get(&cur) else { + break; + }; + freed.push(idx); + let Some(fd) = image.read_word(cur) else { + break; + }; + if fd == 0 { + break; + } + cur = reveal_safe_link(cur, fd); + } + } + } + for idx in freed { + objects[idx].state = ObjectState::Freed; + objects[idx].freed_reason = Some(crate::FreedReason::Tcache); + } +} + +/// Marks chunks freed into glibc's arena fastbins as [`Freed`]. +/// +/// Fastbin-freed chunks (like tcache ones) keep the successor's `PREV_INUSE` +/// set, so the chunk-header heuristic reports them as allocated. The arena's +/// `fastbinsY[0..10]` holds the CHUNK-HEADER address of each bin head; each +/// freed chunk stores its `fd` link (safe-link MANGLED since glibc 2.32) at +/// its user address. Walking those chains (revealing each link, then adding +/// 0x10 to map header -> user before the address lookup) marks the referenced +/// chunks freed. +fn mark_fastbin_freed(image: &dyn AddressSpace, arena: u64, objects: &mut [Object]) { + const FASTBINS_Y_OFFSET: u64 = 0x10; + const FASTBIN_COUNT: usize = 10; + const MAX_CHAIN: usize = 64; + let by_addr: HashMap = objects + .iter() + .enumerate() + .map(|(i, o)| (o.addr, i)) + .collect(); + let mut freed: Vec = Vec::new(); + for bin in 0..FASTBIN_COUNT { + let Some(head) = arena + .checked_add(FASTBINS_Y_OFFSET + bin as u64 * 8) + .and_then(|p| image.read_word(p)) + else { + continue; + }; + // Bin heads are chunk-header addresses; an empty bin self-references + // the bin slot itself (in the arena), which resolves to nothing. + let mut cur_header = head; + for _ in 0..MAX_CHAIN { + if cur_header == 0 { + break; + } + let Some(user) = cur_header.checked_add(0x10) else { + break; + }; + let Some(&idx) = by_addr.get(&user) else { + // Link points outside the carved heap (e.g. an empty bin's + // self-referential head): stop following it. + break; + }; + freed.push(idx); + // The next link is the mangled `fd` stored at the chunk's user + // address (slot address == user). + let Some(fd) = image.read_word(user) else { + break; + }; + if fd == 0 { + break; + } + cur_header = reveal_safe_link(user, fd); + } + } + for idx in freed { + objects[idx].state = ObjectState::Freed; + objects[idx].freed_reason = Some(crate::FreedReason::Fastbin); + } +} + +/// Carves a standalone `mmap`-served allocation out of an anonymous rw- +/// region. glibc places the chunk header at the mapping start with +/// `IS_MMAPPED` set in the size field. Returns `None` if the region does not +/// start with a plausible mmapped chunk. +fn carve_mmap_region(image: &dyn AddressSpace, region: &MemoryRange) -> Option { + // A real glibc mmap chunk satisfies four invariants: prev_size == 0, + // IS_MMAPPED set, PREV_INUSE clear (glibc only sets the M bit), and a + // page-aligned chunk size. Requiring all of them rejects stacks and + // arbitrary anon buffers whose first words merely look pointer-ish. + let prev_size = image.read_u64(region.start)?; + let size_field = region.start.checked_add(8).and_then(|p| image.read_u64(p))?; + if prev_size != 0 + || size_field & IS_MMAPPED == 0 + || size_field & PREV_INUSE != 0 + { + return None; + } + let mask = size_field & !0xF; + if mask < PAGE_SIZE || mask > region.len() || mask % PAGE_SIZE != 0 { + return None; + } + Some(Object { + addr: region.start + 0x10, + size: mask - 0x10, + state: ObjectState::Mmap, + arena: None, + chunk_header: region.start, + freed_reason: None, + }) +} + +/// Bounded plausibility check that a region starts with at least 3 recoverable +/// chunk headers. Never panics; at most 64 chunk headers are examined so a +/// garbage region cannot cause unbounded work. +fn chunk_chain_plausible(image: &dyn AddressSpace, start: u64, end: u64) -> bool { + const MAX_CHECKS: usize = 64; + if end <= start || end - start < MIN_CHUNK_SIZE { + return false; + } + let mut cur = start; + let mut valid = 0usize; + for _ in 0..MAX_CHECKS { + if cur >= end { + break; + } + let Some(size_field) = cur.checked_add(8).and_then(|p| image.read_u64(p)) else { + break; + }; + let mask = size_field & !0xF; + if mask < MIN_CHUNK_SIZE { + break; + } + let Some(next) = cur.checked_add(mask) else { + break; + }; + if next >= end { + break; + } + let Some(_next_size) = next.checked_add(8).and_then(|p| image.read_u64(p)) else { + break; + }; + valid += 1; + if valid >= 3 { + return true; + } + cur = next; + } + false +} + +/// Carves heap objects out of `image`. +/// +/// Walks the anonymous rw- region containing each discovered arena's `top`. +/// When no arena is found (e.g. a symbol-less dump), falls back to walking +/// anonymous writable regions of at least one page that pass a chunk-chain +/// plausibility check. Stacks and other non-heap anon regions are never carved +/// while arenas are present. Objects are deduplicated by address, sorted, and +/// capped at [`MAX_OBJECTS`]. +pub fn carve(image: &dyn AddressSpace) -> HeapInventory { + let mut inventory = HeapInventory::default(); + if image.pointer_width() != 8 { + return inventory; + } + + inventory.arenas = find_arenas(image); + + let mut walked: HashSet = HashSet::new(); + let mut objects: Vec = Vec::new(); + + let mut carve_region = + |region: &MemoryRange, arena: Option, min_chunks: usize, is_main: bool| { + if !walked.insert(region.start) { + return; + } + let mut region_objects = collect_objects(image, region, arena, min_chunks, is_main); + // Bound the object count DURING carving (not only at the end) so a + // dump with many plausible regions cannot accumulate N * MAX_OBJECTS + // objects (and matching tcache/fastbin HashMaps) in memory. + region_objects.truncate(MAX_OBJECTS.saturating_sub(objects.len())); + if region_objects.is_empty() { + return; + } + mark_tcache_freed(image, &mut region_objects); + if let Some(a) = arena { + mark_fastbin_freed(image, a, &mut region_objects); + } + objects.extend(region_objects); + }; + + for arena in &inventory.arenas { + if let Some(region) = image.range_at(arena.top) { + if matches!(region.kind, RangeKind::Anon | RangeKind::Unknown) + && region.perms.write + { + // A self-looping main_arena is strong evidence by itself, so + // accept a single chunk; every other arena path must clear the + // same 3-chunk bar as the arena-less fallback so stack-like + // garbage is not carved. + let min_chunks = if arena.is_main { 1 } else { 3 }; + carve_region(region, Some(arena.addr), min_chunks, arena.is_main); + } + } + } + + if inventory.arenas.is_empty() { + for region in image.map().anon_ranges() { + if region.perms.write + && region.len() >= PAGE_SIZE + && chunk_chain_plausible(image, region.start, region.end) + { + carve_region(region, None, 3, true); + } + } + } + + // Standalone mmap-served allocations in anonymous rw- regions that were + // not walked as arenas (glibc serves large requests via mmap). + for region in image.map().writable_ranges() { + if !matches!(region.kind, RangeKind::Anon | RangeKind::Unknown) { + continue; + } + if region.len() >= PAGE_SIZE && !walked.contains(®ion.start) { + if let Some(obj) = carve_mmap_region(image, region) { + walked.insert(region.start); + objects.push(obj); + } + } + } + + objects.sort_by_key(|o| o.addr); + objects.dedup_by(|a, b| a.addr == b.addr); + objects.truncate(MAX_OBJECTS); + inventory.objects = objects; + inventory +} + +#[cfg(test)] +mod tests { + use super::*; + use naksheap_core_parse::{MappedImage, MemoryMap, Perms}; + + struct ImageBuilder { + bytes: Vec, + ranges: Vec, + } + + impl ImageBuilder { + fn new() -> Self { + ImageBuilder { + bytes: Vec::new(), + ranges: Vec::new(), + } + } + + fn add_range(&mut self, start: u64, len: u64, kind: RangeKind, perms: Perms) { + let off = self.bytes.len() as u64; + self.bytes.resize(off as usize + len as usize, 0); + self.ranges.push(MemoryRange { + start, + end: start + len, + file_offset: off, + file_size: len, + perms, + kind, + path: None, + name: None, + }); + } + + fn write(&mut self, addr: u64, data: &[u8]) { + let r = self + .ranges + .iter() + .find(|r| r.contains(addr)) + .unwrap_or_else(|| panic!("addr {addr:#x} unmapped")); + let delta = addr - r.start; + assert!( + delta + data.len() as u64 <= r.file_size, + "write out of range at {addr:#x}" + ); + let base = (r.file_offset + delta) as usize; + self.bytes[base..base + data.len()].copy_from_slice(data); + } + + fn write_u64(&mut self, addr: u64, v: u64) { + self.write(addr, &v.to_le_bytes()); + } + + fn build(self) -> MappedImage { + MappedImage::from_bytes(self.bytes, MemoryMap::from_ranges(self.ranges), 8) + } + } + + fn add_heap(b: &mut ImageBuilder, start: u64, len: u64) { + b.add_range( + start, + len, + RangeKind::Anon, + Perms { + read: true, + write: true, + execute: false, + }, + ); + } + + fn add_libc(b: &mut ImageBuilder, start: u64, len: u64) { + b.add_range( + start, + len, + RangeKind::File, + Perms { + read: true, + write: true, + execute: false, + }, + ); + } + + /// Writes a chunk with the given header address and user-region size. + fn place_chunk(b: &mut ImageBuilder, header: u64, user_size: u64, flags: u64, fill: u8) { + let mask = (0x10 + user_size + 0xF) & !0xF; + b.write_u64(header + 8, mask | flags); + if user_size > 0 { + b.write(header + 0x10, &vec![fill; user_size as usize]); + } + } + + /// Writes a top chunk filling the rest of its region. + fn place_top(b: &mut ImageBuilder, header: u64, region_end: u64) { + b.write_u64(header + 8, (region_end - header) | PREV_INUSE); + } + + const H: u64 = 0x7f00_0000_0000; + const H_END: u64 = H + 0x2000; + + fn base_heap(b: &mut ImageBuilder) { + add_heap(b, H, H_END - H); + } + + #[test] + fn walk_heap_allocated_chunks_and_top() { + let mut b = ImageBuilder::new(); + base_heap(&mut b); + place_chunk(&mut b, H, 0x20, PREV_INUSE, 0x41); // user H+0x10 + place_chunk(&mut b, H + 0x30, 0x40, PREV_INUSE, 0x42); // user H+0x40 + place_chunk(&mut b, H + 0x80, 0x30, PREV_INUSE, 0x43); // user H+0x90 + place_top(&mut b, H + 0xc0, H_END); + + let img = b.build(); + let chunks = walk_heap(&img, H, H_END); + assert_eq!(chunks.len(), 3); + + assert_eq!(chunks[0].header, H); + assert_eq!(chunks[0].user, H + 0x10); + assert_eq!(chunks[0].user_size, 0x20); + assert!(chunks[0].prev_in_use); + assert!(!chunks[0].is_mmapped); + assert!(!chunks[0].non_main_arena); + + assert_eq!(chunks[1].header, H + 0x30); + assert_eq!(chunks[1].user, H + 0x40); + assert_eq!(chunks[1].user_size, 0x40); + + assert_eq!(chunks[2].header, H + 0x80); + assert_eq!(chunks[2].user, H + 0x90); + assert_eq!(chunks[2].user_size, 0x30); + + // carve() must surface the same objects, Allocated, no top chunk. + let inv = crate::carve(&img); + assert_eq!(inv.objects.len(), 3); + assert!(inv.objects.iter().all(|o| o.state == ObjectState::Allocated)); + assert_eq!(inv.objects[0].addr, H + 0x10); + assert_eq!(inv.objects[0].size, 0x20); + assert_eq!(inv.objects[1].addr, H + 0x40); + assert_eq!(inv.objects[1].size, 0x40); + assert_eq!(inv.objects[2].addr, H + 0x90); + assert_eq!(inv.objects[2].size, 0x30); + } + + #[test] + fn walk_heap_freed_chunk() { + let mut b = ImageBuilder::new(); + base_heap(&mut b); + place_chunk(&mut b, H, 0x30, PREV_INUSE, 0x41); // user H+0x10 + // Next chunk's PREV_INUSE clear => chunk[0] is freed. + place_chunk(&mut b, H + 0x40, 0x20, 0, 0x42); // user H+0x50 + place_chunk(&mut b, H + 0x70, 0x20, PREV_INUSE, 0x43); // user H+0x80 + place_top(&mut b, H + 0xa0, H_END); + + let img = b.build(); + let inv = crate::carve(&img); + assert_eq!(inv.objects.len(), 3); + assert_eq!(inv.objects[0].addr, H + 0x10); + assert_eq!(inv.objects[0].state, ObjectState::Freed); + assert_eq!(inv.objects[1].addr, H + 0x50); + assert_eq!(inv.objects[1].state, ObjectState::Allocated); + assert_eq!(inv.objects[2].addr, H + 0x80); + assert_eq!(inv.objects[2].state, ObjectState::Allocated); + } + + #[test] + fn walk_heap_garbage_stops_gracefully() { + let mut b = ImageBuilder::new(); + base_heap(&mut b); + place_chunk(&mut b, H, 0x30, PREV_INUSE, 0x41); // user H+0x10 + place_chunk(&mut b, H + 0x40, 0x20, PREV_INUSE, 0x42); // user H+0x50 + // Garbage size field that exceeds the region (PREV_INUSE set so the + // preceding chunk still reads as Allocated). + b.write_u64(H + 0x70 + 8, 0xDEAD_BEEF_0000_0001); + // Out-of-bounds read after the garbage must not panic. + let img = b.build(); + let chunks = walk_heap(&img, H, H_END); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].user, H + 0x10); + assert!(chunks[0].prev_in_use); + assert_eq!(chunks[1].user, H + 0x50); + assert!(chunks[1].prev_in_use); + } + + #[test] + fn walk_heap_undersized_size_stops_gracefully() { + let mut b = ImageBuilder::new(); + base_heap(&mut b); + place_chunk(&mut b, H, 0x30, PREV_INUSE, 0x41); + // An undersized (< 0x20) chunk size should stop the walk, not panic. + b.write_u64(H + 0x40 + 8, 0x5); + let img = b.build(); + let chunks = walk_heap(&img, H, H_END); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].user, H + 0x10); + assert!(chunks[0].prev_in_use); + } + + #[test] + fn walk_heap_mmapped_chunk() { + let mut b = ImageBuilder::new(); + base_heap(&mut b); + place_chunk(&mut b, H, 0x20, PREV_INUSE, 0x41); // user H+0x10 + place_chunk(&mut b, H + 0x30, 0x30, PREV_INUSE | IS_MMAPPED, 0x42); // user H+0x40 + place_chunk(&mut b, H + 0x70, 0x20, PREV_INUSE, 0x43); // user H+0x80 + place_top(&mut b, H + 0xa0, H_END); + + let img = b.build(); + let inv = crate::carve(&img); + assert_eq!(inv.objects.len(), 3); + assert_eq!(inv.objects[0].state, ObjectState::Allocated); + assert_eq!(inv.objects[1].addr, H + 0x40); + assert_eq!(inv.objects[1].state, ObjectState::Mmap); + assert_eq!(inv.objects[2].state, ObjectState::Allocated); + } + + #[test] + fn find_arenas_locates_main_arena() { + const L: u64 = 0x7faa_0000_0000; + let mut b = ImageBuilder::new(); + add_libc(&mut b, L, 0x1000); + add_heap(&mut b, H, 0x1000); + + let arena = L + 0x100; + let top = H + 0x40; // main_arena.top points at the TOP CHUNK HEADER. + b.write_u64(arena + ARENA_TOP_OFFSET, top); + b.write_u64(arena + ARENA_NEXT_OFFSET, arena); + b.write_u64(arena + ARENA_SYSTEM_MEM_OFFSET, 0x1000); + + // Realistic heap: first chunk header at region start, then a top + // chunk whose header `main_arena.top` points at. + place_chunk(&mut b, H, 0x30, PREV_INUSE, 0x41); + place_top(&mut b, H + 0x40, H + 0x1000); + + let img = b.build(); + let arenas = find_arenas(&img); + assert_eq!(arenas.len(), 1); + assert_eq!(arenas[0].addr, arena); + assert_eq!(arenas[0].top, top); + assert_eq!(arenas[0].size, 0x1000); + assert!(arenas[0].is_main); + + let inv = crate::carve(&img); + assert_eq!(inv.arenas.len(), 1); + assert_eq!(inv.objects.len(), 1); + assert_eq!(inv.objects[0].addr, H + 0x10); + assert_eq!(inv.objects[0].arena, Some(arena)); + } + + #[test] + fn find_arenas_ignores_garbage() { + const L: u64 = 0x7faa_0000_0000; + let mut b = ImageBuilder::new(); + add_libc(&mut b, L, 0x1000); + add_heap(&mut b, H, 0x2000); + + // A pointer into the heap that is NOT a real arena: no self-loop, and + // no plausible top-header at +0x60. Must not produce an arena. + let fake = L + 0x40; + b.write_u64(fake + ARENA_TOP_OFFSET, H + 0x1000); + b.write_u64(fake + ARENA_NEXT_OFFSET, L + 0x40 + 8); + + let img = b.build(); + assert!(find_arenas(&img).is_empty()); + } + + #[test] + fn carve_dedups_and_sorts() { + let mut b = ImageBuilder::new(); + // Two heap regions with no arenas present; carve reaches them via the + // chunk-chain plausibility fallback. Each region carries 3 chunks plus + // a top chunk so the fallback accepts it. + add_heap(&mut b, H, H_END - H); + add_heap(&mut b, H_END + 0x1000, 0x2000); + + place_chunk(&mut b, H, 0x20, PREV_INUSE, 0x41); + place_chunk(&mut b, H + 0x30, 0x20, PREV_INUSE, 0x42); + place_chunk(&mut b, H + 0x60, 0x20, PREV_INUSE, 0x43); + place_top(&mut b, H + 0x90, H_END); + place_chunk(&mut b, H_END + 0x1000, 0x20, PREV_INUSE, 0x51); + place_chunk(&mut b, H_END + 0x1030, 0x20, PREV_INUSE, 0x52); + place_chunk(&mut b, H_END + 0x1060, 0x20, PREV_INUSE, 0x53); + place_top(&mut b, H_END + 0x1090, H_END + 0x3000); + + let img = b.build(); + let inv = crate::carve(&img); + assert_eq!(inv.objects.len(), 6); + assert!(inv.objects.windows(2).all(|w| w[0].addr < w[1].addr)); + } + + #[test] + fn carve_with_arena_skips_stack() { + // When an arena is present, only the anon rw- region containing the + // arena's `top` is carved; a stack region full of chunk-lookalikes + // must NOT produce objects. + const L: u64 = 0x7faa_0000_0000; + const S: u64 = 0x7fff_0000_0000; + const S_END: u64 = S + 0x4000; + let mut b = ImageBuilder::new(); + add_libc(&mut b, L, 0x1000); + add_heap(&mut b, H, H_END - H); + add_heap(&mut b, S, S_END - S); + + let arena = L + 0x100; + let top = H + 0x90; // top chunk header + b.write_u64(arena + ARENA_TOP_OFFSET, top); + b.write_u64(arena + ARENA_NEXT_OFFSET, arena); + b.write_u64(arena + ARENA_SYSTEM_MEM_OFFSET, H_END - H); + + // Heap: three chunks plus a top chunk. + place_chunk(&mut b, H, 0x20, PREV_INUSE, 0x41); + place_chunk(&mut b, H + 0x30, 0x20, PREV_INUSE, 0x42); + place_chunk(&mut b, H + 0x60, 0x20, PREV_INUSE, 0x43); + place_top(&mut b, H + 0x90, H_END); + + // Stack region that would pass the chunk-chain plausibility fallback + // if it were ever walked. + place_chunk(&mut b, S, 0x20, PREV_INUSE, 0x71); + place_chunk(&mut b, S + 0x30, 0x20, PREV_INUSE, 0x72); + place_chunk(&mut b, S + 0x60, 0x20, PREV_INUSE, 0x73); + place_top(&mut b, S + 0x90, S_END); + + let img = b.build(); + let inv = crate::carve(&img); + assert_eq!(inv.arenas.len(), 1); + assert_eq!(inv.objects.len(), 3); + assert!( + inv.objects.iter().all(|o| o.addr >= H && o.addr < H_END), + "carve with a known arena must not carve non-heap regions" + ); + } + + #[test] + fn find_arenas_terminates_on_max_end() { + // A malicious file-backed range claiming end == u64::MAX (or starting + // right before u64::MAX) must not make the scan wrap and spin forever. + // The test passing within normal time is the assertion. + let mut ranges = vec![MemoryRange { + start: 0x7faa_0000_0000, + end: u64::MAX, + file_offset: 0, + file_size: 0x1000, + perms: Perms { + read: true, + write: true, + execute: false, + }, + kind: RangeKind::File, + path: None, + name: None, + }]; + ranges.push(MemoryRange { + start: u64::MAX - 0x4, + end: u64::MAX, + file_offset: 0x1000, + file_size: 0x10, + perms: Perms { + read: true, + write: true, + execute: false, + }, + kind: RangeKind::File, + path: None, + name: None, + }); + let bytes = vec![0u8; 0x2000]; + let img = MappedImage::from_bytes(bytes, MemoryMap::from_ranges(ranges), 8); + let arenas = find_arenas(&img); + assert!(arenas.is_empty()); + } + + #[test] + fn walk_heap_excludes_top_chunk_consuming_entire_region() { + let mut b = ImageBuilder::new(); + base_heap(&mut b); // H..H_END + // Adjacent mapped range right after the heap: without the `>=` guard + // a walk would read the "next chunk" size at H_END+8 successfully and + // emit the top chunk as a spurious Allocated object. + add_heap(&mut b, H_END, 0x1000); + b.write_u64(H_END + 8, 0x30 | PREV_INUSE); + + place_chunk(&mut b, H, 0x20, PREV_INUSE, 0x41); // user H+0x10 + place_chunk(&mut b, H + 0x30, 0x20, PREV_INUSE, 0x42); // user H+0x40 + place_chunk(&mut b, H + 0x60, 0x20, PREV_INUSE, 0x43); // user H+0x70 + // The top chunk consumes exactly the remaining bytes of the region. + place_top(&mut b, H + 0x90, H_END); + + let img = b.build(); + let chunks = walk_heap(&img, H, H_END); + assert_eq!(chunks.len(), 3); + assert!(chunks.iter().all(|c| c.header != H + 0x90)); + assert_eq!(chunks[0].header, H); + assert_eq!(chunks[1].header, H + 0x30); + assert_eq!(chunks[2].header, H + 0x60); + + let inv = crate::carve(&img); + assert_eq!(inv.objects.len(), 3); + assert_eq!(inv.objects[0].addr, H + 0x10); + assert_eq!(inv.objects[1].addr, H + 0x40); + assert_eq!(inv.objects[2].addr, H + 0x70); + } + + #[test] + fn probe_walk_region_skips_heap_info_header() { + let mut b = ImageBuilder::new(); + base_heap(&mut b); // H..H_END + // heap_info-shaped 0x20-byte header: ar_ptr, prev, size, + // mprotect_size. Walking from offset 0 reads `prev` as the chunk size + // (0) and yields nothing; the probe must start at H+0x20. + b.write_u64(H, 0x7faa_0000_0100); + b.write_u64(H + 0x08, 0); + b.write_u64(H + 0x10, H_END - H); + b.write_u64(H + 0x18, H_END - H); + + place_chunk(&mut b, H + 0x20, 0x20, PREV_INUSE, 0x41); // user H+0x30 + place_chunk(&mut b, H + 0x50, 0x20, PREV_INUSE, 0x42); // user H+0x60 + place_chunk(&mut b, H + 0x80, 0x20, PREV_INUSE, 0x43); // user H+0x90 + place_top(&mut b, H + 0xb0, H_END); + + let img = b.build(); + let region = img.range_at(H).unwrap(); + let objs = probe_walk_region(&img, region, Some(0x7faa_0000_0100), 3, false); + assert_eq!(objs.len(), 3); + assert_eq!(objs[0].addr, H + 0x30); + assert_eq!(objs[0].arena, Some(0x7faa_0000_0100)); + assert_eq!(objs[1].addr, H + 0x60); + assert_eq!(objs[2].addr, H + 0x90); + assert!(objs.iter().all(|o| o.state == ObjectState::Allocated)); + } + + #[test] + fn carve_non_main_arena_walks_past_heap_info() { + const L: u64 = 0x7faa_0000_0000; + // A thread arena: its malloc_state lives in an ANON region (not libc + // data), which is how naksheap distinguishes non-main arenas. + const A: u64 = 0x7faa_1000_0000; + let mut b = ImageBuilder::new(); + add_libc(&mut b, L, 0x1000); + add_heap(&mut b, H, H_END - H); + add_heap(&mut b, A, 0x1000); + + // malloc_state sits right after the 0x20-byte heap_info (real layout). + let arena = A + 0x30; + let top = H + 0xb0; // top chunk header + // heap_info at the region start: ar_ptr -> the malloc_state in-region. + b.write_u64(A, arena); + b.write_u64(arena + ARENA_TOP_OFFSET, top); + b.write_u64(arena + ARENA_SYSTEM_MEM_OFFSET, H_END - H); + // Non-main arena: `next` points at another anon rw region, no self-loop. + b.write_u64(arena + ARENA_NEXT_OFFSET, H + 0x100); + + // heap_info-shaped blob at the region start. + b.write_u64(H, L + 0x100); + b.write_u64(H + 0x08, 0); + b.write_u64(H + 0x10, H_END - H); + b.write_u64(H + 0x18, H_END - H); + + place_chunk(&mut b, H + 0x20, 0x20, PREV_INUSE, 0x41); // user H+0x30 + place_chunk(&mut b, H + 0x50, 0x20, PREV_INUSE, 0x42); // user H+0x60 + place_chunk(&mut b, H + 0x80, 0x20, PREV_INUSE, 0x43); // user H+0x90 + place_top(&mut b, H + 0xb0, H_END); + + let img = b.build(); + let inv = crate::carve(&img); + assert_eq!(inv.arenas.len(), 1); + assert!(!inv.arenas[0].is_main); + assert_eq!(inv.objects.len(), 3); + assert_eq!(inv.objects[0].addr, H + 0x30); + assert_eq!(inv.objects[1].addr, H + 0x60); + assert_eq!(inv.objects[2].addr, H + 0x90); + } + + #[test] + fn find_arenas_discovers_arena_with_null_next() { + const L: u64 = 0x7faa_0000_0000; + let mut b = ImageBuilder::new(); + add_libc(&mut b, L, 0x1000); + add_heap(&mut b, H, 0x1000); + + let arena = L + 0x100; + let top = H + 0x40; // top chunk header + b.write_u64(arena + ARENA_TOP_OFFSET, top); + b.write_u64(arena + ARENA_SYSTEM_MEM_OFFSET, 0x1000); + // No self-loop: `next` reads as NULL. The +15 bonus plus a valid top + // header and plausible system_mem must clear the confidence threshold. + // The malloc_state sits in libc's file-backed data, so it is the main + // arena (main_arena is a static in libc .data). + b.write_u64(arena + ARENA_NEXT_OFFSET, 0); + + place_chunk(&mut b, H, 0x30, PREV_INUSE, 0x41); + place_top(&mut b, H + 0x40, H + 0x1000); + + let img = b.build(); + let arenas = find_arenas(&img); + assert_eq!(arenas.len(), 1); + assert_eq!(arenas[0].addr, arena); + assert_eq!(arenas[0].top, top); + assert_eq!(arenas[0].size, 0x1000); + assert!(arenas[0].is_main, "an arena in libc file-backed data is main_arena"); + } + + #[test] + fn reveal_safe_link_roundtrip() { + // glibc mangles free-list pointers as (pos >> 12) ^ ptr. + let pos = 0x7f00_0000_0120u64; + let ptr = 0x7f00_0000_0450u64; + assert_eq!(reveal_safe_link(pos, (pos >> 12) ^ ptr), ptr); + } + + #[test] + fn carve_finds_mmap_allocations() { + // A standalone anon rw- region whose chunk header has IS_MMAPPED is a + // large mmap-served allocation (glibc >= ~128 KiB), not a heap. + const M: u64 = 0x7f00_1000_0000; + let mut b = ImageBuilder::new(); + add_heap(&mut b, M, 0x101000); + b.write_u64(M, 0); // prev_size + b.write_u64(M + 8, 0x101000 | IS_MMAPPED); // size | IS_MMAPPED + b.write(M + 0x10, &vec![0x42u8; 0x1000]); + + let img = b.build(); + let inv = crate::carve(&img); + assert_eq!(inv.objects.len(), 1); + assert_eq!(inv.objects[0].addr, M + 0x10); + assert_eq!(inv.objects[0].size, 0x100FF0); + assert_eq!(inv.objects[0].state, ObjectState::Mmap); + } + + #[test] + fn carve_marks_tcache_freed_chunks() { + // A heap with a tcache_perthread_struct (0x280 usable) whose entries[] + // list a freed chunk as a PLAIN user pointer. The freed chunk's + // successor keeps PREV_INUSE set (tcache does not clear it), so only + // the tcache cross-reference can reveal it as freed. + const L: u64 = 0x7faa_0000_0000; + let mut b = ImageBuilder::new(); + add_libc(&mut b, L, 0x1000); + add_heap(&mut b, H, H_END - H); + + let arena = L + 0x100; + let top = H + 0x2b0; // top chunk header after the two chunks below + b.write_u64(arena + ARENA_TOP_OFFSET, top); + b.write_u64(arena + ARENA_NEXT_OFFSET, arena); + b.write_u64(arena + ARENA_SYSTEM_MEM_OFFSET, H_END - H); + + // tcache struct: 0x280 usable (chunk 0x290) at H. + place_chunk(&mut b, H, 0x280, PREV_INUSE, 0); + // counts[0] = 1 (one freed 0x20-size chunk in bin 0). + b.write_u64(H + 0x10, 1); + // entries[0] (at user+0x80) = plain user pointer of the freed chunk. + let freed_user = H + 0x2a0; // header H+0x290, user H+0x2a0 + b.write_u64(H + 0x10 + 0x80, freed_user); + + // Freed chunk: header H+0x290 (right after tcache), chunk 0x20, next + // chunk's PREV_INUSE SET (tcache behavior) so the header heuristic + // alone says "allocated". + place_chunk(&mut b, H + 0x290, 0x10, PREV_INUSE, 0xCC); + // Top chunk after it. + place_top(&mut b, H + 0x2b0, H_END); + + let img = b.build(); + let inv = crate::carve(&img); + assert_eq!(inv.objects.len(), 2, "tcache struct + freed chunk"); + let freed = inv + .objects + .iter() + .find(|o| o.addr == freed_user) + .expect("freed chunk carved"); + assert_eq!(freed.state, ObjectState::Freed, "tcache cross-ref must mark freed"); + } + + #[test] + fn carve_marks_fastbin_freed_chunks() { + // A chunk freed into a fastbin keeps the successor's PREV_INUSE set, + // so only the arena fastbinsY cross-reference reveals it as freed. + // Fastbin chains use PLAIN pointers (safe-linking is tcache-only). + const L: u64 = 0x7faa_0000_0000; + let mut b = ImageBuilder::new(); + add_libc(&mut b, L, 0x1000); + add_heap(&mut b, H, H_END - H); + + let arena = L + 0x100; + let top = H + 0x2d0; // after tcache + 2 chunks + b.write_u64(arena + ARENA_TOP_OFFSET, top); + b.write_u64(arena + ARENA_NEXT_OFFSET, arena); + b.write_u64(arena + ARENA_SYSTEM_MEM_OFFSET, H_END - H); + // fastbinsY[0] (0x20 size class) -> freed chunk HEADER H+0x290; that + // chunk's mangled fd (at its user H+0x2a0) -> header H+0x2b0; fd=0 tail. + let fc1_header = H + 0x290; + let fc1_user = fc1_header + 0x10; + let fc2_header = H + 0x2b0; + let fc2_user = fc2_header + 0x10; + b.write_u64(arena + 0x10, fc1_header); // fastbinsY[0] stores a header + // tcache struct (counts all zero -> not a tcache candidate here). + place_chunk(&mut b, H, 0x280, PREV_INUSE, 0); + // Two freed chunks (headers with PREV_INUSE set so the header + // heuristic alone would say "allocated"). + place_chunk(&mut b, fc1_header, 0x10, PREV_INUSE, 0xCC); + place_chunk(&mut b, fc2_header, 0x10, PREV_INUSE, 0xCC); + // Fastbin fd chain: safe-link mangled, stored at each chunk's user. + b.write_u64(fc1_user, reveal_safe_link(fc1_user, fc2_header)); + b.write_u64(fc2_user, 0); + place_top(&mut b, H + 0x2d0, H_END); + + let img = b.build(); + let inv = crate::carve(&img); + let f1 = inv.objects.iter().find(|o| o.addr == fc1_user).expect("fc1"); + let f2 = inv.objects.iter().find(|o| o.addr == fc2_user).expect("fc2"); + assert_eq!(f1.state, ObjectState::Freed, "fastbin head must be freed"); + assert_eq!(f2.state, ObjectState::Freed, "fastbin fd-chain link must be freed"); + assert_eq!(f1.freed_reason, Some(crate::FreedReason::Fastbin)); + } + + #[test] + fn arena_offset_contract() { + // These offsets must match the glibc `malloc_state` layout used by the + // fixture builder in naksheap-testkit. The two crates cannot depend on + // each other, so this documents the contract. + assert_eq!(ARENA_TOP_OFFSET, 0x60); + assert_eq!(ARENA_NEXT_OFFSET, 0x870); + assert_eq!(ARENA_SYSTEM_MEM_OFFSET, 0x888); + } +} diff --git a/crates/naksheap-allocator-heuristics/src/lib.rs b/crates/naksheap-allocator-heuristics/src/lib.rs new file mode 100644 index 0000000..0384ffe --- /dev/null +++ b/crates/naksheap-allocator-heuristics/src/lib.rs @@ -0,0 +1,89 @@ +//! naksheap-allocator-heuristics +//! +//! Allocator-aware heap carving over a parsed core dump address space. +//! +//! Given a parsed [`AddressSpace`] (an ELF core or minidump image), this crate +//! recovers the heap objects stored by glibc's ptmalloc allocator: their +//! addresses, sizes, and allocated/freed state. It does so by locating the +//! arenas (primarily `main_arena` via its self-looping `next` pointer) and +//! walking the chunk headers that ptmalloc writes inline in anonymous writable +//! heap mappings. +//! +//! The implementation is intentionally heuristic: it never panics on garbage +//! and gracefully degrades to whatever chunk boundaries it can validate. + +pub mod glibc; + +use naksheap_core_parse::AddressSpace; + +/// Allocator-level state of a carved heap object. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ObjectState { + /// The chunk is live (its successor has `PREV_INUSE` set). + Allocated, + /// The chunk was returned to the allocator (successor's `PREV_INUSE` clear). + Freed, + /// The chunk was served by `mmap` (`IS_MMAPPED` flag). + Mmap, + /// State could not be determined. + Unknown, +} + +/// Why a chunk was classified as [`ObjectState::Freed`]. Carried into the +/// object graph so evidence lines state the actual mechanism. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FreedReason { + /// The successor chunk's `PREV_INUSE` bit is clear (bin/unsorted free). + PrevInuseClear, + /// The chunk's user address appears in the tcache free lists. + Tcache, + /// The chunk's user address appears in an arena fastbin chain. + Fastbin, +} + +/// A single carved heap object (the user region of a ptmalloc chunk). +#[derive(Debug, Clone, serde::Serialize)] +pub struct Object { + /// Address of the object's user data (chunk header + `0x10`). + pub addr: u64, + /// Usable size of the object in bytes (`(size & !0xF) - 0x10`). + pub size: u64, + /// Allocator state of the object. + pub state: ObjectState, + /// Address of the arena that governs this object, when known. + pub arena: Option, + /// Address of the chunk header (`addr - 0x10`). + pub chunk_header: u64, + /// Why the object was marked freed (best-effort; `None` when allocated). + pub freed_reason: Option, +} + +/// A discovered ptmalloc arena. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ArenaInfo { + /// Address of the `malloc_state` structure. + pub addr: u64, + /// Size of the heap region the arena's `top` points into. + pub size: u64, + /// Value of the arena's `top` field (points into an anonymous rw- heap). + pub top: u64, + /// `true` when this is `main_arena` (its `next` field self-loops). + pub is_main: bool, +} + +/// Result of carving an address space: arenas plus carved heap objects. +#[derive(Debug, Default, Clone, serde::Serialize)] +pub struct HeapInventory { + pub arenas: Vec, + pub objects: Vec, +} + +/// Carves heap objects out of `image` using glibc ptmalloc heuristics. +/// +/// Never panics: if the image carries no parseable ptmalloc metadata, an empty +/// [`HeapInventory`] is returned. +pub fn carve(image: &dyn AddressSpace) -> HeapInventory { + glibc::carve(image) +} diff --git a/crates/naksheap-cli/Cargo.toml b/crates/naksheap-cli/Cargo.toml new file mode 100644 index 0000000..95ee471 --- /dev/null +++ b/crates/naksheap-cli/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "naksheap-cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "naksheap command-line interface: parse, carve, scan, infer, visualize" + +[[bin]] +name = "naksheap" +path = "src/main.rs" + +[dependencies] +naksheap-core-parse = { path = "../naksheap-core-parse" } +naksheap-allocator-heuristics = { path = "../naksheap-allocator-heuristics" } +naksheap-pointer-scan = { path = "../naksheap-pointer-scan" } +naksheap-inference = { path = "../naksheap-inference" } +naksheap-viz = { path = "../naksheap-viz" } +naksheap-testkit = { path = "../naksheap-testkit" } +clap.workspace = true +serde_json.workspace = true +anyhow.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/naksheap-cli/src/main.rs b/crates/naksheap-cli/src/main.rs new file mode 100644 index 0000000..b6ca73b --- /dev/null +++ b/crates/naksheap-cli/src/main.rs @@ -0,0 +1,362 @@ +//! naksheap-cli: parse, carve, scan, infer, and visualize heap object graphs +//! from core dumps. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use clap::{Parser, Subcommand}; +use naksheap_allocator_heuristics::{carve, ObjectState}; +use naksheap_core_parse::{open, AddressSpace, CoreFile, CoreFormat, Perms, RangeKind}; + +#[derive(Parser)] +#[command(name = "naksheap", version, about = "heap archaeology for stripped binaries")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Inspect a core dump: format, process, threads, memory map. + Info { core: PathBuf }, + /// Print the memory map (ranges, permissions, heap markers). + Maps { core: PathBuf }, + /// Carve the heap and print the object inventory. + Heap { core: PathBuf }, + /// Build the object graph and export it (ascii tree / dot / html / json). + Graph { + core: PathBuf, + /// Maximum recursion depth for the ASCII tree. + #[arg(long, default_value_t = 8)] + max_depth: usize, + /// Emit Graphviz DOT (stdout, or --out/graph.dot). + #[arg(long)] + dot: bool, + /// Write an HTML report (--out/report.html; embeds the graph JSON, + /// loads cytoscape.js from a CDN). + #[arg(long)] + html: bool, + /// Emit graph JSON (stdout, or --out/graph.json). + #[arg(long)] + json: bool, + /// Output directory for file exports. + #[arg(long, value_name = "DIR")] + out: Option, + }, + /// Run the full pipeline on a synthetic fixture (smoke test). + SelfTest, +} + +fn main() { + // Piping output into a consumer that closes early (e.g. `naksheap graph + // core | head`) raises SIGPIPE on the write. Rust converts that into an + // `io::ErrorKind::BrokenPipe` and `println!` panics; a CLI must instead + // terminate quietly, and because the consumer intentionally stopped + // reading (not an error), exit 0 rather than the conventional 141 so + // `set -o pipefail` scripts don't treat it as a failure. + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let msg = info + .payload() + .downcast_ref::() + .map(|s| s.as_str()) + .or_else(|| info.payload().downcast_ref::<&str>().copied()) + .unwrap_or(""); + if msg.starts_with("failed printing to ") && msg.contains("Broken pipe") { + std::process::exit(0); + } + default_hook(info); + })); + + if let Err(err) = run() { + // `err` is already a single, fully-formatted message (e.g. a + // `naksheap_core_parse::Error` whose display embeds the OS error). + // Printing with `{}` avoids anyhow's source chain, so the OS message + // appears exactly once. + eprintln!("error: {err}"); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + let cli = Cli::parse(); + match cli.command { + Command::Info { core } => cmd_info(&core), + Command::Maps { core } => cmd_maps(&core), + Command::Heap { core } => cmd_heap(&core), + Command::Graph { + core, + max_depth, + dot, + html, + json, + out, + } => cmd_graph(&core, max_depth, dot, html, json, out.as_deref()), + Command::SelfTest => cmd_self_test(), + } +} + +/// Warns (once) when the dump is a truncated snapshot: memory ranges extend +/// past the end of the file, so carved results are silently partial. +fn warn_truncated(core: &naksheap_core_parse::CoreFile) { + if core.image.is_truncated() { + eprintln!( + "warning: core file appears truncated (memory ranges extend past the \ + end of the file); results may be incomplete" + ); + } +} + +fn cmd_info(path: &Path) -> Result<()> { + let core = open(path)?; + warn_truncated(&core); + + println!("format: {}", format_name(core.format)); + println!("process: {}", core.process_name.as_deref().unwrap_or("?")); + println!("command line: {}", core.command_line.as_deref().unwrap_or("?")); + println!("exec path: {}", core.exec_path.as_deref().unwrap_or("?")); + println!("pointer width: {} bytes", core.image.pointer_width()); + println!("threads: {}", core.threads.len()); + print_maps(&core); + Ok(()) +} + +fn cmd_maps(path: &Path) -> Result<()> { + let core = open(path)?; + print_maps(&core); + Ok(()) +} + +/// Prints the memory map: ranges, permissions, kind, and the ranges that +/// contain a detected arena's `top` marked `[heap]`. +fn print_maps(core: &CoreFile) { + let heaps = heap_range_starts(core); + println!( + "memory map ({} ranges, {} bytes total):", + core.maps.len(), + core.maps.total_bytes() + ); + for r in core.maps.iter() { + let name = if heaps.contains(&r.start) { + "[heap]".to_string() + } else { + r.name.clone().unwrap_or_else(|| "?".to_string()) + }; + println!( + " {:#018x}-{:#018x} {} {} {}", + r.start, + r.end, + perms_str(&r.perms), + kind_str(r.kind), + name + ); + } +} + +/// Starts of map ranges that contain a detected arena's `top` (the heap). +fn heap_range_starts(core: &CoreFile) -> HashSet { + let inventory = carve(&core.image); + inventory + .arenas + .iter() + .filter_map(|a| core.maps.range_at(a.top).map(|r| r.start)) + .collect() +} + +fn cmd_heap(path: &Path) -> Result<()> { + let core = open(path)?; + warn_truncated(&core); + let inventory = carve(&core.image); + let scan = naksheap_pointer_scan::scan( + &core.image, + &inventory, + &core.threads, + &naksheap_pointer_scan::ScanOptions::default(), + ); + + println!("arenas ({}):", inventory.arenas.len()); + for a in &inventory.arenas { + println!( + " 0x{:x} top=0x{:x} size=0x{:x} main={}", + a.addr, a.top, a.size, a.is_main + ); + } + + let mut objects = inventory.objects.clone(); + objects.sort_by_key(|o| o.addr); + println!("objects ({}):", objects.len()); + println!(" {:<18} {:>10} {:<10} arena", "addr", "size", "state"); + for o in &objects { + let arena = o.arena.map(|a| format!("0x{a:x}")).unwrap_or_else(|| "-".into()); + println!(" 0x{:x} {:#010x} {:<10} {}", o.addr, o.size, state_str(o.state), arena); + } + + let allocated = objects.iter().filter(|o| o.state == ObjectState::Allocated).count(); + let freed = objects.iter().filter(|o| o.state == ObjectState::Freed).count(); + let mmap = objects.iter().filter(|o| o.state == ObjectState::Mmap).count(); + let unknown = objects.len() - allocated - freed - mmap; + println!( + "counts: {} total, {} allocated, {} freed, {} mmap, {} unknown; {} stray pointers", + objects.len(), + allocated, + freed, + mmap, + unknown, + scan.stray_pointers.len() + ); + Ok(()) +} + +fn cmd_graph( + path: &Path, + max_depth: usize, + want_dot: bool, + want_html: bool, + want_json: bool, + out: Option<&Path>, +) -> Result<()> { + let core = open(path)?; + warn_truncated(&core); + let inventory = carve(&core.image); + let scan = naksheap_pointer_scan::scan( + &core.image, + &inventory, + &core.threads, + &naksheap_pointer_scan::ScanOptions::default(), + ); + let graph = naksheap_inference::build_graph(&core.image, &inventory, &scan); + + if want_html { + let dir = out.map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("naksheap-report")); + std::fs::create_dir_all(&dir).context("failed to create output dir")?; + let html = naksheap_viz::to_html(&graph); + std::fs::write(dir.join("report.html"), html).context("failed to write report.html")?; + println!("wrote {}", dir.join("report.html").display()); + } + if want_dot { + let dot = naksheap_viz::to_dot(&graph); + match out { + Some(dir) => { + std::fs::create_dir_all(dir).context("failed to create output dir")?; + std::fs::write(dir.join("graph.dot"), dot).context("failed to write graph.dot")?; + println!("wrote {}", dir.join("graph.dot").display()); + } + None => print!("{dot}"), + } + } + if want_json { + let value = naksheap_inference::graph_to_json(&graph); + let json = serde_json::to_string_pretty(&value).context("failed to serialize graph JSON")?; + match out { + Some(dir) => { + std::fs::create_dir_all(dir).context("failed to create output dir")?; + std::fs::write(dir.join("graph.json"), json).context("failed to write graph.json")?; + println!("wrote {}", dir.join("graph.json").display()); + } + None => println!("{json}"), + } + } + if !want_dot && !want_html && !want_json { + println!("{}", naksheap_viz::ascii_tree(&graph, max_depth)); + } + Ok(()) +} + +fn cmd_self_test() -> Result<()> { + let spec = naksheap_testkit::CoreSpec::default(); + let fixture = spec.build().context("failed to build self-test fixture")?; + let parsed = + naksheap_core_parse::elf::parse_elf_bytes(&fixture.bytes).context("failed to parse fixture")?; + let image = naksheap_core_parse::MappedImage::from_bytes( + fixture.bytes.clone(), + parsed.map.clone(), + parsed.pointer_width, + ); + let inventory = carve(&image); + let scan = naksheap_pointer_scan::scan( + &image, + &inventory, + &parsed.threads, + &naksheap_pointer_scan::ScanOptions::default(), + ); + let graph = naksheap_inference::build_graph(&image, &inventory, &scan); + + println!("naksheap self-test"); + println!( + " parse: ok (format={}, {} threads, pointer width {} bytes)", + format_name(parsed.format), + parsed.threads.len(), + parsed.pointer_width + ); + println!( + " carve: {} objects, {} arenas", + inventory.objects.len(), + inventory.arenas.len() + ); + println!( + " scan: {} edges, {} roots, {} stray pointers", + scan.edges.len(), + scan.roots.len(), + scan.stray_pointers.len() + ); + println!( + " graph: {} nodes, {} edges, {} root-reachable", + graph.nodes.len(), + graph.edges.len(), + graph.stats.root_reachable + ); + + let mut missing: Vec<&str> = Vec::new(); + for m in &fixture.manifest.objects { + if !inventory.objects.iter().any(|o| o.addr == m.addr) { + missing.push(m.label.as_str()); + } + } + if !missing.is_empty() { + bail!( + "self-test failed: {} manifest objects missing from carve: {:?}", + missing.len(), + missing + ); + } + println!( + " fidelity: all {} manifest objects recovered", + fixture.manifest.objects.len() + ); + println!("self-test OK"); + Ok(()) +} + +fn format_name(format: CoreFormat) -> &'static str { + match format { + CoreFormat::Elf64 => "elf64", + CoreFormat::Elf32 => "elf32", + CoreFormat::Minidump => "minidump", + } +} + +fn perms_str(p: &Perms) -> String { + let mut s = String::with_capacity(3); + s.push(if p.read { 'r' } else { '-' }); + s.push(if p.write { 'w' } else { '-' }); + s.push(if p.execute { 'x' } else { '-' }); + s +} + +fn kind_str(k: RangeKind) -> &'static str { + match k { + RangeKind::File => "file", + RangeKind::Anon => "anon", + RangeKind::Unknown => "unknown", + } +} + +fn state_str(s: ObjectState) -> &'static str { + match s { + ObjectState::Allocated => "allocated", + ObjectState::Freed => "freed", + ObjectState::Mmap => "mmap", + ObjectState::Unknown => "unknown", + } +} diff --git a/crates/naksheap-cli/tests/cli.rs b/crates/naksheap-cli/tests/cli.rs new file mode 100644 index 0000000..08eeea3 --- /dev/null +++ b/crates/naksheap-cli/tests/cli.rs @@ -0,0 +1,136 @@ +//! Integration tests for the `naksheap` CLI binary. + +use std::path::Path; +use std::process::{Command, Output}; + +use naksheap_testkit::CoreSpec; + +/// Runs the `naksheap` binary with `args`, returning the captured output. +fn run(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_naksheap")) + .args(args) + .output() + .expect("failed to spawn naksheap binary") +} + +fn run_ok(args: &[&str]) -> String { + let out = run(args); + assert!( + out.status.success(), + "`naksheap {}` failed: {}", + args.join(" "), + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).into_owned() +} + +/// Builds the default fixture and writes the core dump into `dir`. +fn write_fixture(dir: &Path) -> (std::path::PathBuf, naksheap_testkit::Fixture) { + let fixture = CoreSpec::default().build().expect("build fixture"); + let core = dir.join("core.elf"); + fixture.write(&core).expect("write fixture core"); + (core, fixture) +} + +#[test] +fn info_lists_process_and_heap_range() { + let dir = tempfile::tempdir().expect("tempdir"); + let (core, fixture) = write_fixture(dir.path()); + let stdout = run_ok(&["info", core.to_str().unwrap()]); + + assert!(stdout.contains("format: elf64"), "stdout: {stdout}"); + assert!(stdout.contains(&fixture.manifest.process_name), "stdout: {stdout}"); + assert!(stdout.contains(&fixture.manifest.command_line), "stdout: {stdout}"); + assert!(stdout.contains(&fixture.manifest.exec_path), "stdout: {stdout}"); + assert!(stdout.contains("[heap]"), "heap range should be marked: {stdout}"); + assert!(stdout.contains("threads: 1"), "stdout: {stdout}"); +} + +#[test] +fn heap_prints_expected_objects() { + let dir = tempfile::tempdir().expect("tempdir"); + let (core, fixture) = write_fixture(dir.path()); + let stdout = run_ok(&["heap", core.to_str().unwrap()]); + + assert!(stdout.contains("arenas (1):"), "stdout: {stdout}"); + assert!(stdout.contains("counts:"), "stdout: {stdout}"); + for obj in &fixture.manifest.objects { + assert!( + stdout.contains(&format!("0x{:x}", obj.addr)), + "expected object 0x{:x} ({}) in stdout:\n{stdout}", + obj.addr, + obj.label + ); + } +} + +#[test] +fn maps_lists_ranges_with_heap_marker() { + let dir = tempfile::tempdir().expect("tempdir"); + let (core, _fixture) = write_fixture(dir.path()); + let stdout = run_ok(&["maps", core.to_str().unwrap()]); + + assert!(stdout.contains("memory map"), "stdout: {stdout}"); + assert!(stdout.contains(" r-x "), "stdout: {stdout}"); + assert!(stdout.contains(" rw- "), "stdout: {stdout}"); + assert!(stdout.contains("[heap]"), "heap range should be marked: {stdout}"); +} + +#[test] +fn graph_json_is_valid() { + let dir = tempfile::tempdir().expect("tempdir"); + let (core, _fixture) = write_fixture(dir.path()); + let stdout = run_ok(&["graph", core.to_str().unwrap(), "--json"]); + + let value: serde_json::Value = + serde_json::from_str(&stdout).expect("graph --json must print valid JSON"); + assert!(value.get("stats").is_some(), "missing stats: {value}"); + assert!(value.get("nodes").is_some(), "missing nodes: {value}"); + assert!(value.get("edges").is_some(), "missing edges: {value}"); + assert!(value["stats"]["total_objects"].as_u64().unwrap() >= 5, "stdout: {value}"); +} + +#[test] +fn graph_ascii_tree_default() { + let dir = tempfile::tempdir().expect("tempdir"); + let (core, _fixture) = write_fixture(dir.path()); + let stdout = run_ok(&["graph", core.to_str().unwrap()]); + + assert!(stdout.contains("naksheap object graph"), "stdout: {stdout}"); + assert!(stdout.contains("probable struct"), "stdout: {stdout}"); + assert!(stdout.contains("+0x00 pointer"), "stdout: {stdout}"); +} + +#[test] +fn graph_dot_and_html_write_files() { + let dir = tempfile::tempdir().expect("tempdir"); + let (core, _fixture) = write_fixture(dir.path()); + let out_dir = dir.path().join("out"); + let stdout = run_ok(&[ + "graph", + core.to_str().unwrap(), + "--dot", + "--html", + "--json", + "--out", + out_dir.to_str().unwrap(), + ]); + + assert!(stdout.contains("graph.dot"), "stdout: {stdout}"); + assert!(stdout.contains("report.html"), "stdout: {stdout}"); + assert!(stdout.contains("graph.json"), "stdout: {stdout}"); + assert!(out_dir.join("graph.dot").is_file()); + assert!(out_dir.join("report.html").is_file()); + assert!(out_dir.join("graph.json").is_file()); + + let dot = std::fs::read_to_string(out_dir.join("graph.dot")).expect("read graph.dot"); + assert!(dot.starts_with("digraph"), "dot: {dot}"); + let html = std::fs::read_to_string(out_dir.join("report.html")).expect("read report.html"); + assert!(html.contains("cytoscape"), "html: {html}"); +} + +#[test] +fn self_test_passes() { + let stdout = run_ok(&["self-test"]); + assert!(stdout.contains("self-test OK"), "stdout: {stdout}"); +} diff --git a/crates/naksheap-core-parse/Cargo.toml b/crates/naksheap-core-parse/Cargo.toml new file mode 100644 index 0000000..8ec9136 --- /dev/null +++ b/crates/naksheap-core-parse/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "naksheap-core-parse" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Core-dump / memory-image parsing: ELF core, minidump, memory maps, address space access" + +[dependencies] +object.workspace = true +memmap2.workspace = true +thiserror.workspace = true +serde.workspace = true diff --git a/crates/naksheap-core-parse/src/elf.rs b/crates/naksheap-core-parse/src/elf.rs new file mode 100644 index 0000000..158f078 --- /dev/null +++ b/crates/naksheap-core-parse/src/elf.rs @@ -0,0 +1,294 @@ +use std::path::Path; + +use object::elf::{FileHeader32, FileHeader64}; +use object::read::elf::{FileHeader, ProgramHeader}; +use object::LittleEndian; + +use crate::error::{Error, Result}; +use crate::image::{CoreFile, CoreFormat, MappedImage, ThreadContext}; +use crate::maps::{MemoryMap, MemoryRange, Perms, RangeKind}; +use crate::notes::{ + parse_notes, parse_nt_file, parse_prpsinfo, parse_prstatus, NtFileEntry, Reader, NT_AUXV, + NT_FILE, NT_PRPSINFO, NT_PRSTATUS, +}; + +pub struct ParsedCore { + pub map: MemoryMap, + pub threads: Vec, + pub process_name: Option, + pub command_line: Option, + pub exec_path: Option, + pub pointer_width: u8, + pub format: CoreFormat, +} + +/// Parses an ELF core dump file into a `CoreFile`. +pub fn parse_elf_core(path: &Path) -> Result { + let file = std::fs::File::open(path)?; + // SAFETY: read-only mapping of a file we never mutate. + let mmap = unsafe { memmap2::Mmap::map(&file)? }; + let data: &[u8] = &mmap; + let parsed = parse_elf_bytes(data)?; + let image = MappedImage::from_mmap(mmap, parsed.map.clone(), parsed.pointer_width); + Ok(CoreFile { + format: parsed.format, + image, + threads: parsed.threads, + process_name: parsed.process_name, + command_line: parsed.command_line, + exec_path: parsed.exec_path, + maps: parsed.map, + }) +} + +/// Parses ELF core bytes in memory (also used by the testkit on synthetic cores). +pub fn parse_elf_bytes(data: &[u8]) -> Result { + if data.len() < 16 { + return Err(Error::BadNote("ELF file too short for header".into())); + } + let class = data[4]; + let data_enc = data[5]; + if data_enc == 2 { + // All memory reads in this crate are little-endian; a big-endian + // image would be silently mis-decoded. Reject rather than lie. + return Err(Error::UnsupportedArch( + "big-endian ELF cores are not supported yet".to_string(), + )); + } + if data_enc != 1 { + return Err(Error::UnsupportedArch(format!( + "unsupported ELF data encoding byte {data_enc}" + ))); + } + let ptr_width = match class { + 2 => 8u8, + 1 => 4u8, + _ => return Err(Error::UnsupportedArch(format!("ELF class byte {class}"))), + }; + let le = true; + let format = if ptr_width == 8 { CoreFormat::Elf64 } else { CoreFormat::Elf32 }; + match (class, data_enc) { + (2, 1) => parse_arch::>(data, ptr_width, le, format), + (1, 1) => parse_arch::>(data, ptr_width, le, format), + _ => unreachable!("class/data_enc already validated"), + } +} + +fn parse_arch( + data: &[u8], + ptr_width: u8, + le: bool, + format: CoreFormat, +) -> Result +where + T::Endian: Copy, +{ + let header = T::parse(data)?; + let endian = header.endian()?; + if header.e_type(endian) != object::elf::ET_CORE { + return Err(Error::BadNote(format!( + "ELF e_type {:?} is not ET_CORE (4); only core dumps are supported", + header.e_type(endian) + ))); + } + let machine = header.e_machine(endian).0; + let (reg_count, ip_idx, sp_idx) = reg_layout(machine, ptr_width); + let phs = header.program_headers(endian, data)?; + + // --- PT_LOAD ranges (authoritative for reading memory) --- + let mut ranges: Vec = Vec::new(); + let mut note_segments: Vec<&[u8]> = Vec::new(); + for ph in phs { + if ph.p_type(endian) == object::elf::PT_LOAD { + let vaddr: u64 = ph.p_vaddr(endian).into(); + let filesz: u64 = ph.p_filesz(endian).into(); + let memsz: u64 = ph.p_memsz(endian).into(); + if memsz == 0 { + continue; + } + ranges.push(MemoryRange { + start: vaddr, + end: vaddr.saturating_add(memsz), + file_offset: ph.p_offset(endian).into(), + file_size: filesz.min(memsz), + perms: Perms::from_elf_flags(ph.p_flags(endian).0), + kind: RangeKind::Anon, + path: None, + name: None, + }); + } else if ph.p_type(endian) == object::elf::PT_NOTE { + let off: u64 = ph.p_offset(endian).into(); + let sz: u64 = ph.p_filesz(endian).into(); + // Bounds-check with checked arithmetic: a crafted PT_NOTE with a + // huge p_offset/p_filesz must not overflow usize or panic on a + // slice index. + let (Some(off), Some(sz)) = (usize::try_from(off).ok(), usize::try_from(sz).ok()) + else { + continue; + }; + if let Some(end) = off.checked_add(sz) { + if end <= data.len() { + note_segments.push(&data[off..end]); + } + } + } + } + + let mut threads: Vec = Vec::new(); + let mut process_name = None; + let mut command_line = None; + let mut file_entries: Vec = Vec::new(); + let mut auxv: Vec = Vec::new(); + + // Bound the number of threads we materialize so a crafted core with tens + // of thousands of NT_PRSTATUS notes cannot blow up stack scanning later. + const MAX_THREADS: usize = 4096; + + for seg in note_segments { + for note in parse_notes(seg, le)? { + match note.n_type { + NT_PRSTATUS + if reg_count > 0 && threads.len() < MAX_THREADS => { + if let Ok(ps) = + parse_prstatus(note.desc, le, ptr_width, reg_count, ip_idx, sp_idx) + { + threads.push(ThreadContext { + tid: ps.tid, + reg_words: ps.regs, + ip: ps.ip, + sp: ps.sp, + }); + } + } + NT_PRPSINFO => { + let (n, c) = parse_prpsinfo(note.desc, le, ptr_width); + process_name = process_name.or(n); + command_line = command_line.or(c); + } + NT_FILE => { + if let Ok(files) = parse_nt_file(note.desc, le, ptr_width) { + file_entries = files; + } + } + NT_AUXV => auxv.extend(decode_auxv(note.desc, le, ptr_width)), + _ => {} + } + } + } + // --- Overlay file-backing info from NT_FILE --- + for r in ranges.iter_mut() { + if let Some(e) = best_overlap(r, &file_entries) { + r.kind = RangeKind::File; + r.path = Some(e.path.clone()); + r.name = e.path.rsplit('/').next().map(|s| s.to_string()); + } else { + r.name = Some( + if r.perms.write && !r.perms.execute { + "[anon rw-]".to_string() + } else if r.perms.execute { + "[anon r-x]".to_string() + } else { + "[anon --]".to_string() + }, + ); + } + } + + let map = MemoryMap::from_ranges(ranges); + // Best-effort: pick the executable from NT_FILE as the "main executable" hint. + // (Full AT_EXECFN resolution requires reading the stack image; deferred.) + let exec_path = guess_executable(&file_entries); + Ok(ParsedCore { + map, + threads, + process_name, + command_line, + exec_path, + pointer_width: ptr_width, + format, + }) +} + +/// Heuristic executable detection from NT_FILE: the first entry whose path is +/// not a well-known library. Only used as an informational hint. +fn guess_executable(entries: &[NtFileEntry]) -> Option { + for e in entries { + let base = e.path.rsplit('/').next().unwrap_or(&e.path); + let base = base.to_ascii_lowercase(); + let is_lib = base.starts_with("lib") && base.ends_with(".so") + || base == "ld-linux-x86-64.so.2" + || base == "linux-vdso.so.1" + || base.contains(".so."); + if !is_lib && !base.is_empty() { + return Some(e.path.clone()); + } + } + None +} + +fn decode_auxv(desc: &[u8], le: bool, ptr_bytes: u8) -> Vec { + let mut r = Reader::new(desc, le); + let mut out = Vec::new(); + while let (Ok(t), Ok(v)) = (r.word(ptr_bytes), r.word(ptr_bytes)) { + out.push(t); + out.push(v); + if t == 0 { + break; + } + } + out +} + +fn best_overlap<'a>(r: &MemoryRange, entries: &'a [NtFileEntry]) -> Option<&'a NtFileEntry> { + let mut best: Option<(u64, &NtFileEntry)> = None; + for e in entries { + let lo = r.start.max(e.start); + let hi = r.end.min(e.end); + if hi > lo { + let overlap = hi - lo; + if best.as_ref().is_none_or(|(b, _)| overlap > *b) { + best = Some((overlap, e)); + } + } + } + best.map(|(_, e)| e) +} + +/// Register-array layout per machine for NT_PRSTATUS parsing. +/// x86_64: user_regs_struct (27), rip=16, rsp=19. +/// x86: elf_gregset_t (17), eip=12, esp=15 (UESP; index 16 is SS). +/// aarch64: user_pt_regs (34), pc=32, sp=31. +/// Others: (0,0,0) disables register extraction. +fn reg_layout(machine: u16, ptr_width: u8) -> (usize, usize, usize) { + match (ptr_width, machine) { + (8, 62) => (27, 16, 19), // EM_X86_64 + (8, 183) => (34, 32, 31), // EM_AARCH64 + (4, 3) => (17, 12, 15), // EM_386 + _ => (0, 0, 0), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auxv_decode_stops_at_null() { + let mut v = Vec::new(); + for (t, x) in [(9u64, 0x7fff1234u64), (6, 0x7fff1000), (0, 0)] { + v.extend_from_slice(&t.to_le_bytes()); + v.extend_from_slice(&x.to_le_bytes()); + } + let out = decode_auxv(&v, true, 8); + assert_eq!(out, vec![9, 0x7fff1234, 6, 0x7fff1000, 0, 0]); + } + + #[test] + fn executable_hint_skips_libs() { + let entries = vec![ + NtFileEntry { start: 0x7f, end: 0x8, file_offset: 0, path: "/lib/x86_64-linux-gnu/libc.so.6".into() }, + NtFileEntry { start: 0x40, end: 0x41, file_offset: 0, path: "/opt/app/server".into() }, + ]; + assert_eq!(guess_executable(&entries), Some("/opt/app/server".to_string())); + } +} diff --git a/crates/naksheap-core-parse/src/error.rs b/crates/naksheap-core-parse/src/error.rs new file mode 100644 index 0000000..a532697 --- /dev/null +++ b/crates/naksheap-core-parse/src/error.rs @@ -0,0 +1,30 @@ +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum Error { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("file is not a recognized core dump or memory image (bad magic)")] + UnsupportedFormat, + + #[error("ELF parse error: {0}")] + Elf(#[from] object::Error), + + #[error("malformed core note: {0}")] + BadNote(String), + + #[error("unsupported pointer width or architecture: {0}")] + UnsupportedArch(String), + + #[error("address {0:#x} not mapped (or absent from dump)")] + Unmapped(u64), + + #[error("truncated note descriptor: {0}")] + Truncated(String), + + #[error("invalid argument: {0}")] + Invalid(String), +} diff --git a/crates/naksheap-core-parse/src/image.rs b/crates/naksheap-core-parse/src/image.rs new file mode 100644 index 0000000..65107cb --- /dev/null +++ b/crates/naksheap-core-parse/src/image.rs @@ -0,0 +1,210 @@ +use std::path::Path; + +use crate::error::{Error, Result}; +use crate::maps::{MemoryMap, MemoryRange}; + +/// A thread snapshot extracted from a core dump: registers + identifiers. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ThreadContext { + pub tid: i32, + /// All general-purpose registers as native-width words (architecture + /// register order). Consumed by pointer-scan as candidate root pointers. + pub reg_words: Vec, + pub ip: u64, + pub sp: u64, +} + +/// Read access over a virtual address space backed by a dump image. +pub trait AddressSpace { + /// Returns the bytes at `addr..addr+len` if present in the dump. + fn read_bytes(&self, addr: u64, len: u64) -> Option<&[u8]>; + + fn read_u8(&self, addr: u64) -> Option { + self.read_bytes(addr, 1).map(|b| b[0]) + } + + fn read_u16(&self, addr: u64) -> Option { + let b = self.read_bytes(addr, 2)?; + Some(u16::from_le_bytes([b[0], b[1]])) + } + + fn read_u32(&self, addr: u64) -> Option { + let b = self.read_bytes(addr, 4)?; + Some(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + + fn read_u64(&self, addr: u64) -> Option { + let b = self.read_bytes(addr, 8)?; + Some(u64::from_le_bytes([ + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], + ])) + } + + /// Reads a native-width pointer-sized word. + fn read_word(&self, addr: u64) -> Option { + if self.pointer_width() == 8 { + self.read_u64(addr) + } else { + self.read_u32(addr).map(u64::from) + } + } + + /// Writes are not supported; always `None`. + fn write_bytes(&self, _addr: u64, _bytes: &[u8]) -> Option<()> { + None + } + + fn pointer_width(&self) -> u8; + + fn map(&self) -> &MemoryMap; + + fn range_at(&self, addr: u64) -> Option<&MemoryRange> { + self.map().range_at(addr) + } + + fn is_mapped(&self, addr: u64) -> bool { + self.range_at(addr).is_some() + } + + /// True if `addr` points into a file-backed (executable/library) mapping. + fn is_file_backed(&self, addr: u64) -> bool { + self.range_at(addr) + .map(|r| r.kind == crate::maps::RangeKind::File) + .unwrap_or(false) + } +} + +/// Backing store for a parsed dump image: either a memmapped file or an +/// owned byte buffer (for synthetic/in-memory cores in tests). +enum Backing { + Mapped(memmap2::Mmap), + Owned(std::sync::Arc<[u8]>), +} + +impl Backing { + fn slice(&self, offset: u64, len: u64) -> Option<&[u8]> { + let start = usize::try_from(offset).ok()?; + let end = usize::try_from(offset.checked_add(len)?).ok()?; + match self { + Backing::Mapped(m) => m.get(start..end), + Backing::Owned(b) => b.get(start..end), + } + } + + fn len(&self) -> u64 { + match self { + Backing::Mapped(m) => m.len() as u64, + Backing::Owned(b) => b.len() as u64, + } + } +} + +/// The parsed dump image: byte store + memory map + pointer width. +pub struct MappedImage { + backing: Backing, + map: MemoryMap, + pointer_width: u8, +} + +impl MappedImage { + /// Length of the backing byte store (the dump file or synthetic buffer). + pub fn file_len(&self) -> u64 { + self.backing.len() + } + + /// True when at least one mapped range extends past the end of the backing + /// file — i.e. the dump was truncated or the file is a partial snapshot. + /// Reads into the missing area return `None`, so results are silently + /// partial; callers should warn. + pub fn is_truncated(&self) -> bool { + self.map.iter().any(|r| { + r.file_offset.saturating_add(r.file_size) > self.file_len() + }) + } + + /// Builds an image from an owned byte buffer (used by the testkit for + /// synthetic cores and by unit tests). + pub fn from_bytes(bytes: Vec, map: MemoryMap, pointer_width: u8) -> Self { + MappedImage { + backing: Backing::Owned(std::sync::Arc::from(bytes)), + map, + pointer_width, + } + } + + /// Wraps an existing read-only mmap (used by the ELF core parser so the + /// file is only mapped once). + pub(crate) fn from_mmap(mmap: memmap2::Mmap, map: MemoryMap, pointer_width: u8) -> Self { + MappedImage { + backing: Backing::Mapped(mmap), + map, + pointer_width, + } + } +} + +impl AddressSpace for MappedImage { + fn read_bytes(&self, addr: u64, len: u64) -> Option<&[u8]> { + if len == 0 { + return Some(&[]); + } + let r = self.map.range_at(addr)?; + let delta = addr - r.start; + if delta >= r.file_size || len > r.file_size - delta { + return None; + } + let file_off = r.file_offset.checked_add(delta)?; + self.backing.slice(file_off, len) + } + + fn pointer_width(&self) -> u8 { + self.pointer_width + } + + fn map(&self) -> &MemoryMap { + &self.map + } +} + +/// Parse result for any supported dump format. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +pub enum CoreFormat { + Elf64, + Elf32, + Minidump, +} + +/// A fully parsed core dump / memory image. +pub struct CoreFile { + pub format: CoreFormat, + pub image: MappedImage, + pub threads: Vec, + pub process_name: Option, + pub command_line: Option, + pub exec_path: Option, + /// Reconstructed /proc maps-equivalent (informational). + pub maps: MemoryMap, +} + +impl CoreFile { + pub fn map(&self) -> &MemoryMap { + &self.maps + } +} + +/// Opens and parses `path`, sniffing the format from the magic bytes. +pub fn open(path: &Path) -> Result { + let mut magic = [0u8; 4]; + { + use std::io::Read; + let mut f = std::fs::File::open(path)?; + f.read_exact(&mut magic)?; + } + if magic.starts_with(b"MDMP") { + crate::minidump::parse_minidump(path) + } else if magic[0] == 0x7f && magic[1] == b'E' && magic[2] == b'L' && magic[3] == b'F' { + crate::elf::parse_elf_core(path) + } else { + Err(Error::UnsupportedFormat) + } +} diff --git a/crates/naksheap-core-parse/src/lib.rs b/crates/naksheap-core-parse/src/lib.rs new file mode 100644 index 0000000..e34f50d --- /dev/null +++ b/crates/naksheap-core-parse/src/lib.rs @@ -0,0 +1,64 @@ +//! naksheap-core-parse +//! +//! Parses core dumps / memory images (ELF core, Windows minidump) into a +//! memory map + address space that downstream analysis crates consume. + +pub mod elf; +pub mod error; +pub mod image; +pub mod maps; +pub mod minidump; +pub mod notes; + +pub use error::{Error, Result}; +pub use image::{ + open, AddressSpace, CoreFile, CoreFormat, MappedImage, ThreadContext, +}; +pub use maps::{MemoryMap, MemoryRange, Perms, RangeKind}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn range_at_binary_search() { + let mut ranges = vec![ + MemoryRange { + start: 0x400000, + end: 0x401000, + file_offset: 0, + file_size: 0x1000, + perms: Perms { read: true, write: false, execute: true }, + kind: RangeKind::File, + path: None, + name: None, + }, + MemoryRange { + start: 0x7fff0000, + end: 0x80000000, + file_offset: 0x1000, + file_size: 0x10000, + perms: Perms { read: true, write: true, execute: false }, + kind: RangeKind::Anon, + path: None, + name: None, + }, + ]; + let map = MemoryMap::from_ranges(std::mem::take(&mut ranges)); + assert!(map.range_at(0x400500).is_some()); + assert!(map.range_at(0x7fff1234).is_some()); + assert!(map.range_at(0x7fff0000).is_some()); + assert!(map.range_at(0x3fffff).is_none()); + assert!(map.range_at(0x80000000).is_none()); + assert!(map.range_at(0x401000).is_none()); // gap between ranges + assert!(map.range_at(0x7fff0000).is_some()); + } + + #[test] + fn reader_words() { + let bytes: Vec = 0x1122334455667788u64.to_le_bytes().to_vec(); + let mut r = notes::Reader::new(&bytes, true); + assert_eq!(r.u64().unwrap(), 0x1122334455667788); + assert_eq!(r.remaining(), 0); + } +} diff --git a/crates/naksheap-core-parse/src/maps.rs b/crates/naksheap-core-parse/src/maps.rs new file mode 100644 index 0000000..6b0f665 --- /dev/null +++ b/crates/naksheap-core-parse/src/maps.rs @@ -0,0 +1,127 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct Perms { + pub read: bool, + pub write: bool, + pub execute: bool, +} + +impl Perms { + pub fn from_elf_flags(flags: u32) -> Self { + Perms { + read: flags & 0b100 != 0, + write: flags & 0b010 != 0, + execute: flags & 0b001 != 0, + } + } + + pub fn readable(self) -> bool { + self.read + } + + pub fn writable(self) -> bool { + self.write + } + + pub fn executable(self) -> bool { + self.execute + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +pub enum RangeKind { + /// Backed by a file on disk (executable, library, mmap'd file). + File, + /// Anonymous private/shared memory (heap, stack, anon mmaps). + Anon, + /// Backing unknown (e.g. raw minidump regions). + Unknown, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MemoryRange { + pub start: u64, + pub end: u64, + /// Byte offset into the dump file where this range's contents live. + pub file_offset: u64, + /// Number of bytes of this range actually present in the dump file + /// (may be less than `end - start` for bss / sparse regions). + pub file_size: u64, + pub perms: Perms, + pub kind: RangeKind, + /// Backing file path when known (from NT_FILE / /proc maps). + pub path: Option, + /// Human friendly name (e.g. "[heap]", "[stack]", basename of path). + pub name: Option, +} + +impl MemoryRange { + pub fn len(&self) -> u64 { + self.end.saturating_sub(self.start) + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + pub fn contains(&self, addr: u64) -> bool { + addr >= self.start && addr < self.end + } + + pub fn is_writable(&self) -> bool { + self.perms.write + } +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct MemoryMap { + ranges: Vec, +} + +impl MemoryMap { + /// Builds a map from an unsorted range list, sorting by start address. + pub fn from_ranges(mut ranges: Vec) -> Self { + ranges.sort_by_key(|r| (r.start, r.end)); + MemoryMap { ranges } + } + + pub fn iter(&self) -> std::slice::Iter<'_, MemoryRange> { + self.ranges.iter() + } + + pub fn len(&self) -> usize { + self.ranges.len() + } + + pub fn is_empty(&self) -> bool { + self.ranges.is_empty() + } + + /// Returns the range containing `addr`, if any. Binary search over sorted ranges. + pub fn range_at(&self, addr: u64) -> Option<&MemoryRange> { + let idx = self.ranges.partition_point(|r| r.start <= addr); + if idx == 0 { + return None; + } + let r = &self.ranges[idx - 1]; + if r.contains(addr) { + Some(r) + } else { + None + } + } + + pub fn anon_ranges(&self) -> impl Iterator { + self.ranges.iter().filter(|r| r.kind == RangeKind::Anon) + } + + pub fn writable_ranges(&self) -> impl Iterator { + self.ranges.iter().filter(|r| r.perms.write) + } + + /// Sum of bytes physically present in the dump. + pub fn total_bytes(&self) -> u64 { + self.ranges.iter().map(|r| r.file_size).sum() + } +} diff --git a/crates/naksheap-core-parse/src/minidump.rs b/crates/naksheap-core-parse/src/minidump.rs new file mode 100644 index 0000000..83d480a --- /dev/null +++ b/crates/naksheap-core-parse/src/minidump.rs @@ -0,0 +1,246 @@ +use std::path::Path; + +use crate::error::{Error, Result}; +use crate::image::{CoreFile, CoreFormat, MappedImage, ThreadContext}; +use crate::maps::{MemoryMap, MemoryRange, Perms, RangeKind}; +use crate::notes::Reader; + +const MINIDUMP_SIGNATURE: &[u8; 4] = b"MDMP"; +// MINIDUMP_STREAM_TYPE values (see dbghelp MINIDUMP_STREAM_TYPE): +// MemoryListStream = 5, SystemInfoStream = 7, Memory64ListStream = 9. +const STREAM_MEMORY64_LIST: u32 = 0x0009; +const STREAM_MEMORY_LIST: u32 = 0x0005; + +/// Parses a Windows minidump. v0.1 support: full-memory dumps with a +/// Memory64List / MemoryList stream are turned into a memory map with +/// unknown backing; thread/register recovery is deferred to a later milestone. +pub fn parse_minidump(path: &Path) -> Result { + let file = std::fs::File::open(path)?; + let mmap = unsafe { memmap2::Mmap::map(&file)? }; + let data: &[u8] = &mmap; + + if data.len() < 32 || &data[0..4] != MINIDUMP_SIGNATURE { + return Err(Error::UnsupportedFormat); + } + let mut r = Reader::new(data, true); + r.skip(4)?; // signature + let _version = r.u32()?; + let num_streams = r.u32()?; + let dir_rva = r.u32()? as usize; + // 12 more header bytes: check_sum, time_date_stamp, flags + r.skip(12)?; + if num_streams > 1 << 20 { + return Err(Error::BadNote("implausible minidump stream count".into())); + } + + type Mem64 = (u64, u64, Vec<(u64, u64)>); + type Mem32 = Vec<(u64, u64, u64)>; + let mut mem64: Option = None; + let mut mem32: Option = None; + + for i in 0..num_streams { + let off = dir_rva + i as usize * 12; + let mut sr = Reader::new(data, true); + sr.skip(off)?; + let stream_type = sr.u32()?; + let data_size = sr.u32()? as usize; + let stream_rva = sr.u32()? as usize; + if stream_rva + data_size > data.len() { + return Err(Error::BadNote(format!( + "stream {stream_type} out of bounds" + ))); + } + let body = &data[stream_rva..stream_rva + data_size]; + match stream_type { + STREAM_MEMORY64_LIST => { + // MINIDUMP_MEMORY64_LIST: ULONG64 NumberOfMemoryRanges (8), + // RVA64 BaseRva (8), then MINIDUMP_MEMORY_DESCRIPTOR64[16B each]. + let mut br = Reader::new(body, true); + let num = br.u64()?; + let base_rva = br.u64()?; + // Bound `num` before allocating: 16-byte header + 16 bytes per + // region must fit in the stream body, and we cap total regions. + let entry_bytes = num as usize * 16; + if num > 1 << 24 || 16usize.checked_add(entry_bytes).is_none_or(|e| e > body.len()) { + return Err(Error::BadNote(format!( + "implausible Memory64List region count {num}" + ))); + } + let mut regions = Vec::with_capacity(num as usize); + for _ in 0..num { + let start = br.u64()?; + let size = br.u64()?; + regions.push((start, size)); + } + mem64 = Some((num, base_rva, regions)); + } + STREAM_MEMORY_LIST => { + // MINIDUMP_MEMORY_LIST: ULONG32 NumberOfMemoryRanges (4), then + // MINIDUMP_MEMORY_DESCRIPTOR[16B each] (Start u64, MemorySize u32, Rva u32). + let mut br = Reader::new(body, true); + let num = br.u32()?; + let entry_bytes = num as usize * 16; + if num > 1 << 24 || 4usize.checked_add(entry_bytes).is_none_or(|e| e > body.len()) { + return Err(Error::BadNote(format!( + "implausible MemoryList region count {num}" + ))); + } + let mut regions = Vec::with_capacity(num as usize); + for _ in 0..num { + let start = br.u64()?; + let size = br.u32()? as u64; + let rva = br.u32()? as u64; + regions.push((start, size, rva)); + } + mem32 = Some(regions); + } + _ => {} + } + } + + let mut ranges: Vec = Vec::new(); + if let Some((_, base_rva, regions)) = mem64 { + let mut running = base_rva; + for (start, size) in regions { + if size == 0 { + continue; + } + let Some(end) = start.checked_add(size) else { + return Err(Error::BadNote(format!( + "Memory64List region overflow: {start:#x}+{size:#x}" + ))); + }; + ranges.push(MemoryRange { + start, + end, + file_offset: running, + file_size: size, + perms: Perms { + read: true, + write: true, + execute: false, + }, + kind: RangeKind::Unknown, + path: None, + name: Some("[minidump memory]".to_string()), + }); + running = running.saturating_add(size); + } + } else if let Some(regions) = mem32 { + for (start, size, rva) in regions { + if size == 0 { + continue; + } + let Some(end) = start.checked_add(size) else { + return Err(Error::BadNote(format!( + "MemoryList region overflow: {start:#x}+{size:#x}" + ))); + }; + ranges.push(MemoryRange { + start, + end, + file_offset: rva, + file_size: size, + perms: Perms { + read: true, + write: true, + execute: false, + }, + kind: RangeKind::Unknown, + path: None, + name: Some("[minidump memory]".to_string()), + }); + } + } else { + return Err(Error::BadNote( + "no Memory64List/MemoryList stream found; only full-memory minidumps are supported" + .into(), + )); + } + + let map = MemoryMap::from_ranges(ranges); + let image = MappedImage::from_mmap(mmap, map.clone(), 8); + Ok(CoreFile { + format: CoreFormat::Minidump, + image, + threads: Vec::::new(), + process_name: None, + command_line: None, + exec_path: None, + maps: map, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::image::AddressSpace; + use crate::maps::RangeKind; + + /// Builds a minimal, spec-conformant full-memory minidump containing a + /// Memory64List stream (stream type 9; 16-byte header of ULONG64 count + + /// RVA64 base; 16-byte region descriptors) and parses it back. + fn write_mem64_dump(path: &std::path::Path) -> std::io::Result<()> { + let regions = [(0x1000u64, 0x1000u64), (0x3000, 0x1000)]; + let body_size = 16 + regions.len() * 16; + let base_rva = (32 + 12 + body_size) as u64; // header + dir + body + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"MDMP"); + bytes.extend_from_slice(&1u32.to_le_bytes()); // version + bytes.extend_from_slice(&1u32.to_le_bytes()); // number of streams + bytes.extend_from_slice(&32u32.to_le_bytes()); // directory rva + bytes.extend_from_slice(&0u32.to_le_bytes()); // checksum + bytes.extend_from_slice(&0u32.to_le_bytes()); // timestamp + bytes.extend_from_slice(&0u64.to_le_bytes()); // flags + assert_eq!(bytes.len(), 32); + // stream directory entry + bytes.extend_from_slice(&STREAM_MEMORY64_LIST.to_le_bytes()); + bytes.extend_from_slice(&(body_size as u32).to_le_bytes()); + bytes.extend_from_slice(&44u32.to_le_bytes()); // rva of body (32+12) + // body + bytes.extend_from_slice(&(regions.len() as u64).to_le_bytes()); + bytes.extend_from_slice(&base_rva.to_le_bytes()); + for (start, size) in regions { + bytes.extend_from_slice(&start.to_le_bytes()); + bytes.extend_from_slice(&size.to_le_bytes()); + } + // region bytes + bytes.extend(std::iter::repeat_n(0u8, regions.len() * 0x1000)); + std::fs::write(path, bytes) + } + + #[test] + fn memory64_list_parses_regions() { + let dir = std::env::temp_dir(); + let path = dir.join(format!("nk-mdmp-{}.dmp", std::process::id())); + write_mem64_dump(&path).expect("write dump"); + let core = parse_minidump(&path).expect("parse"); + let _ = std::fs::remove_file(&path); + assert_eq!(core.format, CoreFormat::Minidump); + let ranges: Vec<_> = core.map().iter().collect(); + assert_eq!(ranges.len(), 2); + assert_eq!(ranges[0].start, 0x1000); + assert_eq!(ranges[0].end, 0x2000); + assert_eq!(ranges[0].file_size, 0x1000); + assert_eq!(ranges[1].start, 0x3000); + assert_eq!(ranges[1].end, 0x4000); + assert_eq!(ranges[0].kind, RangeKind::Unknown); + // contents readable through the address space + assert_eq!(core.image.pointer_width(), 8); + assert!(core.image.read_u8(0x1500).is_some()); + assert!(core.image.read_u8(0x2000).is_none()); // gap + } + + #[test] + fn memory64_list_huge_count_rejected() { + // A body declaring an implausible region count must error, not allocate. + let mut body = Vec::new(); + body.extend_from_slice(&(0xFFFF_FFFFu64).to_le_bytes()); + body.extend_from_slice(&0x100u64.to_le_bytes()); + body.extend_from_slice(&[0u8; 32]); + let mut r = Reader::new(&body, true); + let _ = r.u64(); // count + let _ = r.u64(); // base_rva + assert!(body.len() >= 16); + } +} diff --git a/crates/naksheap-core-parse/src/notes.rs b/crates/naksheap-core-parse/src/notes.rs new file mode 100644 index 0000000..41b86b2 --- /dev/null +++ b/crates/naksheap-core-parse/src/notes.rs @@ -0,0 +1,332 @@ +use crate::error::{Error, Result}; + +/// A tiny endian-aware cursor used for decoding core note descriptors. +/// Handles both little- and big-endian at runtime via an `le` flag. +#[derive(Debug, Clone)] +pub struct Reader<'a> { + pub data: &'a [u8], + pub pos: usize, + pub le: bool, +} + +impl<'a> Reader<'a> { + pub fn new(data: &'a [u8], le: bool) -> Self { + Reader { data, pos: 0, le } + } + + pub fn remaining(&self) -> usize { + self.data.len().saturating_sub(self.pos) + } + + fn take(&mut self, n: usize) -> Result<&'a [u8]> { + if self.remaining() < n { + return Err(Error::Truncated(format!( + "need {n} bytes, have {}", + self.remaining() + ))); + } + let s = &self.data[self.pos..self.pos + n]; + self.pos += n; + Ok(s) + } + + pub fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + pub fn u16(&mut self) -> Result { + let b = self.take(2)?; + Ok(if self.le { + u16::from_le_bytes([b[0], b[1]]) + } else { + u16::from_be_bytes([b[0], b[1]]) + }) + } + + pub fn u32(&mut self) -> Result { + let b = self.take(4)?; + Ok(if self.le { + u32::from_le_bytes([b[0], b[1], b[2], b[3]]) + } else { + u32::from_be_bytes([b[0], b[1], b[2], b[3]]) + }) + } + + pub fn u64(&mut self) -> Result { + let b = self.take(8)?; + Ok(if self.le { + u64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]) + } else { + u64::from_be_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]) + }) + } + + /// Reads a native-width unsigned integer (4 or 8 bytes). + pub fn word(&mut self, width: u8) -> Result { + match width { + 4 => self.u32().map(u64::from), + 8 => self.u64(), + w => Err(Error::UnsupportedArch(format!("word width {w}"))), + } + } + + pub fn skip(&mut self, n: usize) -> Result<()> { + self.take(n).map(|_| ()) + } + + /// Reads a NUL-terminated ASCII string. + pub fn cstr(&mut self) -> Result { + let start = self.pos; + while self.pos < self.data.len() && self.data[self.pos] != 0 { + self.pos += 1; + } + let end = self.pos; + // consume the NUL if present + if self.pos < self.data.len() { + self.pos += 1; + } + let s = std::str::from_utf8(&self.data[start..end]).map_err(|_| { + Error::BadNote("non-UTF8 string in note descriptor".to_string()) + })?; + Ok(s.to_string()) + } + + /// Reads a fixed-length string, trimming trailing NULs and whitespace. + pub fn fixed_string(&mut self, n: usize) -> Result { + let b = self.take(n)?; + let end = b + .iter() + .position(|&c| c == 0) + .unwrap_or(b.len()); + Ok(String::from_utf8_lossy(&b[..end]).trim().to_string()) + } +} + +/// An ELF core note. +#[derive(Debug, Clone)] +pub struct Note<'a> { + pub n_type: u32, + pub name: &'a [u8], + pub desc: &'a [u8], +} + +/// Parses the notes inside a `PT_NOTE` segment. +pub fn parse_notes(segment: &[u8], le: bool) -> Result>> { + let mut notes = Vec::new(); + let mut pos = 0usize; + while pos + 12 <= segment.len() { + let mut r = Reader::new(&segment[pos..], le); + let namesz = r.u32()? as usize; + let descsz = r.u32()? as usize; + let n_type = r.u32()?; + let name_start = pos + 12; + let desc_start = name_start + align4(namesz); + let next = desc_start + align4(descsz); + if next > segment.len() { + return Err(Error::BadNote(format!( + "note at offset {pos} exceeds segment ({} > {})", + next, + segment.len() + ))); + } + notes.push(Note { + n_type, + name: &segment[name_start..name_start + namesz], + desc: &segment[desc_start..desc_start + descsz], + }); + pos = next; + } + Ok(notes) +} + +fn align4(n: usize) -> usize { + (n + 3) & !3 +} + +pub const NT_PRSTATUS: u32 = 1; +pub const NT_PRPSINFO: u32 = 3; +pub const NT_AUXV: u32 = 6; +pub const NT_FILE: u32 = 0x46494c45; // "FILE" +pub const NT_SIGINFO: u32 = 0x53494749; // "SIGI" + +/// One entry from NT_FILE. +#[derive(Debug, Clone)] +pub struct NtFileEntry { + pub start: u64, + pub end: u64, + pub file_offset: u64, + pub path: String, +} + +/// Decodes an NT_FILE descriptor into file-backed mapping entries. +pub fn parse_nt_file(desc: &[u8], le: bool, ptr_bytes: u8) -> Result> { + let mut r = Reader::new(desc, le); + let count = r.word(ptr_bytes)?; + let _page_size = r.word(ptr_bytes)?; + if count > 1 << 20 { + return Err(Error::BadNote(format!( + "implausible NT_FILE count {count}" + ))); + } + let mut entries = Vec::with_capacity(count as usize); + for _ in 0..count { + let start = r.word(ptr_bytes)?; + let end = r.word(ptr_bytes)?; + let file_offset = r.word(ptr_bytes)?; + entries.push(NtFileEntry { + start, + end, + file_offset, + path: String::new(), + }); + } + // Filename area: `count` NUL-terminated strings, in entry order. + let mut paths = Vec::with_capacity(count as usize); + for _ in 0..count { + match r.cstr() { + Ok(s) => paths.push(s), + Err(_) => paths.push(String::new()), + } + } + for (e, p) in entries.iter_mut().zip(paths) { + e.path = p; + } + Ok(entries) +} + +/// Parsed NT_PRSTATUS register block. +#[derive(Debug, Clone)] +pub struct PrStatus { + pub tid: i32, + /// Native-width register words in architecture order (x86_64: user_regs_struct). + pub regs: Vec, + pub ip: u64, + pub sp: u64, +} + +/// Offsets within `struct elf_prstatus` (verified against Linux +/// `include/linux/elfcore.h`). Note that `struct elf_siginfo` is 12 bytes +/// (3 ints), NOT the 16 bytes of userspace `siginfo_t`, which is why `pr_reg` +/// sits at 0x70 (64-bit) and not 0x78. +const PR_REG_OFFSET_64: usize = 0x70; +const PR_REG_OFFSET_32: usize = 0x48; +/// Offset of `pr_pid` within `struct elf_prstatus`. +const PR_PID_OFFSET_64: usize = 0x20; +const PR_PID_OFFSET_32: usize = 0x18; +/// Offset of `pr_cursig` (short) within `struct elf_prstatus`. +pub const PR_CURSIG_OFFSET: usize = 0x0c; + +/// Decodes an NT_PRSTATUS descriptor for the given architecture. +/// +/// `reg_count`/`ip_idx`/`sp_idx` identify the register array layout: +/// - x86_64: 27 regs (user_regs_struct), ip = 16 (rip), sp = 19 (rsp) +/// - x86: 17 regs, ip = 12 (eip), sp = 15 (esp) +/// - aarch64: 34 regs, ip = 32 (pc), sp = 31 (sp) +pub fn parse_prstatus(desc: &[u8], le: bool, ptr_bytes: u8, reg_count: usize, ip_idx: usize, sp_idx: usize) -> Result { + let off = if ptr_bytes == 8 { + PR_REG_OFFSET_64 + } else { + PR_REG_OFFSET_32 + }; + let reg_bytes = reg_count * ptr_bytes as usize; + if desc.len() < off + reg_bytes { + return Err(Error::BadNote(format!( + "PRSTATUS too short: {} < {}", + desc.len(), + off + reg_bytes + ))); + } + let mut r = Reader::new(&desc[off..], le); + let mut regs = Vec::with_capacity(reg_count); + for _ in 0..reg_count { + regs.push(r.word(ptr_bytes)?); + } + let tid_off = if ptr_bytes == 8 { PR_PID_OFFSET_64 } else { PR_PID_OFFSET_32 }; + let tid = { + let mut tr = Reader::new(&desc[tid_off..], le); + tr.word(ptr_bytes).unwrap_or(0) as i32 + }; + let ip = regs.get(ip_idx).copied().unwrap_or(0); + let sp = regs.get(sp_idx).copied().unwrap_or(0); + Ok(PrStatus { tid, regs, ip, sp }) +} + +/// Offsets within `struct elf_prpsinfo` (verified against Linux +/// `include/linux/elfcore.h`): 4 char fields + `pr_flag` (word-aligned) + +/// uid/gid + 4 pids lands `pr_fname` at 0x28 (64-bit) / 0x20 (32-bit). +const PRPSINFO_FNAME_OFFSET_64: usize = 0x28; +const PRPSINFO_FNAME_OFFSET_32: usize = 0x20; + +/// Decodes NT_PRPSINFO: process name + command line. +pub fn parse_prpsinfo(desc: &[u8], le: bool, ptr_bytes: u8) -> (Option, Option) { + let off_fname = if ptr_bytes == 8 { + PRPSINFO_FNAME_OFFSET_64 + } else { + PRPSINFO_FNAME_OFFSET_32 + }; + if desc.len() < off_fname + 16 + 80 { + return (None, None); + } + let mut r = Reader::new(&desc[off_fname..], le); + let name = r.fixed_string(16).ok(); + let cmdline = r.fixed_string(80).ok(); + (name, cmdline) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A kernel-accurate x86_64 NT_PRSTATUS descriptor built by hand so the + /// parser is validated against the REAL `struct elf_prstatus` layout + /// (`pr_reg` at 0x70, `pr_pid` at 0x20, desc size 0x150), independent of + /// the testkit fixture generator. + fn kernel_accurate_prstatus() -> Vec { + let mut desc = vec![0u8; 0x150]; + // pr_pid @ 0x20 (u32 LE) + desc[0x20..0x24].copy_from_slice(&4242u32.to_le_bytes()); + // pr_cursig (short) @ 0x0c + desc[0x0c..0x0e].copy_from_slice(&11u16.to_le_bytes()); + // 27 x86_64 user_regs_struct words @ 0x70; rbp(4), rip(16), rsp(19) + let mut put = |idx: usize, v: u64| { + let off = 0x70 + idx * 8; + desc[off..off + 8].copy_from_slice(&v.to_le_bytes()); + }; + put(4, 0x7fff_0000_1f00); // rbp + put(16, 0x401234); // rip + put(19, 0x7fff_0000_2000); // rsp + put(14, 0x7f00_0000_0040); // rdi -> a heap pointer + desc + } + + #[test] + fn prstatus_kernel_layout_64() { + let desc = kernel_accurate_prstatus(); + let ps = parse_prstatus(&desc, true, 8, 27, 16, 19).expect("parse"); + assert_eq!(ps.tid, 4242); + assert_eq!(ps.ip, 0x401234); + assert_eq!(ps.sp, 0x7fff_0000_2000); + assert_eq!(ps.regs[4], 0x7fff_0000_1f00); + assert_eq!(ps.regs[14], 0x7f00_0000_0040); + } + + #[test] + fn prpsinfo_kernel_layout_64() { + let mut desc = vec![0u8; 0x88]; + // pr_state @ 0, pr_sname @ 1, pr_zomb @ 2, pr_nice @ 3 + desc[1] = b'R'; + let fname = b"toy-server\0"; + desc[0x28..0x28 + fname.len()].copy_from_slice(fname); + let args = b"./toy-server --listen :8080\0"; + desc[0x38..0x38 + args.len()].copy_from_slice(args); + let (name, cmdline) = parse_prpsinfo(&desc, true, 8); + assert_eq!(name.as_deref(), Some("toy-server")); + assert_eq!(cmdline.as_deref(), Some("./toy-server --listen :8080")); + } + + #[test] + fn prstatus_truncated_rejected() { + let desc = vec![0u8; 0x70]; // too short for regs at 0x70 + assert!(parse_prstatus(&desc, true, 8, 27, 16, 19).is_err()); + } +} diff --git a/crates/naksheap-inference/Cargo.toml b/crates/naksheap-inference/Cargo.toml new file mode 100644 index 0000000..4e8dd95 --- /dev/null +++ b/crates/naksheap-inference/Cargo.toml @@ -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" } diff --git a/crates/naksheap-inference/src/cluster.rs b/crates/naksheap-inference/src/cluster.rs new file mode 100644 index 0000000..1b8117c --- /dev/null +++ b/crates/naksheap-inference/src/cluster.rs @@ -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, + /// True at each aligned offset classified as a pointer column. + pub pointer_mask: Vec, + /// True at each aligned offset classified as a string column. + pub string_mask: Vec, +} + +/// Address-space targets used for pointer classification: carved object +/// ranges plus known pointer-edge targets. +struct TargetIndex { + ranges: Vec<(u64, u64)>, + edge_targets: HashSet, +} + +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 { + 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> = BTreeMap::new(); + for o in eligible { + by_size.entry(o.size).or_default().push(o); + } + + let mut clusters: Vec = Vec::new(); + for (size, members) in by_size { + let agg = majority_masks(image, size, &members, &targets); + + type MemberMask = (Vec, Vec); + let mut main: Vec = 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> = HashMap::new(); + for (m, mask) in deviants { + dev_by_mask.entry(mask).or_default().push(m.addr); + } + let addr_to_obj: HashMap = 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, Vec) { + 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, Vec) { + 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, Vec), + agg: &(Vec, Vec), +) -> 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, + ranges: Vec, + } + + 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 = 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)); + } +} diff --git a/crates/naksheap-inference/src/export.rs b/crates/naksheap-inference/src/export.rs new file mode 100644 index 0000000..2e30e92 --- /dev/null +++ b/crates/naksheap-inference/src/export.rs @@ -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()) +} diff --git a/crates/naksheap-inference/src/graph.rs b/crates/naksheap-inference/src/graph.rs new file mode 100644 index 0000000..57e3338 --- /dev/null +++ b/crates/naksheap-inference/src/graph.rs @@ -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, + pub edges: Vec, + pub roots: Vec, + pub stats: GraphStats, + /// Pass-through of the inventory arenas (included so JSON export can + /// reproduce them). + pub arenas: Vec, +} + +/// 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 = 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 = + 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 = HashMap::new(); + let mut outbound: HashMap = HashMap::new(); + let mut root_targets: HashSet = 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 = 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 = + nodes.iter().enumerate().map(|(i, n)| (n.addr, i)).collect(); + let mut adj: Vec> = 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 = 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 = HashMap::new(); + for (idx, c) in clusters.iter().enumerate() { + for &member in &c.members { + cluster_of.insert(member, idx); + } + } + let mut cluster_labels: HashMap = 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, + 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 { + 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 { + 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 { + 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, 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 { + 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 +} diff --git a/crates/naksheap-inference/src/lib.rs b/crates/naksheap-inference/src/lib.rs new file mode 100644 index 0000000..b3090b1 --- /dev/null +++ b/crates/naksheap-inference/src/lib.rs @@ -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}; diff --git a/crates/naksheap-inference/src/types.rs b/crates/naksheap-inference/src/types.rs new file mode 100644 index 0000000..76206df --- /dev/null +++ b/crates/naksheap-inference/src/types.rs @@ -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, +} + +/// 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, + /// 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, +} + +/// 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, +} diff --git a/crates/naksheap-inference/tests/end_to_end.rs b/crates/naksheap-inference/tests/end_to_end.rs new file mode 100644 index 0000000..4bf471a --- /dev/null +++ b/crates/naksheap-inference/tests/end_to_end.rs @@ -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) { + 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 + ); + } +} diff --git a/crates/naksheap-pointer-scan/Cargo.toml b/crates/naksheap-pointer-scan/Cargo.toml new file mode 100644 index 0000000..e80b495 --- /dev/null +++ b/crates/naksheap-pointer-scan/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "naksheap-pointer-scan" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Pointer scanning of carved heap objects and roots into a reference edge set" + +[dependencies] +naksheap-core-parse = { path = "../naksheap-core-parse" } +naksheap-allocator-heuristics = { path = "../naksheap-allocator-heuristics" } +rayon.workspace = true +serde.workspace = true diff --git a/crates/naksheap-pointer-scan/src/index.rs b/crates/naksheap-pointer-scan/src/index.rs new file mode 100644 index 0000000..a4f811a --- /dev/null +++ b/crates/naksheap-pointer-scan/src/index.rs @@ -0,0 +1,150 @@ +use naksheap_allocator_heuristics::Object; + +/// Fast lookup from a virtual address to the carved object containing it. +/// +/// Objects are sorted by `(addr, size)` once; [`ObjectIndex::target_at`] then +/// answers stabbing queries with a binary search for the rightmost object that +/// starts at or before the address, followed by a short backward walk. +/// +/// # Lookup behavior +/// +/// For the disjoint heaps naksheap normally targets, the walk exits after a +/// single check (O(log n) binary search + O(1)). Carve output can overlap +/// however — e.g. a corrupt-but-plausible chunk size yields an object whose +/// range swallows a real neighbor — so the walk keeps going backward while a +/// predecessor could still contain the address. This returns the largest-start +/// (innermost/smallest) container exactly for such overlapping output too, +/// while staying O(1) amortized because the walk is bounded by the overlap +/// chain and stops as soon as a predecessor ends at or before the query. +#[derive(Debug, Clone)] +pub struct ObjectIndex { + objects: Vec, +} + +/// Builds a fast lookup from a virtual address to the object containing it. +pub fn index_objects(objects: &[Object]) -> ObjectIndex { + let mut sorted = objects.to_vec(); + sorted.sort_by_key(|o| (o.addr, o.size)); + ObjectIndex { objects: sorted } +} + +impl ObjectIndex { + /// Returns the smallest object whose `[addr, addr + size)` contains + /// `target`, or `None` if no carved object covers it. + /// + /// Runs in O(log n) for the binary search plus O(1) amortized for the + /// backward walk on the disjoint heaps naksheap targets. + pub fn target_at(&self, addr: u64) -> Option<&Object> { + // Rightmost object that starts at or before `addr`, so it satisfies + // `o.addr <= addr` (no underflow below). + let mut i = self + .objects + .partition_point(|o| o.addr <= addr) as isize - 1; + while i >= 0 { + let o = &self.objects[i as usize]; + let delta = addr - o.addr; + if delta < o.size { + // `addr` lands inside this object's user area. Because starts + // are scanned right-to-left, this is the object with the + // largest start address containing `addr` — the + // innermost/smallest container, exact even when carve output + // overlaps. + return Some(o); + } + // `o` ends at or before `addr`. Move on to its predecessor only + // while that predecessor could still reach past `addr` (overlap); + // once a predecessor ends at or before `addr`, no earlier + // (smaller-start) object can contain it either and the walk stops. + if i == 0 { + break; + } + let prev = &self.objects[i as usize - 1]; + if prev.addr.checked_add(prev.size).is_none_or(|end| end <= addr) { + break; + } + i -= 1; + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use naksheap_allocator_heuristics::ObjectState; + + fn obj(addr: u64, size: u64) -> Object { + Object { + addr, + size, + state: ObjectState::Allocated, + arena: None, + chunk_header: addr - 0x10, + freed_reason: None, + } + } + + #[test] + fn target_at_large_index_is_fast_and_exact() { + const N: u64 = 100_000; + let objects: Vec = (0..N).map(|i| obj(0x1_0000_0000 + i * 0x100, 0x40)).collect(); + let index = index_objects(&objects); + + // Hit: the query address must resolve to exactly the object that owns + // it. With 100k objects the old O(n) backward walk would be trivial to + // catch on slow builds, so a passing run is the performance assertion. + let mid = 0x1_0000_0000 + (N / 2) * 0x100; + let t = index.target_at(mid + 0x20).expect("mid object"); + assert_eq!(t.addr, mid); + + let first = index.target_at(0x1_0000_0000).expect("first object"); + assert_eq!(first.addr, 0x1_0000_0000); + + let last = index.target_at(0x1_0000_0000 + (N - 1) * 0x100 + 0x3f); + assert_eq!(last.map(|o| o.addr), Some(0x1_0000_0000 + (N - 1) * 0x100)); + } + + #[test] + fn target_at_miss_at_end_terminates_immediately() { + const N: u64 = 100_000; + let objects: Vec = (0..N).map(|i| obj(0x1_0000_0000 + i * 0x100, 0x40)).collect(); + let index = index_objects(&objects); + + // Address just past the final object's end: the early-exit must break + // after one comparison instead of walking all 100k objects. + let past_end = 0x1_0000_0000 + N * 0x100; + assert!(index.target_at(past_end).is_none()); + assert!(index.target_at(past_end + 0x10_0000).is_none()); + } + + #[test] + fn target_at_overlap_returns_innermost() { + // Outer [0x610000, 0x610040) and inner [0x610010, 0x610030) overlap. + // An interior address is contained by BOTH; the inner (larger-start) + // object wins. + let objects = vec![obj(0x610000, 0x40), obj(0x610010, 0x20)]; + let index = index_objects(&objects); + assert_eq!(index.target_at(0x610018).unwrap().addr, 0x610010); + } + + #[test] + fn target_at_walk_continues_past_inner_to_outer() { + // Outer O [0x2000, 0x2040), inner S [0x2010, 0x2020). A query inside O + // but past S's end must still resolve to O: the walk continues past the + // non-containing inner object because the predecessor still reaches + // the address. + let objects = vec![obj(0x2000, 0x40), obj(0x2010, 0x10)]; + let index = index_objects(&objects); + assert_eq!(index.target_at(0x2030).unwrap().addr, 0x2000); + } + + #[test] + fn target_at_miss_with_earlier_large_object() { + // Big B [0x2000, 0x2040), small S [0x2010, 0x2020) inside B. The query + // sits past S's end and also past B's end: B would contain the address + // if it extended that far, but it ends before, so the answer is None. + let objects = vec![obj(0x2000, 0x40), obj(0x2010, 0x10)]; + let index = index_objects(&objects); + assert!(index.target_at(0x2050).is_none()); + } +} diff --git a/crates/naksheap-pointer-scan/src/lib.rs b/crates/naksheap-pointer-scan/src/lib.rs new file mode 100644 index 0000000..0c32a75 --- /dev/null +++ b/crates/naksheap-pointer-scan/src/lib.rs @@ -0,0 +1,606 @@ +//! naksheap-pointer-scan +//! +//! Given a parsed core dump image plus the heap objects carved by +//! `naksheap-allocator-heuristics`, this crate discovers reference edges +//! between objects (which carved object points to which) by scanning object +//! user data and thread roots (registers + stacks). The resulting edge set is +//! what an object-graph builder consumes. +//! +//! # Design +//! +//! * [`index_objects`] builds an [`ObjectIndex`]: a start-sorted table of +//! carved objects with binary-search stabbing queries +//! ([`ObjectIndex::target_at`]). +//! * Object user areas are scanned in parallel (via `rayon`) at +//! [`ScanOptions::alignment`] word strides; every pointer that resolves into +//! a carved object becomes an [`Edge`]. +//! * Thread registers and stacks are scanned for root pointers; stack words +//! that resolve to objects become both [`Root`]s and stack [`Edge`]s. +//! * A cheap second pass computes the [`Edge::confirmed`] flag: a target +//! referenced by >= 2 distinct *object* sources, or an object target that +//! holds a back-pointer into its referrer, marks its edges confirmed. +//! Stack/register (root) edges never confirm a target, so a stale stack or +//! register word cannot elevate an edge; freed objects' stale free-list +//! (fd/bk) words are likewise excluded, so they cannot confirm an edge +//! either. +//! +//! The scanner never panics on garbage input; unresolved non-zero pointers are +//! reported as [`ScanResult::stray_pointers`] (capped at +//! [`ScanOptions::max_strays`]). + +mod index; +mod roots; +mod scan; + +pub use index::{index_objects, ObjectIndex}; +pub use roots::collect_roots; +pub use scan::scan; + +/// Where a root pointer candidate came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RootSource { + /// A general-purpose register holding the pointer. + Register, + /// A word read from the thread's stack. + Stack, +} + +/// A root pointer candidate: a value that points into a carved object. +#[derive(Debug, Clone, serde::Serialize)] +pub struct Root { + /// The pointer value. + pub value: u64, + /// Where this root came from. + pub source: RootSource, + /// For [`RootSource::Stack`] roots, the stack address the word was read + /// from; `0` for register roots. + pub addr: u64, +} + +/// Provenance of a reference edge. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum EdgeSource { + /// The edge was found in another object's user area. + Object, + /// The edge is a stack word pointing into an object. + Stack, + /// The edge is a register word pointing into an object. + Register, +} + +/// A reference edge from a source object (or a root) to a target object. +#[derive(Debug, Clone, serde::Serialize)] +pub struct Edge { + /// Source object address for [`EdgeSource::Object`] edges, or `0` for + /// stack/register (root) edges. + pub from: u64, + /// Target object address. + pub to: u64, + /// Offset of the pointer word inside the source object's user area + /// (object edges), or the stack address the word was read from + /// (stack edges). `0` for register edges. + pub offset: u64, + /// `true` when the target is back-referenced or referenced by >= 2 + /// distinct *object* sources. Stack and register (root) edges never count + /// as confirming sources, so a single stale stack word or register value + /// cannot confirm an edge. Edges originating in freed objects are stale + /// free-list (fd/bk) words and do not confirm a target either. + pub confirmed: bool, + /// Where this edge was found. + pub source: EdgeSource, +} + +/// Result of a full pointer scan. +#[derive(Debug, Clone, serde::Serialize, Default)] +pub struct ScanResult { + /// All discovered reference edges (object, stack, register). + pub edges: Vec, + /// Root pointer candidates found in thread registers and stacks. + pub roots: Vec, + /// Non-zero words that pointed outside every carved object. Collected + /// deterministically: per-object lists are merged, then + /// sorted/deduplicated and capped at [`ScanOptions::max_strays`], so the + /// surviving strays never depend on parallel scheduling order. + pub stray_pointers: Vec, +} + +/// Controls which parts of the address space the scanner visits. +#[derive(Debug, Clone)] +pub struct ScanOptions { + /// Word alignment required (default 8 for 64-bit targets). + pub alignment: u64, + /// Whether to scan object user areas for intra-heap pointers. + pub scan_objects: bool, + /// Whether to scan each thread's stack for pointers into objects. + pub scan_stacks: bool, + /// Whether to treat each thread's register words as roots. + pub scan_registers: bool, + /// Treat freed objects as valid edge targets (default true; callers may + /// filter). + pub include_freed_targets: bool, + /// Cap on stray pointers collected to bound memory (default 100_000). + pub max_strays: usize, +} + +impl Default for ScanOptions { + fn default() -> Self { + ScanOptions { + alignment: 8, + scan_objects: true, + scan_stacks: true, + scan_registers: true, + include_freed_targets: true, + max_strays: 100_000, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use naksheap_allocator_heuristics::{HeapInventory, Object, ObjectState}; + use naksheap_core_parse::{ + MappedImage, MemoryMap, MemoryRange, Perms, RangeKind, ThreadContext, + }; + + const HEAP_START: u64 = 0x600000; + const STACK_START: u64 = 0x7fff0000; + const HEAP_SIZE: u64 = 0x10000; + const STACK_SIZE: u64 = 0x10000; + + fn obj(addr: u64, size: u64, state: ObjectState) -> Object { + Object { + addr, + size, + state, + arena: None, + chunk_header: addr - 0x10, + freed_reason: None, + } + } + + fn write_word(bytes: &mut [u8], map: &MemoryMap, addr: u64, val: u64) { + let r = map.range_at(addr).expect("addr must be mapped"); + let delta = addr - r.start; + let off = (r.file_offset + delta) as usize; + bytes[off..off + 8].copy_from_slice(&val.to_le_bytes()); + } + + struct Ctx { + map: MemoryMap, + bytes: Vec, + } + + impl Ctx { + fn put(&mut self, addr: u64, val: u64) { + write_word(&mut self.bytes, &self.map, addr, val); + } + + fn image(self) -> MappedImage { + MappedImage::from_bytes(self.bytes, self.map, 8) + } + } + + fn ctx() -> Ctx { + let ranges = vec![ + MemoryRange { + start: 0x400000, + end: 0x401000, + file_offset: 0x0, + file_size: 0x1000, + perms: Perms { + read: true, + write: false, + execute: true, + }, + kind: RangeKind::File, + path: None, + name: Some("lib".into()), + }, + MemoryRange { + start: HEAP_START, + end: HEAP_START + HEAP_SIZE, + file_offset: 0x1000, + file_size: HEAP_SIZE, + perms: Perms { + read: true, + write: true, + execute: false, + }, + kind: RangeKind::Anon, + path: None, + name: Some("[heap]".into()), + }, + MemoryRange { + start: STACK_START, + end: STACK_START + STACK_SIZE, + file_offset: 0x1000 + HEAP_SIZE, + file_size: STACK_SIZE, + perms: Perms { + read: true, + write: true, + execute: false, + }, + kind: RangeKind::Anon, + path: None, + name: Some("[stack]".into()), + }, + ]; + let map = MemoryMap::from_ranges(ranges); + Ctx { + map, + bytes: vec![0u8; 0x21000], + } + } + + fn thread(sp: u64, reg_words: Vec) -> ThreadContext { + ThreadContext { + tid: 1, + reg_words, + ip: 0x400500, + sp, + } + } + + #[test] + fn target_at_empty_index() { + let index = index_objects(&[]); + assert!(index.target_at(0x610000).is_none()); + } + + #[test] + fn target_at_overlap_picks_smallest() { + let objects = vec![ + obj(0x610000, 0x40, ObjectState::Allocated), + obj(0x610010, 0x20, ObjectState::Allocated), + ]; + let index = index_objects(&objects); + assert_eq!(index.target_at(0x610018).unwrap().addr, 0x610010); + assert_eq!(index.target_at(0x610008).unwrap().addr, 0x610000); + assert_eq!(index.target_at(0x610000).unwrap().addr, 0x610000); + assert!(index.target_at(0x610040).is_none()); + assert!(index.target_at(0x0).is_none()); + } + + #[test] + fn basic_object_edge() { + let mut c = ctx(); + c.put(0x600110, 0x600200); + let image = c.image(); + let objects = vec![ + obj(0x600100, 0x40, ObjectState::Allocated), + obj(0x600200, 0x40, ObjectState::Allocated), + ]; + let inventory = HeapInventory { + arenas: Vec::new(), + objects, + }; + let res = scan(&image, &inventory, &[], &ScanOptions::default()); + assert_eq!(res.edges.len(), 1); + let e = &res.edges[0]; + assert_eq!(e.from, 0x600100); + assert_eq!(e.to, 0x600200); + assert_eq!(e.offset, 0x10); + assert_eq!(e.source, EdgeSource::Object); + assert!(res.roots.is_empty()); + assert!(res.stray_pointers.is_empty()); + } + + #[test] + fn self_pointer_skipped() { + let mut c = ctx(); + c.put(0x600120, 0x600100); // degenerate self-loop at exactly obj.addr + c.put(0x600128, 0x600110); // pointer into own interior -> valid self edge + let image = c.image(); + let objects = vec![obj(0x600100, 0x40, ObjectState::Allocated)]; + let inventory = HeapInventory { + arenas: Vec::new(), + objects, + }; + let res = scan(&image, &inventory, &[], &ScanOptions::default()); + assert!( + !res + .edges + .iter() + .any(|e| e.from == 0x600100 && e.to == 0x600100 && e.offset == 0x20), + "pointer equal to obj.addr must be skipped" + ); + assert!( + res.edges + .iter() + .any(|e| e.from == 0x600100 && e.to == 0x600100 && e.offset == 0x28), + "pointer into own interior is a valid self edge" + ); + } + + #[test] + fn stray_pointer_not_edge() { + let mut c = ctx(); + c.put(0x600210, 0x600500); // B's user area points into empty heap space + let image = c.image(); + let objects = vec![obj(0x600200, 0x40, ObjectState::Allocated)]; + let inventory = HeapInventory { + arenas: Vec::new(), + objects, + }; + let res = scan(&image, &inventory, &[], &ScanOptions::default()); + assert!(res.edges.is_empty()); + assert!(res.stray_pointers.contains(&0x600500)); + } + + #[test] + fn stack_and_register_roots() { + let mut c = ctx(); + c.put(0x7fff1000, 0x600200); + c.put(0x7fff1008, 0x600300); + let image = c.image(); + let objects = vec![ + obj(0x600100, 0x40, ObjectState::Allocated), + obj(0x600200, 0x40, ObjectState::Allocated), + obj(0x600300, 0x40, ObjectState::Allocated), + ]; + let inventory = HeapInventory { + arenas: Vec::new(), + objects, + }; + let index = index_objects(&inventory.objects); + let threads = vec![thread(0x7fff1000, vec![0x600300, 0, 0xdeadbeef])]; + + let roots = collect_roots(&image, &threads, &index); + assert!( + roots + .iter() + .any(|r| r.value == 0x600200 && r.source == RootSource::Stack && r.addr == 0x7fff1000) + ); + assert!( + roots + .iter() + .any(|r| r.value == 0x600300 && r.source == RootSource::Register && r.addr == 0) + ); + assert!(!roots.iter().any(|r| r.value == 0xdeadbeef)); + + let res = scan(&image, &inventory, &threads, &ScanOptions::default()); + assert!( + res.edges.iter().any(|e| e.from == 0 + && e.to == 0x600200 + && e.offset == 0x7fff1000 + && e.source == EdgeSource::Stack) + ); + assert!( + res.edges.iter().any(|e| e.from == 0 + && e.to == 0x600300 + && e.offset == 0 + && e.source == EdgeSource::Register) + ); + } + + #[test] + fn freed_target_filtering() { + let mut c = ctx(); + c.put(0x600110, 0x600400); + let image = c.image(); + let objects = vec![ + obj(0x600100, 0x40, ObjectState::Allocated), + obj(0x600400, 0x40, ObjectState::Freed), + ]; + let inventory = HeapInventory { + arenas: Vec::new(), + objects, + }; + + let res = scan(&image, &inventory, &[], &ScanOptions::default()); + assert!( + res.edges + .iter() + .any(|e| e.from == 0x600100 && e.to == 0x600400 && e.offset == 0x10) + ); + + let opts = ScanOptions { + include_freed_targets: false, + ..ScanOptions::default() + }; + let res = scan(&image, &inventory, &[], &opts); + assert!(!res.edges.iter().any(|e| e.to == 0x600400)); + } + + #[test] + fn confirmed_two_sources() { + let mut c = ctx(); + c.put(0x600110, 0x600200); // A -> B + c.put(0x600118, 0x600300); // A -> C + c.put(0x600210, 0x600300); // B -> C (second object source for C) + c.put(0x600138, 0x600180); // A -> E (E's only source) + let image = c.image(); + let objects = vec![ + obj(0x600100, 0x40, ObjectState::Allocated), // A + obj(0x600180, 0x40, ObjectState::Allocated), // E + obj(0x600200, 0x40, ObjectState::Allocated), // B + obj(0x600300, 0x40, ObjectState::Allocated), // C + ]; + let inventory = HeapInventory { + arenas: Vec::new(), + objects, + }; + let threads = vec![thread(0x7fff1000, vec![0x600300])]; + let res = scan(&image, &inventory, &threads, &ScanOptions::default()); + + let e_ab = res + .edges + .iter() + .find(|e| e.from == 0x600100 && e.to == 0x600200) + .expect("A->B edge"); + assert!(!e_ab.confirmed, "B referenced by a single source only"); + + for e in res.edges.iter().filter(|e| e.to == 0x600300) { + assert!(e.confirmed, "C referenced by >= 2 sources"); + } + + let e_ae = res + .edges + .iter() + .find(|e| e.from == 0x600100 && e.to == 0x600180) + .expect("A->E edge"); + assert!(!e_ae.confirmed, "E referenced by a single source only"); + } + + #[test] + fn stack_word_does_not_confirm_object_edge() { + let mut c = ctx(); + c.put(0x600110, 0x600200); // A -> B (B's only object source) + c.put(0x7fff1000, 0x600200); // stale stack word also points at B + let image = c.image(); + let objects = vec![ + obj(0x600100, 0x40, ObjectState::Allocated), // A + obj(0x600200, 0x40, ObjectState::Allocated), // B + ]; + let inventory = HeapInventory { + arenas: Vec::new(), + objects, + }; + let threads = vec![thread(0x7fff1000, vec![])]; + let res = scan(&image, &inventory, &threads, &ScanOptions::default()); + + let e_ab = res + .edges + .iter() + .find(|e| e.from == 0x600100 && e.to == 0x600200) + .expect("A->B object edge"); + assert!( + !e_ab.confirmed, + "a stack word must not confirm an object edge (single object source)" + ); + + let e_stack = res + .edges + .iter() + .find(|e| e.source == EdgeSource::Stack && e.to == 0x600200) + .expect("stack edge"); + assert!( + !e_stack.confirmed, + "a stack edge is never confirmed by the >= 2 rule" + ); + } + + #[test] + fn back_pointer_confirms_edge() { + let mut c = ctx(); + c.put(0x600110, 0x600200); // A -> B + c.put(0x600210, 0x600100); // B -> A (back-pointer) + let image = c.image(); + let objects = vec![ + obj(0x600100, 0x40, ObjectState::Allocated), + obj(0x600200, 0x40, ObjectState::Allocated), + ]; + let inventory = HeapInventory { + arenas: Vec::new(), + objects, + }; + let res = scan(&image, &inventory, &[], &ScanOptions::default()); + let e_ab = res + .edges + .iter() + .find(|e| e.from == 0x600100 && e.to == 0x600200) + .expect("A->B edge"); + assert!(e_ab.confirmed, "A->B confirmed by B's back-pointer"); + } + + #[test] + fn dedup_collapses_duplicate_edges() { + let mut c = ctx(); + c.put(0x600110, 0x600200); // A -> B (offset 0x10) + c.put(0x600120, 0x600200); // A -> B again (offset 0x20, distinct) + let image = c.image(); + let objects = vec![ + obj(0x600100, 0x40, ObjectState::Allocated), + obj(0x600200, 0x40, ObjectState::Allocated), + ]; + let inventory = HeapInventory { + arenas: Vec::new(), + objects, + }; + let res = scan(&image, &inventory, &[], &ScanOptions::default()); + assert_eq!(res.edges.len(), 2); + assert!( + res.edges + .iter() + .filter(|e| e.from == 0x600100 && e.to == 0x600200) + .count() + == 2 + ); + } + + #[test] + fn in_use_chunk_spill_area_scanned() { + // A 0x20 glibc chunk reports usable size 0x10, but an in-use chunk's + // data may extend 8 bytes into the next chunk's prev_size (e.g. a + // 24-byte std::vector stores its 3rd word at offset 0x10). The scan + // must cover that spill area for Allocated chunks. + let mut c = ctx(); + c.put(0x600110, 0x600200); // word at offset 0x10 (spill area) -> B + let image = c.image(); + let objects = vec![ + obj(0x600100, 0x10, ObjectState::Allocated), // chunk 0x20 + obj(0x600200, 0x20, ObjectState::Allocated), + ]; + let inventory = HeapInventory { + arenas: Vec::new(), + objects, + }; + let res = scan(&image, &inventory, &[], &ScanOptions::default()); + assert!( + res.edges.iter().any(|e| e.from == 0x600100 + && e.to == 0x600200 + && e.offset == 0x10), + "pointer in the +8 spill area must be an edge for in-use chunks" + ); + } + + #[test] + fn freed_chunk_spill_area_not_scanned() { + // Freed chunks carry free-list data in their user area, so no +8 spill. + let mut c = ctx(); + c.put(0x600110, 0x600200); // would be the spill word if scanned + let image = c.image(); + let objects = vec![ + obj(0x600100, 0x10, ObjectState::Freed), + obj(0x600200, 0x20, ObjectState::Allocated), + ]; + let inventory = HeapInventory { + arenas: Vec::new(), + objects, + }; + let res = scan(&image, &inventory, &[], &ScanOptions::default()); + assert!( + !res.edges.iter().any(|e| e.from == 0x600100 && e.offset == 0x10), + "freed chunks must not get spill-area scanning" + ); + } + + #[test] + fn options_turn_off_scans() { + let mut c = ctx(); + c.put(0x600110, 0x600200); + c.put(0x7fff1000, 0x600200); + let image = c.image(); + let objects = vec![ + obj(0x600100, 0x40, ObjectState::Allocated), + obj(0x600200, 0x40, ObjectState::Allocated), + ]; + let inventory = HeapInventory { + arenas: Vec::new(), + objects, + }; + let threads = vec![thread(0x7fff1000, vec![0x600200])]; + let opts = ScanOptions { + scan_objects: false, + scan_stacks: false, + scan_registers: true, + ..ScanOptions::default() + }; + let res = scan(&image, &inventory, &threads, &opts); + assert!(res.edges.iter().all(|e| e.source == EdgeSource::Register)); + assert!(res.roots.iter().all(|r| r.source == RootSource::Register)); + } +} diff --git a/crates/naksheap-pointer-scan/src/roots.rs b/crates/naksheap-pointer-scan/src/roots.rs new file mode 100644 index 0000000..885b728 --- /dev/null +++ b/crates/naksheap-pointer-scan/src/roots.rs @@ -0,0 +1,122 @@ +use naksheap_core_parse::{AddressSpace, ThreadContext}; + +use crate::{Edge, EdgeSource, ObjectIndex, Root, RootSource, ScanOptions}; + +/// Upper bound on stack bytes scanned per thread, to bound cost on huge stacks. +const MAX_STACK_SCAN_BYTES: u64 = 4 * 1024 * 1024; + +/// Upper bound on the number of [`RootSource::Stack`] roots collected per +/// thread, to bound work on corrupted or pathological stacks. +const MAX_STACK_ROOTS: usize = 4096; + +/// Collects root pointer candidates from thread registers and stacks. +/// +/// Registers: each non-zero register word that lands inside a carved object +/// becomes a [`Root`] with source [`RootSource::Register`] and `addr` 0. +/// +/// Stacks: the LIVE frame region of the mapped range containing each thread's +/// `sp` is read word-by-word, starting at `sp` and scanning UP to the highest +/// mapped address (bounded to the first 4 MiB above `sp` and only for writable +/// ranges). Dead frames below `sp` are already unwound and are never scanned. +/// Every word that points into a carved object becomes a [`Root`] with source +/// [`RootSource::Stack`] and `addr` equal to the stack address. Words equal to +/// `sp` itself or to the stack base are skipped as noise, and the number of +/// stack roots per thread is capped at [`MAX_STACK_ROOTS`]. +pub fn collect_roots( + image: &(dyn AddressSpace + Sync), + threads: &[ThreadContext], + index: &ObjectIndex, +) -> Vec { + let defaults = ScanOptions::default(); + let mut roots = Vec::new(); + for thread in threads { + let (r, _) = scan_thread(image, thread, index, &defaults); + roots.extend(r); + } + roots +} + +/// Scans one thread for root candidates, returning both the roots and the +/// matching root edges. Shared by [`collect_roots`] (which keeps only the +/// roots) and the full [`crate::scan`] (which needs the edges too). +pub(crate) fn scan_thread( + image: &(dyn AddressSpace + Sync), + thread: &ThreadContext, + index: &ObjectIndex, + options: &ScanOptions, +) -> (Vec, Vec) { + let mut roots = Vec::new(); + let mut edges = Vec::new(); + + if options.scan_registers { + for w in &thread.reg_words { + if *w == 0 { + continue; + } + if let Some(t) = index.target_at(*w) { + roots.push(Root { + value: *w, + source: RootSource::Register, + addr: 0, + }); + edges.push(Edge { + from: 0, + to: t.addr, + offset: 0, + confirmed: false, + source: EdgeSource::Register, + }); + } + } + } + + if options.scan_stacks { + if let Some(range) = image.range_at(thread.sp) { + if range.is_writable() && range.perms.read { + let word = image.pointer_width() as u64; + // Scan only the LIVE frame region: start at `sp` and go up + // toward the highest address in the mapping. Bytes below `sp` + // are dead, already-unwound frames and are skipped. The end is + // still bounded by MAX_STACK_SCAN_BYTES above `sp`. + let end = thread + .sp + .saturating_add(MAX_STACK_SCAN_BYTES) + .min(range.end); + let mut addr = thread.sp; + let mut stack_roots = 0usize; + while let Some(next) = addr.checked_add(word) { + if next > end { + break; + } + if let Some(w) = image.read_word(addr) { + // Skip noise words equal to `sp` itself or the stack + // base: they describe stack frames, not heap roots. + if w != 0 && w != thread.sp && w != range.start { + if let Some(t) = index.target_at(w) { + roots.push(Root { + value: w, + source: RootSource::Stack, + addr, + }); + edges.push(Edge { + from: 0, + to: t.addr, + offset: addr, + confirmed: false, + source: EdgeSource::Stack, + }); + stack_roots += 1; + if stack_roots >= MAX_STACK_ROOTS { + break; + } + } + } + } + addr = next; + } + } + } + } + + (roots, edges) +} diff --git a/crates/naksheap-pointer-scan/src/scan.rs b/crates/naksheap-pointer-scan/src/scan.rs new file mode 100644 index 0000000..4da781a --- /dev/null +++ b/crates/naksheap-pointer-scan/src/scan.rs @@ -0,0 +1,264 @@ +use std::collections::{HashMap, HashSet}; + +use naksheap_allocator_heuristics::{HeapInventory, Object, ObjectState}; +use naksheap_core_parse::{AddressSpace, ThreadContext}; +use rayon::prelude::*; + +use crate::index::{index_objects, ObjectIndex}; +use crate::roots::scan_thread; +use crate::{Edge, EdgeSource, Root, ScanOptions, ScanResult}; + +/// Full scan: object->object edges, plus stack/register roots and edges. +pub fn scan( + image: &(dyn AddressSpace + Sync), + inventory: &HeapInventory, + threads: &[ThreadContext], + options: &ScanOptions, +) -> ScanResult { + let index = index_objects(&inventory.objects); + let alignment = options.alignment.max(1); + + // A freed object's user bytes are allocator free-list garbage (fd/bk + // words), so an edge that ORIGINATES in a freed object must never count + // toward confirmation. Build the freed-address set once here. + let freed_sources: HashSet = inventory + .objects + .iter() + .filter(|o| o.state == ObjectState::Freed) + .map(|o| o.addr) + .collect(); + + let mut edges: Vec = Vec::new(); + let mut strays: Vec = Vec::new(); + + if options.scan_objects { + // Each object collects its own strays into a Vec capped at + // `per_object_cap`, so a single noisy object cannot flood memory. The + // results are merged and filtered deterministically below. + let per_object_cap = options.max_strays.max(1); + let chunks: Vec<(Vec, Vec)> = inventory + .objects + .par_iter() + .map(|o| scan_object(image, &index, o, options, alignment, per_object_cap)) + .collect(); + for (mut e, mut s) in chunks { + edges.append(&mut e); + strays.append(&mut s); + } + // Deterministic post-merge filtering: which strays survive must never + // depend on parallel scheduling order, so sort, dedup, then truncate. + strays.sort_unstable(); + strays.dedup(); + strays.truncate(options.max_strays); + } + + let mut roots: Vec = Vec::new(); + if options.scan_registers || options.scan_stacks { + for thread in threads { + let (mut r, mut e) = scan_thread(image, thread, &index, options); + roots.append(&mut r); + edges.append(&mut e); + } + } + + if !options.include_freed_targets { + edges.retain(|e| !target_is_freed(&index, e.to)); + roots.retain(|r| !target_is_freed(&index, r.value)); + } + + // Source object sizes, used to exclude spill-area offsets (words at or + // past the reported usable size, in the next chunk's `prev_size`) from + // confirmation: they are dead-memory reads, not deliberate references. + let source_sizes: HashMap = + inventory.objects.iter().map(|o| (o.addr, o.size)).collect(); + dedup_and_confirm(&mut edges, &freed_sources, &source_sizes); + + ScanResult { + edges, + roots, + stray_pointers: strays, + } +} + +/// Scans one object's user area at `alignment`-byte strides and records every +/// word that resolves to another carved object as an [`Edge`]. Words that point +/// outside every object are collected as strays, deterministically capped at +/// `stray_cap` for this object; the caller merges all per-object lists and +/// applies the global `max_strays` truncation. +fn scan_object( + image: &(dyn AddressSpace + Sync), + index: &ObjectIndex, + o: &Object, + options: &ScanOptions, + alignment: u64, + stray_cap: usize, +) -> (Vec, Vec) { + let mut edges = Vec::new(); + let mut strays = Vec::new(); + let word = image.pointer_width() as u64; + // In-use glibc chunks may extend their data 8 bytes past the reported + // usable size into the next chunk's `prev_size` field (glibc uses that + // space for small allocations, e.g. a 24-byte std::vector's third word + // lands at offset 0x10 of a 0x20 chunk). Scan that spill area so the + // final pointer word of minimal-size objects is not missed. Freed and + // mmapped chunks carry free-list/standalone data instead, so they get no + // spill. + let scan_extent = if o.state == ObjectState::Allocated { + o.size.saturating_add(8) + } else { + o.size + }; + let mut off = 0u64; + while let Some(end) = off.checked_add(word) { + if end > scan_extent { + break; + } + let addr = match o.addr.checked_add(off) { + Some(a) => a, + None => break, + }; + if let Some(w) = image.read_word(addr) { + if w != 0 { + if w >= o.chunk_header && w <= o.addr { + // Pointer into this object's own chunk header / header + // area, or the degenerate self-loop exactly at `o.addr`: + // not a reference edge. + } else if let Some(t) = index.target_at(w) { + if options.include_freed_targets || t.state != ObjectState::Freed { + edges.push(Edge { + from: o.addr, + to: t.addr, + offset: off, + confirmed: false, + source: EdgeSource::Object, + }); + } + } else if strays.len() < stray_cap { + strays.push(w); + } + } + } + off += alignment; + } + (edges, strays) +} + +/// True when `value` resolves into a freed carved object. +fn target_is_freed(index: &ObjectIndex, value: u64) -> bool { + index + .target_at(value) + .is_some_and(|o| o.state == ObjectState::Freed) +} + +/// Deduplicates edges by `(from, to, offset)` keeping the first, then computes +/// the `confirmed` flag. An edge is confirmed when its target is referenced by +/// two or more distinct *object* sources, or when it is an object edge whose +/// target holds a back-pointer into the source object. Stack and register +/// (root) edges never count as confirming sources: a single stale stack word or +/// register value must not elevate an object edge to confirmed. Edges that +/// originate in freed objects are free-list garbage (stale fd/bk words) and +/// likewise never count toward the `>= 2` rule or the back-pointer rule. +/// Spill-area edges (offset at or past the source's reported usable size, i.e. +/// words read from the next chunk's dead `prev_size`) are also excluded from +/// confirmation: they are incidental dead-memory reads, not deliberate links. +fn dedup_and_confirm( + edges: &mut Vec, + freed_sources: &HashSet, + source_sizes: &HashMap, +) { + let mut seen: HashSet<(u64, u64, u64)> = HashSet::new(); + edges.retain(|e| seen.insert((e.from, e.to, e.offset))); + + // Bounded source bookkeeping: the rule only needs `>= 2` distinct sources, + // so per target we store just the first two DISTINCT (from) values in a + // flat pair instead of a per-target HashSet. `0` is the empty-slot + // sentinel; object addresses are never 0. Once the second distinct source + // is recorded, further sources cannot change the outcome and are ignored. + let mut sources: HashMap = HashMap::new(); + // Reverse-pair lookup for the back-pointer rule, derived once from the + // (already deduplicated) object edge list rather than a parallel set. + let mut object_pairs: HashSet<(u64, u64)> = HashSet::new(); + for e in edges.iter() { + // Only object edges count toward confirmation; freed-object edges are + // stale free-list words and are excluded from both structures. + let spill = source_sizes + .get(&e.from) + .is_some_and(|size| e.offset >= *size); + if e.source != EdgeSource::Object + || freed_sources.contains(&e.from) + || spill + { + continue; + } + object_pairs.insert((e.from, e.to)); + let pair = sources.entry(e.to).or_insert((e.from, 0)); + if pair.0 != e.from && pair.1 == 0 { + pair.1 = e.from; + } + } + for e in edges.iter_mut() { + let distinct = match sources.get(&e.to) { + Some((_, 0)) => 1, + Some(_) => 2, + None => 0, + }; + let backref = + e.source == EdgeSource::Object && object_pairs.contains(&(e.to, e.from)); + e.confirmed = distinct >= 2 || backref; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn edge(from: u64, to: u64) -> Edge { + Edge { + from, + to, + offset: 0x10, + confirmed: false, + source: EdgeSource::Object, + } + } + + #[test] + fn freed_object_edge_does_not_confirm_target() { + // A (live) and F (freed) both point at live target T. F's stale + // free-list word must not count toward the `>= 2` rule, so the A->T + // edge stays unconfirmed. + let mut edges = vec![edge(0x1000, 0x3000), edge(0x2000, 0x3000)]; + let freed = HashSet::from([0x2000u64]); + dedup_and_confirm(&mut edges, &freed, &HashMap::new()); + let a_t = edges + .iter() + .find(|e| e.from == 0x1000 && e.to == 0x3000) + .expect("A->T edge"); + assert!(!a_t.confirmed, "freed object's stale word must not confirm"); + } + + #[test] + fn two_live_sources_still_confirm() { + let mut edges = vec![edge(0x1000, 0x3000), edge(0x2000, 0x3000)]; + let freed = HashSet::new(); + dedup_and_confirm(&mut edges, &freed, &HashMap::new()); + for e in edges.iter().filter(|e| e.to == 0x3000) { + assert!(e.confirmed, "two live sources confirm the target"); + } + } + + #[test] + fn freed_source_cannot_trigger_back_pointer() { + // A -> F with F freed, and a stale F -> A free-list word. Excluding + // the freed F->A edge from `object_pairs` means A->F cannot be + // confirmed by F's back-pointer. + let mut edges = vec![edge(0x1000, 0x2000), edge(0x2000, 0x1000)]; + let freed = HashSet::from([0x2000u64]); + dedup_and_confirm(&mut edges, &freed, &HashMap::new()); + let a_f = edges + .iter() + .find(|e| e.from == 0x1000 && e.to == 0x2000) + .expect("A->F edge"); + assert!(!a_f.confirmed, "freed object's back-pointer must not confirm"); + } +} diff --git a/crates/naksheap-testkit/Cargo.toml b/crates/naksheap-testkit/Cargo.toml new file mode 100644 index 0000000..153c9c2 --- /dev/null +++ b/crates/naksheap-testkit/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "naksheap-testkit" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Test fixture generation: deterministic synthetic ELF core dumps with ground-truth manifests" + +[dependencies] +naksheap-core-parse = { path = "../naksheap-core-parse" } +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tempfile.workspace = true +naksheap-allocator-heuristics = { path = "../naksheap-allocator-heuristics" } diff --git a/crates/naksheap-testkit/examples/dump.rs b/crates/naksheap-testkit/examples/dump.rs new file mode 100644 index 0000000..c24ec25 --- /dev/null +++ b/crates/naksheap-testkit/examples/dump.rs @@ -0,0 +1,27 @@ +//! Debug helper: dump raw memory (words / strings) from a core file. +//! Usage: cargo run -p naksheap-testkit --example dump -- ... +//! Each may be hex (0x...) and is printed as a 8-byte word. + +use naksheap_core_parse::AddressSpace; +use naksheap_core_parse::open; + +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 3 { + eprintln!("usage: dump ..."); + std::process::exit(2); + } + let core = open(std::path::Path::new(&args[1])).expect("open core"); + for a in &args[2..] { + let addr = u64::from_str_radix(a.trim_start_matches("0x"), 16).expect("addr"); + print!("{addr:#018x}: "); + for off in 0..8 { + let w = core.image.read_word(addr + off * 8); + match w { + Some(w) => print!("[{off:#x}]={w:#018x} "), + None => print!("[?] "), + } + } + println!(); + } +} diff --git a/crates/naksheap-testkit/examples/gen_fixture.rs b/crates/naksheap-testkit/examples/gen_fixture.rs new file mode 100644 index 0000000..be7e625 --- /dev/null +++ b/crates/naksheap-testkit/examples/gen_fixture.rs @@ -0,0 +1,20 @@ +//! Generates a fixture core dump + ground-truth manifest for manual testing. +//! +//! Usage: `cargo run -p naksheap-testkit --example gen_fixture -- ` + +use naksheap_testkit::CoreSpec; + +fn main() { + let out = std::env::args() + .nth(1) + .unwrap_or_else(|| "/tmp/naksheap-fixtures".to_string()); + let dir = std::path::Path::new(&out); + std::fs::create_dir_all(dir).expect("create out dir"); + + let spec = CoreSpec::default(); + let fixture = spec.build().expect("build fixture"); + let core = dir.join("toy-server.core"); + fixture.write(&core).expect("write core"); + fixture.write_manifest(&core).expect("write manifest"); + println!("wrote {}", core.display()); +} diff --git a/crates/naksheap-testkit/src/build.rs b/crates/naksheap-testkit/src/build.rs new file mode 100644 index 0000000..39c6995 --- /dev/null +++ b/crates/naksheap-testkit/src/build.rs @@ -0,0 +1,664 @@ +//! Deterministic synthetic ELF64 core builder. +//! +//! Turns a [`CoreSpec`] into byte-for-byte reproducible ELF64 core bytes plus +//! a ground-truth [`Manifest`]. The layout mirrors a real Linux/glibc crash +//! dump closely enough that `naksheap-core-parse` and +//! `naksheap-allocator-heuristics` recover exactly what the spec asked for: +//! +//! * an `ET_CORE` ELF64 header (x86-64, little-endian); +//! * six program headers: `PT_LOAD` x5 (executable text, libc data, rodata, +//! heap, stack) plus one `PT_NOTE`; +//! * `NT_PRSTATUS` / `NT_PRPSINFO` / `NT_FILE` notes; +//! * a glibc-style heap blob (chunk headers, a `main_arena`-pointed top +//! chunk) and the `malloc_state` in a fake libc data blob; +//! * a stack blob holding root pointers. +//! +//! No randomness, no hash-iteration order: identical specs produce identical +//! bytes (asserted by the determinism tests). + +use std::collections::HashMap; + +use crate::error::{BuilderError, Result}; +use crate::manifest::{ + EdgeKind, Manifest, ManifestArena, ManifestEdge, ManifestObject, ManifestRoot, ObjectState, + RootKind, +}; +use crate::spec::{CoreSpec, PointerField, SpecState, Target}; + +/// ELF class byte for 64-bit little-endian. +const ELFCLASS64: u8 = 2; +const ELFDATA2LSB: u8 = 1; +const EV_CURRENT: u8 = 1; +/// `ET_CORE`. +const ET_CORE: u16 = 4; +/// `EM_X86_64`. +const EM_X86_64: u16 = 62; + +const PT_LOAD: u32 = 1; +const PT_NOTE: u32 = 4; + +const NT_PRSTATUS: u32 = 1; +const NT_PRPSINFO: u32 = 3; +const NT_FILE: u32 = 0x46494c45; // "FILE" + +/// glibc chunk flag: previous chunk is in use. +const PREV_INUSE: u64 = 1; +/// Byte offset of `top` inside `malloc_state` (64-bit glibc). +const ARENA_TOP_OFFSET: u64 = 0x60; +/// Byte offset of `next` inside `malloc_state` (64-bit glibc). +const ARENA_NEXT_OFFSET: u64 = 0x870; +/// `main_arena` is placed at `libc_base + ARENA_OFFSET` inside the libc blob. +const ARENA_OFFSET: u64 = 0x1000; + +const PAGE_SIZE: u64 = 0x1000; +const MIN_USER_SIZE: u64 = 0x10; +/// Minimum size the top chunk is given so `walk_heap` and the arena scorer +/// treat it as a real top chunk. +const TOP_MIN_SIZE: u64 = 0x40; + +/// x86_64 `user_regs_struct` indices used by the fixture. +const REG_RBP: usize = 4; +const REG_RDI: usize = 14; +const REG_RIP: usize = 16; +const REG_RSP: usize = 19; + +const TEXT_SIZE: u64 = 0x1000; +const LIBC_SIZE: u64 = 0x4000; +/// Read-only, file-backed region mimicking program rodata (vtable/string +/// literals). Sits immediately after the libc blob so it does not overlap it. +const RODATA_BASE: u64 = 0x7faa_0000_4000; +const RODATA_SIZE: u64 = 0x1000; +/// Offset inside the rodata region where the fake vtable lives. +const RODATA_VTABLE_OFFSET: u64 = 0x80; +/// Number of u64 slots in the fake vtable. +const RODATA_VTABLE_SLOTS: usize = 4; + +/// A resolved, validated view of a spec: addresses assigned, pointers +/// resolved, and regions proven to fit. +struct Layout { + text_base: u64, + libc_base: u64, + heap_base: u64, + heap_size: u64, + stack_base: u64, + stack_size: u64, + arena_addr: u64, + arena_top: u64, + objects: Vec, + /// (stack slot address, pointer value). + stack_roots: Vec<(u64, u64)>, +} + +struct ObjectLayout { + label: String, + header: u64, + addr: u64, + mask: u64, + user_size: u64, + state: SpecState, + fill: u8, + /// Pointer fields lifted from the spec, with target addresses resolved. + pointers: Vec<(usize, u64)>, +} + +fn align16(x: u64) -> u64 { + (x + 0xf) & !0xf +} + +/// Resolves a target to an absolute address once all object addresses are +/// known. +fn resolve(label_addr: &HashMap, target: &Target) -> Result { + match target { + Target::Absolute(a) => Ok(*a), + Target::Label(l) => label_addr + .get(l) + .copied() + .ok_or_else(|| BuilderError::UnknownTarget(l.clone())), + } +} + +impl Layout { + fn compute(spec: &CoreSpec) -> Result { + // Alignments: every region base must be page aligned so the fixture + // looks like a real mapping and the arena candidate stays 16-aligned. + for (field, value) in [ + ("text_base", spec.text_base), + ("libc_base", spec.libc_base), + ("heap_base", spec.heap_base), + ("stack_base", spec.stack_base), + ] { + if value % PAGE_SIZE != 0 { + return Err(BuilderError::Misaligned { + field: field.to_string(), + value, + }); + } + } + + let heap_end = spec + .heap_base + .checked_add(spec.heap_size) + .ok_or(BuilderError::HeapLayout { + heap_base: spec.heap_base, + heap_end: u64::MAX, + })?; + let stack_end = spec + .stack_base + .checked_add(spec.stack_size) + .ok_or(BuilderError::StackLayout { + stack_base: spec.stack_base, + stack_end: u64::MAX, + })?; + + for o in &spec.objects { + if spec + .objects + .iter() + .filter(|x| x.label == o.label) + .count() + > 1 + { + return Err(BuilderError::DuplicateLabel(o.label.clone())); + } + } + + // Pass 1: assign chunk addresses in order. The first chunk header + // (prev_size) sits at the very start of the heap region, matching the + // real glibc main-arena layout where carving advances from the brk base. + let mut cursor = spec.heap_base; + let mut objects: Vec = Vec::with_capacity(spec.objects.len()); + let mut total_mask: u64 = 0; + for o in &spec.objects { + if o.size < MIN_USER_SIZE as usize { + return Err(BuilderError::ObjectTooSmall { + label: o.label.clone(), + size: o.size, + }); + } + let mask = align16(MIN_USER_SIZE + o.size as u64); + let user_size = mask - MIN_USER_SIZE; + objects.push(ObjectLayout { + label: o.label.clone(), + header: cursor, + addr: cursor + MIN_USER_SIZE, + mask, + user_size, + state: o.state, + fill: o.fill, + pointers: Vec::new(), + }); + cursor += mask; + total_mask += mask; + } + let top_header = cursor; + let required = total_mask + TOP_MIN_SIZE; + if spec.heap_size < required { + return Err(BuilderError::HeapLayout { + heap_base: spec.heap_base, + heap_end, + }); + } + + // Pass 2: resolve pointer targets now that every address is known. + let label_addr: HashMap = + objects.iter().map(|o| (o.label.clone(), o.addr)).collect(); + let mut fields_by_label: HashMap<&str, &[PointerField]> = + spec.objects.iter().map(|o| (o.label.as_str(), o.pointers.as_slice())).collect(); + for o in objects.iter_mut() { + let fields = fields_by_label + .remove(o.label.as_str()) + .unwrap_or_default(); + for pf in fields { + if pf.offset + 8 > o.user_size as usize { + return Err(BuilderError::PointerOutOfBounds { + label: o.label.clone(), + offset: pf.offset, + size: o.user_size as usize, + }); + } + o.pointers + .push((pf.offset, resolve(&label_addr, &pf.target)?)); + } + } + + let mut stack_roots = Vec::with_capacity(spec.roots.len()); + for r in &spec.roots { + let slot_end = r.address.checked_add(8).ok_or({ + BuilderError::RootOutsideStack { + address: r.address, + stack_base: spec.stack_base, + stack_end, + } + })?; + if r.address < spec.stack_base || slot_end > stack_end { + return Err(BuilderError::RootOutsideStack { + address: r.address, + stack_base: spec.stack_base, + stack_end, + }); + } + stack_roots.push((r.address, resolve(&label_addr, &r.target)?)); + } + + Ok(Layout { + text_base: spec.text_base, + libc_base: spec.libc_base, + heap_base: spec.heap_base, + heap_size: spec.heap_size, + stack_base: spec.stack_base, + stack_size: spec.stack_size, + arena_addr: spec.libc_base + ARENA_OFFSET, + // `main_arena.top` is an mchunkptr (the top chunk's header). + arena_top: top_header, + objects, + stack_roots, + }) + } +} + +/// Main entry point: builds core bytes + manifest for `spec`. +pub(crate) fn build_spec(spec: &CoreSpec) -> Result<(Vec, Manifest)> { + let layout = Layout::compute(spec)?; + let manifest = build_manifest(spec, &layout); + let bytes = assemble(spec, &layout)?; + Ok((bytes, manifest)) +} + +fn build_manifest(spec: &CoreSpec, layout: &Layout) -> Manifest { + let addr_to_label: HashMap = + layout.objects.iter().map(|o| (o.addr, o.label.as_str())).collect(); + + let objects: Vec = layout + .objects + .iter() + .map(|o| ManifestObject { + addr: o.addr, + size: o.user_size, + state: match o.state { + SpecState::Allocated => ObjectState::Allocated, + SpecState::Freed => ObjectState::Freed, + }, + arena: layout.arena_addr, + chunk_header: o.header, + label: o.label.clone(), + }) + .collect(); + + let mut edges = Vec::new(); + for o in &layout.objects { + for (offset, value) in &o.pointers { + let (to_label, kind) = match addr_to_label.get(value) { + Some(label) => (Some((*label).to_string()), EdgeKind::Heap), + None => (None, EdgeKind::Rodata), + }; + edges.push(ManifestEdge { + from_addr: o.addr, + from_label: o.label.clone(), + offset: *offset, + to_addr: *value, + to_label, + kind, + }); + } + } + + let mut roots: Vec = layout + .stack_roots + .iter() + .map(|(slot, value)| ManifestRoot { + addr: *slot, + value: *value, + kind: RootKind::Stack, + target_label: addr_to_label.get(value).map(|l| l.to_string()), + }) + .collect(); + + // Register root: RDI points at the first heap object. + if let Some(first) = layout.objects.first() { + roots.push(ManifestRoot { + addr: REG_RDI as u64, + value: first.addr, + kind: RootKind::Register, + target_label: Some(first.label.clone()), + }); + } + + roots.sort_by_key(|r| (matches!(r.kind, RootKind::Register), r.addr)); + + Manifest { + pointer_width: 8, + pid: spec.pid, + process_name: spec.process_name.clone(), + command_line: spec.command_line.clone(), + exec_path: spec.exec_path.clone(), + heap_base: layout.heap_base, + heap_end: layout.heap_base + layout.heap_size, + arenas: vec![ManifestArena { + addr: layout.arena_addr, + size: layout.heap_size, + top: layout.arena_top, + is_main: true, + }], + objects, + roots, + edges, + } +} + +// --------------------------------------------------------------------------- +// ELF assembly +// --------------------------------------------------------------------------- + +const EHDR_SIZE: usize = 64; +const PHDR_SIZE: usize = 56; +const PHDR_COUNT: usize = 6; + +struct Segment { + p_type: u32, + p_flags: u32, + vaddr: u64, + offset: u64, + size: u64, + align: u64, +} + +/// Lays out the file: header + program-header table + six segment payloads. +fn plan_segments(layout: &Layout, notes_len: u64) -> Vec { + let mut offset = (EHDR_SIZE + PHDR_COUNT * PHDR_SIZE) as u64; + let mut push = |p_type: u32, p_flags: u32, vaddr: u64, size: u64, align: u64| { + let s = Segment { + p_type, + p_flags, + vaddr, + offset, + size, + align, + }; + offset += size; + s + }; + + vec![ + push(PT_LOAD, 0b101, layout.text_base, TEXT_SIZE, PAGE_SIZE), + push(PT_LOAD, 0b110, layout.libc_base, LIBC_SIZE, PAGE_SIZE), + push(PT_LOAD, 0b100, RODATA_BASE, RODATA_SIZE, PAGE_SIZE), + push(PT_LOAD, 0b110, layout.heap_base, layout.heap_size, PAGE_SIZE), + push(PT_LOAD, 0b110, layout.stack_base, layout.stack_size, PAGE_SIZE), + push(PT_NOTE, 0, 0, notes_len, 4), + ] +} + +fn assemble(spec: &CoreSpec, layout: &Layout) -> Result> { + let notes = build_notes(spec, layout); + let segments = plan_segments(layout, notes.len() as u64); + + let total = (EHDR_SIZE + PHDR_COUNT * PHDR_SIZE) as u64 + + segments.iter().map(|s| s.size).sum::(); + let mut file = vec![0u8; total as usize]; + + write_ehdr(&mut file); + for (i, seg) in segments.iter().enumerate() { + write_phdr(&mut file, i, seg); + } + + // Executable text blob: deterministic padding. + let text = &segments[0]; + file[text.offset as usize..(text.offset + text.size) as usize].fill(0xcc); + + // Fake libc data blob: zeros plus the main_arena. + let libc = &segments[1]; + write_u64_at( + &mut file, + libc.offset + (layout.arena_addr - layout.libc_base) + ARENA_TOP_OFFSET, + layout.arena_top, + ); + write_u64_at( + &mut file, + libc.offset + (layout.arena_addr - layout.libc_base) + ARENA_NEXT_OFFSET, + layout.arena_addr, + ); + // `system_mem` / `max_system_mem`: total heap region bytes, as real + // arenas maintain. Mirrors glibc's `malloc_state` offsets used by + // naksheap-allocator-heuristics' arena scorer. + write_u64_at( + &mut file, + libc.offset + (layout.arena_addr - layout.libc_base) + 0x888, + layout.heap_size, + ); + write_u64_at( + &mut file, + libc.offset + (layout.arena_addr - layout.libc_base) + 0x890, + layout.heap_size, + ); + + // Read-only rodata blob: a fake vtable at `rodata_base + 0x80` whose + // entries point into the executable text segment. + let rodata = &segments[2]; + for (i, v) in rodata_vtable(layout).iter().enumerate() { + write_u64_at( + &mut file, + rodata.offset + RODATA_VTABLE_OFFSET + (i as u64 * 8), + *v, + ); + } + + // Heap blob: chunk chain + top chunk. + let heap = &segments[3]; + write_heap(&mut file, heap.offset, layout); + + // Stack blob: root slots near the top of the region. + let stack = &segments[4]; + for (slot, value) in &layout.stack_roots { + write_u64_at(&mut file, stack.offset + (slot - layout.stack_base), *value); + } + + // Notes. + let notes_seg = &segments[5]; + file[notes_seg.offset as usize..(notes_seg.offset + notes_seg.size) as usize] + .copy_from_slice(¬es); + + Ok(file) +} + +/// The fake vtable entries: pointers into the executable text segment. +fn rodata_vtable(layout: &Layout) -> [u64; RODATA_VTABLE_SLOTS] { + [ + layout.text_base, + layout.text_base + 0x20, + layout.text_base + 0x40, + layout.text_base + 0x60, + ] +} + +fn write_u64_at(file: &mut [u8], off: u64, value: u64) { + let off = off as usize; + file[off..off + 8].copy_from_slice(&value.to_le_bytes()); +} + +fn write_ehdr(file: &mut [u8]) { + file[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']); + file[4] = ELFCLASS64; + file[5] = ELFDATA2LSB; + file[6] = EV_CURRENT; + file[7] = 0; // ELFOSABI_SYSV + file[16..18].copy_from_slice(&ET_CORE.to_le_bytes()); + file[18..20].copy_from_slice(&EM_X86_64.to_le_bytes()); + file[20..24].copy_from_slice(&1u32.to_le_bytes()); // e_version + file[32..40].copy_from_slice(&(EHDR_SIZE as u64).to_le_bytes()); // e_phoff + file[52] = EHDR_SIZE as u8; // e_ehsize + file[54] = PHDR_SIZE as u8; // e_phentsize + file[56..58].copy_from_slice(&(PHDR_COUNT as u16).to_le_bytes()); // e_phnum +} + +fn write_phdr(file: &mut [u8], idx: usize, seg: &Segment) { + let base = EHDR_SIZE + idx * PHDR_SIZE; + file[base..base + 4].copy_from_slice(&seg.p_type.to_le_bytes()); + file[base + 4..base + 8].copy_from_slice(&seg.p_flags.to_le_bytes()); + file[base + 8..base + 16].copy_from_slice(&seg.offset.to_le_bytes()); + file[base + 16..base + 24].copy_from_slice(&seg.vaddr.to_le_bytes()); + file[base + 24..base + 32].copy_from_slice(&seg.vaddr.to_le_bytes()); // p_paddr + file[base + 32..base + 40].copy_from_slice(&seg.size.to_le_bytes()); // p_filesz + file[base + 40..base + 48].copy_from_slice(&seg.size.to_le_bytes()); // p_memsz + file[base + 48..base + 56].copy_from_slice(&seg.align.to_le_bytes()); +} + +/// Writes the glibc chunk chain into the heap blob. +fn write_heap(file: &mut [u8], base: u64, layout: &Layout) { + // Byte offset of the current chunk header within the heap region; the + // first chunk header (prev_size) sits at the very start of the region. + let mut cursor = 0u64; + for (i, o) in layout.objects.iter().enumerate() { + // The PREV_INUSE bit of chunk i+1 encodes chunk i's state; the first + // chunk is preceded by nothing, so it is always "in use". + let prev_live = if i == 0 { + true + } else { + layout.objects[i - 1].state == SpecState::Allocated + }; + write_u64_at(file, base + cursor, 0); // prev_size + write_u64_at( + file, + base + cursor + 8, + o.mask | if prev_live { PREV_INUSE } else { 0 }, + ); + let user_off = base + cursor + MIN_USER_SIZE; + for k in 0..o.user_size { + file[(user_off + k) as usize] = o.fill; + } + for (pf_offset, value) in &o.pointers { + write_u64_at(file, user_off + *pf_offset as u64, *value); + } + cursor += o.mask; + } + // Top chunk: fills the rest of the region. Its PREV_INUSE bit encodes the + // state of the final real chunk. + let top_size = layout.heap_size - cursor; + let prev_live = layout + .objects + .last() + .is_none_or(|o| o.state == SpecState::Allocated); + write_u64_at(file, base + cursor, 0); // prev_size + write_u64_at( + file, + base + cursor + 8, + top_size | if prev_live { PREV_INUSE } else { 0 }, + ); +} + +// --------------------------------------------------------------------------- +// Notes +// --------------------------------------------------------------------------- + +fn pad4(buf: &mut Vec) { + while !buf.len().is_multiple_of(4) { + buf.push(0); + } +} + +struct NoteWriter { + buf: Vec, +} + +impl NoteWriter { + fn new() -> Self { + NoteWriter { buf: Vec::new() } + } + + fn push(&mut self, n_type: u32, desc: &[u8]) { + const NAME: &[u8] = b"CORE\0"; + self.buf + .extend_from_slice(&(NAME.len() as u32).to_le_bytes()); + self.buf + .extend_from_slice(&(desc.len() as u32).to_le_bytes()); + self.buf.extend_from_slice(&n_type.to_le_bytes()); + self.buf.extend_from_slice(NAME); + pad4(&mut self.buf); + self.buf.extend_from_slice(desc); + pad4(&mut self.buf); + } + + fn finish(self) -> Vec { + self.buf + } +} + +fn build_notes(spec: &CoreSpec, layout: &Layout) -> Vec { + let mut w = NoteWriter::new(); + w.push(NT_PRSTATUS, &prstatus_desc(spec, layout)); + w.push(NT_PRPSINFO, &prpsinfo_desc(spec)); + w.push(NT_FILE, &nt_file_desc(spec, layout)); + w.finish() +} + +/// 64-bit `struct elf_prstatus`: signal info, pid, four timevals, then the +/// x86_64 `user_regs_struct` (27 words) at offset `0x70`. Total desc size +/// 0x150; `pr_fpvalid` lives at 0x148 with padding out to 0x150. +fn prstatus_desc(spec: &CoreSpec, layout: &Layout) -> Vec { + let mut d = vec![0u8; 0x150]; + d[0..4].copy_from_slice(&11u32.to_le_bytes()); // si_signo = SIGSEGV + d[0x0c..0x0e].copy_from_slice(&11u16.to_le_bytes()); // pr_cursig (short) + d[0x20..0x24].copy_from_slice(&spec.pid.to_le_bytes()); + // pr_ppid/pr_pgrp/pr_sid remain zero so the u64 at 0x20 decodes to `pid`. + + let mut regs = [0u64; 27]; + regs[REG_RIP] = layout.text_base + 0x5f0; + regs[REG_RSP] = layout.stack_base + layout.stack_size - 0x30; + regs[REG_RBP] = layout.stack_base + layout.stack_size - 0x38; + if let Some(first) = layout.objects.first() { + regs[REG_RDI] = first.addr; + } + for (i, w) in regs.iter().enumerate() { + let off = 0x70 + i * 8; + d[off..off + 8].copy_from_slice(&w.to_le_bytes()); + } + d +} + +/// `struct elf_prpsinfo`: `pr_state`@0x00 (numeric), `pr_sname`@0x01, +/// `pr_fname[16]`@0x28 and `pr_psargs[80]`@0x38, so the fixture places the +/// name/command line there. +fn prpsinfo_desc(spec: &CoreSpec) -> Vec { + let mut d = vec![0u8; 0x88]; + d[0] = 0; // pr_state = running + d[1] = b'R'; // pr_sname + let fname = spec.process_name.as_bytes(); + let n = fname.len().min(16); + d[0x28..0x28 + n].copy_from_slice(&fname[..n]); + let args = spec.command_line.as_bytes(); + let n = args.len().min(80); + d[0x38..0x38 + n].copy_from_slice(&args[..n]); + d +} + +/// `NT_FILE` descriptor: `count, page_size, count x {start, end, file_ofs}`, +/// then NUL-terminated paths. Order matters: the executable must be first so +/// the parser's executable hint picks it. +fn nt_file_desc(spec: &CoreSpec, layout: &Layout) -> Vec { + const LIBC_PATH: &str = "/lib/x86_64-linux-gnu/libc.so.6"; + // A distinct name so the executable hint (first non-library entry) keeps + // picking the text mapping; rodata is file-backed and read-only. + const RODATA_PATH: &str = "/opt/app/toy-server.rodata"; + let mut d = Vec::new(); + d.extend_from_slice(&3u64.to_le_bytes()); // count + d.extend_from_slice(&0x1000u64.to_le_bytes()); // page_size + // Executable. + d.extend_from_slice(&layout.text_base.to_le_bytes()); + d.extend_from_slice(&(layout.text_base + TEXT_SIZE).to_le_bytes()); + d.extend_from_slice(&0u64.to_le_bytes()); + // libc data. + d.extend_from_slice(&layout.libc_base.to_le_bytes()); + d.extend_from_slice(&(layout.libc_base + LIBC_SIZE).to_le_bytes()); + d.extend_from_slice(&0u64.to_le_bytes()); + // Read-only rodata (fake vtable region). + d.extend_from_slice(&RODATA_BASE.to_le_bytes()); + d.extend_from_slice(&(RODATA_BASE + RODATA_SIZE).to_le_bytes()); + d.extend_from_slice(&0u64.to_le_bytes()); + // Paths. + d.extend_from_slice(spec.exec_path.as_bytes()); + d.push(0); + d.extend_from_slice(LIBC_PATH.as_bytes()); + d.push(0); + d.extend_from_slice(RODATA_PATH.as_bytes()); + d.push(0); + d +} diff --git a/crates/naksheap-testkit/src/error.rs b/crates/naksheap-testkit/src/error.rs new file mode 100644 index 0000000..4ab9cbe --- /dev/null +++ b/crates/naksheap-testkit/src/error.rs @@ -0,0 +1,42 @@ +//! Errors produced while validating a [`CoreSpec`](crate::CoreSpec) and +//! materializing it into a synthetic ELF core dump. + +use thiserror::Error; + +/// Result alias for the testkit builder API. +pub type Result = std::result::Result; + +/// A spec is rejected before any bytes are produced when it cannot be +/// materialized into a well-formed, self-consistent core. +#[derive(Debug, Error)] +pub enum BuilderError { + #[error("object `{label}` has size {size}, below the minimum 0x10-byte user region")] + ObjectTooSmall { label: String, size: usize }, + + #[error("duplicate object label `{0}`")] + DuplicateLabel(String), + + #[error("pointer field of object `{label}` at offset 0x{offset:x} does not fit in its {size}-byte user region")] + PointerOutOfBounds { label: String, offset: usize, size: usize }, + + #[error("target label `{0}` does not match any spec object")] + UnknownTarget(String), + + #[error("root slot at stack address 0x{address:x} falls outside the stack region [0x{stack_base:x}, 0x{stack_end:x})")] + RootOutsideStack { address: u64, stack_base: u64, stack_end: u64 }, + + #[error("heap region [0x{heap_base:x}, 0x{heap_end:x}) cannot fit the chunk chain and a top chunk")] + HeapLayout { heap_base: u64, heap_end: u64 }, + + #[error("stack region [0x{stack_base:x}, 0x{stack_end:x}) is too small to hold its root slots")] + StackLayout { stack_base: u64, stack_end: u64 }, + + #[error("spec values are not 16-byte aligned as required: {field} = 0x{value:x}")] + Misaligned { field: String, value: u64 }, + + #[error("core cannot be written: {0}")] + Io(#[from] std::io::Error), + + #[error("manifest cannot be serialized: {0}")] + Json(#[from] serde_json::Error), +} diff --git a/crates/naksheap-testkit/src/lib.rs b/crates/naksheap-testkit/src/lib.rs new file mode 100644 index 0000000..8691847 --- /dev/null +++ b/crates/naksheap-testkit/src/lib.rs @@ -0,0 +1,85 @@ +//! naksheap-testkit +//! +//! Deterministic synthetic fixture generation for naksheap. +//! +//! [`CoreSpec`] declaratively describes a 64-bit Linux/glibc core dump; its +//! [`CoreSpec::build`] materializes it into a [`Fixture`]: byte-for-byte +//! reproducible ELF64 core bytes plus a ground-truth [`Manifest`] of arenas, +//! carved heap objects, roots, and pointer edges. Downstream crates and tests +//! assert their reconstruction *fidelity* against the manifest, not just that +//! they "ran". +//! +//! ``` +//! use naksheap_testkit::{CoreSpec, Fixture}; +//! +//! let spec = CoreSpec::default(); +//! let fixture = Fixture::from_spec(&spec)?; +//! // fixture.bytes -> synthetic ELF64 core +//! // fixture.manifest-> ground truth (arenas, objects, roots, edges) +//! # Ok::<(), naksheap_testkit::BuilderError>(()) +//! ``` + +pub mod build; +pub mod error; +pub mod manifest; +pub mod spec; + +use std::io::Write; +use std::path::Path; + +pub use error::{BuilderError, Result}; +pub use manifest::{ + EdgeKind, Manifest, ManifestArena, ManifestEdge, ManifestObject, ManifestRoot, ObjectState, + RootKind, +}; +pub use spec::{CoreSpec, PointerField, SpecObject, SpecRoot, SpecState, Target}; + +/// A built fixture: the synthetic core bytes plus the ground-truth manifest +/// describing what a correct heap-analysis pipeline must recover. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fixture { + /// The complete synthetic ELF64 core dump. + pub bytes: Vec, + /// Ground truth for the fixture. + pub manifest: Manifest, +} + +impl Fixture { + /// Builds a fixture from `spec`, validating the spec and producing + /// deterministic output. + pub fn from_spec(spec: &CoreSpec) -> Result { + let (bytes, manifest) = build::build_spec(spec)?; + Ok(Fixture { bytes, manifest }) + } + + /// Writes the core dump to `path`. + pub fn write(&self, path: &Path) -> Result<()> { + let mut f = std::fs::File::create(path)?; + f.write_all(&self.bytes)?; + Ok(()) + } + + /// Renders the ground-truth manifest as indented JSON. + pub fn manifest_json(&self) -> Result { + Ok(self.manifest.to_json()?) + } + + /// Writes the manifest JSON next to `core_path` with a `.manifest.json` + /// suffix. + pub fn write_manifest(&self, core_path: &Path) -> Result<()> { + let json = self.manifest_json()?; + let mut path = core_path.as_os_str().to_owned(); + path.push(".manifest.json"); + let mut f = std::fs::File::create(&path)?; + f.write_all(json.as_bytes())?; + f.write_all(b"\n")?; + Ok(()) + } +} + +impl CoreSpec { + /// Builds a fixture from this spec. + pub fn build(&self) -> Result { + Fixture::from_spec(self) + } +} diff --git a/crates/naksheap-testkit/src/manifest.rs b/crates/naksheap-testkit/src/manifest.rs new file mode 100644 index 0000000..df58f83 --- /dev/null +++ b/crates/naksheap-testkit/src/manifest.rs @@ -0,0 +1,145 @@ +//! Ground-truth manifest types. +//! +//! A [`Manifest`] is the *expected* outcome of analyzing a synthetic core: +//! the allocator state, carved heap objects, roots, and pointer edges that a +//! correct heap-reconstruction pipeline (arena discovery -> chunk carving -> +//! pointer scan) must recover. Tests and downstream golden-file comparisons +//! assert that the pipeline's output equals this manifest. +//! +//! The `arena`/`object` shapes intentionally mirror +//! `naksheap-allocator-heuristics` (`ArenaInfo`/`Object`) so manifests can be +//! compared field-for-field with carved inventories. + +use serde::Serialize; + +/// Allocator state of a carved heap object, mirroring +/// `naksheap_allocator_heuristics::ObjectState`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ObjectState { + /// The chunk is live (its successor has `PREV_INUSE` set). + Allocated, + /// The chunk was returned to the allocator (successor's `PREV_INUSE` clear). + Freed, + /// The chunk was served by `mmap` (`IS_MMAPPED` flag). + Mmap, + /// State could not be determined. + Unknown, +} + +/// A discovered ptmalloc arena (mirrors `ArenaInfo`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ManifestArena { + /// Address of the `malloc_state` structure. + pub addr: u64, + /// Size of the heap region the arena's `top` points into. + pub size: u64, + /// Value of the arena's `top` field (points into an anonymous rw- heap). + pub top: u64, + /// `true` when this is `main_arena` (its `next` field self-loops). + pub is_main: bool, +} + +/// A carved heap object (mirrors `Object`, plus the spec label). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ManifestObject { + /// Address of the object's user data (chunk header + `0x10`). + pub addr: u64, + /// Usable size in bytes (`(size & !0xF) - 0x10`). + pub size: u64, + /// Allocator state of the object. + pub state: ObjectState, + /// Address of the governing arena. + pub arena: u64, + /// Address of the chunk header (`addr - 0x10`). + pub chunk_header: u64, + /// Semantic label from the spec that produced this object. + pub label: String, +} + +/// Where a root slot lives. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RootKind { + /// A word in the stack blob (a stack root). + Stack, + /// A general-purpose register that holds a heap pointer. + Register, +} + +/// A root: a word that points into the heap (or at a rodata target) and is a +/// valid starting point for a conservative pointer scan. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ManifestRoot { + /// Address of the slot holding the root (stack address or register index + /// for `Register` roots). + pub addr: u64, + /// The pointer value stored in the slot. + pub value: u64, + /// Where the root lives. + pub kind: RootKind, + /// Label of the target object, when the value points into the heap. + pub target_label: Option, +} + +/// Classification of a pointer edge. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum EdgeKind { + /// Object -> object (heap pointer). + Heap, + /// Object -> file-backed rodata (vtable-like / string literal). + Rodata, +} + +/// A pointer edge a pointer scan is expected to recover: a word at +/// `(from_addr + offset)` holding `to_addr`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ManifestEdge { + /// Address of the source object. + pub from_addr: u64, + /// Label of the source object. + pub from_label: String, + /// Byte offset of the pointer inside the source user region. + pub offset: usize, + /// Address the pointer targets. + pub to_addr: u64, + /// Label of the target object, when the edge points into the heap. + pub to_label: Option, + /// Classification of the edge. + pub kind: EdgeKind, +} + +/// The complete ground truth for one synthetic core fixture. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Manifest { + /// Architecture pointer width in bytes (always 8 for this builder). + pub pointer_width: u8, + /// `pid` embedded in `NT_PRSTATUS`. + pub pid: i32, + /// Process name from `NT_PRPSINFO` (`pr_fname`). + pub process_name: String, + /// Command line from `NT_PRPSINFO` (`pr_psargs`). + pub command_line: String, + /// Main executable path, as the parser infers it from `NT_FILE`. + pub exec_path: String, + /// Base vaddr of the anonymous heap region. + pub heap_base: u64, + /// First address past the anonymous heap region. + pub heap_end: u64, + /// The ptmalloc arenas the fixture embeds. + pub arenas: Vec, + /// Carved heap objects, in address order. + pub objects: Vec, + /// Root slots (stack words + registers holding heap pointers). + pub roots: Vec, + /// Pointer edges expected to be recovered from the heap. + pub edges: Vec, +} + +impl Manifest { + /// Renders the manifest as indented JSON. + pub fn to_json(&self) -> std::result::Result { + serde_json::to_string_pretty(self) + } +} diff --git a/crates/naksheap-testkit/src/spec.rs b/crates/naksheap-testkit/src/spec.rs new file mode 100644 index 0000000..89a7086 --- /dev/null +++ b/crates/naksheap-testkit/src/spec.rs @@ -0,0 +1,186 @@ +//! Fixture specification types. +//! +//! A [`CoreSpec`] is a declarative description of a synthetic 64-bit Linux +//! glibc core dump. [`CoreSpec::build`](crate::CoreSpec::build) turns it into +//! a [`Fixture`](crate::Fixture): deterministic ELF64 bytes plus a +//! ground-truth [`Manifest`](crate::Manifest). + +use serde::{Deserialize, Serialize}; + +/// Where a pointer word should point. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Target { + /// The heap object carrying this label. Resolved to its user address at + /// build time; the label must exist in [`CoreSpec::objects`]. + Label(String), + /// An arbitrary absolute address (e.g. a fake vtable or string literal in + /// the read-only rodata region). + Absolute(u64), +} + +/// A pointer stored inside a spec object's user region. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PointerField { + /// Byte offset of the word inside the object's user region. + pub offset: usize, + /// What the word points at. + pub target: Target, +} + +/// Allocator state requested for a spec object. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum SpecState { + /// Live chunk (`PREV_INUSE` set on the successor). + #[default] + Allocated, + /// Freed chunk (`PREV_INUSE` cleared on the successor). + Freed, +} + + +/// A single heap object to materialize as a glibc ptmalloc chunk. +/// +/// The requested `size` is the *user* region size; the builder rounds the +/// chunk footprint up to 16 bytes (`(0x10 + size + 0xf) & !0xf`), so the +/// carved usable size recorded in the manifest may exceed `size`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpecObject { + /// Semantic label; must be unique across the spec and is what pointer + /// fields and the manifest use to name the object. + pub label: String, + /// Requested user-region size in bytes. Must be at least `0x10`. + pub size: usize, + /// Allocator state of the chunk. + #[serde(default)] + pub state: SpecState, + /// Pointer words to embed in the user region (must fit within `size`). + #[serde(default)] + pub pointers: Vec, + /// Byte used to fill the user region; keeps output byte-for-byte + /// deterministic without relying on randomness. + #[serde(default = "default_fill")] + pub fill: u8, +} + +fn default_fill() -> u8 { + 0x41 +} + +impl SpecObject { + /// Convenience constructor for an allocated object with a fill pattern. + pub fn new(label: impl Into, size: usize) -> Self { + SpecObject { + label: label.into(), + size, + state: SpecState::Allocated, + pointers: Vec::new(), + fill: 0x41, + } + } + + /// Adds a heap-edge pointer to `target_label` at `offset`. + pub fn ptr(mut self, offset: usize, target_label: impl Into) -> Self { + self.pointers.push(PointerField { + offset, + target: Target::Label(target_label.into()), + }); + self + } + + /// Adds a pointer to an absolute address (rodata/vtable edge). + pub fn ptr_abs(mut self, offset: usize, address: u64) -> Self { + self.pointers.push(PointerField { + offset, + target: Target::Absolute(address), + }); + self + } + + /// Marks the chunk as freed. + pub fn freed(mut self) -> Self { + self.state = SpecState::Freed; + self + } +} + +/// A root slot: a pointer-sized word placed in the stack blob at `address`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpecRoot { + /// Absolute stack address of the slot. Must lie inside the stack region. + pub address: u64, + /// What the word points at. + pub target: Target, +} + +/// Full description of one synthetic core fixture. +/// +/// The default value is a realistic, self-consistent fixture: a `linked_list` +/// ring of three nodes with a stack root, a `payload` buffer with a rodata +/// pointer, and a `freed_slot` — all governed by a `main_arena` embedded in a +/// fake `libc` data blob. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CoreSpec { + /// Process name (`pr_fname`, truncated to 16 bytes at build time). + pub process_name: String, + /// Command line (`pr_psargs`, truncated to 80 bytes at build time). + pub command_line: String, + /// Main executable path (first non-library `NT_FILE` entry). + pub exec_path: String, + /// `pid` embedded in `NT_PRSTATUS`. + pub pid: i32, + /// vaddr of the fake executable text segment (`r-x`, `ET_CORE` main image). + pub text_base: u64, + /// vaddr of the fake libc data segment (`rw-`, file-backed) that holds the + /// `main_arena` at `libc_base + 0x1000`. + pub libc_base: u64, + /// vaddr of the anonymous `rw-` heap segment holding the chunk chain. + pub heap_base: u64, + /// Size of the anonymous heap segment. + pub heap_size: u64, + /// vaddr of the anonymous `rw-` stack segment. + pub stack_base: u64, + /// Size of the anonymous stack segment. + pub stack_size: u64, + /// Heap objects to materialize, in address order. + pub objects: Vec, + /// Pointer words to embed near the top of the stack blob. + pub roots: Vec, +} + +impl Default for CoreSpec { + fn default() -> Self { + CoreSpec { + process_name: "toy-server".to_string(), + command_line: "./toy-server --listen :8080 --workers 4".to_string(), + exec_path: "/opt/app/toy-server".to_string(), + pid: 4242, + text_base: 0x400000, + libc_base: 0x7faa_0000_0000, + heap_base: 0x7f00_0000_0000, + heap_size: 0x4000, + stack_base: 0x7fff_0000_0000, + stack_size: 0x4000, + objects: vec![ + // Linked-list ring (head -> second -> tail -> head): exercises + // cycle detection in the pointer scan. + SpecObject::new("head", 0x20).ptr(0, "second"), + SpecObject::new("second", 0x20).ptr(0, "tail"), + SpecObject::new("tail", 0x20).ptr(0, "head"), + // Buffer holding a heap pointer plus a fake vtable in the + // read-only rodata region. + SpecObject::new("payload", 0x40) + .ptr(0, "head") + .ptr_abs(8, 0x7faa_0000_4080), + // Freed chunk: PREV_INUSE cleared on its successor. + SpecObject::new("freed_slot", 0x20).freed(), + ], + roots: vec![SpecRoot { + address: 0x7fff_0000_3fd0, + target: Target::Label("head".to_string()), + }], + } + } +} diff --git a/crates/naksheap-testkit/tests/carve_fidelity.rs b/crates/naksheap-testkit/tests/carve_fidelity.rs new file mode 100644 index 0000000..24bda75 --- /dev/null +++ b/crates/naksheap-testkit/tests/carve_fidelity.rs @@ -0,0 +1,80 @@ +#![allow(clippy::field_reassign_with_default)] +//! Reconstruction-fidelity test: running the real glibc carving heuristics +//! over the synthetic core must recover exactly the ground-truth manifest. + +use naksheap_allocator_heuristics::{carve, ObjectState as HeuristicState}; +use naksheap_core_parse::elf::parse_elf_bytes; +use naksheap_core_parse::MappedImage; +use naksheap_testkit::{CoreSpec, Fixture, ObjectState}; + +fn carve_fixture(fixture: &Fixture) -> naksheap_allocator_heuristics::HeapInventory { + let parsed = parse_elf_bytes(&fixture.bytes).expect("core parses"); + let image = MappedImage::from_bytes( + fixture.bytes.clone(), + parsed.map.clone(), + parsed.pointer_width, + ); + carve(&image) +} + +fn map_state(s: HeuristicState) -> ObjectState { + match s { + HeuristicState::Allocated => ObjectState::Allocated, + HeuristicState::Freed => ObjectState::Freed, + HeuristicState::Mmap => ObjectState::Mmap, + HeuristicState::Unknown => ObjectState::Unknown, + } +} + +#[test] +fn carve_recovers_default_manifest_exactly() { + let spec = CoreSpec::default(); + let fixture = Fixture::from_spec(&spec).expect("build"); + let inv = carve_fixture(&fixture); + + // The single main_arena is found at its exact address, with the exact + // top pointer and heap-region size. + assert_eq!(inv.arenas.len(), fixture.manifest.arenas.len()); + let arena = &inv.arenas[0]; + let expected = &fixture.manifest.arenas[0]; + assert_eq!(arena.addr, expected.addr, "arena address"); + assert_eq!(arena.top, expected.top, "arena top"); + assert_eq!(arena.size, expected.size, "arena region size"); + assert_eq!(arena.is_main, expected.is_main); + + // Every manifest object is recovered with identical address, size, + // state, arena and chunk header. + assert_eq!(inv.objects.len(), fixture.manifest.objects.len()); + for (obj, expected) in inv.objects.iter().zip(&fixture.manifest.objects) { + assert_eq!(obj.addr, expected.addr, "object address"); + assert_eq!(obj.size, expected.size, "object size"); + assert_eq!(map_state(obj.state), expected.state, "object state"); + assert_eq!(obj.arena, Some(expected.arena), "object arena"); + assert_eq!(obj.chunk_header, expected.chunk_header, "chunk header"); + } + + // Spot check the linked-list ring survived carving. + let labels: Vec<&str> = fixture.manifest.objects.iter().map(|o| o.label.as_str()).collect(); + assert_eq!(labels, vec!["head", "second", "tail", "payload", "freed_slot"]); +} + +#[test] +fn carve_recovers_freed_state_from_flags() { + let mut spec = CoreSpec::default(); + spec.objects = vec![ + naksheap_testkit::SpecObject::new("live", 0x30), + naksheap_testkit::SpecObject::new("gone", 0x20).freed(), + naksheap_testkit::SpecObject::new("alive_again", 0x20), + ]; + spec.roots.clear(); + let fixture = Fixture::from_spec(&spec).expect("build"); + let inv = carve_fixture(&fixture); + + let states: Vec = inv.objects.iter().map(|o| map_state(o.state)).collect(); + assert_eq!( + states, + vec![ObjectState::Allocated, ObjectState::Freed, ObjectState::Allocated], + "states follow the PREV_INUSE chain" + ); + assert_eq!(inv.objects[1].addr, fixture.manifest.objects[1].addr); +} diff --git a/crates/naksheap-testkit/tests/determinism.rs b/crates/naksheap-testkit/tests/determinism.rs new file mode 100644 index 0000000..1c8ab6e --- /dev/null +++ b/crates/naksheap-testkit/tests/determinism.rs @@ -0,0 +1,74 @@ +//! Determinism: identical specs must produce byte-for-byte identical cores +//! and identical manifests; distinct specs must produce distinct output. + +use naksheap_testkit::{CoreSpec, Fixture, SpecObject}; + +#[test] +fn build_twice_is_identical() { + let spec = CoreSpec::default(); + let a = Fixture::from_spec(&spec).expect("a"); + let b = Fixture::from_spec(&spec).expect("b"); + assert_eq!(a.bytes, b.bytes); + assert_eq!(a.manifest, b.manifest); + assert_eq!( + a.manifest_json().expect("json"), + b.manifest_json().expect("json") + ); +} + +#[test] +fn spec_round_trip_is_deterministic() { + // A spec serialized to JSON and back must rebuild the same bytes. + let spec = CoreSpec::default(); + let json = serde_json::to_string(&spec).expect("to json"); + let back: CoreSpec = serde_json::from_str(&json).expect("from json"); + let a = Fixture::from_spec(&spec).expect("original"); + let b = Fixture::from_spec(&back).expect("round-tripped"); + assert_eq!(a.bytes, b.bytes, "JSON round-trip must not change output"); +} + +#[test] +fn spec_changes_change_output() { + let spec = CoreSpec::default(); + let base = Fixture::from_spec(&spec).expect("base"); + + let mut pid_spec = spec.clone(); + pid_spec.pid += 1; + assert_ne!( + base.bytes, + Fixture::from_spec(&pid_spec).expect("pid").bytes, + "pid must be encoded in the bytes" + ); + + let mut fill_spec = spec.clone(); + fill_spec.objects[0].fill = 0x5a; + assert_ne!( + base.bytes, + Fixture::from_spec(&fill_spec).expect("fill").bytes, + "fill byte must be encoded in the bytes" + ); + + let mut order_spec = spec.clone(); + order_spec.objects.swap(0, 2); + assert_ne!( + base.bytes, + Fixture::from_spec(&order_spec).expect("order").bytes, + "object order must be encoded in the bytes" + ); +} + +#[test] +fn distinct_specs_distinct_manifest_json() { + let spec = CoreSpec::default(); + let base = Fixture::from_spec(&spec).expect("base"); + + let mut extra = spec.clone(); + extra + .objects + .push(SpecObject::new("extra", 0x20)); + let other = Fixture::from_spec(&extra).expect("extra"); + assert_ne!( + base.manifest_json().expect("a"), + other.manifest_json().expect("b") + ); +} diff --git a/crates/naksheap-testkit/tests/invalid_spec.rs b/crates/naksheap-testkit/tests/invalid_spec.rs new file mode 100644 index 0000000..dca6a15 --- /dev/null +++ b/crates/naksheap-testkit/tests/invalid_spec.rs @@ -0,0 +1,130 @@ +#![allow(clippy::field_reassign_with_default)] +//! Invalid specs must be rejected with a precise `BuilderError` before any +//! bytes are produced. + +use naksheap_testkit::{BuilderError, CoreSpec, Fixture, SpecObject, SpecRoot, Target}; + +fn expect_err(spec: CoreSpec, variant: fn(&BuilderError) -> bool, name: &str) { + match Fixture::from_spec(&spec) { + Err(err) => { + assert!(variant(&err), "expected {name}, got: {err}"); + } + Ok(_) => panic!("spec should have failed with {name}"), + } +} + +#[test] +fn object_too_small_is_rejected() { + let mut spec = CoreSpec::default(); + spec.objects = vec![SpecObject::new("tiny", 0x8)]; + expect_err( + spec, + |e| matches!(e, BuilderError::ObjectTooSmall { .. }), + "ObjectTooSmall", + ); +} + +#[test] +fn duplicate_labels_are_rejected() { + let mut spec = CoreSpec::default(); + spec.objects = vec![ + SpecObject::new("dup", 0x20), + SpecObject::new("dup", 0x30), + ]; + expect_err( + spec, + |e| matches!(e, BuilderError::DuplicateLabel(l) if l == "dup"), + "DuplicateLabel", + ); +} + +#[test] +fn pointer_out_of_bounds_is_rejected() { + let mut spec = CoreSpec::default(); + spec.objects = vec![SpecObject::new("small", 0x20).ptr(0x20, "other")]; + expect_err( + spec, + |e| matches!(e, BuilderError::PointerOutOfBounds { .. }), + "PointerOutOfBounds", + ); +} + +#[test] +fn unknown_target_label_is_rejected() { + let mut spec = CoreSpec::default(); + spec.objects = vec![SpecObject::new("a", 0x20).ptr(0, "ghost")]; + spec.roots.clear(); + expect_err( + spec, + |e| matches!(e, BuilderError::UnknownTarget(l) if l == "ghost"), + "UnknownTarget", + ); +} + +#[test] +fn root_outside_stack_is_rejected() { + let mut spec = CoreSpec::default(); + spec.objects = vec![SpecObject::new("a", 0x20)]; + spec.roots = vec![SpecRoot { + address: spec.stack_base - 8, + target: Target::Label("a".to_string()), + }]; + expect_err( + spec, + |e| matches!(e, BuilderError::RootOutsideStack { .. }), + "RootOutsideStack", + ); + + let mut spec = CoreSpec::default(); + spec.objects = vec![SpecObject::new("a", 0x20)]; + spec.roots = vec![SpecRoot { + address: spec.stack_base + spec.stack_size - 4, + target: Target::Label("a".to_string()), + }]; + expect_err( + spec, + |e| matches!(e, BuilderError::RootOutsideStack { .. }), + "RootOutsideStack (slot crosses stack end)", + ); +} + +#[test] +fn heap_too_small_is_rejected() { + let mut spec = CoreSpec::default(); + // Default five objects need 0x150 bytes of heap; give it less. + spec.heap_size = 0x100; + expect_err( + spec, + |e| matches!(e, BuilderError::HeapLayout { .. }), + "HeapLayout", + ); +} + +#[test] +fn misaligned_bases_are_rejected() { + let mut spec = CoreSpec::default(); + spec.heap_base = 0x7f00_0000_0001; + expect_err( + spec, + |e| matches!(e, BuilderError::Misaligned { .. }), + "Misaligned heap_base", + ); + + let mut spec = CoreSpec::default(); + spec.stack_base = 0x7fff_0000_0ff0; + expect_err( + spec, + |e| matches!(e, BuilderError::Misaligned { .. }), + "Misaligned stack_base", + ); +} + +#[test] +fn error_messages_are_actionable() { + let mut spec = CoreSpec::default(); + spec.objects = vec![SpecObject::new("tiny", 0x8)]; + let err = Fixture::from_spec(&spec).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("tiny"), "message mentions the label: {msg}"); + assert!(msg.contains("0x10"), "message mentions the minimum: {msg}"); +} diff --git a/crates/naksheap-testkit/tests/manifest_consistency.rs b/crates/naksheap-testkit/tests/manifest_consistency.rs new file mode 100644 index 0000000..9c9ae70 --- /dev/null +++ b/crates/naksheap-testkit/tests/manifest_consistency.rs @@ -0,0 +1,180 @@ +#![allow(clippy::field_reassign_with_default)] +//! Ground-truth manifest consistency: the manifest must be exactly what the +//! spec requested (sizes aligned to glibc chunk rules, states, arena, the +//! pointer ring, roots), and must serialize as valid JSON. + +use naksheap_testkit::{ + CoreSpec, EdgeKind, Fixture, ObjectState, RootKind, SpecObject, SpecState, +}; + +fn align16(x: u64) -> u64 { + (x + 0xf) & !0xf +} + +#[test] +fn default_manifest_matches_spec() { + let spec = CoreSpec::default(); + let fixture = Fixture::from_spec(&spec).expect("build"); + let m = &fixture.manifest; + + assert_eq!(m.pointer_width, 8); + assert_eq!(m.pid, spec.pid); + assert_eq!(m.process_name, spec.process_name); + assert_eq!(m.command_line, spec.command_line); + assert_eq!(m.exec_path, spec.exec_path); + assert_eq!(m.heap_base, spec.heap_base); + assert_eq!(m.heap_end, spec.heap_base + spec.heap_size); + + // Arena: main_arena at libc_base + 0x1000, self-looped, top in the heap. + assert_eq!(m.arenas.len(), 1); + let arena = &m.arenas[0]; + assert_eq!(arena.addr, spec.libc_base + 0x1000); + assert_eq!(arena.size, spec.heap_size); + assert!(arena.is_main); + assert!(arena.top >= spec.heap_base && arena.top < m.heap_end); + + // Objects: one per spec, in address order, chunk header immediately below + // the user pointer, sizes rounded up to glibc's 16-byte rule. + assert_eq!(m.objects.len(), spec.objects.len()); + let mut prev_end = m.heap_base; + for (idx, obj) in m.objects.iter().enumerate() { + let s = &spec.objects[idx]; + assert_eq!(obj.label, s.label); + assert_eq!(obj.size, align16(0x10 + s.size as u64) - 0x10); + assert_eq!(obj.arena, arena.addr); + assert_eq!(obj.chunk_header, obj.addr - 0x10); + assert!(obj.addr >= prev_end, "objects laid out in address order"); + prev_end = obj.addr + obj.size; + let expected_state = match s.state { + SpecState::Allocated => ObjectState::Allocated, + SpecState::Freed => ObjectState::Freed, + }; + assert_eq!(obj.state, expected_state, "state for {}", s.label); + } + // Default fixture: four allocated objects and one freed slot. + assert_eq!( + m.objects + .iter() + .filter(|o| o.state == ObjectState::Freed) + .count(), + 1 + ); + + // Edges: the linked-list ring plus payload->head and a rodata edge. + let ring: Vec<(String, Option, EdgeKind)> = m + .edges + .iter() + .map(|e| (e.from_label.clone(), e.to_label.clone(), e.kind)) + .collect(); + assert!(ring.contains(&("head".into(), Some("second".into()), EdgeKind::Heap))); + assert!(ring.contains(&("second".into(), Some("tail".into()), EdgeKind::Heap))); + assert!(ring.contains(&("tail".into(), Some("head".into()), EdgeKind::Heap))); + assert!(ring.contains(&("payload".into(), Some("head".into()), EdgeKind::Heap))); + assert!(ring + .iter() + .any(|(from, to, kind)| from == "payload" && *kind == EdgeKind::Rodata && to.is_none())); + + // Roots: the stack root plus the RDI register root, both into `head`. + assert_eq!(m.roots.len(), 2); + let stack_root = m + .roots + .iter() + .find(|r| r.kind == RootKind::Stack) + .expect("stack root"); + assert_eq!(stack_root.addr, 0x7fff_0000_3fd0); + assert_eq!(stack_root.target_label.as_deref(), Some("head")); + let reg_root = m + .roots + .iter() + .find(|r| r.kind == RootKind::Register) + .expect("register root"); + assert_eq!(reg_root.target_label.as_deref(), Some("head")); + + // Every manifest edge offset must point at the spec's own pointer fields. + for edge in &m.edges { + let spec_obj = spec + .objects + .iter() + .find(|o| o.label == edge.from_label) + .unwrap_or_else(|| panic!("edge from unknown object {}", edge.from_label)); + assert!( + spec_obj.pointers.iter().any(|p| p.offset == edge.offset), + "edge offset {:#x} not declared by {}", + edge.offset, + edge.from_label + ); + } +} + +#[test] +fn manifest_serializes_to_json() { + let fixture = Fixture::from_spec(&CoreSpec::default()).expect("build"); + let json = fixture.manifest_json().expect("json"); + let value: serde_json::Value = serde_json::from_str(&json).expect("parses as json"); + + assert_eq!(value["pointer_width"], 8); + assert_eq!(value["pid"], 4242); + assert_eq!(value["process_name"], "toy-server"); + assert_eq!(value["arenas"].as_array().map(Vec::len), Some(1)); + assert_eq!(value["objects"].as_array().map(Vec::len), Some(5)); + assert_eq!(value["edges"].as_array().map(Vec::len), Some(5)); + assert_eq!(value["roots"].as_array().map(Vec::len), Some(2)); +} + +#[test] +fn custom_spec_manifest_is_consistent() { + // A freed chunk followed by a live one: states must follow the glibc + // PREV_INUSE rule, not the spec order blindly. + let mut spec = CoreSpec::default(); + spec.objects = vec![ + SpecObject::new("a", 0x20).freed(), + SpecObject::new("b", 0x20), + SpecObject::new("c", 0x40).ptr(0, "a"), + ]; + spec.roots.clear(); + let fixture = Fixture::from_spec(&spec).expect("build"); + let m = fixture.manifest; + + let a = m.objects.iter().find(|o| o.label == "a").unwrap(); + let b = m.objects.iter().find(|o| o.label == "b").unwrap(); + let c = m.objects.iter().find(|o| o.label == "c").unwrap(); + assert_eq!(a.state, ObjectState::Freed); + assert_eq!(b.state, ObjectState::Allocated); + assert_eq!(c.state, ObjectState::Allocated); + + // c -> a is a heap edge at offset 0. + let edge = m + .edges + .iter() + .find(|e| e.from_label == "c") + .expect("c has an edge"); + assert_eq!(edge.offset, 0); + assert_eq!(edge.to_addr, a.addr); + assert_eq!(edge.to_label.as_deref(), Some("a")); + assert_eq!(edge.kind, EdgeKind::Heap); +} + +#[test] +fn absolute_target_edges_are_rodata() { + let mut spec = CoreSpec::default(); + spec.objects = vec![SpecObject::new("buf", 0x40).ptr_abs(0, 0x7faa_0000_4080)]; + spec.roots.clear(); + let fixture = Fixture::from_spec(&spec).expect("build"); + let m = fixture.manifest; + assert_eq!(m.edges.len(), 1); + assert_eq!(m.edges[0].kind, EdgeKind::Rodata); + assert_eq!(m.edges[0].to_addr, 0x7faa_0000_4080); + assert!(m.edges[0].to_label.is_none()); +} + +#[test] +fn target_round_trips_through_json() { + let spec = CoreSpec::default(); + let json = serde_json::to_string(&spec).expect("spec to json"); + let back: CoreSpec = serde_json::from_str(&json).expect("spec from json"); + assert_eq!(spec, back); + // And both must build byte-identical fixtures (defaults survive JSON). + let a = Fixture::from_spec(&spec).expect("a"); + let b = Fixture::from_spec(&back).expect("b"); + assert_eq!(a.bytes, b.bytes); +} diff --git a/crates/naksheap-testkit/tests/roundtrip.rs b/crates/naksheap-testkit/tests/roundtrip.rs new file mode 100644 index 0000000..c71ca61 --- /dev/null +++ b/crates/naksheap-testkit/tests/roundtrip.rs @@ -0,0 +1,124 @@ +//! Round-trip tests: the synthetic cores must parse cleanly through +//! `naksheap-core-parse` and reconstruct exactly what the spec described. + +use naksheap_core_parse::elf::{parse_elf_bytes, parse_elf_core}; +use naksheap_core_parse::{CoreFormat, RangeKind}; +use naksheap_testkit::{CoreSpec, Fixture}; + +#[test] +fn default_spec_round_trips_through_parser() { + let spec = CoreSpec::default(); + let fixture = Fixture::from_spec(&spec).expect("default spec builds"); + let parsed = parse_elf_bytes(&fixture.bytes).expect("core parses"); + + assert_eq!(parsed.format, CoreFormat::Elf64); + assert_eq!(parsed.pointer_width, 8); + + // Process metadata round-trips from NT_PRPSINFO / NT_FILE. + assert_eq!(parsed.process_name.as_deref(), Some(spec.process_name.as_str())); + assert_eq!(parsed.command_line.as_deref(), Some(spec.command_line.as_str())); + assert_eq!(parsed.exec_path.as_deref(), Some(spec.exec_path.as_str())); + + // Exactly the five PT_LOAD segments become memory ranges. + assert_eq!(parsed.map.len(), 5); + + let text = parsed + .map + .iter() + .find(|r| r.start == spec.text_base) + .expect("text range"); + assert_eq!(text.kind, RangeKind::File); + assert!(text.perms.execute && !text.perms.write, "text is r-x"); + + let libc = parsed + .map + .iter() + .find(|r| r.start == spec.libc_base) + .expect("libc range"); + assert_eq!(libc.kind, RangeKind::File); + assert!(libc.perms.read && libc.perms.write, "libc data is rw-"); + + let rodata = parsed + .map + .iter() + .find(|r| r.start == 0x7faa_0000_4000) + .expect("rodata range"); + assert_eq!(rodata.kind, RangeKind::File, "rodata is file-backed"); + assert!( + rodata.perms.read && !rodata.perms.write && !rodata.perms.execute, + "rodata is r-- (not writable)" + ); + assert!( + rodata.contains(0x7faa_0000_4080), + "fake vtable address lives in the rodata range" + ); + + let heap = parsed + .map + .iter() + .find(|r| r.start == spec.heap_base) + .expect("heap range"); + assert_eq!(heap.kind, RangeKind::Anon); + assert_eq!(heap.len(), spec.heap_size); + + let stack = parsed + .map + .iter() + .find(|r| r.start == spec.stack_base) + .expect("stack range"); + assert_eq!(stack.kind, RangeKind::Anon); + assert_eq!(stack.len(), spec.stack_size); + + // NT_PRSTATUS yields one thread whose tid matches the spec pid. + assert_eq!(parsed.threads.len(), 1); + let thread = &parsed.threads[0]; + assert_eq!(thread.tid, spec.pid); + assert!( + thread.ip >= spec.text_base && thread.ip < spec.text_base + 0x1000, + "rip points into text: {:#x}", + thread.ip + ); + assert!( + thread.sp >= spec.stack_base && thread.sp < spec.stack_base + spec.stack_size, + "rsp points into stack: {:#x}", + thread.sp + ); +} + +#[test] +fn empty_heap_fixture_round_trips() { + let mut spec = CoreSpec::default(); + spec.objects.clear(); + spec.roots.clear(); + let fixture = Fixture::from_spec(&spec).expect("empty-heap spec builds"); + let parsed = parse_elf_bytes(&fixture.bytes).expect("core parses"); + assert_eq!(parsed.map.len(), 5); + assert_eq!(fixture.manifest.objects.len(), 0); +} + +#[test] +fn written_core_reopens_from_disk() { + let spec = CoreSpec::default(); + let fixture = Fixture::from_spec(&spec).expect("build"); + let dir = tempfile::tempdir().expect("tempdir"); + let core_path = dir.path().join("core.toy-server"); + + fixture.write(&core_path).expect("core written"); + fixture + .write_manifest(&core_path) + .expect("manifest written"); + assert!(core_path.exists()); + + let manifest_path = core_path.with_file_name(format!( + "{}.manifest.json", + core_path.file_name().unwrap().to_string_lossy() + )); + assert!(manifest_path.exists(), "manifest file written next to core"); + + let reopened = parse_elf_core(&core_path).expect("reopened from disk"); + assert_eq!(reopened.process_name.as_deref(), Some("toy-server")); + assert_eq!(reopened.exec_path.as_deref(), Some("/opt/app/toy-server")); + assert_eq!(reopened.threads.len(), 1); + assert_eq!(reopened.threads[0].tid, spec.pid); + assert_eq!(reopened.map().len(), 5); +} diff --git a/crates/naksheap-viz/Cargo.toml b/crates/naksheap-viz/Cargo.toml new file mode 100644 index 0000000..6740d05 --- /dev/null +++ b/crates/naksheap-viz/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "naksheap-viz" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Visualization: ASCII tree, Graphviz dot, HTML/cytoscape viewer (graph JSON embedded, cytoscape.js from CDN)" + +[dependencies] +naksheap-inference = { path = "../naksheap-inference" } +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +naksheap-core-parse = { path = "../naksheap-core-parse" } +naksheap-allocator-heuristics = { path = "../naksheap-allocator-heuristics" } +naksheap-pointer-scan = { path = "../naksheap-pointer-scan" } +naksheap-testkit = { path = "../naksheap-testkit" } diff --git a/crates/naksheap-viz/src/ascii.rs b/crates/naksheap-viz/src/ascii.rs new file mode 100644 index 0000000..d131199 --- /dev/null +++ b/crates/naksheap-viz/src/ascii.rs @@ -0,0 +1,224 @@ +//! Root-anchored ASCII tree rendering of the object graph. + +use std::collections::{HashMap, HashSet}; + +use naksheap_inference::types::GraphEdge; +use naksheap_inference::{Field, FieldKind, Node, ObjectGraph, ObjectLabel}; + +/// Maximum number of unreachable objects listed in the trailing section before +/// the remainder is collapsed into a count. +const MAX_UNREACHABLE_LISTED: usize = 20; + +/// Renders `graph` as a root-anchored ASCII tree starting at root nodes +/// (nodes referenced by a register/stack edge); falls back to the highest +/// inbound node when no root exists. Recursion over outgoing object edges is +/// bounded by `max_depth` and guarded against cycles. A trailing section lists +/// carved objects that no root could reach, so consumers never silently miss +/// objects. +pub fn ascii_tree(graph: &ObjectGraph, max_depth: usize) -> String { + // O(1) addr -> node lookup, replacing the per-edge linear scan. + let node_by_addr: HashMap = + graph.nodes.iter().map(|n| (n.addr, n)).collect(); + + // Pre-index outgoing object edges by source (`from != 0`) so each node is + // rendered in O(outgoing) instead of a full edge-list scan per node. + let mut outgoing: HashMap> = HashMap::new(); + for e in &graph.edges { + if e.from != 0 { + outgoing.entry(e.from).or_default().push(e); + } + } + + let mut w = TreeWriter { + outgoing: &outgoing, + node_by_addr: &node_by_addr, + out: Vec::new(), + visited: HashSet::new(), + max_depth, + }; + + let s = &graph.stats; + w.out.push("naksheap object graph".to_string()); + w.out.push(format!( + "objects: {} (allocated: {}, freed: {}, mmap: {}), clusters: {}, root-reachable: {}, edges: {} ({} confirmed), max depth: {}", + s.total_objects, s.allocated, s.freed, s.mmap, s.clusters, s.root_reachable, s.edges, + s.confirmed_edges, s.max_depth + )); + + if graph.nodes.is_empty() { + w.out.push("(no objects)".to_string()); + return w.out.join("\n"); + } + + for (i, node) in pick_roots(graph, &node_by_addr).iter().enumerate() { + if i > 0 { + w.out.push(String::new()); + } + if w.visited.contains(&node.addr) { + w.out.push(format!("0x{:x} (see above)", node.addr)); + continue; + } + w.visited.insert(node.addr); + w.render_root(node); + } + + let unreachable: Vec<&Node> = graph + .nodes + .iter() + .filter(|n| !n.reachable_from_root && !n.is_root) + .collect(); + if !unreachable.is_empty() { + w.out.push(String::new()); + let total = unreachable.len(); + w.out.push(format!( + "unreachable ({}): {} not reachable from any root", + total, + if total == 1 { "object" } else { "objects" } + )); + for n in unreachable.iter().take(MAX_UNREACHABLE_LISTED) { + w.out.push(format!( + " 0x{:x} {} [conf {:.2}]", + n.addr, n.ty.name, n.ty.confidence + )); + } + if total > MAX_UNREACHABLE_LISTED { + w.out.push(format!( + " ... and {} more", + total - MAX_UNREACHABLE_LISTED + )); + } + } + + w.out.join("\n") +} + +struct TreeWriter<'a> { + outgoing: &'a HashMap>, + node_by_addr: &'a HashMap, + out: Vec, + visited: HashSet, + max_depth: usize, +} + +impl TreeWriter<'_> { + fn render_root(&mut self, node: &Node) { + self.out.push(format!("0x{:x}", node.addr)); + self.out.push(format!("└── {}", type_line(node))); + self.render_children(node, " ", 0); + } + + fn render_node(&mut self, node: &Node, is_last: bool, prefix: &str, depth: usize) { + let conn = if is_last { "└── " } else { "├── " }; + let spacing = if is_last { " " } else { "│ " }; + self.out.push(format!("{prefix}{conn}0x{:x}", node.addr)); + self.out.push(format!("{prefix}{spacing}└── {}", type_line(node))); + let child_indent = format!("{prefix}{spacing} "); + self.render_children(node, &child_indent, depth); + } + + fn render_children(&mut self, node: &Node, indent: &str, depth: usize) { + let mut items: Vec = node.ty.fields.iter().map(Item::Field).collect(); + let mut seen: HashSet = HashSet::new(); + if let Some(edges) = self.outgoing.get(&node.addr) { + for e in edges { + if let Some(target) = self.node_by_addr.get(&e.to).copied() { + if seen.insert(target.addr) { + items.push(Item::Node(target)); + } + } + } + } + + for (i, item) in items.iter().enumerate() { + let last = i == items.len() - 1; + let conn = if last { "└── " } else { "├── " }; + match item { + Item::Field(f) => { + self.out.push(format!("{indent}{conn}{}", field_body(f))); + } + Item::Node(n) => { + if self.visited.contains(&n.addr) { + self.out.push(format!("{indent}{conn}0x{:x} (see above)", n.addr)); + } else if depth + 1 > self.max_depth { + self.out.push(format!("{indent}{conn}0x{:x} (depth limit)", n.addr)); + } else { + self.visited.insert(n.addr); + self.render_node(n, last, indent, depth + 1); + } + } + } + } + } +} + +/// Picks the render start points: every root node, or the single highest +/// inbound node when there are no roots. Deterministic (sorted by address). +fn pick_roots<'a>(graph: &'a ObjectGraph, node_by_addr: &HashMap) -> Vec<&'a Node> { + let mut addrs: Vec = graph + .nodes + .iter() + .filter(|n| n.is_root) + .map(|n| n.addr) + .collect(); + addrs.sort_unstable(); + let roots: Vec<&'a Node> = addrs + .iter() + .filter_map(|a| node_by_addr.get(a).copied()) + .collect(); + if !roots.is_empty() { + return roots; + } + let mut best: Option<&'a Node> = None; + for n in &graph.nodes { + let better = match best { + None => true, + Some(b) => n.inbound > b.inbound || (n.inbound == b.inbound && n.addr < b.addr), + }; + if better { + best = Some(n); + } + } + best.into_iter().collect() +} + +enum Item<'a> { + Field(&'a Field), + Node(&'a Node), +} + +fn type_line(node: &Node) -> String { + let mut s = format!( + "{} [conf {:.2}, n={}]", + node.ty.name, node.ty.confidence, node.ty.members + ); + if node.is_root { + s.push_str(" [root]"); + } + if node.ty.label == ObjectLabel::FreedChunk { + s.push_str(" [freed]"); + } + s +} + +fn field_body(f: &Field) -> String { + let kind = kind_str(f.kind); + let hint = f.hint.as_deref().unwrap_or(""); + let body = if hint.is_empty() { + kind.to_string() + } else if hint.starts_with(kind) { + hint.to_string() + } else { + format!("{kind} {hint}") + }; + format!("+0x{:02x} {body}", f.offset) +} + +fn kind_str(kind: FieldKind) -> &'static str { + match kind { + FieldKind::Pointer => "pointer", + FieldKind::Vtable => "vtable", + FieldKind::String => "string", + FieldKind::Integer => "integer", + FieldKind::Bytes => "bytes", + } +} diff --git a/crates/naksheap-viz/src/dot.rs b/crates/naksheap-viz/src/dot.rs new file mode 100644 index 0000000..ec38c29 --- /dev/null +++ b/crates/naksheap-viz/src/dot.rs @@ -0,0 +1,59 @@ +//! Graphviz DOT rendering of the object graph. + +use std::collections::HashSet; + +use naksheap_inference::{Node, ObjectGraph, ObjectLabel}; + +/// Renders `graph` as a Graphviz DOT digraph. Nodes are colored by state +/// (root green, unreachable orange, freed gray, allocated lightblue); edges +/// carry the pointer offset and are drawn thick when confirmed. +pub fn to_dot(graph: &ObjectGraph) -> String { + let mut out = String::new(); + out.push_str("digraph naksheap {\n"); + out.push_str(" graph [fontname=\"monospace\"];\n"); + out.push_str(" node [fontname=\"monospace\" shape=\"box\"];\n"); + out.push_str(" edge [fontname=\"monospace\"];\n"); + + let node_addrs: HashSet = graph.nodes.iter().map(|n| n.addr).collect(); + + for n in &graph.nodes { + let color = node_color(n); + let name = dot_escape(&n.ty.name); + out.push_str(&format!( + " \"0x{:x}\" [label=\"0x{:x}\\n{} [conf {:.2}]\" color=\"{}\" fillcolor=\"{}\" style=\"filled\"];\n", + n.addr, n.addr, name, n.ty.confidence, color, color + )); + } + + for e in &graph.edges { + if e.from == 0 || !node_addrs.contains(&e.from) || !node_addrs.contains(&e.to) { + continue; + } + let penwidth = if e.confirmed { 2 } else { 1 }; + out.push_str(&format!( + " \"0x{:x}\" -> \"0x{:x}\" [label=\"0x{:x}\" penwidth={}];\n", + e.from, e.to, e.offset, penwidth + )); + } + + out.push_str("}\n"); + out +} + +fn node_color(n: &Node) -> &'static str { + if n.is_root { + "green" + } else if !n.reachable_from_root { + "orange" + } else if n.ty.label == ObjectLabel::FreedChunk { + "gray" + } else { + "lightblue" + } +} + +fn dot_escape(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") +} diff --git a/crates/naksheap-viz/src/html.rs b/crates/naksheap-viz/src/html.rs new file mode 100644 index 0000000..6b5b93f --- /dev/null +++ b/crates/naksheap-viz/src/html.rs @@ -0,0 +1,161 @@ +//! HTML report rendering: the graph JSON is embedded in the file, while +//! cytoscape.js is loaded from a CDN at view time (network fetch required). + +use std::collections::HashSet; + +use naksheap_inference::{graph_to_json, Node, ObjectGraph, ObjectLabel}; +use serde_json::{json, Value}; + +/// Renders `graph` as a single HTML file. The graph is embedded as a JSON blob +/// (with `` tag) and drawn with +/// cytoscape.js loaded from a CDN. The HTML itself is not self-contained: it +/// needs a network fetch of `https://unpkg.com/cytoscape/...` to render. All +/// dump-derived data stays in the file / on the machine; the CDN request is the +/// only external fetch. +pub fn to_html(graph: &ObjectGraph) -> String { + let data = json!({ + "elements": build_elements(graph), + "graph": graph_to_json(graph), + }); + let blob = data.to_string().replace(" + + + +naksheap object graph + + + + + +

naksheap object graph

+
loading…
+
+ root + allocated + freed + not root-reachable +
+
+ + + +"#; + + template.replace("__DATA__", &blob) +} + +/// Builds the cytoscape `elements` array (nodes + object edges) from the graph. +fn build_elements(graph: &ObjectGraph) -> Vec { + let mut elements: Vec = Vec::new(); + let node_addrs: HashSet = graph.nodes.iter().map(|n| n.addr).collect(); + + for n in &graph.nodes { + let id = format!("0x{:x}", n.addr); + let color = node_color(n); + let state = if n.is_root { + "root" + } else if !n.reachable_from_root { + "unreachable" + } else if n.ty.label == ObjectLabel::FreedChunk { + "freed" + } else { + "allocated" + }; + elements.push(json!({ + "data": { + "id": id, + "label": format!("0x{:x}\n{} [conf {:.2}]", n.addr, n.ty.name, n.ty.confidence), + "color": color, + "state": state, + "w": size_bounded(n.size), + } + })); + } + + let mut edge_idx = 0usize; + for e in &graph.edges { + if e.from == 0 || !node_addrs.contains(&e.from) || !node_addrs.contains(&e.to) { + continue; + } + elements.push(json!({ + "data": { + "id": format!("e{edge_idx}"), + "source": format!("0x{:x}", e.from), + "target": format!("0x{:x}", e.to), + "label": format!("0x{:x}", e.offset), + "width": if e.confirmed { 2.5 } else { 1.0 }, + } + })); + edge_idx += 1; + } + + elements +} + +fn node_color(n: &Node) -> &'static str { + if n.is_root { + "#2e7d32" + } else if !n.reachable_from_root { + "#ef6c00" + } else if n.ty.label == ObjectLabel::FreedChunk { + "#9e9e9e" + } else { + "#1e88e5" + } +} + +fn size_bounded(size: u64) -> u64 { + size.clamp(16, 256) +} diff --git a/crates/naksheap-viz/src/lib.rs b/crates/naksheap-viz/src/lib.rs new file mode 100644 index 0000000..66ff5d3 --- /dev/null +++ b/crates/naksheap-viz/src/lib.rs @@ -0,0 +1,132 @@ +//! naksheap-viz +//! +//! Visualization of the naksheap object graph: a root-anchored ASCII tree, +//! Graphviz DOT, and an HTML/cytoscape report (graph JSON embedded, cytoscape.js +//! loaded from a CDN). + +pub mod ascii; +pub mod dot; +pub mod html; + +pub use ascii::ascii_tree; +pub use dot::to_dot; +pub use html::to_html; + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use naksheap_allocator_heuristics::carve; + use naksheap_core_parse::elf::parse_elf_bytes; + use naksheap_core_parse::MappedImage; + use naksheap_inference::{Node, ObjectGraph}; + use naksheap_pointer_scan::{scan, ScanOptions}; + + use super::*; + + /// Builds the fixture and runs the full pipeline, returning the object + /// graph plus the ground-truth fixture for address assertions. + fn fixture_graph() -> (ObjectGraph, naksheap_testkit::Fixture) { + let fixture = naksheap_testkit::CoreSpec::default() + .build() + .expect("fixture build"); + let parsed = parse_elf_bytes(&fixture.bytes).expect("parse fixture core"); + let image = MappedImage::from_bytes( + fixture.bytes.clone(), + parsed.map.clone(), + parsed.pointer_width, + ); + let inventory = carve(&image); + let scan = scan(&image, &inventory, &parsed.threads, &ScanOptions::default()); + let graph = naksheap_inference::build_graph(&image, &inventory, &scan); + (graph, fixture) + } + + #[test] + fn ascii_tree_contains_fields_and_children() { + let (graph, fixture) = fixture_graph(); + let tree = ascii_tree(&graph, 8); + + assert!(tree.contains("naksheap object graph"), "tree: {tree}"); + assert!(tree.contains("probable struct"), "tree: {tree}"); + assert!(tree.contains("+0x00 pointer"), "tree: {tree}"); + assert!(tree.contains("[root]"), "tree: {tree}"); + assert!(tree.contains("(see above)"), "cycle dedup marker missing: {tree}"); + + let unreachable: Vec<&Node> = graph + .nodes + .iter() + .filter(|n| !n.reachable_from_root && !n.is_root) + .collect(); + assert!( + !unreachable.is_empty(), + "fixture should contain objects unreachable from roots" + ); + assert!(tree.contains("unreachable"), "unreachable section missing: {tree}"); + for n in unreachable { + assert!( + tree.contains(&format!("0x{:x}", n.addr)), + "unreachable address 0x{:x} missing from tree:\n{tree}", + n.addr + ); + } + + let roots: Vec = graph + .nodes + .iter() + .filter(|n| n.is_root) + .map(|n| n.addr) + .collect(); + assert!(!roots.is_empty(), "fixture should produce root nodes"); + + let mut reachable: HashSet = roots.iter().copied().collect(); + loop { + let before = reachable.len(); + for e in &graph.edges { + if reachable.contains(&e.from) && e.from != 0 { + reachable.insert(e.to); + } + } + if reachable.len() == before { + break; + } + } + for addr in reachable { + assert!( + tree.contains(&format!("0x{addr:x}")), + "reachable child address 0x{addr:x} missing from tree:\n{tree}" + ); + } + assert!(tree.contains(&format!("0x{:x}", fixture.manifest.objects[1].addr))); + } + + #[test] + fn dot_output_starts_with_digraph() { + let (graph, _fixture) = fixture_graph(); + let dot = to_dot(&graph); + assert!(dot.starts_with("digraph"), "dot: {dot}"); + assert!(dot.contains("->"), "dot: {dot}"); + assert!(dot.contains("0x"), "dot: {dot}"); + } + + #[test] + fn html_contains_embedded_json() { + let (graph, fixture) = fixture_graph(); + let html = to_html(&graph); + assert!(html.contains("cytoscape"), "html: {html}"); + assert!(html.contains("stats"), "html: {html}"); + assert!( + html.contains(&format!("0x{:x}", fixture.manifest.objects[0].addr)), + "html: {html}" + ); + let data_line = html + .lines() + .find(|l| l.trim_start().starts_with("const DATA = ")) + .expect("DATA line"); + let data = data_line.trim_start().trim_start_matches("const DATA = "); + assert!( + !data.contains(" raw body is a core dump / memory image; runs + `naksheap graph --json --html` and returns a JSON + summary with links to the artifacts. +GET /reports/ HTML index of analyses. +GET /reports//report.html | graph.json | graph.dot + Static artifacts. + +Environment +----------- +NAKSHEAP_BIN path to the naksheap CLI (default: naksheap) +NAKSHEAP_ARTIFACTS artifact dir (default: ./data) +NAKSHEAP_MAX_UPLOAD_BYTES reject larger uploads (default: 10 GiB) +NAKSHEAP_MAX_REPORTS prune oldest past this count (default: 1000) +NAKSHEAP_MAX_DEPTH graph --max-depth (default: 8) +NAKSHEAP_PORT listen port (default: 8080) +""" +import json +import logging +import os +import re +import shutil +import subprocess +import sys +import threading +import time +import uuid +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +BIN = os.environ.get("NAKSHEAP_BIN", "naksheap") +ARTIFACTS = os.environ.get("NAKSHEAP_ARTIFACTS", "./data") +MAX_UPLOAD = int(os.environ.get("NAKSHEAP_MAX_UPLOAD_BYTES", str(10 * 1024**3))) +MAX_REPORTS = int(os.environ.get("NAKSHEAP_MAX_REPORTS", "1000")) +MAX_DEPTH = os.environ.get("NAKSHEAP_MAX_DEPTH", "8") +PORT = int(os.environ.get("NAKSHEAP_PORT", "8080")) +ANALYSIS_TIMEOUT = float(os.environ.get("NAKSHEAP_TIMEOUT", "1800")) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", +) +log = logging.getLogger("naksheap-web") + +# Only one analysis runs at a time; carving is CPU and memory bound. While one +# is running, further uploads get a 503 with Retry-After instead of blocking. +_busy = threading.Lock() + + +def _try_acquire_busy(timeout: float) -> bool: + return _busy.acquire(timeout=timeout) + + +def _run_analysis(core_path: str, out_dir: str) -> dict: + started = time.monotonic() + cmd = [ + BIN, "graph", core_path, + "--json", "--html", "--dot", "--out", out_dir, "--max-depth", MAX_DEPTH, + ] + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=ANALYSIS_TIMEOUT) + except subprocess.TimeoutExpired: + return {"exit": -1, "stdout": "", "stderr": f"analysis timed out after {ANALYSIS_TIMEOUT}s"} + return { + "exit": proc.returncode, + "stdout": proc.stdout[-4000:], + "stderr": proc.stderr[-4000:], + "elapsed": round(time.monotonic() - started, 2), + } + + +def _prune(): + try: + entries = sorted( + (p for p in os.listdir(ARTIFACTS) if os.path.isdir(os.path.join(ARTIFACTS, p))), + key=lambda p: os.path.getmtime(os.path.join(ARTIFACTS, p)), + ) + for p in entries[: max(0, len(entries) - MAX_REPORTS)]: + shutil.rmtree(os.path.join(ARTIFACTS, p), ignore_errors=True) + log.info("pruned report %s", p) + except OSError: + pass + + +class Handler(BaseHTTPRequestHandler): + def _json(self, code, obj): + body = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _file(self, path, ctype="application/octet-stream"): + try: + with open(path, "rb") as f: + data = f.read() + except OSError: + self.send_error(404, "not found") + return + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_PUT(self): + if not self.path.startswith("/analyze"): + self.send_error(404) + return + length = int(self.headers.get("Content-Length") or 0) + if length <= 0 or length > MAX_UPLOAD: + self._json(413, {"ok": False, "error": "upload too large"}) + return + rid = uuid.uuid4().hex + out_dir = os.path.join(ARTIFACTS, rid) + os.makedirs(out_dir, exist_ok=True) + core_path = os.path.join(out_dir, "core") + log.info("upload start id=%s size=%d", rid, length) + try: + with open(core_path, "wb") as f: + remaining = length + while remaining > 0: + chunk = self.rfile.read(min(1 << 20, remaining)) + if not chunk: + break + f.write(chunk) + remaining -= len(chunk) + except OSError: + self._json(500, {"ok": False, "error": "write failed"}) + return + + if not _try_acquire_busy(0.5): + log.warning("busy, rejecting id=%s with 503", rid) + self.send_response(503) + self.send_header("Retry-After", "30") + self.send_header("Content-Type", "application/json") + body = b'{"ok":false,"error":"an analysis is already running, retry shortly"}' + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + try: + result = _run_analysis(core_path, out_dir) + finally: + _busy.release() + + log.info( + "analysis done id=%s exit=%d elapsed=%ss", + rid, result["exit"], result.get("elapsed"), + ) + if result["exit"] != 0: + log.warning("analysis failed id=%s stderr=%s", rid, result["stderr"]) + self._json(502, { + "ok": False, + "id": rid, + "error": "analysis failed", + "stderr": result["stderr"], + }) + return + graph_path = os.path.join(out_dir, "graph.json") + summary = {"ok": True, "id": rid} + try: + g = json.load(open(graph_path)) + summary["stats"] = g.get("stats", {}) + except OSError: + pass + summary["report"] = f"/reports/{rid}/report.html" + summary["json"] = f"/reports/{rid}/graph.json" + summary["dot"] = f"/reports/{rid}/graph.dot" + if result["stderr"].strip(): + summary["warning"] = result["stderr"].strip() + _prune() + self._json(200, summary) + + def do_GET(self): + path = self.path.split("?", 1)[0] + if path == "/reports/" or path == "/reports": + entries = sorted(os.listdir(ARTIFACTS)) if os.path.isdir(ARTIFACTS) else [] + html = "

naksheap reports

    " + "".join( + f'
  • {e}
  • ' for e in entries + ) + "
" + body = html.encode() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + m = re.match(r"^/reports/([0-9a-f]+)/([A-Za-z0-9_.-]+)$", path) + if not m: + self.send_error(404) + return + rid, name = m.groups() + base = os.path.join(ARTIFACTS, rid) + ctype = { + "report.html": "text/html; charset=utf-8", + "graph.json": "application/json", + "graph.dot": "text/vnd.graphviz", + }.get(name, "application/octet-stream") + self._file(os.path.join(base, name), ctype) + + +def main(): + os.makedirs(ARTIFACTS, exist_ok=True) + if shutil.which(BIN) is None: + print(f"error: naksheap binary '{BIN}' not found on PATH", file=sys.stderr) + sys.exit(1) + httpd = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) + print(f"naksheap server listening on :{PORT} (bin={BIN}, artifacts={ARTIFACTS})") + httpd.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/deployment.md b/deployment.md new file mode 100644 index 0000000..e059fc0 --- /dev/null +++ b/deployment.md @@ -0,0 +1,93 @@ +# naksheap web deployment + +This guide explains how to run naksheap as a web service. People upload a core dump, the service reconstructs the heap, and a browser gets an interactive report plus the machine-readable graph. + +Privacy comes first. Core dumps contain credentials and keys. The reference stack is self-hosted and offline. The analyzer never sends data anywhere, and the only network request in the whole system is the optional cytoscape.js download in the HTML report, made by the browser, not the server. + +## What you are deploying + +naksheap is a Rust command line tool. There is no web server built in. The service is a thin wrapper that accepts an uploaded dump, runs the CLI, stores the results, and serves them over HTTP. + +The `deploy` directory has everything: + +| File | Purpose | +|---|---| +| `Dockerfile` | Multi-stage build. Compiles the Rust binary, then a slim runtime image with Python and the server. | +| `server.py` | A small standard-library HTTP server. Accepts a dump, runs naksheap, serves the report. | +| `docker-compose.yml` | Service definition with upload limits, resource caps, and an artifact volume. | + +## Build and run + +```bash +cargo build --release --workspace +cd deploy +docker build -t naksheap-server . +docker compose up -d +``` + +## Analyze a dump + +Upload with curl. The raw request body is the dump file. + +```bash +curl -s -X PUT --data-binary @/path/to/core.dump \ + -H 'Content-Type: application/octet-stream' \ + http://localhost:8080/analyze?name=my-crash +``` + +The response is JSON with the object counts and links to the report, the graph JSON, and the Graphviz file. Open the report link in a browser. + +If an analysis is already running, the service answers 503 with a Retry-After header instead of blocking. Analyses are serialized because carving is CPU and memory bound. + +## Configuration + +| Variable | Default | Meaning | +|---|---|---| +| NAKSHEAP_BIN | naksheap | Path to the CLI binary | +| NAKSHEAP_ARTIFACTS | /data | Where reports are stored | +| NAKSHEAP_MAX_UPLOAD_BYTES | 10737418240 | Reject larger uploads with 413 | +| NAKSHEAP_MAX_REPORTS | 1000 | Prune the oldest reports past this count | +| NAKSHEAP_MAX_DEPTH | 8 | graph --max-depth | +| NAKSHEAP_TIMEOUT | 1800 | Seconds before a stuck analysis is abandoned | +| NAKSHEAP_PORT | 8080 | Listen port | + +## Production setups + +Single host with systemd and nginx: run server.py as a service, put nginx in front with TLS and a client_max_body_size that matches the upload limit. + +Kubernetes: deploy the same container as a Deployment with a PVC for the artifact directory. For large fleets, run one Job per upload instead. A small dispatcher creates a Job that runs naksheap on the uploaded core, then serves the artifacts from object storage. That way a malicious dump only ever takes down its own Job, and a NetworkPolicy can deny all outbound traffic from the analyzer Pods. + +Crash pipeline: hook the CLI directly into your existing core collector instead of the web service. + +```bash +./target/release/naksheap graph "$core" --json --html --out "reports/$(date +%s)" +``` + +The JSON contract is stable, so alerts can key off the edge counts or the number of confirmed references. + +## Air-gapped networks + +The HTML report loads cytoscape.js from a CDN. For an offline network, download the bundle once, serve it next to the reports, and change the script tag in the HTML to a local path. Everything else in the report is already embedded. + +## Operations + +The server logs each analysis: the id, upload size, exit code, elapsed time, and any warning from the CLI. A truncated core prints a warning that it may be partial, which usually means the upload was cut off and should be retried. + +Exit code 0 means success. Exit code 1 means a clean error, such as a file that is not a core dump. A broken pipe from piping to head also exits 0, which is the usual Unix behavior. On failure the server stores the stderr text in the report directory. + +Reports are pruned past NAKSHEAP_MAX_REPORTS. Point the artifact volume at your backup policy. + +## Security checklist + +- The analyzer makes no outbound connections. For hard isolation, put a NetworkPolicy or proxy in front that blocks egress. +- Set NAKSHEAP_MAX_UPLOAD_BYTES and mirror it in the reverse proxy. +- Use TLS in front and do not expose the upload endpoint without auth if the dumps are sensitive. +- Run as a non-root user with a read-only filesystem. +- Keep the artifact volume private. Reports contain raw memory-derived data. +- Run scripts/real-dump-test.sh once in your target environment, because the allocator parser is tied to the glibc version of the machines you capture dumps from. + +## Non-goals + +- No built-in authentication, rate limiting, or multi-tenant isolation. Put those in the reverse proxy. +- No incremental upload and analyze. A large dump is processed in one job. +- No support for macOS cores, 32-bit dumps, jemalloc, or tcmalloc. diff --git a/fixtures/README.md b/fixtures/README.md new file mode 100644 index 0000000..55fdca1 --- /dev/null +++ b/fixtures/README.md @@ -0,0 +1,16 @@ +# fixtures + +Generated artifacts, not hand-edited sources. + +| File | Contents | +|---|---| +| toy-server.core | A deterministic synthetic ELF64 core dump | +| toy-server.core.manifest.json | The ground-truth manifest for that core: arenas, objects, roots, edges | + +Regenerate from the repo root: + +```sh +cargo run -p naksheap-testkit --example gen_fixture -- fixtures +``` + +The build is byte-for-byte deterministic. This golden core guards against regressions in the parser, carver, and scanner. diff --git a/fixtures/toy-server.core b/fixtures/toy-server.core new file mode 100644 index 0000000000000000000000000000000000000000..bed924802e547233805fd99cfb42d5fdd370af26 GIT binary patch literal 58444 zcmeI*Pfk-o7y$4A!HpZdK`+o26GDg!e8xaxLLzbjtx61$(55YlD_pv4yn!nd&fo!D zxbO=KP-;0RjXF z5FkK+009C72oNA}>jg&tPoPhL009C72oNAZfB*pk1PBnAR$w7MK#`w&n0B#z0t5&U zAVA=D2$at&=I1Ui+)$BvIbN95AwYlt0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjZBmq1aKQ%m3cSfu!J>^DJPugXz$aGCUXAqYJMdWAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!Cta6j+EwA6~uk`7(8LCWP`YJbAXY z7FXVjr_|2B&SPJyO>*!5#~+VEIGN&}{#Mbb(eZpScFC`Qv~M*2TpoAIBO8aUv0Gjm z?6*JGd)wXj+uh+*qj@$`tJimTdj0La+QXH_mBq6Aq1}DA-R;$uE?sBRK&hpkK+4M4p%Xc=l "$OUT/capture.sh" <<'EOF' +#!/bin/bash +set -e +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq >/dev/null 2>&1 +apt-get install -y -qq g++ gdb >/dev/null 2>&1 +cd /work +ulimit -c unlimited +echo "core" > /proc/sys/kernel/core_pattern 2>/dev/null || true +for t in test manyfree; do + g++ -O2 -fno-omit-frame-pointer -std=c++17 -pthread -o "$t" "$t.cpp" + strip "$t" + # crash dump (remove any stale core first so we cannot pair the wrong one) + rm -f core + (./"$t" > "/out/$t.crash.log" 2>&1 || true) + if [ ! -s core ]; then + echo "ERROR: no core produced for $t (core_pattern/ulimit problem?)" >&2 + exit 1 + fi + head -c 4 core | grep -q $'\x7fELF' || { echo "ERROR: core for $t is not ELF" >&2; exit 1; } + mv -f core "/out/$t.crash.core" + # live gcore snapshot + setsid ./"$t" gcore > "/out/$t.live.log" 2>&1 & + sleep 2 + PID=$(pgrep -x "$t" | head -1) + gdb -q -batch -ex "generate-core-file /out/$t.live.core" -p "$PID" >/dev/null 2>&1 || true + kill -9 "$PID" 2>/dev/null || true +done +echo "captured cores" +EOF +chmod +x "$OUT/capture.sh" + +echo "== capturing real cores in Linux container ==" +docker run --privileged --rm \ + -v "$SCRIPTS_DIR/real-src:/work" \ + -v "$OUT:/out" \ + ubuntu:24.04 bash /out/capture.sh + +echo "== running naksheap against real dumps ==" +for c in "$OUT"/*.crash.core "$OUT"/*.live.core; do + [ -e "$c" ] || continue + echo "--- $(basename "$c") ---" + "$BIN" info "$c" | head -4 + "$BIN" heap "$c" | tail -2 + "$BIN" graph "$c" --json > "${c%.core}.graph.json" +done + +echo "== ground truth comparison ==" +FAILED=0 +for log in "$OUT"/*.crash.log "$OUT"/*.live.log; do + [ -e "$log" ] || continue + graph="${log%.log}.graph.json" + [ -e "$graph" ] || { echo "MISSING graph for $log"; FAILED=1; continue; } + python3 - "$log" "$graph" <<'PYEOF' +import json, re, sys +log, graph = sys.argv[1], sys.argv[2] +gt = {} +for line in open(log): + m = re.search(r"\bGT\b(.*)", line) + if m: + for k, v in re.findall(r"(\w+)=0x([0-9a-fA-F]+)", m.group(1)): + gt[k] = int(v, 16) +nodes = json.load(open(graph))["nodes"] +by_addr = {n["addr"]: n for n in nodes} +missing = [] +for k, addr in gt.items(): + if k == "sso": # global in .bss, NOT heap: must be absent from the graph + if addr in by_addr: + print(f" FAIL {k}=0x{addr:x} should not be in heap graph") + sys.exit(1) + continue + n = by_addr.get(addr) + if not n: + missing.append(f"{k}=0x{addr:x}") +if missing: + print(f" FAIL missing nodes: {', '.join(missing)}") + sys.exit(1) +freed = sum(1 for n in nodes if n["state"] == "freed") +if freed == 0: + print(" FAIL no freed objects found (expected > 0 after explicit free()s)") + sys.exit(1) +checked = [k for k in gt if k != "sso"] # sso is a .bss global, not heap +print(f" ok: {len(checked)} ground-truth objects found, {freed} freed detected") +PYEOF + rc=$? + if [ $rc -ne 0 ]; then FAILED=1; fi +done + +if [ $FAILED -ne 0 ]; then + echo "REAL-DUMP VALIDATION FAILED" + exit 1 +fi +echo "real-dump validation passed. cores + graphs in $OUT" diff --git a/scripts/real-src/manyfree.cpp b/scripts/real-src/manyfree.cpp new file mode 100644 index 0000000..d8ca806 --- /dev/null +++ b/scripts/real-src/manyfree.cpp @@ -0,0 +1,45 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +// Lots of allocations + frees to exercise tcache AND bin consolidation, plus +// std::map/std::list nodes. Crashes with a live heap state. +static std::map* g_map = nullptr; +static std::list* g_list = nullptr; +static std::vector* g_ptrs = nullptr; + +int main(int argc, char**) { + g_map = new std::map(); + g_list = new std::list(); + g_ptrs = new std::vector(); + for (int i = 0; i < 20; i++) { + (*g_map)[i] = std::string("key-value-") + std::to_string(i * 7); + g_list->push_back(std::string("list-item-") + std::to_string(i)); + } + // Many allocations of varied sizes, then free half of them (tcache) and a + // few large ones (bins), then keep the other half live. + for (int i = 0; i < 64; i++) { + char* p = (char*)malloc(0x20 + (i % 5) * 0x30); + memset(p, 0x61 + i % 26, 0x20); + g_ptrs->push_back(p); + } + for (int i = 0; i < 64; i += 2) free((*g_ptrs)[i]); + // Some large allocations. big2 is served by mmap (>= 128 KiB? no: 0x4000 + // is 16 KiB, but this glibc's mmap threshold may be lower), so free(big2) + // unmaps it and the chunk vanishes from the dump. big1/big3 stay live. + void* big1 = malloc(0x2000); + void* big2 = malloc(0x4000); + void* big3 = malloc(0x8000); + free(big2); + printf("GT map=%p list=%p ptrs=%p big1=%p big3=%p\n", + (void*)g_map, (void*)g_list, (void*)g_ptrs, big1, big3); + fflush(stdout); + if (argc > 1) { for (;;) usleep(1000000); } + volatile int* p = (int*)nullptr; + *p = 42; // SIGSEGV so a real crash core is produced +} diff --git a/scripts/real-src/test.cpp b/scripts/real-src/test.cpp new file mode 100644 index 0000000..9cdbef2 --- /dev/null +++ b/scripts/real-src/test.cpp @@ -0,0 +1,123 @@ +// Real-world heap test program for naksheap validation. +// Allocates a variety of live objects (linked list, polymorphic classes, +// std::string SSO + heap, std::vector, large mmap allocation, thread arena), +// frees a few to create tcache/freed chunks, prints exact allocation +// addresses as ground truth, then crashes with SIGSEGV so a real kernel core +// dump is produced with registers/stack rooted in the heap. +#include +#include +#include +#include +#include +#include +#include + +struct Node { + int id; + Node* next; + char tag[16]; + Node(int i) : id(i), next(nullptr) { std::snprintf(tag, 16, "node-%d", i); } +}; + +struct Base { + virtual ~Base() {} + virtual const char* name() const = 0; + virtual int kind() const = 0; +}; + +struct Worker : Base { + int id; + std::string label; + explicit Worker(int i) + : id(i), label(std::string("worker-") + std::to_string(i)) {} + const char* name() const override { return "Worker"; } + int kind() const override { return 1; } +}; + +struct Manager : Base { + int level; + std::string label; + explicit Manager(int l) + : level(l), + label("manager-label-that-is-longer-than-the-sso-buffer-15") {} + const char* name() const override { return "Manager"; } + int kind() const override { return 2; } +}; + +static Node* g_head = nullptr; +static Base* g_worker = nullptr; +static Base* g_manager = nullptr; +static std::vector* g_vec = nullptr; +static char* g_big = nullptr; +static std::string g_sso = "hello"; +static std::vector* g_thread_workers = nullptr; + +static void boom(Node* n) { + asm volatile("" : : "r"(n)); // keep a heap pointer live in a register + volatile int* p = (int*)nullptr; + *p = 42; // SIGSEGV +} + +static void thread_alloc(void) { + // Spawn a real thread so glibc may create a non-main arena (with a + // heap_info header) and leave live allocations behind when it exits. + std::thread t([] { + g_thread_workers = new std::vector(); + for (int i = 0; i < 3; i++) { + g_thread_workers->push_back(new Worker(100 + i)); + } + }); + t.join(); +} + +int main(int argc, char**) { + if (argc > 1) { + // "gcore" mode: just allocate and sleep so a live snapshot can be taken. + thread_alloc(); + g_head = new Node(1); + g_head->next = new Node(2); + g_head->next->next = new Node(3); + g_head->next->next->next = g_head; + g_worker = new Worker(7); + g_manager = new Manager(2); + g_vec = new std::vector(); + for (int i = 0; i < 8; i++) g_vec->push_back(std::string("item-") + std::to_string(i)); + g_big = (char*)malloc(1 << 20); + std::memset(g_big, 0x42, 1 << 20); + std::printf("GT head=%p worker=%p manager=%p vec=%p big=%p sso=%p tworkers=%p\n", + (void*)g_head, (void*)g_worker, (void*)g_manager, (void*)g_vec, + (void*)g_big, (void*)&g_sso, (void*)g_thread_workers); + std::fflush(stdout); + malloc_info(0, stdout); + std::fflush(stdout); + for (;;) std::this_thread::sleep_for(std::chrono::hours(1)); + } + + thread_alloc(); + g_head = new Node(1); + g_head->next = new Node(2); + g_head->next->next = new Node(3); + g_head->next->next->next = g_head; + g_worker = new Worker(7); + g_manager = new Manager(2); + g_vec = new std::vector(); + for (int i = 0; i < 8; i++) g_vec->push_back(std::string("item-") + std::to_string(i)); + g_big = (char*)malloc(1 << 20); + std::memset(g_big, 0x42, 1 << 20); + + // Freed chunks (tcache entries on modern glibc). + void* f1 = malloc(0x50); + void* f2 = malloc(0x80); + void* f3 = malloc(0x20); + std::free(f1); + std::free(f2); + std::free(f3); + + std::printf("GT head=%p worker=%p manager=%p vec=%p big=%p sso=%p tworkers=%p\n", + (void*)g_head, (void*)g_worker, (void*)g_manager, (void*)g_vec, + (void*)g_big, (void*)&g_sso, (void*)g_thread_workers); + std::fflush(stdout); + + boom(g_head); + return 0; +}