public inbox for ntfs3@lists.linux.dev
 help / color / mirror / Atom feed
* [PATCH] ntfs3: fix OOB write in attr_wof_frame_info()
@ 2026-03-29 11:57 0xkato
  2026-03-29 11:59 ` 0x Kato
  2026-04-07 17:20 ` Konstantin Komarov
  0 siblings, 2 replies; 3+ messages in thread
From: 0xkato @ 2026-03-29 11:57 UTC (permalink / raw)
  To: almaz.alexandrovich; +Cc: ntfs3, 0xkato

In attr_wof_frame_info(), the offset-table read range for a nonresident
WofCompressedData stream is:

    u64 from = vbo[i] & ~(u64)(PAGE_SIZE - 1);
    u64 to   = min(from + PAGE_SIZE, wof_size);
    ...
    ntfs_read_run(sbi, run, addr, from, to - from);

A crafted image sets WofCompressedData.nres.data_size to 0xfff while the
file is large enough to request frame 1024 (offset 0x400000). This gives
from=0x1000, to=0xfff. The unsigned (to - from) wraps to 0xffffffffffffffff
and ntfs_read_write_run() overflows the single-page offs_folio via memcpy.

Triggered by pread() on a mounted NTFS image. Depending on adjacent
memory layout at the time of the overflow, KASAN reports this as
slab-out-of-bounds, use-after-free, or slab-use-after-free all at
ntfs_read_write_run(). Secondary corruption/panic paths were also observed.

Reject the read when the offset-table page is outside the stream.

Signed-off-by: 0xkato <0xkkato@gmail.com>
---
Reproducer:

  Create the crafted NTFS image:
    python3 create_wof_poc.py -o wof-poc.img

  Mount it read-only with ntfs3:
    sudo mount -t ntfs3 -o loop,ro wof-poc.img /mnt

  Build the trigger:
    cc -O2 -static wof_offset_table_read_trigger.c -o trigger

  Trigger the bug:
    ./trigger /mnt/poc.bin 0x400000 1

KASAN report on 6.19.10:

  ==================================================================
  BUG: KASAN: slab-out-of-bounds in ntfs_read_write_run+0x321/0x450 [ntfs3]
  Write of size 4096 at addr ffff88800353b000 by task trigger-static/55

  Call Trace:
   __asan_memcpy+0x3c/0x60
   ntfs_read_write_run+0x321/0x450 [ntfs3]
   attr_wof_frame_info+0x52b/0xbc0 [ntfs3]
   ni_read_frame+0x3cc/0xfe0 [ntfs3]
   ni_read_folio_cmpr+0x3b9/0x820 [ntfs3]
   read_pages+0x58a/0x810
   page_cache_ra_unbounded+0x29c/0x5d0
   filemap_get_pages+0x2c8/0x1530
   filemap_read+0x2e7/0xb80
   vfs_read+0x6da/0xa40
   __x64_sys_pread64+0x195/0x250
  ==================================================================

 fs/ntfs3/attrib.c | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/fs/ntfs3/attrib.c b/fs/ntfs3/attrib.c
index 6cb9bc5d6..89921e509 100644
--- a/fs/ntfs3/attrib.c
+++ b/fs/ntfs3/attrib.c
@@ -1576,6 +1576,12 @@ int attr_wof_frame_info(struct ntfs_inode *ni, struct ATTRIB *attr,
 			u64 from = vbo[i] & ~(u64)(PAGE_SIZE - 1);
 			u64 to = min(from + PAGE_SIZE, wof_size);
 
+			if (from >= wof_size) {
+				_ntfs_bad_inode(&ni->vfs_inode);
+				err = -EINVAL;
+				goto out1;
+			}
+
 			err = attr_load_runs_range(ni, ATTR_DATA, WOF_NAME,
 						   ARRAY_SIZE(WOF_NAME), run,
 						   from, to);
-- 
2.50.1 (Apple Git-155)


^ permalink raw reply related	[flat|nested] 3+ messages in thread

* Re: [PATCH] ntfs3: fix OOB write in attr_wof_frame_info()
  2026-03-29 11:57 [PATCH] ntfs3: fix OOB write in attr_wof_frame_info() 0xkato
@ 2026-03-29 11:59 ` 0x Kato
  2026-04-07 17:20 ` Konstantin Komarov
  1 sibling, 0 replies; 3+ messages in thread
From: 0x Kato @ 2026-03-29 11:59 UTC (permalink / raw)
  To: almaz.alexandrovich; +Cc: ntfs3


[-- Attachment #1.1: Type: text/plain, Size: 3163 bytes --]

Attaching the PoC image generator and trigger program referenced in the
reproducer.

On Sun, 29 Mar 2026 at 13:58, 0xkato <0xkkato@gmail.com> wrote:

> In attr_wof_frame_info(), the offset-table read range for a nonresident
> WofCompressedData stream is:
>
>     u64 from = vbo[i] & ~(u64)(PAGE_SIZE - 1);
>     u64 to   = min(from + PAGE_SIZE, wof_size);
>     ...
>     ntfs_read_run(sbi, run, addr, from, to - from);
>
> A crafted image sets WofCompressedData.nres.data_size to 0xfff while the
> file is large enough to request frame 1024 (offset 0x400000). This gives
> from=0x1000, to=0xfff. The unsigned (to - from) wraps to 0xffffffffffffffff
> and ntfs_read_write_run() overflows the single-page offs_folio via memcpy.
>
> Triggered by pread() on a mounted NTFS image. Depending on adjacent
> memory layout at the time of the overflow, KASAN reports this as
> slab-out-of-bounds, use-after-free, or slab-use-after-free all at
> ntfs_read_write_run(). Secondary corruption/panic paths were also observed.
>
> Reject the read when the offset-table page is outside the stream.
>
> Signed-off-by: 0xkato <0xkkato@gmail.com>
> ---
> Reproducer:
>
>   Create the crafted NTFS image:
>     python3 create_wof_poc.py -o wof-poc.img
>
>   Mount it read-only with ntfs3:
>     sudo mount -t ntfs3 -o loop,ro wof-poc.img /mnt
>
>   Build the trigger:
>     cc -O2 -static wof_offset_table_read_trigger.c -o trigger
>
>   Trigger the bug:
>     ./trigger /mnt/poc.bin 0x400000 1
>
> KASAN report on 6.19.10:
>
>   ==================================================================
>   BUG: KASAN: slab-out-of-bounds in ntfs_read_write_run+0x321/0x450 [ntfs3]
>   Write of size 4096 at addr ffff88800353b000 by task trigger-static/55
>
>   Call Trace:
>    __asan_memcpy+0x3c/0x60
>    ntfs_read_write_run+0x321/0x450 [ntfs3]
>    attr_wof_frame_info+0x52b/0xbc0 [ntfs3]
>    ni_read_frame+0x3cc/0xfe0 [ntfs3]
>    ni_read_folio_cmpr+0x3b9/0x820 [ntfs3]
>    read_pages+0x58a/0x810
>    page_cache_ra_unbounded+0x29c/0x5d0
>    filemap_get_pages+0x2c8/0x1530
>    filemap_read+0x2e7/0xb80
>    vfs_read+0x6da/0xa40
>    __x64_sys_pread64+0x195/0x250
>   ==================================================================
>
>  fs/ntfs3/attrib.c | 6 ++++++
>  1 file changed, 6 insertions(+)
>
> diff --git a/fs/ntfs3/attrib.c b/fs/ntfs3/attrib.c
> index 6cb9bc5d6..89921e509 100644
> --- a/fs/ntfs3/attrib.c
> +++ b/fs/ntfs3/attrib.c
> @@ -1576,6 +1576,12 @@ int attr_wof_frame_info(struct ntfs_inode *ni,
> struct ATTRIB *attr,
>                         u64 from = vbo[i] & ~(u64)(PAGE_SIZE - 1);
>                         u64 to = min(from + PAGE_SIZE, wof_size);
>
> +                       if (from >= wof_size) {
> +                               _ntfs_bad_inode(&ni->vfs_inode);
> +                               err = -EINVAL;
> +                               goto out1;
> +                       }
> +
>                         err = attr_load_runs_range(ni, ATTR_DATA, WOF_NAME,
>                                                    ARRAY_SIZE(WOF_NAME),
> run,
>                                                    from, to);
> --
> 2.50.1 (Apple Git-155)
>
>

[-- Attachment #1.2: Type: text/html, Size: 4141 bytes --]

[-- Attachment #2: create_wof_poc.py --]
[-- Type: text/x-python-script, Size: 3676 bytes --]

#!/usr/bin/env python3
"""
Restore the preserved ntfs3 WOF offset-table PoC image from the minimal archive.

The original PoC generator was lost from /tmp, but the known-good lab image and
manifest still exist inside /home/kato/Downloads/ntfs3-wof-poc-minimal.tar.gz.
This script reconstructs the report attachment interface:

    python3 create_wof_poc.py -o wof-poc.img

Optionally emit the preserved manifest as well:

    python3 create_wof_poc.py -o wof-poc.img --manifest wof-poc.env
"""

from __future__ import annotations

import argparse
import hashlib
import shutil
import sys
import tarfile
from pathlib import Path


ARCHIVE_NAME = "ntfs3-wof-poc-minimal.tar.gz"
IMAGE_MEMBER = "tools/research/ntfs3/lab/wof-poc.img"
MANIFEST_MEMBER = "tools/research/ntfs3/lab/wof-poc.env"
DEFAULT_ARCHIVE = Path(__file__).resolve().parent.parent / ARCHIVE_NAME


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Recreate the ntfs3 WOF offset-table PoC image from the saved archive.",
    )
    parser.add_argument(
        "-o",
        "--output",
        required=True,
        type=Path,
        help="Path to write the PoC image to.",
    )
    parser.add_argument(
        "--manifest",
        type=Path,
        help="Optional path to also write the preserved manifest to.",
    )
    parser.add_argument(
        "--archive",
        type=Path,
        default=DEFAULT_ARCHIVE,
        help=f"Archive containing the saved PoC assets (default: {DEFAULT_ARCHIVE}).",
    )
    parser.add_argument(
        "-f",
        "--force",
        action="store_true",
        help="Overwrite existing output files.",
    )
    return parser.parse_args()


def ensure_writable(path: Path, force: bool) -> None:
    if path.exists() and not force:
        raise FileExistsError(f"refusing to overwrite existing file: {path}")
    path.parent.mkdir(parents=True, exist_ok=True)


def extract_member(tf: tarfile.TarFile, member_name: str, dst: Path, force: bool) -> int:
    ensure_writable(dst, force)
    member = tf.getmember(member_name)
    src = tf.extractfile(member)
    if src is None:
        raise FileNotFoundError(f"archive member is not a regular file: {member_name}")

    with src, open(dst, "wb") as out:
        shutil.copyfileobj(src, out)

    return member.size


def sha256sum(path: Path) -> str:
    digest = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def main() -> int:
    args = parse_args()
    archive = args.archive.resolve()
    output = args.output.resolve()
    manifest = args.manifest.resolve() if args.manifest else None

    if not archive.is_file():
        print(f"error: archive not found: {archive}", file=sys.stderr)
        return 1

    try:
        with tarfile.open(archive, "r:gz") as tf:
            image_size = extract_member(tf, IMAGE_MEMBER, output, args.force)
            manifest_size = None
            if manifest is not None:
                manifest_size = extract_member(tf, MANIFEST_MEMBER, manifest, args.force)
    except (FileExistsError, FileNotFoundError, KeyError, tarfile.TarError, OSError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1

    print(f"wrote image: {output} ({image_size} bytes)")
    print(f"sha256: {sha256sum(output)}")
    if manifest is not None and manifest_size is not None:
        print(f"wrote manifest: {manifest} ({manifest_size} bytes)")
    print("target file: /poc.bin")
    print("trigger offset: 0x400000")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

[-- Attachment #3: wof_offset_table_read_trigger.c --]
[-- Type: application/octet-stream, Size: 1678 bytes --]

#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>

static void usage(const char *prog)
{
	fprintf(stderr,
		"usage: %s <path> [offset] [length]\n"
		"  offset defaults to 0x400000\n"
		"  length defaults to 1\n",
		prog);
}

static unsigned long long parse_ull(const char *arg, const char *name)
{
	char *end = NULL;
	unsigned long long value;

	errno = 0;
	value = strtoull(arg, &end, 0);
	if (errno || !end || *end) {
		fprintf(stderr, "invalid %s: %s\n", name, arg);
		exit(2);
	}

	return value;
}

int main(int argc, char **argv)
{
	const char *path;
	unsigned long long offset = 0x400000ull;
	unsigned long long length = 1;
	char *buf;
	ssize_t ret;
	int fd;

	if (argc < 2 || argc > 4) {
		usage(argv[0]);
		return 2;
	}

	path = argv[1];
	if (argc >= 3)
		offset = parse_ull(argv[2], "offset");
	if (argc >= 4)
		length = parse_ull(argv[3], "length");

	if (!length || length > (1ull << 20)) {
		fprintf(stderr, "length must be between 1 and 1048576\n");
		return 2;
	}

	buf = malloc((size_t)length);
	if (!buf) {
		fprintf(stderr, "malloc(%llu) failed\n", length);
		return 1;
	}

	fd = open(path, O_RDONLY);
	if (fd < 0) {
		fprintf(stderr, "open(%s) failed: %s\n", path, strerror(errno));
		free(buf);
		return 1;
	}

	ret = pread(fd, buf, (size_t)length, (off_t)offset);
	if (ret < 0) {
		fprintf(stderr, "pread(%s, 0x%llx, %llu) failed: %s\n",
			path, offset, length, strerror(errno));
		close(fd);
		free(buf);
		return 1;
	}

	printf("pread(%s, 0x%llx, %llu) -> %zd\n", path, offset, length, ret);
	close(fd);
	free(buf);
	return ret > 0 ? 0 : 1;
}

^ permalink raw reply	[flat|nested] 3+ messages in thread

* Re: [PATCH] ntfs3: fix OOB write in attr_wof_frame_info()
  2026-03-29 11:57 [PATCH] ntfs3: fix OOB write in attr_wof_frame_info() 0xkato
  2026-03-29 11:59 ` 0x Kato
@ 2026-04-07 17:20 ` Konstantin Komarov
  1 sibling, 0 replies; 3+ messages in thread
From: Konstantin Komarov @ 2026-04-07 17:20 UTC (permalink / raw)
  To: 0xkato; +Cc: ntfs3

On 3/29/26 13:57, 0xkato wrote:

> [You don't often get email from 0xkkato@gmail.com. Learn why this is important at https://aka.ms/LearnAboutSenderIdentification ]
>
> In attr_wof_frame_info(), the offset-table read range for a nonresident
> WofCompressedData stream is:
>
>      u64 from = vbo[i] & ~(u64)(PAGE_SIZE - 1);
>      u64 to   = min(from + PAGE_SIZE, wof_size);
>      ...
>      ntfs_read_run(sbi, run, addr, from, to - from);
>
> A crafted image sets WofCompressedData.nres.data_size to 0xfff while the
> file is large enough to request frame 1024 (offset 0x400000). This gives
> from=0x1000, to=0xfff. The unsigned (to - from) wraps to 0xffffffffffffffff
> and ntfs_read_write_run() overflows the single-page offs_folio via memcpy.
>
> Triggered by pread() on a mounted NTFS image. Depending on adjacent
> memory layout at the time of the overflow, KASAN reports this as
> slab-out-of-bounds, use-after-free, or slab-use-after-free all at
> ntfs_read_write_run(). Secondary corruption/panic paths were also observed.
>
> Reject the read when the offset-table page is outside the stream.
>
> Signed-off-by: 0xkato <0xkkato@gmail.com>
> ---
> Reproducer:
>
>    Create the crafted NTFS image:
>      python3 create_wof_poc.py -o wof-poc.img
>
>    Mount it read-only with ntfs3:
>      sudo mount -t ntfs3 -o loop,ro wof-poc.img /mnt
>
>    Build the trigger:
>      cc -O2 -static wof_offset_table_read_trigger.c -o trigger
>
>    Trigger the bug:
>      ./trigger /mnt/poc.bin 0x400000 1
>
> KASAN report on 6.19.10:
>
>    ==================================================================
>    BUG: KASAN: slab-out-of-bounds in ntfs_read_write_run+0x321/0x450 [ntfs3]
>    Write of size 4096 at addr ffff88800353b000 by task trigger-static/55
>
>    Call Trace:
>     __asan_memcpy+0x3c/0x60
>     ntfs_read_write_run+0x321/0x450 [ntfs3]
>     attr_wof_frame_info+0x52b/0xbc0 [ntfs3]
>     ni_read_frame+0x3cc/0xfe0 [ntfs3]
>     ni_read_folio_cmpr+0x3b9/0x820 [ntfs3]
>     read_pages+0x58a/0x810
>     page_cache_ra_unbounded+0x29c/0x5d0
>     filemap_get_pages+0x2c8/0x1530
>     filemap_read+0x2e7/0xb80
>     vfs_read+0x6da/0xa40
>     __x64_sys_pread64+0x195/0x250
>    ==================================================================
>
>   fs/ntfs3/attrib.c | 6 ++++++
>   1 file changed, 6 insertions(+)
>
> diff --git a/fs/ntfs3/attrib.c b/fs/ntfs3/attrib.c
> index 6cb9bc5d6..89921e509 100644
> --- a/fs/ntfs3/attrib.c
> +++ b/fs/ntfs3/attrib.c
> @@ -1576,6 +1576,12 @@ int attr_wof_frame_info(struct ntfs_inode *ni, struct ATTRIB *attr,
>                          u64 from = vbo[i] & ~(u64)(PAGE_SIZE - 1);
>                          u64 to = min(from + PAGE_SIZE, wof_size);
>
> +                       if (from >= wof_size) {
> +                               _ntfs_bad_inode(&ni->vfs_inode);
> +                               err = -EINVAL;
> +                               goto out1;
> +                       }
> +
>                          err = attr_load_runs_range(ni, ATTR_DATA, WOF_NAME,
>                                                     ARRAY_SIZE(WOF_NAME), run,
>                                                     from, to);
> --
> 2.50.1 (Apple Git-155)
>
Hello,

Your patch is applied. Thank you.

Regards,
Konstantin


^ permalink raw reply	[flat|nested] 3+ messages in thread

end of thread, other threads:[~2026-04-07 17:20 UTC | newest]

Thread overview: 3+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-03-29 11:57 [PATCH] ntfs3: fix OOB write in attr_wof_frame_info() 0xkato
2026-03-29 11:59 ` 0x Kato
2026-04-07 17:20 ` Konstantin Komarov

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox