WARNING in filename_mkdirat: end_dirop() unlocks the wrong inode and leaks the parent i_rwsem (v6.19+ regression, still in 7.2-rc7)

杨贵琨 <[email protected]>
Newsgroups dev.linux.lists.ntfs3,org.kernel.vger.linux-fsdevel,org.kernel.vger.linux-kernel
Message-ID <ABkAlQBXK0gwKdPbcemgOqpX.1.1787549121738.Hmail.2025244165@tju.edu.cn>
Hello,
While fuzzing mainline with syzkaller I hit "WARNING in filename_mkdirat"
(syzbot previously reported the same title as [1]; that report was
auto-obsoleted on 2026/07/18 and the bug is still present).  I have a
reliable C reproducer, and the WARN is only the first symptom: the same
execution permanently leaks a directory's write-locked i_rwsem, so every
later operation in that directory hangs (hung-task reports and, with
hung_task_panic=1, a panic).


The warning
-----------


DEBUG_RWSEMS_WARN_ON((rwsem_owner(sem) != current) &&
!rwsem_test_oflags(sem, RWSEM_NONSPINNABLE)):
count = 0x0, magic = 0xffff88804367ec18, owner = 0x0,
curr 0xffff88802163cc00, list not empty
WARNING: kernel/locking/rwsem.c:1412 at __up_write
CPU: 0 PID: 13812 Comm: syz.3.169
RIP: 0010:up_write+0x229/0x5c0
Call Trace:
inode_unlock include/linux/fs.h:1034
end_dirop fs/namei.c:2955
end_creating include/linux/namei.h:117
end_creating_path fs/namei.c:4995
filename_mkdirat+0x26f/0x5d0 fs/namei.c:5314
__x64_sys_mkdirat+0x95/0x100 fs/namei.c:5327


i.e. up_write() on an i_rwsem this task does not hold (in other
occurrences count was 0x1/0x3 — read-held by unrelated tasks).


Seconds later, other threads of the same reproducer hang forever waiting
for a write lock that nobody will ever release:


INFO: task racer:9471 blocked for more than 143 seconds.
task:racer  state:D stack:27384
Call Trace:
__schedule+0x12e4/0x64e0
rwsem_down_write_slowpath+0x3a4/0x1280
down_write_nested+0x1d9/0x210
filename_create+0x1b1/0x400
filename_mkdirat+0xd8/0x5d0
__x64_sys_mkdirat+0x95/0x100


Reproducer
----------


An NTFS image crafted by syzkaller (2 MB; the MFT makes one directory
reachable through several directory entries) is mounted once, and N
threads then race mkdirat()/renameat2()/newfstatat() on the names
"file0", "file0/file0", "file1", "file7" inside the mount.  On a cold
mount the WARNING triggers within seconds (I saw 7 s and 38 s in two
fresh boots, out of three attempts); afterwards the leaked lock hangs
further mkdirat() calls forever.


The image and a ready-to-run QEMU setup are available on request; the
racer itself is appended below.  The original syzkaller program is a
single syz_mount_image$ntfs3 call followed by renameat2 + two mkdirat
calls, run with collide (two threads).


Analysis (hypothesis, not fully proven)
---------------------------------------


The dirop rework of v6.19 (4037d966f034 "VFS: introduce start_dirop()
and end_dirop()", fe497f0759e0 "VFS: change vfs_mkdir() to unlock on
failure", plus 88d5baf69082 "Change inode_operations.mkdir to return
struct dentry *") established the invariant that filename_mkdirat()
unlocks the parent through the *final* dentry:


end_dirop(de) { inode_unlock(de->d_parent->d_inode); dput(de); }


The locked inode, however, is the parent resolved at start_dirop()
time.  I could not construct an imbalance from the generic code alone;
every path I traced (vfs_mkdir() error path end_creating(),
filename_create() fail label, the retry_estale/delegation retries) is
balanced for a filesystem like ntfs3 whose ->mkdir only returns NULL or
ERR_PTR.


What breaks the invariant is a dentry whose ->d_parent changes while
its creator is between start_dirop() and end_dirop().  d_splice_alias()
on a directory with a *connected* alias (possible on ntfs3 with a
crafted image where the same directory inode is reachable under two
paths; ntfs_lookup() calls d_splice_alias() directly) invokes
__d_unalias(), which locks only the alias's *old* parent (shared,
trylock) and s_vfs_rename_mutex — cf. lock_rename(), which locks *both*
parents — and then __d_move()s the alias under the new parent:


fs/dcache.c __d_unalias():
if (!mutex_trylock(&dentry->d_sb->s_vfs_rename_mutex))
goto out_err;
if (!inode_trylock_shared(alias->d_parent->d_inode))
goto out_err;
...
__d_move(alias, dentry, false);


A task that resolved a path through the old parent and holds that
parent's i_rwsem exclusive (start_dirop) while holding the moved
dentry will, at end_dirop(), unlock the *new* parent's inode (free or
read-held by others -> the WARN above) and leak the old parent's write
lock (-> the permanent hang).


Fix directions might include making __d_unalias() refuse to move an
alias whose d_parent is write-locked (or taking the new parent into
account the way lock_rename() does), or making the dirop helpers
unlock the inode they actually locked rather than the one the dentry
points at at unlock time.


Kernel: stock mainline 7.2.0-rc7-00232-gdcb68831eac7, syzkaller's
upstream KASAN config (KASAN inline, lockdep enabled).


[1] https://syzkaller.appspot.com/bug?extid=8e725133f14f47d38632
    ("WARNING in filename_mkdirat", occurrences 2026/02/10 - 2026/05/11,
    auto-obsoleted 2026/07/18; first occurrence postdates the v6.19 dirop
    rework)


---- racer.c ----
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <pthread.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <sys/ioctl.h>
#include <sys/mount.h>
#include <linux/loop.h>


static volatile int stop = 0;


static void *racer(void *arg)
{
long id = (long)arg;


while (!stop) {
mkdirat(AT_FDCWD, "./file0", 0700);
mkdirat(AT_FDCWD, "./file0/file0", 0700);
mkdirat(AT_FDCWD, "./file1", 0700);
mkdirat(AT_FDCWD, "./file0/file1", 0700);


switch (id % 4) {
case 0:
syscall(SYS_renameat2, AT_FDCWD, "./file0",
AT_FDCWD, "./file7", 0);
break;
case 1:
syscall(SYS_renameat2, AT_FDCWD, "./file7",
AT_FDCWD, "./file0", 0);
break;
case 2:
syscall(SYS_newfstatat, AT_FDCWD, "./file0/file0",
NULL, 0);
syscall(SYS_newfstatat, AT_FDCWD, "./file1/file1",
NULL, 0);
break;
case 3:
rmdir("./file0/file0");
rmdir("./file0/file1");
break;
}
}
return NULL;
}


int main(int argc, char **argv)
{
const char *img = argc > 1 ? argv[1] : "./ntfs3.img";
const char *mnt = argc > 2 ? argv[2] : "/tmp/m";
int n = argc > 3 ? atoi(argv[3]) : 16;
int secs = argc > 4 ? atoi(argv[4]) : 60;
char loopdev[64];
pthread_t t[128];
int cfd, idx, ld, fd;


mkdir(mnt, 0777);
cfd = open("/dev/loop-control", O_RDWR);
idx = cfd >= 0 ? ioctl(cfd, LOOP_CTL_GET_FREE) : 0;
if (cfd >= 0)
close(cfd);
snprintf(loopdev, sizeof(loopdev), "/dev/loop%d", idx < 0 ? 0 : idx);


ld = open(loopdev, O_RDWR);
fd = open(img, O_RDWR);
if (ld < 0 || fd < 0 || ioctl(ld, LOOP_SET_FD, fd) < 0) {
fprintf(stderr, "loop setup failed: %s\n", strerror(errno));
return 1;
}
close(fd);
/* Must close the loop fd before mount(2), else EBUSY. */
close(ld);


if (mount(loopdev, mnt, "ntfs3", 0, "") < 0) {
fprintf(stderr, "mount failed: %s\n", strerror(errno));
return 1;
}
if (chdir(mnt) < 0) {
perror("chdir");
return 1;
}
fprintf(stderr, "racing %d threads for %ds\n", n, secs);
for (long i = 0; i < n; i++)
pthread_create(&t[i], NULL, racer, (void *)i);
sleep(secs);
stop = 1;
for (int i = 0; i < n; i++)
pthread_join(t[i], NULL);
fprintf(stderr, "done\n");
return 0;
}


Run: ./racer ntfs3.img /tmp/m 16 60   (watch dmesg for
DEBUG_RWSEMS_WARN_ON, then for tasks blocked in down_write_nested)


Reported-by: syzbot
Found-by: syzkaller on linux-7.2.0-rc7-00232-gdcb68831eac7
1_mkdirat.zip (application/x-zip-compressed, 368.4 KB) - not displayed
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.