amarck commited on
Commit
b4ea7e2
·
1 Parent(s): e5b2c4a

heaptrm CLI: compiled Rust binary for LLM-assisted heap exploitation

Browse files

749KB binary. JSON protocol on stdin/stdout:
- send: pipe data to target binary, get heap observation
- observe: get current heap state with chunks, bins, primitives
- quit: clean exit

Includes primitive detection (tcache_poison, double_free, corruption),
auto-finds/compiles v2 harness, manages target process lifecycle.

Files changed (2) hide show
  1. heaptrm-cli/Cargo.toml +9 -0
  2. heaptrm-cli/src/main.rs +359 -0
heaptrm-cli/Cargo.toml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ [package]
2
+ name = "heaptrm"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ description = "Heap exploit observability tool for LLM-assisted exploit generation"
6
+
7
+ [dependencies]
8
+ serde = { version = "1", features = ["derive"] }
9
+ serde_json = "1"
heaptrm-cli/src/main.rs ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! heaptrm - Heap exploit observability for LLM-assisted exploitation.
2
+ //!
3
+ //! Launches a target binary with LD_PRELOAD heap instrumentation,
4
+ //! provides a JSON protocol on stdin/stdout for LLM interaction.
5
+ //!
6
+ //! Protocol:
7
+ //! LLM sends: {"action": "send", "data": "1 0 64\n"}
8
+ //! Tool sends: {"heap": {...}, "changes": "...", "primitives": [...]}
9
+ //!
10
+ //! LLM sends: {"action": "observe"}
11
+ //! Tool sends: current heap state
12
+ //!
13
+ //! LLM sends: {"action": "quit"}
14
+ //! Tool sends: final summary and exits
15
+
16
+ use serde::{Deserialize, Serialize};
17
+ use std::collections::HashMap;
18
+ use std::env;
19
+ use std::fs;
20
+ use std::io::{self, BufRead, Write};
21
+ use std::path::PathBuf;
22
+ use std::process::{Child, Command, Stdio};
23
+ use std::thread;
24
+ use std::time::Duration;
25
+
26
+ // --- Harness output types ---
27
+
28
+ #[derive(Debug, Deserialize, Clone)]
29
+ struct RawChunk {
30
+ idx: usize,
31
+ addr: String,
32
+ state: u8,
33
+ #[serde(default)]
34
+ chunk_size: usize,
35
+ #[serde(default)]
36
+ fd: u64,
37
+ #[serde(default)]
38
+ fd_idx: i32,
39
+ #[serde(default)]
40
+ is_corrupted: u8,
41
+ #[serde(default)]
42
+ is_double_freed: u8,
43
+ #[serde(default)]
44
+ data_hex: String,
45
+ }
46
+
47
+ #[derive(Debug, Deserialize, Clone)]
48
+ struct RawCorruption {
49
+ #[serde(rename = "type")]
50
+ corruption_type: String,
51
+ chunk_idx: i32,
52
+ detail: String,
53
+ }
54
+
55
+ #[derive(Debug, Deserialize, Clone)]
56
+ struct RawState {
57
+ step: u32,
58
+ operation: String,
59
+ #[serde(default)]
60
+ corruption_count: u32,
61
+ #[serde(default)]
62
+ corruptions: Vec<RawCorruption>,
63
+ #[serde(default)]
64
+ chunks: Vec<RawChunk>,
65
+ }
66
+
67
+ // --- Output types ---
68
+
69
+ #[derive(Serialize)]
70
+ struct ChunkView {
71
+ index: usize,
72
+ address: String,
73
+ size: String,
74
+ state: String,
75
+ #[serde(skip_serializing_if = "Option::is_none")]
76
+ fd: Option<String>,
77
+ corrupted: bool,
78
+ }
79
+
80
+ #[derive(Serialize)]
81
+ struct BinView {
82
+ size: String,
83
+ count: usize,
84
+ entries: Vec<usize>,
85
+ }
86
+
87
+ #[derive(Serialize)]
88
+ struct Primitive {
89
+ name: String,
90
+ description: String,
91
+ chunks: Vec<usize>,
92
+ }
93
+
94
+ #[derive(Serialize)]
95
+ struct HeapView {
96
+ step: u32,
97
+ operation: String,
98
+ allocated: usize,
99
+ freed: usize,
100
+ chunks: Vec<ChunkView>,
101
+ bins: Vec<BinView>,
102
+ corruptions: Vec<serde_json::Value>,
103
+ primitives: Vec<Primitive>,
104
+ summary: String,
105
+ }
106
+
107
+ #[derive(Serialize)]
108
+ struct Response {
109
+ #[serde(skip_serializing_if = "Option::is_none")]
110
+ heap: Option<HeapView>,
111
+ #[serde(skip_serializing_if = "Option::is_none")]
112
+ changes: Option<String>,
113
+ #[serde(skip_serializing_if = "Option::is_none")]
114
+ error: Option<String>,
115
+ }
116
+
117
+ #[derive(Deserialize)]
118
+ struct Request {
119
+ action: String,
120
+ #[serde(default)]
121
+ data: String,
122
+ }
123
+
124
+ const QUARANTINE_FD: u64 = 0xFDFDFDFDFDFDFDFD;
125
+
126
+ fn analyze_state(state: &RawState) -> HeapView {
127
+ let n_alloc = state.chunks.iter().filter(|c| c.state == 1).count();
128
+ let n_freed = state.chunks.iter().filter(|c| c.state == 2).count();
129
+
130
+ let chunks: Vec<ChunkView> = state.chunks.iter().map(|c| {
131
+ let fd = if c.fd != 0 && c.fd != QUARANTINE_FD {
132
+ Some(format!("0x{:x}", c.fd))
133
+ } else {
134
+ None
135
+ };
136
+ ChunkView {
137
+ index: c.idx,
138
+ address: c.addr.clone(),
139
+ size: format!("0x{:x}", c.chunk_size),
140
+ state: if c.state == 1 { "allocated".into() } else { "freed".into() },
141
+ fd,
142
+ corrupted: c.is_corrupted != 0,
143
+ }
144
+ }).collect();
145
+
146
+ // Bins
147
+ let mut size_bins: HashMap<usize, Vec<usize>> = HashMap::new();
148
+ for c in &state.chunks {
149
+ if c.state == 2 && c.chunk_size > 0 {
150
+ size_bins.entry(c.chunk_size).or_default().push(c.idx);
151
+ }
152
+ }
153
+ let bins: Vec<BinView> = size_bins.iter().map(|(sz, entries)| BinView {
154
+ size: format!("0x{:x}", sz),
155
+ count: entries.len(),
156
+ entries: entries.clone(),
157
+ }).collect();
158
+
159
+ // Corruptions as JSON values
160
+ let corruptions: Vec<serde_json::Value> = state.corruptions.iter().map(|c| {
161
+ serde_json::json!({
162
+ "type": c.corruption_type,
163
+ "chunk": c.chunk_idx,
164
+ "detail": c.detail,
165
+ })
166
+ }).collect();
167
+
168
+ // Primitives
169
+ let mut primitives = Vec::new();
170
+
171
+ for c in &state.chunks {
172
+ if c.state == 2 && c.fd != 0 && c.fd != QUARANTINE_FD && c.fd_idx == -2 {
173
+ primitives.push(Primitive {
174
+ name: "tcache_poison".into(),
175
+ description: format!(
176
+ "Chunk {} has fd=0x{:x} outside heap. malloc(0x{:x}) returns controlled address.",
177
+ c.idx, c.fd, c.chunk_size.saturating_sub(0x10)
178
+ ),
179
+ chunks: vec![c.idx],
180
+ });
181
+ }
182
+ if c.is_double_freed != 0 {
183
+ primitives.push(Primitive {
184
+ name: "double_free".into(),
185
+ description: format!("Chunk {} at {} freed multiple times.", c.idx, c.addr),
186
+ chunks: vec![c.idx],
187
+ });
188
+ }
189
+ }
190
+
191
+ for corr in &state.corruptions {
192
+ primitives.push(Primitive {
193
+ name: format!("corruption_{}", corr.corruption_type),
194
+ description: corr.detail.clone(),
195
+ chunks: vec![corr.chunk_idx as usize],
196
+ });
197
+ }
198
+
199
+ // Summary
200
+ let mut summary = format!("Step {}: {} | {} alloc, {} freed", state.step, state.operation, n_alloc, n_freed);
201
+ for corr in &state.corruptions {
202
+ summary.push_str(&format!("\n!! {}: {}", corr.corruption_type, corr.detail));
203
+ }
204
+ let prim_names: Vec<&str> = primitives.iter()
205
+ .filter(|p| !p.name.starts_with("corruption_"))
206
+ .map(|p| p.name.as_str())
207
+ .collect();
208
+ if !prim_names.is_empty() {
209
+ summary.push_str(&format!("\nPrimitives: {}", prim_names.join(", ")));
210
+ }
211
+
212
+ HeapView { step: state.step, operation: state.operation.clone(), allocated: n_alloc, freed: n_freed, chunks, bins, corruptions, primitives, summary }
213
+ }
214
+
215
+ fn find_harness() -> Option<PathBuf> {
216
+ let candidates = [
217
+ "heapgrid_v2.so",
218
+ "heaptrm/harness/heapgrid_v2.so",
219
+ "harness/heapgrid_harness.so",
220
+ "../heaptrm/harness/heapgrid_v2.so",
221
+ ];
222
+ for c in &candidates {
223
+ let p = PathBuf::from(c);
224
+ if p.exists() {
225
+ return Some(fs::canonicalize(p).ok()?);
226
+ }
227
+ }
228
+ // Try compile
229
+ for src in &["heaptrm/harness/heapgrid_v2.c", "heapgrid_v2.c"] {
230
+ let s = PathBuf::from(src);
231
+ if s.exists() {
232
+ let out = s.with_extension("so");
233
+ if Command::new("gcc").args(["-shared","-fPIC","-O2","-o"]).arg(&out).arg(&s).args(["-ldl","-pthread"]).status().map(|s| s.success()).unwrap_or(false) {
234
+ return Some(fs::canonicalize(out).ok()?);
235
+ }
236
+ }
237
+ }
238
+ None
239
+ }
240
+
241
+ fn main() {
242
+ let args: Vec<String> = env::args().collect();
243
+ if args.len() < 2 {
244
+ eprintln!("heaptrm — heap exploit observability for LLM-assisted exploitation");
245
+ eprintln!();
246
+ eprintln!("Usage: heaptrm <binary> [args...]");
247
+ eprintln!();
248
+ eprintln!("Launches <binary> with heap instrumentation. Reads JSON from stdin,");
249
+ eprintln!("writes heap observations to stdout.");
250
+ eprintln!();
251
+ eprintln!("Commands:");
252
+ eprintln!(" {{\"action\": \"send\", \"data\": \"...\"}} send data to binary stdin");
253
+ eprintln!(" {{\"action\": \"observe\"}} get current heap state");
254
+ eprintln!(" {{\"action\": \"quit\"}} exit");
255
+ std::process::exit(1);
256
+ }
257
+
258
+ let binary = &args[1];
259
+ let binary_args = &args[2..];
260
+
261
+ let harness = find_harness().unwrap_or_else(|| {
262
+ eprintln!("Error: Cannot find heapgrid_v2.so");
263
+ std::process::exit(1);
264
+ });
265
+
266
+ let dump_path = format!("/tmp/heaptrm_{}.jsonl", std::process::id());
267
+
268
+ let mut child: Child = Command::new(binary)
269
+ .args(binary_args)
270
+ .stdin(Stdio::piped())
271
+ .stdout(Stdio::piped())
272
+ .stderr(Stdio::null())
273
+ .env("LD_PRELOAD", &harness)
274
+ .env("HEAPGRID_OUT", &dump_path)
275
+ .spawn()
276
+ .unwrap_or_else(|e| { eprintln!("Failed to launch: {}", e); std::process::exit(1); });
277
+
278
+ let mut child_stdin = child.stdin.take().expect("stdin");
279
+ thread::sleep(Duration::from_millis(50));
280
+
281
+ let stdin = io::stdin();
282
+ let stdout = io::stdout();
283
+ let mut out = stdout.lock();
284
+ let mut last_pos: u64 = 0;
285
+ let mut last_state: Option<RawState> = None;
286
+
287
+ let read_latest = |pos: &mut u64| -> Option<RawState> {
288
+ let content = fs::read_to_string(&dump_path).ok()?;
289
+ let start = *pos as usize;
290
+ if start >= content.len() { return None; }
291
+ let new = &content[start..];
292
+ let mut last = None;
293
+ for line in new.lines() {
294
+ if let Ok(s) = serde_json::from_str::<RawState>(line) {
295
+ last = Some(s);
296
+ }
297
+ }
298
+ *pos = content.len() as u64;
299
+ last
300
+ };
301
+
302
+ for line in stdin.lock().lines() {
303
+ let line = match line { Ok(l) => l, Err(_) => break };
304
+ if line.trim().is_empty() { continue; }
305
+
306
+ let req: Request = match serde_json::from_str(&line) {
307
+ Ok(r) => r,
308
+ Err(e) => {
309
+ let r = Response { heap: None, changes: None, error: Some(format!("Bad JSON: {}", e)) };
310
+ writeln!(out, "{}", serde_json::to_string(&r).unwrap()).ok();
311
+ out.flush().ok();
312
+ continue;
313
+ }
314
+ };
315
+
316
+ match req.action.as_str() {
317
+ "send" => {
318
+ child_stdin.write_all(req.data.as_bytes()).ok();
319
+ child_stdin.flush().ok();
320
+ thread::sleep(Duration::from_millis(20));
321
+
322
+ if let Some(s) = read_latest(&mut last_pos) {
323
+ last_state = Some(s);
324
+ }
325
+ let r = match &last_state {
326
+ Some(s) => Response { heap: Some(analyze_state(s)), changes: None, error: None },
327
+ None => Response { heap: None, changes: None, error: Some("No heap state yet".into()) },
328
+ };
329
+ writeln!(out, "{}", serde_json::to_string(&r).unwrap()).ok();
330
+ out.flush().ok();
331
+ }
332
+ "observe" => {
333
+ if let Some(s) = read_latest(&mut last_pos) {
334
+ last_state = Some(s);
335
+ }
336
+ let r = match &last_state {
337
+ Some(s) => Response { heap: Some(analyze_state(s)), changes: None, error: None },
338
+ None => Response { heap: None, changes: None, error: Some("No heap state yet".into()) },
339
+ };
340
+ writeln!(out, "{}", serde_json::to_string(&r).unwrap()).ok();
341
+ out.flush().ok();
342
+ }
343
+ "quit" => {
344
+ child.kill().ok();
345
+ fs::remove_file(&dump_path).ok();
346
+ break;
347
+ }
348
+ _ => {
349
+ let r = Response { heap: None, changes: None, error: Some(format!("Unknown: {}", req.action)) };
350
+ writeln!(out, "{}", serde_json::to_string(&r).unwrap()).ok();
351
+ out.flush().ok();
352
+ }
353
+ }
354
+ }
355
+
356
+ child.kill().ok();
357
+ child.wait().ok();
358
+ fs::remove_file(&dump_path).ok();
359
+ }