v0.3: ELF symbol parsing, sym()/addr()/symbols() globals, addrstr/here prelude helpers
This commit is contained in:
@@ -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<Value<'js>> {
|
||||
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<Value<'js>> {
|
||||
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<Value<'js>> {
|
||||
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.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! - EOF on stdin exits cleanly.
|
||||
|
||||
mod target;
|
||||
mod symbols;
|
||||
mod js;
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
|
||||
+195
@@ -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/<pid>/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<u64, Symbol>,
|
||||
/// 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<String, u64>,
|
||||
/// 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/<pid>/exe, applying the load base from
|
||||
/// /proc/<pid>/maps if the binary is PIE.
|
||||
pub fn from_pid(pid: i32) -> Result<SymbolTable, String> {
|
||||
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<u64, Symbol> = BTreeMap::new();
|
||||
let mut by_name: BTreeMap<String, u64> = 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<String> {
|
||||
// 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<u64> {
|
||||
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<PathBuf, String> {
|
||||
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/<pid>/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<u64, String> {
|
||||
// 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/<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 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,
|
||||
}
|
||||
}
|
||||
+18
-5
@@ -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<libc::user_regs_struct>,
|
||||
/// ELF symbols of the main executable. None if parsing failed (e.g.
|
||||
/// stripped binary, unreadable /proc) — symbol lookups become no-ops.
|
||||
symbols: Option<SymbolTable>,
|
||||
}
|
||||
|
||||
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<StopReason, String> {
|
||||
let status = waitpid(self.pid, None).map_err(|e| format!("waitpid: {}", e))?;
|
||||
let reason = match status {
|
||||
|
||||
Reference in New Issue
Block a user