libext: fix heap overflow in ext_readlink and ext_readdir from a crafted ext4 image - #1449
libext: fix heap overflow in ext_readlink and ext_readdir from a crafted ext4 image#1449gburd wants to merge 1 commit into
Conversation
|
Related upstream note: while auditing the ext/ext4 image-trust surface I also found an out-of-bounds read in the vendored lwext4 ( Since |
13fe54a to
4d81010
Compare
… 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.
4d81010 to
63444df
Compare
There was a problem hiding this comment.
Pull request overview
This PR hardens OSv’s ext4 VFS glue (libext) against crafted ext4 images that could previously trigger kernel heap corruption via oversized symlink inode sizes (ext_readlink) and oversized directory entry names (ext_readdir), and adds a regression test intended to validate the ext_readlink hardening.
Changes:
- Clamp ext4 directory entry name length to the fixed
dirent::d_namesize inext_readdir(). - Add size/offset/malloc guards in
ext_readlink()to prevent heap overflow/underflow on attacker-controlled inode metadata. - Add a new
tst-ext-readlinktest and wire it into the test build.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| modules/libext/ext_vnops.cc | Adds bounds checks in ext_readdir() and hardens ext_readlink() against crafted inode metadata. |
| tests/tst-ext-readlink.cc | Introduces a regression test for ext_readlink() behavior. |
| modules/tests/Makefile | Registers the new test in the test build/manifest inputs. |
Suppressed comments (1)
modules/tests/Makefile:118
- If tst-ext-readlink.so is meant to validate libext behavior, add it to
ext-only-testsso it is included (and executed) only when building/running the ext test image (fs_type=ext).
tests := tst-ext-readlink.so tst-pthread.so misc-ramdisk.so tst-vblk.so tst-mq-smoke.so tst-bsd-evh.so \
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 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; | ||
| } |
There was a problem hiding this comment.
The fast-symlink path stores the target inline in the inode (a fixed <60-byte field) and cannot be driven past that bound by uio_offset, so the overflow class the guard defends against does not exist there; the slow path reads attacker-influenced block contents, which is why the guard lives there. I will add the same offset clamp to the fast path anyway for consistency and defense-in-depth in the next revision.
| 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 \ |
There was a problem hiding this comment.
Will move tst-ext-readlink.so out of the unconditional tests list; it is ext-specific and should only run with an ext disk mounted, like tst-ext4-rw.
| // read back correctly (fast + slow), and the bounds guards must not break it. | ||
| #include <unistd.h> | ||
| #include <string.h> | ||
| #include <stdio.h> | ||
| #include <errno.h> | ||
|
|
||
| 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; | ||
| } |
There was a problem hiding this comment.
The test already skips (returns success) when its ext prerequisite is absent rather than hard-failing; I will make the missing-prerequisite path explicit and self-contained (create the symlink under the mounted ext dir rather than assuming a fixed /data/mylink) in the next revision.
Security fix — Critical: kernel heap overflow from mounting a crafted ext4 image (a realistic threat for attached volumes / multi-tenant hosts; in a unikernel this is kernel heap corruption → potential RCE).
1.
ext_readlink()heap buffer overflow (Critical)For a slow symlink,
modules/libext/ext_vnops.ccdid:fsize = ext4_inode_get_size()is the raw 64-bit inode size read straight off disk — fully attacker-controlled and not clamped toblock_size.ext_internal_readthen writes ~fsizebytes into theblock_sizeheap buffer.PoC (verified with debugfs): craft an ext4 image with a symlink inode whose
i_size = 0x100000(1 MiB) andi_blocks != 0(so the slow-symlinkelsebranch is taken).readlink()— or any path traversal through the link — →malloc(4096)then a ~1 MiB write into it → heap corruption. Alsouio_offset > fsizeunderflowsfsize - offsetinsideext_internal_read.Fix: a symlink target is at most one block (bounded by PATH_MAX); reject
fsize > block_size, guarduio_offset >= fsize, checkmalloc.2.
ext_readdir()d_name[256]overflow (High, old-rev image)On a rev-0 (minor<5) image,
ext4_dir_en_get_name_lenfolds inname_length_highand can return up toblock_size-8(~4088) → ~4 KB memcpy into the 256-byted_name. Fix: clamp tosizeof(d_name)-1.Verification
With debugfs-crafted images: a normal symlink still reads back correctly (new
tst-ext-readlinkregression), and a symlink inode withi_size=1MiBnow returnsEINVALinstead of overflowing the heap.Severity
#1 Critical (CVSS ~8.4, AV:L/needs mounted crafted image/C:H/I:H/A:H — RCE-class). #2 High.
Both bugs are in shipped
master(the OSv ext/lwext4 glue), not new code.