Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Race condition in ptrace() allows setuid debugging #5230

cees-elzinga opened this issue Feb 3, 2021 · 3 comments

Race condition in ptrace() allows setuid debugging #5230

cees-elzinga opened this issue Feb 3, 2021 · 3 comments


Copy link

A race condition exists in ptrace() that allows any user to attach to setuid processes. The core issue is a lack of synchronisation between ptrace() and execve().


When handling a ptrace() syscall the kernel starts by checking the state of the process, permissions, supplied arguments etc. If all these checks pass the kernel will continue to perform the requested action. This can be seen as two seperate steps.

The problem is that there is a small window between these steps. In this window 'things' could change without the kernel being aware of that. For example, the debuggee might be running with different privileges.

A sample timeline of how the bug might happen:

| Tracer             | Debuggee        |
|                    | running ...     |
| PT_ATTACH          |                 |
|  1. run checks     |                 |
|  2. perform action |                 |
|                    |                 |
| PT_CONTINUE        |                 |
|  1. run checks     |                 |
|  2. perform action |                 |
|                    |                 |
| PT_POKE            |                 |
|  1. run checks     |                 |
|  *interrupt*       |                 |
|                    | execl() setuid  |
|                    | setting m_euid  |
|                    | load elf        |
|  2. perform action |                 |
|  BUG HERE          |                 |


The window to race is very small, and in this window a lot of other code must run. To reproduce the bug its easiest to widen the window with a fake sleep at the critical moment.

I used the following patch in Kernel/Ptrace.cpp

     case PT_POKE:
+        // Fake a delay to increase the race window
+        for(int i=0; i<4096; i++) {
+            klog() << "PT_POKE: delaying..." << i;
+        }
+        klog() << "PT_POKE: all security checks done, going to poke!";

Before showing the bug you can use the attached demo script to show NORMAL behaviour, even when this patch is applied. The script will:

  • fork()
  • parent: attach to child
  • child: run a setuid binary
  • parent: call PT_POKE. this fails because of permissions

Expected output:

$ demo
parent: running with pid xx
parent: attached, calling PT_CONTINUE
child: running with pid xx
child: calling setuid binary
New password:
parent: calling PT_POKE. This should fail.
PT_POKE: Permission denied

Now comment out the sleep in the parent:

	// sleep a bit and let the child execute execl
	// sleep(3);

This will mimic the timeline above. The code now calls PT_POKE before the child has started the setuid binary. All checks pass, but because of the added print statements it will hang for a bit after the checks. By the time printing is done the debuggee has changed to the setuid passwd, but we are still allowed to poke it.

Expected output:

$ demo_no_sleep
parent: running with pid xx
parent: attached, calling PT_CONTINUE
child: running with pid xx
child: calling setuid binary
New password:
parent: calling PT_POKE. This should fail.
PT_POKE: Bad address                        <-- BUG: should be permission denied

The error message is a bit misleading. Because the demo tries to poke an unmapped address (0x41414141) and errors out. But to confirm the real problem you can verity the kernel log. It should print "PT_POKE: all security checks done, going to poke!" to indicate it passed all the checks.


Exploitation might be tricky:

  • the racing window is very small
  • can only do one ptrace() call on debuggee
  • dont know anything about memory layout of debuggee

However, I feel that if there is a reliable way to win the race exploitation should be possible. An attacker could for example break ASLR by running the exploit many times.

Winning the race might be possible by using tricks with signals, thread priorities, or abusing side-effects/delays of functions between the two steps. I did not look into this.



Copy link

Nice catch! :)

How would you break ASLR by running this many times though? Every time you execve you'll have a fresh new address space with everything randomized again.

Copy link

cees-elzinga commented Feb 3, 2021

That is true, but because it's a 32 bit system you only have so many options to place it.

If I take a quick look into the code it looks like userspace is limited from 0x00800000 -> 0xbe000000. Everything will be page-aligned. So this leaves (0xbe000000-0x00800000)/0x1000 = 776192 possible locations to load something. (Not sure if this is exactly right, but just as a ballpark)

That means that if the exploit is only failing because of ASLR you could just run it 776192 times, literally, and it will work.

Assuming 10 tries per second that would take ~22 hours. There's room for improvement ;-)

Copy link

cees-elzinga commented Feb 18, 2021

Adding an example exploit that combines this issue with #5270 to get a local privilege escalation.

To run the exploit first revert to commit 4b7b92c. This is the last commit before the patch above.

The race in ptrace.cpp is incredibly small. To test this exploit you want to add a delay at the critical moment in Kernel/Syscall/ptrace.cpp:

    case PT_SETREGS: {
    if (!tracer->has_regs())
        return EINVAL;

+   // fake a delay
+   for(int i=0; i<128; i++) { 
+       klog() << "PT_SETREGS: delaying..." << i;
+   }

The exploit will probably fail on the first run, but work on the second:

courage ~ $ hax
ERROR: leaking was too slow. Try again

courage ~ $ hax
run_tracer: running with pid 31
run_suid: running with pid 32
run_leaker: running with pid 33
run_suid: spawning passwd
New password: .text: 0x2e37d000
eax: 0000004b  ebx: 00000000  ecx: 000001ff  edx: 00000004  esp: 00000000
ebp: 00000000  esi: 00000000  edi: 00000000  eip: 2e37d158  eflags: 00000000
cs:  00000000  ss:  00000000  ds:  00000000  es:  00000000  gs:  00000000
Success! Reboot to get your root shell

courage ~ $ reboot
courage /home/anon # id

The attached code contains some additional details.


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
None yet

No branches or pull requests

2 participants