ext4: validate readdir offset before accessing dirent
PATCH 1/1 ext4: validate readdir offset before accessing dirent
From: Yao Kai <[email protected]>
commit bc4b7b0414c33b2c8898eb04386df0d21a13dad8 upstream.
A corrupted directory can trigger the following KASAN report when ext4_readdir() resumes from an invalid position:
BUG: KASAN: use-after-free in __ext4_check_dir_entry+0x5ef/0x820 Read of size 2 at addr ffff88810a646000 by task repro_linear/509 Call Trace: <TASK> dump_stack_lvl+0x53/0x70 print_report+0xd0/0x630 kasan_report+0xce/0x100 __ext4_check_dir_entry+0x5ef/0x820 ext4_readdir+0xcde/0x2b70 iterate_dir+0x1a1/0x520 __x64_sys_getdents64+0x12b/0x220 do_syscall_64+0xf9/0x540 entry_SYSCALL_64_after_hwframe+0x77/0x7f </TASK>
KASAN reports use-after-free because the out-of-bounds access lands in an adjacent freed page. The directory buffer itself is still referenced.
ext4_dir_llseek() invalidates the directory cookie so that ext4_readdir() rescans directory entries from the start of the block. The rescan checks only the lower bound of rec_len before advancing. A corrupted rec_len can therefore place the offset where the block has insufficient space for a complete directory entry. The rescan itself may dereference that truncated entry, or the main loop may pass it to __ext4_check_dir_entry(). The latter reads de->rec_len before validating the range. For example:
block offset 0 4092 4096
|---- de1.rec_len = 4092 -----|----|
de2.inode
| de2.rec_len
^ OOB, reported as UAFde2 starts at offset 4092 in this 4 KiB block. Its four-byte inode fits in the block, but its rec_len starts at offset 4096 and crosses the boundary.
The minimum safe length is inode-dependent. Encrypted and casefolded directory entries need eight additional hash bytes, while a valid metadata checksum tail is only 12 bytes.
Cache the metadata checksum feature state and derive the minimum directory entry length from the on-disk format. Use it to bound both the rescan and the offset passed to the main loop. Report an offset in a truncated block tail and skip the remainder of the block, while continuing to accept an offset exactly at the block boundary.
--- fs/ext4/dir.c | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-)
fs/ext4/dir.c +18 -2
@@ -137,6 +137,7 @@ static int ext4_readdir(struct file *file, struct dir_context *ctx)struct buffer_head *bh =NULL ;struct fscrypt_str fstr =FSTR_INIT (NULL ,0 );struct dir_private_info *info = file->private_data; +bool has_csum =ext4_has_metadata_csum (sb); err =fscrypt_prepare_readdir (inode);if (err)
@@ -148,7 +149,7 @@ static int ext4_readdir(struct file *file, struct dir_context *ctx)return err;/* Can we just clear INDEX flag to ignore htree information? */ -if (!ext4_has_metadata_csum (sb)) { +if (!has_csum) {/* * We don't set the inode dirty flag since it's not * critical that it gets flushed back to the disk.
@@ -234,7 +235,10 @@ static int ext4_readdir(struct file *file, struct dir_context *ctx)* dirent right now. Scan from the start of the block * to make sure. */ if (!inode_eq_iversion (inode, info->cookie)) { -for (i =0 ; i < sb->s_blocksize && i < offset; ) { +for (i =0 ; + i <= sb->s_blocksize - +ext4_dir_rec_len (1 , has_csum ?NULL : inode) && + i < offset;) { de = (struct ext4_dir_entry_2 *) (bh->b_data + i);/* It's too expensive to do a full
@@ -256,6 +260,17 @@ static int ext4_readdir(struct file *file, struct dir_context *ctx) info->cookie =inode_query_iversion (inode); } +if (unlikely (offset < sb->s_blocksize && + offset > sb->s_blocksize - +ext4_dir_rec_len (1 , has_csum ?NULL : inode))) { +EXT4_ERROR_FILE (file, bh->b_blocknr, +"bad entry in directory: %s - offset=%u, size=%lu" , +"directory entry too close to block end" , + offset, sb->s_blocksize); + ctx->pos =round_up (ctx->pos, sb->s_blocksize); + goto next_block; + } +while (ctx->pos < inode->i_size && offset < sb->s_blocksize) { de = (struct ext4_dir_entry_2 *) (bh->b_data + offset);
@@ -311,6 +326,7 @@ static int ext4_readdir(struct file *file, struct dir_context *ctx) ctx->pos +=ext4_rec_len_from_disk (de->rec_len, sb->s_blocksize); } +next_block:if ((ctx->pos < inode->i_size) && !dir_relax_shared (inode)) goto done;brelse (bh);
base-commit: 85d82e0d7c2dfd3e75d34f35b7ebbf41f45c9c4e
Signature · 1 line
2.56.0
[email protected]
AI reviewThank you for your contribution! Sashiko AI review found 1 potential issue(s) to consider: - [Medium] ext4: unvalidated rescan rec_len in ext4_readdir() bypasses tail check --
Thank you for your contribution! Sashiko AI review found 1 potential issue(s) to consider:
- [Medium] ext4: unvalidated rescan rec_len in ext4_readdir() bypasses tail check --
commit f6671320411a20d7f17d0b2fabcaf53f7a52ec83
Author: Yao Kai <[email protected]>
ext4: validate readdir offset before accessing dirent
This commit bounds the directory entry rescan loop in ext4_readdir() using the minimum directory entry record length and validates that offset does not land in a truncated block tail before entering the main entry-processing loop. If offset falls into the trailing bytes at the end of the block, it logs a directory error and advances ctx->pos to the next block.
Quoted 4 lines
fs/ext4/dir.c +0
-0
[ ... ]
Quoted 16 lines
fs/ext4/dir.c +4
-1@@ -234,7 +235,10 @@ static int ext4_readdir(struct file *file, struct dir_context *ctx)* dirent right now. Scan from the start of the block * to make sure. */ if (!inode_eq_iversion (inode, info->cookie)) { -for (i =0 ; i < sb->s_blocksize && i < offset; ) { +for (i =0 ; + i <= sb->s_blocksize - +ext4_dir_rec_len (1 , has_csum ?NULL : inode) && + i < offset;) { de = (struct ext4_dir_entry_2 *) (bh->b_data + i);/* It's too expensive to do a full @@ -256,6 +260,17 @@ static int ext4_readdir(struct file *file, struct dir_context *ctx) info->cookie =inode_query_iversion (inode); }
fs/ext4/dir.c static int ext4_readdir(struct file *file, struct dir_context *ctx) · line 272
Quoted 10 lines
fs/ext4/dir.c +10
-0+if (unlikely (offset < sb->s_blocksize && + offset > sb->s_blocksize - +ext4_dir_rec_len (1 , has_csum ?NULL : inode))) { +EXT4_ERROR_FILE (file, bh->b_blocknr, +"bad entry in directory: %s - offset=%u, size=%lu" , +"directory entry too close to block end" , + offset, sb->s_blocksize); + ctx->pos =round_up (ctx->pos, sb->s_blocksize); + goto next_block; + }
[Severity: Medium]
What happens in ext4_readdir() if a corrupted de->rec_len in the rescan loop advances i past sb->s_blocksize?
In fs/ext4/dir.c:ext4_readdir(), when !inode_eq_iversion(inode, info->cookie) triggers a rescan (for instance after ext4_dir_llseek()), the loop only checks the lower bound of de->rec_len before adding it to i and updating ctx->pos:
if (!inode_eq_iversion(inode, info->cookie)) {
for (i = 0;
i <= sb->s_blocksize -
ext4_dir_rec_len(1, has_csum ? NULL : inode) &&
i < offset;) {
...
if (ext4_rec_len_from_disk(de->rec_len,
sb->s_blocksize) < ext4_dir_rec_len(1,
inode))
break;
i += ext4_rec_len_from_disk(de->rec_len,
sb->s_blocksize);
}
offset = i;
ctx->pos = (ctx->pos & ~(sb->s_blocksize - 1))
| offset;
info->cookie = inode_query_iversion(inode);
}Because de->rec_len is a 16-bit on-disk value, a corrupted entry can set offset > sb->s_blocksize. That value is bitwise-ORed into ctx->pos, which corrupts the upper block-number bits of ctx->pos, and info->cookie is updated to match inode->i_version.
Then, because the new tail check requires offset < sb->s_blocksize:
if (unlikely(offset < sb->s_blocksize &&
offset > sb->s_blocksize -
ext4_dir_rec_len(1, has_csum ? NULL : inode))) {and the subsequent main loop also checks offset < sb->s_blocksize, an oversized offset bypasses both EXT4_ERROR_FILE() and ext4_check_dir_entry() on the corrupted block. On the next iteration of the outer loop, ext4_readdir() will read the block corresponding to the corrupted ctx->pos at an arbitrary mid-block offset = ctx->pos & (sb->s_blocksize - 1) without rescanning, since info->cookie now matches inode->i_version.
Should the tail check (or the rescan loop) also catch offset > sb->s_blocksize before bitwise-ORing offset into ctx->pos and validating info->cookie?
Signature · 1 line
Sashiko AI review · https://sashiko.dev/#/patchset/[email protected]?part=1
[+Cc recipients that Sashiko dropped]
Should the Sashiko configuration for linux-ext4 be updated to enable reply-all? That would be my preference, and various other lists already use that setting.
Quoted 7 lines · On Wed, Oct 28, 2026 at 01:02:25AM +0000, [email protected] wrote:
[Severity: Medium]
What happens inext4_readdir()if a corruptedde->rec_lenin the rescan loop advances i pastsb->s_blocksize?In fs/ext4/dir.c:ext4_readdir(), when !inode_eq_iversion(inode,
info->cookie) triggers a rescan (for instance afterext4_dir_llseek()), the loop only checks the lower bound ofde->rec_lenbefore adding it to i and updatingctx->pos:
Sounds like this fix was incomplete but the backport is still safe.
- Eric