Talos Vulnerability Report

TALOS-2019-0973

E2fsprogs e2fsck rehash.c mutate_name() Code Execution Vulnerability

January 7, 2020
CVE Number

CVE-2019-5188

Summary

A code execution vulnerability exists in the directory rehashing functionality of E2fsprogs e2fsck 1.45.4. A specially crafted ext4 directory can cause an out-of-bounds write on the stack, resulting in code execution. An attacker can corrupt a partition to trigger this vulnerability.

Tested Versions

E2fsprogs 1.43.3 - 1.45.4

Product URLs

http://e2fsprogs.sourceforge.net/

CVSSv3 Score

7.5 - CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:C/C:H/I:H/A:H

CWE

CWE-787: Out-of-bounds Write

Details

E2fsprogs is a tried and true set of programs for interacting with ext2, ext3, and ext4 filesystems, and is considered essential software for linux and unix-like operating systems. As such, it ships by default on most linux distributions. Within e2fsprogs is the e2fsck binary which fixes corrupted ext2,3,4 filesystems and will be the topic of this advisory.

Within the implementation of directories in ext2,3,4 lay a lot of data structures needed for optimizing size of the files on disk. As such, hash trees were used for unique mapping of all files within a directory. The fill_dir_struct structure in charge of this hash tables along with the hashed items are seen below:

struct fill_dir_struct {  
    char *buf;  
    struct ext2_inode *inode;  
    ext2_ino_t ino;  
    errcode_t err;  
    e2fsck_t ctx;  
    struct hash_entry *harray;  // [1] 
    int max_array, num_array;   // [2] 
    unsigned int dir_size;  
    int compress;   
    ino_t parent;  
    ext2_ino_t dir;   
};

Of most importance are the hash_entry struct at [1], which contains all the hash entries for the hash tree, and num_array at [2], which contains the number of hash entries. In order to maintain the integrity and assumptions of a hash table, all the entries should in fact be unique (i.e. two individual files within a directory cannot share the same name). To enforce this uniqueness requirement, when e2fsck detects a collision, it will append the second file name with {~0, ~1, ..., ~n}, depending on how many duplicates already exist. The structures utilized for this process follow:

struct hash_entry {      
    ext2_dirhash_t  hash;   
    ext2_dirhash_t  minor_hash;   
    ino_t       ino;   
    struct ext2_dir_entry   *dir;   
};   

#define EXT2_NAME_LEN 255   

struct ext2_dir_entry {   
    __u32   inode;          /* Inode number */   
    __u16   rec_len;        /* Directory entry length */   
    __u16   name_len;       /* Name length */   
    char    name[EXT2_NAME_LEN];    /* File name */   
};   

For the active de-duplication process, duplicate_search_and_fix is called on the fill_dir_struct containing the hash tree:

static int duplicate_search_and_fix(e2fsck_t ctx, ext2_filsys fs,       
                    ext2_ino_t ino,       
                    struct fill_dir_struct *fd  )   
{      
    struct problem_context  pctx;      
    struct hash_entry   *ent, *prev;      
    int         i, j;      
    int         fixed = 0;      
    char            new_name[256];         // [1]   
    unsigned int        new_len;            
    int         hash_alg;      
    int hash_flags = fd->inode->i_flags & EXT4_CASEFOLD_FL;      

    [...]      
    for (i=1; i < fd->num_array; i++) {    // [2]      
        ent = fd->harray + i;      
        prev = ent - 1;      

        [...]      

        new_len = ext2fs_dirent_name_len(ent->dir);    // [3]   
        memcpy(new_name, ent->dir->name, new_len);      
        mutate_name(new_name, &new_len);               // [4] 

        }      
    }      
    [...]      

At [1], we have a temporary stack buffer used to temporarily store any mutated names such that they can be written back to disk later, and at [2] we can see the iteration over each hash_entry struct. When there is a collision between file names, the length of the current file name is grabbed at [3] (entry->name_len & 0xff;). It is important to note that the name_len field of an ext2_dir_entry struct is read straight from disk and it does not come from ext2_dir_entry->name. After this, we enter mutate_name [4], where the changes actually occur:

static void mutate_name(char *str, unsigned int *len) {   
    int i;   
    unsigned int l = *len;   

    for (i = l-1; i > 0; i--) {  // [1] 
        if (!isdigit(str[i]))   
            break;   
    }   

    if ((i == (int)l - 1) || (str[i] != '~')) {   // [2] 
        if (((l-1) & 3) < 2)   
            l += 2;   
        else   
            l = (l+3) & ~3;  // [3]   
        str[l-2] = '~';      // [4] 
        str[l-1] = '0';      // [5]
        *len = l;   
        return;   
    }   

    for (i = l-1; i >= 0; i--) {   

[..]   
}

The first checks at [1] and [2] look to see if the file has already been mutated, but for our purposes, we only care that our filename does not have any digits, such that when the loop at [1] exits, int i is set to -1. At [2], the assumption is that the file ends in ~X already (where x == digit), but if the length of our file is 0x0, (i == (int)l - 1) evaluates to true, since int i is currently -1. We then reach the line at [3], which does nothing since (0 + 3) & ~3 => 0, and l ends up being 0 when we reach [4] and [5]. Since l is still 0 and also an unsigned integer, the value underflows when used in the array indexing:

   0x00000000004422e4 <+180>:   mov    eax,DWORD PTR [rbp-0x18]   
   0x00000000004422e7 <+183>:   add    eax,0x3   
   0x00000000004422ea <+186>:   and    eax,0xfffffffc   
   0x00000000004422ed <+189>:   mov    DWORD PTR [rbp-0x18],eax   
   0x00000000004422f0 <+192>:   mov    rax,QWORD PTR [rbp-0x8]   
   0x00000000004422f4 <+196>:   mov    ecx,DWORD PTR [rbp-0x18]   
   0x00000000004422f7 <+199>:   sub    ecx,0x2                   // [1] 
   0x00000000004422fa <+202>:   mov    ecx,ecx   
   0x00000000004422fc <+204>:   mov    edx,ecx   
   0x00000000004422fe <+206>:   mov    BYTE PTR [rax+rdx*1],0x7e // [2] 
   0x0000000000442302 <+210>:   mov    rax,QWORD PTR [rbp-0x8]   
   0x0000000000442306 <+214>:   mov    ecx,DWORD PTR [rbp-0x18]   
   0x0000000000442309 <+217>:   sub    ecx,0x1                   // [3] 
   0x000000000044230c <+220>:   mov    ecx,ecx   
   0x000000000044230e <+222>:   mov    edx,ecx   
   0x0000000000442310 <+224>:   mov    BYTE PTR [rax+rdx*1],0x30 // [4] 

[1] and [2] correspond to the str[l-2] = '~';, and [3] and [4] correspond to str[l-1] = '0';, resulting in a out of bounds stack write of "~0". For the actual exploitation, since the indexing is truncated to ecx beforehand, on a 64-bit system, we see different behavior than on a 32-bit system. In both cases the end result is that new_name[0xFFFFFFFF] = '0' and new_name[0xFFFFFFFE] = '~', but on a 64-bit system, the exploitation is very unlikely:

// sample 64-bit address space.
0x00007f64b9062000 0x00007f64b9063000 0x0000000000000000 rw-
0x00007ffc69ed5000 0x00007ffc69ef7000 0x0000000000000000 rw- [stack]   // [1]
0x00007ffc69fba000 0x00007ffc69fbc000 0x0000000000000000 r-x [vdso]
0x00007ffc69fbd000 0x00007ffc69fbe000 0x0000000000000000 r-x
0xffffffffff600000 0xffffffffff601000 0x0000000000000000 r-x [vsyscall]

By default, the stack is not nearly big enough for this to work, as seen by [1], it becomes necessary to have a user-controlled method for expanding the stack large enough order to not crash, along with having an uncommon configuration on the host machine itself to even allow for the expansion to happen (ulimit -s unlimited).

For 32-bit versions, the end result is a lot more dependent on the binary itself and how it was compiled. As shown once again:

static int duplicate_search_and_fix(e2fsck_t ctx, ext2_filsys fs,   
                    ext2_ino_t ino,   
                    struct fill_dir_struct *fd)   
{   
    struct problem_context  pctx;   
    struct hash_entry   *ent, *prev;   
    int         i, j;   
    int         fixed = 0;   
    char            new_name[256];   
    unsigned int        new_len;   
    int         hash_alg;   
    int hash_flags = fd->inode->i_flags & EXT4_CASEFOLD_FL;   

If we're underflowing the new_name[256] stack variable, the affected target variable is completely up to the compiler, and thus the exploitability is binary-dependent.

Crash Information

Program received signal SIGSEGV, Segmentation fault.   
─── registers ────   
$rax   : 0x000010001ffff6b1  →  0x0000000000000000   
$rbx   : 0x00007fffffffb6e0  →  0x000000000000000c   
$rcx   : 0x00000000fffffffe  →  0x0000000000000000   
$rdx   : 0x00000000fffffffe  →  0x0000000000000000   
$rsp   : 0x00007fffffffb210  →  0x000000000072c930  →  <ext2fs_block_iterate3+0> push rbp   
$rbp   : 0x00007fffffffb4d0  →  0x00007fffffffbc10  →  0x00007fffffffc1d0  →  0x00007fffffffc570  →  0x00007fffffffc990  →  0x00007fffffffcad0  →  0x00007fffffffe280  →  0x00000000009814c0   
$rsi   : 0x8000ffffb58e   
$rdi   : 0x8000ffffb58e   
$rip   : 0x000000000069764b  →  <mutate_name+1435> mov cl, BYTE PTR [rax+0x7fff8000]   
$r8    : 0xff   
$r9    : 0x00007ffff6f8db01  →  <__memrchr_avx2+721> ret 0x8520   
$r10   : 0x0   
$r11   : 0x0   
$r12   : 0x0   
$r13   : 0x000010007fff7901  →  0x0000000000000000   
$r14   : 0x1   
$r15   : 0x1   
$eflags: [zero CARRY PARITY adjust sign trap INTERRUPT direction overflow RESUME virtualx86 identification]   
$cs: 0x0033 $ss: 0x002b $ds: 0x0000 $es: 0x0000 $fs: 0x0000 $gs: 0x0000   
──── stack ────   
0x00007fffffffb210│+0x0000: 0x000000000072c930  →  <ext2fs_block_iterate3+0> push rbp    ← $rsp   
0x00007fffffffb218│+0x0008: 0x00000000004c6389  →  <__asan::GetCurrentThreadStats()+9> test rax, rax   
0x00007fffffffb220│+0x0010: 0x0000c00040404040 ("@@@@"?)   
0x00007fffffffb228│+0x0018: 0x4040404040404040   
0x00007fffffffb230│+0x0020: 0x5df2ae5f40404040   
0x00007fffffffb238│+0x0028: 0x0000001000004040  →  0x0000000000000000   
0x00007fffffffb240│+0x0030: 0x4040404000000000   
0x00007fffffffb248│+0x0038: 0x0000000000000000   
─── code:x86:64 ────   
     0x69763b <mutate_name+1419> call   0x4f85a0 <__ubsan::__ubsan_handle_type_mismatch_v1(__ubsan::TypeMismatchData*,  __ubsan::ValueHandle)>   
     0x697640 <mutate_name+1424> mov    rax, QWORD PTR [rbp-0xd8]   
     0x697647 <mutate_name+1431> shr    rax, 0x3   
=>   0x69764b <mutate_name+1435> mov    cl, BYTE PTR [rax+0x7fff8000]   
     0x697651 <mutate_name+1441> cmp    cl, 0x0   
     0x697654 <mutate_name+1444> mov    BYTE PTR [rbp-0xe1], cl   
     0x69765a <mutate_name+1450> je     0x697687 <mutate_name+1495>   
     0x697660 <mutate_name+1456> mov    rax, QWORD PTR [rbp-0xd8]   
     0x697667 <mutate_name+1463> and    rax, 0x7   
─── source:rehash.c+329 ────   
    324         if ((i == (int)l - 1) || (str[i] != '~')) {   
    325                 if (((l-1) & 3) < 2)   
    326                         l += 2;   
    327                 else   
    328                         l = (l+3) & ~3;   
           // str=0x00007fffffffb4b0  →  [...]  →  0x0000c00040404040 ("@@@@"?), l=0x0   
 →  329                 str[l-2] = '~';   
    330                 str[l-1] = '0';   
    331                 *len = l;   
    332                 return;   
    333         }   
    334         for (i = l-1; i >= 0; i--) {   
── threads ────   
[#0] Id 1, Name: "asan_e2fsck_1_4", stopped 0x69764b in mutate_name (), reason: SIGSEGV   
── trace ────   
[#0] 0x69764b → mutate_name(str=0x7fffffffb590 "H\n", len=0x7fffffffb6d0)   
[#1] 0x68c0d3 → duplicate_search_and_fix(ctx=0x619000000080, fs=0x613000000040, ino=0x2d, fd=0x7fffffffbce0)   
[#2] 0x68534f → e2fsck_rehash_dir(ctx=0x619000000080, ino=0x2d, pctx=0x7fffffffc200)   
[#3] 0x6960e9 → e2fsck_rehash_directories(ctx=0x619000000080)   
[#4] 0x5f36a5 → e2fsck_pass3(ctx=0x619000000080)   
[#5] 0x52e91b → e2fsck_run(ctx=0x619000000080)   
[#6] 0x506e8f → main(argc=0x3, argv=0x7fffffffe368)   
--------------------------------------------   
0x000000000069764b in mutate_name (str=0x7fffffffb590 "H\n", len=0x7fffffffb6d0) at rehash.c:329   
329                     str[l-2] = '~';   

Timeline

2019-12-18 - Vendor Disclosure
2019-12-20 - Vendor acknowledged
2020-01-07 - Public Release

Credit

Discovered by Lilith [^_^] of Cisco Talos.