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)
This commit is contained in:
claude
2026-05-27 21:21:29 +00:00
parent d587dbcd4c
commit 8ae6103ce7
5 changed files with 382 additions and 45 deletions
+94
View File
@@ -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<String>,
}
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<Option<...>> is fine.
items: Vec<Option<Breakpoint>>,
/// Address → bp id, for O(1) lookup when SIGTRAP fires.
by_addr: HashMap<u64, u32>,
/// name → bp id, for `remove("name")`.
by_name: HashMap<String, u32>,
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<String>) -> Result<u32, String> {
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<Breakpoint> {
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<u32> {
self.by_name.get(name).copied()
}
/// Iterate over live breakpoints in id order.
pub fn iter(&self) -> impl Iterator<Item = &Breakpoint> {
self.items.iter().filter_map(|b| b.as_ref())
}
}
+81
View File
@@ -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<String>| -> rquickjs::Result<u32> {
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<Value<'js>> {
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.
+1
View File
@@ -11,6 +11,7 @@
mod target;
mod symbols;
mod breakpoint;
mod js;
use std::io::{BufRead, Write};
+11 -14
View File
@@ -150,32 +150,29 @@ fn read_load_base(pid: i32, exe_path: &PathBuf) -> Result<u64, String> {
// /proc/<pid>/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);
}
+195 -31
View File
@@ -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<SymbolTable>,
/// 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<StopReason, String> {
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<StopReason, String> {
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<libc::user_regs_struct> {
self.prev_regs
}
@@ -156,7 +140,134 @@ impl Target {
self.symbols.as_ref()
}
fn wait_for_stop(&mut self) -> Result<StopReason, String> {
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<String>) -> Result<u32, String> {
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<u32> {
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<StopReason, String> {
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<StopReason, String> {
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<StopReason, String> {
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<libc::user_regs_struct, String> {
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<u8, String> {
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(())
}