Wait, man...man wait

Forking, executing, and waiting on processes in x86 assembly

September 26, 2024

I spent a lot of yesterday combing through man pages. man fork, man wait, man sleep... sounds like an axiom of some sort. I was trying to debug a simple program in assembly that did three things:

  1. Fork a running process
  2. Call exec from the child process
  3. Call wait from the parent process to resume running until the child process completes.

The first two steps went fine. I'd already performed these separately in different programs, so combining them was straightforward. We start out by immediately performing a fork system call:

mov eax, 2 ; fork() syscall
int 0x80
cmp eax, 0 ; check if we are in the child process
jz child ; if eax == 0, jump to child

In the child process we request the execve system call by moving the correct arguments:

child:
mov eax, 0x0b ; syscall number 11 for execve
mov ebx, filename
mov ecx, argv
mov edx, envp
int 0x80

In gdb we can validate the process is forked and the child loads and executes the new program:

$ gdb
(gdb) layout asm
(gdb) break child
(gdb) set follow-fork-mode child
(gdb) run

We can stepi until we get to the exec call and see the ./sleep program from yesterday is loaded.

Validating system call arguments

I had trouble with the wait system call in the parent. After I made the wait call (which is code 7) it seemed like it never got successfully invoked. It turns out I wasn't paying enough attention to the required system call arguments. Let's have a closer look with man wait.

wait, waitpid, waitid - wait for process to change state pid_t waitpid(pid_t pid, int *wstatus, int options); Our system call requires three arguments that are provided in the ebx, ecx, and edx` respectively:

  1. pid_it d: the pid of the chid process to wait for. If this is set to 0, the parent process will wait for any child who's process group ID is equal to that of the calling process. If set to -1 the parent will wait for any child to terminate. We'll just use 0 for now.
  2. int *wstatus: a pointer to an integer where the status information of the child is stored.
  3. int options: controls the behavior of the system call. We can ignore this for now and just use 0.
section .bss
   status resd 1 ; reserve space for the status variable
...
_start:
...
mov eax, 7 ; wait() syscall number
mov ebx,0
mov ecx,status ; pointer to status variable (for exit status)
mov edx,0
int 0x80

When I first wrote the program, I'd neglected to add the uninitialized variable status in the .bss section. The system call failed, but I didn't know why. When I inspected the registers after the call, however, I saw -22 in the eax register. With man errno we see the 22nd entry to be EINVAL , or Invalid Argument. Aside: why are these entries not numbered in the man pages? I had to actually count down 22 error codes to get to EINVAL ! :thinking_face:

These types of rejections are security mechanisms implemented into the kernel.

From OSTEP:

...the OS must check what the user passes in and ensure that arguments are properly specified, or otherwise reject the call ... In general, a secure system must treat user inputs with great suspicion. Not doing so will undoubtedly lead to easily hacked software, a despairing sense that the world is an unsafe and scary place, and the loss of job security for the all-too-trusting OS developer. [6]

Anyway, after fixing this issue and allocating a memory address in the .bss section our program runs, forks, executes in the child, and waits in the parent for the child process to terminate. Success!

Here's the entire program:

section .data
	filename db './sleep', 0 ; the filename (path to the program)
	arg1 db './sleep', 0 ; argv[0] (the program name)
	argv dd arg1, 0 ; argv array: {arg1, NULL}
	envp dd 0 ; envp array: {NULL}
	parent_waiting_message db "Parent process is waiting for child process to complete",0xa
	parent_waiting_length equ $ - parent_waiting_message
	child_completed_message db "Child process complete",0xa
	child_completed_message_length equ $ - child_completed_message
	child_message db "Child process created, loading new program",0xa
	child_length equ $ - child_message
	section .bss
	status resd 1 ; reserve space for the status variable

section .text
	global _start

_start:
	mov eax, 2 ; fork() syscall
	int 0x80
	cmp eax, 0 ; check if we are in the child process
	jz child ; if eax == 0, jump to child
  
parent:
	mov ecx, parent_waiting_message
	mov edx, parent_waiting_length
	call print
	mov eax, 7 ; wait() syscall number
	mov ebx,0
	mov ecx,status ; pointer to status variable (for exit status)
	mov edx,0
	int 0x80 ; invoke the system call
	jnz exit_with_error ; check if wait() returned an error
	mov ecx, child_completed_message
	mov edx, child_completed_message_length
	call print
	call exit

child:
	mov ecx, child_message
	mov edx, child_length
	call print
	call exec
  
exec:
	mov eax, 0x0b ; syscall number 11 for execve
	mov ebx, filename ; filename -> ebx
	mov ecx, argv ; argv -> ecx
	mov edx, envp ; envp -> edx
	int 0x80 ; make the system call
	call exit

print:
	mov ebx, 1 ; first argument: file handle (stdout)
	mov eax, 4 ; system call number (SYS_WRITE)
	int 0x80
	ret

exit:
	mov ebx, 0 ; return code
	mov eax, 1 ; syscall number for exit
	int 0x80

exit_with_error:
	mov ebx, eax ; return error code in ebx
	mov eax, 1 ; syscall number for exit
	int 0x80

I've added some system call to print status strings to stdout, as well as added a label to explicitly exit the program with a status code of 1 if the wait process fails.

More questions than answers:

I'm still interested in digging into the fork system call, specifically investigating how memory is locked and freed during context switching. Maybe performing some write system calls before exec is called in the child would illuminate some of the inner workings of these safety mechanisms, like "copy on write'".