Demangling the Details: Symbol Resolution in Rusty Trap

View all posts and upcoming topics →
A few posts ago, we used ownership in Rust to drive the design of our API when setting multiple breakpoints in our rusty_trap
debugger. Then we did some vibe coded further refactoring of the API. The API we have for inferiors and breakpoints now is pretty clean and we can move onto the next feature. This has been a long time coming, and I’m proud to announce we are finally going to implement support for looking up symbols from the symbol table when setting breakpoints.
Exploring ELF Files and Symbol Tables
Symbol lookup, or symbol resolution, means that we will no longer have to specify raw addresses for breakpoints. Instead, our users (and tests) can set breakpoints in “twelve::main” or “loop::foo”. rusty_trap
will do the work of figuring out what the address is for the breakpoint and then once the address is known the breakpoint can be set and handled the same way we have been doing this entire time.
For reference, I’m starting at commit c8fc68e
.
Now, it turns out there are a lot of different symbol tables available. There are dynamic tables used for dynamic linking, static tables, and debug information. For this post we will work with the norm symbol table. Here’s an example of what I mean taken from one of our inferiors. Our inferior (all programs) gets compiled and linked into an object file or some type. The format used on Linux is usually ELF or some variant of ELF. There’s a handy tool called readelf
that can show us the content of an ELF object file. readelf -S
shows the different sections of the ELF file. Below is the output after running readelf -S
on our inferior named twelve
and it gives a taste of what we are looking at.
readelf -S twelve
There are 41 section headers, starting at offset 0x37f610:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .interp PROGBITS 0000000000000350 00000350
000000000000001c 0000000000000000 A 0 0 1
[ 2] .note.gnu.pr[...] NOTE 0000000000000370 00000370
0000000000000020 0000000000000000 A 0 0 8
[ 3] .note.gnu.bu[...] NOTE 0000000000000390 00000390
0000000000000024 0000000000000000 A 0 0 4
[ 4] .note.ABI-tag NOTE 00000000000003b4 000003b4
0000000000000020 0000000000000000 A 0 0 4
[ 5] .gnu.hash GNU_HASH 00000000000003d8 000003d8
0000000000000024 0000000000000000 A 6 0 8
[ 6] .dynsym DYNSYM 0000000000000400 00000400
0000000000000660 0000000000000018 A 7 1 8
[ 7] .dynstr STRTAB 0000000000000a60 00000a60
0000000000000412 0000000000000000 A 0 0 1
[ 8] .gnu.version VERSYM 0000000000000e72 00000e72
0000000000000088 0000000000000002 A 6 0 2
[ 9] .gnu.version_r VERNEED 0000000000000f00 00000f00
0000000000000110 0000000000000000 A 7 3 8
[10] .rela.dyn RELA 0000000000001010 00001010
0000000000004740 0000000000000018 A 6 0 8
[11] .rela.plt RELA 0000000000005750 00005750
0000000000000030 0000000000000018 AI 6 28 8
[12] .init PROGBITS 0000000000006000 00006000
000000000000001b 0000000000000000 AX 0 0 4
[13] .plt PROGBITS 0000000000006020 00006020
0000000000000030 0000000000000010 AX 0 0 16
[14] .plt.got PROGBITS 0000000000006050 00006050
0000000000000008 0000000000000008 AX 0 0 8
[15] .text PROGBITS 0000000000006060 00006060
000000000003ceeb 0000000000000000 AX 0 0 16
[16] .fini PROGBITS 0000000000042f4c 00042f4c
000000000000000d 0000000000000000 AX 0 0 4
[17] .rodata PROGBITS 0000000000043000 00043000
0000000000005550 0000000000000000 A 0 0 16
[18] .debug_gdb_s[...] PROGBITS 0000000000048550 00048550
0000000000000022 0000000000000001 AMS 0 0 1
[19] .eh_frame_hdr PROGBITS 0000000000048574 00048574
0000000000000fdc 0000000000000000 A 0 0 4
[20] .eh_frame PROGBITS 0000000000049550 00049550
0000000000005700 0000000000000000 A 0 0 8
[21] .gcc_except_table PROGBITS 000000000004ec50 0004ec50
0000000000000fac 0000000000000000 A 0 0 4
[22] .tdata PROGBITS 0000000000050fe8 0004ffe8
0000000000000020 0000000000000000 WAT 0 0 8
[23] .tbss NOBITS 0000000000051008 00050008
0000000000000040 0000000000000000 WAT 0 0 8
[24] .init_array INIT_ARRAY 0000000000051008 00050008
0000000000000010 0000000000000008 WA 0 0 8
[25] .fini_array FINI_ARRAY 0000000000051018 00050018
0000000000000008 0000000000000008 WA 0 0 8
[26] .data.rel.ro PROGBITS 0000000000051020 00050020
00000000000025a8 0000000000000000 WA 0 0 8
[27] .dynamic DYNAMIC 00000000000535c8 000525c8
0000000000000210 0000000000000010 WA 7 0 8
[28] .got PROGBITS 00000000000537d8 000527d8
0000000000000828 0000000000000008 WA 0 0 8
[29] .data PROGBITS 0000000000054000 00053000
0000000000000978 0000000000000000 WA 0 0 8
[30] .bss NOBITS 0000000000054978 00053978
00000000000000e8 0000000000000000 WA 0 0 8
[31] .comment PROGBITS 0000000000000000 00053978
0000000000000057 0000000000000001 MS 0 0 1
[32] .debug_aranges PROGBITS 0000000000000000 000539cf
00000000000074c0 0000000000000000 0 0 1
[33] .debug_info PROGBITS 0000000000000000 0005ae8f
00000000000fae6f 0000000000000000 0 0 1
[34] .debug_abbrev PROGBITS 0000000000000000 00155cfe
00000000000010f2 0000000000000000 0 0 1
[35] .debug_line PROGBITS 0000000000000000 00156df0
000000000006830e 0000000000000000 0 0 1
[36] .debug_str PROGBITS 0000000000000000 001bf0fe
0000000000147907 0000000000000001 MS 0 0 1
[37] .debug_ranges PROGBITS 0000000000000000 00306a05
00000000000672c0 0000000000000000 0 0 1
[38] .symtab SYMTAB 0000000000000000 0036dcc8
0000000000004ae8 0000000000000018 39 461 8
[39] .strtab STRTAB 0000000000000000 003727b0
000000000000ccbb 0000000000000000 0 0 1
[40] .shstrtab STRTAB 0000000000000000 0037f46b
000000000000019e 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
D (mbind), l (large), p (processor specific)
There are 40 sections here, the sections are things like .symtab which is the symbol table we will be looking at, .strtab which holds string that can be referenced in other sections (reused strings only get stored once), .data which holds an initialized image of all the global variables. .bss which represents uninitialized (or zeroed) global data but usually doesn’t occupy any space in the object file. The size for .bss given as 0xe8 bytes is likely the size of the section header. There are also .dynsym and .dynstr which are similar to .symtab and .strtab but are used for dynamic linking.
We can take a look at the symbol table using the -s option, though it prints with mangled names. We can use the --demangle=rust
option to get nice readable symbols.
$ readelf -s --demangle=rust twelve | head
Symbol table '.dynsym' contains 68 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND [...]@GLIBC_2.2.5 (2)
2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND [...]@GLIBC_2.2.5 (2)
3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND free@GLIBC_2.2.5 (2)
4: 0000000000000000 0 FUNC GLOBAL DEFAULT UND _[...]@GLIBC_2.34 (3)
5: 0000000000000000 0 FUNC GLOBAL DEFAULT UND abort@GLIBC_2.2.5 (2)
6: 0000000000000000 0 FUNC GLOBAL DEFAULT UND _Unw[...]@GCC_3.3 (4)
There are a lot of symbols and it seems that readelf has opted to give us the dynamic symbol table (.dynsym) this may be because our executable is linked PIE and uses, effectively, dynamic loading or at least relocation by default.
We can search for “main” to find:
$ readelf -s --demangle=rust twelve | grep main
381: 0000000000007820 12 FUNC LOCAL DEFAULT 15 twelve::main[...]
692: 0000000000007830 32 FUNC GLOBAL DEFAULT 15 main
Now this is all fine and good but we don’t really want to exec readelf and grep from within rusty_trap
. Instead, there is a crate for rust called object
that can read the object files for us.
Using the object Crate for Symbol Resolution
Taking a look at the object create I see that the read module provides a unified read api with a nice example which lists the sections. It specifically has SymbolTable
with a symbols iterator - this might be what we want. Looking deeper, it has symbol_by_name
. This is perfect. And of course there is Symbol
with name
and address
Let’s put this into action.
Tests for Symbol Resolution
Instead of writing a new test, instead we’ll update the existing tests to set breakpoints by address. To pass the tests:
- Add the object crate to our build.
- In
trap_inferior_exec
following the example from the unified read API and load the inferior file as anObject
. - Store the
Object
in the inferior. - When setting breakpoints
I guess this leaves us with the question of supporting setting breakpoints by address?
- If do this it should be a different interface that takes a proper
InferiorPointer
.- Oh it actually takes a u64 which is basically
InferiorPointer
. I guess I couldn’t play so fast and loose with the types in Rust, in C it takes achar *
which I could no interpret as a string.
- Oh it actually takes a u64 which is basically
- Tests for this would be very fragile, as we’ve seen already. I suppose the test could use object to look up the address and then pass in that address to
rusty_trap
but that feels a little bit like overkill. - If this is really for educational purposes then I think we don’t need this feature as we have learned this already. But if you want to be able to set breakpoints on raw addresses you should know how to implement it.
Here are the updated tests:
modified tests/lib.rs
@@ -17,7 +17,7 @@ fn it_can_set_breakpoints() {
let inferior = rusty_trap::trap_inferior_exec(Path::new("./target/debug/twelve"), &[]).unwrap();
let expected_pid = inferior.pid;
- let (inferior, bp) = rusty_trap::trap_inferior_set_breakpoint(inferior, 0x000055555555b821);
+ let (inferior, bp) = rusty_trap::trap_inferior_set_breakpoint(inferior, "twelve::main");
let (_, _) = rusty_trap::trap_inferior_continue(inferior, |passed_inferior, passed_bp| {
assert_eq!(passed_inferior.pid, expected_pid);
assert_eq!(passed_bp, bp);
@@ -33,7 +33,7 @@ fn it_can_handle_a_breakpoint_more_than_once() {
let inferior = rusty_trap::trap_inferior_exec(Path::new("./target/debug/loop"), &[]).unwrap();
let expected_pid = inferior.pid;
- let (inferior, bp) = rusty_trap::trap_inferior_set_breakpoint(inferior, ADDRESS_OF_FOO);
+ let (inferior, bp) = rusty_trap::trap_inferior_set_breakpoint(inferior, "loop::foo");
rusty_trap::trap_inferior_continue(inferior, |passed_inferior, passed_bp| {
assert_eq!(passed_inferior.pid, expected_pid);
assert_eq!(passed_bp, bp);
@@ -50,8 +50,8 @@ fn it_can_handle_more_than_one_breakpoint() {
let inferior = rusty_trap::trap_inferior_exec(Path::new("./target/debug/loop"), &[]).unwrap();
let expected_pid = inferior.pid;
- let (inferior, bp_main) = rusty_trap::trap_inferior_set_breakpoint(inferior, ADDRESS_OF_MAIN);
- let (inferior, bp_foo) = rusty_trap::trap_inferior_set_breakpoint(inferior, ADDRESS_OF_FOO);
+ let (inferior, bp_main) = rusty_trap::trap_inferior_set_breakpoint(inferior, "loop::main");
+ let (inferior, bp_foo) = rusty_trap::trap_inferior_set_breakpoint(inferior, "loop::foo");
let (_, _) = rusty_trap::trap_inferior_continue(inferior, |passed_inferior, passed_bp| {
assert_eq!(passed_inferior.pid, expected_pid);
if passed_bp == bp_main {
When we run the tests the fail, as expected:
Compiling rusty_trap v0.1.0 (/home/jkain/projects/rusty_trap)
error[E0308]: mismatched types
--> tests/lib.rs:20:77
|
20 | let (inferior, bp) = rusty_trap::trap_inferior_set_breakpoint(inferior, "twelve::main");
| ---------------------------------------- ^^^^^^^^^^^^^^ expected `u64`, found `&str`
| |
| arguments to this function are incorrect
|
note: function defined here
--> /home/jkain/projects/rusty_trap/src/breakpoint/mod.rs:71:8
|
71 | pub fn trap_inferior_set_breakpoint(
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error[E0308]: mismatched types
--> tests/lib.rs:36:77
|
36 | let (inferior, bp) = rusty_trap::trap_inferior_set_breakpoint(inferior, "loop::foo");
| ---------------------------------------- ^^^^^^^^^^^ expected `u64`, found `&str`
| |
| arguments to this function are incorrect
|
note: function defined here
--> /home/jkain/projects/rusty_trap/src/breakpoint/mod.rs:71:8
|
71 | pub fn trap_inferior_set_breakpoint(
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error[E0308]: mismatched types
--> tests/lib.rs:53:82
|
53 | let (inferior, bp_main) = rusty_trap::trap_inferior_set_breakpoint(inferior, "loop::main");
| ---------------------------------------- ^^^^^^^^^^^^ expected `u64`, found `&str`
| |
| arguments to this function are incorrect
|
note: function defined here
--> /home/jkain/projects/rusty_trap/src/breakpoint/mod.rs:71:8
|
71 | pub fn trap_inferior_set_breakpoint(
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error[E0308]: mismatched types
--> tests/lib.rs:54:81
|
54 | let (inferior, bp_foo) = rusty_trap::trap_inferior_set_breakpoint(inferior, "loop::foo");
| ---------------------------------------- ^^^^^^^^^^^ expected `u64`, found `&str`
| |
| arguments to this function are incorrect
|
note: function defined here
--> /home/jkain/projects/rusty_trap/src/breakpoint/mod.rs:71:8
|
71 | pub fn trap_inferior_set_breakpoint(
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
For more information about this error, try `rustc --explain E0308`.
error: could not compile `rusty_trap` (test "lib") due to 4 previous errors
Passing symbol names through rusty_trap
So, it’s not surprising we need to update the API to take a string with the symbols name. But as we do this let’s rename the current trap_inferior_set_breakpoint
because ultimately we will end up setting a breakpoint at an address, that’s just how this works, so we want to keep this functionality. I’ll call it set_breakpoint_at_address
.
modified src/breakpoint/mod.rs
@@ -68,7 +68,7 @@ where
};
}
-pub fn trap_inferior_set_breakpoint(
+fn set_breakpoint_at_address(
mut inferior: TrapInferior,
location: u64,
) -> (TrapInferior, TrapBreakpoint) {
@@ -91,3 +91,11 @@ pub fn trap_inferior_set_breakpoint(
(inferior, InferiorPointer(location))
}
+
+pub fn trap_inferior_set_breakpoint(
+ mut inferior: TrapInferior,
+ location: &str,
+) -> (TrapInferior, TrapBreakpoint) {
+ let address: u64 = 0x55555555b9f4;
+ return set_breakpoint_at_address(inferior, address);
+}
So I have a placeholder address in trap_inferior_set_breakpoint
. And of course that causes the test to fail. So, now we need to follow our plan and use the object
crate.
- Add the object crate to our build.
modified Cargo.toml
@@ -6,6 +6,7 @@ authors = ["Joseph Kain <joekain@gmail.com>"]
[dependencies]
libc = "0.2.174"
nix = {version = "0.30.1", features = ["process", "ptrace", "signal"]}
+object = "0.37"
[lib]
name = "rusty_trap"
Our next steps are:
- In
trap_inferior_exec
following the example from the unified read API and load the inferior file as anObject
.- Store the
Object
in the inferior.
This should be, easy, right?
modified src/inferior/mod.rs
@@ -4,6 +4,9 @@ use libc::pid_t;
use std::collections::HashMap;
use std::fmt;
use std::ops::{Add, Sub};
+use object;
+use std::fs;
+use std::path::Path;
#[derive(Copy, Clone)]
pub enum InferiorState {
@@ -17,14 +20,17 @@ pub struct Inferior {
pub pid: pid_t,
pub state: InferiorState,
pub breakpoints: HashMap<InferiorPointer, Breakpoint>,
+ obj: object::File,
}
impl Inferior {
- pub fn new(pid: pid_t) -> Inferior {
+ pub fn new(pid: pid_t, path: &Path) -> Inferior {
+ let data = fs::read(path).unwrap();
Inferior {
pid,
state: InferiorState::Stopped,
breakpoints: HashMap::new(),
+ obj: object::File::parse(&*data).unwrap(),
}
}
}
modified src/lib.rs
@@ -1,5 +1,6 @@
extern crate libc;
extern crate nix;
+extern crate object;
use libc::pid_t;
use nix::sys::wait::*;
@@ -45,10 +46,10 @@ fn exec_inferior(filename: &Path, _args: &[&str]) {
unreachable!();
}
-fn attach_inferior(raw_pid: pid_t) -> Result<Inferior, Error> {
+fn attach_inferior(raw_pid: pid_t, filename: &Path) -> Result<Inferior, Error> {
let nix_pid = Pid::from_raw(raw_pid);
match waitpid(nix_pid, None) {
- Ok(WaitStatus::Stopped(pid, signal::Signal::SIGTRAP)) => Ok(Inferior::new(pid.into())),
+ Ok(WaitStatus::Stopped(pid, signal::Signal::SIGTRAP)) => Ok(Inferior::new(pid.into(), filename)),
Ok(_) => panic!("Unexpected stop in attach_inferior"),
Err(e) => Err(e),
}
@@ -61,7 +62,7 @@ pub fn trap_inferior_exec(filename: &Path, args: &[&str]) -> Result<Inferior, Er
exec_inferior(filename, args);
unreachable!();
}
- Ok(ForkResult::Parent { child: pid }) => return attach_inferior(pid.into()),
+ Ok(ForkResult::Parent { child: pid }) => return attach_inferior(pid.into(), filename),
Err(Error::EAGAIN) => continue,
Err(e) => return Err(e),
}
Lifetimes? What is this about lifetimes?
Lifetimes and Self-Referential Structs
error[E0106]: missing lifetime specifier
--> src/inferior/mod.rs:23:18
|
23 | obj: object::File,
| ^^^^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
19 ~ pub struct Inferior<'a> {
20 | pub pid: pid_t,
21 | pub state: InferiorState,
22 | pub breakpoints: HashMap<InferiorPointer, Breakpoint>,
23 ~ obj: object::File<'a>,
Ok, I don’t really understand lifetimes that well. Well, I understand what lifetimes are conceptually: when object A references object B then B must live longer that A, otherwise when B goes out of scope object A would be left with an invalid or dangling reference. And in our particular case I know that I want this object::File
to live as long as the inferior. I just don’t have a good understanding of the Rust syntax. That said, I can follow the compiler’s suggestion.
But, I think I can understand that the compiler is saying what I just said I wanted. The lifetime of the Inferior
and of the object::File
are the same and we express that as lifetime 'a
.
Fixing this just unlocks follow on errors:
error[E0726]: implicit elided lifetime not allowed here
--> src/inferior/mod.rs:26:6
|
26 | impl Inferior {
| ^^^^^^^^ expected lifetime parameter
|
help: indicate the anonymous lifetime
|
26 | impl Inferior<'_> {
| ++++
error[E0106]: missing lifetime specifier
--> src/inferior/mod.rs:38:25
|
38 | pub type TrapInferior = Inferior;
| ^^^^^^^^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
38 | pub type TrapInferior<'a> = Inferior<'a>;
| ++++ ++++
error[E0106]: missing lifetime specifier
--> src/breakpoint/mod.rs:34:84
|
34 | fn find_breakpoint_matching_inferior_instruction_pointer(inf: &Inferior) -> Option<&Breakpoint> {
| --------- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say which one of `inf`'s 2 lifetimes it is borrowed from
help: consider introducing a named lifetime parameter
|
34 | fn find_breakpoint_matching_inferior_instruction_pointer<'a>(inf: &'a Inferior<'a>) -> Option<&'a Breakpoint> {
| ++++ ++ ++++ ++
error[E0106]: missing lifetime specifier
--> src/lib.rs:58:69
|
58 | pub fn trap_inferior_exec(filename: &Path, args: &[&str]) -> Result<Inferior, Error> {
| ----- ------- ^^^^^^^^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `filename` or one of `args`'s 2 lifetimes
help: consider introducing a named lifetime parameter
|
58 | pub fn trap_inferior_exec<'a>(filename: &'a Path, args: &'a [&'a str]) -> Result<Inferior<'a>, Error> {
| ++++ ++ ++ ++ ++++
error[E0277]: the trait bound `object::File<'_, &[u8]>: Clone` is not satisfied
--> src/inferior/mod.rs:23:5
|
18 | #[derive(Clone)]
| ----- in this derive macro expansion
...
23 | obj: object::File<'a>,
| ^^^^^^^^^^^^^^^^^^^^^ the trait `Clone` is not implemented for `object::File<'_, &[u8]>`
error[E0515]: cannot return value referencing local variable `data`
--> src/inferior/mod.rs:29:9
|
29 | / Inferior {
30 | | pid,
31 | | state: InferiorState::Stopped,
32 | | breakpoints: HashMap::new(),
33 | | obj: object::File::parse(&*data).unwrap(),
| | ---- `data` is borrowed here
34 | | }
| |_________^ returns a value referencing data owned by the current function
A couple of these stand out as something more than just propagating the lifetime annotation.
error[E0515]: cannot return value referencing local variable `data`
--> src/inferior/mod.rs:29:9
|
29 | / Inferior {
30 | | pid,
31 | | state: InferiorState::Stopped,
32 | | breakpoints: HashMap::new(),
33 | | obj: object::File::parse(&*data).unwrap(),
| | ---- `data` is borrowed here
34 | | }
| |_________^ returns a value referencing data owned by the current function
Ok, so the data is read outside of setting up the Inferior
struct and then discarded when Inferior::new
returns. I guess it is borrowed into object::File::parse
? Need to fix that. And the second error that stands out is:
error[E0106]: missing lifetime specifier
--> src/inferior/mod.rs:38:25
|
38 | pub type TrapInferior = Inferior;
| ^^^^^^^^ expected named lifetime parameter
I guess on one hand this is just propagation of the lifetime parameter, but on the other this is a leftover wart from some prior refactoring. TrapInferior
used to be just a handle that we returned to the user and Inferior
was the real struct. Now there is no handle and we return the real struct to the user. And this was a hack to avoid renaming everything. I want to clean this up. Ideally, I would clean this up before making the changes the required addition of lifetimes so that I could compile successfully to verify the rename is correct. I guess I’ll back up and do that first. The way I do this is to:
- save the commit so I can cherry-pick it later:
f3f9a19
- reset back to origin/inferior-ownership-api (starting point for this post)
- Verify that build is good
- Rename all occurrences
- Verify that the build is still good
Ok, this was just a straightforward search and replace.
modified src/breakpoint/mod.rs
@@ -31,13 +31,13 @@ fn set(inferior: &TrapInferior, bp: &Breakpoint) {
poke_text(inferior.pid, bp.aligned_address, modified);
}
-fn find_breakpoint_matching_inferior_instruction_pointer(inf: &Inferior) -> Option<&Breakpoint> {
+fn find_breakpoint_matching_inferior_instruction_pointer(inf: &TrapInferior) -> Option<&Breakpoint> {
let InferiorPointer(ip) = get_instruction_pointer(inf.pid);
let ip = InferiorPointer(ip - 1);
return inf.breakpoints.get(&ip);
}
-pub fn handle<F>(inferior: &mut Inferior, callback: &mut F) -> InferiorState
+pub fn handle<F>(inferior: &mut TrapInferior, callback: &mut F) -> InferiorState
where
F: FnMut(&TrapInferior, TrapBreakpoint),
{
modified src/inferior/mod.rs
@@ -13,15 +13,15 @@ pub enum InferiorState {
}
#[derive(Clone)]
-pub struct Inferior {
+pub struct TrapInferior {
pub pid: pid_t,
pub state: InferiorState,
pub breakpoints: HashMap<InferiorPointer, Breakpoint>,
}
-impl Inferior {
- pub fn new(pid: pid_t) -> Inferior {
- Inferior {
+impl TrapInferior {
+ pub fn new(pid: pid_t) -> TrapInferior {
+ TrapInferior {
pid,
state: InferiorState::Stopped,
breakpoints: HashMap::new(),
@@ -29,8 +29,6 @@ impl Inferior {
}
}
-pub type TrapInferior = Inferior;
-
#[derive(Copy, Clone, PartialEq, PartialOrd, Debug, Eq, Hash)]
pub struct InferiorPointer(pub u64);
impl InferiorPointer {
modified src/lib.rs
@@ -45,16 +45,16 @@ fn exec_inferior(filename: &Path, _args: &[&str]) {
unreachable!();
}
-fn attach_inferior(raw_pid: pid_t) -> Result<Inferior, Error> {
+fn attach_inferior(raw_pid: pid_t) -> Result<TrapInferior, Error> {
let nix_pid = Pid::from_raw(raw_pid);
match waitpid(nix_pid, None) {
- Ok(WaitStatus::Stopped(pid, signal::Signal::SIGTRAP)) => Ok(Inferior::new(pid.into())),
+ Ok(WaitStatus::Stopped(pid, signal::Signal::SIGTRAP)) => Ok(TrapInferior::new(pid.into())),
Ok(_) => panic!("Unexpected stop in attach_inferior"),
Err(e) => Err(e),
}
}
-pub fn trap_inferior_exec(filename: &Path, args: &[&str]) -> Result<Inferior, Error> {
+pub fn trap_inferior_exec(filename: &Path, args: &[&str]) -> Result<TrapInferior, Error> {
loop {
match unsafe { fork() } {
Ok(ForkResult::Child) => {
The build looks good and the tests pass.
Now, I did this in a side branch and I think I can just rebase my current changes on that branch.
$ git checkout object-files
Switched to branch 'object-files'
$ git rebase one-trap-inferior-type
Auto-merging src/inferior/mod.rs
CONFLICT (content): Merge conflict in src/inferior/mod.rs
Auto-merging src/lib.rs
CONFLICT (content): Merge conflict in src/lib.rs
error: could not apply f3f9a19... lifetimes?
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
Ok, so let’s fix the merge conflicts starting with inferior/mod.rs:
#[derive(Clone)]
pub struct TrapInferior {
pub pid: pid_t,
pub state: InferiorState,
pub breakpoints: HashMap<InferiorPointer, Breakpoint>,
obj: object::File,
}
<<<<<<< HEAD
impl TrapInferior {
pub fn new(pid: pid_t) -> TrapInferior {
TrapInferior {
=======
impl Inferior {
pub fn new(pid: pid_t, path: &Path) -> Inferior {
let data = fs::read(path).unwrap();
Inferior {
>>>>>>> f3f9a19 (lifetimes?)
pid,
state: InferiorState::Stopped,
breakpoints: HashMap::new(),
obj: object::File::parse(&*data).unwrap(),
}
}
}
Ok, so we want the bottom version which we updated with the Path and we need to change Inferior
to TrapInferior
.
Then the conflict in src/lib.rs:
<<<<<<< HEAD
fn attach_inferior(raw_pid: pid_t) -> Result<TrapInferior, Error> {
let nix_pid = Pid::from_raw(raw_pid);
match waitpid(nix_pid, None) {
Ok(WaitStatus::Stopped(pid, signal::Signal::SIGTRAP)) => Ok(TrapInferior::new(pid.into())),
=======
fn attach_inferior(raw_pid: pid_t, filename: &Path) -> Result<Inferior, Error> {
let nix_pid = Pid::from_raw(raw_pid);
match waitpid(nix_pid, None) {
Ok(WaitStatus::Stopped(pid, signal::Signal::SIGTRAP)) => Ok(Inferior::new(pid.into(), filename)),
>>>>>>> f3f9a19 (lifetimes?)
Ok(_) => panic!("Unexpected stop in attach_inferior"),
Err(e) => Err(e),
}
}
Again, I want the bottom version that has the filename handling and I’ll have to do the rename.
Ok, git rebase --continue
and we are done. Note this does not build right now which is to be expected. We are now back to working on getting the lifetimes right. And it’s pretty much the list of errors we had before minus needing a lifetime on the TrapInferior
type alias as we have just removed the alias altogether.
So I want to try to address the one that seems most important first.
error[E0515]: cannot return value referencing local variable `data`
--> src/inferior/mod.rs:29:9
|
29 | / TrapInferior {
30 | | pid,
31 | | state: InferiorState::Stopped,
32 | | breakpoints: HashMap::new(),
33 | | obj: object::File::parse(&*data).unwrap(),
| | ---- `data` is borrowed here
34 | | }
| |_________^ returns a value referencing data owned by the current function
I ended up spending a lot of of time working on this after writing the above part of the blog. And this is more of a rabbit hole that I expected. At first I wasn’t quite sure what was going on. But it turns out that object::File::parse
just builds up references to parts of data
and uses that to provide the sections. It’s zero-copy. Cool, that’s efficient. But it means I have to keep the data around for the lifetime of TrapInferior
.
My first inclination was to just keep the data in the TrapInferior
like this:
modified src/inferior/mod.rs
@@ -21,16 +21,19 @@ pub struct TrapInferior<'a> {
pub state: InferiorState,
pub breakpoints: HashMap<InferiorPointer, Breakpoint>,
obj: object::File<'a>,
+ data: Vec<u8>,
}
impl TrapInferior {
pub fn new(pid: pid_t, path: &Path) -> TrapInferior {
let data = fs::read(path).unwrap();
+ let obj = object::File::parse(&*data).unwrap();
TrapInferior {
pid,
state: InferiorState::Stopped,
breakpoints: HashMap::new(),
- obj: object::File::parse(&*data).unwrap(),
+ obj,
+ data,
}
}
}
Now, there are a bunch of problems with this. The first, is that I still have data
and now also obj
on the stack in this function so I can’t return them.
Now, it’s hard to try to get them into the struct together but even if I could it wouldn’t work. I didn’t know this but Rust does not support self-referential structs. That is, one field in a struct can’t contain a reference to another field. I found this both by asking GitHub Copilot and from this Stack Overflow answer. To summarize, the reason Rust does not support self-referential structs is because they can lead to unsafe memory accesses. The recommendation for how to solve this is to have the user call fs::read
and pass in the data.
Google Gemini proposed an alternative of using the ouroboros
crate which allows creation of self referential structs. However, Gemini recommended that having the user call fs::read
is idiomatic Rust.
Coming from languages other than Rust this seemed strange to me. I would favor building an API that takes on the responsibility of reading the file so as to:
- Unburden the user from having to do it.
- Hide the implementation detail of where the data comes from and what format it is in.
Reading a file isn’t really a detail that needs to be hidden, I suppose. But the fact that it matches the input to object::File::parse
is. What if I switched to a different crate one day? Now, it’s also just a vector of bytes so maybe any object parser would want that. But maybe it wouldn’t. In my opinion this couples my API to the implementation a little too tightly.
Gemini points out that the flip side of burdening the user with responsibility is empowering them with control over the lifetime of these objects. Which does sound like a good trade. Gemini provided a number of examples of popular crates that have APIs like this (serde, gimli).
Gemini goes to say
This pattern is a cornerstone of a philosophy in the Rust community called “Parse, Don’t Validate.” Instead of repeatedly passing around raw data (like a
&[u8]
) and validating it in every function, you parse it once into a structured type (like yourTrapInferior<'data>
) that guarantees validity through the type system. The lifetime parameter is the mechanism that makes this zero-copy parsing possible and safe.
That makes sense and I buy into “parse, don’t validate”. But, one thing about this bothered me: trap_inferior_exec
is where we are currently passed the binary filename. And it will need to be updated to take the binary data as well. So it will look something like:
pub fn trap_inferior_exec(filename: &Path, args: &[&str], data: vec<u8>) -> Result<TrapSession, Error>
This looks great. Well except for one thing, if I’m going to validate up front and then encode all the validated inputs in the type system then do I need to validate that filename
and data
actually correspond to each other? That is, how do I validate that the user actually called fs::read
on the right file? Or should I validate this?
Encapsulating Validated File Names and File Data
If I really wanted to validate it then that would mean calling fs::read
inside of trap_inferior_exec
and comparing that the data I read matches the data
the user passed in. This seems inefficient at best and silly at worse. This is a (proposed) code smell that this is not a good API choice.
When I asked Gemini it agreed that this was overkill (it’s very agreeable) and then ran off to tell me all about how to implement it.
So instead of blindly accepting Gemini’s validation code, I sat back and thought a little bit. Parse, don’t validate–right? What I would really like is for there to be a type, a struct, that encapsulates both the filename and the data. And that type should indicate that the two are in correspondence. I see no reason why such a thing can’t exist. rusty_trap
should just provide this type and a API to create it. The type can be created from just the filename and the API can do the fs::read
. Then it can return the validated type to the user and the user can in turn pass it (lend) into trap_inferior_exec
. The user can control the lifetime of this thing.
Ok, so let’s build that. I guess call it TrapData
.
Ideally, I should go back to an earlier commit where tests pass and add this but I’m tired from arguing with Gemini. Let’s just do it.
pub struct TrapData<'a> {
pub filename: &'a Path,
pub data: Vec<u8>,
}
impl <'a> TrapData<'a> {
pub fn new(filename: &Path) -> TrapData {
TrapData {
filename: filename,
data: fs::read(filename).unwrap()
}
}
}
Now, let’s see if I can implement TrapInferior
using this.
Ok, this much of it seems to compile:
#[derive(Clone)]
pub struct TrapInferior<'a> {
pub pid: pid_t,
pub state: InferiorState,
pub breakpoints: HashMap<InferiorPointer, Breakpoint>,
obj: object::File<'a>,
}
impl <'a> TrapInferior<'a> {
pub fn new(pid: pid_t, trap_data: &'a TrapData<'a>) -> TrapInferior<'a> {
TrapInferior {
pid,
state: InferiorState::Stopped,
breakpoints: HashMap::new(),
obj: object::File::parse(&*trap_data.data).unwrap(),
}
}
}
Well, actually it doesn’t because object::File
doesn’t implement Clone
so TrapInferior
can’t derive Clone
but I’ll come back to that later.
I think the lifetimes here are correct. For TrapInferior
: the object::File
lifetime has to be at least as long as the TrapInferior
lifetime as a whole.
And for TrapInferior::new
we require that TrapData
live at least as long as the returned TrapInferior
because the TrapInferior
will reference the data.
Ok, now we can push this interface change up the call chain.
modified src/lib.rs
@@ -46,23 +46,23 @@ fn exec_inferior(filename: &Path, _args: &[&str]) {
unreachable!();
}
-fn attach_inferior(raw_pid: pid_t, filename: &Path) -> Result<TrapInferior, Error> {
+fn attach_inferior<'a>(raw_pid: pid_t, data: &'a TrapData) -> Result<TrapInferior<'a>, Error> {
let nix_pid = Pid::from_raw(raw_pid);
match waitpid(nix_pid, None) {
- Ok(WaitStatus::Stopped(pid, signal::Signal::SIGTRAP)) => Ok(TrapInferior::new(pid.into(), filename)),
+ Ok(WaitStatus::Stopped(pid, signal::Signal::SIGTRAP)) => Ok(TrapInferior::new(pid.into(), data)),
Ok(_) => panic!("Unexpected stop in attach_inferior"),
Err(e) => Err(e),
}
}
-pub fn trap_inferior_exec(filename: &Path, args: &[&str]) -> Result<TrapInferior, Error> {
+pub fn trap_inferior_exec<'a>(data: &'a TrapData, args: &[&str]) -> Result<TrapInferior<'a>, Error> {
loop {
match unsafe { fork() } {
Ok(ForkResult::Child) => {
- exec_inferior(filename, args);
+ exec_inferior(data.filename, args);
unreachable!();
}
- Ok(ForkResult::Parent { child: pid }) => return attach_inferior(pid.into(), filename),
+ Ok(ForkResult::Parent { child: pid }) => return attach_inferior(pid.into(), data),
Err(Error::EAGAIN) => continue,
Err(e) => return Err(e),
}
Ok, this was pretty straightfoward and seems ok so far. There are some other compile errors to fix and who knows if the next compile pass will find some new problems here after that.
Let’s clean up the leftover errors:
error[E0106]: missing lifetime specifier
--> src/breakpoint/mod.rs:34:88
|
34 | fn find_breakpoint_matching_inferior_instruction_pointer(inf: &TrapInferior) -> Option<&Breakpoint> {
| ------------- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say which one of `inf`'s 2 lifetimes it is borrowed from
help: consider introducing a named lifetime parameter
|
34 | fn find_breakpoint_matching_inferior_instruction_pointer<'a>(inf: &'a TrapInferior<'a>) -> Option<&'a Breakpoint> {
| ++++ ++ ++++ ++
Ok, we need some lifetimes here because TrapInferior
just needs one and the returned Breakpoint
reference is a borrow from inf.breakpoints
.
modified src/breakpoint/mod.rs
@@ -31,7 +31,7 @@ fn set(inferior: &TrapInferior, bp: &Breakpoint) {
poke_text(inferior.pid, bp.aligned_address, modified);
}
-fn find_breakpoint_matching_inferior_instruction_pointer(inf: &TrapInferior) -> Option<&Breakpoint> {
+fn find_breakpoint_matching_inferior_instruction_pointer<'a>(inf: &'a TrapInferior) -> Option<&'a Breakpoint> {
let InferiorPointer(ip) = get_instruction_pointer(inf.pid);
let ip = InferiorPointer(ip - 1);
return inf.breakpoints.get(&ip);
error[E0106]: missing lifetime specifier
--> src/breakpoint/mod.rs:98:7
|
96 | mut inferior: TrapInferior,
| ------------
97 | location: &str,
| ----
98 | ) -> (TrapInferior, TrapBreakpoint) {
| ^^^^^^^^^^^^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `inferior` or `location`
help: consider introducing a named lifetime parameter
|
95 ~ pub fn trap_inferior_set_breakpoint<'a>(
96 ~ mut inferior: TrapInferior<'a>,
97 ~ location: &'a str,
98 ~ ) -> (TrapInferior<'a>, TrapBreakpoint) {
|
This is the nice API where we borrow in and then give back. So it’s borrowed from inferior
.
-pub fn trap_inferior_set_breakpoint(
- mut inferior: TrapInferior,
+pub fn trap_inferior_set_breakpoint<'a>(
+ mut inferior: TrapInferior<'a>,
location: &str,
-) -> (TrapInferior, TrapBreakpoint) {
+) -> (TrapInferior<'a>, TrapBreakpoint) {
let address: u64 = 0x55555555b9f4;
return set_breakpoint_at_address(inferior, address);
}
Ok, now we are down to this error about clone:
error[E0277]: the trait bound `object::File<'_, &[u8]>: Clone` is not satisfied
--> src/inferior/mod.rs:38:5
|
33 | #[derive(Clone)]
| ----- in this derive macro expansion
...
38 | obj: object::File<'a>,
| ^^^^^^^^^^^^^^^^^^^^^ the trait `Clone` is not implemented for `object::File<'_, &[u8]>`
Some errors have detailed explanations: E0106, E0277.
For more information about an error, try `rustc --explain E0106`.
error: could not compile `rusty_trap` (lib) due to 3 previous errors
warning: build failed, waiting for other jobs to finish...
error: could not compile `rusty_trap` (lib test) due to 3 previous errors
Ok, so I guess to start with let me ask: why do I need clone?
- I think it’s because I have something like this:
pub fn trap_inferior_continue<F>(mut inferior: TrapInferior, mut callback: F) -> (TrapInferior, i32)
where
F: FnMut(&TrapInferior, TrapBreakpoint)
So that I’m passing these TrapInferior
objects around without references and they would shallow copy. As a reminder this is what’s in there:
#[derive(Clone)]
pub struct TrapInferior<'a> {
pub pid: pid_t,
pub state: InferiorState,
pub breakpoints: HashMap<InferiorPointer, Breakpoint>,
obj: object::File<'a>,
}
Before I added obj
we could easily clone this because it’s just some ints and a HashMap
that can be cloned. If I got rid of Clone
I would have to copy and copying the HashMap
seems like a bad idea.
So, what if instead I get rid of Clone
and pass by reference? I guess let’s try it.
FWIW just removing clone actually builds (though the tests need some modifications). But, it’s not efficient so let’s keep fixing (honestly maybe that’s not the best choice in real life - get your tests to pass before optimizing).
Interesting, because I’m allowed to pass a reference through a non-reference I can’t use the compiler to find all the places I need to update. But, I think this does it:
modified src/breakpoint/mod.rs
@@ -68,10 +68,10 @@ where
};
}
-fn set_breakpoint_at_address(
- mut inferior: TrapInferior,
- location: u64,
-) -> (TrapInferior, TrapBreakpoint) {
+fn set_breakpoint_at_address<'a>(
+ mut inferior: &'a mut TrapInferior<'a>,
+ location: u64
+) -> (&'a TrapInferior<'a>, TrapBreakpoint) {
let aligned_address = location & !0x7u64;
let target_address = InferiorPointer(location);
inferior.breakpoints.insert(
@@ -93,9 +93,9 @@ fn set_breakpoint_at_address(
}
pub fn trap_inferior_set_breakpoint<'a>(
- mut inferior: TrapInferior<'a>,
+ mut inferior: &'a mut TrapInferior<'a>,
location: &str,
-) -> (TrapInferior<'a>, TrapBreakpoint) {
+) -> (&'a TrapInferior<'a>, TrapBreakpoint) {
let address: u64 = 0x55555555b9f4;
return set_breakpoint_at_address(inferior, address);
}
modified src/inferior/mod.rs
@@ -30,7 +30,6 @@ pub enum InferiorState {
SingleStepping,
}
-#[derive(Clone)]
pub struct TrapInferior<'a> {
pub pid: pid_t,
pub state: InferiorState,
modified src/lib.rs
@@ -69,7 +69,7 @@ pub fn trap_inferior_exec<'a>(data: &'a TrapData, args: &[&str]) -> Result<TrapI
}
}
-pub fn trap_inferior_continue<F>(mut inferior: TrapInferior, mut callback: F) -> (TrapInferior, i32)
+pub fn trap_inferior_continue<'a, F>(mut inferior: &'a mut TrapInferior, mut callback: F) -> (&'a TrapInferior<'a>, i32)
where
F: FnMut(&TrapInferior, TrapBreakpoint),
{
Now we just need to fix the tests. I have a bunch of errors like this:
error[E0308]: mismatched types
--> tests/lib.rs:9:51
|
9 | ... = rusty_trap::trap_inferior_exec(Path::new("./target/debug/twelve"), &...
| ------------------------------ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `&TrapData<'_>`, found `&Path`
| |
| arguments to this function are incorrect
|
= note: expected reference `&rusty_trap::inferior::TrapData<'_>`
found reference `&Path`
note: function defined here
--> /home/jkain/projects/rusty_trap/src/lib.rs:58:8
|
58 | pub fn trap_inferior_exec<'a>(data: &'a TrapData, args: &[&str]) -> ...
| ^^^^^^^^^^^^^^^^^^
There is one of these per test. We need to fix them all by creating a TrapData
and passing that instead of the filename.
Ok, that’s done. Now tests compile but fail because they are passing in breakpoint names and we are setting some bogus breakpoint address. E.g. we’ve finally got our types, lifetimes, and references right and can get back to work on adding symbol table support to our debugger. Let’s use the object::File
to look up the addresses for the symbol names:
pub fn trap_inferior_set_breakpoint<'a>(
inferior: &'a mut TrapInferior<'a>,
location: &str,
) -> (&'a mut TrapInferior<'a>, TrapBreakpoint) {
let mut address: u64 = 0;
for symbol in inferior.obj.symbols() {
let name = symbol.name().unwrap();
let symbol_address = symbol.address();
println!("Checking symbol {name} at {symbol_address}");
if name == location {
address = symbol_address;
break;
}
}
return set_breakpoint_at_address(inferior, address);
}
Implementing Symbol Resolution
But this never finds the symbol because the symbols look like this:
Checking symbol _ZN4loop3foo17hcca06054783c5707E at 0x7990
Checking symbol _ZN4loop4main17h32a7c34928272921E at 0x79a0
This is the name mangling that I mentioned before. If you are not familiar with these, they are essentially loop::foo
and loop::main
with additional information encoded such as namespaces, type signatures, and a hash. This ensures uniqueness (think function overloading). It’s also worth noting that the address is the small address and doesn’t include the offset where the binary gets loaded in memory (e.g. the 0x55555… we keep seeing). We’ll need to come back to that. Demangling is the process of converting back to the human readable names and is useful so we can match the names form the symbol table against the function names where we want to set breakpoints.
First, we can use the rustc_demangle crate to decode symbol names. I’m just following the example and ignorign the hash. Ok, this much seems to be working:
Found symbol loop::foo at 0x79c0
Now we need to work on the address. There are two things really:
- We need to find the offset to add to get the real address.
- It looks like there is something else a little off in that I thought the lower 3 nibbles should match (the page offest) but they don’t. We used to have:
const ADDRESS_OF_FOO: u64 = 0x55555555b9e0;
Let’s start by just taking the offset as a constant and adding it on. That way we can have passing tests and then we can figure out where to get the offset from properly. I’m assuming that both (all) inferiors would have the same offset.
const BASE: u64 = 0x555555554000;
And the tests pass. Where did I get this address, well I computed
0x55555555b9e0 - 0x79c0
Though this actually works out to 0x555555554020
but that address wouldn’t make any sense. It should aligned to 4KB (4096 or 0x1000 bytes). So I rounded it down. Now, why doesn’t the subtraction give this number to being with? I’m not sure, lihely the inferior has changed since the last time I checked. Let’s check again by loading the inferior in gdb:
Breakpoint 1, 0x000055555555ba20 in main ()
(gdb) b loop::main
Breakpoint 2 at 0x55555555b9d4: file tests/inferiors/loop.rs, line 6.
(gdb) b loop::foo
Breakpoint 3 at 0x55555555b9c0: file tests/inferiors/loop.rs, line 3.
So, it seems they have moved and in fact by 0x20 bytes. This is rather timely as we’ve just implemented a feature (symbol lookup) that our tests immune to these changes.
And now when I do the subtraction it works out properly:
(gdb) p /x 0x55555555b9c0 - 0x79c0
$1 = 0x555555554000
The tests now pass:
running 4 tests
test it_can_exec ... ok
test it_can_handle_a_breakpoint_more_than_once ... ok
test it_can_set_breakpoints ... ok
test it_can_handle_more_than_one_breakpoint ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Finding the Base Address
Now that we know that having the correct base address will make our tests pass we need to add support to rusty_trap
to look it up rather than hardcode it. I poked around at a few different fields in the object file trying to figure out where to get the load address. But ultimately, I ended up asking Bing Copilot:
Me:
When a PIE binary is loaded into memory on Linux to be executed what is the load address? Usually there is address space randomization but I have disabled that using the personality flag before exexcing my process because I am building a debugger. I can find symbol addresses in the ELF object but they need to be offset by the load address. Where should I find this?
Copilot:
When you compile with -pie, the kernel treats your executable as an ET_DYN object and maps its PT_LOAD segments at a runtime-chosen “load base.” With ASLR disabled (ADDR_NO_RANDOMIZE), the kernel uses a fixed, architecture-specific base (on x86_64 it’s traditionally 0x400000).
Why the load base matters Symbol values in the ELF file (st_value) are relative to the link-time base (often 0 for ET_DYN). To turn a symbol’s file offset into the actual virtual address, you add the runtime load base—aka the load bias or l_addr.
Ok, this is basically matches what I understood. Note that there is a lot of jargon here. Let me try to explain:
- PIE binary: Position Independent Executable - meaning that the binary can be loaded into any address in memory. Older styles of object files did not do this for the main executable but did for dynamic libraries.
- ET_DYN: I think Copilot is saying that the binary gets treated the same way that a dynamic library or other dynamic object would be treated.
- PT_LOAD Segment: A segment is a range of an ELF object that gets loaded into memory. It can contain sections.
PT_LOAD
includes the main binary code sections.
Anyway, I vaguely remember the address 0x400000 being the Linux default load address but that’s clearly not what we are seeing here. Copilot goes on to give some practical advice:
Three ways to discover the load base
- /proc/[pid]/maps: Open the target’s
/proc/[pid]/maps
and locate the entry whose pathname matches your binary. The start address of that VMA is your load base. - Auxiliary vector (auxv): Read
/proc/[pid]/auxv
and find theAT_PHDR
entry (pointer to the in-memory program headers). Compute:- load_base =
AT_PHDR
−ELF_HDR.e_phoff
** - where
e_phoff
comes straight from your ELF header.
- load_base =
- Dynamic loader APIs: Use dl_iterate_phdr. In its callback, when dlpi_name[0] == ‘\0’, the field dlpi_addr is the main executable’s base. Alternatively, dladdr(&main, &info) returns info.dli_fbase.
Copilot goes on to give an example of how to implement the first option in C:
uint64_t get_load_base(pid_t pid, const char* exe_path) {
char filename[64], line[256];
uint64_t base = 0;
snprintf(filename, sizeof(filename), "/proc/%d/maps", pid);
FILE *f = fopen(filename, "r");
while (fgets(line, sizeof(line), f)) {
if (strstr(line, exe_path)) {
sscanf(line, "%lx-", &base);
break;
}
}
fclose(f);
return base;
}
This sounds pretty good. And we may need the maps file again later if we want to find any other dynamic libraries that are loaded (if we wanted to set breakpoints in them). Let’s implement a function like this in Rust and then we will call it after we exec
and ptrace
the inferior and it stops for the first time. That’s our first opportunity to poke around.
I ended up with this function:
// Helper function to look up the base address by inspected /proc/pid/maps
fn get_base_address(pid: pid_t, filename: &Path) -> u64 {
let proc_filename = format!("/proc/{pid}/maps");
let file = fs::File::open(proc_filename).expect("Unable to open file");
let expected = fs::canonicalize(filename).unwrap();
let expected = expected.to_str().unwrap();
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line.unwrap();
if line.contains(expected) {
let addr_str = line.split('-').next().unwrap();
println!("Found base address 0x{addr_str}");
return u64::from_str_radix(addr_str, 16).unwrap();
}
}
// This should be an error, there should be error handling.
println!("Could not find base address for {expected}");
assert!(false);
0
}
Which in fact finds the address and prints:
Found base address 0x555555554000
Now we just use this instead of our hard coded version and find that the tests still pass.
Wow, that was a lot of work, but we’ve done it. We’ve implemented symbol look for our inferior and can use it to set breakpoints! Here’s the final set of changes:
modified src/breakpoint/mod.rs
@@ -100,14 +100,12 @@ pub fn trap_inferior_set_breakpoint<'a>(
) -> (&'a mut TrapInferior<'a>, TrapBreakpoint) {
let mut address: u64 = 0;
- const BASE: u64 = 0x555555554000;
-
for symbol in inferior.obj.symbols() {
let name = format!("{:#}", demangle(symbol.name().unwrap()));
let symbol_address = symbol.address();
if name == location {
println!("Found symbol {name} at 0x{symbol_address:x}");
- address = symbol_address + BASE;
+ address = symbol_address + inferior.base_address;
break;
}
}
modified src/inferior/mod.rs
@@ -7,6 +7,7 @@ use std::ops::{Add, Sub};
use object;
use std::fs;
use std::path::Path;
+use std::io::{BufRead, BufReader};
pub struct TrapData<'a> {
@@ -35,6 +36,7 @@ pub struct TrapInferior<'a> {
pub state: InferiorState,
pub breakpoints: HashMap<InferiorPointer, Breakpoint>,
pub obj: object::File<'a>,
+ pub base_address: u64,
}
impl <'a> TrapInferior<'a> {
@@ -44,10 +46,33 @@ impl <'a> TrapInferior<'a> {
state: InferiorState::Stopped,
breakpoints: HashMap::new(),
obj: object::File::parse(&*trap_data.data).unwrap(),
+ base_address: get_base_address(pid, trap_data.filename),
}
}
}
+// Helper function to look up the base address by inspected /proc/pid/maps
+fn get_base_address(pid: pid_t, filename: &Path) -> u64 {
+ let proc_filename = format!("/proc/{pid}/maps");
+ let file = fs::File::open(proc_filename).expect("Unable to open file");
+ let expected = fs::canonicalize(filename).unwrap();
+ let expected = expected.to_str().unwrap();
+ let reader = BufReader::new(file);
+ for line in reader.lines() {
+ let line = line.unwrap();
+ if line.contains(expected) {
+ let addr_str = line.split('-').next().unwrap();
+ println!("Found base address 0x{addr_str}");
+ return u64::from_str_radix(addr_str, 16).unwrap();
+ }
+ }
+ // This should be an error, there should be error handling.
+ println!("Could not find base address for {expected}");
+ assert!(false);
+ 0
+}
+
+
#[derive(Copy, Clone, PartialEq, PartialOrd, Debug, Eq, Hash)]
pub struct InferiorPointer(pub u64);
impl InferiorPointer {
I’ve pushed a pull request for Symbol Lookup in breakpoints.
Enjoying the post? Sign up for the newsletter or support the work on Buy Me a Coffee .
Lessons Learned
So, we set out implement symbol resolution and along the way learned about lifetimes, self referential structs, and applied systems thinking to our API design (again). In the end we made our debugger much more powerful in and less fragile.
In C, I would have just stuffed everything into one struct, given only a cursory thought to lifetime, and focused on the symbol resolution and breakpoint setting. Rust forces us to think deeply about systems-level problems and that’s a good thing. In C I might think about the lifetimes of the various objects and I might conclude that things look good inside of the debugger but it would be hard to convey those requirements back to the user. Rust makes this explicit and the trade off is that I have to think a little more to make sure it’s going to work both for rusty_tray
and for the user of rusty_trap
. I have to solve that up front and then I can encode it the type system. This way I solve it on behalf of all the user as well. In C each user must independently think through this proble.. In some way this is like code-usability except here it’s lifetime reusablity. The solution to the lifetime issues for rusty_trap are solved once and reused. The extra thinking required is a good investment.
The self referential structs are also a interesting topic. In C, or maybe C++, I would actually prefer to keep the file data and my uses of the file data together. They are used together so store them together. Their lifetimes are coupled so keep them together. In fact, for my use their lifetimes are basically the same - I only want the file data so I can get an object::File
to work with. I suppose though if I wanted to use the file data for something else then coupling tightly together would be a bad idea. I am happy though that we were able to build a nice way to insure that the file name and file data were in correspondence and encode that in the type system.
I like the fact that Rust is pushing my understanding of API design by forcing me to think harder about lifetimes, abstractions, and how information should flow through my systems.
In our next post we’ll see how Rust can inform our design when we work on supporting multithreaded inferiors in our debugger.

View all posts and upcoming topics →