
I co-authored dyslexic with 0xLE for NNS CTF 2026. Players got an unprivileged shell on a Linux guest and a flag they could see but not read. The bug was not in the challenge code. It was in vboxsf, the in-tree Linux driver for VirtualBox shared folders, which applies a caller's requested permission change without ever establishing that the caller is allowed to make it.
The first clue is in mount
Nothing about the shell looks unusual until you ask what /challenge actually is:

cat is refused, as expected for a file we do not own. The interesting line is the one above it. /challenge is vboxsf, a driver that does not store files itself but relays operations to a shared folder service outside the guest. The filesystem type decides which code runs on a chmod, so that is the path to follow:
chmod("/challenge/flag.txt", 0777) do_fchmodat() chmod_common() notify_change() vboxsf_setattr() vboxsf_create_at_dentry() vboxsf_fsinfo()The top three kernel frames are generic VFS code. Everything from vboxsf_setattr() down belongs to the driver. The question is which of these frames is supposed to check that we own the file.
1. The syscall does not check ownership
chmod_common() takes write access to the mount, locks the inode, runs the LSM hook, then builds an iattr and hands it off:
int chmod_common(const struct path *path, umode_t mode){ struct inode *inode = path->dentry->d_inode; struct delegated_inode delegated_inode = { }; struct iattr newattrs; int error;
error = mnt_want_write(path->mnt); if (error) return error;retry_deleg: error = inode_lock_killable(inode); if (error) goto out_mnt_unlock; error = security_path_chmod(path, mode); if (error) goto out_unlock; newattrs.ia_mode = (mode & S_IALLUGO) | (inode->i_mode & ~S_IALLUGO); newattrs.ia_valid = ATTR_MODE | ATTR_CTIME; error = notify_change(mnt_idmap(path->mnt), path->dentry, &newattrs, &delegated_inode);It sets the new mode, tags ia_valid with ATTR_MODE | ATTR_CTIME, and passes it on. There is no owner check here. security_path_chmod() is an LSM hook that only adds policy on top of the normal check, and with no policy loaded it returns 0.
2. notify_change() does not check it either
notify_change() opens with one gate:
int notify_change(struct mnt_idmap *idmap, struct dentry *dentry, struct iattr *attr, struct delegated_inode *delegated_inode){ struct inode *inode = dentry->d_inode; umode_t mode = inode->i_mode; int error; struct timespec64 now; unsigned int ia_valid = attr->ia_valid;
WARN_ON_ONCE(!inode_is_locked(inode));
error = may_setattr(idmap, inode, ia_valid); if (error) return error;So everything hinges on what may_setattr() considers its job:
int may_setattr(struct mnt_idmap *idmap, struct inode *inode, unsigned int ia_valid){ int error;
if (ia_valid & (ATTR_MODE | ATTR_UID | ATTR_GID | ATTR_TIMES_SET)) { if (IS_IMMUTABLE(inode) || IS_APPEND(inode)) return -EPERM; }
/* * If utimes(2) and friends are called with times == NULL (or both * times are UTIME_NOW), then we need to check for write permission */ if (ia_valid & ATTR_TOUCH) { if (IS_IMMUTABLE(inode)) return -EPERM;
if (!inode_owner_or_capable(idmap, inode)) { error = inode_permission(idmap, inode, MAY_WRITE); if (error) return error; } } return 0;}For our request the first branch only rejects immutable and append-only inodes. The owner check in the second branch is gated on ATTR_TOUCH, which a chmod never sets, so nothing here authorizes it. After a few unrelated checks, notify_change() dispatches to the filesystem:
if (inode->i_op->setattr) error = inode->i_op->setattr(idmap, dentry, attr); else error = simple_setattr(idmap, dentry, attr);The common path never calls setattr_prepare(). That is by design, since the filesystem is responsible for deciding whether the caller may make the change. On this mount the callback is wired straight to the driver:
const struct inode_operations vboxsf_reg_iops = { .getattr = vboxsf_getattr, .setattr = vboxsf_setattr, .fileattr_get = vboxsf_fileattr_get,};3. The driver skips the check and opens a host handle
vboxsf_setattr() begins like this:
int vboxsf_setattr(struct mnt_idmap *idmap, struct dentry *dentry, struct iattr *iattr){ struct vboxsf_inode *sf_i = VBOXSF_I(d_inode(dentry)); struct vboxsf_sbi *sbi = VBOXSF_SBI(dentry->d_sb); struct shfl_createparms params = {}; struct shfl_fsobjinfo info = {}; u32 buf_len; int err;
params.handle = SHFL_HANDLE_NIL; params.create_flags = SHFL_CF_ACT_OPEN_IF_EXISTS | SHFL_CF_ACT_FAIL_IF_NEW | SHFL_CF_ACCESS_ATTR_WRITE;
/* this is at least required for Posix hosts */ if (iattr->ia_valid & ATTR_SIZE) params.create_flags |= SHFL_CF_ACCESS_WRITE;
err = vboxsf_create_at_dentry(dentry, ¶ms); if (err || params.result != SHFL_FILE_EXISTS) return err ? err : -ENOENT;The first thing it does is ask the host to open the file for attribute writing. There is no setattr_prepare() above it, and the idmap parameter is never read. It then copies the requested mode bits into the shared folder protocol's format and sends them to the host:
buf_len = sizeof(info); err = vboxsf_fsinfo(sbi->root, params.handle, SHFL_INFO_SET | SHFL_INFO_FILE, &buf_len, &info); if (err) { vboxsf_close(sbi->root, params.handle); return err; }
/* the host may have given us different attr then requested */ sf_i->force_restat = 1; }So a mode chosen by an unprivileged guest user reaches the host, and nothing on the guest side ever asked whether that user owns the file.
4. The check lives inside the helper that was skipped
setattr_prepare() is not optional. Its kerneldoc says it "should be called as the first thing in ->setattr implementations", and the body is where the missing check actually lives:
/* If force is set do it anyway. */ if (ia_valid & ATTR_FORCE) goto kill_priv;
/* Make sure a caller can chown. */ if ((ia_valid & ATTR_UID) && !chown_ok(idmap, inode, attr->ia_vfsuid)) return -EPERM;
/* Make sure caller can chgrp. */ if ((ia_valid & ATTR_GID) && !chgrp_ok(idmap, inode, attr->ia_vfsgid)) return -EPERM;
/* Make sure a caller can chmod. */ if (ia_valid & ATTR_MODE) { vfsgid_t vfsgid;
if (!inode_owner_or_capable(idmap, inode)) return -EPERM;A chmod does not set ATTR_FORCE, so it reaches the ATTR_MODE branch, inode_owner_or_capable() fails for a non-owner, and the call returns -EPERM before anything reaches the host. This is the only owner check a chmod can hit, so skipping the helper removes it entirely.
What a correct passthrough driver looks like
9p is the same kind of filesystem, a thin guest driver that forwards operations to a server holding the real files. Its ->setattr starts with the one line vboxsf is missing:
static int v9fs_vfs_setattr(struct mnt_idmap *idmap, struct dentry *dentry, struct iattr *iattr){ int retval, use_dentry = 0; struct inode *inode = d_inode(dentry); struct v9fs_session_info *v9ses; struct p9_fid *fid = NULL; struct p9_wstat wstat;
p9_debug(P9_DEBUG_VFS, "\n"); retval = setattr_prepare(&nop_mnt_idmap, dentry, iattr); if (retval) return retval;That is the whole fix, and the same three lines belong at the top of vboxsf_setattr():
err = setattr_prepare(idmap, dentry, iattr); if (err) return err;
/* only now open the host handle and apply the requested attributes */

The solve
The exploit is the most boring part. An unprivileged user asks the kernel to make a root owned file world readable, and the kernel agrees:

On any vboxsf mount, a local user can change permissions on files they do not own, including root owned ones. Only LSM policy or the host can stop them, and neither knows which guest user asked.
A simple, but elegant beginner CTF kernel challenge.