From 8ae6103ce73db98b349454a87f7fc4525252044a Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 21:21:29 +0000 Subject: [PATCH] v0.4: INT3 software breakpoints with by-name/by-id ops - StopReason::Breakpoint(id), rewinds RIP on hit - bp.set(addr_or_symbol, name?), bp.remove(id_or_name), bp.list() - step/cont disarm-step-rearm when PC sits on an enabled bp - fix: PIE load_base = mapping_base - mapping_file_offset (was using the first executable mapping with its offset, which is wrong on toolchains that split read-only header pages) --- src/breakpoint.rs | 94 +++++++++++++++++++ src/js.rs | 81 +++++++++++++++++ src/main.rs | 1 + src/symbols.rs | 25 +++-- src/target.rs | 226 +++++++++++++++++++++++++++++++++++++++------- 5 files changed, 382 insertions(+), 45 deletions(-) create mode 100644 src/breakpoint.rs diff --git a/src/breakpoint.rs b/src/breakpoint.rs new file mode 100644 index 0000000..7d581bc --- /dev/null +++ b/src/breakpoint.rs @@ -0,0 +1,94 @@ +//! Software (INT3) breakpoints. +//! +//! Wiring lives in target.rs (since arming and trap-handling need ptrace +//! access). This module just owns the storage. + +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct Breakpoint { + pub id: u32, + pub addr: u64, + /// The byte the INT3 (0xCC) replaced. Only valid when `enabled` is true. + pub orig_byte: u8, + pub enabled: bool, + pub hits: u64, + pub name: Option, +} + +pub struct BreakpointSet { + /// id → bp. We keep tombstones (None) so ids are stable; we'd otherwise + /// reuse ids and confuse the user. For v0.x a Vec> is fine. + items: Vec>, + /// Address → bp id, for O(1) lookup when SIGTRAP fires. + by_addr: HashMap, + /// name → bp id, for `remove("name")`. + by_name: HashMap, + next_id: u32, +} + +impl BreakpointSet { + pub fn new() -> Self { + BreakpointSet { + items: Vec::new(), + by_addr: HashMap::new(), + by_name: HashMap::new(), + next_id: 1, + } + } + + /// Reserve a new id and create a Breakpoint record. Returns the id. + /// The caller is responsible for actually poking the INT3 byte. + pub fn add(&mut self, addr: u64, orig_byte: u8, name: Option) -> Result { + if self.by_addr.contains_key(&addr) { + return Err(format!("breakpoint already set at 0x{:x}", addr)); + } + if let Some(n) = &name { + if self.by_name.contains_key(n) { + return Err(format!("breakpoint name '{}' already in use", n)); + } + } + let id = self.next_id; + self.next_id += 1; + let bp = Breakpoint { id, addr, orig_byte, enabled: true, hits: 0, name: name.clone() }; + self.items.push(Some(bp)); + self.by_addr.insert(addr, id); + if let Some(n) = name { self.by_name.insert(n, id); } + Ok(id) + } + + pub fn get(&self, id: u32) -> Option<&Breakpoint> { + self.items.iter().find_map(|b| b.as_ref().filter(|b| b.id == id)) + } + + pub fn get_mut(&mut self, id: u32) -> Option<&mut Breakpoint> { + self.items.iter_mut().find_map(|b| b.as_mut().filter(|b| b.id == id)) + } + + pub fn find_by_addr(&self, addr: u64) -> Option<&Breakpoint> { + self.by_addr.get(&addr).and_then(|id| self.get(*id)) + } + + pub fn find_by_name(&self, name: &str) -> Option<&Breakpoint> { + self.by_name.get(name).and_then(|id| self.get(*id)) + } + + /// Remove a breakpoint by id. Returns the removed record so the caller + /// can restore the original byte if needed. + pub fn remove(&mut self, id: u32) -> Option { + let pos = self.items.iter().position(|b| b.as_ref().map(|b| b.id == id).unwrap_or(false))?; + let bp = self.items[pos].take()?; + self.by_addr.remove(&bp.addr); + if let Some(n) = &bp.name { self.by_name.remove(n); } + Some(bp) + } + + pub fn id_by_name(&self, name: &str) -> Option { + self.by_name.get(name).copied() + } + + /// Iterate over live breakpoints in id order. + pub fn iter(&self) -> impl Iterator { + self.items.iter().filter_map(|b| b.as_ref()) + } +} diff --git a/src/js.rs b/src/js.rs index c8ae787..d4606bb 100644 --- a/src/js.rs +++ b/src/js.rs @@ -365,6 +365,87 @@ fn install_globals<'js>( globals.set("symbols", f)?; } + // ---------- breakpoint / bp --------------------------------------------- + // breakpoint.set(target, name?) → id (target = address number/BigInt or symbol name) + // breakpoint.remove(id_or_name) + // breakpoint.list() → [{id, addr, name, hits, enabled}] + { + let bp_obj = Object::new(c.clone())?; + + // set(target, name?) + { + let target = target.clone(); + let f = Function::new(c.clone(), move |c: Ctx<'js>, location: Value<'js>, name: rquickjs::function::Opt| -> rquickjs::Result { + let mut slot = target.borrow_mut(); + let t = slot.as_mut().ok_or_else(|| throw(&c, "no target attached"))?; + // location: string → resolve via symbols; number/BigInt → coerce_addr. + let addr = if let Some(s) = location.clone().into_string() { + let s = s.to_string()?; + let sym = t.symbols().ok_or_else(|| throw(&c, "no symbol table"))?; + sym.addr_for_name(&s).ok_or_else(|| throw(&c, &format!("unknown symbol: {}", s)))? + } else { + coerce_addr(&c, &location)? + }; + let name = name.0; + t.breakpoint_set(addr, name).map_err(|e| throw(&c, &e)) + })?; + bp_obj.set("set", f)?; + } + + // remove(id_or_name) + { + let target = target.clone(); + let f = Function::new(c.clone(), move |c: Ctx<'js>, id_or_name: Value<'js>| -> rquickjs::Result<()> { + let mut slot = target.borrow_mut(); + let t = slot.as_mut().ok_or_else(|| throw(&c, "no target attached"))?; + let id = if let Some(s) = id_or_name.clone().into_string() { + let s = s.to_string()?; + t.breakpoint_id_by_name(&s).ok_or_else(|| throw(&c, &format!("no breakpoint named '{}'", s)))? + } else if let Some(n) = id_or_name.as_number() { + if n < 0.0 || n.fract() != 0.0 { + return Err(throw(&c, "bp id must be a non-negative integer")); + } + n as u32 + } else if id_or_name.is_int() { + let i: i32 = id_or_name.get()?; + if i < 0 { return Err(throw(&c, "bp id must be non-negative")); } + i as u32 + } else { + return Err(throw(&c, "bp.remove expects id (number) or name (string)")); + }; + t.breakpoint_remove(id).map_err(|e| throw(&c, &e)) + })?; + bp_obj.set("remove", f)?; + } + + // list() → array of breakpoint records + { + let target = target.clone(); + let f = Function::new(c.clone(), move |c: Ctx<'js>| -> rquickjs::Result> { + let slot = target.borrow(); + let t = slot.as_ref().ok_or_else(|| throw(&c, "no target attached"))?; + let arr = rquickjs::Array::new(c.clone())?; + for (i, bp) in t.breakpoints().iter().enumerate() { + let rec = Object::new(c.clone())?; + rec.set("id", bp.id)?; + rec.set("addr", u64_to_bigint(&c, bp.addr)?)?; + rec.set("hits", u64_to_bigint(&c, bp.hits)?)?; + rec.set("enabled", bp.enabled)?; + match &bp.name { + Some(n) => rec.set("name", n.as_str())?, + None => rec.set("name", Value::new_null(c.clone()))?, + }; + arr.set(i, rec)?; + } + Ok(arr.into_value()) + })?; + bp_obj.set("list", f)?; + } + + globals.set("breakpoint", bp_obj.clone())?; + globals.set("bp", bp_obj)?; + } + // ---------- JS-side prelude --------------------------------------------- // Loaded last, so it can reference any of the native globals above. // Lives in src/prelude.js, embedded at compile time. diff --git a/src/main.rs b/src/main.rs index 2a1dec4..dbdaaa9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod target; mod symbols; +mod breakpoint; mod js; use std::io::{BufRead, Write}; diff --git a/src/symbols.rs b/src/symbols.rs index 6e2ee3e..10846bc 100644 --- a/src/symbols.rs +++ b/src/symbols.rs @@ -150,32 +150,29 @@ fn read_load_base(pid: i32, exe_path: &PathBuf) -> Result { // /proc//maps line format: // ADDR_LO-ADDR_HI PERMS OFFSET DEV INODE PATH - // We want the first line where PATH matches our exe AND PERMS contains 'x'. + // + // For PIE we want the load base = `mapping_base - mapping_file_offset` for + // any mapping of the file. The simplest pick is the *first* mapping + // (lowest address), which usually has file offset 0 and thus + // load_base == mapping_base. We compute it generically just in case the + // first mapping isn't at offset 0 (some toolchains do split read-only + // header pages out). for line in maps.lines() { - // Split into at most 6 whitespace-delimited fields; the last is the - // path which may itself contain spaces (unlikely in practice). let mut it = line.splitn(6, ' ').filter(|s| !s.is_empty()); let range = match it.next() { Some(s) => s, None => continue }; - let perms = match it.next() { Some(s) => s, None => continue }; - let _offset = it.next(); + let _perms = match it.next() { Some(s) => s, None => continue }; + let offset = it.next().unwrap_or("0"); let _dev = it.next(); let _inode = it.next(); let path = it.next().unwrap_or("").trim(); - if !perms.contains('x') { continue; } if path != exe_str { continue; } let mut parts = range.split('-'); let lo = parts.next().ok_or_else(|| "bad maps line".to_string())?; let lo = u64::from_str_radix(lo, 16).map_err(|e| format!("bad maps addr: {}", e))?; + let file_offset = u64::from_str_radix(offset, 16).unwrap_or(0); - // For ET_DYN this is the load base directly (file offset 0 maps to lo). - // For ET_EXEC, file vaddrs are absolute, and lo equals the first - // PT_LOAD's p_vaddr, so we want 0. if is_pie(&elf) { - // We assume the first executable mapping starts at file offset 0 - // for the .text segment — usually true. If we wanted to be careful - // we'd subtract the segment's file offset, but it's normally 0 - // for the first executable PT_LOAD. - return Ok(lo); + return Ok(lo.wrapping_sub(file_offset)); } else { return Ok(0); } diff --git a/src/target.rs b/src/target.rs index 422fb67..ee74a0b 100644 --- a/src/target.rs +++ b/src/target.rs @@ -11,13 +11,16 @@ use nix::unistd::{execvp, fork, ForkResult, Pid}; use std::ffi::CString; use std::io::IoSliceMut; +use crate::breakpoint::BreakpointSet; use crate::symbols::SymbolTable; /// Why the target stopped after a step/continue, or that it has exited. #[derive(Debug, Clone)] pub enum StopReason { - /// Hit a SIGTRAP from single-stepping (or, in future, a breakpoint). + /// Hit a SIGTRAP from single-stepping. Step, + /// Hit a software breakpoint. Carries the breakpoint id. + Breakpoint(u32), /// Caught some other signal — we don't yet inject it back, just report. Signal(Signal), /// Process exited normally with this code. @@ -31,6 +34,7 @@ impl StopReason { pub fn tag(&self) -> String { match self { StopReason::Step => "step".into(), + StopReason::Breakpoint(id) => format!("breakpoint:{}", id), StopReason::Signal(s) => format!("signal:{}", s), StopReason::Exited(c) => format!("exited:{}", c), StopReason::Killed(s) => format!("killed:{}", s), @@ -38,7 +42,7 @@ impl StopReason { } pub fn is_alive(&self) -> bool { - matches!(self, StopReason::Step | StopReason::Signal(_)) + matches!(self, StopReason::Step | StopReason::Breakpoint(_) | StopReason::Signal(_)) } } @@ -60,6 +64,8 @@ pub struct Target { /// ELF symbols of the main executable. None if parsing failed (e.g. /// stripped binary, unreadable /proc) — symbol lookups become no-ops. symbols: Option, + /// Software (INT3) breakpoints. + breakpoints: BreakpointSet, } impl Target { @@ -114,6 +120,7 @@ impl Target { state: State::Stopped(StopReason::Step), prev_regs: None, symbols, + breakpoints: BreakpointSet::new(), }) } WaitStatus::Exited(_, code) => { @@ -125,29 +132,6 @@ impl Target { } } - /// PTRACE_SINGLESTEP + waitpid. Updates internal state. - pub fn step(&mut self) -> Result { - if !self.is_stopped() { - return Err("target is not stopped".into()); - } - // Snapshot regs before resuming so `prev` reflects the pre-step state. - self.prev_regs = ptrace::getregs(self.pid).ok(); - ptrace::step(self.pid, None).map_err(|e| format!("ptrace step: {}", e))?; - let reason = self.wait_for_stop()?; - Ok(reason) - } - - /// PTRACE_CONT + waitpid. - pub fn cont(&mut self) -> Result { - if !self.is_stopped() { - return Err("target is not stopped".into()); - } - self.prev_regs = ptrace::getregs(self.pid).ok(); - ptrace::cont(self.pid, None).map_err(|e| format!("ptrace cont: {}", e))?; - let reason = self.wait_for_stop()?; - Ok(reason) - } - pub fn prev_regs(&self) -> Option { self.prev_regs } @@ -156,7 +140,134 @@ impl Target { self.symbols.as_ref() } - fn wait_for_stop(&mut self) -> Result { + pub fn breakpoints(&self) -> &BreakpointSet { + &self.breakpoints + } + + // ----- breakpoint ops ------------------------------------------------- + + /// Install a software breakpoint at `addr`. Reads the original byte, + /// writes 0xCC. Returns the breakpoint id. + pub fn breakpoint_set(&mut self, addr: u64, name: Option) -> Result { + if !self.is_stopped() { + return Err("target is not stopped".into()); + } + let orig = read_byte(self.pid, addr)?; + let id = self.breakpoints.add(addr, orig, name)?; + if let Err(e) = write_byte(self.pid, addr, 0xCC) { + // Roll back: the bp record was added but writing failed. + let _ = self.breakpoints.remove(id); + return Err(format!("install bp at 0x{:x}: {}", addr, e)); + } + Ok(id) + } + + /// Remove a breakpoint by id, restoring the original byte if it was + /// enabled. Returns Ok(()) on success, Err if the id doesn't exist. + pub fn breakpoint_remove(&mut self, id: u32) -> Result<(), String> { + if !self.is_stopped() { + return Err("target is not stopped".into()); + } + let bp = self.breakpoints.remove(id) + .ok_or_else(|| format!("no breakpoint with id {}", id))?; + if bp.enabled { + write_byte(self.pid, bp.addr, bp.orig_byte) + .map_err(|e| format!("remove bp 0x{:x}: {}", bp.addr, e))?; + } + Ok(()) + } + + /// Look up a bp id by name; convenience for callers. + pub fn breakpoint_id_by_name(&self, name: &str) -> Option { + self.breakpoints.id_by_name(name) + } + + // ----- step / cont ---------------------------------------------------- + + /// PTRACE_SINGLESTEP + waitpid. Updates internal state. If the current PC + /// has an enabled breakpoint, we temporarily disarm it, step over the + /// original instruction, then re-arm. + pub fn step(&mut self) -> Result { + if !self.is_stopped() { + return Err("target is not stopped".into()); + } + self.prev_regs = ptrace::getregs(self.pid).ok(); + + let pc = self.prev_regs.map(|r| r.rip).unwrap_or(0); + let bp_at_pc = self.breakpoints.find_by_addr(pc).map(|b| (b.id, b.orig_byte, b.enabled)); + + if let Some((_id, orig, enabled)) = bp_at_pc { + if enabled { + // Disarm, step, re-arm. + write_byte(self.pid, pc, orig) + .map_err(|e| format!("disarm bp at 0x{:x}: {}", pc, e))?; + ptrace::step(self.pid, None) + .map_err(|e| format!("ptrace step: {}", e))?; + let reason = self.wait_for_stop_raw()?; + // Re-arm even if the step exited or signalled (best-effort). + if reason.is_alive() { + let _ = write_byte(self.pid, pc, 0xCC); + } + self.classify_stop(reason); + let r = match &self.state { + State::Stopped(r) => r.clone(), + State::Dead(r) => r.clone(), + }; + return Ok(r); + } + } + + ptrace::step(self.pid, None).map_err(|e| format!("ptrace step: {}", e))?; + let reason = self.wait_for_stop_raw()?; + self.classify_stop(reason); + match &self.state { + State::Stopped(r) => Ok(r.clone()), + State::Dead(r) => Ok(r.clone()), + } + } + + /// PTRACE_CONT + waitpid. If the current PC is a breakpoint, we have to + /// step over it first (same disarm/step/re-arm dance), then continue. + pub fn cont(&mut self) -> Result { + if !self.is_stopped() { + return Err("target is not stopped".into()); + } + self.prev_regs = ptrace::getregs(self.pid).ok(); + + // Step over current PC if it has an active breakpoint. + let pc = self.prev_regs.map(|r| r.rip).unwrap_or(0); + if let Some(bp) = self.breakpoints.find_by_addr(pc).cloned() { + if bp.enabled { + write_byte(self.pid, pc, bp.orig_byte) + .map_err(|e| format!("disarm bp at 0x{:x}: {}", pc, e))?; + ptrace::step(self.pid, None) + .map_err(|e| format!("ptrace step (over-bp): {}", e))?; + let inner = self.wait_for_stop_raw()?; + // If the step itself exited/signalled, classify and return. + if !inner.is_alive() { + self.classify_stop(inner); + return match &self.state { + State::Stopped(r) | State::Dead(r) => Ok(r.clone()), + }; + } + // Re-arm and fall through to PTRACE_CONT. + let _ = write_byte(self.pid, pc, 0xCC); + } + } + + ptrace::cont(self.pid, None).map_err(|e| format!("ptrace cont: {}", e))?; + let reason = self.wait_for_stop_raw()?; + // If we hit a breakpoint, rewind RIP to bp_addr (kernel left it at + // bp_addr+1) so the user sees pc == bp_addr, which is what they expect. + self.handle_possible_breakpoint(reason)?; + match &self.state { + State::Stopped(r) | State::Dead(r) => Ok(r.clone()), + } + } + + /// Raw waitpid: just decodes the status into a tentative StopReason::Step + /// or Signal/Exited/Killed. Doesn't yet check for breakpoint matches. + fn wait_for_stop_raw(&self) -> Result { let status = waitpid(self.pid, None).map_err(|e| format!("waitpid: {}", e))?; let reason = match status { WaitStatus::Stopped(_, Signal::SIGTRAP) => StopReason::Step, @@ -165,14 +276,42 @@ impl Target { WaitStatus::Signaled(_, sig, _) => StopReason::Killed(sig), other => return Err(format!("unexpected wait status: {:?}", other)), }; - self.state = if reason.is_alive() { - State::Stopped(reason.clone()) - } else { - State::Dead(reason.clone()) - }; Ok(reason) } + /// After a wait, if the reason is a SIGTRAP, check whether PC-1 matches a + /// known breakpoint; if so, rewind PC and mark the stop as + /// Breakpoint(id). Update self.state. + fn handle_possible_breakpoint(&mut self, mut reason: StopReason) -> Result<(), String> { + if let StopReason::Step = reason { + // After INT3 the kernel leaves RIP at addr+1. + if let Ok(r) = ptrace::getregs(self.pid) { + let candidate = r.rip.wrapping_sub(1); + if let Some(bp) = self.breakpoints.find_by_addr(candidate).cloned() { + // Rewind RIP to the breakpoint address. + let mut new_regs = r; + new_regs.rip = candidate; + ptrace::setregs(self.pid, new_regs) + .map_err(|e| format!("setregs (rewind bp): {}", e))?; + if let Some(bpm) = self.breakpoints.get_mut(bp.id) { + bpm.hits += 1; + } + reason = StopReason::Breakpoint(bp.id); + } + } + } + self.classify_stop(reason); + Ok(()) + } + + fn classify_stop(&mut self, reason: StopReason) { + self.state = if reason.is_alive() { + State::Stopped(reason) + } else { + State::Dead(reason) + }; + } + pub fn regs(&self) -> Result { if !self.is_stopped() { return Err("target is not stopped".into()); @@ -210,3 +349,28 @@ impl Drop for Target { } } } + +// --- ptrace byte-level peek/poke for breakpoint patching ------------------ + +/// Read a single byte at `addr` via PTRACE_PEEKDATA. +fn read_byte(pid: Pid, addr: u64) -> Result { + let word = ptrace::read(pid, addr as ptrace::AddressType) + .map_err(|e| format!("peek 0x{:x}: {}", addr, e))?; + let bytes = (word as u64).to_le_bytes(); + Ok(bytes[0]) +} + +/// Write a single byte at `addr` via PEEK+POKE (read-modify-write of one word). +/// Writes the 8-byte word starting at addr; the upper 7 bytes are unchanged +/// because we read them first. +fn write_byte(pid: Pid, addr: u64, value: u8) -> Result<(), String> { + let word = ptrace::read(pid, addr as ptrace::AddressType) + .map_err(|e| format!("peek 0x{:x} (for write): {}", addr, e))?; + let mut bytes = (word as u64).to_le_bytes(); + bytes[0] = value; + let new_word = u64::from_le_bytes(bytes) as i64; + ptrace::write(pid, addr as ptrace::AddressType, new_word as libc::c_long) + .map_err(|e| format!("poke 0x{:x}: {}", addr, e))?; + Ok(()) +} +