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

From: 杨贵琨

Date: Mon Aug 24 2026 - 01:31:20 EST


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


Attachment: 1_mkdirat.zip
Description: Zip compressed data