From: sashiko-bot@kernel.org
To: "Artem Blagodarenko" <artem.blagodarenko@gmail.com>,
linux-ext4@vger.kernel.org
Cc: tytso@mit.edu
Subject: Re: [PATCH v13 10/11] ext4: add dirdata set/get helpers
Date: Thu, 29 Oct 2026 12:44:46 +0000 [thread overview]
Message-ID: <sashiko-outbox-189303@kernel.org> (raw)
In-Reply-To: <20261029122413.230409-11-ablagodarenko@thelustrecollective.com>
Thank you for your contribution! Sashiko AI review found 2 potential issue(s) to consider:
- [High] ext4: out-of-bounds read in EXT4_DIRENT_HASH() fallback paths
- [High] ext4: LUFID and CFHASH corruption in ext4_setent() on whiteout rollback
--
commit 539752e69c673cf26e5b8cfb8fcd2a83e0a00a76
Author: Artem Blagodarenko <artem.blagodarenko@gmail.com>
ext4: add dirdata set/get helpers
This commit adds ext4_dirdata_get() and ext4_dirdata_set() helpers to read and
write directory entry extension payloads, and wires them into directory lookup,
readdir, htree indexing, insertion, and rename paths for LUFID and
casefold+encryption hashes.
> diff --git a/fs/ext4/dir.c b/fs/ext4/dir.c
> index 15816f2a5f472..12df2d2a09978 100644
> --- a/fs/ext4/dir.c
> +++ b/fs/ext4/dir.c
> @@ -302,8 +302,19 @@ static int ext4_readdir(struct file *file, struct dir_context *ctx)
> u32 minor_hash;
>
> if (IS_CASEFOLDED(inode)) {
> - hash = EXT4_DIRENT_HASH(de);
> - minor_hash = EXT4_DIRENT_MINOR_HASH(de);
> + if (ext4_has_feature_dirdata(sb) &&
> + (de->file_type &
> + EXT4_DIRENT_CFHASH)) {
> + struct dx_hash_info hi = {};
> + ext4_dirdata_get(de, inode,
> + sb->s_blocksize,
> + NULL, &hi);
> + hash = hi.hash;
> + minor_hash = hi.minor_hash;
> + } else {
> + hash = EXT4_DIRENT_HASH(de);
> + minor_hash = EXT4_DIRENT_MINOR_HASH(de);
> + }
> } else {
> hash = 0;
> minor_hash = 0;
[ ... ]
> diff --git a/fs/ext4/inline.c b/fs/ext4/inline.c
> index d3dda1d65d067..700a0282278b0 100644
> --- a/fs/ext4/inline.c
> +++ b/fs/ext4/inline.c
> @@ -1364,14 +1364,20 @@ int ext4_inlinedir_to_tree(struct file *dir_file,
> pos += de_len;
> }
>
> - if (ext4_hash_in_dirent(dir)) {
> - hinfo->hash = EXT4_DIRENT_HASH(de);
> - hinfo->minor_hash = EXT4_DIRENT_MINOR_HASH(de);
> - } else {
> - err = ext4fs_dirhash(dir, de->name, de->name_len, hinfo);
> - if (err) {
> - ret = err;
> - goto out;
> + if (!(ext4_dirdata_get(de, dir, inline_size, NULL, hinfo) &
> + EXT4_DIRENT_CFHASH)) {
> + if (ext4_hash_in_dirent(dir)) {
> + /* Un-migrated entry: hash at legacy fixed
> + * offset. */
> + hinfo->hash = EXT4_DIRENT_HASH(de);
> + hinfo->minor_hash = EXT4_DIRENT_MINOR_HASH(de);
> + } else {
> + err = ext4fs_dirhash(dir, de->name,
> + de->name_len, hinfo);
> + if (err) {
> + ret = err;
> + goto out;
> + }
> }
> }
[ ... ]
> diff --git a/fs/ext4/namei.c b/fs/ext4/namei.c
> index 433bee757ea4c..5a118320eef9b 100644
> --- a/fs/ext4/namei.c
> +++ b/fs/ext4/namei.c
> @@ -1142,22 +1142,34 @@ static int htree_dirblock_to_tree(struct file *dir_file,
> /* silently ignore the rest of the block */
> break;
> }
> - if (ext4_hash_in_dirent(dir)) {
> - if (de->name_len && de->inode) {
> - hinfo->hash = EXT4_DIRENT_HASH(de);
> - hinfo->minor_hash = EXT4_DIRENT_MINOR_HASH(de);
> - } else {
> - hinfo->hash = 0;
> - hinfo->minor_hash = 0;
> - }
> + if (de->name_len && de->inode) {
> + /* check for saved hash first, or generate it from name */
> + if (!(ext4_dirdata_get(de, dir, dir->i_sb->s_blocksize,
> + NULL, hinfo) &
> + EXT4_DIRENT_CFHASH)) {
> + if (ext4_hash_in_dirent(dir)) {
> + /* Un-migrated entry: hash is at the
> + * legacy fixed offset, not in a CFHASH
> + * extension. Read it directly rather
> + * than hashing the encrypted name. */
> + hinfo->hash = EXT4_DIRENT_HASH(de);
> + hinfo->minor_hash =
> + EXT4_DIRENT_MINOR_HASH(de);
> + } else {
[ ... ]
> +unsigned char ext4_dirdata_get(struct ext4_dir_entry_2 *de, struct inode *dir,
> + int buf_size,
> + struct ext4_dirent_fid *dfid,
> + struct dx_hash_info *hinfo)
> +{
> + unsigned char ret = 0;
> + unsigned int data_offset = de->name_len + 1;
> + unsigned int rec_len = ext4_rec_len_from_disk(de->rec_len, buf_size);
> +
> + /* data_offset is relative to de->name, which itself starts
> + * EXT4_BASE_DIR_LEN bytes into the entry -- rec_len is relative to
> + * the start of the entry, so add the header size before comparing,
> + * or this lets reads run EXT4_BASE_DIR_LEN bytes past the entry. */
> + if (EXT4_BASE_DIR_LEN + data_offset > rec_len)
> + return ret;
> +
> + /* compatibility: hash stored inline after filename (no dirdata) */
> + if (hinfo && !ext4_has_feature_dirdata(dir->i_sb) &&
> + ext4_hash_in_dirent(dir)) {
> + hinfo->hash = EXT4_DIRENT_HASH(de);
> + hinfo->minor_hash = EXT4_DIRENT_MINOR_HASH(de);
> + ret |= EXT4_DIRENT_CFHASH;
> + return ret;
> + }
[ ... ]
> @@ -1360,13 +1568,21 @@ static int dx_make_map(struct inode *dir, struct buffer_head *bh,
> ((char *)de) - base))
> return -EFSCORRUPTED;
> if (de->name_len && de->inode) {
> - if (ext4_hash_in_dirent(dir))
> - h.hash = EXT4_DIRENT_HASH(de);
> - else {
> - int err = ext4fs_dirhash(dir, de->name,
> - de->name_len, &h);
> - if (err < 0)
> - return err;
> + if (!(ext4_dirdata_get(de, dir, dir->i_sb->s_blocksize,
> + NULL, &h) &
> + EXT4_DIRENT_CFHASH)) {
> + if (ext4_hash_in_dirent(dir)) {
> + /* Un-migrated entry: hash at legacy
> + * fixed offset. */
> + h.hash = EXT4_DIRENT_HASH(de);
> + h.minor_hash =
> + EXT4_DIRENT_MINOR_HASH(de);
> + } else {
[ ... ]
> @@ -1495,10 +1712,29 @@ static bool ext4_match(struct inode *parent,
> * considering the calculated hash.
> */
> if (sb_no_casefold_compat_fallback(parent->i_sb) &&
> - IS_ENCRYPTED(parent) && fname->cf_name.name &&
> - (fname->hinfo.hash != EXT4_DIRENT_HASH(de) ||
> - fname->hinfo.minor_hash != EXT4_DIRENT_MINOR_HASH(de)))
> - return false;
> + IS_ENCRYPTED(parent) && fname->cf_name.name) {
> + __u32 de_hash, de_minor_hash;
> +
> + if (ext4_has_feature_dirdata(parent->i_sb) &&
> + (de->file_type & EXT4_DIRENT_CFHASH)) {
> + /* Hash is in a CFHASH extension at a variable
> + * offset (past any LUFID bytes). Read it via
> + * ext4_dirdata_get() to get the correct offset. */
> + struct dx_hash_info dirent_hinfo = {};
> +
> + ext4_dirdata_get(de, parent,
> + buf_size,
> + NULL, &dirent_hinfo);
> + de_hash = dirent_hinfo.hash;
> + de_minor_hash = dirent_hinfo.minor_hash;
> + } else {
> + de_hash = EXT4_DIRENT_HASH(de);
> + de_minor_hash = EXT4_DIRENT_MINOR_HASH(de);
> + }
> + if (fname->hinfo.hash != de_hash ||
> + fname->hinfo.minor_hash != de_minor_hash)
> + return false;
> + }
> /*
> * Treat comparison errors as not a match. The
> * only case where it happens is on a disk
> @@ -1529,11 +1765,18 @@ int ext4_search_dir(struct buffer_head *bh, char *search_buf, int buf_size,
> dlimit = search_buf + buf_size;
> while ((char *) de < dlimit - EXT4_BASE_DIR_LEN) {
> /* this code is executed quadratically often */
> - /* do minimal checking `by hand' */
> + /* Decode rec_len before ext4_match() so that ext4_dirdata_get()
> + * inside ext4_match() never uses an unvalidated rec_len as a
> + * read bound. A crafted entry with rec_len > buf_size would
> + * let ext4_dirdata_get() read past the block buffer. */
> + de_len = ext4_rec_len_from_disk(de->rec_len,
> + dir->i_sb->s_blocksize);
> + if (de_len <= 0 || (char *)de + de_len > dlimit)
> + return -EFSCORRUPTED;
> if (de->name + de->name_len <= dlimit &&
> (!ext4_hash_in_dirent(dir) ||
> (char *)de + ext4_dirent_rec_len(de->name_len, dir) <= dlimit) &&
> - ext4_match(dir, fname, de)) {
> + ext4_match(dir, fname, de, buf_size)) {
[Severity: High]
Can the EXT4_DIRENT_HASH(de) and EXT4_DIRENT_MINOR_HASH(de) fallback paths
read 8 bytes past the end of bh->b_data or dir_buf?
In fs/ext4/ext4.h:ext4_dirent_rec_len(), when EXT4_FEATURE_INCOMPAT_DIRDATA
is enabled on a casefolded and encrypted directory, sizeof(struct
ext4_dir_entry_hash) is not added to rec_len:
if (dir && ext4_hash_in_dirent(dir) &&
!ext4_has_feature_dirdata(dir->i_sb))
rec_len += sizeof(struct ext4_dir_entry_hash);
return (rec_len & ~EXT4_DIR_ROUND);
Because of this, both __ext4_check_dir_entry() and the pre-check in
fs/ext4/namei.c:ext4_search_dir() accept a trailing entry without
EXT4_DIRENT_CFHASH (or with a malformed CFHASH header where
ext4_dirent_get_data_len() and ext4_dirdata_get() return 0) whose rec_len is
((de->name_len + 8 + EXT4_DIR_ROUND) & ~EXT4_DIR_ROUND) right at the end of
the buffer.
However, EXT4_DIRENT_HASHES(de) starts at offset ((8 + de->name_len +
EXT4_DIR_ROUND) & ~EXT4_DIR_ROUND) and reads 8 bytes from that offset. When
fs/ext4/dir.c:ext4_readdir(), fs/ext4/inline.c:ext4_inlinedir_to_tree(),
fs/ext4/namei.c:htree_dirblock_to_tree(), fs/ext4/namei.c:dx_make_map(), or
fs/ext4/namei.c:ext4_match() falls back to EXT4_DIRENT_HASH(de) and
EXT4_DIRENT_MINOR_HASH(de) without checking that those 8 bytes fit within
rec_len, doesn't that read 8 bytes past de + rec_len and past the end of
bh->b_data or the kmalloc(inline_size, GFP_NOFS) buffer dir_buf?
Similarly, in fs/ext4/namei.c:ext4_dirdata_get(), the non-dirdata
compatibility branch only checks:
if (EXT4_BASE_DIR_LEN + data_offset > rec_len)
return ret;
which verifies 8 + de->name_len + 1 <= rec_len before reading 8 bytes at
((8 + de->name_len + EXT4_DIR_ROUND) & ~EXT4_DIR_ROUND). And in
fs/ext4/inline.c:ext4_inlinedir_to_tree(), the synthesized "." and ".."
entries use an uninitialized stack variable fake with rec_len == 12 and no
EXT4_DIRENT_CFHASH bit, causing both ext4_dirdata_get() and the fallback to
read 8 bytes of uninitialized kernel stack from fake.name[4..11] and pass
them to ext4_htree_store_dirent().
Should all legacy EXT4_DIRENT_HASH(de) reads verify that
((EXT4_BASE_DIR_LEN + de->name_len + EXT4_DIR_ROUND) & ~EXT4_DIR_ROUND) +
sizeof(struct ext4_dir_entry_hash) <= rec_len first?
[ ... ]
> @@ -3746,6 +3974,43 @@ static int ext4_rename_dir_finish(handle_t *handle, struct ext4_renament *ent,
> return 0;
> }
>
> +/*
> + * When LUFID is cleared from an entry that also carries CFHASH, the CFHASH
> + * bytes are stranded: LUFID precedes CFHASH in the layout, so removing LUFID
> + * shifts the expected CFHASH offset. Slide CFHASH into the vacated slot so
> + * the parser finds it at the right position. If the headers are malformed,
> + * clear CFHASH instead of risking a corrupt read.
> + */
> +static void ext4_setent_compact_exts(struct ext4_renament *ent,
> + struct ext4_dirent_data_header *lddh,
> + unsigned int lufid_off,
> + unsigned int rec_len)
> +{
> + struct ext4_dirent_data_header *cddh;
> + unsigned int coff;
> +
> + if (!(ent->de->file_type & EXT4_DIRENT_CFHASH))
> + return;
> + if (lufid_off + sizeof(*lddh) > rec_len ||
> + !lddh->ddh_length ||
> + lufid_off + lddh->ddh_length > rec_len) {
> + ent->de->file_type &= ~EXT4_DIRENT_CFHASH;
> + return;
> + }
> +
> + coff = lufid_off + lddh->ddh_length;
> + cddh = (struct ext4_dirent_data_header *)((char *)ent->de + coff);
> +
> + if (coff + sizeof(*cddh) > rec_len ||
> + !cddh->ddh_length ||
> + coff + cddh->ddh_length > rec_len) {
> + ent->de->file_type &= ~EXT4_DIRENT_CFHASH;
> + return;
> + }
> +
> + memmove(lddh, cddh, cddh->ddh_length);
> +}
> +
> static int ext4_setent(handle_t *handle, struct ext4_renament *ent,
> unsigned ino, unsigned file_type,
> const struct ext4_dirent_fid *src_fid)
> @@ -3786,13 +4051,25 @@ static int ext4_setent(handle_t *handle, struct ext4_renament *ent,
> if (ddh_off + sizeof(*ddh) > rec_len ||
> ddh->ddh_length != copy_len ||
> ddh_off + copy_len > rec_len) {
> - /* Cannot copy: clear the flag so the
> - * slot does not advertise a stale LUFID
> - * from the old inode. */
> + /* Cannot copy in-place (size mismatch).
> + * If CFHASH is also present, slide it
> + * into the LUFID slot so the parser
> + * finds it at the correct offset after
> + * the LUFID flag is cleared. */
> + ext4_setent_compact_exts(ent, ddh,
> + ddh_off, rec_len);
> ent->de->file_type &= ~EXT4_DIRENT_LUFID;
> } else {
> memcpy(ddh, src_fid, copy_len);
> }
> + } else if (src_fid) {
> + /* Destination has no LUFID slot; cannot
> + * propagate in-place. Clear the flag from
> + * the on-disk entry directly — clearing only
> + * the local file_type variable has no effect
> + * because the final assignment takes the high
> + * bits from ent->de->file_type, not file_type. */
> + ent->de->file_type &= ~EXT4_DIRENT_LUFID;
> } else if (!src_fid) {
> /* Sync the LUFID flag with what file_type requests.
> * For normal rename (source has no LUFID) and for
> @@ -3800,7 +4077,24 @@ static int ext4_setent(handle_t *handle, struct ext4_renament *ent,
> * so we clear the stale flag. For ext4_resetent
> * (error recovery), file_type is the original
> * file_type with LUFID=1, so we restore the flag —
> - * the LUFID bytes are still on disk untouched. */
> + * the LUFID bytes are still on disk untouched.
> + * When clearing LUFID and CFHASH is present, slide
> + * CFHASH bytes into the LUFID slot first so the
> + * parser finds CFHASH at the expected offset. */
> + if (!(file_type & EXT4_DIRENT_LUFID) &&
> + (ent->de->file_type & EXT4_DIRENT_LUFID)) {
> + unsigned int loff =
> + EXT4_BASE_DIR_LEN +
> + ent->de->name_len + 1;
> + unsigned int rlen =
> + ext4_rec_len_from_disk(
> + ent->de->rec_len,
> + ent->dir->i_sb->s_blocksize);
> + ext4_setent_compact_exts(ent,
> + (struct ext4_dirent_data_header *)
> + ((char *)ent->de + loff),
> + loff, rlen);
> + }
> ent->de->file_type =
> (ent->de->file_type & ~EXT4_DIRENT_LUFID) |
> (file_type & EXT4_DIRENT_LUFID);
[Severity: High]
Does calling ext4_setent_compact_exts() here corrupt old.de when a
RENAME_WHITEOUT rename fails and rolls back via ext4_resetent()?
In fs/ext4/namei.c:ext4_rename(), when whiteout is non-NULL and old.de has
both EXT4_DIRENT_LUFID and EXT4_DIRENT_CFHASH set, ext4_rename() first
updates old.de to point to the whiteout inode before adding the new entry:
if (whiteout) {
retval = ext4_setent(handle, &old, whiteout->i_ino,
EXT4_FT_CHRDEV, NULL);
if (retval)
goto end_rename;
}
if (!new.de) {
retval = ext4_add_entry(handle, new.dentry, old.inode,
old_fid);
Inside that first ext4_setent() call, src_fid is NULL and file_type is
EXT4_FT_CHRDEV (which does not have EXT4_DIRENT_LUFID set), so
ext4_setent_compact_exts() memmoves the 9-byte CFHASH extension over the
on-disk LUFID slot at loff = EXT4_BASE_DIR_LEN + old.de->name_len + 1.
If ext4_add_entry() (or a subsequent step) fails, execution jumps to
end_rename:
end_rename:
kfree(edp);
ext4_lufid_snap_release(&old_snap);
if (whiteout) {
if (retval) {
ext4_resetent(handle, &old,
old.inode->i_ino, old_file_type);
ext4_resetent() calls ext4_setent(handle, &old, old.inode->i_ino,
old_file_type, NULL). The comment in the !src_fid branch assumes the LUFID
bytes are still on disk untouched, and sets EXT4_DIRENT_LUFID back to 1 in
old.de->file_type. However, the LUFID slot was already overwritten with the
CFHASH bytes during the whiteout step, leaving old.de with both
EXT4_DIRENT_LUFID and EXT4_DIRENT_CFHASH set in file_type, the CFHASH sitting
in the LUFID slot, and stale bytes at the post-LUFID offset where
ext4_dirdata_get() will look for CFHASH.
Also, in ext4_setent_compact_exts(), should coff account for
EXT4_DIRENT_INO64 if that flag is set between EXT4_DIRENT_LUFID and
EXT4_DIRENT_CFHASH, matching the layout in ext4_dirdata_get()?
--
Sashiko AI review · https://sashiko.dev/#/patchset/20261029122413.230409-1-ablagodarenko@thelustrecollective.com?part=10
next prev parent reply other threads:[~2026-10-29 12:44 UTC|newest]
Thread overview: 23+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-29 12:23 [PATCH v13 00/11] Data in direntry (dirdata) feature Artem Blagodarenko
2026-10-29 12:24 ` [PATCH v13 01/11] ext4: validate count against limit in ext4_dx_csum_verify/_set Artem Blagodarenko
2026-10-29 12:37 ` sashiko-bot
2026-10-29 12:24 ` [PATCH v13 02/11] ext4: replace ext4_dir_entry with ext4_dir_entry_2 Artem Blagodarenko
2026-10-29 12:34 ` sashiko-bot
2026-10-29 12:24 ` [PATCH v13 03/11] ext4: add ext4_dir_entry_is_tail() Artem Blagodarenko
2026-10-29 12:31 ` sashiko-bot
2026-10-29 12:24 ` [PATCH v13 04/11] ext4: add dirdata format definitions and access helpers Artem Blagodarenko
2026-10-29 12:31 ` sashiko-bot
2026-10-29 12:24 ` [PATCH v13 05/11] ext4: refactor dx_root to support variable dirent sizes Artem Blagodarenko
2026-10-29 12:49 ` sashiko-bot
2026-10-29 12:24 ` [PATCH v13 06/11] ext4: add ext4_dir_entry_len() Artem Blagodarenko
2026-10-29 12:50 ` sashiko-bot
2026-10-29 12:24 ` [PATCH v13 07/11] ext4: rename ext4_dir_rec_len() and clarify dirdata usage Artem Blagodarenko
2026-10-29 12:50 ` sashiko-bot
2026-10-29 12:24 ` [PATCH v13 08/11] ext4: dirdata feature Artem Blagodarenko
2026-10-29 12:51 ` sashiko-bot
2026-10-29 12:24 ` [PATCH v13 09/11] ext4: add dirdata LUFID support for directory entry rename Artem Blagodarenko
2026-10-29 12:47 ` sashiko-bot
2026-10-29 12:24 ` [PATCH v13 10/11] ext4: add dirdata set/get helpers Artem Blagodarenko
2026-10-29 12:44 ` sashiko-bot [this message]
2026-10-29 12:24 ` [PATCH v13 11/11] ext4: Add EXT4_IOC_SET_LUFID ioctl for setting LUFID on directory entries Artem Blagodarenko
2026-10-29 12:58 ` sashiko-bot
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=sashiko-outbox-189303@kernel.org \
--to=sashiko-bot@kernel.org \
--cc=artem.blagodarenko@gmail.com \
--cc=linux-ext4@vger.kernel.org \
--cc=sashiko-reviews@lists.linux.dev \
--cc=tytso@mit.edu \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox