Read and Write Another Process’s Memory in Rust
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:
1 |
|
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.
1 |
|
Usage:
cargo run --bin reader <PID> 0x<ADDR>
Step 3: A Writer Program
The writer is similar, but uses process_vm_writev:
1 |
|
Usage:
cargo run --bin writer <PID> 0x<ADDR> 1234
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.