CVE-2020–15702 Race Condition vulnerability in handling of PID by apport
By: Flatt Security Inc.
Tags: Vulnerability, Linux, Security, Ubuntu, Cybersecurity

PoC overview

There are two main points that are important in writing this PoC.

Adjust timing of PID recycling The way to escalate privileges when core file is saved to cwd of privileged process

Adjust timing of PID recycling

The first important thing is to adjust the timing of PID recycling. What is currently required is to send a SIGSEGV to launch the apport and duplicate the PID by sending SIGKILL + launching privileged process before reading procfs.

It is practically difficult to make a round of PID in a short time. But since we can send SIGSEGV at any time, if we send SIGSEGV after repeating the run and kill process until just before the PID goes around, the issues of taking time can be solved.

However, timing issues remains. For example, in an implementation that sends SIGSEGV and sends SIGKILL 1sec later, the apport completes its task and terminates before receiving SIGKILL. On the other hand, if the timing of sending SIGKILL is too early, it will also not work well. If we send signals at about the same time, the do_coredump() function will not be called in the first place. I’ll explain below.

Linux kernel handles the received signals when the target process transitions from kernel mode to user mode. At this time, the operation is performed by the get_signal() function according to each received signal. For example, if a critical signal such as SIGSEGV is received, the do_coredump() function will be called as shown below, and a core dump file(crash report function if a pipe is set) will be saved.



if (sig_kernel_coredump(signr)) {

if (print_fatal_signals)

print_fatal_signal(ksig->info.si_signo);

proc_coredump_connector(current);

/*

* If it was able to dump core, this kills all

* other threads in the group and synchronizes with

* their demise. If we lost the race with another

* thread getting here, it set group_exit_code

* first and our do_group_exit call below will use

* that value and ignore the one we pass it.

*/

do_coredump(&ksig->info);

}



https://github.com/torvalds/linux/blob/9907ab371426da8b3cffa6cc3e4ae54829559207/kernel/signal.c#L2739-L2751

If both SIGSEGV and SIGKILL are held as received signals when get_signal() function is called, the do_coredump() function will not be called due to the following conditional branching, assuming that the process should be terminated. This is why it doesn’t work if the signal is sent too early.



/* Has this task already been marked for death? */

if (signal_group_exit(signal)) {

ksig->info.si_signo = signr = SIGKILL;

sigdelset(¤t->pending.signal, SIGKILL);

trace_signal_deliver(SIGKILL, SEND_SIG_NOINFO,

&sighand->action[SIGKILL - 1]);

recalc_sigpending();

goto fatal;

}



https://github.com/torvalds/linux/blob/9907ab371426da8b3cffa6cc3e4ae54829559207/kernel/signal.c#L2601-L2609

I mean, we shouldn’t send them at least almost at the same time, but if it’s too late, apport will finish its task. It makes timing adjustment difficult. So I decided to use the lock implemented in apport. apport checks to see if another apport has already launched with a file called /var/run/apport.lock at startup. This checking function(check_lock()) is called before getting proc_pid_fd . I mean, if we keep running another apport process, we can stop execution between calling do_coredump() function and referencing procfs.

However, of course, execution will resume immediately when the another apport process terminates. I remembered the function of dbus that I used when I discovered another vulnerability (CVE-2020–11936). In the function is_closing_session(), by specifying the environment variable DBUS_SESSION_BUS_ADDRESS here, the request will be sent to the TCP server owned by an attacker.



def is_closing_session(uid):

'''Check if pid is in a closing user session. During that, crashes are common as the session D-BUS and X.org are going

away, etc. These crash reports are mostly noise, so should be ignored.

'''

with open('environ', 'rb', opener=proc_pid_opener) as e:

env = e.read().split(b'\0')

for e in env:

if e.startswith(b'DBUS_SESSION_BUS_ADDRESS='):

dbus_addr = e.split(b'=', 1)[1].decode()

break

else:

error_log('is_closing_session(): no DBUS_SESSION_BUS_ADDRESS in environment')

return False orig_uid = os.geteuid()

os.setresuid(-1, os.getuid(), -1)

try:

gdbus = subprocess.Popen(['/usr/bin/gdbus', 'call', '-e', '-d',

'org.gnome.SessionManager', '-o', '/org/gnome/SessionManager', '-m',

'org.gnome.SessionManager.IsSessionRunning'], stdout=subprocess.PIPE,

stderr=subprocess.PIPE, env={'DBUS_SESSION_BUS_ADDRESS': dbus_addr})

(out, err) = gdbus.communicate()



https://git.launchpad.net/ubuntu/+source/apport/tree/data/apport?h=applied/ubuntu/focal-devel&id=02a1fd19eafeae3f8e98f1461c9bcea850f0c419#n253

Now what if the TCP server doesn’t send a response? The gdbus command does not terminate, and of course the line gdbus.communicate() does not return and execution is paused. In other words, the program execution is stopped while holding the lock of /var/run/apport.lock.

In summary, it is possible to stop the execution of apport for the target PID and wait for the reuse of PID stably in the following steps.

Set the environment variable DBUS_SESSION_BUS_ADDRESS to a value like tcp: host = 127.0.0.1, port = 8888 Launch the TCP server on the specified port Launch apport by sending SIGSEGV to process A AUTH request comes from gdbus to TCP server, but stop it by not responding here. Launch apport by sendin SIGSEGV to newly created process B apport stops running at check_lock() function, because another apport process is not terminated. Send SIGKILL to process B. Launch a privileged process in some way to duplicate the PID of process B Send some random response from TCP server to resume execution apport for process A terminates The lock is released and the apport execution for process B resumes (at this point the PID of process B is being recycled).

How to escalate privileges when core file is saved to cwd of privileged process

Well, in the previous explanation, I explained how to recycle PID. But I haven’t yet described how to escalate privileges. I referred to the following exploit.

First, to summarize the current situation, it is possible to save a core dump file of the prepared process to the cwd of the privileged process that can be launched even by an unprivileged process or the start timing can be predicted. This means that the target process must be exploitable by reading a file from cwd. It was a little difficult to find a process that meets this condition. There is a lot of software (such as cron) that allows us to specify commands in a file, but it’s hard to find a process that calls chdir() to change the cwd. But finally, it turned out that logrotate was the software that met that condition.

logrotate was perfect for this exploit as follows.

Basically always executed at a specific time (timing can be predicted) Basically run with high privileges call chdir() before reading files in /etc/logrotate.d/

a.https://github.com/logrotate/logrotate/blob/dac0b9e5db48861f7554fdf668f51ed59a07adb5/config.c#L684-L713 It doesn’t depend on file name. (read all files in that directory) Since the file format is not strictly checked and invalid characters are skipped, it is sufficient if the abnormal binary file contains a string of normal configuration.

https://github.com/logrotate/logrotate/blob/dac0b9e5db48861f7554fdf668f51ed59a07adb5/config.c#L1048-L2050

As for 3, as I said earlier, 5 is also quite important. Since the core dump file outputs the memory state, it cannot be manipulated completely. So it is basically treated as an abnormal binary file.

Thanks to the 5, it was possible to create a configuration file in /etc/logrotate.d/ that executes the touch /tmp/exploited part by crashing the program that defines the following character string.



char payload[] = "

/tmp/pwn.log{

su root root

daily

size=0

firstaction

touch /tmp/exploited;

endscript

}

";



When logrotate is executed again, the command will be executed.

Aside

As an aside, the content explained so far is not perfect when you actually try to exploit. This is because it takes too short time after chdir() is called to finish parsing the config file and chdir() to the original directory. Since we have to execute a few lines of the python program(apport) in that short time, very hard timing adjustment is required.

For this issue, (because it is just a PoC) it was okay if I could exploit theoretically. So I created a meaningless config file with a large file size in /etc/logrotate.d/, I dealt with it. The larger the config file, the longer it will take to parse, so you can increase the amount of time the cwd is changed to privileged process’s one. I used inotify to detect chdir(), then the core file is written to /etc/logrotate.d/, and it was confirmed that the privileges can be elevated when the logrotate is executed again.

Below is an image of the result of the execution. (logrotate is executed manually.)