From d587dbcd4cef6a2057a9e35c81836690dfeaf4ed Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 21:13:10 +0000 Subject: [PATCH] v0.3: ELF symbol parsing, sym()/addr()/symbols() globals, addrstr/here prelude helpers --- Cargo.lock | 16 ++++ Cargo.toml | 1 + src/js.rs | 47 ++++++++++++ src/main.rs | 1 + src/prelude.js | 26 +++++++ src/symbols.rs | 195 +++++++++++++++++++++++++++++++++++++++++++++++++ src/target.rs | 23 ++++-- 7 files changed, 304 insertions(+), 5 deletions(-) create mode 100644 src/symbols.rs diff --git a/Cargo.lock b/Cargo.lock index dfa3917..3d80fc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,9 +77,16 @@ version = "0.1.0" dependencies = [ "libc", "nix", + "object", "rquickjs", ] +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + [[package]] name = "nix" version = "0.31.3" @@ -92,6 +99,15 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + [[package]] name = "proc-macro2" version = "1.0.106" diff --git a/Cargo.toml b/Cargo.toml index a665123..8c62ce2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,3 +7,4 @@ edition = "2024" nix = { version = "0.31", features = ["ptrace", "process", "uio", "signal"] } rquickjs = { version = "0.12", features = ["std"] } libc = "0.2" +object = { version = "0.36", default-features = false, features = ["read", "std"] } diff --git a/src/js.rs b/src/js.rs index 1624bb5..c8ae787 100644 --- a/src/js.rs +++ b/src/js.rs @@ -318,6 +318,53 @@ fn install_globals<'js>( globals.set("prev", prev_obj)?; } + // ---------- sym(addr) -> "name+0xN" | null ---------- + { + let target = target.clone(); + let f = Function::new(c.clone(), move |c: Ctx<'js>, addr: Value<'js>| -> rquickjs::Result> { + let addr = coerce_addr(&c, &addr)?; + let slot = target.borrow(); + let t = slot.as_ref().ok_or_else(|| throw(&c, "no target attached"))?; + let s = t.symbols().ok_or_else(|| throw(&c, "no symbol table"))?; + match s.name_for_addr(addr) { + Some(name) => Ok(rquickjs::String::from_str(c.clone(), &name)?.into_value()), + None => Ok(Value::new_null(c.clone())), + } + })?; + globals.set("sym", f)?; + } + + // ---------- addr(name) -> BigInt | null ---------- + { + let target = target.clone(); + let f = Function::new(c.clone(), move |c: Ctx<'js>, name: String| -> rquickjs::Result> { + let slot = target.borrow(); + let t = slot.as_ref().ok_or_else(|| throw(&c, "no target attached"))?; + let s = t.symbols().ok_or_else(|| throw(&c, "no symbol table"))?; + match s.addr_for_name(&name) { + Some(a) => u64_to_bigint(&c, a), + None => Ok(Value::new_null(c.clone())), + } + })?; + globals.set("addr", f)?; + } + + // ---------- symbols() -> { count, load_base, path } ---------- + { + 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 s = t.symbols().ok_or_else(|| throw(&c, "no symbol table"))?; + let obj = Object::new(c.clone())?; + obj.set("count", s.len() as u32)?; + obj.set("load_base", u64_to_bigint(&c, s.load_base)?)?; + obj.set("path", s.path.to_string_lossy().into_owned())?; + Ok(obj.into_value()) + })?; + globals.set("symbols", f)?; + } + // ---------- 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 fc88206..2a1dec4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ //! - EOF on stdin exits cleanly. mod target; +mod symbols; mod js; use std::io::{BufRead, Write}; diff --git a/src/prelude.js b/src/prelude.js index 5632dbb..7acfb64 100644 --- a/src/prelude.js +++ b/src/prelude.js @@ -85,3 +85,29 @@ globalThis.repr = function (v) { } return String(v); }; + +// --------------------------------------------------------------------------- +// addrstr(v) — render an address with symbol context. +// Returns "0x7fff... (main+0x4)" if the address is inside a known symbol, +// or just "0x7fff..." otherwise. Safe to call before a target is attached: +// falls back to bare hex if `sym` throws. + +globalThis.addrstr = function (v) { + const h = hex(v); + try { + const s = sym(v); + return s ? `${h} (${s})` : h; + } catch (_) { + return h; + } +}; + +// --------------------------------------------------------------------------- +// here() — quick "where am I" one-liner. Prints pc with symbol context. + +globalThis.here = function (label) { + const s = addrstr(state.pc); + if (label) print(label, s); else print(s); +}; + + diff --git a/src/symbols.rs b/src/symbols.rs new file mode 100644 index 0000000..6e2ee3e --- /dev/null +++ b/src/symbols.rs @@ -0,0 +1,195 @@ +//! ELF symbol table parsing and address lookups for the main executable. +//! +//! v0.3 scope: +//! - Parse the main binary's symbol table (.symtab + .dynsym). +//! - Handle PIE (ET_DYN) by reading the load base from /proc//maps. +//! - Provide name → addr and addr → "name+0xoff" lookups. +//! - Shared libraries are NOT covered — those need ld.so awareness and live +//! in a later milestone. + +use object::{Object, ObjectSymbol, ObjectSection, SymbolKind}; +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; + +/// One named symbol with a load-base-adjusted virtual address and a size. +/// Size==0 is common (assembly globals etc.); we treat 0 as "unknown size, +/// match only by exact start address" when doing addr → name lookups. +#[derive(Debug, Clone)] +pub struct Symbol { + pub name: String, + pub addr: u64, + pub size: u64, +} + +pub struct SymbolTable { + /// addr → symbol. Keys are post-relocation virtual addresses. + by_addr: BTreeMap, + /// Lowercase name → addr. We keep the case-sensitive name in Symbol but + /// allow case-insensitive lookups since CLI debugger users won't always + /// remember exact capitalisation. Stores the *first* address seen for + /// a name (binaries can have duplicates after stripping). + by_name: BTreeMap, + /// Load base offset that was applied to file-relative addresses. 0 for + /// non-PIE binaries. + pub load_base: u64, + /// Path the symbols came from, for diagnostics. + pub path: PathBuf, +} + +impl SymbolTable { + /// Load symbols from /proc//exe, applying the load base from + /// /proc//maps if the binary is PIE. + pub fn from_pid(pid: i32) -> Result { + let exe_path = read_exe_path(pid)?; + let load_base = read_load_base(pid, &exe_path)?; + let bytes = fs::read(&exe_path) + .map_err(|e| format!("read exe {}: {}", exe_path.display(), e))?; + let elf = object::File::parse(&*bytes) + .map_err(|e| format!("parse ELF: {}", e))?; + + let mut by_addr: BTreeMap = BTreeMap::new(); + let mut by_name: BTreeMap = BTreeMap::new(); + + for sym in elf.symbols().chain(elf.dynamic_symbols()) { + if !matches!(sym.kind(), SymbolKind::Text | SymbolKind::Data | SymbolKind::Unknown) { + continue; + } + let raw_name = match sym.name() { + Ok(n) if !n.is_empty() => n, + _ => continue, + }; + // Filter out section-undefined symbols (imports). + if sym.section_index().is_none() && !sym.is_definition() { + continue; + } + let addr = sym.address().wrapping_add(load_base); + if addr == 0 { continue; } + let s = Symbol { + name: raw_name.to_string(), + addr, + size: sym.size(), + }; + // Prefer the entry already there if both exist at same address + // and the newer one has size 0 — keeps size info around. + match by_addr.get(&addr) { + Some(existing) if existing.size > 0 && s.size == 0 => { /* keep existing */ } + _ => { by_addr.insert(addr, s.clone()); } + } + by_name.entry(raw_name.to_string()).or_insert(addr); + // Also stash a lowercase alias if it isn't already a key + let lc = raw_name.to_lowercase(); + if lc != raw_name { + by_name.entry(lc).or_insert(addr); + } + } + + // For diagnostic: also note executable section ranges (might help us + // later filter "is this addr even code"). Not used yet but cheap. + let _exec_sections: Vec<_> = elf.sections() + .filter(|s| s.kind() == object::SectionKind::Text) + .map(|s| (s.address().wrapping_add(load_base), s.size())) + .collect(); + + Ok(SymbolTable { by_addr, by_name, load_base, path: exe_path }) + } + + /// Find the symbol containing or starting at `addr`. + /// Returns "name" if exact, "name+0xN" if interior, None if no match. + pub fn name_for_addr(&self, addr: u64) -> Option { + // Look at the symbol whose addr ≤ ours, and check whether ours falls + // within [sym.addr, sym.addr + sym.size). For size==0 symbols, only + // match an exact equality. + let (&_, sym) = self.by_addr.range(..=addr).next_back()?; + let off = addr - sym.addr; + if sym.size == 0 { + if off == 0 { Some(sym.name.clone()) } else { None } + } else if off < sym.size { + if off == 0 { Some(sym.name.clone()) } else { Some(format!("{}+0x{:x}", sym.name, off)) } + } else { + None + } + } + + /// Resolve a name to a single address. Tries exact case first, then + /// lowercase. Returns None if not found or ambiguous. + pub fn addr_for_name(&self, name: &str) -> Option { + if let Some(&a) = self.by_name.get(name) { return Some(a); } + self.by_name.get(&name.to_lowercase()).copied() + } + + pub fn len(&self) -> usize { + self.by_addr.len() + } +} + +fn read_exe_path(pid: i32) -> Result { + fs::read_link(format!("/proc/{}/exe", pid)) + .map_err(|e| format!("read /proc/{}/exe: {}", pid, e)) +} + +/// For a PIE (ET_DYN) binary, find the first executable mapping of the exe +/// file in /proc//maps and use its address as the load base. For non-PIE +/// (ET_EXEC), the load base is 0. +fn read_load_base(pid: i32, exe_path: &PathBuf) -> Result { + // Peek at the ELF header e_type via the file we'll parse anyway. + let bytes = fs::read(exe_path) + .map_err(|e| format!("read exe {}: {}", exe_path.display(), e))?; + let elf = object::File::parse(&*bytes) + .map_err(|e| format!("parse ELF: {}", e))?; + // object doesn't expose e_type directly on the high-level File, but + // it does expose `relative_address_base` which is what we want: for + // ET_EXEC this is the absolute base of the file's address space, + // for ET_DYN it's 0 (so symbol addresses are file-offsets-as-vaddrs). + // We compute load base = first executable mapping start - (file's PT_LOAD + // p_vaddr for that segment). Easier: parse /proc/maps and look for the + // first executable mapping whose pathname matches exe_path. + let exe_str = exe_path.to_string_lossy().into_owned(); + let maps = fs::read_to_string(format!("/proc/{}/maps", pid)) + .map_err(|e| format!("read /proc/{}/maps: {}", pid, e))?; + + // /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 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 _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))?; + + // 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); + } else { + return Ok(0); + } + } + // Fallback: no executable mapping found. Probably never happens. + Ok(0) +} + +fn is_pie(elf: &object::File) -> bool { + // ET_DYN is what gcc produces by default for executables ("-pie") and + // also for shared libraries. For an executable file, ET_DYN ≡ PIE. + match elf { + object::File::Elf64(e) => e.elf_header().e_type.get(object::Endianness::Little) == object::elf::ET_DYN, + object::File::Elf32(e) => e.elf_header().e_type.get(object::Endianness::Little) == object::elf::ET_DYN, + _ => false, + } +} diff --git a/src/target.rs b/src/target.rs index 824afcc..422fb67 100644 --- a/src/target.rs +++ b/src/target.rs @@ -11,6 +11,8 @@ use nix::unistd::{execvp, fork, ForkResult, Pid}; use std::ffi::CString; use std::io::IoSliceMut; +use crate::symbols::SymbolTable; + /// Why the target stopped after a step/continue, or that it has exited. #[derive(Debug, Clone)] pub enum StopReason { @@ -55,6 +57,9 @@ pub struct Target { /// Snapshot of registers from the previous stop. None until at least one /// step/cont has happened after spawn. prev_regs: Option, + /// ELF symbols of the main executable. None if parsing failed (e.g. + /// stripped binary, unreadable /proc) — symbol lookups become no-ops. + symbols: Option, } impl Target { @@ -102,11 +107,15 @@ impl Target { let status = waitpid(child, None) .map_err(|e| format!("waitpid (initial) failed: {}", e))?; match status { - WaitStatus::Stopped(_, Signal::SIGTRAP) => Ok(Target { - pid: child, - state: State::Stopped(StopReason::Step), - prev_regs: None, - }), + WaitStatus::Stopped(_, Signal::SIGTRAP) => { + let symbols = SymbolTable::from_pid(child.as_raw()).ok(); + Ok(Target { + pid: child, + state: State::Stopped(StopReason::Step), + prev_regs: None, + symbols, + }) + } WaitStatus::Exited(_, code) => { Err(format!("child exited immediately with code {} (exec failed?)", code)) } @@ -143,6 +152,10 @@ impl Target { self.prev_regs } + pub fn symbols(&self) -> Option<&SymbolTable> { + self.symbols.as_ref() + } + fn wait_for_stop(&mut self) -> Result { let status = waitpid(self.pid, None).map_err(|e| format!("waitpid: {}", e))?; let reason = match status {