Recently, I set out to explore how to read and write the memory of a running process in Rust. Programming isn’t just about writing code that runs—it’s also about understanding what happens under the hood. Sometimes it is necessary to understand how memory works, for debugging, or even implement a simple IPC mechanism without going through sockets or shared memory. This experiment turned into a neat experiment that combines Linux internals (process_vm_readv/process_vm_writev), unsafe Rust, and a reminder of why process isolation is such an important security boundary.

In this blog post, we’ll walk through:

  • Why you might want to read/write memory across processes
  • Options for doing it on Linux
  • A working Rust example: a program that increments a counter, plus a reader and writer
  • Pitfalls and best practices
  • Tips for experimenting safely

Why Read Memory from Another Process?

Debuggers, profilers, and tracing tools all rely on this ability. On Linux, the kernel exposes syscalls like ptrace and process_vm_readv/process_vm_writev that let one process peek into another’s address space (provided it has the right permissions).

The Options on Linux

There are several ways to read/write another process’s memory:

  • /proc/[pid]/mem
    You can open the target process’s memory pseudo-file and seek + read/write. This requires ptrace attach permissions.

  • ptrace
    The standard way debuggers work. Very flexible, but a bit cumbersome to use.

  • process_vm_readv / process_vm_writev
    Syscalls introduced in Linux 3.2. They allow bulk memory operations between processes without ptrace. Perfect for this experiment.

Experiment

For this experiment, I wrote a simple counter program that prints its process ID (PID) along with the memory addresses of a stack variable, a heap variable, and a global constant. Then, I built two additional programs: one to read from these memory locations and another to write to them.

Step 1: A Counter Program

Let’s start with a Rust program that declares a stack variable, a heap variable, and a global constant. It prints their memory addresses and then increments the counters in a loop:

counter.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::thread;
use std::time::Duration;

const COUNTER: u16 = 100;

fn main() {
    let mut counter_stack: u16 = 0;
    let mut counter_heap = Box::new(0u16);

    let pid = std::process::id();
    println!("Current process PID: {}", pid);
    println!("stack variable (counter_stack) address: {:p}", &counter_stack);
    println!("heap variable (counter_heap) address: {:p}", &*counter_heap);
    println!("global variable (COUNTER) address: {:p}", &COUNTER);

    loop {
        // wrap around at 65535
        counter_stack = counter_stack.wrapping_add(1);
        println!("counter_stack: {}", counter_stack);
        *counter_heap = (*counter_heap).wrapping_add(1);
        println!("counter_heap: {}", counter_heap);
        thread::sleep(Duration::from_secs(10));
    }
}

When you run this, it will tell you its PID and memory addresses. We’ll use those later.

Usage:

cargo run --bin counter

Step 2: A Reader Program

The reader program attaches to a running process and copies a u16 from the given address using process_vm_readv.

reader.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
use libc::{process_vm_readv, iovec, c_void, c_ulong, pid_t};
use std::env;
use std::process::exit;

fn read_u16(pid: pid_t, addr: usize) -> Option<u16> {
    let mut value: u16 = 0;

    let local = iovec {
        iov_base: &mut value as *mut _ as *mut c_void,
        iov_len: std::mem::size_of::<u16>(),
    };
    let remote = iovec {
        iov_base: addr as *mut c_void,
        iov_len: std::mem::size_of::<u16>(),
    };

    let nread = unsafe {
        process_vm_readv(
            pid,
            &local as *const iovec,
            1 as c_ulong,
            &remote as *const iovec,
            1 as c_ulong,
            0,
        )
    };

    if nread < 0 {
        eprintln!("process_vm_readv failed: {}", std::io::Error::last_os_error());
        None
    } else {
        Some(value)
    }
}

fn main() {
    let args: Vec<String> = env::args().collect();
    if args.len() != 3 {
        eprintln!("Usage: {} <PID> <ADDR_HEX>", args[0]);
        exit(1);
    }

    let pid: pid_t = args[1].parse().unwrap();
    let addr = usize::from_str_radix(args[2].trim_start_matches("0x"), 16).unwrap();

    if let Some(val) = read_u16(pid, addr) {
        println!("Read counter = {}", val);
    }
}

Usage:

cargo run --bin reader <PID> 0x<ADDR>

example read stack address

Step 3: A Writer Program

The writer is similar, but uses process_vm_writev:

writer.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
use libc::{c_ulong, c_void, iovec, pid_t, process_vm_writev};
use std::env;
use std::process::exit;

fn write_u16(pid: pid_t, addr: usize, value: u16) -> bool {
    let mut local_value = value;

    let local = iovec {
        iov_base: &mut local_value as *mut _ as *mut c_void,
        iov_len: std::mem::size_of::<u16>(),
    };
    let remote = iovec {
        iov_base: addr as *mut c_void,
        iov_len: std::mem::size_of::<u16>(),
    };

    let nwritten = unsafe {
        process_vm_writev(
            pid,
            &local as *const iovec,
            1 as c_ulong,
            &remote as *const iovec,
            1 as c_ulong,
            0,
        )
    };

    if nwritten < 0 {
        eprintln!(
            "process_vm_writev failed: {}",
            std::io::Error::last_os_error()
        );
        false
    } else {
        true
    }
}

fn main() {
    let args: Vec<String> = env::args().collect();
    if args.len() != 4 {
        eprintln!("Usage: {} <PID> <ADDR_HEX> <VALUE>", args[0]);
        exit(1);
    }

    let pid: pid_t = args[1].parse().unwrap();
    let addr = usize::from_str_radix(args[2].trim_start_matches("0x"), 16).unwrap();
    let value: u16 = args[3].parse().unwrap();

    println!("Writing value {} to {:x} in pid {}", value, addr, pid);
    write_u16(pid, addr, value);
}

Usage:

cargo run --bin writer <PID> 0x<ADDR> 1234

example write heap address

Verifying Memory Addresses with /proc/[pid]/maps

When the counter program runs, it prints out the memory addresses of the stack variable, the heap variable, and the global constant:

stack variable (counter_stack) address: 0x7ffee17655b6
heap variable (counter_heap) address: 0x55d3a758db10
global variable (COUNTER) address: 0x55d3745c51a0

On Linux, we can confirm where these addresses live by inspecting the process’s memory layout via /proc/[pid]/maps. For example:

cat /proc/25392/maps

This shows all the memory regions currently mapped into the process:

55d37457c000-55d374582000 r--p 00000000 08:40 1436                       /project/rust-memory-learn/target/debug/counter
55d374582000-55d3745c5000 r-xp 00006000 08:40 1436                       /project/rust-memory-learn/target/debug/counter
55d3745c5000-55d3745d3000 r--p 00049000 08:40 1436                       /project/rust-memory-learn/target/debug/counter
55d3745d3000-55d3745d7000 r--p 00056000 08:40 1436                       /project/rust-memory-learn/target/debug/counter
55d3745d7000-55d3745d8000 rw-p 0005a000 08:40 1436                       /project/rust-memory-learn/target/debug/counter
55d3a758d000-55d3a75ae000 rw-p 00000000 00:00 0                          [heap]
7ffee1747000-7ffee1768000 rw-p 00000000 00:00 0                          [stack]
7ffee1770000-7ffee1774000 r--p 00000000 00:00 0                          [vvar]
7ffee1774000-7ffee1776000 r-xp 00000000 00:00 0                          [vdso]

If we line this up with our variables:

  • 0x7ffee17655b6 falls inside the [stack]region (7ffee1747000-7ffee1768000) → this is our stack counter.

  • 0x55d3a758db10 falls inside the [heap] region (55d3a758d000-55d3a75ae000) → this is our heap counter.

  • 0x55d3745c51a0 falls inside the program’s data segment (55d3745c5000-55d3745d3000) → this is our global constant.

This mapping helps us understand why the addresses look so different. Stack variables live near the high 0x7f… region, heap allocations grow upward from a dynamically mapped region, and global/static data is part of the binary’s memory-mapped segments.

Pitfalls and Gotchas

  • Permissions: On modern Linux, ptrace_scope restricts cross-process memory access. You may need root or adjust /proc/sys/kernel/yama/ptrace_scope.
  • Addresses change: ASLR (address space layout randomization) means addresses aren’t stable across runs. Always grab them at runtime.
  • Alignment: Reading wrong-sized data at the wrong address can fail or give garbage.
  • Undefined behavior: The target process may free or reallocate that memory while you’re poking it.

Best Practices

  • Use this technique for debugging, experimentation, or educational purposes, not as a production IPC mechanism.
  • Wrap unsafe FFI calls carefully and check return values.
  • Prefer stable IPC (sockets, pipes, shared memory) for real applications.
  • Run experiments inside a container or VM to avoid accidentally corrupting your host.

Conclusion

Reading and writing another process’s memory in Rust is a great way to peek under the hood of Linux internals. By combining a simple counter program with process_vm_readv and process_vm_writev, you can build your own mini-debugger in just a few lines of code. It’s a powerful reminder that while processes are isolated, the kernel still provides hooks for debugging and tracing—just be mindful of permissions and security implications.

If you want to play around, start a container, spin up the counter, and try reading/writing values.