RSSAmplifier

Signs of Triviality · Nov 9, 2025

Process Memory Sharing

0
Sign in to vote or save

netmeister.org

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:

Diagram of a
    process's layout in memory as a vertical rectangle with
    the high address at the top, the stack growing down
    and three thread stacks adjacent to each other.

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

Diagram of a
    two processes' layout in memory as vertical rectangles with
    the high address at the top, the stack growing down, a
    shared memory region and dynamically allocated
    memory below that.

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.

A cropped
section of Dali's 'The Disintegration of the
Persistence of Memory

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:

Read the original on netmeister.org

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.