CVE-2019-5188
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.
E2fsprogs 1.43.3 - 1.45.4
http://e2fsprogs.sourceforge.net/
7.5 - CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:C/C:H/I:H/A:H
CWE-787: Out-of-bounds Write
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.
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] = '~';
2019-12-18 - Vendor Disclosure
2019-12-20 - Vendor acknowledged
2020-01-07 - Public Release
Discovered by Lilith [^_^] of Cisco Talos.