What happens when your child inherits a breakpoint
If a process
fork()s while debugging, will the child inherit the breakpoint ?
In a rush ? Go to takeaways
How does a debugger attach to a running process forking into 2 ? Will it automatically attach to two processes ? What if we change a function while we have a breakpoint, will it affect child and parent both ? Let’s try it out.
#include<stdio.h>
#include <unistd.h>
#include <sys/types.h>
void answer(){
// breakpoint
int ans = 42;
printf("the answer to life is %d\n", ans);
}
int main(){
int naptime = 5;
printf("Hello, program start\n");
pid_t cpid = fork();
pid_t mypid = getpid();
printf("Child created, pid: %d\n", cpid);
if (cpid == 0) {
printf("[%d] I'm the child. cpid: %d\n", mypid, cpid);
} else {
printf("[%d] I'm the parent. cpid: %d\n", mypid, cpid);
}
printf("Sleeping for %d secs\n", naptime);
sleep(naptime);
printf("[%d] Awake!\n", mypid);
answer();
printf("[%d] bye ! \n", mypid);
return 0;
}This simple program creates a child fork, sleeps for 5 seconds, wakes up and then
calls answer().
Without a breakpoint, our output is something like this:
; ./fork
Hello, program start
Child created, pid: 66817
[66816] I'm the parent. cpid: 66817
Sleeping for 5 secs
Child created, pid: 0
[66817] I'm the child. cpid: 0
Sleeping for 5 secs
[66817] Awake!
[66817] the answer to life is 42
[66817] bye !
[66816] Awake!
[66816] the answer to life is 42
[66816] bye !Upcoming fun fact. During the sleep, the process relationship looks sane enough, child belongs to parent:
; pstree -p 66817 -+= 00001 root /sbin/launchd \-+= 02074 neo tmux \-+= 66629 neo -zsh \-+= 66816 neo ./fork \--- 66817 neo ./fork
Let’s rerun this in lldb (gdb for mac) and break at answer. Then we can change ans from 42 to 29 and see what
happens to the child.
; gcc fork.c -g -o fork
; lldb fork
(lldb) target create "fork"
Current executable set to '/Users/neo/raw/fork' (arm64).
(lldb) b answer
Breakpoint 1: where = fork`answer + 12 at fork.c:7:7, address = 0x000000010000046c
(lldb) r
Process 61763 launched: '/Users/neo/raw/fork' (arm64)
Hello, program start
Child created, pid: 61773
[61763] I'm the parent. cpid: 61773
Sleeping for 5 secs
Child created, pid: 0
[61773] I'm the child. cpid: 0
Sleeping for 5 secs
[61773] Awake!
[61763] Awake!
Process 61763 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x000000010000046c fork`answer at fork.c:7:7
4
5 void answer(){
6 // breakpoint
-> 7 int ans = 42;
8 printf("[%d] the answer to life is %d\n", getpid(), ans);
9 }
10
Target 0: (fork) stopped.
(lldb) n
Process 61763 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step over
frame #0: 0x0000000100000474 fork`answer at fork.c:8:45
5 void answer(){
6 // breakpoint
7 int ans = 42;
-> 8 printf("[%d] the answer to life is %d\n", getpid(), ans);
9 }
10
11 int main(){
Target 0: (fork) stopped.
(lldb) expr ans = 29
(int) $0 = 29
(lldb) c
Process 61763 resuming
[61763] the answer to life is 29
[61763] bye !
Process 61763 exited with status = 0 (0x00000000)
(lldb)Note that we must change ans on line 8, since at line 7 ans is uninitialized. After line 7 is executed, any value we set will be overwritten.
Output contains only one answer 29 and a single bye from parent. What happened to our child’s output ?
Probably this debugger only captures stdout of a single process, but we did get two Awakes from child and
parent. The stdout split happened only after the breakpoint. Let’s get another debugger and attach it to child as well.
; lldb fork
(lldb) target create "fork"
Current executable set to '/Users/neo/raw/fork' (arm64).
(lldb) b answer
Breakpoint 1: where = fork`answer + 12 at fork.c:7:7, address = 0x000000010000046c
(lldb) r
Process 62875 launched: '/Users/neo/raw/fork' (arm64)
Hello, program start
Child created, pid: 62878
[62875] I'm the parent. cpid: 62878
Sleeping for 5 secs
Child created, pid: 0
[62878] I'm the child. cpid: 0
Sleeping for 5 secs
[62875] Awake!
Process 62875 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x000000010000046c fork`answer at fork.c:7:7
4
5 void answer(){
6 // breakpoint
-> 7 int ans = 42;
8 printf("[%d] the answer to life is %d\n", getpid(), ans);
9 }
10
Target 0: (fork) stopped.
[62878] Awake!
(lldb)
; lldb -p 62878
(lldb) process attach --pid 62878
Process 62878 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = signal SIGSTOP
frame #0: 0x00000001830864e8 libsystem_kernel.dylib`__semwait_signal + 8
libsystem_kernel.dylib`__semwait_signal:
-> 0x1830864e8 <+8>: b.lo 0x183086508 ; <+40>
0x1830864ec <+12>: pacibsp
0x1830864f0 <+16>: stp x29, x30, [sp, #-0x10]!
0x1830864f4 <+20>: mov x29, sp
Target 0: (fork) stopped.
Executable binary set to "/Users/neo/raw/fork".
Architecture set to: arm64-apple-macosx-.
(lldb) c
Process 62878 resuming
Process 62878 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BREAKPOINT (code=1, subcode=0x10000046c)
frame #0: 0x000000010000046c fork`answer at fork.c:7:7
4
5 void answer(){
6 // breakpoint
-> 7 int ans = 42;
8 printf("[%d] the answer to life is %d\n", getpid(), ans);
9 }
10
Target 0: (fork) stopped.
(lldb) n
Process 62878 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BREAKPOINT (code=1, subcode=0x10000046c)
frame #0: 0x000000010000046c fork`answer at fork.c:7:7
4
5 void answer(){
6 // breakpoint
-> 7 int ans = 42;
8 printf("[%d] the answer to life is %d\n", getpid(), ans);
9 }
10
Target 0: (fork) stopped.
(lldb) c
Process 62878 resuming
Process 62878 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BREAKPOINT (code=1, subcode=0x10000046c)
frame #0: 0x000000010000046c fork`answer at fork.c:7:7
4
5 void answer(){
6 // breakpoint
-> 7 int ans = 42;
8 printf("[%d] the answer to life is %d\n", getpid(), ans);
9 }
10
Target 0: (fork) stopped.
(lldb)
The first breakpoint in child is set by the debugger (SIGSTOP) to hook to this process. The second breakpoint,
however, is our breakpoint that we set in the parent process. So, yes ! Attaching a breakpoint to a parent
does propagate it to the child.
But, why doesn’t the child continue ? Hitting next or even continue does nothing in the child’s debugger.
The child is evidently stuck!
Fun fact. Let’s check the
pstreeat this moment in time:; pstree -p 62875 # parent -+= 00001 root /sbin/launchd \-+= 02074 neo tmux \-+= 60121 neo -zsh \-+= 62867 neo /Library/Developer/CommandLineTools/usr/bin/lldb fork \-+= 62876 neo /Library/Developer/CommandLineTools/Library/PrivateFrameworks \--= 62875 neo /Users/neo/raw/fork ; pstree -p 62878 # child -+= 00001 root /sbin/launchd \-+= 02074 neo tmux \-+= 60276 neo -zsh \-+= 62898 neo /Library/Developer/CommandLineTools/usr/bin/lldb -p 62878 \-+= 62899 neo /Library/Developer/CommandLineTools/Library/PrivateFrameworks \--- 62878 neo /Users/neo/raw/forkThe child no longer belongs to the parent. The child has effectively been kidnapped by lldb !
A disassemble shows the current instruction pointer on a brk instruction.
(lldb) disassemble
fork`answer:
0x100000460 <+0>: sub sp, sp, #0x30
0x100000464 <+4>: stp x29, x30, [sp, #0x20]
0x100000468 <+8>: add x29, sp, #0x20
-> 0x10000046c <+12>: brk #0
0x100000470 <+16>: stur w8, [x29, #-0x4]
0x100000474 <+20>: bl 0x1000005bc ; symbol stub for: getpid
0x100000478 <+24>: ldur w8, [x29, #-0x4]
0x10000047c <+28>: mov x9, sp
0x100000480 <+32>: mov x10, x0
0x100000484 <+36>: str x10, [x9]
0x100000488 <+40>: str x8, [x9, #0x8]
0x10000048c <+44>: adrp x0, 0
0x100000490 <+48>: add x0, x0, #0x5e0 ; "[%d] the answer to life is %d\n"
0x100000494 <+52>: bl 0x1000005c8 ; symbol stub for: printf
0x100000498 <+56>: ldp x29, x30, [sp, #0x20]
0x10000049c <+60>: add sp, sp, #0x30
0x1000004a0 <+64>: ret
For reference, here’s how it looks in the parent
(lldb) disassemble
fork`answer:
0x100000460 <+0>: sub sp, sp, #0x30
0x100000464 <+4>: stp x29, x30, [sp, #0x20]
0x100000468 <+8>: add x29, sp, #0x20
-> 0x10000046c <+12>: mov w8, #0x2a ; =42
0x100000470 <+16>: stur w8, [x29, #-0x4]
0x100000474 <+20>: bl 0x1000005bc ; symbol stub for: getpid
0x100000478 <+24>: ldur w8, [x29, #-0x4]
0x10000047c <+28>: mov x9, sp
0x100000480 <+32>: mov x10, x0
0x100000484 <+36>: str x10, [x9]
0x100000488 <+40>: str x8, [x9, #0x8]
0x10000048c <+44>: adrp x0, 0
0x100000490 <+48>: add x0, x0, #0x5e0 ; "[%d] the answer to life is %d\n"
0x100000494 <+52>: bl 0x1000005c8 ; symbol stub for: printf
0x100000498 <+56>: ldp x29, x30, [sp, #0x20]
0x10000049c <+60>: add sp, sp, #0x30
0x1000004a0 <+64>: ret
A mov instruction has been replaced by brk in the child. Since brk does not increase the program counter,
the instruction pointer remains on the same brk instruction and even hitting ccontinue does nothing
and reexecutes brk instruction indefinitely.
This is how debuggers work internally. They replace a single instruction at the point of breakpoint with a brk,
whenever the program hits this brk, it stops with EXC_BREAKPOINT and the debugger replaces the brk with
the actual instruction.
Since, our second debugger knows nothing about this brk trick being present in the program (and which
instruction to replace it with), it does not replace brk during the breakpoint hit as it assumes brk is a part of the
program and keeps executing indefinitily without ever increasing program counter.
This is why we never got our bye and answer to life from the child. It was stuck forever in the brk
until the OS decided to kill it after the parent died.
Would stepping over this instruction manually in the debugger solve our issue ?
(lldb) expr $pc = 0x100000470
(unsigned long) $0 = 4294968432
(lldb) disassemble
fork`answer:
0x100000460 <+0>: sub sp, sp, #0x30
0x100000464 <+4>: stp x29, x30, [sp, #0x20]
0x100000468 <+8>: add x29, sp, #0x20
0x10000046c <+12>: brk #0
-> 0x100000470 <+16>: stur w8, [x29, #-0x4]
0x100000474 <+20>: bl 0x1000005bc ; symbol stub for: getpid
0x100000478 <+24>: ldur w8, [x29, #-0x4]
0x10000047c <+28>: mov x9, sp
0x100000480 <+32>: mov x10, x0
0x100000484 <+36>: str x10, [x9]
0x100000488 <+40>: str x8, [x9, #0x8]
0x10000048c <+44>: adrp x0, 0
0x100000490 <+48>: add x0, x0, #0x5e0 ; "[%d] the answer to life is %d\n"
0x100000494 <+52>: bl 0x1000005c8 ; symbol stub for: printf
0x100000498 <+56>: ldp x29, x30, [sp, #0x20]
0x10000049c <+60>: add sp, sp, #0x30
0x1000004a0 <+64>: ret
(lldb) c
Process 62878 resuming
Process 62878 exited with status = 0 (0x00000000)
Yes ! ?
Sadly, no. The stdout is still connected to the parent lldb and we got a garbage answer from child.
[62878] the answer to life is -324843284
[62878] bye !
(lldb) c
Process 62875 resuming
[62875] the answer to life is 42
[62875] bye !
This is because although we stepped over the brk instruction in the child, we didn’t replace it with an
approprate original instruction (here: 0x10000046c <+12>: mov w8, #0x2a ; =42).
This line was responsible for initializing our ans variable in the child.
Of course, we could run the missing instruction manually in the child’s debugger to fill the missing link, and everything comes back to normal.
Takeaways⌗
brkis actually replaced not added, and debugger knows when to replace back with original instruction.- Debugger, if attached on child (created by fork()), it will steal the child from parent process
- When fork()-ed, breakpoint instructions are also forked.
- If a debugger is atteched to the new child, the BRK instruction is also copid, the child stops at that instruction but debugger does not know about this BRK and assumes it as program’s intended instruction.
- Cpu will not increase program counter (pc) when
brkis hit.
Credits⌗
We stumbled upon this while pondering over with Roshan and Sapra