Memory Scanning with Rust
Reading time 8 minutes
Table of Contents
In this article, we’ll find out where a program stores its heap in memory and how to access it from another process. We’ll take the example of a simple password manager storing a password as a string on the heap and attempt to access it from another process.
You can find the code for this article on GitHub.
How does a program handle memory ?
The program starts by requesting some memory to the operating system using a call like malloc
. The OS allocates some memory and provides the program with a pointer to it.
The address of the pointer given by the OS is a virtual memory address. The computer’s memory management unit (MMU) handles the translations between a program’s virtual addresses and the physical memory addresses.
The programs are prohibited from handling memory directly; instead they use their own virtual address space. The program is allowed to modify its memory. The OS enforces access permissions over the memory.
How to access another process’ memory
In order to access another process’ memory, we can’t simply create a pointer to the data we’re interesting in and start reading. The OS wouldn’t allow that.
What we can do, however, is use the libc. With admin privileges, we can copy some bytes from another process’ memory using the method process_vm_readv
.
Knowing this, let’s try to put it in practice. We’ll build a simple password manager that keeps a string on the heap and use another program to go read the password from memory.
In practice
Password manager
Let’s start with our password manager. It will prompt the user for a password, store it on the heap in a Rust string and then wait for the user to press enter to close the program. Nothing is stored on the disk, we only keep the password in memory while the password manager is running.
use std::io::Write;
fn main() {
// The string we'll try to access
let mut pass: String = String::new();
// Prompt the user for the password
print!("Enter a password: ");
let _ = std::io::stdout().flush();
std::io::stdin()
.read_line(&mut pass)
.expect("Failed to read password");
// Print the virtual memory address of the String
println!("String.as_ptr {:p}", pass.as_ptr());
// Wait for the user to quit the program
print!("Press enter to quit.");
let _ = std::io::stdout().flush();
let _ = std::io::stdin().read_line(&mut String::new());
}
We’ll run it, enter a password and leave it waiting while we try to access its memory.
Memory scanner
First, note that we’ll need to start our memory scanner with admin privileges in order to access the other process’ memory.
We’ll use the system call process_vm_readv
from the libc in order to copy bytes from the password manager to our memory scanner.
See the documentation with man 2 process_vm_readv
Here’s the definition of the function. It takes the process id of our target, structs called iovec
(buffers), the number of structs used (liovcnt
and riovcnt
) and some flags.
ssize_t process_vm_readv(pid_t pid, // The target PID
const struct iovec *local_iov, // Local iovec
unsigned long liovcnt,
const struct iovec *remote_iov,// Remote iovec
unsigned long riovcnt,
unsigned long flags);
Great, now how do we call this function from a Rust program ?
In order to use process_vm_readv
, we’ll use the crate called libc
. You can add it to the Cargo.toml using cargo add libc
.
We import the crate in our main.rs
with
use libc::{c_void, iovec, pid_t, process_vm_readv};
We start by preparing the parameters.
let data: [u8; 16] = [0; 16]; // We'll store the bytes we copy here
// Local buffer
let local = iovec {
iov_base: data.as_ptr() as *mut c_void,
iov_len: 16, // Number of bytes in our buffer
};
// The remote buffer we want to read from
let remote = iovec {
iov_base: 0x12345 as *mut c_void, // Pointer to the the virtual memory of
iov_len: 16, // the other processs
};
// We retrieve the pointers to our two iovec structs
let p_local = &local as *const iovec;
let p_remote = &remote as *const iovec;
We can set the remote.iov_base
to the address of our string in the password manager as displayed in its output.
The call to the function is simple.
let res = unsafe { process_vm_readv(pid, p_local, 1, p_remote, 1, 0) };
You can find the PID of the password manager with the command pidof password-manager
.
In the man page, we find that the return value is -1
when an error has occurred.
if res == -1 {
println!("ERROR: Could not read data");
} else {
for b in &data {
print!("{:02x} ", b); // Print each byte in our data array
}
println!();
}
We now have read bytes from the other process’ memory.
Currently, our program is hard to work with. We can only read 16 bytes and we are required to input the PID and the address to the string by hand. Let’s fix all these points.
Finding the PID
The command pidof
can be used to retrieve the PID of our target process from our program.
use std::process::Command;
/// Return the pid of the target process using `pidof`
fn get_pid(process_name: &str) -> pid_t {
// Call the `pidof` command
let v = Command::new("sh")
.arg("-c")
.arg(format!("pidof {}", process_name))
.output()
.expect("failed to get pid")
.stdout;
// If the process doesn't exist, `pidof` returns nothing
assert!(v.len() > 0, "PID not found");
// Convert the output of `pidof` to a String
let s = match String::from_utf8(v[0..(v.len() - 1)].to_vec()) {
Ok(s) => s,
Err(_) => panic!("Can't convert from utf8"),
};
// Convert to i32
let i: i32 = match s.parse() {
Ok(i) => i,
Err(_) => panic!("Could not parse string"),
};
// Returns the pid as type pid_t
pid_t::from(i)
}
Finding the virtual memory addresses
The memory map used by a process can is stored in the file /proc/$pid/maps
. It shows the virtual memory addresses used by the program. We’ll use it to get the range of addresses used to store the heap.
See the documentation with man 5 proc
The line of interest to us is this one:
5633733f2000-563373413000 rw-p 00000000 00:00 0 [heap]
We need the first part: 5633733f2000-563373413000
. It represents the range of addresses used by the heap, in hexadecimal.
We’ll use it to loop over the range and read all of the content of the heap.
We can automate the process of retrieving the heap’s address range:
use std::fs;
fn get_heap_addresses(pid: pid_t) -> (usize, usize) {
// Read the maps file
let data = fs::read_to_string(format!("/proc/{}/maps", pid))
.expect(&format!("Can't read /proc/{}/maps", pid));
// String manipulation to get the range as a 2-valued tuple
for line in data.split('\n') {
if line.contains("[heap]") {
let heap = line.split(' ').next().unwrap();
let mut heap_v = heap.split('-');
// Conversion &str -> i64 -> usize
let start_addr = i64::from_str_radix(heap_v.next().unwrap(), 16).unwrap() as usize;
let end_addr = i64::from_str_radix(heap_v.next().unwrap(), 16).unwrap() as usize;
return (start_addr, end_addr);
}
}
panic!("No heap found in /proc/{}/maps", pid)
}
Putting it all together
The entire code is accessible in this GitHub repository. I added some improvements: a for-loop for iterating over the range of addresses, cli parameters using clap
and improved formatting for the output.
Let’s begin with the password manager. Here’s what it looks like after a user has input a password. It is now waiting to be closed with the password stored in the heap. We’ll now to try to access the password from the memory scanner.
memscan
needs administrative privileges:
sudo memscan/target/release/memscan password-manager -za
The z
flag ensures that lines consisting of nothing but zeroes are not displayed and a
attempts to display the bytes as ASCII.
The password is visible at the address 0x557dd9581bb0
. It appears we have our password stored in two locations with the second one at address 0x557dd9581ba0
.
Why is that ?
It’s actually the buffer used by stdin. We can experiment with it by modifying the password-manager.
use std::io::Write;
fn main() {
// The string we'll try to access
let mut pass: String = String::new();
// Prompt the user for the password
print!("Enter a password: ");
let _ = std::io::stdout().flush();
std::io::stdin()
.read_line(&mut pass)
.expect("Failed to read password");
// Print the virtual memory address of the String
println!("String.as_ptr {:p}", pass.as_ptr());
// Add a useless input to fill the stdin buffer with something new
print!("Write anything: ");
let _ = std::io::stdout().flush();
let _ = std::io::stdin().read_line(&mut String::new());
// Wait for the user to quit the program
print!("Press enter to quit.");
let _ = std::io::stdout().flush();
let _ = std::io::stdin().read_line(&mut String::new());
}
We added a new prompt to overwrite the stdin buffer.
Here’s what our password-manager looks like now.
And what memscan
shows:
We can see we have (partially) overwritten the stdin buffer.
Conclusion
Programs use virtual memory addressing. The memory management unit (MMU) translates between virtual addresses and physical memory addresses. With admin privileges, we can use the function process_vm_readv
in order to copy bytes from another running process.
Going further
We could use the system call process_vm_writev
to modify memory within another process. This could be used to alter variables in a running program, such as a video game.
Resources
- Wikipedia: Memory management unit
man 2 process_vm_readv
man 5 proc