Skip to content

libext: fix heap overflow in ext_readlink and ext_readdir from a crafted ext4 image - #1449

Open
gburd wants to merge 1 commit into
cloudius-systems:masterfrom
gburd:pr/sec-ext4-readlink
Open

libext: fix heap overflow in ext_readlink and ext_readdir from a crafted ext4 image#1449
gburd wants to merge 1 commit into
cloudius-systems:masterfrom
gburd:pr/sec-ext4-readlink

Conversation

@gburd

@gburd gburd commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

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.cc did:

uint32_t block_size = ext4_sb_get_block_size(&fs->sb);
void *buf = malloc(block_size);                              // e.g. 4096 bytes
ext_internal_read(fs, &inode_ref._ref, uio->uio_offset, buf, fsize, &read_count);
//                                                          ^^^^^ up to 2^64-1

fsize = ext4_inode_get_size() is the raw 64-bit inode size read straight off disk — fully attacker-controlled and not clamped to block_size. ext_internal_read then writes ~fsize bytes into the block_size heap buffer.

PoC (verified with debugfs): craft an ext4 image with a symlink inode whose i_size = 0x100000 (1 MiB) and i_blocks != 0 (so the slow-symlink else branch is taken). readlink() — or any path traversal through the link — → malloc(4096) then a ~1 MiB write into it → heap corruption. Also uio_offset > fsize underflows fsize - offset inside ext_internal_read.

Fix: a symlink target is at most one block (bounded by PATH_MAX); reject fsize > block_size, guard uio_offset >= fsize, check malloc.

2. ext_readdir() d_name[256] overflow (High, old-rev image)

uint16_t name_length = ext4_dir_en_get_name_len(&fs->sb, it.curr);
memcpy(dir->d_name, it.curr->name, name_length);   // d_name is 256 bytes

On a rev-0 (minor<5) image, ext4_dir_en_get_name_len folds in name_length_high and can return up to block_size-8 (~4088) → ~4 KB memcpy into the 256-byte d_name. Fix: clamp to sizeof(d_name)-1.

Verification

With debugfs-crafted images: a normal symlink still reads back correctly (new tst-ext-readlink regression), and a symlink inode with i_size=1MiB now returns EINVAL instead 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.

@gburd

gburd commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Related upstream note: while auditing the ext/ext4 image-trust surface I also found an out-of-bounds read in the vendored lwext4 (osvunikernel/lwext4, cloned by modules/lwext4/Makefile): ext4_ext_check() in src/ext4_extent.c validates eh_entries <= eh_max but never bounds eh_max/eh_entries to the block capacity, so a crafted extent header (e.g. eh_max=eh_entries=0xFFFF) drives find_ext4_extent_tail() and the extent binary search hundreds of KiB past the one-block buffer on any read of that file.

Since osvunikernel/lwext4 has issues disabled, I reported it against the original upstream: gkostka/lwext4#100 (with a repro and a suggested fix porting Linux's ext4_valid_extent_entries() capacity bound). Flagging here so it's on the OSv radar for the osvunikernel/lwext4 fork — it's outside this PR's scope (this PR fixes the OSv-side ext_readlink/ext_readdir overflows), but it's the same 'crafted ext image' threat surface.

@gburd
gburd force-pushed the pr/sec-ext4-readlink branch from 13fe54a to 4d81010 Compare July 15, 2026 13:46
… 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_name size in ext_readdir().
  • Add size/offset/malloc guards in ext_readlink() to prevent heap overflow/underflow on attacker-controlled inode metadata.
  • Add a new tst-ext-readlink test 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-tests so 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.

Comment on lines +1360 to +1364
// 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;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread modules/tests/Makefile
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 \

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread tests/tst-ext-readlink.cc
Comment on lines +9 to +33
// 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;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants