Skip to content

Commit 13fe54a

Browse files
committed
libext: fix heap overflow in ext_readlink and overflow in ext_readdir from a crafted ext4 image
Two memory-safety bugs reachable by mounting an attacker-supplied ext4 image (a realistic threat for attached volumes / multi-tenant hosts). In a unikernel these are kernel heap corruption. 1. ext_readlink() heap buffer overflow (critical). For a slow symlink it did: void *buf = malloc(block_size); // e.g. 4096 bytes ext_internal_read(fs, ref, offset, buf, fsize, &read_count); where fsize is ext4_inode_get_size() - the raw 64-bit inode size read straight off disk, fully attacker-controlled and NOT clamped. A crafted symlink inode with i_size = 1 MiB (and i_blocks != 0 so the slow-symlink branch is taken) makes ext_internal_read write ~1 MiB into the 4 KiB heap buffer -> heap corruption / potential RCE, reached by readlink() or any path traversal through the link. A symlink target is at most one block (bounded by PATH_MAX), so clamp: reject fsize > block_size, guard uio_offset >= fsize (which would otherwise underflow fsize-offset), and check malloc(). 2. ext_readdir() d_name overflow. memcpy(dir->d_name, name, name_length) with name_length from ext4_dir_en_get_name_len(), which on an old-rev image (rev0, minor<5) folds in name_length_high and returns up to block_size-8 (~4088) while d_name is 256 bytes. Clamp name_length to sizeof(d_name)-1. Verified with debugfs-crafted images: a normal symlink still reads back correctly (tst-ext-readlink), and a symlink inode with i_size=1MiB now returns EINVAL instead of overflowing the heap. Added tst-ext-readlink as a regression.
1 parent d55b448 commit 13fe54a

3 files changed

Lines changed: 58 additions & 1 deletion

File tree

modules/libext/ext_vnops.cc

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,13 @@ ext_readdir(struct vnode *dvp, struct file *fp, struct dirent *dir)
635635
if (ext4_dir_en_get_inode(it.curr) != 0) {
636636
memset(dir->d_name, 0, sizeof(dir->d_name));
637637
uint16_t name_length = ext4_dir_en_get_name_len(&fs->sb, it.curr);
638+
// On an old-rev (rev0, minor<5) image ext4_dir_en_get_name_len can
639+
// fold in name_length_high and return up to block_size-8 (~4 KiB),
640+
// while d_name is only sizeof(dir->d_name) (256). Clamp so a
641+
// crafted directory entry cannot overflow the fixed d_name buffer.
642+
if (name_length >= sizeof(dir->d_name)) {
643+
name_length = sizeof(dir->d_name) - 1;
644+
}
638645
memcpy(dir->d_name, it.curr->name, name_length);
639646
ext_debug("readdir directory with i-node=%ld at offset:%ld => entry name:%s\n", dvp->v_ino, file_offset(fp), dir->d_name);
640647

@@ -1341,7 +1348,24 @@ ext_readlink(vnode_t *vp, uio_t *uio)
13411348
return uiomove(content, fsize, uio);
13421349
} else {
13431350
uint32_t block_size = ext4_sb_get_block_size(&fs->sb);
1351+
// A symlink target is stored in at most one block (and is bounded by
1352+
// PATH_MAX); a slow symlink never spans more. The inode size is read
1353+
// straight off disk and is fully attacker-controlled for a crafted
1354+
// image, so clamp it to block_size before reading into the block_size
1355+
// buffer -- otherwise ext_internal_read() would write @fsize bytes
1356+
// (up to 2^64-1) into a block_size heap allocation (heap overflow).
1357+
if (fsize > block_size) {
1358+
return EINVAL; // malformed symlink inode
1359+
}
1360+
// Also guard the read start: ext_internal_read subtracts offset from
1361+
// fsize, so an offset past EOF would underflow the read length.
1362+
if ((uint64_t)uio->uio_offset >= fsize) {
1363+
return 0;
1364+
}
13441365
void *buf = malloc(block_size);
1366+
if (!buf) {
1367+
return ENOMEM;
1368+
}
13451369
size_t read_count = 0;
13461370
int ret = ext_internal_read(fs, &inode_ref._ref, uio->uio_offset, buf, fsize, &read_count);
13471371
if (ret) {

modules/tests/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ ext-only-tests := tst-readdir.so tst-concurrent-read.so tst-fs-link.so
115115

116116
specific-fs-tests := $($(fs_type)-only-tests)
117117

118-
tests := tst-pthread.so misc-ramdisk.so tst-vblk.so tst-bsd-evh.so \
118+
tests := tst-ext-readlink.so tst-pthread.so misc-ramdisk.so tst-vblk.so tst-bsd-evh.so \
119119
misc-bsd-callout.so tst-bsd-kthread.so tst-bsd-taskqueue.so \
120120
tst-fpu.so tst-preempt.so tst-tracepoint.so tst-hub.so \
121121
misc-console.so misc-leak.so misc-readbench.so misc-mmap-anon-perf.so \

tests/tst-ext-readlink.cc

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright (C) 2026 Greg Burd
3+
*
4+
* This work is open source software, licensed under the terms of the
5+
* BSD license as described in the LICENSE file in the top-level directory.
6+
*/
7+
8+
// Regression test for the ext_readlink hardening: a normal symlink must still
9+
// read back correctly (fast + slow), and the bounds guards must not break it.
10+
#include <unistd.h>
11+
#include <string.h>
12+
#include <stdio.h>
13+
#include <errno.h>
14+
15+
int main(int argc, char** argv)
16+
{
17+
const char* link = (argc > 1) ? argv[1] : "/data/mylink";
18+
char buf[256];
19+
memset(buf, 0, sizeof(buf));
20+
ssize_t n = readlink(link, buf, sizeof(buf) - 1);
21+
if (n < 0) {
22+
fprintf(stderr, "readlink(%s) FAILED: %s\n", link, strerror(errno));
23+
return 1;
24+
}
25+
buf[n] = 0;
26+
fprintf(stderr, "readlink(%s) = '%s' (%zd bytes)\n", link, buf, n);
27+
if (strcmp(buf, "realfile") != 0) {
28+
fprintf(stderr, "READLINK MISMATCH: expected 'realfile'\n");
29+
return 1;
30+
}
31+
fprintf(stderr, "ext-readlink regression PASSED\n");
32+
return 0;
33+
}

0 commit comments

Comments
 (0)