CVE-2018-6924: FreeBSD ELF Header Parsing Kernel Memory Disclosure

On September 2018, FreeBSD published the security advisory FreeBSD-SA-18:12, fixing a kernel memory disclosure vulnerability affecting all the supported versions of this operating system.

This vulnerability -- identified as CVE-2018-6924 -- is caused by insufficient validation when the FreeBSD kernel parses the ELF header of a binary prior to its execution, and it can be used by a local unprivileged user to disclose the contents of kernel memory.

Introduction

On September 12th, 2018 FreeBSD published the security advisory FreeBSD-SA-18:12, fixing CVE-2018-6924, a kernel memory disclosure vulnerability discovered by Thomas Barabosch and Mark Johnston, caused by improper ELF header parsing in the kernel. As stated in the advisory, "execution of a malicious ELF binary may result in a kernel crash or may disclose kernel memory". Interestingly, all the supported versions of FreeBSD were affected by this bug, including versions 10, 10.4, 11, 11.1 and 11.2.

The analysis provided here is based on FreeBSD 11.2-RELEASE x64, running a GENERIC kernel.

Understanding the fix

As usual, the security advisory contains a link to a source code patch that fixes the bug. Let's start by taking a look at it:

--- sys/kern/imgact_elf.c.orig
+++ sys/kern/imgact_elf.c
@@ -839,7 +839,8 @@
            break;
        case PT_INTERP:
            /* Path to interpreter */
-                   if (phdr[i].p_filesz > MAXPATHLEN) {
+                   if (phdr[i].p_filesz < 2 ||
+                       phdr[i].p_filesz > MAXPATHLEN) {
                uprintf("Invalid PT_INTERP\n");
                error = ENOEXEC;
                goto ret;
@@ -870,6 +871,11 @@
            } else {
                interp = __DECONST(char *, imgp->image_header) +
                    phdr[i].p_offset;
+                           if (interp[interp_name_len - 1] != '\0') {
+                                   uprintf("Invalid PT_INTERP\n");
+                                   error = ENOEXEC;
+                                   goto ret;
+                           }
            }
            break;
        case PT_GNU_STACK:
--- sys/kern/vfs_vnops.c.orig
+++ sys/kern/vfs_vnops.c
@@ -528,6 +528,8 @@
    struct vn_io_fault_args args;
    int error, lock_flags;

+   if (offset < 0 && vp->v_type != VCHR)
+           return (EINVAL);
    auio.uio_iov = &aiov;
    auio.uio_iovcnt = 1;
    aiov.iov_base = base;

So there are two modified files here: sys/kern/imgact_elf.c and sys/kern/vfs_vnops.c. The sys/kern/imgact_elf.c file contains the code used by the kernel to parse the ELF header of a binary prior to its execution, and the fixed function is this one:

776 static int
777 __CONCAT(exec_, __elfN(imgact))(struct image_params *imgp)
778 {
    [...]

So the name of the affected function is generated by using the __CONCAT and __elfN macros. __CONCAT well... it concatenates its two arguments :), while __elfN is defined like this in sys/sys/elf_generic.h:

#define __elfN(x)       __CONCAT(__CONCAT(__CONCAT(elf,__ELF_WORD_SIZE),_),x)

As a result, the function name __CONCAT(exec_, __elfN(imgact)) expands to either exec_elf32_imgact or exec_elf64_imgact, depending on whether __ELF_WORD_SIZE is defined as 32 or 64. But if we check the sys/kern/ source directory, we see that there exists two very small files named imgact_elf32.c and imgact_elf64.c, which simply define __ELF_WORD_SIZE to the proper word size and then just include kern/imgact_elf.c, the file containing the vulnerable function. As a consequence, the kernel includes two versions of every function in sys/kern/imgact_elf.c whose name is built using the __elfN macro: one version that handles 32-bit ELF binaries, and another version that deals with 64-bit ELF files.

imgact_elf32.c:

#define __ELF_WORD_SIZE 32
#include <kern/imgact_elf.c>

imgact_elf64.c:

#define __ELF_WORD_SIZE 64
#include <kern/imgact_elf.c>

Going back to the patch, it's clear that the issue is located in the code that handles the PT_INTERP program header of an ELF file:

static int
__CONCAT(exec_, __elfN(imgact))(struct image_params *imgp)
{
    [...]
    for (i = 0; i < hdr->e_phnum; i++) {
        switch (phdr[i].p_type) {
        [...]
        case PT_INTERP:
            /* Path to interpreter */
            if (phdr[i].p_filesz > MAXPATHLEN) {
                uprintf("Invalid PT_INTERP\n");
                error = ENOEXEC;
                goto ret;
            }
            [...]

The PT_INTERP program header holds the path name of a program interpreter. This segment type is meaningful only for executable files. The executable file referenced by the PT_INTERP program header is used as the dynamic linker, being in charge of loading all the needed shared libraries for dynamically linked executables. Typically, the program interpreter on FreeBSD is set to /libexec/ld-elf.so.1.

As a reference, here's the specialized version of the Elf_Phdr structure for 32-bit ELF files:

typedef struct {
    Elf32_Word      p_type;         /* Entry type. */
    Elf32_Off       p_offset;       /* File offset of contents. */
    Elf32_Addr      p_vaddr;        /* Virtual address in memory image. */
    Elf32_Addr      p_paddr;        /* Physical address (not used). */
    Elf32_Word      p_filesz;       /* Size of contents in file. */
    Elf32_Word      p_memsz;        /* Size of contents in memory. */
    Elf32_Word      p_flags;        /* Access permission flags. */
    Elf32_Word      p_align;        /* Alignment in memory and file. */
} Elf32_Phdr;

The old, vulnerable version only checks if phdr[i].p_filesz > MAXPATHLEN, that is, it checks if the size of the contents in file of the PT_INTERP program header (that is, the length of the path to the interpreter) is greater than MAXPATHLEN (1024); if that's the case, the function quickly bails out with error ENOEXEC.

On the other hand, the fixed version adds an additional check: now it also detects as an error when phdr[i].p_filesz < 2, that is, those cases in which the size of the contents in file of the PT_INTERP program header is smaller than 2. This tells us that the bug should be triggered by executing an ELF file with a header specifying a very short interpreter path, either 0 or 1 bytes long.

Digging for History

Not being sure at first about how I should leverage an ELF file with a PT_INTERP program header specifying a very short interpreter path, I decided to take a look at the commit history of the affected file sys/kern/imgact_elf.c, with the hopes of finding some previous bug related to a bogus interpreter path that would give me some inspiration.

The commit message of revision 238617, dated July 19th, 2012, turns out to be very interesting:

Fix several reads beyond the mapped first page of the binary in the
ELF parser. Specifically, do not allow note reader and interpreter
path comparision in the brandelf code to read past end of the page.
This may happen if specially crafter ELF image is activated.

So this means that the sys/kern/imgact_elf.c file has already been affected by bugs related to the handling of the interpreter path; specifically, this older bug talks about "reads beyond the mapped first page of the binary" triggered by an "specially crafted ELF image"; to me, that sounds like those memory reads past the end of the first page could probably result in the disclosure of kernel memory, so this sparks an idea!

Building a Proof of Concept

The older bug we found that was fixed on revision 238617 talks about memory "reads beyond the mapped first page of the binary". If we were to trigger a memory read past the end of the first page of the binary, we would want our ELF file to fit into a single page (that is, 4096 bytes), so that the read operation would access memory beyond the end of our ELF file, potentially leading to a memory disclosure condition.

In order to make an ELF file fitting into a single page, I made a minimal C program like this:

int main(int argc, char** argv){
    return argc;
}

The compiled program size should be at most 4096 bytes. It's easier to meet that goal by instructing clang to generate a 32-bit executable with -m32. Additionally I used clang's command-line options -Oz (code generation optimized to reduce code size further than -Os) and -Wl,-s (instructing the linker to strip all debug and symbol information) to keep the file size as small as possible.

% clang -Oz -Wl,-s -m32 test.c -o test

The result is a 3560 bytes 32-bit ELF file, which will definitely fit into a single memory page.

So, the question now is: how can we craft the interpreter path in a way that the parsing code in the kernel accesses memory past the end of the first (and only) page of our binary? Well, the obvious answer would be to craft a PT_INTERP program header having a p_offset member (indicating the file offset of the interpreter path string) greater than 4096. However it's not that trivial, since the code of the exec_elf32_imgact() function performs some checks on the value of the p_offset member:

840                 case PT_INTERP:
[...]
852                         interp_name_len = phdr[i].p_filesz;
853                         if (phdr[i].p_offset > PAGE_SIZE ||
854                             interp_name_len > PAGE_SIZE - phdr[i].p_offset) {
855                                 VOP_UNLOCK(imgp->vp, 0);
856                                 interp_buf = malloc(interp_name_len + 1, M_TEMP,
857                                     M_WAITOK);
858                                 vn_lock(imgp->vp, LK_EXCLUSIVE | LK_RETRY);
859                                 error = vn_rdwr(UIO_READ, imgp->vp, interp_buf,
860                                     interp_name_len, phdr[i].p_offset,
861                                     UIO_SYSSPACE, IO_NODELOCKED, td->td_ucred,
862                                     NOCRED, NULL, td);
863                                 if (error != 0) {
864                                         uprintf("i/o error PT_INTERP\n");
865                                         goto ret;
866                                 }
867                                 interp_buf[interp_name_len] = '\0';
868                                 interp = interp_buf;
869                         } else {
870                                 interp = __DECONST(char *, imgp->image_header) +
871                                     phdr[i].p_offset;
872                         }
873                         break;

As we can see at lines 853 and 854, if the file offset of the interpreter path string is located past the first page (phdr[i].p_offset > PAGE_SIZE), or if the interpreter path is long enough that it extends past the first page (interp_name_len > PAGE_SIZE - phdr[i].p_offset), then the vn_rdwr() function will be called, in order to read from the vnode representing our ELF file, at the offset given by p_offset into the interp_buf buffer. My guess is that, at this early stage of the loading process of an executable, only the first page of the file is loaded in memory, so accessing past the first page must touch the disk. But since our ELF file is smaller than PAGE_SIZE bytes, vn_rdwr() will fail to read from offset > PAGE_SIZE, quickly kicking us out of the function. (By the way, note that the vn_rdwr() function, defined in sys/kern/vfs_vnops.c, is the one being additionally fixed in the patch included in the advisory; the patch adds an extra check to reject those cases in which a negative offset is provided for vnodes that are not a character device).

On the contrary, if the p_offset member is not greater than 0x1000 and the length of the interpreter path string is not greater than PAGE_SIZE - phdr[i].p_offset, we would hit the else branch at line 869, where the contents of the PT_INTERP program header are considered to be fine, and therefore the interp variable is set to point to image_header + phdr[i].p_offset, that is, the interpreter path string.

So the key to trigger the vulnerability becomes evident now: we need to craft a PT_INTERP program header having a p_offset member with value 0x1000, and a p_filesz member with value 0. This p_offset value bypasses the check at line 853 since it's not greater than 0x1000; and the p_filesz value bypasses the check at line 854, since interp_name_len (0) is not greater than PAGE_SIZE - phdr[i].p_offset == 0x1000 - 0x1000 == 0. With these two special values we will hit the else branch at line 869 and the interp value will end up pointing to offset 0x1000 of our ELF image, that is, exactly one page past the beginning of our ELF file; but since our ELF file takes only one memory page, interp will point out-of-bounds to the page right after our ELF file.

The goal of the patch becomes clearer now: the path to the interpreter is now enforced to be at least 2 bytes long to avoid this corner case that led to the vulnerability. After all, an interpreter path should be at least 1 char long, plus its null terminator (the value of the p_filesz field of PT_INTERP must account for the null byte terminating the string).

In order to craft the proper PT_INTERP program header I loaded the test binary we compiled before into Kaitai WebIDE to easily inspect its ELF header. We can see that the PT_INTERP program header starts at offset 0x54. To be precise, we need to set the DWORD at offset 0x58 (p_offset, highlighted in red, original value 0x134) to 0x1000, and the DWORD at offset 0x64 (p_filesz, highlighted in green, original value 0x15) to 0.

Disclosing kernel memory

So, what happens when the interp variable points out-of-bounds to whatever data is located at the page right after our ELF file? Later within the exec_elf32_imgact() function, at line 1059, the elf32_load_file() function is called to load the interpreter whose file name is pointed by the interp variable. If elf32_load_file() fails to load the interpreter for any reason (for example, because it couldn't find the given file) it returns an error, and then the uprintf() function is called at line 1064 to print an error message to the current process' controlling tty, including the (bogus) interpreter file name pointed by interp:

1036                if (interp != NULL) {
1037                        have_interp = FALSE;
[...]
1058                        if (!have_interp) {
1059                                error = __elfN(load_file)(imgp->proc, interp, &addr,
1060                                    &imgp->entry_addr, sv->sv_pagesize);
1061                        }
1062                        vn_lock(imgp->vp, LK_EXCLUSIVE | LK_RETRY);
1063                        if (error != 0) {
1064                                uprintf("ELF interpreter %s not found, error %d\n",
1065                                    interp, error);
1066                                goto ret;
1067                        }

Printing the null-terminated string pointed by interp to the current process' tty as part of the error message is what makes the memory disclosure possible.

We can run the modified ELF file several times on our FreeBSD test machine and see how it starts leaking the contents of kernel memory through the error message produced by the kernel:

francisco@freebsd112:~ % ./poc1
ELF interpreter Ø3¤ not found, error 2
Abort

francisco@freebsd112:~ % ./poc1
ELF interpreter  not found, error 2
Abort

francisco@freebsd112:~ % ./poc1
ELF interpreter $ûÿÿl not found, error 2
Abort

francisco@freebsd112:~ % ./poc1
ELF interpreter ^?ELF^A^A^A  not found, error 2
Abort

Note that if the out-of-bounds memory read hits an unmapped page, the kernel would crash. However, after several dozens of tests (including reboots to make sure that I wasn't being too lucky with a particular memory layout in a single run) I have never hit a kernel panic.

Capturing non-printable output

The uprintf() function treats the bogus interp pointer as a pointer to a null-terminated string (%s), and therefore it will print whatever characters it finds until a null byte is found. Since the characters being printed are not necessarily in the printable range, it may be a good idea to capture the error message containing the leaked kernel data in order to hexdump it. Since uprintf() writes to the current process' controlling tty, we can capture its output by using the script tool, which saves everything printed on a terminal to a typescript file.

The snippet below shows the hex dump of 75 bytes of kernel memory being leaked with this vulnerability:

francisco@freebsd112:~ % script -q capture1 ./poc1
ELF interpreter ?^[(^[(?^[(?^[(^[(^Z(^Z(^Z(^Z(^[(17^[(5^[(^[(^[(^[(  not found, error 2

francisco@freebsd112:~ % hexdump -C capture1
00000000  70 6f 63 31 3a 0d 0a 45  4c 46 20 69 6e 74 65 72  |poc1:..ELF inter|
00000010  70 72 65 74 65 72 20 c5  83 5e 5b 28 cc 83 5e 5b  |preter ..^[(..^[|
00000020  28 d4 83 5e 5b 28 dc 83  5e 5b 28 98 83 5e 5b 28  |(..^[(..^[(..^[(|
00000030  d8 d1 5e 5a 28 e2 d1 5e  5a 28 fe e5 5e 5a 28 9c  |..^Z(..^Z(..^Z(.|
00000040  bf 5e 5a 28 e3 83 5e 5b  28 31 37 5e 5b 28 35 ba  |.^Z(..^[(17^[(5.|
00000050  5e 5b 28 e6 83 5e 5b 28  e9 83 5e 5b 28 f2 83 5e  |^[(..^[(..^[(..^|
00000060  5b 28 20 6e 6f 74 20 66  6f 75 6e 64 2c 20 65 72  |[( not found, er|
00000070  72 6f 72 20 32 0d 0a 70  6f 63 31 3a 20 73 69 67  |ror 2..poc1: sig|
00000080  6e 61 6c 20 36 0d 0a                              |nal 6..|
00000087

Conclusions

This kernel memory disclosure vulnerability affected all the supported versions of FreeBSD when parsing an ELF file at load time; in the case of the 11.2 branch, on which our analysis is based, it has been fixed on the FreeBSD 11.2-RELEASE-p3 build. An unprivileged user can trigger an out-of-bounds memory access in the kernel by executing an ELF file of size < 0x1000 bytes containing an specially crafted PT_INTERP program header, with fields p_offset == 0x1000 and p_filesz == 0. Such values will bypass the sanity checks of the kernel when processing the PT_INTERP program header, causing it to treat as the interpreter path string whatever data is located at the beginning of the page right after our ELF file, effectively accessing memory out-of-bounds.

After failing to load the interpreter from the bogus path string, the kernel will print an error message including the interpreter path that it couldn't find, thus leaking to user-mode whatever data is located at the beginning of the page right after our ELF file, until it finds a null byte that is interpreted as the end of the bogus string.

The tests shown above were performed without any prior kernel memory grooming; such techniques may be needed if you want to obtain a predictable memory layout in order to have better control of what is leaked via this vulnerability.

The careful reader may have noticed that there are actually more ways to trigger the bug, other than setting p_offset == 0x1000 and p_filesz == 0. Hint: ELF file size == 0x1000, p_offset = 0xfff, p_filesz == 1, with the last byte of the file being non-null (that is, PT_INTERP pointing at the last byte of the file, with the interpreter string lacking a null terminator). In fact, p_offset and p_filesz could have other values, as long as the referenced interpreter string is located at the end of the page, and it is not null-terminated.

Finally, I'd like to highlight the dangers of interpreting the same chunk of data according to two different string representations: the PT_INTERP program header stores the interpreter string as a pointer + length representation, while the code handling the interpreter treats it as a null-terminated string, ignoring the string length indicated in the program header. This differential in the way the string data is interpreted had a direct impact on this bug, because if the 0-length specified in the p_filesz field were honored, no data would have been leaked to user-mode.

Thanks

A big thanks goes to my colleagues at Quarkslab for proof-reading this blog post.

Comments