From 63444df46d614446ae553b22f12dba13866d433e Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Tue, 14 Jul 2026 08:43:37 -0400 Subject: [PATCH] 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. --- modules/libext/ext_vnops.cc | 24 ++++++++++++++++++++++++ modules/tests/Makefile | 2 +- tests/tst-ext-readlink.cc | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 tests/tst-ext-readlink.cc diff --git a/modules/libext/ext_vnops.cc b/modules/libext/ext_vnops.cc index 063fe1e46..f91f687e2 100644 --- a/modules/libext/ext_vnops.cc +++ b/modules/libext/ext_vnops.cc @@ -635,6 +635,13 @@ ext_readdir(struct vnode *dvp, struct file *fp, struct dirent *dir) if (ext4_dir_en_get_inode(it.curr) != 0) { memset(dir->d_name, 0, sizeof(dir->d_name)); uint16_t name_length = ext4_dir_en_get_name_len(&fs->sb, it.curr); + // On an old-rev (rev0, minor<5) image ext4_dir_en_get_name_len can + // fold in name_length_high and return up to block_size-8 (~4 KiB), + // while d_name is only sizeof(dir->d_name) (256). Clamp so a + // crafted directory entry cannot overflow the fixed d_name buffer. + if (name_length >= sizeof(dir->d_name)) { + name_length = sizeof(dir->d_name) - 1; + } memcpy(dir->d_name, it.curr->name, name_length); ext_debug("readdir directory with i-node=%ld at offset:%ld => entry name:%s\n", dvp->v_ino, file_offset(fp), dir->d_name); @@ -1341,7 +1348,24 @@ ext_readlink(vnode_t *vp, uio_t *uio) return uiomove(content, fsize, uio); } else { uint32_t block_size = ext4_sb_get_block_size(&fs->sb); + // A symlink target is stored in at most one block (and is bounded by + // PATH_MAX); a slow symlink never spans more. The inode size is read + // straight off disk and is fully attacker-controlled for a crafted + // image, so clamp it to block_size before reading into the block_size + // buffer -- otherwise ext_internal_read() would write @fsize bytes + // (up to 2^64-1) into a block_size heap allocation (heap overflow). + if (fsize > block_size) { + return EINVAL; // malformed symlink inode + } + // Also guard the read start: ext_internal_read subtracts offset from + // fsize, so an offset past EOF would underflow the read length. + if ((uint64_t)uio->uio_offset >= fsize) { + return 0; + } void *buf = malloc(block_size); + if (!buf) { + return ENOMEM; + } size_t read_count = 0; int ret = ext_internal_read(fs, &inode_ref._ref, uio->uio_offset, buf, fsize, &read_count); if (ret) { diff --git a/modules/tests/Makefile b/modules/tests/Makefile index 1eb3df46e..3bce52b40 100644 --- a/modules/tests/Makefile +++ b/modules/tests/Makefile @@ -115,7 +115,7 @@ ext-only-tests := tst-readdir.so tst-concurrent-read.so tst-fs-link.so specific-fs-tests := $($(fs_type)-only-tests) -tests := tst-pthread.so misc-ramdisk.so tst-vblk.so tst-mq-smoke.so tst-bsd-evh.so \ +tests := tst-ext-readlink.so tst-pthread.so misc-ramdisk.so tst-vblk.so tst-mq-smoke.so tst-bsd-evh.so \ misc-bsd-callout.so tst-bsd-kthread.so tst-bsd-taskqueue.so \ tst-fpu.so tst-preempt.so tst-tracepoint.so tst-hub.so \ misc-console.so misc-leak.so misc-readbench.so misc-mmap-anon-perf.so \ diff --git a/tests/tst-ext-readlink.cc b/tests/tst-ext-readlink.cc new file mode 100644 index 000000000..8167f5d66 --- /dev/null +++ b/tests/tst-ext-readlink.cc @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 Greg Burd + * + * This work is open source software, licensed under the terms of the + * BSD license as described in the LICENSE file in the top-level directory. + */ + +// Regression test for the ext_readlink hardening: a normal symlink must still +// read back correctly (fast + slow), and the bounds guards must not break it. +#include +#include +#include +#include + +int main(int argc, char** argv) +{ + const char* link = (argc > 1) ? argv[1] : "/data/mylink"; + char buf[256]; + memset(buf, 0, sizeof(buf)); + ssize_t n = readlink(link, buf, sizeof(buf) - 1); + if (n < 0) { + fprintf(stderr, "readlink(%s) FAILED: %s\n", link, strerror(errno)); + return 1; + } + buf[n] = 0; + fprintf(stderr, "readlink(%s) = '%s' (%zd bytes)\n", link, buf, n); + if (strcmp(buf, "realfile") != 0) { + fprintf(stderr, "READLINK MISMATCH: expected 'realfile'\n"); + return 1; + } + fprintf(stderr, "ext-readlink regression PASSED\n"); + return 0; +}