November 9th, 2025
This is the second post in a short series exploring the layout of the virtual memory for a Unix process. The first post, discussing the location of thread stacks, can be found here. In that post, we determined that on some platforms the placement of the stack for each thread in a multi-threaded program (using POSIX threads) may be predictable even with the use of Address Space Layout Randomization (ASLR) and could be visualized like this:
All of this is part of the same virtual memory space, which of course allows multiple threads to access global variables and leads to the necessity of coordination amongst threads (e.g., using a mutex) when accessing and especially writing to shared resources.
But what is perhaps a bit less obvious is that if one thread knows the address of the stack of another thread, then it can reach into another thread and access or even manipulate that thread's local variables. This does not cause a segmentation violation. Let us verify that this is indeed the case:
$ cc -Wall -Wextra thread-stacks2.c -lpthread -lm $ ./a.out T0 says it's at : 0xfee6027ffbc8 T0 says T1 is at : 0xfee601fefbc8 T0 says its random number is : 1216059923 T0 says T1's random number is : 870926020 T1 says it's at : 0xfee601fefbc8 T1 says T0 is at : 0xfee6027ffbc8 T1 says its random number is : 870926020 T1 says T0's random number is : 1216059923 T0 now changes thread T1's number. Now T0 says its random number is: 1216059923 Now T1 says its random number is: 1111111111
Here, we run two threads. Each thread writes a random number string to a thread-local buffer, then reports both what it finds in its own buffer as well as what it sees in that of the other thread. Next, one thread overwrites the value in the other thread's buffer, which we then see indeed reported in that thread.
This even works on a platform with random placement of each thread's stack, such as, e.g., OpenBSD. But you might argue that we cheated a bit here, because we had each thread place its variable address into a globally shared array. Fair enough, let's see if we can just guess the location of the other thread's variable:
$ cc -Wall -Wextra thread-stacks3.c -lpthread $ ./a.out 8454144 # our guess at the thread offset T2 is at 0xf07a40beffc8 T2 says T1's random number is : 1768453602 T1 is at 0xf07a413fffc8 T1 says its random number is : 1768453602 Now T1 says its random number is: 1111111111 $
Neat. If we can guess the offset1, we can reach into the other thread and monkey around with its variables.
What the fork(2)?
The example of above applies to threads.
What happens if instead of using threads, we just call
fork(2) and try to change
the values at the known addresses? Let's take a look:
$ clang -Wall -Wextra forkmem.c $ ./a.out 0xffffffc66e38 12963: argc 0xffffffc66e30 12963: argv 0xffffffc66e2c 12963: main (before fork) 0xfffffbefe000 12963: shared memory: "--------" 0xf54304898000 12963: heap memory : "++++++++" 0x000200111830 20948: buf[BUFSIZ] Parent changes heap memory at 0xf54304898000. Child changes shared memory at 0xfffffbefe000. [ Child ]: 0xffffffc669a4 8102: func frame 0xffffffc669a8 8102: func arg: "Child" 0xffffffc66e2c 8102: main (after fork) 0xffffffc66e30 8102: argv 0xffffffc66ea8 8102: argv[0] : child argv0 0x000200111768 8102: n : 1 0xfffffbefe000 8102: shared memory: "XXXXXXXX" 0xf54304898000 8102: heap memory : "++++++++" 0x000200111830 20948: buf[BUFSIZ] [ Parent ]: 0xffffffc669a4 12963: func frame 0xffffffc669a8 12963: func arg: "Parent" 0xffffffc66e2c 12963: main (after fork) 0xffffffc66e30 12963: argv 0xffffffc66ea8 12963: argv[0] : ./a.out 0x000200111768 12963: n : 0 0xfffffbefe000 12963: shared memory: "XXXXXXXX" 0xf54304898000 12963: heap memory : "########" 0x000200111830 20948: buf[BUFSIZ] $
Our program dynamically allocates some memory and
creates a shared memory segment prior to
calling fork(2), then lets
one process change the data at the malloc(3)'d pointer and the other
the one in the shared memory.
We note that, as promised by fork(2), the parent and child
processes are identical (modulo PID, PPID,
etc.). That is, they both have the exact
same virtual memory layout, including
identical addresses for main, func, or any of the global
variables, including the dynamically allocated pointer
to memory on the heap.
Both processes have a pointer (void *hptr) to memory address
0xf54304898000, but if one
changes the contents of that memory location, that
change is only visible within that
process: the child process, despite having
hptr point to the same
address, sees it's value unchanged. Similarly for the
use of the global char
buf[BUFSIZ] from the BSS segment. On the other
hand, changing the bytes located in the shared memory
segment is reflected in both processes.
This is perhaps counterintuitive: how can the memory at location 0xf54304898000 be one value in one process and another in a separate process? The answer lies in the fact that we're dealing with virtual memory, and that the virtual memory of one process is entirely separate from the virtual memory of another process. Because it's virtual memory, it's possible to have multiple virtual memory layouts with the exact same addresses but different contents.2
This distinction is worth making explicit: two processes may have identical virtual memory addresses, but can't access each other's process space, while two threads can reach into each other -- consider it the disintegration of the persistence of virtual memory -- because threads execute within the same virtual memory space, not just a virtual memory space with the same addresses.
November 9th, 2025
Footnotes:
[1]
In our previous post, we showed that the offset
between thread stacks is predictable, but of course
it's not always the same. That is, the offset depends
on whatever that thread is actually doing. If we
increase the size of buf
in thread1, then
we'll have to adjust our guess of the offset accordingly. But
nevertheless, I hope this example makes the point: if
you know (or can guess) the address of a variable, you
can manipulate it -- even across threads, because they
execute within the same virtual memory space.↩
[2] Underneath, the virtual memory may be mapped to the same physical memory pages until one process changes any of the data, at which point the backing physical memory pages are copied. See Copy-on-Write.↩
[3] If you're wondering why I'm
explicitly using clang in
some code examples and cc in
others: different compilers may use different
strategies relating to how arguments are passed,
registers spilled onto the stack, or how they
optimize the stack layout.
For example, you should find that clang allocates space for spilled
registers and function parameters above the function
local variables, while gcc
may spill the registers towads the low address of the
stack, but retain memory saved function arguments at
the high address. You can play around with this program to observe the
difference.
For a more detailed discussion of argument passing, see also this blog post from a while ago.
Links:
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.