In this post, we will see the Operating System aspects of a fork() call and spawning of new processes using exec family of calls. Further, we analyze an important aspect of process memory layout that is ASLR (Address space layout randomization).
Before moving forward, let's look into the typical memory layout of the processes in an operating system.
Code Segment
This is the segment where the machine code of your program resides. This segment is generally read only so that your code doesn't override it accidently.
Data Segment
This segment is where global, static, constant and external variables ( declared with extern keyword ) reside in the memory. This can be of two types - initialized (also known as .data section) and uninitialized (also known .bss section). You might think that these two sections are defined separately because one is initialized and the other is not. But if you recall your knowledge of C language, you would remember that all the global, static, and external variables are initialized with zero value if not explicitly initialized. So what causes the creation of these two segment if both of them are initialized anyway? The difference lies in the code footprint. All the global variables that are initialized explicitly, goes to your object code as it is. All the uninitialized variables stored as just the list in object and exec call initializes them with 0 when loaded into the memory.
Heap
Heap is the memory segment of the program where the dynamically allocated variables reside. So, all the variables that are allocated memory via malloc or new keyword are given memory in this segment. As you can see in the picture, it grows from bottom to top whenever new memory is allocated and shrinks back when it's freed.
Stack
The locally declared variable (the AUTO type) goes into the memory at the stack segment. Unlike Heap, this segment grows downwards whenever the execution goes inside a Function and shrinks back when the Function returns.
Now one interesting thing to notice here is that it shows how the static variables retain their values in between different Function calls. If static variables would be on the stack, then once the Function returns, the stack area where that variable resided would have been invalidated, but since it is in the Data segment, it can retain the values in between the function calls.
Now let's move on to the actual problem. So recently, in a lecture on Logical Addresses and Memory Layouts, it was pointed out that forking a process creates a new process in the memory. Not only does this process get a new pid, but also a new address space and place in the memory. However, if we printed the memory addresses reported by the programs - the parent and the child, we would notice that the two reported exactly the same addresses! Well, this is a consequence of Virtual Memory Addressing used in operating systems. So, a claim was made that the behavior should be repeatable and consistent across different instances of the binary itself. It does not have to use a fork inside to see that.
Someone coded it up and invalidated the claim! Every time the binary was run, most of the variables seemed to print different addresses. Globals and Static variables consistently reported the same addresses - but those which were locally declared (the AUTO type) reported different addresses.
Following are the actual code and results:
At first, we checked the addresses in the parent and child process created using fork, and found out that the claim was right.
#include<stdio.h> #include<stdlib.h> #include<unistd.h> #include<sys/types.h> #include<sys/wait.h> int global = 5; int main(void) { pid_t p; int a; int *b; a = 6; b = &a; p = fork(); if(p<0) { fprintf(stderr, "Error\n"); exit(-1); } else if(p>0) { /**< Child Process: Modify values to force copy of memory after Copy-on-write */ a = 4; global = 4; fprintf(stderr, "Child!\n"); fprintf(stderr, "a(%p): %d\tb(%p):%x Value=%d\tglobal(%p):%d\n",&a,a,&b,b,*b,&global,global); execlp("/bin/ls", "ls", NULL); } else { /**< Parent Process */ a = 8; fprintf(stderr, "Parent\n"); global = 8; fprintf(stderr, "a(%p): %d\tb(%p):%x Value=%d\tglobal(%p):%d\n",&a,a,&b,b,*b,&global,global); wait(NULL); return(0); } return(1); }
The result was:
Child!
a(0x7ffd0bd6b4b8): 4 b(0x7ffd0bd6b4c0):bd6b4b8 Value=4 global(0x601068):4
Parent
a(0x7ffd0bd6b4b8): 8 b(0x7ffd0bd6b4c0):bd6b4b8 Value=8 global(0x601068):8
It shows that both global and local variable are having same addresses, so far so good!
Now in next example, we created the child process using the exec call.
#include<stdio.h> #include<stdlib.h> #include<unistd.h> #include<sys/types.h> #include<sys/wait.h> int global; int main(void) { pid_t p; int a; int *b; FILE *fp; a = 6; b = &a; /** * To tell if the process has another instance running: * Parent process creates a dummy file. * Child process deletes it. */ fp = fopen("random", "r"); if(fp== NULL) { fprintf(stderr, "File does not exist. Parent process.\n"); fp = fopen("random", "w"); fclose(fp); p = fork(); if(p<0) { fprintf(stderr, "Error\n"); exit(-1); } else if(p>0) { a = 4; execlp("/home/abhishek/forking_this", "/home/abhishek/forking_this", NULL); } else { a = 8; global = 8; fprintf(stderr, "Back in Parent\n"); fprintf(stderr, "a(%p): %d\tb(%p):%x Value=%d\tglobal(%p):%d\n",&a,a,&b,b,*b,&global,global); wait(NULL); return(0); } } else { fprintf(stderr, "Child process: No forking\n"); remove("random"); a = 9; global =9; fprintf(stderr, "a(%p): %d\tb(%p):%x Value=%d\tglobal(%p):%d\n",&a,a,&b,b,*b,&global,global); } return(1); }
And the result was:
Child process: No forking
a(0x7ffc902d34d0): 9 b(0x7ffc902d34d8):902d34d0 Value=9 global(0x60108c):9
Back in Parent
a(0x7ffd3e18f0e0): 8 b(0x7ffd3e18f0e8):3e18f0e0 Value=8 global(0x60108c):8
Address of global variable is same in parent and child but local variables have different addresses. These local variables are stored in Stack but if we used the Heap then also the result would be same.
The behavior is preserved across forks, but not across different runs of the binary or usage of exec call. Why is it so?
Now, conceptually, the addresses should not change at all. However, they change. Turns out, this was intentionally introduced as a security feature under the name "Address Space Layout Randomization" (ASLR) in BSD and Linux. Then, Windows followed suit. ASLR randomly arranges the address space positions of key data areas of a process, including the base of the executable and the positions of the stack, heap and libraries. Now the question is if ASLR scheme is followed in creating the process then what is wrong with creating processes using fork call - why does it get the same addresses? It turns out that fork call makes an exact copy of the parent process while creating the child, thus address space layout is exactly same as the Parent process, so fork is generally used to create the related child process. On the other hand, exec call is used to create a separate process, that is why it's used by the shell, and that explains the similar behavior between creating the Child process using exec call and rerunning the binary from shell.